diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml index 60c73bff..3dd05a0f 100644 --- a/.github/workflows/documentation-build.yml +++ b/.github/workflows/documentation-build.yml @@ -16,6 +16,9 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: +permissions: + contents: write + # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job called "build" @@ -28,7 +31,7 @@ jobs: fetch-depth: 0 # otherwise, you will failed to push refs to dest repo - name: Setup Pixi - uses: ./.github/actions/setup-pixi + uses: easyscience/actions/pixi@master - name: Build documentation run: pixi run --environment dev docs-build @@ -39,8 +42,8 @@ jobs: # install_requirements: true # documentation_path: docs/src - - name: Push changes - uses: ad-m/github-push-action@master - continue-on-error: true + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 with: - branch: gh-pages \ No newline at end of file + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/_build/html diff --git a/.github/workflows/nightly-check.yml b/.github/workflows/nightly-check.yml index 133ba4da..f9c1143f 100644 --- a/.github/workflows/nightly-check.yml +++ b/.github/workflows/nightly-check.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup Pixi - uses: ./.github/actions/setup-pixi + uses: easyscience/actions/pixi@master - name: Update lock file run: pixi run update-lock diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 80ab2a27..4a78c7a0 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v5 - name: Setup Pixi - uses: ./.github/actions/setup-pixi + uses: easyscience/actions/pixi@master - name: Run linting run: pixi run lint @@ -85,7 +85,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup Pixi - uses: ./.github/actions/setup-pixi + uses: easyscience/actions/pixi@master - name: Build package run: pixi run build diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 02d39381..61bc66ba 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup Pixi - uses: ./.github/actions/setup-pixi + uses: easyscience/actions/pixi@master - name: Set Python version run: pixi add python=${{ matrix.python-version }} diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 2187d301..471d0703 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup Pixi - uses: ./.github/actions/setup-pixi + uses: easyscience/actions/pixi@master - name: Build package run: pixi run build diff --git a/.github/workflows/verify_issue_labels.yml b/.github/workflows/verify_issue_labels.yml index 69ec3a1f..4315fe7e 100644 --- a/.github/workflows/verify_issue_labels.yml +++ b/.github/workflows/verify_issue_labels.yml @@ -17,6 +17,7 @@ jobs: with: secret: ${{ github.TOKEN }} prefix: "[scope]" + labelSeparator: " " addLabel: true defaultLabel: "[scope] ⚠️ label needed" @@ -26,6 +27,7 @@ jobs: with: secret: ${{ github.TOKEN }} prefix: "[priority]" + labelSeparator: " " addLabel: true defaultLabel: "[priority] ⚠️ label needed" diff --git a/README.md b/README.md index 56e2ceb0..b41a80be 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![PyPI badge](http://img.shields.io/pypi/v/EasyScience.svg)](https://pypi.python.org/pypi/EasyScience) [![License: BSD 3-Clause](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](LICENSE) +[![codecov](https://codecov.io/github/EasyScience/corelib/graph/badge.svg?token=wc6Q0j0Q9t)](https://codecov.io/github/EasyScience/corelib) # Easyscience @@ -33,7 +34,7 @@ After installation, launch the test suite: Documentation can be found at: -[https://easyScience.github.io/EasyScience](https://easyScience.github.io/EasyScience) +[https://easyScience.github.io/corelib](https://easyScience.github.io/corelib) ## Contributing We absolutely welcome contributions. **EasyScience** is maintained by the ESS and on a volunteer basis and thus we need to foster a community that can support user questions and develop new features to make this software a useful tool for all users while encouraging every member of the community to share their ideas. diff --git a/src/easyscience/base_classes/__init__.py b/src/easyscience/base_classes/__init__.py index 9f3ba080..878d869e 100644 --- a/src/easyscience/base_classes/__init__.py +++ b/src/easyscience/base_classes/__init__.py @@ -1,13 +1,8 @@ from .based_base import BasedBase from .collection_base import CollectionBase +from .easy_list import EasyList from .model_base import ModelBase from .new_base import NewBase from .obj_base import ObjBase -__all__ = [ - BasedBase, - CollectionBase, - ObjBase, - ModelBase, - NewBase, -] +__all__ = [BasedBase, CollectionBase, ObjBase, ModelBase, NewBase, EasyList] diff --git a/src/easyscience/base_classes/collection_base.py b/src/easyscience/base_classes/collection_base.py index 3cc0586a..0a4e8272 100644 --- a/src/easyscience/base_classes/collection_base.py +++ b/src/easyscience/base_classes/collection_base.py @@ -14,6 +14,7 @@ from typing import Tuple from typing import Union +from easyscience.base_classes.new_base import NewBase from easyscience.global_object.undo_redo import NotarizedDict from ..variable.descriptor_base import DescriptorBase @@ -34,7 +35,7 @@ class CollectionBase(BasedBase, MutableSequence): def __init__( self, name: str, - *args: Union[BasedBase, DescriptorBase], + *args: Union[BasedBase, DescriptorBase, NewBase], interface: Optional[InterfaceFactoryTemplate] = None, unique_name: Optional[str] = None, **kwargs, @@ -64,7 +65,7 @@ def __init__( _kwargs[key] = item kwargs = _kwargs for item in list(kwargs.values()) + _args: - if not issubclass(type(item), (DescriptorBase, BasedBase)): + if not issubclass(type(item), (DescriptorBase, BasedBase, NewBase)): raise AttributeError('A collection can only be formed from easyscience objects.') args = _args _kwargs = {} @@ -90,19 +91,19 @@ def __init__( self.interface = interface self._kwargs._stack_enabled = True - def insert(self, index: int, value: Union[DescriptorBase, BasedBase]) -> None: + def insert(self, index: int, value: Union[DescriptorBase, BasedBase, NewBase]) -> None: """ Insert an object into the collection at an index. :param index: Index for EasyScience object to be inserted. :type index: int :param value: Object to be inserted. - :type value: Union[BasedBase, DescriptorBase] + :type value: Union[BasedBase, DescriptorBase, NewBase] :return: None :rtype: None """ t_ = type(value) - if issubclass(t_, (BasedBase, DescriptorBase)): + if issubclass(t_, (BasedBase, DescriptorBase, NewBase)): update_key = list(self._kwargs.keys()) values = list(self._kwargs.values()) # Update the internal dict @@ -117,7 +118,7 @@ def insert(self, index: int, value: Union[DescriptorBase, BasedBase]) -> None: else: raise AttributeError('Only EasyScience objects can be put into an EasyScience group') - def __getitem__(self, idx: Union[int, slice]) -> Union[DescriptorBase, BasedBase]: + def __getitem__(self, idx: Union[int, slice]) -> Union[DescriptorBase, BasedBase, NewBase]: """ Get an item in the collection based on its index. @@ -151,7 +152,7 @@ def __getitem__(self, idx: Union[int, slice]) -> Union[DescriptorBase, BasedBase keys = list(self._kwargs.keys()) return self._kwargs[keys[idx]] - def __setitem__(self, key: int, value: Union[BasedBase, DescriptorBase]) -> None: + def __setitem__(self, key: int, value: Union[BasedBase, DescriptorBase, NewBase]) -> None: """ Set an item via it's index. @@ -163,7 +164,7 @@ def __setitem__(self, key: int, value: Union[BasedBase, DescriptorBase]) -> None if isinstance(value, Number): # noqa: S3827 item = self.__getitem__(key) item.value = value - elif issubclass(type(value), (BasedBase, DescriptorBase)): + elif issubclass(type(value), (BasedBase, DescriptorBase, NewBase)): update_key = list(self._kwargs.keys()) values = list(self._kwargs.values()) old_item = values[key] @@ -233,7 +234,11 @@ def data(self) -> Tuple: def __repr__(self) -> str: return f'{self.__class__.__name__} `{getattr(self, "name")}` of length {len(self)}' - def sort(self, mapping: Callable[[Union[BasedBase, DescriptorBase]], Any], reverse: bool = False) -> None: + def sort( + self, + mapping: Callable[[Union[BasedBase, DescriptorBase, NewBase]], Any], + reverse: bool = False, + ) -> None: """ Sort the collection according to the given mapping. diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py new file mode 100644 index 00000000..6af761e6 --- /dev/null +++ b/src/easyscience/base_classes/easy_list.py @@ -0,0 +1,281 @@ +# SPDX-FileCopyrightText: 2025 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +# © 2021-2025 Contributors to the EasyScience project ProtectedType_: ... + @overload + def __getitem__(self, idx: slice) -> 'EasyList[ProtectedType_]': ... + @overload + def __getitem__(self, idx: str) -> ProtectedType_: ... + def __getitem__(self, idx: int | slice | str) -> ProtectedType_ | 'EasyList[ProtectedType_]': + """ + Get an item by index, slice, or unique_name. + + :param idx: Index, slice, or unique_name of the item + :return: The item or a new EasyList for slices + """ + if isinstance(idx, int): + return self._data[idx] + elif isinstance(idx, slice): + return self.__class__(self._data[idx], protected_types=self._protected_types) + elif isinstance(idx, str): + element = next((r for r in self._data if self._get_key(r) == idx), None) + if element is not None: + return element + raise KeyError(f'No item with unique name "{idx}" found') + else: + raise TypeError('Index must be an int, slice, or str') + + @overload + def __setitem__(self, idx: int, value: ProtectedType_) -> None: ... + @overload + def __setitem__(self, idx: slice, value: Iterable[ProtectedType_]) -> None: ... + + def __setitem__(self, idx: int | slice, value: ProtectedType_ | Iterable[ProtectedType_]) -> None: + """ + Set an item at an index. + + :param idx: Index to set + :param value: New value + """ + if isinstance(idx, int): + if not isinstance(value, tuple(self._protected_types)): + raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}') + if value is not self._data[idx] and value in self: + warnings.warn(f'Item with unique name "{self._get_key(value)}" already in EasyList, it will be ignored') + return + self._data[idx] = value + elif isinstance(idx, slice): + if not isinstance(value, Iterable): + raise TypeError('Value must be an iterable for slice assignment') + replaced = self._data[idx] + new_values = list(value) + if len(new_values) != len(replaced): + raise ValueError('Length of new values must match the length of the slice being replaced') + for i, v in enumerate(new_values): + if not isinstance(v, tuple(self._protected_types)): + raise TypeError(f'Items must be one of {self._protected_types}, got {type(v)}') + if v in self and replaced[i] is not v: + warnings.warn(f'Item with unique name "{v.unique_name}" already in EasyList, it will be ignored') + new_values[i] = replaced[i] # Keep the original value if the new one is a duplicate + self._data[idx] = new_values + else: + raise TypeError('Index must be an int or slice') + + def __delitem__(self, idx: int | slice | str) -> None: + """ + Delete an item by index, slice, or name. + + :param idx: Index, slice, or name of item to delete + """ + if isinstance(idx, (int, slice)): + del self._data[idx] + elif isinstance(idx, str): + for i, item in enumerate(self._data): + if self._get_key(item) == idx: + del self._data[i] + return + raise KeyError(f'No item with unique name "{idx}" found') + else: + raise TypeError('Index must be an int, slice, or str') + + def __len__(self) -> int: + """Return the number of items in the collection.""" + return len(self._data) + + def insert(self, index: int, value: ProtectedType_) -> None: + """ + Insert an item at an index. + + :param index: Index to insert at + :param value: Item to insert + """ + if not isinstance(index, int): + raise TypeError('Index must be an integer') + elif not isinstance(value, tuple(self._protected_types)): + raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}') + if value in self: + warnings.warn(f'Item with unique name "{self._get_key(value)}" already in EasyList, it will be ignored') + return + self._data.insert(index, value) + + def _get_key(self, obj) -> str: + """ + Get the unique name of an object. + Can be overridden to use a different attribute as the key. + :param object: Object to get the key for + :return: The key of the object + :rtype: str + """ + return obj.unique_name + + # Overwriting methods + + def __repr__(self) -> str: + return f'{self.__class__.__name__} of length {len(self)} of type(s) {self._protected_types}' + + def __contains__(self, item: ProtectedType_ | str) -> bool: + if isinstance(item, str): + return any(self._get_key(r) == item for r in self._data) + return item in self._data + + def __reversed__(self): + return self._data.__reversed__() + + def sort(self, key: Callable[[ProtectedType_], Any] = None, reverse: bool = False) -> None: + """ + Sort the collection according to the given key function. + + :param key: Mapping function to sort by + :param reverse: Whether to reverse the sort + """ + self._data.sort(reverse=reverse, key=key) + + def index(self, value: ProtectedType_ | str, start: int = 0, stop: int = None) -> int: + if stop is None: + stop = len(self._data) + if isinstance(value, str): + for i in range(start, min(stop, len(self._data))): + if self._get_key(self._data[i]) == value: + return i + raise ValueError(f'{value} is not in EasyList') + return self._data.index(value, start, stop) + + def pop(self, index: int | str = -1) -> ProtectedType_: + """ + Remove and return an item at the given index or unique_name. + + :param index: Index or unique_name of the item to remove + :return: The removed item + """ + if isinstance(index, int): + return self._data.pop(index) + elif isinstance(index, str): + for i, item in enumerate(self._data): + if self._get_key(item) == index: + return self._data.pop(i) + raise KeyError(f'No item with unique name "{index}" found') + else: + raise TypeError('Index must be an int or str') + + # Serialization support + + def to_dict(self) -> dict: + """ + Convert the EasyList to a dictionary for serialization. + + :return: Dictionary representation of the EasyList + """ + dict_repr = super().to_dict() + if self._protected_types != [NewBase]: + dict_repr['protected_types'] = [ + {'@module': cls_.__module__, '@class': cls_.__name__} for cls_ in self._protected_types + ] # noqa: E501 + dict_repr['data'] = [item.to_dict() for item in self._data] + return dict_repr + + @classmethod + def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase: + """ + Re-create an EasyScience object from a full encoded dictionary. + + :param obj_dict: dictionary containing the serialized contents (from `SerializerDict`) of an EasyScience object + :return: Reformed EasyScience object + """ + if not SerializerBase._is_serialized_easyscience_object(obj_dict): + raise ValueError('Input must be a dictionary representing an EasyScience EasyList object.') + temp_dict = copy.deepcopy(obj_dict) # Make a copy to avoid mutating the input + if temp_dict['@class'] == cls.__name__: + if 'protected_types' in temp_dict: + protected_types = temp_dict.pop('protected_types') + for i, type_dict in enumerate(protected_types): + if '@module' in type_dict and '@class' in type_dict: + modname = type_dict['@module'] + classname = type_dict['@class'] + mod = __import__(modname, globals(), locals(), [classname], 0) + if hasattr(mod, classname): + cls_ = getattr(mod, classname) + protected_types[i] = cls_ + else: + raise ImportError(f'Could not import class {classname} from module {modname}') + else: + raise ValueError( + 'Each protected type must be a serialized EasyScience class with @module and @class keys' + ) # noqa: E501 + else: + protected_types = None + kwargs = SerializerBase.deserialize_dict(temp_dict) + data = kwargs.pop('data', []) + return cls(data, protected_types=protected_types, **kwargs) + else: + raise ValueError(f'Class name in dictionary does not match the expected class: {cls.__name__}.') diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index ac093cc6..3315d2eb 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -98,6 +98,11 @@ def fit( :type method: str :return: Fit results :rtype: ModelResult + + For standard least squares, the weights should be 1/sigma, where + sigma is the standard deviation of the measurement. For + unweighted least squares, these should be 1. + """ method_dict = self._get_method_kwargs(method) @@ -191,6 +196,8 @@ def _make_model(self, parameters: Optional[List[BumpsParameter]] = None) -> Call Generate a bumps model from the supplied `fit_function` and parameters in the base object. Note that this makes a callable as it needs to be initialized with *x*, *y*, *weights* + Weights are converted to dy (standard deviation of y). + :return: Callable to make a bumps Curve model :rtype: Callable """ @@ -205,7 +212,7 @@ def _make_func(x, y, weights): else: for par in parameters: bumps_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = obj.convert_to_par_object(par) - return Curve(fit_func, x, y, dy=weights, **bumps_pars) + return Curve(fit_func, x, y, dy=1 / weights, **bumps_pars) return _make_func diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py index 8f23d88d..dacef618 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -74,7 +74,7 @@ def fit( :type x: np.ndarray :param y: measured points :type y: np.ndarray - :param weights: Weights for supplied measured points + :param weights: Weights for supplied measured points. :type weights: np.ndarray :param model: Optional Model which is being fitted to :type model: lmModel @@ -85,6 +85,10 @@ def fit( :type method: str :return: Fit results :rtype: ModelResult + + For standard least squares, the weights should be 1/sigma, where + sigma is the standard deviation of the measurement. For + unweighted least squares, these should be 1. """ x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) @@ -163,7 +167,7 @@ def _make_func(x, y, weights): def _residuals(pars_values: List[float]) -> np.ndarray: for idx, par_name in enumerate(dfo_pars.keys()): dfo_pars[par_name] = pars_values[idx] - return (y - fit_func(x, **dfo_pars)) / weights + return (y - fit_func(x, **dfo_pars)) * weights return _residuals @@ -267,7 +271,11 @@ def _dfo_fit( return results @staticmethod - def _prepare_kwargs(tolerance: Optional[float] = None, max_evaluations: Optional[int] = None, **kwargs) -> dict[str:str]: + def _prepare_kwargs( + tolerance: Optional[float] = None, + max_evaluations: Optional[int] = None, + **kwargs, + ) -> dict[str:str]: if max_evaluations is not None: kwargs['maxfun'] = max_evaluations # max number of function evaluations if tolerance is not None: diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index 4a10993c..2c63cea3 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -111,6 +111,11 @@ def fit( :param kwargs: Additional arguments for the fitting function. :return: Fit results :rtype: ModelResult + + For standard least squares, the weights should be 1/sigma, where + sigma is the standard deviation of the measurement. For + unweighted least squares, these should be 1. + """ x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) @@ -230,7 +235,12 @@ def _make_model(self, pars: Optional[LMParameters] = None) -> LMModel: else: value = item.value - model.set_param_hint(MINIMIZER_PARAMETER_PREFIX + str(name), value=value, min=item.min, max=item.max) + model.set_param_hint( + MINIMIZER_PARAMETER_PREFIX + str(name), + value=value, + min=item.min, + max=item.max, + ) # Cache the model for later reference self._cached_model = model diff --git a/src/easyscience/global_object/map.py b/src/easyscience/global_object/map.py index c64bfaff..a9d0b576 100644 --- a/src/easyscience/global_object/map.py +++ b/src/easyscience/global_object/map.py @@ -74,9 +74,32 @@ def __init__(self): # A dict with object names as keys and a list of their object types as values, with weak references self.__type_dict = {} + def _snapshot_items(self): + """Return a stable snapshot of __type_dict items. + + Some callers iterate over __type_dict while other threads or + weakref finalizers may modify it. Creating a list snapshot (with + a retry loop) prevents RuntimeError: dictionary changed size during iteration. + """ + while True: + try: + return list(self.__type_dict.items()) + except RuntimeError: + # Dict changed during snapshot creation, retry + continue + def vertices(self) -> List[str]: - """returns the vertices of a map""" - return list(self._store.keys()) + """Returns the vertices of a map. + + Uses a retry loop to handle RuntimeError that can occur when the + WeakValueDictionary is modified during iteration (e.g., by garbage collection). + """ + while True: + try: + return list(self._store) + except RuntimeError: + # Dictionary changed size during iteration, retry + continue def edges(self): """returns the edges of a map""" @@ -100,46 +123,64 @@ def returned_objs(self) -> List[str]: def _nested_get(self, obj_type: str) -> List[str]: """Access a nested object in root by key sequence.""" - return [key for key, item in self.__type_dict.items() if obj_type in item.type] + # Create a stable snapshot of the dict items to avoid RuntimeError + # when the dict is modified during iteration (e.g., by finalizers). + while True: + try: + items = self._snapshot_items() + return [key for key, item in items if obj_type in item.type] + except RuntimeError: + # In case the snapshot itself raises (very rare), retry + continue def get_item_by_key(self, item_id: str) -> object: - if item_id in self._store.keys(): + if item_id in self._store: return self._store[item_id] raise ValueError('Item not in map.') def is_known(self, vertex: object) -> bool: - # All objects should have a 'unique_name' attribute - return vertex.unique_name in self._store.keys() + """Check if a vertex is known in the map. + + All objects should have a 'unique_name' attribute. + """ + return vertex.unique_name in self._store def find_type(self, vertex: object) -> List[str]: if self.is_known(vertex): return self.__type_dict[vertex.unique_name].type def reset_type(self, obj, default_type: str): - if obj.unique_name in self.__type_dict.keys(): + if obj.unique_name in self.__type_dict: self.__type_dict[obj.unique_name].reset_type(default_type) def change_type(self, obj, new_type: str): - if obj.unique_name in self.__type_dict.keys(): + if obj.unique_name in self.__type_dict: self.__type_dict[obj.unique_name].type = new_type def add_vertex(self, obj: object, obj_type: str = None): name = obj.unique_name - if name in self._store.keys(): + if name in self._store: raise ValueError(f'Object name {name} already exists in the graph.') + # Clean up stale entry in __type_dict if the weak reference was collected + # but the finalizer hasn't run yet + if name in self.__type_dict: + del self.__type_dict[name] + self._store[name] = obj - self.__type_dict[name] = _EntryList() # Add objects type to the list of types - self.__type_dict[name].finalizer = weakref.finalize(self._store[name], self.prune, name) - self.__type_dict[name].type = obj_type + + entry_list = _EntryList() + entry_list.finalizer = weakref.finalize(obj, self.prune_type_dict, name) + entry_list.type = obj_type + self.__type_dict[name] = entry_list # Add objects type to the list of types def add_edge(self, start_obj: object, end_obj: object): - if start_obj.unique_name in self.__type_dict.keys(): + if start_obj.unique_name in self.__type_dict: self.__type_dict[start_obj.unique_name].append(end_obj.unique_name) else: raise AttributeError('Start object not in map.') def get_edges(self, start_obj) -> List[str]: - if start_obj.unique_name in self.__type_dict.keys(): + if start_obj.unique_name in self.__type_dict: return list(self.__type_dict[start_obj.unique_name]) else: raise AttributeError @@ -151,8 +192,11 @@ def __generate_edges(self) -> list: vertices """ edges = [] - for vertex in self.__type_dict: - for neighbour in self.__type_dict[vertex]: + # Iterate over a snapshot of items and snapshot neighbour lists to + # avoid concurrent modification issues. + for vertex, neighbours in self._snapshot_items(): + neighbours_snapshot = list(neighbours) + for neighbour in neighbours_snapshot: if {neighbour, vertex} not in edges: edges.append({vertex, neighbour}) return edges @@ -163,22 +207,25 @@ def prune_vertex_from_edge(self, parent_obj, child_obj): return vertex2 = child_obj.unique_name - if vertex1 in self.__type_dict.keys() and vertex2 in self.__type_dict[vertex1]: + if vertex1 in self.__type_dict and vertex2 in self.__type_dict[vertex1]: del self.__type_dict[vertex1][self.__type_dict[vertex1].index(vertex2)] + def prune_type_dict(self, key: str): + if key in self.__type_dict: + del self.__type_dict[key] + def prune(self, key: str): - if key in self.__type_dict.keys(): + if key in self.__type_dict: del self.__type_dict[key] - del self._store[key] + if key in self._store: + del self._store[key] def find_isolated_vertices(self) -> list: """returns a list of isolated vertices.""" - graph = self.__type_dict isolated = [] - for vertex in graph: - print(isolated, vertex) - if not graph[vertex]: - isolated += [vertex] + for vertex, neighbours in self._snapshot_items(): + if not list(neighbours): + isolated.append(vertex) return isolated def find_path(self, start_vertex: str, end_vertex: str, path=[]) -> list: @@ -230,9 +277,10 @@ def reverse_route(self, end_vertex: str, start_vertex: Optional[str] = None) -> path_length = sys.maxsize optimum_path = [] if start_vertex is None: - # We now have to find where to begin..... - for possible_start, vertices in self.__type_dict.items(): - if end_vertex in vertices: + # We now have to find where to begin..... Iterate over a snapshot + for possible_start, vertices in self._snapshot_items(): + vertices_snapshot = list(vertices) + if end_vertex in vertices_snapshot: temp_path = self.find_path(possible_start, end_vertex) if len(temp_path) < path_length: path_length = len(temp_path) @@ -247,13 +295,13 @@ def is_connected(self, vertices_encountered=None, start_vertex=None) -> bool: if vertices_encountered is None: vertices_encountered = set() graph = self.__type_dict - vertices = list(graph.keys()) + vertices = list(graph) if not start_vertex: # chose a vertex from graph as a starting point start_vertex = vertices[0] vertices_encountered.add(start_vertex) if len(vertices_encountered) != len(vertices): - for vertex in graph[start_vertex]: + for vertex in list(graph[start_vertex]): if vertex not in vertices_encountered and self.is_connected(vertices_encountered, vertex): return True else: @@ -262,10 +310,9 @@ def is_connected(self, vertices_encountered=None, start_vertex=None) -> bool: def _clear(self): """Reset the map to an empty state. Only to be used for testing""" - for vertex in self.vertices(): - self.prune(vertex) + self._store.clear() + self.__type_dict.clear() gc.collect() - self.__type_dict = {} def __repr__(self) -> str: return f'Map object of {len(self._store)} vertices.' diff --git a/src/easyscience/variable/descriptor_number.py b/src/easyscience/variable/descriptor_number.py index 7d35ca0b..38b233f1 100644 --- a/src/easyscience/variable/descriptor_number.py +++ b/src/easyscience/variable/descriptor_number.py @@ -423,16 +423,13 @@ def __rmul__(self, other: numbers.Number) -> DescriptorNumber: def __truediv__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorNumber: if isinstance(other, numbers.Number): - original_other = other if other == 0: raise ZeroDivisionError('Cannot divide by zero') new_value = self.full_value / other elif type(other) is DescriptorNumber: - original_other = other.value - if original_other == 0: + if other.value == 0: raise ZeroDivisionError('Cannot divide by zero') new_value = self.full_value / other.full_value - other.value = original_other else: return NotImplemented descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value) diff --git a/src/easyscience/variable/parameter.py b/src/easyscience/variable/parameter.py index 55787ad4..6ea8d1a6 100644 --- a/src/easyscience/variable/parameter.py +++ b/src/easyscience/variable/parameter.py @@ -121,7 +121,12 @@ def __init__( @classmethod def from_dependency( - cls, name: str, dependency_expression: str, dependency_map: Optional[dict] = None, **kwargs + cls, + name: str, + dependency_expression: str, + dependency_map: Optional[dict] = None, + desired_unit: str | sc.Unit | None = None, + **kwargs, ) -> Parameter: # noqa: E501 """ Create a dependent Parameter directly from a dependency expression. @@ -129,15 +134,20 @@ def from_dependency( :param name: The name of the parameter :param dependency_expression: The dependency expression to evaluate. This should be a string which can be evaluated by the ASTEval interpreter. :param dependency_map: A dictionary of dependency expression symbol name and dependency object pairs. This is inserted into the asteval interpreter to resolve dependencies. + :param desired_unit: The desired unit of the dependent parameter. :param kwargs: Additional keyword arguments to pass to the Parameter constructor. :return: A new dependent Parameter object. """ # noqa: E501 # Set default values for required parameters for the constructor, they get overwritten by the dependency anyways - default_kwargs = {'value': 0.0, 'unit': '', 'variance': 0.0, 'min': -np.inf, 'max': np.inf} + default_kwargs = {'value': 0.0, 'variance': 0.0, 'min': -np.inf, 'max': np.inf} # Update with user-provided kwargs, to avoid errors. default_kwargs.update(kwargs) parameter = cls(name=name, **default_kwargs) - parameter.make_dependent_on(dependency_expression=dependency_expression, dependency_map=dependency_map) + parameter.make_dependent_on( + dependency_expression=dependency_expression, + dependency_map=dependency_map, + desired_unit=desired_unit, + ) return parameter def _update(self) -> None: @@ -158,11 +168,20 @@ def _update(self) -> None: ) # noqa: E501 self._min.unit = temporary_parameter.unit self._max.unit = temporary_parameter.unit + + if self._desired_unit is not None: + self._convert_unit(self._desired_unit) + self._notify_observers() else: warnings.warn('This parameter is not dependent. It cannot be updated.') - def make_dependent_on(self, dependency_expression: str, dependency_map: Optional[dict] = None) -> None: + def make_dependent_on( + self, + dependency_expression: str, + dependency_map: Optional[dict] = None, + desired_unit: str | sc.Unit | None = None, + ) -> None: """ Make this parameter dependent on another parameter. This will overwrite the current value, unit, variance, min and max. @@ -183,6 +202,9 @@ def make_dependent_on(self, dependency_expression: str, dependency_map: Optional A dictionary of dependency expression symbol name and dependency object pairs. This is inserted into the asteval interpreter to resolve dependencies. + :param desired_unit: + The desired unit of the dependent parameter. If None, the default unit of the dependency expression result is used. + """ # noqa: E501 if not isinstance(dependency_expression, str): raise TypeError('`dependency_expression` must be a string representing a valid dependency expression.') @@ -212,6 +234,7 @@ def make_dependent_on(self, dependency_expression: str, dependency_map: Optional '_dependency_map': self._dependency_map, '_dependency_interpreter': self._dependency_interpreter, '_clean_dependency_string': self._clean_dependency_string, + '_desired_unit': self._desired_unit, } for dependency in self._dependency_map.values(): dependency._detach_observer(self) @@ -219,6 +242,9 @@ def make_dependent_on(self, dependency_expression: str, dependency_map: Optional self._independent = False self._dependency_string = dependency_expression self._dependency_map = dependency_map if dependency_map is not None else {} + if desired_unit is not None and not (isinstance(desired_unit, str) or isinstance(desired_unit, sc.Unit)): + raise TypeError('`desired_unit` must be a string representing a valid unit.') + self._desired_unit = desired_unit # List of allowed python constructs for the asteval interpreter asteval_config = { 'import': False, @@ -289,6 +315,17 @@ def make_dependent_on(self, dependency_expression: str, dependency_map: Optional raise error # Update the parameter with the dependency result self._fixed = False + + if self._desired_unit is not None: + try: + dependency_result._convert_unit(self._desired_unit) + except Exception as e: + desired_unit_for_error_message = self._desired_unit + self._revert_dependency() # also deletes self._desired_unit + raise UnitError( + f'Failed to convert unit from {dependency_result.unit} to {desired_unit_for_error_message}: {e}' + ) + self._update() def make_independent(self) -> None: @@ -306,6 +343,7 @@ def make_independent(self) -> None: del self._dependency_interpreter del self._dependency_string del self._clean_dependency_string + del self._desired_unit else: raise AttributeError('This parameter is already independent.') @@ -470,6 +508,28 @@ def convert_unit(self, unit_str: str) -> None: """ self._convert_unit(unit_str) + def set_desired_unit(self, unit_str: str | sc.Unit | None) -> None: + """ + Set the desired unit for a dependent Parameter. This will convert the parameter to the desired unit. + + :param unit_str: The desired unit as a string. + """ + + if self._independent: + raise AttributeError('This is an independent parameter, desired unit can only be set for dependent parameters.') + if not (isinstance(unit_str, str) or isinstance(unit_str, sc.Unit) or unit_str is None): + raise TypeError('`unit_str` must be a string representing a valid unit.') + + if unit_str is not None: + try: + old_unit_for_message = self.unit + self._convert_unit(unit_str) + except Exception as e: + raise UnitError(f'Failed to convert unit from {old_unit_for_message} to {unit_str}: {e}') + + self._desired_unit = unit_str + self._update() + @property def min(self) -> numbers.Number: """ @@ -580,6 +640,9 @@ def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: # Save the dependency expression raw_dict['_dependency_string'] = self._clean_dependency_string + if self._desired_unit is not None: + raw_dict['_desired_unit'] = self._desired_unit + # Mark that this parameter is dependent raw_dict['_independent'] = self._independent @@ -648,6 +711,7 @@ def from_dict(cls, obj_dict: dict) -> 'Parameter': dependency_string = raw_dict.pop('_dependency_string', None) dependency_map_serializer_ids = raw_dict.pop('_dependency_map_serializer_ids', None) is_independent = raw_dict.pop('_independent', True) + desired_unit = raw_dict.pop('_desired_unit', None) # Note: Keep _serializer_id in the dict so it gets passed to __init__ # Create the parameter using the base class method (serializer_id is now handled in __init__) @@ -659,6 +723,7 @@ def from_dict(cls, obj_dict: dict) -> 'Parameter': param._pending_dependency_map_serializer_ids = dependency_map_serializer_ids # Keep parameter as independent initially - will be made dependent after all objects are loaded param._independent = True + param._pending_desired_unit = desired_unit return param @@ -874,10 +939,14 @@ def __truediv__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) elif self.max <= 0: combinations = [self.max / other.min, np.inf] else: - combinations = [self.min / other.min, self.max / other.max, self.min / other.max, self.max / other.min] + combinations = [ + self.min / other.min, + self.max / other.max, + self.min / other.max, + self.max / other.min, + ] else: combinations = [self.min / other.value, self.max / other.value] - other.value = other_value else: return NotImplemented min_value = min(combinations) @@ -926,7 +995,6 @@ def __rtruediv__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parame parameter = Parameter.from_scipp(name=self.name, full_value=new_full_value, min=min_value, max=max_value) parameter._convert_unit(parameter._base_unit()) parameter.name = parameter.unique_name - self.value = original_self return parameter def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter: @@ -1019,13 +1087,18 @@ def resolve_pending_dependencies(self) -> None: # Establish the dependency relationship try: - self.make_dependent_on(dependency_expression=dependency_string, dependency_map=dependency_map) + self.make_dependent_on( + dependency_expression=dependency_string, + dependency_map=dependency_map, + desired_unit=self._pending_desired_unit, + ) except Exception as e: raise ValueError(f"Error establishing dependency '{dependency_string}': {e}") # Clean up temporary attributes delattr(self, '_pending_dependency_string') delattr(self, '_pending_dependency_map_serializer_ids') + delattr(self, '_pending_desired_unit') def _find_parameter_by_serializer_id(self, serializer_id: str) -> Optional['DescriptorNumber']: """Find a parameter by its serializer_id from all parameters in the global map.""" diff --git a/tests/integration_tests/fitting/test_fitter.py b/tests/integration_tests/fitting/test_fitter.py index bfa3f237..d5c9b255 100644 --- a/tests/integration_tests/fitting/test_fitter.py +++ b/tests/integration_tests/fitting/test_fitter.py @@ -12,6 +12,7 @@ from easyscience.fitting.minimizers import FitError from easyscience.base_classes import ModelBase + # Model and container of parameters for tests class AbsSin(ObjBase): phase: Parameter @@ -36,20 +37,21 @@ def __init__(self, offset_val: float, phase_val: float): super().__init__("sin2D", offset=offset, phase=phase) def __call__(self, x): - X = x[:, :, 0] # x is a 2D array + X = x[:, :, 0] # x is a 2D array Y = x[:, :, 1] - return np.abs( - np.sin(self.phase.value * X + self.offset.value) - ) * np.abs(np.sin(self.phase.value * Y + self.offset.value)) + return np.abs(np.sin(self.phase.value * X + self.offset.value)) * np.abs( + np.sin(self.phase.value * Y + self.offset.value) + ) class AbsSin2DL(AbsSin2D): def __call__(self, x): - X = x[:, 0] # x is a 1D array + X = x[:, 0] # x is a 1D array Y = x[:, 1] - return np.abs( - np.sin(self.phase.value * X + self.offset.value) - ) * np.abs(np.sin(self.phase.value * Y + self.offset.value)) + return np.abs(np.sin(self.phase.value * X + self.offset.value)) * np.abs( + np.sin(self.phase.value * Y + self.offset.value) + ) + class StraightLine(ModelBase): def __init__(self, slope: float, intercept: float): @@ -60,7 +62,7 @@ def __init__(self, slope: float, intercept: float): @property def slope(self) -> Parameter: return self._slope - + @slope.setter def slope(self, value: float) -> None: self._slope.value = value @@ -100,7 +102,15 @@ def check_fit_results(result, sp_sin, ref_sin, x, **kwargs): assert result.residual == pytest.approx(sp_sin(x) - y_calc_ref, abs=1e-2) -@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) def test_basic_fit(fit_engine: AvailableMinimizers): ref_sin = AbsSin(0.2, np.pi) sp_sin = AbsSin(0.354, 3.05) @@ -122,12 +132,22 @@ def test_basic_fit(fit_engine: AvailableMinimizers): result = f.fit(x=x, y=y, weights=weights) if fit_engine is not None: - assert result.minimizer_engine.package == fit_engine.name.lower() # Special case where minimizer matches package + assert ( + result.minimizer_engine.package == fit_engine.name.lower() + ) # Special case where minimizer matches package assert sp_sin.phase.value == pytest.approx(ref_sin.phase.value, rel=1e-3) assert sp_sin.offset.value == pytest.approx(ref_sin.offset.value, rel=1e-3) -@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) def test_fit_result(fit_engine): ref_sin = AbsSin(0.2, np.pi) sp_sin = AbsSin(0.354, 3.05) @@ -160,7 +180,15 @@ def test_fit_result(fit_engine): check_fit_results(result, sp_sin, ref_sin, x, sp_ref1=sp_ref1, sp_ref2=sp_ref2) -@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) def test_basic_max_evaluations(fit_engine): ref_sin = AbsSin(0.2, np.pi) sp_sin = AbsSin(0.354, 3.05) @@ -189,7 +217,15 @@ def test_basic_max_evaluations(fit_engine): assert "Objective has been called MAXFUN times" in str(e) -@pytest.mark.parametrize("fit_engine,tolerance", [(None, 10), (AvailableMinimizers.LMFit, 10), (AvailableMinimizers.Bumps, 10), (AvailableMinimizers.DFO, 0.1)]) +@pytest.mark.parametrize( + "fit_engine,tolerance", + [ + (None, 10), + (AvailableMinimizers.LMFit, 10), + (AvailableMinimizers.Bumps, 10), + (AvailableMinimizers.DFO, 0.1), + ], +) def test_basic_tolerance(fit_engine, tolerance): ref_sin = AbsSin(0.2, np.pi) sp_sin = AbsSin(0.354, 3.05) @@ -232,7 +268,7 @@ def test_lmfit_methods(fit_method): check_fit_results(result, sp_sin, ref_sin, x) -#@pytest.mark.xfail(reason="known bumps issue") +# @pytest.mark.xfail(reason="known bumps issue") @pytest.mark.parametrize("fit_method", ["newton", "lm"]) def test_bumps_methods(fit_method): ref_sin = AbsSin(0.2, np.pi) @@ -252,7 +288,10 @@ def test_bumps_methods(fit_method): check_fit_results(result, sp_sin, ref_sin, x) -@pytest.mark.parametrize("fit_engine", [AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) +@pytest.mark.parametrize( + "fit_engine", + [AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO], +) def test_dependent_parameter(fit_engine): ref_sin = AbsSin(np.pi * 0.45, 0.45 * np.pi * 0.5) sp_sin = AbsSin(1, 0.5) @@ -263,7 +302,9 @@ def test_dependent_parameter(fit_engine): f = Fitter(sp_sin, sp_sin) - sp_sin.offset.make_dependent_on(dependency_expression='2*phase', dependency_map={"phase": sp_sin.phase}) + sp_sin.offset.make_dependent_on( + dependency_expression="2*phase", dependency_map={"phase": sp_sin.phase} + ) if fit_engine is not None: try: @@ -274,13 +315,20 @@ def test_dependent_parameter(fit_engine): result = f.fit(x, y, weights=weights) check_fit_results(result, sp_sin, ref_sin, x) -@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) + +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) def test_2D_vectorized(fit_engine): x = np.linspace(0, 5, 200) mm = AbsSin2D(0.3, 1.6) - m2 = AbsSin2D( - 0.1, 1.8 - ) # The fit is quite sensitive to the initial values :-( + m2 = AbsSin2D(0.1, 1.8) # The fit is quite sensitive to the initial values :-( X, Y = np.meshgrid(x, x) XY = np.stack((X, Y), axis=2) weights = np.ones_like(mm(XY)) @@ -306,13 +354,19 @@ def test_2D_vectorized(fit_engine): assert result.residual == pytest.approx(mm(XY) - y_calc_ref, abs=1e-2) -@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) def test_2D_non_vectorized(fit_engine): x = np.linspace(0, 5, 200) mm = AbsSin2DL(0.3, 1.6) - m2 = AbsSin2DL( - 0.1, 1.8 - ) # The fit is quite sensitive to the initial values :-( + m2 = AbsSin2DL(0.1, 1.8) # The fit is quite sensitive to the initial values :-( X, Y = np.meshgrid(x, x) XY = np.stack((X, Y), axis=2) weights = np.ones_like(mm(XY.reshape(-1, 2))) @@ -323,7 +377,9 @@ def test_2D_non_vectorized(fit_engine): except AttributeError: pytest.skip(msg=f"{fit_engine} is not installed") try: - result = ff.fit(x=XY, y=mm(XY.reshape(-1, 2)), weights=weights, vectorized=False) + result = ff.fit( + x=XY, y=mm(XY.reshape(-1, 2)), weights=weights, vectorized=False + ) except FitError as e: if "Unable to allocate" in str(e): pytest.skip(msg="MemoryError - Matrix too large") @@ -339,7 +395,16 @@ def test_2D_non_vectorized(fit_engine): mm(XY.reshape(-1, 2)) - y_calc_ref, abs=1e-2 ) -@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) + +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) def test_fixed_parameter_does_not_change(fit_engine): # WHEN ref_sin = AbsSin(0.2, np.pi) @@ -365,12 +430,13 @@ def test_fixed_parameter_does_not_change(fit_engine): result = f.fit(x=x, y=y, weights=weights) - # EXPECT + # EXPECT # Offset should remain unchanged assert sp_sin.offset.value == pytest.approx(fixed_offset_before, abs=1e-12) # Phase should be optimized assert sp_sin.phase.value != pytest.approx(ref_sin.phase.value, rel=1e-3) + def test_fitter_new_model_base_integration(): # WHEN ground_truth = StraightLine(slope=2.0, intercept=1.0) @@ -388,4 +454,63 @@ def test_fitter_new_model_base_integration(): # EXPECT assert model.slope.value == pytest.approx(ground_truth.slope.value, rel=1e-3) - assert model.intercept.value == pytest.approx(ground_truth.intercept.value, rel=1e-3) \ No newline at end of file + assert model.intercept.value == pytest.approx( + ground_truth.intercept.value, rel=1e-3 + ) + + +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) +def test_fitter_variable_weights(fit_engine): + # WHEN + ref_sin = AbsSin(0.2, np.pi) + sp_sin = AbsSin(0.354, 3.05) + + x = np.linspace(0, 5, 200) + y_true = ref_sin(x) + + # Introduce bias in second half of data + y = y_true.copy() + y[100:] += 0.5 # Artificial distortion + + # Case 1: High weight on distorted region + weights_high = np.ones_like(x) + weights_high[100:] = 10.0 + + # Case 2: Low weight on distorted region + weights_low = np.ones_like(x) + weights_low[100:] = 0.1 + + sp_sin.offset.fixed = False + sp_sin.phase.fixed = False + + def run_fit(weights): + model = AbsSin(0.354, 3.05) + model.offset.fixed = False + model.phase.fixed = False + + f = Fitter(model, model) + if fit_engine is not None: + try: + f.switch_minimizer(fit_engine) + except AttributeError: + pytest.skip(msg=f"{fit_engine} is not installed") + + f.fit(x=x, y=y, weights=weights) + return model.offset.value, model.phase.value + + offset_high, phase_high = run_fit(weights_high) + offset_low, phase_low = run_fit(weights_low) + + # The fit should shift more toward the distorted region + # when it has higher weight + assert abs(offset_high - ref_sin.offset.value) > abs( + offset_low - ref_sin.offset.value + ) diff --git a/tests/unit_tests/base_classes/test_easy_list.py b/tests/unit_tests/base_classes/test_easy_list.py new file mode 100644 index 00000000..18e5989c --- /dev/null +++ b/tests/unit_tests/base_classes/test_easy_list.py @@ -0,0 +1,522 @@ +# SPDX-FileCopyrightText: 2025 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +# © 2021-2025 Contributors to the EasyScience project DFO: minimizer = DFO( - obj='obj', - fit_function='fit_function', - minimizer_enum=MagicMock(package='dfo', method='leastsq') + obj="obj", + fit_function="fit_function", + minimizer_enum=MagicMock(package="dfo", method="leastsq"), ) return minimizer def test_init(self, minimizer: DFO) -> None: assert minimizer._p_0 == {} - assert minimizer.package == 'dfo' + assert minimizer.package == "dfo" def test_init_exception(self) -> None: with pytest.raises(FitError): DFO( - obj='obj', - fit_function='fit_function', - minimizer_enum=MagicMock(package='dfo', method='not_leastsq') + obj="obj", + fit_function="fit_function", + minimizer_enum=MagicMock(package="dfo", method="not_leastsq"), ) def test_supported_methods(self, minimizer: DFO) -> None: # When Then Expect - assert minimizer.supported_methods() == ['leastsq'] + assert minimizer.supported_methods() == ["leastsq"] def test_supported_methods(self, minimizer: DFO) -> None: # When Then Expect - assert minimizer.supported_methods() == ['leastsq'] + assert minimizer.supported_methods() == ["leastsq"] def test_fit(self, minimizer: DFO) -> None: # When from easyscience import global_object + global_object.stack.enabled = False mock_model = MagicMock() mock_model_function = MagicMock(return_value=mock_model) minimizer._make_model = MagicMock(return_value=mock_model_function) - minimizer._dfo_fit = MagicMock(return_value='fit') + minimizer._dfo_fit = MagicMock(return_value="fit") minimizer._set_parameter_fit_result = MagicMock() - minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results') + minimizer._gen_fit_results = MagicMock(return_value="gen_fit_results") cached_par = MagicMock() cached_par.value = 1 - cached_pars = {'mock_parm_1': cached_par} + cached_pars = {"mock_parm_1": cached_par} minimizer._cached_pars = cached_pars # Then result = minimizer.fit(x=1.0, y=2.0, weights=1) # Expect - assert result == 'gen_fit_results' + assert result == "gen_fit_results" minimizer._dfo_fit.assert_called_once_with(cached_pars, mock_model) minimizer._make_model.assert_called_once_with(parameters=None) - minimizer._set_parameter_fit_result.assert_called_once_with('fit', False) - minimizer._gen_fit_results.assert_called_once_with('fit', 1) + minimizer._set_parameter_fit_result.assert_called_once_with("fit", False) + minimizer._gen_fit_results.assert_called_once_with("fit", 1) mock_model_function.assert_called_once_with(1.0, 2.0, 1) def test_generate_fit_function(self, minimizer: DFO) -> None: # When - minimizer._original_fit_function = MagicMock(return_value='fit_function_result') + minimizer._original_fit_function = MagicMock(return_value="fit_function_result") minimizer._object = MagicMock() mock_parm_1 = MagicMock() - mock_parm_1.unique_name = 'mock_parm_1' + mock_parm_1.unique_name = "mock_parm_1" mock_parm_1.value = 1.0 mock_parm_1.error = 0.1 mock_parm_2 = MagicMock() - mock_parm_2.unique_name = 'mock_parm_2' + mock_parm_2.unique_name = "mock_parm_2" mock_parm_2.value = 2.0 mock_parm_2.error = 0.2 - minimizer._object.get_fit_parameters = MagicMock(return_value=[mock_parm_1, mock_parm_2]) + minimizer._object.get_fit_parameters = MagicMock( + return_value=[mock_parm_1, mock_parm_2] + ) # Then fit_function = minimizer._generate_fit_function() fit_function_result = fit_function([10.0]) # Expect - assert 'fit_function_result' == fit_function_result + assert "fit_function_result" == fit_function_result minimizer._original_fit_function.assert_called_once_with([10.0]) - assert minimizer._cached_pars['mock_parm_1'] == mock_parm_1 - assert minimizer._cached_pars['mock_parm_2'] == mock_parm_2 - - @pytest.mark.parametrize("weights", [np.array([1, 2, 3, 4]), np.array([[1, 2, 3], [4, 5, 6]]), np.repeat(np.nan,3), np.zeros(3), np.repeat(np.inf,3), -np.ones(3)], ids=["wrong_length", "multidimensional", "NaNs", "zeros", "Infs", "negative"]) + assert minimizer._cached_pars["mock_parm_1"] == mock_parm_1 + assert minimizer._cached_pars["mock_parm_2"] == mock_parm_2 + + @pytest.mark.parametrize( + "weights", + [ + np.array([1, 2, 3, 4]), + np.array([[1, 2, 3], [4, 5, 6]]), + np.repeat(np.nan, 3), + np.zeros(3), + np.repeat(np.inf, 3), + -np.ones(3), + ], + ids=["wrong_length", "multidimensional", "NaNs", "zeros", "Infs", "negative"], + ) def test_fit_weight_exceptions(self, minimizer: DFO, weights) -> None: # When Then Expect with pytest.raises(ValueError): @@ -105,87 +119,115 @@ def test_make_model(self, minimizer: DFO) -> None: minimizer._generate_fit_function = MagicMock(return_value=mock_fit_function) mock_parm_1 = MagicMock() - mock_parm_1.unique_name = 'mock_parm_1' + mock_parm_1.unique_name = "mock_parm_1" mock_parm_1.value = 1000.0 mock_parm_2 = MagicMock() - mock_parm_2.unique_name = 'mock_parm_2' + mock_parm_2.unique_name = "mock_parm_2" mock_parm_2.value = 2000.0 # Then model = minimizer._make_model(parameters=[mock_parm_1, mock_parm_2]) - residuals_for_model = model(x=np.array([1, 2]), y=np.array([10, 20]), weights=np.array([100, 200])) + residuals_for_model = model( + x=np.array([1, 2]), + y=np.array([10, 20]), + weights=np.array([1 / 100, 1 / 200]), + ) # Expect minimizer._generate_fit_function.assert_called_once_with() - assert all(np.array([-0.01, -0.01]) == residuals_for_model(np.array([1111, 2222]))) + assert all( + np.array([-0.01, -0.01]) == residuals_for_model(np.array([1111, 2222])) + ) assert all(mock_fit_function.call_args[0][0] == np.array([1, 2])) - assert mock_fit_function.call_args[1] == {'pmock_parm_1': 1111, 'pmock_parm_2': 2222} + assert mock_fit_function.call_args[1] == { + "pmock_parm_1": 1111, + "pmock_parm_2": 2222, + } def test_set_parameter_fit_result_no_stack_status(self, minimizer: DFO): # When minimizer._cached_pars = { - 'a': MagicMock(), - 'b': MagicMock(), + "a": MagicMock(), + "b": MagicMock(), } - minimizer._cached_pars['a'].value = 'a' - minimizer._cached_pars['b'].value = 'b' + minimizer._cached_pars["a"].value = "a" + minimizer._cached_pars["b"].value = "b" mock_fit_result = MagicMock() mock_fit_result.x = [1.0, 2.0] - mock_fit_result.jacobian = 'jacobian' - mock_fit_result.resid = 'resid' + mock_fit_result.jacobian = "jacobian" + mock_fit_result.resid = "resid" - minimizer._error_from_jacobian = MagicMock(return_value=np.array([[0.1, 0.0], [0.0, 0.2]])) + minimizer._error_from_jacobian = MagicMock( + return_value=np.array([[0.1, 0.0], [0.0, 0.2]]) + ) # Then minimizer._set_parameter_fit_result(mock_fit_result, False) # Expect - assert minimizer._cached_pars['a'].value == 1.0 - assert minimizer._cached_pars['a'].error == 0.1 - assert minimizer._cached_pars['b'].value == 2.0 - assert minimizer._cached_pars['b'].error == 0.2 - minimizer._error_from_jacobian.assert_called_once_with('jacobian', 'resid', 0.95) + assert minimizer._cached_pars["a"].value == 1.0 + assert minimizer._cached_pars["a"].error == 0.1 + assert minimizer._cached_pars["b"].value == 2.0 + assert minimizer._cached_pars["b"].error == 0.2 + minimizer._error_from_jacobian.assert_called_once_with( + "jacobian", "resid", 0.95 + ) def test_gen_fit_results(self, minimizer: DFO, monkeypatch): # When mock_domain_fit_results = MagicMock() mock_FitResults = MagicMock(return_value=mock_domain_fit_results) - monkeypatch.setattr(easyscience.fitting.minimizers.minimizer_dfo, "FitResults", mock_FitResults) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_dfo, "FitResults", mock_FitResults + ) mock_fit_result = MagicMock() mock_fit_result.flag = False mock_cached_model = MagicMock() - mock_cached_model.x = 'x' - mock_cached_model.y = 'y' + mock_cached_model.x = "x" + mock_cached_model.y = "y" minimizer._cached_model = mock_cached_model mock_cached_par_1 = MagicMock() - mock_cached_par_1.value = 'par_value_1' + mock_cached_par_1.value = "par_value_1" mock_cached_par_2 = MagicMock() - mock_cached_par_2.value = 'par_value_2' - minimizer._cached_pars = {'par_1': mock_cached_par_1, 'par_2': mock_cached_par_2} + mock_cached_par_2.value = "par_value_2" + minimizer._cached_pars = { + "par_1": mock_cached_par_1, + "par_2": mock_cached_par_2, + } - minimizer._p_0 = 'p_0' - minimizer.evaluate = MagicMock(return_value='evaluate') + minimizer._p_0 = "p_0" + minimizer.evaluate = MagicMock(return_value="evaluate") # Then - domain_fit_results = minimizer._gen_fit_results(mock_fit_result, 'weights', **{'kwargs_set_key': 'kwargs_set_val'}) + domain_fit_results = minimizer._gen_fit_results( + mock_fit_result, "weights", **{"kwargs_set_key": "kwargs_set_val"} + ) # Expect assert domain_fit_results == mock_domain_fit_results - assert domain_fit_results.kwargs_set_key == 'kwargs_set_val' - assert domain_fit_results.success == True - assert domain_fit_results.y_obs == 'y' - assert domain_fit_results.x == 'x' - assert domain_fit_results.p == {'ppar_1': 'par_value_1', 'ppar_2': 'par_value_2'} - assert domain_fit_results.p0 == 'p_0' - assert domain_fit_results.y_calc == 'evaluate' - assert domain_fit_results.y_err == 'weights' - assert str(domain_fit_results.minimizer_engine) == "" + assert domain_fit_results.kwargs_set_key == "kwargs_set_val" + assert domain_fit_results.success == True + assert domain_fit_results.y_obs == "y" + assert domain_fit_results.x == "x" + assert domain_fit_results.p == { + "ppar_1": "par_value_1", + "ppar_2": "par_value_2", + } + assert domain_fit_results.p0 == "p_0" + assert domain_fit_results.y_calc == "evaluate" + assert domain_fit_results.y_err == "weights" + assert ( + str(domain_fit_results.minimizer_engine) + == "" + ) assert domain_fit_results.fit_args is None - minimizer.evaluate.assert_called_once_with('x', minimizer_parameters={'ppar_1': 'par_value_1', 'ppar_2': 'par_value_2'}) + minimizer.evaluate.assert_called_once_with( + "x", minimizer_parameters={"ppar_1": "par_value_1", "ppar_2": "par_value_2"} + ) def test_dfo_fit(self, minimizer: DFO, monkeypatch): # When @@ -199,26 +241,28 @@ def test_dfo_fit(self, minimizer: DFO, monkeypatch): mock_parm_2.max = 20.0 pars = {1: mock_parm_1, 2: mock_parm_2} - kwargs = {'kwargs_set_key': 'kwargs_set_val'} + kwargs = {"kwargs_set_key": "kwargs_set_val"} mock_dfols = MagicMock() mock_results = MagicMock() - mock_results.msg = 'Success' + mock_results.msg = "Success" mock_dfols.solve = MagicMock(return_value=mock_results) - monkeypatch.setattr(easyscience.fitting.minimizers.minimizer_dfo, "dfols", mock_dfols) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_dfo, "dfols", mock_dfols + ) # Then - results = minimizer._dfo_fit(pars, 'model', **kwargs) - + results = minimizer._dfo_fit(pars, "model", **kwargs) + # Expect assert results == mock_results - assert mock_dfols.solve.call_args[0][0] == 'model' - assert all(mock_dfols.solve.call_args[0][1] == np.array([1., 2.])) - assert all(mock_dfols.solve.call_args[1]['bounds'][0] == np.array([0.1, 0.2])) - assert all(mock_dfols.solve.call_args[1]['bounds'][1] == np.array([10., 20.])) - assert mock_dfols.solve.call_args[1]['scaling_within_bounds'] is True - assert mock_dfols.solve.call_args[1]['kwargs_set_key'] == 'kwargs_set_val' + assert mock_dfols.solve.call_args[0][0] == "model" + assert all(mock_dfols.solve.call_args[0][1] == np.array([1.0, 2.0])) + assert all(mock_dfols.solve.call_args[1]["bounds"][0] == np.array([0.1, 0.2])) + assert all(mock_dfols.solve.call_args[1]["bounds"][1] == np.array([10.0, 20.0])) + assert mock_dfols.solve.call_args[1]["scaling_within_bounds"] is True + assert mock_dfols.solve.call_args[1]["kwargs_set_key"] == "kwargs_set_val" def test_dfo_fit_no_scaling(self, minimizer: DFO, monkeypatch): # When @@ -232,40 +276,46 @@ def test_dfo_fit_no_scaling(self, minimizer: DFO, monkeypatch): mock_parm_2.max = 20.0 pars = {1: mock_parm_1, 2: mock_parm_2} - kwargs = {'kwargs_set_key': 'kwargs_set_val'} + kwargs = {"kwargs_set_key": "kwargs_set_val"} mock_dfols = MagicMock() mock_results = MagicMock() - mock_results.msg = 'Success' + mock_results.msg = "Success" mock_dfols.solve = MagicMock(return_value=mock_results) - monkeypatch.setattr(easyscience.fitting.minimizers.minimizer_dfo, "dfols", mock_dfols) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_dfo, "dfols", mock_dfols + ) # Then - results = minimizer._dfo_fit(pars, 'model', **kwargs) - + results = minimizer._dfo_fit(pars, "model", **kwargs) + # Expect assert results == mock_results - assert mock_dfols.solve.call_args[0][0] == 'model' - assert all(mock_dfols.solve.call_args[0][1] == np.array([1., 2.])) - assert all(mock_dfols.solve.call_args[1]['bounds'][0] == np.array([-np.inf, 0.2])) - assert all(mock_dfols.solve.call_args[1]['bounds'][1] == np.array([10., 20.])) - assert not 'scaling_within_bounds' in list(mock_dfols.solve.call_args[1].keys()) - assert 'kwargs_set_key' in list(mock_dfols.solve.call_args[1].keys()) - assert mock_dfols.solve.call_args[1]['kwargs_set_key'] == 'kwargs_set_val' + assert mock_dfols.solve.call_args[0][0] == "model" + assert all(mock_dfols.solve.call_args[0][1] == np.array([1.0, 2.0])) + assert all( + mock_dfols.solve.call_args[1]["bounds"][0] == np.array([-np.inf, 0.2]) + ) + assert all(mock_dfols.solve.call_args[1]["bounds"][1] == np.array([10.0, 20.0])) + assert not "scaling_within_bounds" in list(mock_dfols.solve.call_args[1].keys()) + assert "kwargs_set_key" in list(mock_dfols.solve.call_args[1].keys()) + assert mock_dfols.solve.call_args[1]["kwargs_set_key"] == "kwargs_set_val" def test_dfo_fit_exception(self, minimizer: DFO, monkeypatch): # When pars = {1: MagicMock(Parameter)} - kwargs = {'kwargs_set_key': 'kwargs_set_val'} + kwargs = {"kwargs_set_key": "kwargs_set_val"} mock_dfols = MagicMock() mock_results = MagicMock() - mock_results.msg = 'Failed' + mock_results.msg = "Failed" mock_dfols.solve = MagicMock(return_value=mock_results) - monkeypatch.setattr(easyscience.fitting.minimizers.minimizer_dfo, "dfols", mock_dfols) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_dfo, "dfols", mock_dfols + ) # Then Expect with pytest.raises(FitError): - minimizer._dfo_fit(pars, 'model', **kwargs) \ No newline at end of file + minimizer._dfo_fit(pars, "model", **kwargs) diff --git a/tests/unit_tests/global_object/test_map.py b/tests/unit_tests/global_object/test_map.py index 05eec1a8..a2783e6e 100644 --- a/tests/unit_tests/global_object/test_map.py +++ b/tests/unit_tests/global_object/test_map.py @@ -241,6 +241,24 @@ def test_find_type_unknown_object(self, clear): # When/Then result = global_object.map.find_type(unknown_obj) assert result is None + + def test_returned_objs_access_safe_under_modification(self, clear): + """Ensure accessing returned_objs doesn't raise when entries change size during iteration.""" + objs = [ObjBase(name=f"race_{i}") for i in range(8)] + # Mark all as returned + for o in objs: + global_object.map.change_type(o, 'returned') + + # Repeatedly access returned_objs while deleting objects and forcing GC to + # try to trigger concurrent modification. This used to raise RuntimeError. + for _ in range(200): + _ = global_object.map.returned_objs # should not raise + if _ and objs: + # delete one object and collect to trigger finalizer/prune + del objs[0] + gc.collect() + # If we got here without exceptions, consider the access safe + assert True def test_reset_type(self, clear, base_object): """Test resetting object type""" @@ -484,3 +502,248 @@ def test_map_initialization(self): assert len(test_map.vertices()) == 0 assert test_map.edges() == [] + def test_vertices_retry_on_runtime_error(self, clear): + """Test that vertices() retries when RuntimeError occurs during iteration. + + This tests the thread-safety fix for WeakValueDictionary modification + during iteration (e.g., by garbage collection). + """ + # Given + test_map = Map() + + # Create a mock _store that raises RuntimeError on first iteration attempt + call_count = 0 + original_store = test_map._store + + class MockWeakValueDict: + def __init__(self): + self.data = {} + self.iteration_count = 0 + + def __iter__(self): + self.iteration_count += 1 + if self.iteration_count == 1: + # First iteration raises RuntimeError (simulates GC interference) + raise RuntimeError("dictionary changed size during iteration") + # Subsequent iterations succeed + return iter(self.data) + + def __len__(self): + return len(self.data) + + mock_store = MockWeakValueDict() + test_map._store = mock_store + + # When + vertices = test_map.vertices() + + # Then + assert vertices == [] + assert mock_store.iteration_count == 2 # Should have retried once + + def test_add_vertex_cleans_stale_type_dict_entry(self, clear): + """Test that add_vertex cleans up stale __type_dict entries. + + This can happen when a weak reference was collected but the finalizer + hasn't run yet, and a new object is created with the same unique_name. + """ + # Given + test_map = Map() + + # Manually add a stale entry to __type_dict (simulating GC collected but finalizer not run) + stale_name = "StaleObject_0" + test_map._Map__type_dict[stale_name] = _EntryList() + + # Create a mock object with the same unique_name + mock_obj = MagicMock() + mock_obj.unique_name = stale_name + + # When - Adding the object should clean up the stale entry first + test_map.add_vertex(mock_obj, 'created') + + # Then - Object should be added successfully + assert stale_name in test_map._store + assert stale_name in test_map._Map__type_dict + assert test_map._Map__type_dict[stale_name].type == ['created'] + + def test_prune_key_not_in_store(self, clear): + """Test that prune handles case when key is not in _store. + + This defensive check prevents KeyError when the weak reference has + already been garbage collected but __type_dict entry remains. + """ + # Given + test_map = Map() + + # Manually add entry to __type_dict without corresponding _store entry + orphan_key = "OrphanObject_0" + test_map._Map__type_dict[orphan_key] = _EntryList() + + # When - Pruning should not raise error + test_map.prune(orphan_key) + + # Then - Entry should be removed from __type_dict + assert orphan_key not in test_map._Map__type_dict + + def test_prune_key_in_both_dicts(self, clear, base_object): + """Test that prune removes key from both _store and __type_dict.""" + # Given + unique_name = base_object.unique_name + assert unique_name in global_object.map._store + assert unique_name in global_object.map._Map__type_dict + + # When + global_object.map.prune(unique_name) + + # Then + assert unique_name not in global_object.map._Map__type_dict + # Note: _store entry may or may not exist depending on weak ref state + + def test_prune_nonexistent_key(self, clear): + """Test that prune handles nonexistent key gracefully.""" + # When/Then - Should not raise error + global_object.map.prune("nonexistent_key") + + def test_reset_type_unknown_object(self, clear): + """Test reset_type with object not in map.""" + # Given + unknown_obj = MagicMock() + unknown_obj.unique_name = "unknown" + + # When/Then - Should not raise error + global_object.map.reset_type(unknown_obj, 'argument') + + def test_change_type_unknown_object(self, clear): + """Test change_type with object not in map.""" + # Given + unknown_obj = MagicMock() + unknown_obj.unique_name = "unknown" + + # When/Then - Should not raise error + global_object.map.change_type(unknown_obj, 'argument') + + def test_find_path_start_not_in_graph(self, clear): + """Test find_path when start vertex is not in graph.""" + # When + path = global_object.map.find_path("nonexistent", "also_nonexistent") + + # Then + assert path == [] + + def test_find_all_paths_start_not_in_graph(self, clear): + """Test find_all_paths when start vertex is not in graph.""" + # When + paths = global_object.map.find_all_paths("nonexistent", "also_nonexistent") + + # Then + assert paths == [] + + def test_find_isolated_vertices(self, clear, base_object, parameter_object): + """Test finding isolated vertices (vertices with no outgoing edges).""" + # Given - No edges added, both objects are isolated + + # When + isolated = global_object.map.find_isolated_vertices() + + # Then + assert base_object.unique_name in isolated + assert parameter_object.unique_name in isolated + + def test_find_isolated_vertices_with_edges(self, clear, base_object, parameter_object): + """Test finding isolated vertices when some have edges.""" + # Given + global_object.map.add_edge(base_object, parameter_object) + + # When + isolated = global_object.map.find_isolated_vertices() + + # Then + # base_object has an edge, so it's not isolated + assert base_object.unique_name not in isolated + # parameter_object has no outgoing edges, so it's isolated + assert parameter_object.unique_name in isolated + + def test_prune_vertex_from_edge_edge_not_exists(self, clear, base_object, parameter_object): + """Test pruning edge that doesn't exist.""" + # Given - No edge added between objects + + # When/Then - Should not raise error + global_object.map.prune_vertex_from_edge(base_object, parameter_object) + + def test_prune_vertex_from_edge_parent_not_in_map(self, clear, parameter_object): + """Test pruning edge when parent is not in map.""" + # Given + unknown_obj = MagicMock() + unknown_obj.unique_name = "unknown" + + # When/Then - Should not raise error (vertex1 not in type_dict) + global_object.map.prune_vertex_from_edge(unknown_obj, parameter_object) + + def test_created_internal_property(self, clear): + """Test created_internal property.""" + # Given + obj = ObjBase(name="internal_obj") + global_object.map.change_type(obj, 'created_internal') + + # When + internal_objs = global_object.map.created_internal + + # Then + assert obj.unique_name in internal_objs + + def test_clear_empties_both_dicts(self, clear, base_object, parameter_object): + """Test that _clear() properly empties both _store and __type_dict.""" + # Given + assert len(global_object.map._store) == 2 + assert len(global_object.map._Map__type_dict) == 2 + + # When + global_object.map._clear() + + # Then + assert len(global_object.map._store) == 0 + assert len(global_object.map._Map__type_dict) == 0 + + def test_entry_list_delitem(self): + """Test _EntryList __delitem__ method.""" + # Given + entry = _EntryList() + entry.append("item1") + entry.append("item2") + entry.append("item3") + + # When + del entry[1] + + # Then + assert len(entry) == 2 + assert "item2" not in entry + assert "item1" in entry + assert "item3" in entry + + def test_entry_list_repr_with_finalizer(self): + """Test _EntryList repr when finalizer is set.""" + # Given + entry = _EntryList() + entry.type = 'created' + entry.finalizer = MagicMock() # Non-None finalizer + + # When + repr_str = str(entry) + + # Then + assert 'created' in repr_str + assert 'With a finalizer' in repr_str + + def test_entry_list_remove_type_unknown(self): + """Test removing a type that's not in known types.""" + # Given + entry = _EntryList() + entry.type = 'created' + + # When - Try to remove unknown type + entry.remove_type('unknown_type') + + # Then - Should not change anything + assert 'created' in entry.type + diff --git a/tests/unit_tests/variable/test_parameter.py b/tests/unit_tests/variable/test_parameter.py index 0ca8f85e..f2b756eb 100644 --- a/tests/unit_tests/variable/test_parameter.py +++ b/tests/unit_tests/variable/test_parameter.py @@ -10,6 +10,7 @@ from easyscience import global_object from easyscience import ObjBase + class TestParameter: @pytest.fixture def parameter(self) -> Parameter: @@ -28,7 +29,7 @@ def parameter(self) -> Parameter: parent=None, ) return parameter - + @pytest.fixture def normal_parameter(self) -> Parameter: parameter = Parameter( @@ -75,7 +76,7 @@ def test_init(self, parameter: Parameter): assert parameter._observers == [] def test_init_value_min_exception(self): - # When + # When mock_callback = MagicMock() value = -1 @@ -96,7 +97,7 @@ def test_init_value_min_exception(self): ) def test_init_value_max_exception(self): - # When + # When mock_callback = MagicMock() value = 100 @@ -118,162 +119,421 @@ def test_init_value_max_exception(self): def test_make_dependent_on(self, normal_parameter: Parameter): # When - independent_parameter = Parameter(name="independent", value=1, unit="m", variance=0.01, min=0, max=10) - + independent_parameter = Parameter( + name="independent", value=1, unit="m", variance=0.01, min=0, max=10 + ) + # Then - normal_parameter.make_dependent_on(dependency_expression='2*a', dependency_map={'a': independent_parameter}) + normal_parameter.make_dependent_on( + dependency_expression="2*a", dependency_map={"a": independent_parameter} + ) # Expect assert normal_parameter._independent == False - assert normal_parameter.dependency_expression == '2*a' - assert normal_parameter.dependency_map == {'a': independent_parameter} - self.compare_parameters(normal_parameter, 2*independent_parameter) + assert normal_parameter.dependency_expression == "2*a" + assert normal_parameter.dependency_map == {"a": independent_parameter} + self.compare_parameters(normal_parameter, 2 * independent_parameter) # Then independent_parameter.value = 2 # Expect normal_parameter.value == 4 - self.compare_parameters(normal_parameter, 2*independent_parameter) + self.compare_parameters(normal_parameter, 2 * independent_parameter) + + def test_dependent_parameter_make_dependent_on_with_desired_unit( + self, normal_parameter: Parameter + ): + # When + independent_parameter = Parameter( + name="independent", value=1, unit="m", variance=0.01, min=0, max=10 + ) + + # Then + normal_parameter.make_dependent_on( + dependency_expression="2*a", + dependency_map={"a": independent_parameter}, + desired_unit="cm", + ) + + # Expect + assert normal_parameter._independent == False + assert normal_parameter.dependency_expression == "2*a" + assert normal_parameter.dependency_map == {"a": independent_parameter} + + assert normal_parameter.value == 200 * independent_parameter.value + assert normal_parameter.unit == "cm" + assert ( + normal_parameter.variance == independent_parameter.variance * 4 * 10000 + ) # unit conversion from m to cm squared + assert normal_parameter.min == 200 * independent_parameter.min + assert normal_parameter.max == 200 * independent_parameter.max + assert normal_parameter._min.unit == "cm" + assert normal_parameter._max.unit == "cm" + + # Then + independent_parameter.value = 2 + + # Expect + assert normal_parameter.value == 200 * independent_parameter.value + assert normal_parameter.unit == "cm" + assert ( + normal_parameter.variance == independent_parameter.variance * 4 * 10000 + ) # unit conversion from m to cm squared + assert normal_parameter.min == 200 * independent_parameter.min + assert normal_parameter.max == 200 * independent_parameter.max + assert normal_parameter._min.unit == "cm" + assert normal_parameter._max.unit == "cm" + + # Then # Change the dependency expression and unit again + normal_parameter.make_dependent_on( + dependency_expression="3*a", + dependency_map={"a": independent_parameter}, + desired_unit="mm", + ) + + # Expect + assert normal_parameter._independent == False + assert normal_parameter.dependency_expression == "3*a" + assert normal_parameter.dependency_map == {"a": independent_parameter} + + assert normal_parameter.value == 3000 * independent_parameter.value + assert normal_parameter.unit == "mm" + assert ( + normal_parameter.variance == independent_parameter.variance * 9 * 1000000 + ) # unit conversion from m to mm squared + assert normal_parameter.min == 3000 * independent_parameter.min + assert normal_parameter.max == 3000 * independent_parameter.max + assert normal_parameter._min.unit == "mm" + assert normal_parameter._max.unit == "mm" + + # Then + independent_parameter.value = 2 + + # Expect + assert normal_parameter.value == 3000 * independent_parameter.value + assert normal_parameter.unit == "mm" + assert ( + normal_parameter.variance == independent_parameter.variance * 9 * 1000000 + ) # unit conversion from m to mm squared + assert normal_parameter.min == 3000 * independent_parameter.min + assert normal_parameter.max == 3000 * independent_parameter.max + assert normal_parameter._min.unit == "mm" + assert normal_parameter._max.unit == "mm" + + def test_dependent_parameter_make_dependent_on_with_desired_unit_incompatible_unit_raises( + self, normal_parameter: Parameter + ): + # When + independent_parameter = Parameter( + name="independent", value=1, unit="m", variance=0.01, min=0, max=10 + ) + + # Then Expect + with pytest.raises(UnitError, match="Failed to convert unit from m to s."): + normal_parameter.make_dependent_on( + dependency_expression="2*a", + dependency_map={"a": independent_parameter}, + desired_unit="s", + ) + + def test_dependent_parameter_make_dependent_on_with_incorrect_unit_raises( + self, normal_parameter: Parameter + ): + # When + independent_parameter = Parameter( + name="independent", value=1, unit="m", variance=0.01, min=0, max=10 + ) + + # Then Expect + with pytest.raises( + TypeError, match="`desired_unit` must be a string representing a valid unit" + ): + normal_parameter.make_dependent_on( + dependency_expression="2*a", + dependency_map={"a": independent_parameter}, + desired_unit=123, + ) def test_parameter_from_dependency(self, normal_parameter: Parameter): # When Then dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, - display_name='display_name', + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", ) # Expect assert dependent_parameter._independent == False - assert dependent_parameter.dependency_expression == '2*a' - assert dependent_parameter.dependency_map == {'a': normal_parameter} - assert dependent_parameter.name == 'dependent' - assert dependent_parameter.display_name == 'display_name' - self.compare_parameters(dependent_parameter, 2*normal_parameter) + assert dependent_parameter.dependency_expression == "2*a" + assert dependent_parameter.dependency_map == {"a": normal_parameter} + assert dependent_parameter.name == "dependent" + assert dependent_parameter.display_name == "display_name" + self.compare_parameters(dependent_parameter, 2 * normal_parameter) # Then normal_parameter.value = 2 # Expect - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) - def test_dependent_parameter_with_unique_name(self, clear, normal_parameter: Parameter): + def test_parameter_from_dependency_with_desired_unit( + self, normal_parameter: Parameter + ): # When Then dependent_parameter = Parameter.from_dependency( - name = 'dependent', + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", + desired_unit="cm", + ) + + # Expect + assert dependent_parameter._independent == False + assert dependent_parameter.dependency_expression == "2*a" + assert dependent_parameter.dependency_map == {"a": normal_parameter} + assert dependent_parameter.name == "dependent" + assert dependent_parameter.display_name == "display_name" + + assert dependent_parameter.value == 200 * normal_parameter.value + assert dependent_parameter.unit == "cm" + assert ( + dependent_parameter.variance == normal_parameter.variance * 4 * 10000 + ) # unit conversion from m to cm squared + assert dependent_parameter.min == 200 * normal_parameter.min + assert dependent_parameter.max == 200 * normal_parameter.max + assert dependent_parameter._min.unit == "cm" + assert dependent_parameter._max.unit == "cm" + + # Then + normal_parameter.value = 2 + + # Expect + assert dependent_parameter.value == 200 * normal_parameter.value + assert dependent_parameter.unit == "cm" + assert ( + dependent_parameter.variance == normal_parameter.variance * 4 * 10000 + ) # unit conversion from m to cm squared + assert dependent_parameter.min == 200 * normal_parameter.min + assert dependent_parameter.max == 200 * normal_parameter.max + assert dependent_parameter._min.unit == "cm" + assert dependent_parameter._max.unit == "cm" + + def test_parameter_from_dependency_with_desired_unit_incompatible_unit_raises( + self, normal_parameter: Parameter + ): + # When Then Expect + with pytest.raises(UnitError): + dependent_parameter = Parameter.from_dependency( + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", + desired_unit="s", + ) + + def test_dependent_parameter_with_unique_name( + self, clear, normal_parameter: Parameter + ): + # When Then + dependent_parameter = Parameter.from_dependency( + name="dependent", dependency_expression='2*"Parameter_0"', ) # Expect assert dependent_parameter.dependency_expression == '2*"Parameter_0"' - assert dependent_parameter.dependency_map == {'__Parameter_0__': normal_parameter} - self.compare_parameters(dependent_parameter, 2*normal_parameter) + assert dependent_parameter.dependency_map == { + "__Parameter_0__": normal_parameter + } + self.compare_parameters(dependent_parameter, 2 * normal_parameter) # Then normal_parameter.value = 2 # Expect - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) - def test_process_dependency_unique_names_double_quotes(self, clear, normal_parameter: Parameter): + def test_process_dependency_unique_names_double_quotes( + self, clear, normal_parameter: Parameter + ): # When - independent_parameter = Parameter(name="independent", value=1, unit="m", variance=0.01, min=0, max=10, unique_name='Special_name') + independent_parameter = Parameter( + name="independent", + value=1, + unit="m", + variance=0.01, + min=0, + max=10, + unique_name="Special_name", + ) normal_parameter._dependency_map = {} # Then - normal_parameter._process_dependency_unique_names(dependency_expression='2*"Special_name"') + normal_parameter._process_dependency_unique_names( + dependency_expression='2*"Special_name"' + ) # Expect - assert normal_parameter._dependency_map == {'__Special_name__': independent_parameter} - assert normal_parameter._clean_dependency_string == '2*__Special_name__' - - def test_process_dependency_unique_names_single_quotes(self, clear, normal_parameter: Parameter): + assert normal_parameter._dependency_map == { + "__Special_name__": independent_parameter + } + assert normal_parameter._clean_dependency_string == "2*__Special_name__" + + def test_process_dependency_unique_names_single_quotes( + self, clear, normal_parameter: Parameter + ): # When - independent_parameter = Parameter(name="independent", value=1, unit="m", variance=0.01, min=0, max=10, unique_name='Special_name') - independent_parameter_2 = Parameter(name="independent_2", value=1, unit="m", variance=0.01, min=0, max=10, unique_name='Special_name_2') + independent_parameter = Parameter( + name="independent", + value=1, + unit="m", + variance=0.01, + min=0, + max=10, + unique_name="Special_name", + ) + independent_parameter_2 = Parameter( + name="independent_2", + value=1, + unit="m", + variance=0.01, + min=0, + max=10, + unique_name="Special_name_2", + ) normal_parameter._dependency_map = {} # Then - normal_parameter._process_dependency_unique_names(dependency_expression="'Special_name' + 'Special_name_2'") + normal_parameter._process_dependency_unique_names( + dependency_expression="'Special_name' + 'Special_name_2'" + ) # Expect - assert normal_parameter._dependency_map == {'__Special_name__': independent_parameter, - '__Special_name_2__': independent_parameter_2} - assert normal_parameter._clean_dependency_string == '__Special_name__ + __Special_name_2__' + assert normal_parameter._dependency_map == { + "__Special_name__": independent_parameter, + "__Special_name_2__": independent_parameter_2, + } + assert ( + normal_parameter._clean_dependency_string + == "__Special_name__ + __Special_name_2__" + ) - def test_process_dependency_unique_names_exception_unique_name_does_not_exist(self, clear, normal_parameter: Parameter): + def test_process_dependency_unique_names_exception_unique_name_does_not_exist( + self, clear, normal_parameter: Parameter + ): # When normal_parameter._dependency_map = {} # Then Expect - with pytest.raises(ValueError, match='A Parameter with unique_name Special_name does not exist. Please check your dependency expression.'): - normal_parameter._process_dependency_unique_names(dependency_expression='2*"Special_name"') + with pytest.raises( + ValueError, + match="A Parameter with unique_name Special_name does not exist. Please check your dependency expression.", + ): + normal_parameter._process_dependency_unique_names( + dependency_expression='2*"Special_name"' + ) - def test_process_dependency_unique_names_exception_not_a_descriptorNumber(self, clear, normal_parameter: Parameter): + def test_process_dependency_unique_names_exception_not_a_descriptorNumber( + self, clear, normal_parameter: Parameter + ): # When normal_parameter._dependency_map = {} - base_obj = ObjBase(name='ObjBase', unique_name='base_obj') + base_obj = ObjBase(name="ObjBase", unique_name="base_obj") # Then Expect - with pytest.raises(ValueError, match='The object with unique_name base_obj is not a Parameter or DescriptorNumber. Please check your dependency expression.'): - normal_parameter._process_dependency_unique_names(dependency_expression='2*"base_obj"') - - @pytest.mark.parametrize("dependency_expression, dependency_map", [ - (2, {'a': Parameter(name='a', value=1)}), - ('2*a', ['a', Parameter(name='a', value=1)]), - ('2*a', {4: Parameter(name='a', value=1)}), - ('2*a', {'a': ObjBase(name='a')}), - ], ids=["dependency_expression_not_a_string", "dependency_map_not_a_dict", "dependency_map_keys_not_strings", "dependency_map_values_not_descriptor_number"]) - def test_parameter_from_dependency_input_exceptions(self, dependency_expression, dependency_map): + with pytest.raises( + ValueError, + match="The object with unique_name base_obj is not a Parameter or DescriptorNumber. Please check your dependency expression.", + ): + normal_parameter._process_dependency_unique_names( + dependency_expression='2*"base_obj"' + ) + + @pytest.mark.parametrize( + "dependency_expression, dependency_map", + [ + (2, {"a": Parameter(name="a", value=1)}), + ("2*a", ["a", Parameter(name="a", value=1)]), + ("2*a", {4: Parameter(name="a", value=1)}), + ("2*a", {"a": ObjBase(name="a")}), + ], + ids=[ + "dependency_expression_not_a_string", + "dependency_map_not_a_dict", + "dependency_map_keys_not_strings", + "dependency_map_values_not_descriptor_number", + ], + ) + def test_parameter_from_dependency_input_exceptions( + self, dependency_expression, dependency_map + ): # When Then Expect with pytest.raises(TypeError): Parameter.from_dependency( - name = 'dependent', - dependency_expression=dependency_expression, + name="dependent", + dependency_expression=dependency_expression, dependency_map=dependency_map, ) - @pytest.mark.parametrize("dependency_expression, error", [ - ('2*a + b', NameError), - ('2*a + 3*', SyntaxError), - ('2 + 2', TypeError), - ('2*"special_name"', ValueError), - ], ids=["parameter_not_in_map", "invalid_dependency_expression", "result_not_a_descriptor_number", "unique_name_does_not_exist"]) - def test_parameter_make_dependent_on_exceptions_cleanup_previously_dependent(self, normal_parameter, dependency_expression, error): - # When - independent_parameter = Parameter(name='independent', value=10, unit='s', variance=0.02) + @pytest.mark.parametrize( + "dependency_expression, error", + [ + ("2*a + b", NameError), + ("2*a + 3*", SyntaxError), + ("2 + 2", TypeError), + ('2*"special_name"', ValueError), + ], + ids=[ + "parameter_not_in_map", + "invalid_dependency_expression", + "result_not_a_descriptor_number", + "unique_name_does_not_exist", + ], + ) + def test_parameter_make_dependent_on_exceptions_cleanup_previously_dependent( + self, normal_parameter, dependency_expression, error + ): + # When + independent_parameter = Parameter( + name="independent", value=10, unit="s", variance=0.02 + ) dependent_parameter = Parameter.from_dependency( - name= 'dependent', - dependency_expression='best', - dependency_map={'best': independent_parameter} - ) + name="dependent", + dependency_expression="best", + dependency_map={"best": independent_parameter}, + ) # Then Expect # Check that the correct error is raised with pytest.raises(error): dependent_parameter.make_dependent_on( - dependency_expression=dependency_expression, - dependency_map={'a': normal_parameter}, - ) + dependency_expression=dependency_expression, + dependency_map={"a": normal_parameter}, + ) # Check that everything is properly cleaned up assert normal_parameter._observers == [] assert dependent_parameter.independent == False - assert dependent_parameter.dependency_expression == 'best' - assert dependent_parameter.dependency_map == {'best': independent_parameter} + assert dependent_parameter.dependency_expression == "best" + assert dependent_parameter.dependency_map == {"best": independent_parameter} independent_parameter.value = 50 self.compare_parameters(dependent_parameter, independent_parameter) - def test_parameter_make_dependent_on_exceptions_cleanup_previously_independent(self, normal_parameter): - # When - independent_parameter = Parameter(name='independent', value=10, unit='s', variance=0.02) + def test_parameter_make_dependent_on_exceptions_cleanup_previously_independent( + self, normal_parameter + ): + # When + independent_parameter = Parameter( + name="independent", value=10, unit="s", variance=0.02 + ) # Then Expect # Check that the correct error is raised with pytest.raises(NameError): independent_parameter.make_dependent_on( - dependency_expression='2*a + b', - dependency_map={'a': normal_parameter}, - ) + dependency_expression="2*a + b", + dependency_map={"a": normal_parameter}, + ) # Check that everything is properly cleaned up assert normal_parameter._observers == [] assert independent_parameter.independent == True @@ -283,84 +543,88 @@ def test_parameter_make_dependent_on_exceptions_cleanup_previously_independent(s def test_dependent_parameter_updates(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect normal_parameter.value = 2 - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) normal_parameter.variance = 0.02 - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) normal_parameter.error = 0.2 - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) normal_parameter.convert_unit("cm") - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) normal_parameter.min = 1 - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) normal_parameter.max = 300 - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) def test_dependent_parameter_indirect_updates(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) dependent_parameter_2 = Parameter.from_dependency( - name = 'dependent_2', - dependency_expression='10*a', - dependency_map={'a': normal_parameter}, + name="dependent_2", + dependency_expression="10*a", + dependency_map={"a": normal_parameter}, ) dependent_parameter_3 = Parameter.from_dependency( - name = 'dependent_3', - dependency_expression='b+c', - dependency_map={'b': dependent_parameter, 'c': dependent_parameter_2}, + name="dependent_3", + dependency_expression="b+c", + dependency_map={"b": dependent_parameter, "c": dependent_parameter_2}, ) # Then normal_parameter.value = 2 # Expect - self.compare_parameters(dependent_parameter, 2*normal_parameter) - self.compare_parameters(dependent_parameter_2, 10*normal_parameter) - self.compare_parameters(dependent_parameter_3, 2*normal_parameter + 10*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) + self.compare_parameters(dependent_parameter_2, 10 * normal_parameter) + self.compare_parameters( + dependent_parameter_3, 2 * normal_parameter + 10 * normal_parameter + ) def test_dependent_parameter_cyclic_dependencies(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) dependent_parameter_2 = Parameter.from_dependency( - name = 'dependent_2', - dependency_expression='2*b', - dependency_map={'b': dependent_parameter}, + name="dependent_2", + dependency_expression="2*b", + dependency_map={"b": dependent_parameter}, ) # Then Expect with pytest.raises(RuntimeError): - normal_parameter.make_dependent_on(dependency_expression='2*c', dependency_map={'c': dependent_parameter_2}) + normal_parameter.make_dependent_on( + dependency_expression="2*c", dependency_map={"c": dependent_parameter_2} + ) # Check that everything is properly cleaned up assert dependent_parameter_2._observers == [] assert normal_parameter.independent == True assert normal_parameter.value == 1 normal_parameter.value = 50 - self.compare_parameters(dependent_parameter_2, 4*normal_parameter) + self.compare_parameters(dependent_parameter_2, 4 * normal_parameter) def test_dependent_parameter_logical_dependency(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='a if a.value > 0 else -a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="a if a.value > 0 else -a", + dependency_map={"a": normal_parameter}, ) self.compare_parameters(dependent_parameter, normal_parameter) @@ -372,51 +636,102 @@ def test_dependent_parameter_logical_dependency(self, normal_parameter: Paramete def test_dependent_parameter_return_is_descriptor_number(self): # When - descriptor_number = DescriptorNumber(name='descriptor', value=1, unit='m', variance=0.01) - + descriptor_number = DescriptorNumber( + name="descriptor", value=1, unit="m", variance=0.01 + ) + # Then dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*descriptor', - dependency_map={'descriptor': descriptor_number}, + name="dependent", + dependency_expression="2*descriptor", + dependency_map={"descriptor": descriptor_number}, ) # Expect - assert dependent_parameter.value == 2*descriptor_number.value + assert dependent_parameter.value == 2 * descriptor_number.value assert dependent_parameter.unit == descriptor_number.unit assert dependent_parameter.variance == 0.04 - assert dependent_parameter.min == 2*descriptor_number.value - assert dependent_parameter.max == 2*descriptor_number.value + assert dependent_parameter.min == 2 * descriptor_number.value + assert dependent_parameter.max == 2 * descriptor_number.value + + def test_dependent_parameter_division_expression_order(self): + """Test that division expressions work regardless of operand order. + + This is a regression test for https://github.com/easyscience/corelib/issues/190 + where 'a / b' would fail with an observer notification error while '1/b * a' + would work correctly. + """ + # When + angstrom = DescriptorNumber("angstrom", 1e-10, unit="m") + jump_length = Parameter( + name="jump_length", + value=float(1.0), + fixed=False, + unit="angstrom", + ) + + expression = "jump_length / angstrom" + dependency_map = { + "jump_length": jump_length, + "angstrom": angstrom, + } + + # Calculate the expected result from direct division + expected_result = jump_length / angstrom + expected_value = expected_result.value + + # Then - This should not raise an error + dependent_param = Parameter(name='a', value=1.0) + dependent_param.make_dependent_on( + dependency_expression=expression, + dependency_map=dependency_map, + ) + + # Expect - The dependent parameter should have the same value as the direct division + assert dependent_param.value == pytest.approx(expected_value) + + # Also test the alternative expression that previously worked + expression_alt = "1/angstrom * jump_length" + dependent_param_alt = Parameter(name='b', value=1.0) + dependent_param_alt.make_dependent_on( + dependency_expression=expression_alt, + dependency_map=dependency_map, + ) + assert dependent_param_alt.value == pytest.approx(expected_value) def test_dependent_parameter_overwrite_dependency(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) # Then - normal_parameter_2 = Parameter(name='a2', value=-2, unit='m', variance=0.01, min=-10, max=0) - dependent_parameter.make_dependent_on(dependency_expression='3*a2', dependency_map={'a2': normal_parameter_2}) + normal_parameter_2 = Parameter( + name="a2", value=-2, unit="m", variance=0.01, min=-10, max=0 + ) + dependent_parameter.make_dependent_on( + dependency_expression="3*a2", dependency_map={"a2": normal_parameter_2} + ) normal_parameter.value = 3 # Expect - self.compare_parameters(dependent_parameter, 3*normal_parameter_2) - assert dependent_parameter.dependency_expression == '3*a2' - assert dependent_parameter.dependency_map == {'a2': normal_parameter_2} + self.compare_parameters(dependent_parameter, 3 * normal_parameter_2) + assert dependent_parameter.dependency_expression == "3*a2" + assert dependent_parameter.dependency_map == {"a2": normal_parameter_2} assert normal_parameter._observers == [] - + def test_make_independent(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) assert dependent_parameter.independent == False - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) # Then dependent_parameter.make_independent() @@ -437,46 +752,52 @@ def test_independent_setter(self, normal_parameter: Parameter): with pytest.raises(AttributeError): normal_parameter.independent = False - def test_independent_parameter_dependency_expression(self, normal_parameter: Parameter): + def test_independent_parameter_dependency_expression( + self, normal_parameter: Parameter + ): # When Then Expect with pytest.raises(AttributeError): normal_parameter.dependency_expression - def test_dependent_parameter_dependency_expression_setter(self, normal_parameter: Parameter): + def test_dependent_parameter_dependency_expression_setter( + self, normal_parameter: Parameter + ): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect with pytest.raises(AttributeError): - dependent_parameter.dependency_expression = '3*a' + dependent_parameter.dependency_expression = "3*a" def test_independent_parameter_dependency_map(self, normal_parameter: Parameter): # When Then Expect with pytest.raises(AttributeError): normal_parameter.dependency_map - def test_dependent_parameter_dependency_map_setter(self, normal_parameter: Parameter): + def test_dependent_parameter_dependency_map_setter( + self, normal_parameter: Parameter + ): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect with pytest.raises(AttributeError): - dependent_parameter.dependency_map = {'a': normal_parameter} + dependent_parameter.dependency_map = {"a": normal_parameter} def test_min(self, parameter: Parameter): # When Then Expect assert parameter.min == 0 def test_set_min(self, parameter: Parameter): - # When Then + # When Then self.mock_callback.fget.return_value = 1.0 # Ensure fget returns a scalar value parameter.min = 0.1 @@ -487,9 +808,9 @@ def test_set_min(self, parameter: Parameter): def test_set_min_dependent_parameter(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect @@ -502,7 +823,7 @@ def test_set_min_exception(self, parameter: Parameter): parameter.min = 10 def test_set_max(self, parameter: Parameter): - # When Then + # When Then parameter.max = 10 # Expect @@ -511,9 +832,9 @@ def test_set_max(self, parameter: Parameter): def test_set_max_dependent_parameter(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect @@ -535,8 +856,109 @@ def test_convert_unit(self, parameter: Parameter): assert parameter._max.value == 10000 assert parameter._max.unit == "mm" + def test_set_desired_unit(self, normal_parameter: Parameter): + # When Then + dependent_parameter = Parameter.from_dependency( + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", + ) + + # Then + dependent_parameter.set_desired_unit("cm") + + # Expect + + assert dependent_parameter.value == 200 * normal_parameter.value + assert dependent_parameter.unit == "cm" + assert ( + dependent_parameter.variance == normal_parameter.variance * 4 * 10000 + ) # unit conversion from m to cm squared + assert dependent_parameter.min == 200 * normal_parameter.min + assert dependent_parameter.max == 200 * normal_parameter.max + assert dependent_parameter._min.unit == "cm" + assert dependent_parameter._max.unit == "cm" + + # Then + normal_parameter.value = 2 + + # Expect + assert dependent_parameter.value == 200 * normal_parameter.value + assert dependent_parameter.unit == "cm" + assert ( + dependent_parameter.variance == normal_parameter.variance * 4 * 10000 + ) # unit conversion from m to cm squared + assert dependent_parameter.min == 200 * normal_parameter.min + assert dependent_parameter.max == 200 * normal_parameter.max + assert dependent_parameter._min.unit == "cm" + assert dependent_parameter._max.unit == "cm" + + def test_set_desired_unit_incompatible_units_raises( + self, normal_parameter: Parameter + ): + # When Then + dependent_parameter = Parameter.from_dependency( + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", + ) + + # Then Expect + with pytest.raises( + UnitError, + match="Failed to convert unit from m to s.", + ): + dependent_parameter.set_desired_unit("s") + + def test_set_desired_unit_None(self, normal_parameter: Parameter): + # When Then + dependent_parameter = Parameter.from_dependency( + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", + desired_unit="cm", + ) + + # EXPECT + assert dependent_parameter.unit == "cm" + + # Then + dependent_parameter.set_desired_unit(None) + + # EXPECT + assert dependent_parameter.value == 2 * normal_parameter.value + assert dependent_parameter.unit == "m" + + def test_set_desired_unit_independent_parameter_raises( + self, normal_parameter: Parameter + ): + # When Then Expect + with pytest.raises( + AttributeError, + match="This is an independent parameter, desired unit can only be set for dependent parameters.", + ): + normal_parameter.set_desired_unit("cm") + + def test_set_desired_unit_incorrect_unit_type_raises( + self, normal_parameter: Parameter + ): + # When Then + dependent_parameter = Parameter.from_dependency( + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", + ) + + # Then Expect + with pytest.raises(TypeError, match="must be a string"): + dependent_parameter.set_desired_unit(5) + def test_set_fixed(self, parameter: Parameter): - # When Then + # When Then parameter.fixed = True # Expect @@ -545,9 +967,9 @@ def test_set_fixed(self, parameter: Parameter): def test_set_fixed_dependent_parameter(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect @@ -563,9 +985,9 @@ def test_set_fixed_exception(self, parameter: Parameter, fixed): def test_error(self, parameter: Parameter): # When Then Expect assert parameter.error == 0.1 - + def test_set_error(self, parameter: Parameter): - # When + # When parameter.error = 10 # Then Expect @@ -584,14 +1006,20 @@ def test_set_error_exception(self, parameter: Parameter): def test_repr(self, parameter: Parameter): # When Then Expect - assert repr(parameter) == "" + assert ( + repr(parameter) + == "" + ) def test_repr_fixed(self, parameter: Parameter): - # When + # When parameter.fixed = True # Then Expect - assert repr(parameter) == "" + assert ( + repr(parameter) + == "" + ) def test_value_match_callback(self, parameter: Parameter): # When @@ -600,7 +1028,7 @@ def test_value_match_callback(self, parameter: Parameter): # Then Expect assert parameter.value == 1.0 assert parameter._callback.fget.call_count == 1 - + def test_value_no_match_callback(self, parameter: Parameter): # When self.mock_callback.fget.return_value = 2.0 @@ -621,14 +1049,14 @@ def test_set_value(self, parameter: Parameter): # Expect parameter._callback.fset.assert_called_with(2) assert parameter._callback.fset.call_count == 1 - assert parameter._scalar == sc.scalar(2, unit='m') + assert parameter._scalar == sc.scalar(2, unit="m") def test_set_value_dependent_parameter(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect @@ -638,14 +1066,14 @@ def test_set_value_dependent_parameter(self, normal_parameter: Parameter): def test_set_full_value(self, parameter: Parameter): # When Then Expect with pytest.raises(AttributeError): - parameter.full_value = sc.scalar(2, unit='s') + parameter.full_value = sc.scalar(2, unit="s") def test_set_variance_dependent_parameter(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect @@ -655,9 +1083,9 @@ def test_set_variance_dependent_parameter(self, normal_parameter: Parameter): def test_set_error_dependent_parameter(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect @@ -684,13 +1112,35 @@ def test_copy(self, parameter: Parameter): assert parameter_copy._display_name == parameter._display_name assert parameter_copy._independent == parameter._independent - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (Parameter("test", 2, "m", 0.01, -10, 20), Parameter("name + test", 3, "m", 0.02, -10, 30), Parameter("test + name", 3, "m", 0.02, -10, 30)), - (Parameter("test", 2, "m", 0.01), Parameter("name + test", 3, "m", 0.02, min=-np.inf, max=np.inf),Parameter("test + name", 3, "m", 0.02, min=-np.inf, max=np.inf)), - (Parameter("test", 2, "cm", 0.01, -10, 10), Parameter("name + test", 1.02, "m", 0.010001, -0.1, 10.1), Parameter("test + name", 102, "cm", 100.01, -10, 1010))], - ids=["regular", "no_bounds", "unit_conversion"]) - def test_addition_with_parameter(self, parameter : Parameter, test : Parameter, expected : Parameter, expected_reverse : Parameter): - # When + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + Parameter("test", 2, "m", 0.01, -10, 20), + Parameter("name + test", 3, "m", 0.02, -10, 30), + Parameter("test + name", 3, "m", 0.02, -10, 30), + ), + ( + Parameter("test", 2, "m", 0.01), + Parameter("name + test", 3, "m", 0.02, min=-np.inf, max=np.inf), + Parameter("test + name", 3, "m", 0.02, min=-np.inf, max=np.inf), + ), + ( + Parameter("test", 2, "cm", 0.01, -10, 10), + Parameter("name + test", 1.02, "m", 0.010001, -0.1, 10.1), + Parameter("test + name", 102, "cm", 100.01, -10, 1010), + ), + ], + ids=["regular", "no_bounds", "unit_conversion"], + ) + def test_addition_with_parameter( + self, + parameter: Parameter, + test: Parameter, + expected: Parameter, + expected_reverse: Parameter, + ): + # When parameter._callback = property() # Then @@ -705,7 +1155,7 @@ def test_addition_with_parameter(self, parameter : Parameter, test : Parameter, assert result.min == expected.min assert result.max == expected.max - assert result_reverse.name == result_reverse.unique_name + assert result_reverse.name == result_reverse.unique_name assert result_reverse.value == expected_reverse.value assert result_reverse.unit == expected_reverse.unit assert result_reverse.variance == expected_reverse.variance @@ -737,10 +1187,12 @@ def test_addition_with_scalar(self): assert result_reverse.min == 1.0 assert result_reverse.max == 11.0 - def test_addition_with_descriptor_number(self, parameter : Parameter): - # When + def test_addition_with_descriptor_number(self, parameter: Parameter): + # When parameter._callback = property() - descriptor_number = DescriptorNumber(name="test", value=1, variance=0.1, unit="cm") + descriptor_number = DescriptorNumber( + name="test", value=1, variance=0.1, unit="cm" + ) # Then result = parameter + descriptor_number @@ -766,21 +1218,54 @@ def test_addition_with_descriptor_number(self, parameter : Parameter): assert parameter.unit == "m" assert descriptor_number.unit == "cm" - @pytest.mark.parametrize("test", [1.0, Parameter("test", 2, "s",)], ids=["add_scalar_to_unit", "incompatible_units"]) - def test_addition_exception(self, parameter : Parameter, test): + @pytest.mark.parametrize( + "test", + [ + 1.0, + Parameter( + "test", + 2, + "s", + ), + ], + ids=["add_scalar_to_unit", "incompatible_units"], + ) + def test_addition_exception(self, parameter: Parameter, test): # When Then Expect with pytest.raises(UnitError): result = parameter + test with pytest.raises(UnitError): result_reverse = test + parameter - - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (Parameter("test", 2, "m", 0.01, -20, 20), Parameter("name - test", -1, "m", 0.02, -20, 30), Parameter("test - name", 1, "m", 0.02, -30, 20)), - (Parameter("test", 2, "m", 0.01), Parameter("name - test", -1, "m", 0.02, min=-np.inf, max=np.inf),Parameter("test - name", 1, "m", 0.02, min=-np.inf, max=np.inf)), - (Parameter("test", 2, "cm", 0.01, -10, 10), Parameter("name - test", 0.98, "m", 0.010001, -0.1, 10.1), Parameter("test - name", -98, "cm", 100.01, -1010, 10))], - ids=["regular", "no_bounds", "unit_conversion"]) - def test_subtraction_with_parameter(self, parameter : Parameter, test : Parameter, expected : Parameter, expected_reverse : Parameter): - # When + + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + Parameter("test", 2, "m", 0.01, -20, 20), + Parameter("name - test", -1, "m", 0.02, -20, 30), + Parameter("test - name", 1, "m", 0.02, -30, 20), + ), + ( + Parameter("test", 2, "m", 0.01), + Parameter("name - test", -1, "m", 0.02, min=-np.inf, max=np.inf), + Parameter("test - name", 1, "m", 0.02, min=-np.inf, max=np.inf), + ), + ( + Parameter("test", 2, "cm", 0.01, -10, 10), + Parameter("name - test", 0.98, "m", 0.010001, -0.1, 10.1), + Parameter("test - name", -98, "cm", 100.01, -1010, 10), + ), + ], + ids=["regular", "no_bounds", "unit_conversion"], + ) + def test_subtraction_with_parameter( + self, + parameter: Parameter, + test: Parameter, + expected: Parameter, + expected_reverse: Parameter, + ): + # When parameter._callback = property() # Then @@ -806,7 +1291,9 @@ def test_subtraction_with_parameter(self, parameter : Parameter, test : Paramete def test_subtraction_with_parameter_nan_cases(self): # When - parameter = Parameter(name="name", value=1, variance=0.01, min=-np.inf, max=np.inf) + parameter = Parameter( + name="name", value=1, variance=0.01, min=-np.inf, max=np.inf + ) test = Parameter(name="test", value=2, variance=0.01, min=-np.inf, max=np.inf) # Then @@ -851,10 +1338,12 @@ def test_subtraction_with_scalar(self): assert result_reverse.min == -9.0 assert result_reverse.max == 1.0 - def test_subtraction_with_descriptor_number(self, parameter : Parameter): - # When + def test_subtraction_with_descriptor_number(self, parameter: Parameter): + # When parameter._callback = property() - descriptor_number = DescriptorNumber(name="test", value=1, variance=0.1, unit="cm") + descriptor_number = DescriptorNumber( + name="test", value=1, variance=0.1, unit="cm" + ) # Then result = parameter - descriptor_number @@ -880,21 +1369,54 @@ def test_subtraction_with_descriptor_number(self, parameter : Parameter): assert parameter.unit == "m" assert descriptor_number.unit == "cm" - @pytest.mark.parametrize("test", [1.0, Parameter("test", 2, "s",)], ids=["sub_scalar_to_unit", "incompatible_units"]) - def test_subtraction_exception(self, parameter : Parameter, test): + @pytest.mark.parametrize( + "test", + [ + 1.0, + Parameter( + "test", + 2, + "s", + ), + ], + ids=["sub_scalar_to_unit", "incompatible_units"], + ) + def test_subtraction_exception(self, parameter: Parameter, test): # When Then Expect with pytest.raises(UnitError): result = parameter - test with pytest.raises(UnitError): result_reverse = test - parameter - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (Parameter("test", 2, "m", 0.01, -10, 20), Parameter("name * test", 2, "m^2", 0.05, -100, 200), Parameter("test * name", 2, "m^2", 0.05, -100, 200)), - (Parameter("test", 2, "m", 0.01), Parameter("name * test", 2, "m^2", 0.05, min=-np.inf, max=np.inf), Parameter("test * name", 2, "m^2", 0.05, min=-np.inf, max=np.inf)), - (Parameter("test", 2, "dm", 0.01, -10, 20), Parameter("name * test", 0.2, "m^2", 0.0005, -10, 20), Parameter("test * name", 0.2, "m^2", 0.0005, -10, 20))], - ids=["regular", "no_bounds", "base_unit_conversion"]) - def test_multiplication_with_parameter(self, parameter : Parameter, test : Parameter, expected : Parameter, expected_reverse : Parameter): - # When + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + Parameter("test", 2, "m", 0.01, -10, 20), + Parameter("name * test", 2, "m^2", 0.05, -100, 200), + Parameter("test * name", 2, "m^2", 0.05, -100, 200), + ), + ( + Parameter("test", 2, "m", 0.01), + Parameter("name * test", 2, "m^2", 0.05, min=-np.inf, max=np.inf), + Parameter("test * name", 2, "m^2", 0.05, min=-np.inf, max=np.inf), + ), + ( + Parameter("test", 2, "dm", 0.01, -10, 20), + Parameter("name * test", 0.2, "m^2", 0.0005, -10, 20), + Parameter("test * name", 0.2, "m^2", 0.0005, -10, 20), + ), + ], + ids=["regular", "no_bounds", "base_unit_conversion"], + ) + def test_multiplication_with_parameter( + self, + parameter: Parameter, + test: Parameter, + expected: Parameter, + expected_reverse: Parameter, + ): + # When parameter._callback = property() # Then @@ -916,11 +1438,25 @@ def test_multiplication_with_parameter(self, parameter : Parameter, test : Param assert result_reverse.min == expected_reverse.min assert result_reverse.max == expected_reverse.max - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (Parameter("test", 0, "", 0.01, -10, 0), Parameter("name * test", 0.0, "dimensionless", 0.01, -np.inf, 0), Parameter("test * name", 0, "dimensionless", 0.01, -np.inf, 0)), - (Parameter("test", 0, "", 0.01, 0, 10), Parameter("name * test", 0.0, "dimensionless", 0.01, 0, np.inf), Parameter("test * name", 0, "dimensionless", 0.01, 0, np.inf))], - ids=["zero_min", "zero_max"]) - def test_multiplication_with_parameter_nan_cases(self, test, expected, expected_reverse): + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + Parameter("test", 0, "", 0.01, -10, 0), + Parameter("name * test", 0.0, "dimensionless", 0.01, -np.inf, 0), + Parameter("test * name", 0, "dimensionless", 0.01, -np.inf, 0), + ), + ( + Parameter("test", 0, "", 0.01, 0, 10), + Parameter("name * test", 0.0, "dimensionless", 0.01, 0, np.inf), + Parameter("test * name", 0, "dimensionless", 0.01, 0, np.inf), + ), + ], + ids=["zero_min", "zero_max"], + ) + def test_multiplication_with_parameter_nan_cases( + self, test, expected, expected_reverse + ): # When parameter = Parameter(name="name", value=1, variance=0.01, min=1, max=np.inf) @@ -943,12 +1479,26 @@ def test_multiplication_with_parameter_nan_cases(self, test, expected, expected_ assert result_reverse.min == expected_reverse.min assert result_reverse.max == expected_reverse.max - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (DescriptorNumber(name="test", value=2, variance=0.1, unit="cm"), Parameter("name * test", 2, "dm^2", 0.14, 0, 20), Parameter("test * name", 2, "dm^2", 0.14, 0, 20)), - (DescriptorNumber(name="test", value=0, variance=0.1, unit="cm"), DescriptorNumber("name * test", 0, "dm^2", 0.1), DescriptorNumber("test * name", 0, "dm^2", 0.1))], - ids=["regular", "zero_value"]) - def test_multiplication_with_descriptor_number(self, parameter : Parameter, test, expected, expected_reverse): - # When + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + DescriptorNumber(name="test", value=2, variance=0.1, unit="cm"), + Parameter("name * test", 2, "dm^2", 0.14, 0, 20), + Parameter("test * name", 2, "dm^2", 0.14, 0, 20), + ), + ( + DescriptorNumber(name="test", value=0, variance=0.1, unit="cm"), + DescriptorNumber("name * test", 0, "dm^2", 0.1), + DescriptorNumber("test * name", 0, "dm^2", 0.1), + ), + ], + ids=["regular", "zero_value"], + ) + def test_multiplication_with_descriptor_number( + self, parameter: Parameter, test, expected, expected_reverse + ): + # When parameter._callback = property() # Then @@ -974,12 +1524,26 @@ def test_multiplication_with_descriptor_number(self, parameter : Parameter, test assert result_reverse.min == expected_reverse.min assert result_reverse.max == expected_reverse.max - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (2, Parameter("name * 2", 2, "m", 0.04, 0, 20), Parameter("2 * name", 2, "m", 0.04, 0, 20)), - (0, DescriptorNumber("name * 0", 0, "m", 0), DescriptorNumber("0 * name", 0, "m", 0))], - ids=["regular", "zero_value"]) - def test_multiplication_with_scalar(self, parameter : Parameter, test, expected, expected_reverse): - # When + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + 2, + Parameter("name * 2", 2, "m", 0.04, 0, 20), + Parameter("2 * name", 2, "m", 0.04, 0, 20), + ), + ( + 0, + DescriptorNumber("name * 0", 0, "m", 0), + DescriptorNumber("0 * name", 0, "m", 0), + ), + ], + ids=["regular", "zero_value"], + ) + def test_multiplication_with_scalar( + self, parameter: Parameter, test, expected, expected_reverse + ): + # When parameter._callback = property() # Then @@ -1004,13 +1568,31 @@ def test_multiplication_with_scalar(self, parameter : Parameter, test, expected, assert result_reverse.min == expected_reverse.min assert result_reverse.max == expected_reverse.max - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (Parameter("test", 2, "s", 0.01, -10, 20), Parameter("name / test", 0.5, "m/s", 0.003125, -np.inf, np.inf), Parameter("test / name", 2, "s/m", 0.05, -np.inf, np.inf)), - (Parameter("test", 2, "s", 0.01, 0, 20), Parameter("name / test", 0.5, "m/s", 0.003125, 0.0, np.inf), Parameter("test / name", 2, "s/m", 0.05, 0.0, np.inf)), - (Parameter("test", -2, "s", 0.01, -10, 0), Parameter("name / test", -0.5, "m/s", 0.003125, -np.inf, 0.0), Parameter("test / name", -2, "s/m", 0.05, -np.inf, 0.0))], - ids=["crossing_zero", "only_positive", "only_negative"]) - def test_division_with_parameter(self, parameter : Parameter, test, expected, expected_reverse): - # When + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + Parameter("test", 2, "s", 0.01, -10, 20), + Parameter("name / test", 0.5, "m/s", 0.003125, -np.inf, np.inf), + Parameter("test / name", 2, "s/m", 0.05, -np.inf, np.inf), + ), + ( + Parameter("test", 2, "s", 0.01, 0, 20), + Parameter("name / test", 0.5, "m/s", 0.003125, 0.0, np.inf), + Parameter("test / name", 2, "s/m", 0.05, 0.0, np.inf), + ), + ( + Parameter("test", -2, "s", 0.01, -10, 0), + Parameter("name / test", -0.5, "m/s", 0.003125, -np.inf, 0.0), + Parameter("test / name", -2, "s/m", 0.05, -np.inf, 0.0), + ), + ], + ids=["crossing_zero", "only_positive", "only_negative"], + ) + def test_division_with_parameter( + self, parameter: Parameter, test, expected, expected_reverse + ): + # When parameter._callback = property() # Then @@ -1034,11 +1616,31 @@ def test_division_with_parameter(self, parameter : Parameter, test, expected, ex assert result_reverse.min == expected_reverse.min assert result_reverse.max == expected_reverse.max - @pytest.mark.parametrize("first, second, expected", [ - (Parameter("name", 1, "m", 0.01, -10, 20), Parameter("test", -2, "s", 0.01, -10, 0), Parameter("name / test", -0.5, "m/s", 0.003125, -np.inf, np.inf)), - (Parameter("name", -10, "m", 0.01, -20, -10), Parameter("test", -2, "s", 0.01, -10, 0), Parameter("name / test", 5.0, "m/s", 0.065, 1, np.inf)), - (Parameter("name", 10, "m", 0.01, 10, 20), Parameter("test", -20, "s", 0.01, -20, -10), Parameter("name / test", -0.5, "m/s", 3.125e-5, -2, -0.5))], - ids=["first_crossing_zero_second_negative_0", "both_negative_second_negative_0", "finite_limits"]) + @pytest.mark.parametrize( + "first, second, expected", + [ + ( + Parameter("name", 1, "m", 0.01, -10, 20), + Parameter("test", -2, "s", 0.01, -10, 0), + Parameter("name / test", -0.5, "m/s", 0.003125, -np.inf, np.inf), + ), + ( + Parameter("name", -10, "m", 0.01, -20, -10), + Parameter("test", -2, "s", 0.01, -10, 0), + Parameter("name / test", 5.0, "m/s", 0.065, 1, np.inf), + ), + ( + Parameter("name", 10, "m", 0.01, 10, 20), + Parameter("test", -20, "s", 0.01, -20, -10), + Parameter("name / test", -0.5, "m/s", 3.125e-5, -2, -0.5), + ), + ], + ids=[ + "first_crossing_zero_second_negative_0", + "both_negative_second_negative_0", + "finite_limits", + ], + ) def test_division_with_parameter_remaining_cases(self, first, second, expected): # When Then result = first / second @@ -1051,12 +1653,26 @@ def test_division_with_parameter_remaining_cases(self, first, second, expected): assert result.min == expected.min assert result.max == expected.max - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (DescriptorNumber(name="test", value=2, variance=0.1, unit="s"), Parameter("name / test", 0.5, "m/s", 0.00875, 0, 5), Parameter("test / name", 2, "s/m", 0.14, 0.2, np.inf)), - (2, Parameter("name / 2", 0.5, "m", 0.0025, 0, 5), Parameter("2 / name", 2, "m**-1", 0.04, 0.2, np.inf))], - ids=["descriptor_number", "number"]) - def test_division_with_descriptor_number_and_number(self, parameter : Parameter, test, expected, expected_reverse): - # When + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + DescriptorNumber(name="test", value=2, variance=0.1, unit="s"), + Parameter("name / test", 0.5, "m/s", 0.00875, 0, 5), + Parameter("test / name", 2, "s/m", 0.14, 0.2, np.inf), + ), + ( + 2, + Parameter("name / 2", 0.5, "m", 0.0025, 0, 5), + Parameter("2 / name", 2, "m**-1", 0.04, 0.2, np.inf), + ), + ], + ids=["descriptor_number", "number"], + ) + def test_division_with_descriptor_number_and_number( + self, parameter: Parameter, test, expected, expected_reverse + ): + # When parameter._callback = property() # Then @@ -1080,12 +1696,21 @@ def test_division_with_descriptor_number_and_number(self, parameter : Parameter, assert result_reverse.min == expected_reverse.min assert result_reverse.max == expected_reverse.max - @pytest.mark.parametrize("test, expected", [ - (DescriptorNumber(name="test", value=0, variance=0.1, unit="s"), DescriptorNumber("test / name", 0.0, "s/m", 0.1)), - (0, DescriptorNumber("0 / name", 0.0, "1/m", 0.0))], - ids=["descriptor_number", "number"]) - def test_zero_value_divided_by_parameter(self, parameter : Parameter, test, expected): - # When + @pytest.mark.parametrize( + "test, expected", + [ + ( + DescriptorNumber(name="test", value=0, variance=0.1, unit="s"), + DescriptorNumber("test / name", 0.0, "s/m", 0.1), + ), + (0, DescriptorNumber("0 / name", 0.0, "1/m", 0.0)), + ], + ids=["descriptor_number", "number"], + ) + def test_zero_value_divided_by_parameter( + self, parameter: Parameter, test, expected + ): + # When parameter._callback = property() # Then @@ -1098,14 +1723,46 @@ def test_zero_value_divided_by_parameter(self, parameter : Parameter, test, expe assert result.unit == expected.unit assert result.variance == expected.variance - @pytest.mark.parametrize("first, second, expected", [ - (DescriptorNumber("name", 1, "m", 0.01), Parameter("test", 2, "s", 0.1, -10, 10), Parameter("name / test", 0.5, "m/s", 0.00875, -np.inf, np.inf)), - (DescriptorNumber("name", -1, "m", 0.01), Parameter("test", 2, "s", 0.1, 0, 10), Parameter("name / test", -0.5, "m/s", 0.00875, -np.inf, -0.1)), - (DescriptorNumber("name", 1, "m", 0.01), Parameter("test", -2, "s", 0.1, -10, 0), Parameter("name / test", -0.5, "m/s", 0.00875, -np.inf, -0.1)), - (DescriptorNumber("name", -1, "m", 0.01), Parameter("test", -2, "s", 0.1, -10, 0), Parameter("name / test", 0.5, "m/s", 0.00875, 0.1, np.inf)), - (DescriptorNumber("name", 1, "m", 0.01), Parameter("test", 2, "s", 0.1, 1, 10), Parameter("name / test", 0.5, "m/s", 0.00875, 0.1, 1))], - ids=["crossing_zero", "positive_0_with_negative", "negative_0_with_positive", "negative_0_with_negative", "finite_limits"]) - def test_division_with_descriptor_number_missing_cases(self, first, second, expected): + @pytest.mark.parametrize( + "first, second, expected", + [ + ( + DescriptorNumber("name", 1, "m", 0.01), + Parameter("test", 2, "s", 0.1, -10, 10), + Parameter("name / test", 0.5, "m/s", 0.00875, -np.inf, np.inf), + ), + ( + DescriptorNumber("name", -1, "m", 0.01), + Parameter("test", 2, "s", 0.1, 0, 10), + Parameter("name / test", -0.5, "m/s", 0.00875, -np.inf, -0.1), + ), + ( + DescriptorNumber("name", 1, "m", 0.01), + Parameter("test", -2, "s", 0.1, -10, 0), + Parameter("name / test", -0.5, "m/s", 0.00875, -np.inf, -0.1), + ), + ( + DescriptorNumber("name", -1, "m", 0.01), + Parameter("test", -2, "s", 0.1, -10, 0), + Parameter("name / test", 0.5, "m/s", 0.00875, 0.1, np.inf), + ), + ( + DescriptorNumber("name", 1, "m", 0.01), + Parameter("test", 2, "s", 0.1, 1, 10), + Parameter("name / test", 0.5, "m/s", 0.00875, 0.1, 1), + ), + ], + ids=[ + "crossing_zero", + "positive_0_with_negative", + "negative_0_with_positive", + "negative_0_with_negative", + "finite_limits", + ], + ) + def test_division_with_descriptor_number_missing_cases( + self, first, second, expected + ): # When Then result = first / second @@ -1117,9 +1774,13 @@ def test_division_with_descriptor_number_missing_cases(self, first, second, expe assert result.min == expected.min assert result.max == expected.max - @pytest.mark.parametrize("test", [0, DescriptorNumber("test", 0, "s", 0.1)], ids=["number", "descriptor_number"]) - def test_divide_parameter_by_zero(self, parameter : Parameter, test): - # When + @pytest.mark.parametrize( + "test", + [0, DescriptorNumber("test", 0, "s", 0.1)], + ids=["number", "descriptor_number"], + ) + def test_divide_parameter_by_zero(self, parameter: Parameter, test): + # When parameter._callback = property() # Then Expect @@ -1135,20 +1796,34 @@ def test_divide_by_zero_value_parameter(self): with pytest.raises(ZeroDivisionError): result = descriptor / parameter - @pytest.mark.parametrize("test, expected", [ - (3, Parameter("name ** 3", 125, "m^3", 281.25, -125, 1000)), - (2, Parameter("name ** 2", 25, "m^2", 5.0, 0, 100)), - (-1, Parameter("name ** -1", 0.2, "1/m", 8e-5, -np.inf, np.inf)), - (-2, Parameter("name ** -2", 0.04, "1/m^2", 1.28e-5, 0, np.inf)), - (0, DescriptorNumber("name ** 0", 1, "dimensionless", 0)), - (DescriptorNumber("test", 2), Parameter("name ** test", 25, "m^2", 5.0, 0, 100))], - ids=["power_3", "power_2", "power_-1", "power_-2", "power_0", "power_descriptor_number"]) + @pytest.mark.parametrize( + "test, expected", + [ + (3, Parameter("name ** 3", 125, "m^3", 281.25, -125, 1000)), + (2, Parameter("name ** 2", 25, "m^2", 5.0, 0, 100)), + (-1, Parameter("name ** -1", 0.2, "1/m", 8e-5, -np.inf, np.inf)), + (-2, Parameter("name ** -2", 0.04, "1/m^2", 1.28e-5, 0, np.inf)), + (0, DescriptorNumber("name ** 0", 1, "dimensionless", 0)), + ( + DescriptorNumber("test", 2), + Parameter("name ** test", 25, "m^2", 5.0, 0, 100), + ), + ], + ids=[ + "power_3", + "power_2", + "power_-1", + "power_-2", + "power_0", + "power_descriptor_number", + ], + ) def test_power_of_parameter(self, test, expected): - # When + # When parameter = Parameter("name", 5, "m", 0.05, -5, 10) # Then - result = parameter ** test + result = parameter**test # Expect assert type(result) == type(expected) @@ -1160,18 +1835,65 @@ def test_power_of_parameter(self, test, expected): assert result.min == expected.min assert result.max == expected.max - @pytest.mark.parametrize("test, exponent, expected", [ - (Parameter("name", 5, "m", 0.05, 0, 10), -1, Parameter("name ** -1", 0.2, "1/m", 8e-5, 0.1, np.inf)), - (Parameter("name", -5, "m", 0.05, -5, 0), -1, Parameter("name ** -1", -0.2, "1/m", 8e-5, -np.inf, -0.2)), - (Parameter("name", 5, "m", 0.05, 5, 10), -1, Parameter("name ** -1", 0.2, "1/m", 8e-5, 0.1, 0.2)), - (Parameter("name", -5, "m", 0.05, -10, -5), -1, Parameter("name ** -1", -0.2, "1/m", 8e-5, -0.2, -0.1)), - (Parameter("name", -5, "m", 0.05, -10, -5), -2, Parameter("name ** -2", 0.04, "1/m^2", 1.28e-5, 0.01, 0.04)), - (Parameter("name", 5, "", 0.1, 1, 10), 0.3, Parameter("name ** 0.3", 1.6206565966927624, "", 0.0009455500095853564, 1, 1.9952623149688795)), - (Parameter("name", 5, "", 0.1), 0.5, Parameter("name ** 0.5", 2.23606797749979, "", 0.005, 0, np.inf))], - ids=["0_positive", "negative_0", "both_positive", "both_negative_invert", "both_negative_invert_square", "fractional", "fractional_negative_limit"]) + @pytest.mark.parametrize( + "test, exponent, expected", + [ + ( + Parameter("name", 5, "m", 0.05, 0, 10), + -1, + Parameter("name ** -1", 0.2, "1/m", 8e-5, 0.1, np.inf), + ), + ( + Parameter("name", -5, "m", 0.05, -5, 0), + -1, + Parameter("name ** -1", -0.2, "1/m", 8e-5, -np.inf, -0.2), + ), + ( + Parameter("name", 5, "m", 0.05, 5, 10), + -1, + Parameter("name ** -1", 0.2, "1/m", 8e-5, 0.1, 0.2), + ), + ( + Parameter("name", -5, "m", 0.05, -10, -5), + -1, + Parameter("name ** -1", -0.2, "1/m", 8e-5, -0.2, -0.1), + ), + ( + Parameter("name", -5, "m", 0.05, -10, -5), + -2, + Parameter("name ** -2", 0.04, "1/m^2", 1.28e-5, 0.01, 0.04), + ), + ( + Parameter("name", 5, "", 0.1, 1, 10), + 0.3, + Parameter( + "name ** 0.3", + 1.6206565966927624, + "", + 0.0009455500095853564, + 1, + 1.9952623149688795, + ), + ), + ( + Parameter("name", 5, "", 0.1), + 0.5, + Parameter("name ** 0.5", 2.23606797749979, "", 0.005, 0, np.inf), + ), + ], + ids=[ + "0_positive", + "negative_0", + "both_positive", + "both_negative_invert", + "both_negative_invert_square", + "fractional", + "fractional_negative_limit", + ], + ) def test_power_of_diffent_parameters(self, test, exponent, expected): # When Then - result = test ** exponent + result = test**exponent # Expect assert result.name == result.unique_name @@ -1181,16 +1903,33 @@ def test_power_of_diffent_parameters(self, test, exponent, expected): assert result.min == expected.min assert result.max == expected.max - @pytest.mark.parametrize("parameter, exponent, expected", [ - (Parameter("name", 5, "m"), DescriptorNumber("test", 2, unit="s"), UnitError), - (Parameter("name", 5, "m"), DescriptorNumber("test", 2, variance=0.01), ValueError), - (Parameter("name", 5, "m"), 0.5, UnitError), - (Parameter("name", -5, ""), 0.5, ValueError),], - ids=["exponent_unit", "exponent_variance", "exponent_fractional", "negative_base_fractional"]) + @pytest.mark.parametrize( + "parameter, exponent, expected", + [ + ( + Parameter("name", 5, "m"), + DescriptorNumber("test", 2, unit="s"), + UnitError, + ), + ( + Parameter("name", 5, "m"), + DescriptorNumber("test", 2, variance=0.01), + ValueError, + ), + (Parameter("name", 5, "m"), 0.5, UnitError), + (Parameter("name", -5, ""), 0.5, ValueError), + ], + ids=[ + "exponent_unit", + "exponent_variance", + "exponent_fractional", + "negative_base_fractional", + ], + ) def test_power_exceptions(self, parameter, exponent, expected): # When Then Expect with pytest.raises(expected): - result = parameter ** exponent + result = parameter**exponent def test_negation(self): # When @@ -1207,18 +1946,28 @@ def test_negation(self): assert result.min == -10 assert result.max == 5 - @pytest.mark.parametrize("test, expected", [ - (Parameter("name", -5, "m", 0.05, -10, -5), Parameter("abs(name)", 5, "m", 0.05, 5, 10)), - (Parameter("name", 5, "m", 0.05, -10, 10), Parameter("abs(name)", 5, "m", 0.05, 0, 10))], - ids=["pure_negative", "crossing_zero"]) + @pytest.mark.parametrize( + "test, expected", + [ + ( + Parameter("name", -5, "m", 0.05, -10, -5), + Parameter("abs(name)", 5, "m", 0.05, 5, 10), + ), + ( + Parameter("name", 5, "m", 0.05, -10, 10), + Parameter("abs(name)", 5, "m", 0.05, 0, 10), + ), + ], + ids=["pure_negative", "crossing_zero"], + ) def test_abs(self, test, expected): # When Then result = abs(test) # Expect - assert result.name == result.unique_name + assert result.name == result.unique_name assert result.value == expected.value assert result.unit == expected.unit assert result.variance == expected.variance assert result.min == expected.min - assert result.max == expected.max \ No newline at end of file + assert result.max == expected.max diff --git a/tests/unit_tests/variable/test_parameter_dependency_serialization.py b/tests/unit_tests/variable/test_parameter_dependency_serialization.py index 8cf7abc0..574e2497 100644 --- a/tests/unit_tests/variable/test_parameter_dependency_serialization.py +++ b/tests/unit_tests/variable/test_parameter_dependency_serialization.py @@ -4,13 +4,19 @@ from unittest.mock import Mock from easyscience import Parameter, global_object -from easyscience.variable.parameter_dependency_resolver import resolve_all_parameter_dependencies -from easyscience.variable.parameter_dependency_resolver import get_parameters_with_pending_dependencies -from easyscience.variable.parameter_dependency_resolver import deserialize_and_resolve_parameters +from easyscience.variable.parameter_dependency_resolver import ( + resolve_all_parameter_dependencies, +) +from easyscience.variable.parameter_dependency_resolver import ( + get_parameters_with_pending_dependencies, +) +from easyscience.variable.parameter_dependency_resolver import ( + deserialize_and_resolve_parameters, +) class TestParameterDependencySerialization: - + @pytest.fixture def clear_global_map(self): """This fixture pattern: @@ -34,19 +40,19 @@ def clear_global_map(self): def test_independent_parameter_serialization(self, clear_global_map): """Test that independent parameters serialize normally without dependency info.""" param = Parameter(name="test", value=5.0, unit="m", min=0, max=10) - + # Serialize serialized = param.as_dict() - + # Should not contain dependency fields - assert '_dependency_string' not in serialized - assert '_dependency_map_serializer_ids' not in serialized - assert '_independent' not in serialized - + assert "_dependency_string" not in serialized + assert "_dependency_map_serializer_ids" not in serialized + assert "_independent" not in serialized + # Deserialize global_object.map._clear() new_param = Parameter.from_dict(serialized) - + # Should be identical assert new_param.name == param.name assert new_param.value == param.value @@ -57,108 +63,161 @@ def test_dependent_parameter_serialization(self, clear_global_map): """Test serialization of parameters with dependencies.""" # Create independent parameter a = Parameter(name="a", value=2.0, unit="m", min=0, max=10) - + # Create dependent parameter b = Parameter.from_dependency( - name="b", - dependency_expression="2 * a", - dependency_map={"a": a}, - unit="m" + name="b", dependency_expression="2 * a", dependency_map={"a": a}, unit="m" ) - + # Serialize dependent parameter serialized = b.as_dict() - + # Should contain dependency information - assert serialized['_dependency_string'] == "2 * a" - assert serialized['_dependency_map_serializer_ids'] == {"a": a._DescriptorNumber__serializer_id} - assert serialized['_independent'] is False - + assert serialized["_dependency_string"] == "2 * a" + assert serialized["_dependency_map_serializer_ids"] == { + "a": a._DescriptorNumber__serializer_id + } + assert serialized["_independent"] is False + # Deserialize global_object.map._clear() new_b = Parameter.from_dict(serialized) - + # Should have pending dependency info - assert hasattr(new_b, '_pending_dependency_string') + assert hasattr(new_b, "_pending_dependency_string") assert new_b._pending_dependency_string == "2 * a" - assert new_b._pending_dependency_map_serializer_ids == {"a": a._DescriptorNumber__serializer_id} - assert new_b.independent is True # Initially independent until dependencies resolved + assert new_b._pending_dependency_map_serializer_ids == { + "a": a._DescriptorNumber__serializer_id + } + assert ( + new_b.independent is True + ) # Initially independent until dependencies resolved def test_dependency_resolution_after_deserialization(self, clear_global_map): """Test that dependencies are properly resolved after deserialization.""" # Create test parameters with dependencies a = Parameter(name="a", value=2.0, unit="m", min=0, max=10) b = Parameter(name="b", value=3.0, unit="m", min=0, max=10) - + c = Parameter.from_dependency( name="c", - dependency_expression="a + b", + dependency_expression="a + b", dependency_map={"a": a, "b": b}, - unit="m" + unit="m", ) - + # Verify original dependency works assert c.value == 5.0 # 2 + 3 - + # Serialize all parameters - params_data = { - "a": a.as_dict(), - "b": b.as_dict(), - "c": c.as_dict() - } - + params_data = {"a": a.as_dict(), "b": b.as_dict(), "c": c.as_dict()} + # Clear and deserialize (manual approach) global_object.map._clear() new_params = {} for name, data in params_data.items(): new_params[name] = Parameter.from_dict(data) - + # Before resolution, c should be independent with pending dependency assert new_params["c"].independent is True - assert hasattr(new_params["c"], '_pending_dependency_string') - + assert hasattr(new_params["c"], "_pending_dependency_string") + # Resolve dependencies resolve_all_parameter_dependencies(new_params) - + # Alternative simplified approach using the helper function: # global_object.map._clear() # new_params = deserialize_and_resolve_parameters(params_data) - + # After resolution, c should be dependent and functional assert new_params["c"].independent is False assert new_params["c"].value == 5.0 # Still 2 + 3 - + # Test that dependency still works new_params["a"].value = 10.0 assert new_params["c"].value == 13.0 # 10 + 3 + def test_dependency_resolution_after_deserialization_desired_unit( + self, clear_global_map + ): + """Test that dependencies are properly resolved after deserialization.""" + # Create test parameters with dependencies + a = Parameter(name="a", value=2.0, unit="m", min=0, max=10) + b = Parameter(name="b", value=3.0, unit="m", min=0, max=10) + + c = Parameter.from_dependency( + name="c", + dependency_expression="a + b", + dependency_map={"a": a, "b": b}, + desired_unit="cm", + ) + + # Verify original dependency works + assert c.value == 5.0 * 100 # 2 + 3 + assert c.unit == "cm" + + # Serialize all parameters + params_data = {"a": a.as_dict(), "b": b.as_dict(), "c": c.as_dict()} + + # Clear and deserialize (manual approach) + global_object.map._clear() + new_params = {} + for name, data in params_data.items(): + new_params[name] = Parameter.from_dict(data) + + # Before resolution, c should be independent with pending dependency + assert new_params["c"].independent is True + assert hasattr(new_params["c"], "_pending_dependency_string") + + # Resolve dependencies + resolve_all_parameter_dependencies(new_params) + + # Alternative simplified approach using the helper function: + # global_object.map._clear() + # new_params = deserialize_and_resolve_parameters(params_data) + + # After resolution, c should be dependent and functional + assert new_params["c"].independent is False + assert new_params["c"]._desired_unit == "cm" # Desired unit should be preserved + assert new_params["c"].value == 5.0 * 100 # Still 2 + 3, converted to cm + + # Test that dependency still works + new_params["a"].value = 10.0 + assert new_params["c"].value == 13.0 * 100 # 10 + 3 + assert new_params["c"].unit == "cm" + def test_unique_name_dependency_serialization(self, clear_global_map): """Test serialization of dependencies using unique names.""" a = Parameter(name="a", value=3.0, unit="m", min=0, max=10) - + # Create dependent parameter using unique name b = Parameter.from_dependency( - name="b", + name="b", dependency_expression='2 * "Parameter_0"', # Using unique name - unit="m" + unit="m", ) - + # Serialize both parameters a_serialized = a.as_dict() b_serialized = b.as_dict() - + # Should contain unique name mapping - assert b_serialized['_dependency_string'] == '2 * __Parameter_0__' - assert "__Parameter_0__" in b_serialized['_dependency_map_serializer_ids'] - assert b_serialized['_dependency_map_serializer_ids']["__Parameter_0__"] == a._DescriptorNumber__serializer_id - + assert b_serialized["_dependency_string"] == "2 * __Parameter_0__" + assert "__Parameter_0__" in b_serialized["_dependency_map_serializer_ids"] + assert ( + b_serialized["_dependency_map_serializer_ids"]["__Parameter_0__"] + == a._DescriptorNumber__serializer_id + ) + # Deserialize both and resolve global_object.map._clear() - c = Parameter(name='c', value=0.0) # Dummy to occupy unique name, to force new unique_names + c = Parameter( + name="c", value=0.0 + ) # Dummy to occupy unique name, to force new unique_names # Remove unique_name from serialized data to force generation of new unique names - a_serialized.pop('unique_name', None) - b_serialized.pop('unique_name', None) + a_serialized.pop("unique_name", None) + b_serialized.pop("unique_name", None) new_b = Parameter.from_dict(b_serialized) new_a = Parameter.from_dict(a_serialized) @@ -174,35 +233,35 @@ def test_json_serialization_roundtrip(self, clear_global_map): # Create parameters with dependencies length = Parameter(name="length", value=10.0, unit="m", min=0, max=100) width = Parameter(name="width", value=5.0, unit="m", min=0, max=50) - + area = Parameter.from_dependency( name="area", dependency_expression="length * width", dependency_map={"length": length, "width": width}, - unit="m^2" + unit="m^2", ) - + # Serialize to JSON params_data = { "length": length.as_dict(), "width": width.as_dict(), - "area": area.as_dict() + "area": area.as_dict(), } json_str = json.dumps(params_data, default=str) - + # Deserialize from JSON global_object.map._clear() loaded_data = json.loads(json_str) new_params = {} for name, data in loaded_data.items(): new_params[name] = Parameter.from_dict(data) - + # Resolve dependencies resolve_all_parameter_dependencies(new_params) - + # Test functionality assert new_params["area"].value == 50.0 # 10 * 5 - + # Test dependency updates new_params["length"].value = 20.0 assert new_params["area"].value == 100.0 # 20 * 5 @@ -211,44 +270,37 @@ def test_multiple_dependent_parameters(self, clear_global_map): """Test serialization with multiple dependent parameters.""" # Create a chain of dependencies x = Parameter(name="x", value=2.0, unit="m", min=0, max=10) - + y = Parameter.from_dependency( - name="y", - dependency_expression="2 * x", - dependency_map={"x": x}, - unit="m" + name="y", dependency_expression="2 * x", dependency_map={"x": x}, unit="m" ) - + z = Parameter.from_dependency( - name="z", + name="z", dependency_expression="y + x", dependency_map={"y": y, "x": x}, - unit="m" + unit="m", ) - + # Verify original chain works assert y.value == 4.0 # 2 * 2 assert z.value == 6.0 # 4 + 2 - + # Serialize all - params_data = { - "x": x.as_dict(), - "y": y.as_dict(), - "z": z.as_dict() - } - + params_data = {"x": x.as_dict(), "y": y.as_dict(), "z": z.as_dict()} + # Deserialize and resolve global_object.map._clear() new_params = {} for name, data in params_data.items(): new_params[name] = Parameter.from_dict(data) - + resolve_all_parameter_dependencies(new_params) - + # Test chain still works assert new_params["y"].value == 4.0 assert new_params["z"].value == 6.0 - + # Test cascade updates new_params["x"].value = 5.0 assert new_params["y"].value == 10.0 # 2 * 5 @@ -257,6 +309,7 @@ def test_multiple_dependent_parameters(self, clear_global_map): def test_dependency_with_descriptor_number(self, clear_global_map): """Test that dependencies involving DescriptorNumber serialize correctly.""" from easyscience.variable import DescriptorNumber + # When x = DescriptorNumber(name="x", value=3.0, unit="m") @@ -272,11 +325,7 @@ def test_dependency_with_descriptor_number(self, clear_global_map): # Then # Serialize all - params_data = { - "x": x.as_dict(), - "y": y.as_dict(), - "z": z.as_dict() - } + params_data = {"x": x.as_dict(), "y": y.as_dict(), "z": z.as_dict()} # Deserialize and resolve global_object.map._clear() new_params = {} @@ -303,26 +352,23 @@ def test_get_parameters_with_pending_dependencies(self, clear_global_map): # Create parameters a = Parameter(name="a", value=1.0, unit="m") b = Parameter.from_dependency( - name="b", - dependency_expression="2 * a", - dependency_map={"a": a}, - unit="m" + name="b", dependency_expression="2 * a", dependency_map={"a": a}, unit="m" ) - + # Serialize and deserialize params_data = {"a": a.as_dict(), "b": b.as_dict()} global_object.map._clear() new_params = {} for name, data in params_data.items(): new_params[name] = Parameter.from_dict(data) - + # Find pending dependencies pending = get_parameters_with_pending_dependencies(new_params) - + assert len(pending) == 1 assert pending[0].name == "b" - assert hasattr(pending[0], '_pending_dependency_string') - + assert hasattr(pending[0], "_pending_dependency_string") + # After resolution, should be empty resolve_all_parameter_dependencies(new_params) pending_after = get_parameters_with_pending_dependencies(new_params) @@ -332,54 +378,54 @@ def test_error_handling_missing_dependency(self, clear_global_map): """Test error handling when dependency cannot be resolved.""" a = Parameter(name="a", value=1.0, unit="m") b = Parameter.from_dependency( - name="b", - dependency_expression="2 * a", - dependency_map={"a": a}, - unit="m" + name="b", dependency_expression="2 * a", dependency_map={"a": a}, unit="m" ) - + # Serialize b but not a b_data = b.as_dict() - + # Deserialize without a in the global map global_object.map._clear() new_b = Parameter.from_dict(b_data) - + # Should raise error when trying to resolve - with pytest.raises(ValueError, match="Cannot find parameter with serializer_id"): + with pytest.raises( + ValueError, match="Cannot find parameter with serializer_id" + ): new_b.resolve_pending_dependencies() def test_backward_compatibility_base_deserializer(self, clear_global_map): """Test that the base deserializer path still works for dependent parameters.""" from easyscience.io.serializer_dict import SerializerDict - + # Create dependent parameter a = Parameter(name="a", value=2.0, unit="m") b = Parameter.from_dependency( - name="b", - dependency_expression="3 * a", - dependency_map={"a": a}, - unit="m" + name="b", dependency_expression="3 * a", dependency_map={"a": a}, unit="m" ) - + # Use base serializer path (SerializerDict.decode) serialized = b.encode(encoder=SerializerDict) global_object.map._clear() - + # This should not raise the "_independent" error anymore deserialized = SerializerDict.decode(serialized) - + # Should be a valid Parameter (but without dependency resolution) assert isinstance(deserialized, Parameter) assert deserialized.name == "b" assert deserialized.independent is True # Base path doesn't handle dependencies - @pytest.mark.parametrize("order", [ - ["x", "y", "z"], - ["z", "x", "y"], - ["y", "z", "x"], - ["z", "y", "x"] - ], ids=['normal_order', 'dependent_first', 'mixed_order', 'dependent_first_reverse']) + @pytest.mark.parametrize( + "order", + [["x", "y", "z"], ["z", "x", "y"], ["y", "z", "x"], ["z", "y", "x"]], + ids=[ + "normal_order", + "dependent_first", + "mixed_order", + "dependent_first_reverse", + ], + ) def test_serializer_id_system_order_independence(self, clear_global_map, order): """Test that dependency IDs allow parameters to be loaded in any order.""" # WHEN @@ -391,7 +437,7 @@ def test_serializer_id_system_order_independence(self, clear_global_map, order): name="z", dependency_expression="x * y", dependency_map={"x": x, "y": y}, - unit="m^2" + unit="m^2", ) # Verify original functionality @@ -402,11 +448,7 @@ def test_serializer_id_system_order_independence(self, clear_global_map, order): y_dep_id = y._DescriptorNumber__serializer_id # Serialize all parameters - params_data = { - "x": x.as_dict(), - "y": y.as_dict(), - "z": z.as_dict() - } + params_data = {"x": x.as_dict(), "y": y.as_dict(), "z": z.as_dict()} # Verify dependency IDs are in serialized data assert params_data["x"]["__serializer_id"] == x_dep_id @@ -451,18 +493,14 @@ def test_deserialize_and_resolve_parameters_helper(self, clear_global_map): name="c", dependency_expression="a + b", dependency_map={"a": a, "b": b}, - unit="m" + unit="m", ) # Verify original dependency works assert c.value == 5.0 # 2 + 3 # Serialize all parameters - params_data = { - "a": a.as_dict(), - "b": b.as_dict(), - "c": c.as_dict() - } + params_data = {"a": a.as_dict(), "b": b.as_dict(), "c": c.as_dict()} # Clear global map global_object.map._clear() @@ -497,4 +535,3 @@ def test_deserialize_and_resolve_parameters_helper(self, clear_global_map): # Verify no pending dependencies remain pending = get_parameters_with_pending_dependencies(new_params) assert len(pending) == 0 -