From 79a9deb34eb5e351f0d676de4114abcdcdd622aa Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 16 Oct 2026 12:38:16 +0200 Subject: [PATCH 1/3] Removed interface factory --- docs/docs/api-reference/index.md | 4 +- docs/docs/user-guide/external-calculators.md | 303 +++++++++ docs/docs/user-guide/index.md | 4 + docs/mkdocs.yml | 1 + legacy/examples_old/example3.py | 101 --- legacy/examples_old/example4.py | 448 ------------- legacy/examples_old/example5_broken.py | 367 ----------- legacy/examples_old/example6_broken.py | 446 ------------- .../fitting/calculators/__init__.py | 6 - .../fitting/calculators/interface_factory.py | 271 -------- src/easyscience/fitting/engine_base.py | 3 +- src/easyscience/job/analysis.py | 19 +- src/easyscience/variable/descriptor_base.py | 4 +- src/easyscience/variable/parameter.py | 52 -- tests/helpers/easy_test_models.py | 2 +- .../fitting/test_stateless_calculator.py | 177 +++++ .../unit/base_classes/test_serializer_base.py | 22 +- tests/unit/fitting/calculators/__init__.py | 3 - .../calculators/test_interface_factory.py | 606 ------------------ .../unit/fitting/test_calculators_removed.py | 13 + tests/unit/job/test_job.py | 37 +- tests/unit/variable/test_parameter.py | 127 ++-- 22 files changed, 581 insertions(+), 2435 deletions(-) create mode 100644 docs/docs/user-guide/external-calculators.md delete mode 100644 legacy/examples_old/example3.py delete mode 100644 legacy/examples_old/example4.py delete mode 100644 legacy/examples_old/example5_broken.py delete mode 100644 legacy/examples_old/example6_broken.py delete mode 100644 src/easyscience/fitting/calculators/__init__.py delete mode 100644 src/easyscience/fitting/calculators/interface_factory.py create mode 100644 tests/integration/fitting/test_stateless_calculator.py delete mode 100644 tests/unit/fitting/calculators/__init__.py delete mode 100644 tests/unit/fitting/calculators/test_interface_factory.py create mode 100644 tests/unit/fitting/test_calculators_removed.py diff --git a/docs/docs/api-reference/index.md b/docs/docs/api-reference/index.md index 385601ca..3b5a6d35 100644 --- a/docs/docs/api-reference/index.md +++ b/docs/docs/api-reference/index.md @@ -10,8 +10,8 @@ available in EasyScience. - [base_classes](base_classes.md) – Core abstract and helper base classes used to build EasyScience objects (e.g. `NewBase`, `ModelBase`, `EasyList`, `SerializerBase`). -- [fitting](fitting.md) – Fitting utilities and interfaces, including - `Fitter` and available minimizers. +- [fitting](fitting.md) – Fitting and sampling, including `Fitter`, + `Sampler` and the available minimizers. - [global_object](global_object.md) – Global singleton providing shared services (logger, map, undo/redo stack, script manager). - [job](job.md) – Job and experiment abstractions for running and diff --git a/docs/docs/user-guide/external-calculators.md b/docs/docs/user-guide/external-calculators.md new file mode 100644 index 00000000..6138baff --- /dev/null +++ b/docs/docs/user-guide/external-calculators.md @@ -0,0 +1,303 @@ +# Connecting an External Calculator + +Technique libraries built on EasyScience usually compute their model +with an external package (for example refnx or refl1d for +reflectometry). This page describes how such a calculator connects to +the EasyScience model and to `Fitter`, from the library author's point +of view. + +EasyScience does not provide a calculator base class, a factory or a +registry. A calculator is any object your library defines; the only +thing EasyScience sees is the fit function you hand to `Fitter`. + +## General overview + +```mermaid +flowchart LR + subgraph lib["Technique library"] + calc["Calculator
backend choice and configuration"] + func["Fit function
calculator.fit_func_for(model)"] + backend["Backend package
e.g. refnx, refl1d"] + end + subgraph core["EasyScience core"] + fitter["Fitter / MultiFitter"] + minimizer["Minimizer
LMFit, Bumps, DFO"] + model["Model (ModelBase)
Parameters: the only state"] + end + + calc -- "creates" --> func + func -. "handed over once:
Fitter(model, fit_function)" .-> fitter + fitter -- "get_fit_parameters()" --> model + minimizer -- "trial values" --> fitter + fitter -- "1: sets Parameter.value" --> model + fitter -- "2: calls fit_function(x)" --> func + func -- "3: reads current values" --> model + func -- "4: builds and evaluates
with plain numbers" --> backend + backend -- "5: y" --> func +``` + +The arrows between the library and the model only point one way: the fit +function reads parameters, it never writes them. `Fitter` is the only +common point between core and the library, and the only thing passed +across is a callable. + +## The Contract + +- **EasyScience is the only store of parameter state.** Values, + uncertainties, bounds, fixed flags and dependencies live in the + `Parameter` objects of the model. The calculator does not keep a + second copy that has to be kept in step. +- **The calculator reads the model when asked to calculate.** Each + evaluation uses the current values of the independent parameters and + of the dependent parameters derived from them. +- **Reading a parameter has no side effects.** `Parameter.value` returns + the stored value; it never calls into a calculator. +- **Evaluation does not change the model.** Calculating leaves values, + uncertainties, bounds, fixed flags, dependencies and the undo history + as they were. Only the fitting engine writes parameter values during a + fit, through the ordinary `value` setter. +- **Structural edits are seen by the next evaluation.** Adding or + removing a part of the model needs no extra step, because the + calculator reads the model fresh. + +## A Minimal Example + +The calculator below evaluates a straight line. It holds no parameter +values; it reads them from the model on every call. + +```python +import numpy as np + +from easyscience import Parameter +from easyscience.base_classes import ModelBase +from easyscience.fitting import Fitter + + +class Line(ModelBase): + def __init__(self, slope: float, intercept: float): + super().__init__() + self._slope = Parameter(slope, display_name='slope') + self._intercept = Parameter(intercept, display_name='intercept') + + @property + def slope(self) -> Parameter: + return self._slope + + @property + def intercept(self) -> Parameter: + return self._intercept + + +class LineCalculator: + """Owned by the technique library, unknown to EasyScience.""" + + def calculate(self, model: Line, x: np.ndarray) -> np.ndarray: + # Hand plain numbers to the backend + return model.slope.value * x + model.intercept.value + + def fit_func_for(self, model: Line): + return lambda x: self.calculate(model, x) + + +model = Line(slope=1.0, intercept=0.0) +calculator = LineCalculator() + +x = np.linspace(0, 10, 50) +y = 3.0 * x + 1.5 + np.random.default_rng(0).normal(0.0, 0.01, x.size) + +fitter = Fitter(model, calculator.fit_func_for(model)) +result = fitter.fit(x=x, y=y, weights=np.ones_like(x)) + +print(model.slope.value, model.intercept.value) # close to 3.0 and 1.5 +``` + +During the fit, the engine sets the parameter values and then calls the +fit function, so the calculator always sees the values being tried. With +the undo stack enabled, the final values are committed as one undo step. + +Dependent parameters need no special handling. If the intercept is made +dependent on the slope, the engine fits only the slope and the +calculator reads the recomputed intercept: + +```python +model.intercept.make_dependent_on( + dependency_expression='0.5 * s', + dependency_map={'s': model.slope}, +) +``` + +## Several Datasets + +With `MultiFitter`, pass one fit function per dataset. A `Parameter` +object used by several models is fitted once and every calculator call +will read the same value: + +```python +from easyscience.fitting.multi_fitter import MultiFitter + +fitter = MultiFitter( + [model_1, model_2], + [calculator.fit_func_for(model_1), calculator.fit_func_for(model_2)], +) +results = fitter.fit(x=[x_1, x_2], y=[y_1, y_2], weights=[w_1, w_2]) +``` + +## Performance + +Rebuilding the backend object on every evaluation is simple and, for +typical models, cheap. If profiling shows that construction dominates, +build the backend object once when a fit starts, refresh only the +numbers on each call, and discard it when the fit ends. Such a +fit-scoped session lives inside the fit function in your library. The +parameter set does not change during a fit, so the session does not have +to track structural edits. + +## Writing Values Back + +EasyScience has no API for a backend to write values into the model, and +ordinary evaluation should never need one. If your library has an +operation that genuinely changes the model based on a backend result, +perform it in your own code through the public `Parameter.value` setter, +outside the fit, as an explicit step the user can see. + +## Following a Fit Iteration by Iteration + +Because the calculator only reads the model, it is not the place to +watch a fit's progress. To see the parameter values at each iteration, +pass a `progress_callback` to `fit()`. Continuing the minimal example: + +```python +history = [] + + +def on_iteration(payload: dict) -> None: + # Called by the minimizer as the fit progresses. Values are keyed + # by unique_name and describe the point the minimizer reports. + values = payload['parameter_values'] + slope = values[model.slope.unique_name] + intercept = values[model.intercept.unique_name] + history.append((payload['iteration'], slope, intercept)) + print( + f'iteration {payload["iteration"]:3d}: ' + f'slope={slope:.6f} intercept={intercept:.6f} chi2={payload["chi2"]:.4g}' + ) + + +fitter = Fitter(model, calculator.fit_func_for(model)) +result = fitter.fit(x=x, y=y, weights=np.ones_like(x), progress_callback=on_iteration) +``` + +The callback receives a dictionary with these keys: + +| Key | Meaning | +| --------------------------- | ------------------------------------------------------------------------------- | +| `iteration` | Iteration or evaluation number, as counted by the minimizer | +| `chi2` | Chi-squared at the reported point | +| `reduced_chi2` | Chi-squared divided by the degrees of freedom | +| `parameter_values` | Values of the fitted parameters at the reported point, indexed by `unique_name` | +| `refresh_plots`, `finished` | Hints for user interfaces | + +Its return value is ignored. To stop a fit from the callback, see +[Stopping a Fit](#stopping-a-fit). + +When the callback is called, and which point it reports, depends on the +minimizer: + +| Minimizer | Called | Reported point | +| --------- | ---------------------------- | ------------------------------------------- | +| LMFit | on every function evaluation | the point just evaluated | +| Bumps | once per optimizer step | the optimizer's current point for that step | +| DFO | on every function evaluation | the best point found so far | + +With LMFit you will see iteration `-1` for the starting point, small +steps of about 1e-5 while LMFit estimates the gradient numerically, and +the last iteration number twice. Skip `iteration < 0` and repeated +numbers to keep only real steps. With DFO the reported values change +only when an evaluation improves on the best point. + +If you need a hook on every evaluation rather than every iteration, put +it in the fit function your calculator returns: it is called each time +and reads the current values from the model. + +## Stopping a Fit + +EasyScience has no single cancel switch that works the same way on every +minimizer. The options and how each minimizer responds: + +| How | LMFit | Bumps | DFO | +| ------------------------------------ | ------------------------ | ------------------------------------------------ | ----------------------------------- | +| Raise from `progress_callback` | stops, raises `FitError` | stops, raises `FitError` | stops, raises `FitError` | +| Raise from the fit function | stops, raises `FitError` | stops only after logging every failed evaluation | stops, raises `FitError` | +| `abort_test=` passed to `fit()` | ignored with a warning | stops, returns `success=False` | raises `FitError` (unknown keyword) | +| `max_evaluations=` passed to `fit()` | stops at the limit | stops at the limit | stops at the limit | + +### Raising from the progress callback + +This is the one approach that works on all three minimizers. Each +minimizer catches the exception, puts the fitted parameters back to the +values they had before the fit, and raises `FitError` with your +exception as its argument. No undo entry is recorded. + +A user interface can set a flag from its "Stop" button and let the +callback check it: + +```python +from easyscience.fitting.minimizers import FitError + + +class FitCancelled(Exception): + pass + + +stop_requested = False # set to True from the user interface + + +def on_iteration(payload: dict) -> None: + if stop_requested: + raise FitCancelled() + + +try: + result = fitter.fit(x=x, y=y, weights=np.ones_like(x), progress_callback=on_iteration) +except FitError: + # The fit was cancelled; the model is back at its pre-fit values. + ... +``` + +With LMFit and DFO the callback runs on every function evaluation, so +the fit stops at the next evaluation. With Bumps it runs once per +optimizer step, so the fit stops at the end of the current step. + +### Raising from the fit function + +Avoid using the fit function as a stop signal. LMFit and DFO stop as +they would for an exception from the callback, but Bumps catches the +error on every evaluation, logs a traceback, and carries on for many +failing evaluations before it gives up. + +### `abort_test` with Bumps + +Bumps, and the DREAM sampler, accept an `abort_test` argument: a +function without arguments that returns `True` when the fit should stop. +It is polled during the optimizer loop and once more after it finishes, +so it must have no side effects: + +```python +fitter.switch_minimizer('Bumps') +result = fitter.fit( + x=x, y=y, weights=np.ones_like(x), abort_test=lambda: stop_requested +) +if not result.success: + print(result.message) # 'Fit aborted before convergence' +``` + +Unlike raising an exception, an aborted Bumps fit returns normally and +keeps the best values found so far in the model. Do not pass +`abort_test` to the other minimizers: LMFit ignores it and runs the fit +to the end, and DFO rejects it with a `FitError`. + +### Limiting the number of evaluations + +`max_evaluations` stops a fit after a fixed number of evaluations on +every minimizer. It is a budget, not a cancel button, but it keeps a fit +from running for longer than you are prepared to wait. diff --git a/docs/docs/user-guide/index.md b/docs/docs/user-guide/index.md index 7c2e58ec..4c31eb8c 100644 --- a/docs/docs/user-guide/index.md +++ b/docs/docs/user-guide/index.md @@ -11,3 +11,7 @@ icon: material/book-open-variant - [:material-link-variant: Serializing Dependent Parameters](parameter-dependency-serialization.md) – How to save and load parameters that depend on other parameters, and how dependencies are resolved after loading. +- [:material-calculator-variant: Connecting an External Calculator](external-calculators.md) + – How a technique library connects its own calculator to an + EasyScience model and to `Fitter`, and what to change when migrating + from `InterfaceFactoryTemplate`. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index e90a3976..905fa409 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -194,6 +194,7 @@ nav: - Controlling Log Output: user-guide/controlling-log-output.md - Serializing Dependent Parameters: user-guide/parameter-dependency-serialization.md + - Connecting an External Calculator: user-guide/external-calculators.md - Tutorials: - Tutorials: tutorials/index.md - Bayesian analysis: tutorials/fitting-bayesian.ipynb diff --git a/legacy/examples_old/example3.py b/legacy/examples_old/example3.py deleted file mode 100644 index 49d6ede9..00000000 --- a/legacy/examples_old/example3.py +++ /dev/null @@ -1,101 +0,0 @@ -__author__ = 'github.com/wardsimon' -__version__ = '0.1.0' - -from typing import Callable - -import numpy as np - -from easyscience.fitting import Fitter -from easyscience.Objects.ObjectClasses import BaseObj -from easyscience.Objects.ObjectClasses import Parameter - -# In this case we have inherited from `BaseObj` to create a class which has fitable attributes. -# This class does not know about the `Calculator`, only the interface. - - -class Calculator: - def __init__(self, m=1, c=0): - self.m = m - self.c = c - - def calculate(self, x): - return self.m * x + self.c - - -class Interface: - def __init__(self): - self.calculator = Calculator() - - def get_value(self, value_label: str): - return getattr(self.calculator, value_label, None) - - def set_value(self, value_label: str, value): - print(f'Value of {value_label} set to {value}') - setattr(self.calculator, value_label, value) - - def fit_func(self, x): - return self.calculator.calculate(x) - - -class Line(BaseObj): - _defaults = [Parameter('m', 1), - Parameter('c', 0)] - - def __init__(self, interface=None): - super().__init__(self.__class__.__name__, - *self._defaults) - self.interface = interface - if self.interface: - for parameter in self.get_fit_parameters(): - name = parameter.name - setattr(self.__class__.__dict__[name], - '_callback', - property(self.__gitem(name), self.__sitem(self, name))) - - @property - def gradient(self): - if self.interface: - return self.interface.get_value('m') - else: - return self.m.value - - @property - def intercept(self): - if self.interface: - return self.interface.get_value('c') - else: - return self.c.value - - def fit_func(self, x: np.ndarray) -> np.ndarray: - if self.interface: - return self.interface.fit_func(x) - else: - raise NotImplementedError - - def __repr__(self): - return f'Line: m={self.m}, c={self.c}' - - @staticmethod - def __gitem(key: str) -> Callable: - def inner(obj): - obj.interface.get_value(key) - return lambda obj: inner(obj) - - @staticmethod - def __sitem(obj, key): - def inner(value): - obj.interface.set_value(key, value) - return inner - - -l = Line(interface=Interface()) -l.interface -f = Fitter(l, l.fit_func) - -x = np.array([1, 2, 3]) -y = np.array([2, 4, 6]) - 1 - -f_res = f.fit(x, y) - -print(f_res) -print(l) \ No newline at end of file diff --git a/legacy/examples_old/example4.py b/legacy/examples_old/example4.py deleted file mode 100644 index 720ff961..00000000 --- a/legacy/examples_old/example4.py +++ /dev/null @@ -1,448 +0,0 @@ -__author__ = "github.com/wardsimon" -__version__ = "0.1.0" - -import json -from abc import ABCMeta -from abc import abstractmethod -from typing import Callable -from typing import List - -import numpy as np - -from easyscience import global_object -from easyscience.fitting import Fitter -from easyscience.Objects.component_serializer import ComponentSerializer -from easyscience.Objects.ObjectClasses import BaseObj -from easyscience.Objects.ObjectClasses import Parameter - -# This is a much more complex case where we have calculators, interfaces, interface factory and an -# inherited object (from `BaseObj`). In this case the Line class is available with/without an interface -# With an interface it connects to one of the calculator interfaces. This calculator interface then translates -# interface commands to calculator specific commands - - -class Calculator1: - """ - Generic calculator in the style of crysPy - """ - - def __init__(self, m: float = 1, c: float = 0): - """ - Create a calculator object with m and c - - :param m: gradient - :type m: float - :param c: intercept - :type c: float - """ - self.m = m - self.c = c - - def calculate(self, x_array: np.ndarray) -> np.ndarray: - """ - For a given x calculate the corresponding y - - :param x_array: array of data points to be calculated - :type x_array: np.ndarray - :return: points calculated at `x` - :rtype: np.ndarray - """ - return self.m * x_array + self.c - - -class Calculator2: - """ - Isolated calculator. This calculator can't have values set, it can - only load/save data and calculate from it. i.e in the style of crysfml - """ - - def __init__(self): - """ """ - self._data = {"m": 0, "c": 0} - - def calculate(self, x_array: np.ndarray) -> np.ndarray: - """ - For a given x calculate the corresponding y - - :param x_array: array of data points to be calculated - :type x_array: np.ndarray - :return: points calculated at `x` - :rtype: np.ndarray - """ - return self._data["m"] * x_array + self._data["c"] - - def export_data(self) -> str: - return json.dumps(self._data) - - def import_data(self, input_str: str): - self._data = json.loads(input_str) - - -class InterfaceTemplate(ComponentSerializer, metaclass=ABCMeta): - """ - This class is a template and defines all properties that an interface should have. - """ - - _interfaces = [] - _global_object = global_object - - def __init_subclass__(cls, is_abstract: bool = False, **kwargs): - """ - Initialise all subclasses so that they can be created in the factory - - :param is_abstract: Is this a subclass which shouldn't be dded - :type is_abstract: bool - :param kwargs: key word arguments - :type kwargs: dict - :return: None - :rtype: noneType - """ - super().__init_subclass__(**kwargs) - if not is_abstract: - cls._interfaces.append(cls) - - @abstractmethod - def get_value(self, value_label: str) -> float: - """ - Method to get a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :return: associated value - :rtype: float - """ - pass - - @abstractmethod - def set_value(self, value_label: str, value: float): - """ - Method to set a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :param value: new numeric value - :type value: float - :return: None - :rtype: noneType - """ - pass - - @abstractmethod - def fit_func(self, x_array: np.ndarray) -> np.ndarray: - """ - Function to perform a fit - - :param x_array: points to be calculated at - :type x_array: np.ndarray - :return: calculated points - :rtype: np.ndarray - """ - pass - - -class Interface1(InterfaceTemplate): - """ - A simple example interface using Calculator1 - """ - - def __init__(self): - # This interface will use calculator1 - self.calculator = Calculator1() - - def get_value(self, value_label: str) -> float: - """ - Method to get a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :return: associated value - :rtype: float - """ - return getattr(self.calculator, value_label, None) - - def set_value(self, value_label: str, value: float): - """ - Method to set a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :param value: new numeric value - :type value: float - :return: None - :rtype: noneType - """ - if self._global_object.debug: - print(f"Interface1: Value of {value_label} set to {value}") - setattr(self.calculator, value_label, value) - - def fit_func(self, x_array: np.ndarray) -> np.ndarray: - """ - Function to perform a fit - - :param x_array: points to be calculated at - :type x_array: np.ndarray - :return: calculated points - :rtype: np.ndarray - """ - return self.calculator.calculate(x_array) - - -class Interface2(InterfaceTemplate): - """ - This is a more complex template. Here we need to export_data data, - transfer it to the calculator (get and calculate) and import data - from the calculator (set) - """ - - def __init__(self): - """ - Set up a calculator and a local dict - """ - self.calculator = Calculator2() - self._data: dict = {} - - def get_value(self, value_label: str) -> float: - """ - Method to get a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :return: associated value - :rtype: float - """ - self._data = json.loads(self.calculator.export_data()) - return getattr(self._data, value_label, None) - - def set_value(self, value_label: str, value: float): - """ - Method to set a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :param value: new numeric value - :type value: float - :return: None - :rtype: noneType - """ - if self._global_object.debug: - print(f"Interface2: Value of {value_label} set to {value}") - self._data = json.loads(self.calculator.export_data()) - if value_label in self._data.keys(): - self._data[value_label] = value - self.calculator.import_data(json.dumps(self._data)) - - def fit_func(self, x_array: np.ndarray) -> np.ndarray: - """ - Function to perform a fit - - :param x_array: points to be calculated at - :type x_array: np.ndarray - :return: calculated points - :rtype: np.ndarray - """ - return self.calculator.calculate(x_array) - - -class InterfaceFactory: - """ - This class allows for the creation and transference of interfaces. - """ - - def __init__(self): - self._interfaces: List[InterfaceTemplate] = InterfaceTemplate._interfaces - self._current_interface = None - self.__interface_obj = None - self.create() - - def create(self, interface_name: str = None): - """ - Create an interface to a calculator from those initialized. Interfaces can be selected - by `interface_name` where `interface_name` is one of `obj.available_interfaces`. This - interface can now be accessed by obj() - - :param interface_name: name of interface to be created - :type interface_name: str - :return: None - :rtype: noneType - """ - if interface_name is None: - interface_name = self._interfaces[0].__name__ - - interfaces = self.available_interfaces - if interface_name in interfaces: - self._current_interface = self._interfaces[interfaces.index(interface_name)] - self.__interface_obj = self._current_interface() - - def switch(self, new_interface: str): - """ - Changes the current interface to a new interface. The current interface is destroyed and - all ComponentSerializer parameters carried over to the new interface. i.e. pick up where you left off. - - :param new_interface: name of new interface to be created - :type new_interface: str - :return: None - :rtype: noneType - """ - serialized = self.__interface_obj.as_dict() - interfaces = self.available_interfaces - if new_interface in interfaces: - self._current_interface: InterfaceTemplate = self._interfaces[ - interfaces.index(new_interface) - ] - self.__interface_obj = self._current_interface.from_dict(serialized) - else: - raise AttributeError - - @property - def available_interfaces(self) -> List[str]: - """ - Return all available interfaces. - - :return: List of available interface names - :rtype: List[str] - """ - return [this_interface.__name__ for this_interface in self._interfaces] - - @property - def current_interface(self): - """ - Returns the constructor for the currently selected interface - - :return: Interface constructor - :rtype: InterfaceTemplate - """ - return self._current_interface - - def fit_func(self, x_array: np.ndarray, *args, **kwargs) -> np.ndarray: - """ - Pass through to the underlying interfaces fitting function. - - :param x_array: points to be calculated at - :type x_array: np.ndarray - :param args: positional arguments for the fitting function - :type args: Any - :param kwargs: key/value pair arguments for the fitting function. - :type kwargs: Any - :return: points calculated at positional values `x` - :rtype: np.ndarray - """ - return self.__interface_obj.fit_function(x_array, *args, **kwargs) - - def generate_bindings(self, name): - """ - Automatically bind a `Parameter` to the corresponding interface. - - :param name: parameter name - :type name: str - :return: binding property - :rtype: property - """ - return property(self.__get_item(name), self.__set_item(self, name)) - - def __call__(self, *args, **kwargs) -> InterfaceTemplate: - return self.__interface_obj - - @staticmethod - def __get_item(key: str) -> Callable: - """ - Access the value of a key by a callable object - - :param key: name of parameter to be retrieved - :type key: str - :return: function to get key - :rtype: Callable - """ - - def inner(obj): - obj().get_value(key) - - return lambda obj: inner(obj) - - @staticmethod - def __set_item(obj, key): - """ - Set the value of a key by a callable object - - :param obj: object to be created from - :type obj: InterfaceFactory - :param key: name of parameter to be set - :type key: str - :return: function to set key - :rtype: Callable - """ - - def inner(value): - obj().set_value(key, value) - - return inner - - -class Line(BaseObj): - """ - Simple descriptor of a line. - """ - - _defaults = [Parameter("m", 1), Parameter("c", 0)] - - def __init__(self, interface_factory: InterfaceFactory = None): - """ - Create a line and add an interface if requested - - :param interface_factory: interface controller object - :type interface_factory: InterfaceFactory - """ - super().__init__(self.__class__.__name__, *self._defaults) - self.interface = interface_factory - self._set_interface() - - def _set_interface(self): - if self.interface: - # If an interface is given, generate bindings - for parameter in self.get_fit_parameters(): - name = parameter.name - self.interface.generate_bindings(name) - - @property - def gradient(self): - if self.interface: - return self.interface().get_value("m") - else: - return self.m.value - - @property - def intercept(self): - if self.interface: - return self.interface().get_value("c") - else: - return self.c.value - - def __repr__(self): - return f"Line: m={self.m}, c={self.c}" - - -interface = InterfaceFactory() -line = Line(interface_factory=interface) -f = Fitter(line, interface.fit_func) - -# y = 2x -1 -x = np.array([1, 2, 3], dtype=np.float64) -y = np.array([2, 4, 6], dtype=np.float64) - 1 - -f_res = f.fit(x, y) - -print("\n######### Interface 1 #########\n") -print(f_res) -print(line) - -# This gets the interface name `'Interface2'` -other_interface = interface.available_interfaces[1] -# Switch over the interfaces -line.interface.switch(other_interface) -# Reset the values so we don't cheat -line.m.value = 1 -line.c.value = 0 -f_res = f.fit(x, y) - -print("\n######### Interface 2 #########\n") -print(f_res) -print(line) diff --git a/legacy/examples_old/example5_broken.py b/legacy/examples_old/example5_broken.py deleted file mode 100644 index a7967ee8..00000000 --- a/legacy/examples_old/example5_broken.py +++ /dev/null @@ -1,367 +0,0 @@ -__author__ = "github.com/wardsimon" -__version__ = "0.1.0" - -import json -from abc import ABCMeta -from abc import abstractmethod -from typing import Callable - -import numpy as np - -from easyscience import global_object -from easyscience.fitting import Fitter -from easyscience.Objects.Base import BaseObj -from easyscience.Objects.Base import Parameter -from easyscience.Objects.component_serializer import ComponentSerializer - -# from easyscience.Objects.Base import LoggedProperty -from easyscience.Objects.Inferface import InterfaceFactoryTemplate - -# This is a much more complex case where we have calculators, interfaces, interface factory and an -# inherited object (from `BaseObj`). In this case the Line class is available with/without an interface -# With an interface it connects to one of the calculator interfaces. This calculator interface then translates -# interface commands to calculator specific commands - - -class Calculator1: - """ - Generic calculator in the style of crysPy - """ - - def __init__(self, m: float = 1, c: float = 0): - """ - Create a calculator object with m and c - - :param m: gradient - :type m: float - :param c: intercept - :type c: float - """ - self.m = m - self.c = c - - def calculate(self, x_array: np.ndarray) -> np.ndarray: - """ - For a given x calculate the corresponding y - - :param x_array: array of data points to be calculated - :type x_array: np.ndarray - :return: points calculated at `x` - :rtype: np.ndarray - """ - return self.m * x_array + self.c - - -class Calculator2: - """ - Isolated calculator. This calculator can't have values set, it can - only load/save data and calculate from it. i.e in the style of crysfml - """ - - def __init__(self): - """ """ - self._data = {"m": 0, "c": 0} - - def calculate(self, x_array: np.ndarray) -> np.ndarray: - """ - For a given x calculate the corresponding y - - :param x_array: array of data points to be calculated - :type x_array: np.ndarray - :return: points calculated at `x` - :rtype: np.ndarray - """ - return self._data["m"] * x_array + self._data["c"] - - def export_data(self) -> str: - return json.dumps(self._data) - - def import_data(self, input_str: str): - self._data = json.loads(input_str) - - -class InterfaceTemplate(ComponentSerializer, metaclass=ABCMeta): - """ - This class is a template and defines all properties that an interface should have. - """ - - _interfaces = [] - _global_object = global_object - - def __init_subclass__(cls, is_abstract: bool = False, **kwargs): - """ - Initialise all subclasses so that they can be created in the factory - - :param is_abstract: Is this a subclass which shouldn't be dded - :type is_abstract: bool - :param kwargs: key word arguments - :type kwargs: dict - :return: None - :rtype: noneType - """ - super().__init_subclass__(**kwargs) - if not is_abstract: - cls._interfaces.append(cls) - - @abstractmethod - def get_value(self, value_label: str) -> float: - """ - Method to get a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :return: associated value - :rtype: float - """ - pass - - @abstractmethod - def set_value(self, value_label: str, value: float): - """ - Method to set a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :param value: new numeric value - :type value: float - :return: None - :rtype: noneType - """ - pass - - @abstractmethod - def fit_func(self, x_array: np.ndarray) -> np.ndarray: - """ - Function to perform a fit - - :param x_array: points to be calculated at - :type x_array: np.ndarray - :return: calculated points - :rtype: np.ndarray - """ - pass - - -class Interface1(InterfaceTemplate): - """ - A simple example interface using Calculator1 - """ - - def __init__(self): - # This interface will use calculator1 - self.calculator = Calculator1() - - def get_value(self, value_label: str) -> float: - """ - Method to get a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :return: associated value - :rtype: float - """ - return getattr(self.calculator, value_label, None) - - def set_value(self, value_label: str, value: float): - """ - Method to set a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :param value: new numeric value - :type value: float - :return: None - :rtype: noneType - """ - if self._global_object.debug: - print(f"Interface1: Value of {value_label} set to {value}") - setattr(self.calculator, value_label, value) - - def fit_func(self, x_array: np.ndarray) -> np.ndarray: - """ - Function to perform a fit - - :param x_array: points to be calculated at - :type x_array: np.ndarray - :return: calculated points - :rtype: np.ndarray - """ - return self.calculator.calculate(x_array) - - -class Interface2(InterfaceTemplate): - """ - This is a more complex template. Here we need to export_data data, - transfer it to the calculator (get and calculate) and import data - from the calculator (set) - """ - - def __init__(self): - """ - Set up a calculator and a local dict - """ - self.calculator = Calculator2() - self._data: dict = {} - - def get_value(self, value_label: str) -> float: - """ - Method to get a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :return: associated value - :rtype: float - """ - self._data = json.loads(self.calculator.export_data()) - return getattr(self._data, value_label, None) - - def set_value(self, value_label: str, value: float): - """ - Method to set a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :param value: new numeric value - :type value: float - :return: None - :rtype: noneType - """ - if self._global_object.debug: - print(f"Interface2: Value of {value_label} set to {value}") - self._data = json.loads(self.calculator.export_data()) - if value_label in self._data.keys(): - self._data[value_label] = value - self.calculator.import_data(json.dumps(self._data)) - - def fit_func(self, x_array: np.ndarray) -> np.ndarray: - """ - Function to perform a fit - - :param x_array: points to be calculated at - :type x_array: np.ndarray - :return: calculated points - :rtype: np.ndarray - """ - return self.calculator.calculate(x_array) - - -class InterfaceFactory(InterfaceFactoryTemplate): - def __init__(self): - super(InterfaceFactory, self).__init__(InterfaceTemplate._interfaces) - - def generate_bindings(self, name, *args, **kwargs) -> property: - """ - Automatically bind a `Parameter` to the corresponding interface. - - :param name: parameter name - :type name: str - :return: binding property - :rtype: property - """ - return property(self.__get_item(name), self.__set_item(self, name)) - - @staticmethod - def __get_item(key: str) -> Callable: - """ - Access the value of a key by a callable object - - :param key: name of parameter to be retrieved - :type key: str - :return: function to get key - :rtype: Callable - """ - - def inner(obj): - obj().get_value(key) - - return lambda obj: inner(obj) - - @staticmethod - def __set_item(obj, key): - """ - Set the value of a key by a callable object - - :param obj: object to be created from - :type obj: InterfaceFactory - :param key: name of parameter to be set - :type key: str - :return: function to set key - :rtype: Callable - """ - - def inner(value): - obj().set_value(key, value) - - return inner - - -class Line(BaseObj): - """ - Simple descriptor of a line. - """ - - _defaults = [Parameter("m", 1), Parameter("c", 0)] - - def __init__(self, interface_factory: InterfaceFactory = None): - """ - Create a line and add an interface if requested. - - :param interface_factory: interface controller object - :type interface_factory: InterfaceFactory - """ - self.interface = interface_factory - super().__init__(self.__class__.__name__, *self._defaults) - self._set_interface() - - def _set_interface(self): - if self.interface: - # If an interface is given, generate bindings - for parameter in self.get_fit_parameters(): - name = parameter.name - self.set_binding(name, self.interface.generate_bindings) - - @property - def gradient(self): - # if self.interface: - # return self.interface().get_value('m') - # else: - return self.m.value - - @property - def intercept(self): - # if self.interface: - # return self.interface().get_value('c') - # else: - return self.c.value - - def __repr__(self): - return f"Line: m={self.m}, c={self.c}" - - -global_object.debug = True - -interface = InterfaceFactory() -line = Line(interface_factory=interface) -f = Fitter(line, interface.fit_func) - -# y = 2x -1 -x = np.array([1, 2, 3], dtype=np.float64) -y = 2 * x - 1 - -f_res = f.fit(x, y) - -print("\n######### Interface 1 #########\n") -print(f_res) -print(line) - -a = line.c - -# Now lets change fitting engine -f.switch_minimizer("bumps") -# Reset the values so we don't cheat -line.m = 1 -line.c = 0 -f_res = f.fit(x, y, weights=0.1 * np.ones_like(x)) -print("\n######### bumps fitting #########\n") -print(f_res) -print(line) diff --git a/legacy/examples_old/example6_broken.py b/legacy/examples_old/example6_broken.py deleted file mode 100644 index fe2dbbf2..00000000 --- a/legacy/examples_old/example6_broken.py +++ /dev/null @@ -1,446 +0,0 @@ -__author__ = "github.com/wardsimon" -__version__ = "0.1.0" - -import json -from abc import ABCMeta -from abc import abstractmethod -from typing import Callable - -import numpy as np - -from easyscience import global_object -from easyscience.fitting import Fitter -from easyscience.Objects.ObjectClasses import BaseObj -from easyscience.Objects.Variable import Parameter -from easyscience.Objects.component_serializer import ComponentSerializer -from easyscience.Objects.Inferface import InterfaceFactoryTemplate - -# This is a much more complex case where we have calculators, interfaces, interface factory and an -# inherited object (from `BaseObj`). In this case the Line class is available with/without an interface -# With an interface it connects to one of the calculator interfaces. This calculator interface then translates -# interface commands to calculator specific commands - - -class Calculator1: - """ - Generic calculator in the style of crysPy - """ - - class calcLine: - def __init__(self, m: float = 1, c: float = 0): - self.m = m - self.c = c - - def __call__(self, *args, **kwargs): - return self.m * args[0] + self.c - - class calcCurve: - def __init__(self, A=0, p=0, x_shift=0): - self.A = A - self.p = p - self.x_shift = x_shift - - def __call__(self, *args, **kwargs): - return self.A * np.sin((2 * np.pi / self.p) * (x + self.x_shift)) - - def __init__(self): - """ - Create a calculator object with m and c - - :param m: gradient - :type m: float - :param c: intercept - :type c: float - """ - self._line = [] - self._curve = [] - - def add_line(self, m: float = 1, c: float = 0): - self._line.append(self.calcLine(m, c)) - - def add_curve(self, A=0, p=0, x_shift=0): - self._curve.append(self.calcCurve(A, p, x_shift)) - - @property - def lines(self): - return self._line - - @property - def curves(self): - return self._curve - - def calculate(self, x_array: np.ndarray) -> np.ndarray: - """ - For a given x calculate the corresponding y - - :param x_array: array of data points to be calculated - :type x_array: np.ndarray - :return: points calculated at `x` - :rtype: np.ndarray - """ - y = np.zeros_like(x_array) - for line in self._line: - y += line(x_array) - for curve in self._curve: - y += curve(x_array) - return y - - -class InterfaceTemplate(ComponentSerializer, metaclass=ABCMeta): - """ - This class is a template and defines all properties that an interface should have. - """ - - _interfaces = [] - _global_object = global_object - - def __init_subclass__(cls, is_abstract: bool = False, **kwargs): - """ - Initialise all subclasses so that they can be created in the factory - - :param is_abstract: Is this a subclass which shouldn't be dded - :type is_abstract: bool - :param kwargs: key word arguments - :type kwargs: dict - :return: None - :rtype: noneType - """ - super().__init_subclass__(**kwargs) - if not is_abstract: - cls._interfaces.append(cls) - - @abstractmethod - def create_curve(self, A, p, x_shift): - pass - - @abstractmethod - def get_curve_value(self, curve, par, value_label: str) -> float: - """ - Method to get a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :return: associated value - :rtype: float - """ - pass - - @abstractmethod - def set_curve_value(self, curve, par, value_label: str, value: float): - """ - Method to set a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :param value: new numeric value - :type value: float - :return: None - :rtype: noneType - """ - pass - - @abstractmethod - def fit_func(self, x_array: np.ndarray) -> np.ndarray: - """ - Function to perform a fit - - :param x_array: points to be calculated at - :type x_array: np.ndarray - :return: calculated points - :rtype: np.ndarray - """ - pass - - -class Interface1(InterfaceTemplate): - """ - A simple example interface using Calculator1 - """ - - def __init__(self): - # This interface will use calculator1 - self.calculator = Calculator1() - - def get_value(self, value_label: str) -> float: - """ - Method to get a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :return: associated value - :rtype: float - """ - return getattr(self.calculator, value_label, None) - - def set_value(self, value_label: str, value: float): - """ - Method to set a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :param value: new numeric value - :type value: float - :return: None - :rtype: noneType - """ - if self._global_object.debug: - print(f"Interface1: Value of {value_label} set to {value}") - setattr(self.calculator, value_label, value) - - def fit_func(self, x_array: np.ndarray) -> np.ndarray: - """ - Function to perform a fit - - :param x_array: points to be calculated at - :type x_array: np.ndarray - :return: calculated points - :rtype: np.ndarray - """ - return self.calculator.calculate(x_array) - - -class Calculator2: - """ - Isolated calculator. This calculator can't have values set, it can - only load/save data and calculate from it. i.e in the style of crysfml - """ - - def __init__(self): - """ """ - self._data = {"m": 0, "c": 0} - - def calculate(self, x_array: np.ndarray) -> np.ndarray: - """ - For a given x calculate the corresponding y - - :param x_array: array of data points to be calculated - :type x_array: np.ndarray - :return: points calculated at `x` - :rtype: np.ndarray - """ - return self._data["m"] * x_array + self._data["c"] - - def export_data(self) -> str: - return json.dumps(self._data) - - def import_data(self, input_str: str): - self._data = json.loads(input_str) - - -class Interface2(InterfaceTemplate): - """ - This is a more complex template. Here we need to export_data data, - transfer it to the calculator (get and calculate) and import data - from the calculator (set) - """ - - def __init__(self): - """ - Set up a calculator and a local dict - """ - self.calculator = Calculator2() - self._data: dict = {} - - def get_value(self, value_label: str) -> float: - """ - Method to get a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :return: associated value - :rtype: float - """ - self._data = json.loads(self.calculator.export_data()) - return getattr(self._data, value_label, None) - - def set_value(self, value_label: str, value: float): - """ - Method to set a value from the calculator - - :param value_label: parameter name to get - :type value_label: str - :param value: new numeric value - :type value: float - :return: None - :rtype: noneType - """ - if self._global_object.debug: - print(f"Interface2: Value of {value_label} set to {value}") - self._data = json.loads(self.calculator.export_data()) - if value_label in self._data.keys(): - self._data[value_label] = value - self.calculator.import_data(json.dumps(self._data)) - - def fit_func(self, x_array: np.ndarray) -> np.ndarray: - """ - Function to perform a fit - - :param x_array: points to be calculated at - :type x_array: np.ndarray - :return: calculated points - :rtype: np.ndarray - """ - return self.calculator.calculate(x_array) - - -class InterfaceFactory(InterfaceFactoryTemplate): - def __init__(self): - super(InterfaceFactory, self).__init__(InterfaceTemplate._interfaces) - - def generate_bindings(self, name, *args, **kwargs) -> property: - """ - Automatically bind a `Parameter` to the corresponding interface. - - :param name: parameter name - :type name: str - :return: binding property - :rtype: property - """ - return property(self.__get_item(name), self.__set_item(self, name)) - - @staticmethod - def __get_item(key: str) -> Callable: - """ - Access the value of a key by a callable object - - :param key: name of parameter to be retrieved - :type key: str - :return: function to get key - :rtype: Callable - """ - - def inner(obj): - obj().get_value(key) - - return lambda obj: inner(obj) - - @staticmethod - def __set_item(obj, key): - """ - Set the value of a key by a callable object - - :param obj: object to be created from - :type obj: InterfaceFactory - :param key: name of parameter to be set - :type key: str - :return: function to set key - :rtype: Callable - """ - - def inner(value): - obj().set_value(key, value) - - return inner - - -class Line(BaseObj): - """ - Simple descriptor of a line. - """ - - _defaults = [Parameter("m", 1), Parameter("c", 0)] - - def __init__(self, interface_factory: InterfaceFactory = None): - """ - Create a line and add an interface if requested - - :param interface_factory: interface controller object - :type interface_factory: InterfaceFactory - """ - self.interface = interface_factory - super().__init__(self.__class__.__name__, *self._defaults) - self._set_interface() - - def _set_interface(self): - if self.interface: - # If an interface is given, generate bindings - for parameter in self.get_fit_parameters(): - name = parameter.name - self.set_binding(name, self.interface.generate_bindings) - - def __repr__(self): - return f"Line: m={self.m}, c={self.c}" - - -class Curve(BaseObj): - """ - Simple descriptor of a line. - """ - - _defaults = [Parameter("A", 1.0), Parameter("p", np.pi), Parameter("x_shift", 0.0)] - - def __init__(self, interface_factory: InterfaceFactory = None): - """ - Create a line and add an interface if requested - - :param interface_factory: interface controller object - :type interface_factory: InterfaceFactory - """ - self.interface = interface_factory - super().__init__(self.__class__.__name__, *self._defaults) - self._set_interface() - - def _set_interface(self): - if self.interface: - # If an interface is given, generate bindings - for parameter in self.get_fit_parameters(): - name = parameter.name - self.set_binding(name, self.interface.generate_bindings) - - def __repr__(self): - return f"Curve: A={self.A}, p={self.p}, x_shift={self.x_shift}" - - -class Model(BaseObj): - """ - Simple descriptor of a line. - """ - - _defaults = [Line, Curve] - - def __init__(self, interface_factory: InterfaceFactory = None): - """ - Create a line and add an interface if requested - - :param interface_factory: interface controller object - :type interface_factory: InterfaceFactory - """ - self.interface = interface_factory - super().__init__( - self.__class__.__name__, *[default(interface) for default in self._defaults] - ) - - def __repr__(self): - this_str = "Hybrid Model:\n" - for name, item in self._kwargs.items(): - this_str += f"{name}: " - for par in item._defaults: - this_str += f"{par.name} = {par.value}, " - this_str = this_str[:-2] + "\n" - return this_str - - -interface = InterfaceFactory() -hybrid = Model(interface_factory=interface) -f = Fitter(hybrid, interface.fit_func) -print(hybrid) - -# y = 2x -1 -x = np.array([1, 2, 3]) -y = 2 * x - 1 - -f_res = f.fit(x, y) - -print("\n######### Interface 1 #########\n") -print(f_res) -print(hybrid) - -# Now lets change fitting engine -f.switch_minimizer("bumps") -# Reset the values so we don't cheat -hybrid.m = 1 -hybrid.c = 0 -f_res = f.fit(x, y, weights=0.1 * np.ones_like(x)) -print("\n######### bumps fitting #########\n") -print(f_res) -print(hybrid) diff --git a/src/easyscience/fitting/calculators/__init__.py b/src/easyscience/fitting/calculators/__init__.py deleted file mode 100644 index 4104ddae..00000000 --- a/src/easyscience/fitting/calculators/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# SPDX-FileCopyrightText: 2025 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from .interface_factory import InterfaceFactoryTemplate - -__all__ = [InterfaceFactoryTemplate] diff --git a/src/easyscience/fitting/calculators/interface_factory.py b/src/easyscience/fitting/calculators/interface_factory.py deleted file mode 100644 index 772131d3..00000000 --- a/src/easyscience/fitting/calculators/interface_factory.py +++ /dev/null @@ -1,271 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -from typing import TYPE_CHECKING -from typing import Any -from typing import Callable -from typing import List -from typing import NamedTuple -from typing import Optional -from typing import Type - -from easyscience import global_object - -if TYPE_CHECKING: - from abc import ABCMeta - - from .. import Fitter - - -class InterfaceFactoryTemplate: - """ - This class allows for the creation and transference of interfaces. - """ - - def __init__(self, interface_list: List[ABCMeta], *args, **kwargs): - self._interfaces: List[ABCMeta] = interface_list - self._current_interface: ABCMeta - self.__interface_obj: None = None - self.create(*args, **kwargs) - - def create(self, *args: Any, interface_name: str | None = None, **kwargs: Any) -> None: - """ - Create an interface to a calculator from those initialized. - - Interfaces can be selected by ``interface_name`` where - ``interface_name`` is one of ``obj.available_interfaces``. This - interface can now be accessed by obj(). - - Parameters - ---------- - *args : Any - Positional arguments forwarded to the interface constructor. - interface_name : str | None, default=None - Name of interface to be created. - - **kwargs : Any - Keyword arguments forwarded to the interface constructor. - - Raises - ------ - NotImplementedError - If no interfaces are available to instantiate. - """ - if interface_name is None: - if len(self._interfaces) > 0: - # Fallback name - interface_name = self.return_name(self._interfaces[0]) - else: - raise NotImplementedError - interfaces = self.available_interfaces - if interface_name in interfaces: - self._current_interface = self._interfaces[interfaces.index(interface_name)] - self.__interface_obj = self._current_interface(*args, **kwargs) - - def switch(self, new_interface: str, fitter: Optional[Type[Fitter]] = None) -> None: - """ - Changes the current interface to a new interface. - - The current interface is destroyed and all - parameters carried over to the new interface. i.e. pick up where - you left off. - - Parameters - ---------- - new_interface : str - Name of new interface to be created. - fitter : Optional[Type[Fitter]], default=None - Fitting interface which contains the fitting object which - may have bindings which will be updated. By default, None. - - Raises - ------ - AttributeError - If ``new_interface`` is not a valid interface name. - """ - interfaces = self.available_interfaces - if new_interface in interfaces: - self._current_interface = self._interfaces[interfaces.index(new_interface)] - self.__interface_obj = self._current_interface() - else: - raise AttributeError('The user supplied interface is not valid.') - if fitter is not None: - if hasattr(fitter, '_fit_object'): - obj = getattr(fitter, '_fit_object') - try: - if hasattr(obj, 'update_bindings'): - obj.update_bindings() - except Exception as e: - global_object.log.getLogger('fitting.calculators').warning( - 'Unable to auto generate bindings.\n%s', e - ) - elif hasattr(fitter, 'generate_bindings'): - try: - fitter.generate_bindings() - except Exception as e: - global_object.log.getLogger('fitting.calculators').warning( - 'Unable to auto generate bindings.\n%s', e - ) - - @property - def available_interfaces(self) -> List[str]: - """ - Return all available interfaces. - - Returns - ------- - List[str] - List of available interface names. - """ - return [self.return_name(this_interface) for this_interface in self._interfaces] - - @property - def current_interface(self) -> ABCMeta: - """ - Returns the constructor for the currently selected interface. - - Returns - ------- - ABCMeta - Interface constructor. - """ - return self._current_interface - - @property - def current_interface_name(self) -> str: - """ - Returns the constructor name for the currently selected - interface. - - Returns - ------- - str - Interface constructor name. - """ - return self.return_name(self._current_interface) - - @property - def fit_func( - self, - ) -> Callable: # , x_array: np.ndarray, *args, **kwargs) -> np.ndarray: - """ - Pass through to the underlying interfaces fitting function. - - Returns - ------- - Callable - Callable proxy to the underlying interface fit function. - """ - - def __fit_func(*args, **kwargs): - return self.__interface_obj.fit_func(*args, **kwargs) - - return __fit_func - - def call(self, *args, **kwargs): - return self.fit_func(*args, **kwargs) - - def generate_bindings(self, model: Any, *args: Any, ifun: Any = None, **kwargs: Any) -> None: - """ - Automatically bind a ``Parameter`` to the corresponding - interface. - - Parameters - ---------- - model : Any - Model whose variables should be bound. - *args : Any - Positional arguments reserved for interface-specific binding - hooks. - ifun : Any, default=None - Optional interface hook. By default, None. - **kwargs : Any - Keyword arguments reserved for interface-specific binding - hooks. - """ - - class_links = self.__interface_obj.create(model) - if hasattr(model, '_get_linkable_attributes'): - props = model._get_linkable_attributes() - else: - props = model.get_all_variables() - props_names = [prop.display_name for prop in props] - for item in class_links: - for item_key in item.name_conversion.keys(): - if item_key not in props_names: - continue - idx = props_names.index(item_key) - prop = props[idx] - - # Should be fetched this way to ensure we don't get value from callback - if hasattr(prop, 'value_no_call_back'): - # Property object - prop_value = prop.value_no_call_back - else: - # Descriptor object - prop_value = prop.value - - prop._callback = item.make_prop(item_key) - prop._callback.fset(prop_value) - - def __call__(self, *args, **kwargs) -> None: - return self.__interface_obj - - def __reduce__(self): - return ( - self.__state_restore__, - ( - self.__class__, - self.current_interface_name, - ), - ) - - @staticmethod - def __state_restore__(cls, interface_str): - obj = cls() - if interface_str in obj.available_interfaces: - obj.switch(interface_str) - return obj - - @staticmethod - def return_name(this_interface) -> str: - """Return an interfaces name.""" - interface_name = this_interface.__name__ - if hasattr(this_interface, 'name'): - interface_name = getattr(this_interface, 'name') - return interface_name - - -class ItemContainer(NamedTuple): - link_name: str - name_conversion: dict - getter_fn: Callable - setter_fn: Callable - - def make_prop(self, parameter_name) -> property: - return property( - fget=self.__make_getter(parameter_name), - fset=self.__make_setter(parameter_name), - ) - - def convert_key(self, lookup_key: str) -> str: - key = self.name_conversion.get(lookup_key, None) - return key - - def __make_getter(self, get_name: str) -> Callable: - - def get_value(): - inner_key = self.name_conversion.get(get_name, None) - return self.getter_fn(self.link_name, inner_key) - - return get_value - - def __make_setter(self, get_name: str) -> Callable: - - def set_value(value): - inner_key = self.name_conversion.get(get_name, None) - self.setter_fn(self.link_name, **{inner_key: value}) - - return set_value diff --git a/src/easyscience/fitting/engine_base.py b/src/easyscience/fitting/engine_base.py index edfd58e6..5f6e3df5 100644 --- a/src/easyscience/fitting/engine_base.py +++ b/src/easyscience/fitting/engine_base.py @@ -195,7 +195,7 @@ def _fit_function(x: np.ndarray, **kwargs) -> np.ndarray: np.ndarray Points calculated at ``x``. """ - # Update the `Parameter` values and the callback if needed + # Update the `Parameter` values # TODO THIS IS NOT THREAD SAFE :-( for name, value in kwargs.items(): @@ -205,7 +205,6 @@ def _fit_function(x: np.ndarray, **kwargs) -> np.ndarray: if self._cached_pars[par_name].value != value: self._cached_pars[par_name].value = value - # Since we are calling the parameter fset will be called. # TODO Pre processing here return_data = func(x) # TODO Loading or manipulating data here diff --git a/src/easyscience/job/analysis.py b/src/easyscience/job/analysis.py index c53be0a8..2b3aa183 100644 --- a/src/easyscience/job/analysis.py +++ b/src/easyscience/job/analysis.py @@ -3,6 +3,7 @@ from abc import ABCMeta from abc import abstractmethod +from typing import Any from typing import Optional import numpy as np @@ -19,15 +20,13 @@ class AnalysisBase(ModelBase, metaclass=ABCMeta): def __init__( self, + *, display_name: Optional[str] = None, - interface=None, unique_name: Optional[str] = None, ): super(AnalysisBase, self).__init__(unique_name=unique_name, display_name=display_name) self._calculator = None self._minimizer = None - self._fitter = None - self.interface = interface @abstractmethod def calculate_theory(self, x: np.ndarray, **kwargs) -> np.ndarray: @@ -38,15 +37,19 @@ def fit(self, x: np.ndarray, y: np.ndarray, e: np.ndarray, **kwargs) -> None: raise NotImplementedError('fit not implemented') @property - def calculator(self) -> str: - if self._calculator is None and self.interface is not None: - self._calculator = self.interface.current_interface_name + def calculator(self) -> Any: + """ + The calculator chosen by the technique library. + + Core does not interpret this object; its type and meaning are + defined by the library implementing the analysis. + """ return self._calculator @calculator.setter - def calculator(self, value) -> None: + def calculator(self, value: Any) -> None: # TODO: check if the calculator is available for the given JobType - self.interface.switch(value, fitter=self._fitter) + self._calculator = value @property def minimizer(self) -> MinimizerBase: diff --git a/src/easyscience/variable/descriptor_base.py b/src/easyscience/variable/descriptor_base.py index ba1614df..825bfeee 100644 --- a/src/easyscience/variable/descriptor_base.py +++ b/src/easyscience/variable/descriptor_base.py @@ -20,9 +20,7 @@ class DescriptorBase(NewBase, metaclass=abc.ABCMeta): It contains all information to describe a single unique property of an object. This description includes a value as well as optionally a - unit, description and url (for reference material). Also implemented - is a callback so that the value can be read/set from a linked - library object. + unit, description and url (for reference material). A ``Descriptor`` is typically something which describes part of a model and is non-fittable and generally changes the state of an diff --git a/src/easyscience/variable/parameter.py b/src/easyscience/variable/parameter.py index f86ad789..499bdd81 100644 --- a/src/easyscience/variable/parameter.py +++ b/src/easyscience/variable/parameter.py @@ -3,10 +3,8 @@ from __future__ import annotations -import copy import numbers import re -import weakref from typing import Any from typing import Dict from typing import List @@ -20,7 +18,6 @@ from scipp import Variable from easyscience import global_object -from easyscience.base_classes.serializer_base import SerializerBase from easyscience.global_object.undo_redo import property_stack from .descriptor_number import DescriptorNumber @@ -47,7 +44,6 @@ def __init__( description: Optional[str] = None, url: Optional[str] = None, display_name: Optional[str] = None, - callback: property = property(), **kwargs: Any, # Additional keyword arguments (used for (de)serialization) ): """ @@ -84,9 +80,6 @@ def __init__( display_name : Optional[str], default=None The name of the object as it should be displayed. Falls back to ``unique_name`` when not given. By default, None. - callback : property, default=property() - Callback used to synchronize the parameter with an external - model. **kwargs : Any Additional keyword arguments used during serialization. @@ -136,13 +129,6 @@ def __init__( **kwargs, # Additional keyword arguments (used for (de)serialization) ) - self._callback = callback # Callback is used by interface to link to model - if self._callback.fdel is not None: - weakref.finalize(self, self._callback.fdel) - - # Create additional fitting elements - self._initial_scalar = copy.deepcopy(self._scalar) - @classmethod def from_dependency( cls, @@ -217,9 +203,6 @@ def _update(self) -> None: if self._desired_unit is not None: self._convert_unit(self._desired_unit) - if self._callback.fset is not None: - self._callback.fset(self._scalar.value) - self._notify_observers() else: global_object.log.getLogger('variable').warning( @@ -508,18 +491,6 @@ def dependency_map(self, new_map: Dict[str, DescriptorNumber]) -> None: 'Dependency map is read-only. Use `make_dependent_on` to change the dependency map.' ) - @property - def value_no_call_back(self) -> numbers.Number: - """ - Get the currently hold value of self suppressing call back. - - Returns - ------- - numbers.Number - Value of self without unit. - """ - return self._scalar.value - @property def full_value(self) -> Variable: """ @@ -552,10 +523,6 @@ def value(self) -> numbers.Number: numbers.Number Value of self without unit. """ - if self._callback.fget is not None: - existing_value = self._callback.fget() - if existing_value != self._scalar.value: - self._scalar.value = existing_value return self._scalar.value @value.setter @@ -590,9 +557,6 @@ def value(self, value: numbers.Number) -> None: self._scalar.value = value - if self._callback.fset is not None: - self._callback.fset(self._scalar.value) - # Notify observers of the change self._notify_observers() else: @@ -885,17 +849,6 @@ def free(self) -> bool: def free(self, value: bool) -> None: self.fixed = not value - @property - def _arg_spec(self) -> List[str]: - """ - Names of the constructor arguments the serializer has to collect. - - ``callback`` links to a calculator object. - Holds a ``property`` that cannot be serialized. - """ - _, args = SerializerBase.get_arg_spec(self.__class__.__init__) - return [name for name in args if name != 'callback'] - def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: """ Overwrite the to_dict method to handle dependency information. @@ -1012,11 +965,6 @@ def from_dict(cls, obj_dict: dict) -> 'Parameter': return param - def __copy__(self) -> Parameter: - new_obj = super().__copy__() - new_obj._callback = property() - return new_obj - def __repr__(self) -> str: """Return printable representation of a Parameter object.""" super_str = super().__repr__() diff --git a/tests/helpers/easy_test_models.py b/tests/helpers/easy_test_models.py index 4c65a316..8b6a51c8 100644 --- a/tests/helpers/easy_test_models.py +++ b/tests/helpers/easy_test_models.py @@ -27,7 +27,7 @@ class CoefficientModel(ModelBase): A minimal model holding its parameters in an ``EasyList``. It exercises nested containers in serialization, copying, fitting - parameter discovery and calculator bindings. Plain numbers become + parameter discovery and external calculators. Plain numbers become ``Parameter`` objects named ``c0``, ``c1``, ... The model evaluates as a polynomial with the coefficients in ``numpy.polyval`` order. diff --git a/tests/integration/fitting/test_stateless_calculator.py b/tests/integration/fitting/test_stateless_calculator.py new file mode 100644 index 00000000..79a2917b --- /dev/null +++ b/tests/integration/fitting/test_stateless_calculator.py @@ -0,0 +1,177 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +""" +Fitting through an external, stateless calculator. + +Core keeps the only copy of parameter state. A calculator reads the +model when asked to calculate and does not write back. These tests pin +that contract down with a plain class, without any core calculator +machinery. +""" + +import numpy as np +import pytest +from easy_test_models import CoefficientModel + +from easyscience import Parameter +from easyscience import global_object +from easyscience.fitting import Fitter +from easyscience.fitting.multi_fitter import MultiFitter + + +class PolynomialCalculator: + """ + A stateless calculator owned by a technique library. + + It keeps no copy of any parameter: every evaluation reads the + current values from the model it is handed. + """ + + def __init__(self): + self.n_calls = 0 + + def calculate(self, model: CoefficientModel, x: np.ndarray) -> np.ndarray: + self.n_calls += 1 + coefficients = [float(c.value) for c in model.coefficients] + return np.polyval(coefficients, x) + + def fit_func_for(self, model: CoefficientModel): + return lambda x: self.calculate(model, x) + + +@pytest.fixture(autouse=True) +def clear(): + global_object.map._clear() + yield + global_object.map._clear() + if global_object.stack: + global_object.stack.clear() + global_object.stack.enabled = False + + +def noise(x: np.ndarray) -> np.ndarray: + # Small, reproducible noise so every engine can estimate uncertainties + return np.random.default_rng(0).normal(0.0, 0.01, size=x.shape) + + +def snapshot(model: CoefficientModel): + return [ + (p.unique_name, p.value, p.variance, p.min, p.max, p.fixed, p.independent) + for p in model.coefficients + ] + + +def test_calculation_reads_current_values_and_leaves_model_untouched(): + # When + model = CoefficientModel(coefficients=[2.0, 1.0]) + calculator = PolynomialCalculator() + x = np.linspace(0, 1, 5) + before = snapshot(model) + + # Then Expect + np.testing.assert_allclose(calculator.calculate(model, x), 2.0 * x + 1.0) + assert snapshot(model) == before + + # Then # Edits made through core are seen by the next evaluation + model.coefficients[0].value = 3.0 + + # Expect + np.testing.assert_allclose(calculator.calculate(model, x), 3.0 * x + 1.0) + + +@pytest.mark.parametrize('fit_engine', ['LMFit', 'Bumps', 'DFO']) +def test_fit_with_dependent_parameter(fit_engine): + # When # The intercept is tied to the slope, so only the slope is fitted + slope = Parameter(1.0, display_name='slope', min=-10, max=10) + intercept = Parameter.from_dependency('0.5 * s', {'s': slope}, display_name='intercept') + model = CoefficientModel(coefficients=[slope, intercept]) + calculator = PolynomialCalculator() + x = np.linspace(0, 10, 50) + y = 3.0 * x + 1.5 + noise(x) + weights = np.ones_like(x) + + fitter = Fitter(model, calculator.fit_func_for(model)) + fitter.switch_minimizer(fit_engine) + + # Then + result = fitter.fit(x=x, y=y, weights=weights) + + # Expect + assert result.success + assert calculator.n_calls > 0 + assert [p.unique_name for p in model.get_fit_parameters()] == [slope.unique_name] + assert slope.value == pytest.approx(3.0, abs=1e-2) + assert intercept.value == pytest.approx(0.5 * slope.value) + np.testing.assert_allclose(calculator.calculate(model, x), y, atol=0.1) + + +@pytest.mark.parametrize('fit_engine', ['LMFit', 'Bumps', 'DFO']) +def test_multi_fit_with_shared_parameter(fit_engine): + # When # Two models share their intercept + shared = Parameter(0.0, display_name='shared', min=-10, max=10) + model_1 = CoefficientModel( + coefficients=[Parameter(1.0, display_name='slope_1', min=-10, max=10), shared] + ) + model_2 = CoefficientModel( + coefficients=[Parameter(1.0, display_name='slope_2', min=-10, max=10), shared] + ) + calculator = PolynomialCalculator() + x_1 = np.linspace(0, 5, 30) + x_2 = np.linspace(-5, 0, 40) + y_1 = 2.0 * x_1 - 1.0 + noise(x_1) + y_2 = -3.0 * x_2 - 1.0 + noise(x_2) + + fitter = MultiFitter( + [model_1, model_2], + [calculator.fit_func_for(model_1), calculator.fit_func_for(model_2)], + ) + fitter.switch_minimizer(fit_engine) + + # Then + results = fitter.fit( + x=[x_1, x_2], y=[y_1, y_2], weights=[np.ones_like(x_1), np.ones_like(x_2)] + ) + + # Expect + assert all(result.success for result in results) + assert len({p.unique_name for p in fitter.fit_object.get_fit_parameters()}) == 3 + assert model_1.coefficients[0].value == pytest.approx(2.0, abs=1e-2) + assert model_2.coefficients[0].value == pytest.approx(-3.0, abs=1e-2) + assert shared.value == pytest.approx(-1.0, abs=1e-2) + assert model_1.coefficients[1] is model_2.coefficients[1] + + +def test_fit_is_one_undo_step(): + # When + if not global_object.stack: + global_object.instantiate_stack() + global_object.stack.enabled = True + slope = Parameter(1.0, display_name='slope', min=-10, max=10) + intercept = Parameter(0.0, display_name='intercept', min=-10, max=10) + model = CoefficientModel(coefficients=[slope, intercept]) + calculator = PolynomialCalculator() + x = np.linspace(0, 10, 50) + y = 3.0 * x + 1.5 + global_object.stack.clear() + + fitter = Fitter(model, calculator.fit_func_for(model)) + + # Then + result = fitter.fit(x=x, y=y, weights=np.ones_like(x)) + + # Expect + assert result.success + fitted = (slope.value, intercept.value) + assert len(global_object.stack.history) == 1 + + # Then + global_object.stack.undo() + + # Expect + assert (slope.value, intercept.value) == (1.0, 0.0) + + # Then + global_object.stack.redo() + + # Expect + assert (slope.value, intercept.value) == fitted diff --git a/tests/unit/base_classes/test_serializer_base.py b/tests/unit/base_classes/test_serializer_base.py index 42989845..5dd159d8 100644 --- a/tests/unit/base_classes/test_serializer_base.py +++ b/tests/unit/base_classes/test_serializer_base.py @@ -315,17 +315,29 @@ def test_convert_to_dict_with_arg_spec(self, serializer, clear): assert result['value'] == 10 assert 'special_attr' not in result - def test_convert_to_dict_nested_parameter_omits_callback(self, serializer, clear): - """A Parameter nested in a model serializes without its callback""" - model = MockModelWithParameter(p=Parameter(2.0, display_name='p')) + def test_convert_to_dict_nested_parameter_round_trip(self, serializer, clear): + """A Parameter nested in a model survives a dict round trip""" + model = MockModelWithParameter( + p=Parameter(2.0, unit='m', variance=0.1, min=0, max=5, fixed=True, display_name='p') + ) result = serializer._convert_to_dict(model) nested = result['p'] assert nested['@class'] == 'Parameter' - assert nested['value'] == 2.0 - assert 'callback' not in nested json.dumps(nested) + global_object.map._clear() + restored = Parameter.from_dict(nested) + + assert restored is not model._p + assert restored.value == 2.0 + assert restored.unit == 'm' + assert restored.variance == 0.1 + assert restored.min == 0 + assert restored.max == 5 + assert restored.fixed is True + assert restored.display_name == 'p' + def test_convert_to_dict_with_custom_convert_to_dict(self, serializer, clear): """Test _convert_to_dict with custom _convert_to_dict method""" obj = MockSerializerWithConvertToDict('custom_test', 5) diff --git a/tests/unit/fitting/calculators/__init__.py b/tests/unit/fitting/calculators/__init__.py deleted file mode 100644 index b1990ec8..00000000 --- a/tests/unit/fitting/calculators/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - diff --git a/tests/unit/fitting/calculators/test_interface_factory.py b/tests/unit/fitting/calculators/test_interface_factory.py deleted file mode 100644 index cc351731..00000000 --- a/tests/unit/fitting/calculators/test_interface_factory.py +++ /dev/null @@ -1,606 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import logging -from unittest.mock import MagicMock - -import pytest - -from easyscience import global_object -from easyscience.fitting.calculators.interface_factory import InterfaceFactoryTemplate -from easyscience.fitting.calculators.interface_factory import ItemContainer - - -class TestInterfaceFactoryTemplate: - @pytest.fixture - def clear(self): - """Clear global map to avoid test contamination""" - global_object.map._clear() - yield - global_object.map._clear() - - @pytest.fixture - def mock_interface1(self): - """Create a mock interface class""" - - class MockInterface1: - name = 'MockInterface1' - - def __init__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs - - def fit_func(self, *args, **kwargs): - return 'result1' - - def create(self, model): - return [] - - return MockInterface1 - - @pytest.fixture - def mock_interface2(self): - """Create a second mock interface class""" - - class MockInterface2: - def __init__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs - - def fit_func(self, *args, **kwargs): - return 'result2' - - def create(self, model): - return [] - - return MockInterface2 - - @pytest.fixture - def mock_interface_with_custom_name(self): - """Create a mock interface with custom name attribute""" - - class MockInterfaceCustom: - name = 'CustomName' - - def __init__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs - - def fit_func(self, *args, **kwargs): - return 'custom_result' - - def create(self, model): - return [] - - return MockInterfaceCustom - - @pytest.fixture - def factory_single_interface(self, clear, mock_interface1): - """Create factory with single interface""" - return InterfaceFactoryTemplate([mock_interface1]) - - @pytest.fixture - def factory_multiple_interfaces(self, clear, mock_interface1, mock_interface2): - """Create factory with multiple interfaces""" - return InterfaceFactoryTemplate([mock_interface1, mock_interface2]) - - def test_init_with_single_interface_default_selection(self, clear, mock_interface1): - """Test initialization with single interface uses first as default""" - # When - factory = InterfaceFactoryTemplate([mock_interface1]) - - # Then - assert factory._interfaces == [mock_interface1] - assert factory._current_interface == mock_interface1 - assert factory() is not None - assert isinstance(factory(), mock_interface1) - - def test_init_with_empty_interface_list_raises_error(self, clear): - """Test initialization with empty interface list raises NotImplementedError""" - # When/Then - with pytest.raises(NotImplementedError): - InterfaceFactoryTemplate([]) - - def test_init_with_specific_interface_name(self, clear, mock_interface1, mock_interface2): - """Test initialization with specific interface name""" - # When - factory = InterfaceFactoryTemplate( - [mock_interface1, mock_interface2], interface_name='MockInterface2' - ) - - # Then - assert factory._current_interface == mock_interface2 - assert isinstance(factory(), mock_interface2) - - def test_init_passes_args_and_kwargs_to_interface(self, clear, mock_interface1): - """Test that args and kwargs are passed to interface constructor""" - # When - factory = InterfaceFactoryTemplate([mock_interface1], 'arg1', 'arg2', kwarg1='value1') - - # Then - interface_obj = factory() - assert interface_obj.args == ('arg1', 'arg2') - assert interface_obj.kwargs == {'kwarg1': 'value1'} - - def test_create_with_interface_name(self, factory_multiple_interfaces, mock_interface2): - """Test create method with specific interface name""" - # When - factory_multiple_interfaces.create(interface_name='MockInterface2') - - # Then - assert factory_multiple_interfaces._current_interface == mock_interface2 - assert isinstance(factory_multiple_interfaces(), mock_interface2) - - def test_create_without_interface_name_uses_first( - self, factory_multiple_interfaces, mock_interface1 - ): - """Test create method without interface name uses first interface""" - # When - factory_multiple_interfaces.create() - - # Then - assert factory_multiple_interfaces._current_interface == mock_interface1 - - def test_available_interfaces_property(self, factory_multiple_interfaces): - """Test available_interfaces property returns correct names""" - # When - interfaces = factory_multiple_interfaces.available_interfaces - - # Then - assert 'MockInterface1' in interfaces - assert 'MockInterface2' in interfaces - assert len(interfaces) == 2 - - def test_current_interface_property(self, factory_single_interface, mock_interface1): - """Test current_interface property returns correct interface class""" - # When/Then - assert factory_single_interface.current_interface == mock_interface1 - - def test_current_interface_name_property(self, factory_single_interface): - """Test current_interface_name property returns correct name""" - # When/Then - assert factory_single_interface.current_interface_name == 'MockInterface1' - - def test_switch_to_valid_interface(self, factory_multiple_interfaces, mock_interface2): - """Test switching to a valid interface""" - # When - factory_multiple_interfaces.switch('MockInterface2') - - # Then - assert factory_multiple_interfaces._current_interface == mock_interface2 - assert isinstance(factory_multiple_interfaces(), mock_interface2) - - def test_switch_to_invalid_interface_raises_error(self, factory_single_interface): - """Test switching to invalid interface raises AttributeError""" - # When/Then - with pytest.raises(AttributeError, match='The user supplied interface is not valid'): - factory_single_interface.switch('NonExistentInterface') - - def test_switch_with_fitter_having_fit_object_with_update_bindings( - self, factory_single_interface - ): - """Test switch with fitter that has _fit_object with update_bindings method""" - # Given - mock_fit_object = MagicMock() - mock_fit_object.update_bindings = MagicMock() - mock_fitter = MagicMock() - mock_fitter._fit_object = mock_fit_object - - # When - factory_single_interface.switch('MockInterface1', fitter=mock_fitter) - - # Then - mock_fit_object.update_bindings.assert_called_once() - - def test_switch_with_fitter_having_generate_bindings(self, factory_single_interface): - """Test switch with fitter that has generate_bindings method""" - # Given - mock_fitter = MagicMock() - mock_fitter.generate_bindings = MagicMock() - del mock_fitter._fit_object # Ensure _fit_object doesn't exist - - # When - factory_single_interface.switch('MockInterface1', fitter=mock_fitter) - - # Then - mock_fitter.generate_bindings.assert_called_once() - - def test_switch_with_fitter_exception_handling( - self, factory_single_interface, caplog: 'pytest.LogCaptureFixture' - ): - """Test switch handles exceptions in fitter binding updates gracefully""" - # Given - mock_fit_object = MagicMock() - mock_fit_object.update_bindings.side_effect = Exception('Test exception') - mock_fitter = MagicMock() - mock_fitter._fit_object = mock_fit_object - - # When - with caplog.at_level(logging.WARNING, logger='easyscience.fitting.calculators'): - factory_single_interface.switch('MockInterface1', fitter=mock_fitter) - - # Then - assert 'Unable to auto generate bindings' in caplog.text - assert 'Test exception' in caplog.text - - def test_fit_func_property_returns_callable(self, factory_single_interface): - """Test fit_func property returns a callable""" - # When - fit_func = factory_single_interface.fit_func - - # Then - assert callable(fit_func) - - def test_fit_func_calls_interface_fit_func(self, factory_single_interface): - """Test fit_func calls the underlying interface's fit_func""" - # When - result = factory_single_interface.fit_func('arg1', kwarg1='value1') - - # Then - assert result == 'result1' - - def test_call_method(self, factory_single_interface): - """Test call method delegates to fit_func""" - # When - result = factory_single_interface.call('arg1', kwarg1='value1') - - # Then - assert result == 'result1' - - def test_generate_bindings_with_matching_properties(self, factory_single_interface): - """Test generate_bindings method with matching properties""" - # Given - mock_model = MagicMock() - mock_prop = MagicMock() - mock_prop.display_name = 'test_param' - mock_prop.value = 42 - mock_model._get_linkable_attributes.return_value = [mock_prop] - - mock_item = MagicMock() - mock_item.name_conversion = {'test_param': 'internal_param'} - mock_item.make_prop.return_value = MagicMock() - - # Mock the interface object's create method - interface_obj = factory_single_interface() - interface_obj.create = MagicMock(return_value=[mock_item]) - - # When - factory_single_interface.generate_bindings(mock_model) - - # Then - mock_item.make_prop.assert_called_once_with('test_param') - assert mock_prop._callback is not None - - def test_generate_bindings_with_value_no_call_back_property(self, factory_single_interface): - """Test generate_bindings handles properties with value_no_call_back""" - # Given - mock_model = MagicMock() - mock_prop = MagicMock() - mock_prop.display_name = 'test_param' - mock_prop.value_no_call_back = 24 - mock_model._get_linkable_attributes.return_value = [mock_prop] - - mock_item = MagicMock() - mock_item.name_conversion = {'test_param': 'internal_param'} - mock_callback = MagicMock() - mock_item.make_prop.return_value = mock_callback - - # Mock the interface object's create method - interface_obj = factory_single_interface() - interface_obj.create = MagicMock(return_value=[mock_item]) - - # When - factory_single_interface.generate_bindings(mock_model) - - # Then - mock_callback.fset.assert_called_once_with(24) - - def test_generate_bindings_skips_non_matching_properties(self, factory_single_interface): - """Test generate_bindings skips properties not in name_conversion""" - # Given - mock_model = MagicMock() - mock_prop = MagicMock() - mock_prop.display_name = 'non_matching_param' - mock_model._get_linkable_attributes.return_value = [mock_prop] - - mock_item = MagicMock() - mock_item.name_conversion = {'different_param': 'internal_param'} - - # Mock the interface object's create method - interface_obj = factory_single_interface() - interface_obj.create = MagicMock(return_value=[mock_item]) - - # When - factory_single_interface.generate_bindings(mock_model) - - # Then - mock_item.make_prop.assert_not_called() - - def test_call_dunder_method_returns_interface_obj(self, factory_single_interface): - """Test __call__ method returns the interface object""" - # When - result1 = factory_single_interface() - result2 = factory_single_interface() - - # Then - assert result1 is not None - assert result1 is result2 # Should return the same object - - def test_reduce_method_for_pickle_support(self, factory_single_interface): - """Test __reduce__ method for pickle serialization support""" - # When - result = factory_single_interface.__reduce__() - - # Then - assert len(result) == 2 - assert result[0] == InterfaceFactoryTemplate.__state_restore__ - assert result[1][0] == InterfaceFactoryTemplate - assert result[1][1] == 'MockInterface1' - - def test_state_restore_static_method_implementation_issue( - self, clear, mock_interface1, mock_interface2 - ): - """Test __state_restore__ static method - demonstrates implementation issue""" - # Given - original_factory = InterfaceFactoryTemplate([mock_interface1, mock_interface2]) - - # When/Then - this implementation has a bug where it tries to call cls() without arguments - # which would fail since the constructor requires interface_list parameter - with pytest.raises(TypeError): - InterfaceFactoryTemplate.__state_restore__(InterfaceFactoryTemplate, 'MockInterface2') - - def test_return_name_static_method_with_name_attribute(self, mock_interface_with_custom_name): - """Test return_name static method with interface having name attribute""" - # When - name = InterfaceFactoryTemplate.return_name(mock_interface_with_custom_name) - - # Then - assert name == 'CustomName' - - def test_return_name_static_method_without_name_attribute(self, mock_interface1): - """Test return_name static method without name attribute uses __name__""" - # When - name = InterfaceFactoryTemplate.return_name(mock_interface1) - - # Then - assert name == 'MockInterface1' - - def test_switch_with_fitter_generate_bindings_exception( - self, factory_single_interface, caplog: 'pytest.LogCaptureFixture' - ): - """Test switch handles exceptions in fitter.generate_bindings gracefully""" - # Given - mock_fitter = MagicMock() - mock_fitter.generate_bindings.side_effect = Exception('Generate bindings test exception') - # Ensure _fit_object doesn't exist so it goes to the elif branch - if hasattr(mock_fitter, '_fit_object'): - del mock_fitter._fit_object - - # When - with caplog.at_level(logging.WARNING, logger='easyscience.fitting.calculators'): - factory_single_interface.switch('MockInterface1', fitter=mock_fitter) - - # Then - assert 'Unable to auto generate bindings' in caplog.text - assert 'Generate bindings test exception' in caplog.text - - def test_generate_bindings_with_property_without_value_no_call_back( - self, factory_single_interface - ): - """Test generate_bindings handles properties without value_no_call_back (descriptor objects)""" - # Given - mock_model = MagicMock() - mock_prop = MagicMock() - mock_prop.display_name = 'test_param' - mock_prop.value = 99 # This will be used since no value_no_call_back attribute - # Explicitly remove value_no_call_back to force the else branch - del mock_prop.value_no_call_back - mock_model._get_linkable_attributes.return_value = [mock_prop] - - mock_item = MagicMock() - mock_item.name_conversion = {'test_param': 'internal_param'} - mock_callback = MagicMock() - mock_item.make_prop.return_value = mock_callback - - interface_obj = factory_single_interface() - interface_obj.create = MagicMock(return_value=[mock_item]) - - # When - factory_single_interface.generate_bindings(mock_model) - - # Then - mock_callback.fset.assert_called_once_with(99) - - -class TestItemContainer: - @pytest.fixture - def mock_getter(self): - """Mock getter function""" - - def getter(link_name, key): - return f'got_{link_name}_{key}' - - return getter - - @pytest.fixture - def mock_setter(self): - """Mock setter function""" - - def setter(link_name, **kwargs): - pass - - return setter - - @pytest.fixture - def item_container(self, mock_getter, mock_setter): - """Create ItemContainer instance""" - return ItemContainer( - link_name='test_link', - name_conversion={'param1': 'internal_param1', 'param2': 'internal_param2'}, - getter_fn=mock_getter, - setter_fn=mock_setter, - ) - - def test_item_container_creation(self, item_container): - """Test ItemContainer can be created with required fields""" - # Then - assert item_container.link_name == 'test_link' - assert item_container.name_conversion == { - 'param1': 'internal_param1', - 'param2': 'internal_param2', - } - assert callable(item_container.getter_fn) - assert callable(item_container.setter_fn) - - def test_convert_key_with_existing_key(self, item_container): - """Test convert_key method with existing key""" - # When - result = item_container.convert_key('param1') - - # Then - assert result == 'internal_param1' - - def test_convert_key_with_non_existing_key(self, item_container): - """Test convert_key method with non-existing key returns None""" - # When - result = item_container.convert_key('non_existing') - - # Then - assert result is None - - def test_make_prop_returns_property(self, item_container): - """Test make_prop method returns a property object""" - # When - prop = item_container.make_prop('param1') - - # Then - assert isinstance(prop, property) - assert prop.fget is not None - assert prop.fset is not None - - def test_make_getter_function_behavior(self, item_container): - """Test the behavior of the __make_getter method indirectly""" - # When - prop = item_container.make_prop('param1') - - # Then - assert isinstance(prop, property) - assert prop.fget is not None - assert callable(prop.fget) - - def test_make_setter_function_behavior(self, item_container): - """Test the behavior of the __make_setter method indirectly""" - # Given - setter_calls = [] - - def mock_setter(link_name, **kwargs): - setter_calls.append((link_name, kwargs)) - - container = ItemContainer( - link_name='test_link', - name_conversion={'param1': 'internal_param1'}, - getter_fn=lambda x, y: None, - setter_fn=mock_setter, - ) - - # When - prop = container.make_prop('param1') - - # Then - assert isinstance(prop, property) - assert prop.fset is not None - assert callable(prop.fset) - - def test_convert_key_behavior_in_property_creation(self, item_container): - """Test that convert_key behavior is used correctly in property creation""" - # When - prop1 = item_container.make_prop('param1') # exists in name_conversion - prop2 = item_container.make_prop('non_existing_param') # doesn't exist - - # Then - # Both should create valid property objects - assert isinstance(prop1, property) - assert isinstance(prop2, property) - # The difference is in the internal key mapping which affects runtime behavior - - def test_actual_getter_setter_execution(self): - """Test that getter and setter functions are actually executed""" - # Given - getter_calls = [] - setter_calls = [] - - def mock_getter(link_name, key): - getter_calls.append((link_name, key)) - return f'value_for_{key}' - - def mock_setter(link_name, **kwargs): - setter_calls.append((link_name, kwargs)) - - container = ItemContainer( - link_name='test_link', - name_conversion={'param1': 'internal_param1'}, - getter_fn=mock_getter, - setter_fn=mock_setter, - ) - - # When - prop = container.make_prop('param1') - - # Execute the getter function directly (this covers the actual getter execution) - getter_func = prop.fget - getter_result = getter_func() # type: ignore - - # Execute the setter function directly (this covers the actual setter execution) - setter_func = prop.fset - setter_func('test_value') # type: ignore - - # Then - assert getter_result == 'value_for_internal_param1' - assert len(getter_calls) == 1 - assert getter_calls[0] == ('test_link', 'internal_param1') - - assert len(setter_calls) == 1 - assert setter_calls[0] == ('test_link', {'internal_param1': 'test_value'}) - - -def test_generate_bindings_with_model_base(): - """Bindings are generated for a real ModelBase via get_all_variables.""" - from easy_test_models import CoefficientModel - - global_object.map._clear() - storage = {} - - def getter(link_name, key): - return storage[link_name][key] - - def setter(link_name, **kwargs): - storage.setdefault(link_name, {}).update(kwargs) - - class Calculator: - name = 'Calculator' - - def create(self, model): - return [ - ItemContainer( - link_name='poly', - name_conversion={'c0': 'a', 'c1': 'b'}, - getter_fn=getter, - setter_fn=setter, - ) - ] - - def fit_func(self, *args, **kwargs): - return None - - poly = CoefficientModel(coefficients=[1.0, 2.0]) - factory = InterfaceFactoryTemplate([Calculator]) - - factory.generate_bindings(poly) - - # Current values are pushed to the calculator on binding - assert storage['poly'] == {'a': 1.0, 'b': 2.0} - - # And later changes are forwarded - poly.coefficients[1].value = 5.0 - assert storage['poly']['b'] == 5.0 - global_object.map._clear() diff --git a/tests/unit/fitting/test_calculators_removed.py b/tests/unit/fitting/test_calculators_removed.py new file mode 100644 index 00000000..96c41ea7 --- /dev/null +++ b/tests/unit/fitting/test_calculators_removed.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import importlib + +import pytest + + +def test_calculators_package_removed(): + # The interface factory was removed in 3.0 (#294); calculators are + # owned by the technique libraries. + with pytest.raises(ModuleNotFoundError): + importlib.import_module('easyscience.fitting.calculators') diff --git a/tests/unit/job/test_job.py b/tests/unit/job/test_job.py index d6cf4fb4..f5531700 100644 --- a/tests/unit/job/test_job.py +++ b/tests/unit/job/test_job.py @@ -6,7 +6,6 @@ from easyscience import global_object from easyscience.base_classes import ModelBase -from easyscience.fitting.calculators.interface_factory import InterfaceFactoryTemplate from easyscience.job import AnalysisBase from easyscience.job import ExperimentBase from easyscience.job import JobBase @@ -48,20 +47,6 @@ def fit(self, *args, **kwargs): return super().fit(*args, **kwargs) -class CalculatorA: - name = 'A' - - def create(self, model): - return [] - - def fit_func(self, *args, **kwargs): - return None - - -class CalculatorB(CalculatorA): - name = 'B' - - @pytest.mark.parametrize('cls', [Analysis, ExperimentBase, Job, TheoreticalModelBase]) def test_job_classes_are_model_bases(cls): obj = cls(display_name='name', unique_name=f'{cls.__name__}_unique') @@ -76,25 +61,25 @@ class TestAnalysisBase: def test_defaults(self): analysis = Analysis(display_name='analysis') - assert analysis.interface is None assert analysis.calculator is None assert analysis.minimizer is None assert str(analysis) == 'Analysis: analysis' - def test_calculator_comes_from_interface(self): - interface = InterfaceFactoryTemplate([CalculatorA, CalculatorB]) - - analysis = Analysis(interface=interface) + def test_calculator_setter(self): + analysis = Analysis() + calculator = object() - assert analysis.calculator == 'A' + analysis.calculator = calculator - def test_calculator_setter_switches_interface(self): - interface = InterfaceFactoryTemplate([CalculatorA, CalculatorB]) - analysis = Analysis(interface=interface) + assert analysis.calculator is calculator - analysis.calculator = 'B' + def test_interface_argument_removed(self): + with pytest.raises(TypeError): + Analysis(interface=object()) - assert interface.current_interface_name == 'B' + def test_arguments_are_keyword_only(self): + with pytest.raises(TypeError): + Analysis('analysis') def test_minimizer_setter(self): analysis = Analysis() diff --git a/tests/unit/variable/test_parameter.py b/tests/unit/variable/test_parameter.py index c9a6f283..e937688e 100644 --- a/tests/unit/variable/test_parameter.py +++ b/tests/unit/variable/test_parameter.py @@ -17,7 +17,6 @@ class TestParameter: @pytest.fixture def parameter(self) -> Parameter: - self.mock_callback = MagicMock() parameter = Parameter( value=1, unit='m', @@ -27,7 +26,6 @@ def parameter(self) -> Parameter: description='description', url='url', display_name='display_name', - callback=self.mock_callback, ) return parameter @@ -55,7 +53,6 @@ def test_init(self, parameter: Parameter): assert parameter._min.unit == 'm' assert parameter._max.value == 10 assert parameter._max.unit == 'm' - assert parameter._callback == self.mock_callback assert parameter._independent == True # From super @@ -70,7 +67,6 @@ def test_init(self, parameter: Parameter): def test_init_value_min_exception(self): # When - mock_callback = MagicMock() value = -1 # Then Expect @@ -84,12 +80,10 @@ def test_init_value_min_exception(self): description='description', url='url', display_name='display_name', - callback=mock_callback, ) def test_init_value_max_exception(self): # When - mock_callback = MagicMock() value = 100 # Then Expect @@ -103,7 +97,6 @@ def test_init_value_max_exception(self): description='description', url='url', display_name='display_name', - callback=mock_callback, ) def test_make_dependent_on(self, normal_parameter: Parameter): @@ -130,11 +123,8 @@ def test_make_dependent_on(self, normal_parameter: Parameter): normal_parameter.value == 4 self.compare_parameters(normal_parameter, 2 * independent_parameter) - def test_dependent_parameter_update_pushes_value_to_callback(self, parameter: Parameter): - # When # Simulate a calculator bound to the parameter via the callback (issue #281) - calculator = {'value': parameter._scalar.value} - self.mock_callback.fget.side_effect = lambda: calculator['value'] - self.mock_callback.fset.side_effect = lambda value: calculator.update(value=value) + def test_dependent_parameter_update_follows_independent(self, parameter: Parameter): + # When independent_parameter = Parameter( display_name='independent', value=1, unit='m', variance=0.01, min=0, max=10 ) @@ -144,28 +134,21 @@ def test_dependent_parameter_update_pushes_value_to_callback(self, parameter: Pa dependency_expression='2*a', dependency_map={'a': independent_parameter} ) - # Expect # Making the parameter dependent pushes the computed value to the calculator - assert calculator['value'] == 2.0 - self.mock_callback.fset.assert_called_with(2.0) - assert self.mock_callback.fset.call_count == 1 + # Expect # Making the parameter dependent computes its value + assert parameter.value == 2.0 + assert parameter.unit == 'm' # Then independent_parameter.value = 3 - # Expect # The change of the independent parameter propagates to the calculator - assert calculator['value'] == 6.0 - self.mock_callback.fset.assert_called_with(6.0) - assert self.mock_callback.fset.call_count == 2 - # Reading the value must not return a stale calculator value + # Expect # The change of the independent parameter propagates (issue #281) assert parameter.value == 6.0 + assert parameter.unit == 'm' - def test_dependent_parameter_update_pushes_value_to_callback_with_desired_unit( + def test_dependent_parameter_update_follows_independent_with_desired_unit( self, parameter: Parameter ): - # When # Simulate a calculator bound to the parameter via the callback (issue #281) - calculator = {'value': parameter._scalar.value} - self.mock_callback.fget.side_effect = lambda: calculator['value'] - self.mock_callback.fset.side_effect = lambda value: calculator.update(value=value) + # When independent_parameter = Parameter( display_name='independent', value=1, unit='m', variance=0.01, min=0, max=10 ) @@ -177,16 +160,16 @@ def test_dependent_parameter_update_pushes_value_to_callback_with_desired_unit( desired_unit='cm', ) - # Expect # The calculator receives the value converted to the desired unit - assert calculator['value'] == 200.0 - self.mock_callback.fset.assert_called_with(200.0) + # Expect # The value is converted to the desired unit + assert parameter.value == 200.0 + assert parameter.unit == 'cm' # Then independent_parameter.value = 2 # Expect - assert calculator['value'] == 400.0 assert parameter.value == 400.0 + assert parameter.unit == 'cm' def test_dependent_parameter_make_dependent_on_with_desired_unit( self, normal_parameter: Parameter @@ -815,8 +798,6 @@ def test_min(self, parameter: Parameter): def test_set_min(self, parameter: Parameter): # When Then - self.mock_callback.fget.return_value = 1.0 # Ensure fget returns a scalar value - parameter.min = 0.1 # Expect @@ -1027,34 +1008,33 @@ def test_repr_fixed(self, parameter: Parameter): == "" ) - def test_value_match_callback(self, parameter: Parameter): - # When - self.mock_callback.fget.return_value = 1.0 + def test_callback_argument_removed(self): + # When Then Expect + with pytest.raises(TypeError, match='callback'): + Parameter(1.0, callback=property()) - # Then Expect - assert parameter.value == 1.0 - assert parameter._callback.fget.call_count == 1 + def test_callback_attributes_removed(self, parameter: Parameter): + # When Then Expect + assert not hasattr(parameter, 'value_no_call_back') + assert not hasattr(parameter, '_callback') - def test_value_no_match_callback(self, parameter: Parameter): + def test_value_read_is_repeatable_and_has_no_side_effects(self, parameter: Parameter): # When - self.mock_callback.fget.return_value = 2.0 + observer = MagicMock() + parameter._attach_observer(observer) - # Then Expect - assert parameter.value == 2.0 - assert parameter._callback.fget.call_count == 1 + # Then + values = [parameter.value for _ in range(3)] - def test_set_value(self, parameter: Parameter): - # When - # First call returns 1.0 that is used to enforce the undo/redo functionality to register the value has changed - # Second and third call returns 2.0 is used in the constraint check - self.mock_callback.fget.side_effect = [1.0, 2.0, 2.0] + # Expect + assert values == [1.0, 1.0, 1.0] + assert observer.mock_calls == [] - # Then + def test_set_value(self, parameter: Parameter): + # When Then parameter.value = 2 # Expect - parameter._callback.fset.assert_called_with(2) - assert parameter._callback.fset.call_count == 1 assert parameter._scalar == sc.scalar(2, unit='m') def test_set_value_dependent_parameter(self, normal_parameter: Parameter): @@ -1100,13 +1080,11 @@ def test_set_error_dependent_parameter(self, normal_parameter: Parameter): def test_copy(self, parameter: Parameter): # When Then - self.mock_callback.fget.return_value = 1.0 # Ensure fget returns a scalar value parameter_copy = parameter.__copy__() # Expect assert type(parameter_copy) == Parameter assert id(parameter_copy._scalar) != id(parameter._scalar) - assert isinstance(parameter_copy._callback, property) assert parameter_copy._scalar == parameter._scalar assert parameter_copy._min == parameter._min @@ -1158,10 +1136,7 @@ def test_addition_with_parameter( expected: Parameter, expected_reverse: Parameter, ): - # When - parameter._callback = property() - - # Then + # When Then result = parameter + test result_reverse = test + parameter @@ -1203,7 +1178,6 @@ def test_addition_with_scalar(self): def test_addition_with_descriptor_number(self, parameter: Parameter): # When - parameter._callback = property() descriptor_number = DescriptorNumber(display_name='test', value=1, variance=0.1, unit='cm') # Then @@ -1291,10 +1265,7 @@ def test_subtraction_with_parameter( expected: Parameter, expected_reverse: Parameter, ): - # When - parameter._callback = property() - - # Then + # When Then result = parameter - test result_reverse = test - parameter @@ -1358,7 +1329,6 @@ def test_subtraction_with_scalar(self): def test_subtraction_with_descriptor_number(self, parameter: Parameter): # When - parameter._callback = property() descriptor_number = DescriptorNumber(display_name='test', value=1, variance=0.1, unit='cm') # Then @@ -1448,10 +1418,7 @@ def test_multiplication_with_parameter( expected: Parameter, expected_reverse: Parameter, ): - # When - parameter._callback = property() - - # Then + # When Then result = parameter * test result_reverse = test * parameter @@ -1556,10 +1523,7 @@ def test_multiplication_with_parameter_nan_cases(self, test, expected, expected_ def test_multiplication_with_descriptor_number( self, parameter: Parameter, test, expected, expected_reverse ): - # When - parameter._callback = property() - - # Then + # When Then result = parameter * test result_reverse = test * parameter @@ -1599,10 +1563,7 @@ def test_multiplication_with_descriptor_number( def test_multiplication_with_scalar( self, parameter: Parameter, test, expected, expected_reverse ): - # When - parameter._callback = property() - - # Then + # When Then result = parameter * test result_reverse = test * parameter @@ -1676,10 +1637,7 @@ def test_multiplication_with_scalar( ids=['crossing_zero', 'only_positive', 'only_negative'], ) def test_division_with_parameter(self, parameter: Parameter, test, expected, expected_reverse): - # When - parameter._callback = property() - - # Then + # When Then result = parameter / test result_reverse = test / parameter @@ -1775,10 +1733,7 @@ def test_division_with_parameter_remaining_cases(self, first, second, expected): def test_division_with_descriptor_number_and_number( self, parameter: Parameter, test, expected, expected_reverse ): - # When - parameter._callback = property() - - # Then + # When Then result = parameter / test result_reverse = test / parameter @@ -1809,10 +1764,7 @@ def test_division_with_descriptor_number_and_number( ids=['descriptor_number', 'number'], ) def test_zero_value_divided_by_parameter(self, parameter: Parameter, test, expected): - # When - parameter._callback = property() - - # Then + # When Then result = test / parameter # Expect @@ -1906,7 +1858,6 @@ def test_division_with_descriptor_number_missing_cases(self, first, second, expe ) def test_divide_parameter_by_zero(self, parameter: Parameter, test): # When - parameter._callback = property() # Then Expect with pytest.raises(ZeroDivisionError): From 70e69a01b592a40be766c10f6990ca62997ed21a Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 16 Oct 2026 13:22:54 +0200 Subject: [PATCH 2/3] PR review issues addressed --- docs/docs/user-guide/external-calculators.md | 81 ++++++++++++++++---- 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/docs/docs/user-guide/external-calculators.md b/docs/docs/user-guide/external-calculators.md index 6138baff..3eaef5d8 100644 --- a/docs/docs/user-guide/external-calculators.md +++ b/docs/docs/user-guide/external-calculators.md @@ -164,9 +164,12 @@ outside the fit, as an explicit step the user can see. Because the calculator only reads the model, it is not the place to watch a fit's progress. To see the parameter values at each iteration, -pass a `progress_callback` to `fit()`. Continuing the minimal example: +pass a `progress_callback` to `fit()`. Starting again from a fresh, +independent line (the dependent intercept from the previous section +would not be in the payload, see below): ```python +model = Line(slope=1.0, intercept=0.0) history = [] @@ -197,6 +200,11 @@ The callback receives a dictionary with these keys: | `parameter_values` | Values of the fitted parameters at the reported point, indexed by `unique_name` | | `refresh_plots`, `finished` | Hints for user interfaces | +`parameter_values` holds exactly the parameters returned by +`model.get_fit_parameters()`: the free, independent ones. Fixed and +dependent parameters are not in it. To report a dependent value, read it +from the model or recompute it from the free values in the payload. + Its return value is ignored. To stop a fit from the callback, see [Stopping a Fit](#stopping-a-fit). @@ -209,11 +217,13 @@ minimizer: | Bumps | once per optimizer step | the optimizer's current point for that step | | DFO | on every function evaluation | the best point found so far | -With LMFit you will see iteration `-1` for the starting point, small -steps of about 1e-5 while LMFit estimates the gradient numerically, and -the last iteration number twice. Skip `iteration < 0` and repeated -numbers to keep only real steps. With DFO the reported values change -only when an evaluation improves on the best point. +With LMFit `iteration` is an evaluation counter, not a step index: it +starts at `-1`, the first few callbacks all describe the starting point, +the small steps of about 1e-5 are LMFit estimating the gradient +numerically, and the last number is reported twice. Compare the values +rather than the counter if you only want real steps. With DFO the +reported values change only when an evaluation improves on the best +point. If you need a hook on every evaluation rather than every iteration, put it in the fit function your calculator returns: it is called each time @@ -224,12 +234,12 @@ and reads the current values from the model. EasyScience has no single cancel switch that works the same way on every minimizer. The options and how each minimizer responds: -| How | LMFit | Bumps | DFO | -| ------------------------------------ | ------------------------ | ------------------------------------------------ | ----------------------------------- | -| Raise from `progress_callback` | stops, raises `FitError` | stops, raises `FitError` | stops, raises `FitError` | -| Raise from the fit function | stops, raises `FitError` | stops only after logging every failed evaluation | stops, raises `FitError` | -| `abort_test=` passed to `fit()` | ignored with a warning | stops, returns `success=False` | raises `FitError` (unknown keyword) | -| `max_evaluations=` passed to `fit()` | stops at the limit | stops at the limit | stops at the limit | +| How | LMFit | Bumps | DFO | +| --------------------------------- | ------------------------- | ------------------------------------------------ | ----------------------------------- | +| Raise from `progress_callback` | stops, raises `FitError` | stops, raises `FitError` | stops, raises `FitError` | +| Raise from the fit function | stops, raises `FitError` | stops only after logging every failed evaluation | stops, raises `FitError` | +| `abort_test=` passed to `fit()` | ignored with a warning | stops, returns `success=False` | raises `FitError` (unknown keyword) | +| `fitter.max_evaluations` property | stops after N evaluations | stops after N optimizer steps | stops after N evaluations | ### Raising from the progress callback @@ -298,6 +308,47 @@ to the end, and DFO rejects it with a `FitError`. ### Limiting the number of evaluations -`max_evaluations` stops a fit after a fixed number of evaluations on -every minimizer. It is a budget, not a cancel button, but it keeps a fit -from running for longer than you are prepared to wait. +Set the budget on the `Fitter` before calling `fit()`. Do not pass +`max_evaluations=` (or `tolerance=`) as a keyword to `fit()`: the +`Fitter` already forwards its own value, and a second one raises +`TypeError: got multiple values for keyword argument` on every +minimizer. + +```python +fitter.max_evaluations = 100 +result = fitter.fit(x=x, y=y, weights=np.ones_like(x)) +if not result.success: + print(result.message) +``` + +The budget means different things to the minimizers. For LMFit and DFO +it is a limit on function evaluations. For Bumps it is forwarded as the +optimizer's `steps` setting, and one step can contain several +evaluations, so the calculator may be called many more times than the +number you set. The `iterations` reported in the result and in the +progress payload count the same quantity as the budget for each +minimizer, so they are not comparable across minimizers. It is a budget, +not a cancel button, and it does not bound elapsed time when an +evaluation is slow. + +## Migrating from `InterfaceFactoryTemplate` + +EasyScience 3.0 removes the calculator factory and the parameter +callbacks that let a backend own parameter state. Libraries built on the +old mechanism need these changes: + +- Replace imports of `easyscience.fitting.calculators` + (`InterfaceFactoryTemplate`, `ItemContainer`) with calculator code + owned by your library. Backend selection and switching live there too. +- Remove `Parameter(..., callback=...)`. It is no longer accepted and + raises `TypeError`. Read the current value with `Parameter.value` + inside your fit function instead; `value_no_call_back` is gone because + `value` no longer calls a backend. +- Remove binding generation. The only thing EasyScience needs from your + library is the callable handed to `Fitter(model, fit_function)`. +- `AnalysisBase` no longer takes an `interface` argument. Its + constructor arguments are keyword-only, and `AnalysisBase.calculator` + stores whatever object your library chooses. +- If your code replaced a calculator instance after creating a `Fitter`, + hand the fitter a new fit function. A closure created earlier still + captures the previous calculator. From adaed6152c15c9216ad1c7f04a3614440952771e Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 16 Oct 2026 14:07:27 +0200 Subject: [PATCH 3/3] more PR review comments --- src/easyscience/fitting/fitter.py | 2 +- src/easyscience/job/analysis.py | 16 ---------------- tests/unit/fitting/test_calculators_removed.py | 13 ------------- tests/unit/job/test_job.py | 9 --------- 4 files changed, 1 insertion(+), 39 deletions(-) delete mode 100644 tests/unit/fitting/test_calculators_removed.py diff --git a/src/easyscience/fitting/fitter.py b/src/easyscience/fitting/fitter.py index a1dd64e0..4d4f98f1 100644 --- a/src/easyscience/fitting/fitter.py +++ b/src/easyscience/fitting/fitter.py @@ -47,7 +47,7 @@ def evaluate(self, pars=None) -> np.ndarray: # TODO: remove this method when we are ready to adjust the dependent products def initialize(self, fit_object: object, fit_function: Callable) -> None: """ - Set the model and callable in the calculator interface. + Set the model and callable. Parameters ---------- diff --git a/src/easyscience/job/analysis.py b/src/easyscience/job/analysis.py index 2b3aa183..06defcf2 100644 --- a/src/easyscience/job/analysis.py +++ b/src/easyscience/job/analysis.py @@ -25,7 +25,6 @@ def __init__( unique_name: Optional[str] = None, ): super(AnalysisBase, self).__init__(unique_name=unique_name, display_name=display_name) - self._calculator = None self._minimizer = None @abstractmethod @@ -36,21 +35,6 @@ def calculate_theory(self, x: np.ndarray, **kwargs) -> np.ndarray: def fit(self, x: np.ndarray, y: np.ndarray, e: np.ndarray, **kwargs) -> None: raise NotImplementedError('fit not implemented') - @property - def calculator(self) -> Any: - """ - The calculator chosen by the technique library. - - Core does not interpret this object; its type and meaning are - defined by the library implementing the analysis. - """ - return self._calculator - - @calculator.setter - def calculator(self, value: Any) -> None: - # TODO: check if the calculator is available for the given JobType - self._calculator = value - @property def minimizer(self) -> MinimizerBase: return self._minimizer diff --git a/tests/unit/fitting/test_calculators_removed.py b/tests/unit/fitting/test_calculators_removed.py deleted file mode 100644 index 96c41ea7..00000000 --- a/tests/unit/fitting/test_calculators_removed.py +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import importlib - -import pytest - - -def test_calculators_package_removed(): - # The interface factory was removed in 3.0 (#294); calculators are - # owned by the technique libraries. - with pytest.raises(ModuleNotFoundError): - importlib.import_module('easyscience.fitting.calculators') diff --git a/tests/unit/job/test_job.py b/tests/unit/job/test_job.py index f5531700..2e48dd21 100644 --- a/tests/unit/job/test_job.py +++ b/tests/unit/job/test_job.py @@ -61,18 +61,9 @@ class TestAnalysisBase: def test_defaults(self): analysis = Analysis(display_name='analysis') - assert analysis.calculator is None assert analysis.minimizer is None assert str(analysis) == 'Analysis: analysis' - def test_calculator_setter(self): - analysis = Analysis() - calculator = object() - - analysis.calculator = calculator - - assert analysis.calculator is calculator - def test_interface_argument_removed(self): with pytest.raises(TypeError): Analysis(interface=object())