diff --git a/news/fitbase-standards.rst b/news/fitbase-standards.rst new file mode 100644 index 00000000..25c3476a --- /dev/null +++ b/news/fitbase-standards.rst @@ -0,0 +1,26 @@ +**Added:** + +* + +**Changed:** + +* Rewrite the ``diffpy.srfit.fitbase`` docstrings in NumPy style, and correct + those that described the wrong types, defaults or raised exceptions. + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* Fix the ``equationFromString`` deprecation warning, which pointed to a + nonexistent ``RecipeOrganizer.get_equation_from_string`` instead of + ``diffpy.srfit.fitbase.recipeorganizer.get_equation_from_string``. + +**Security:** + +* diff --git a/src/diffpy/srfit/fitbase/calculator.py b/src/diffpy/srfit/fitbase/calculator.py index cc8d32a5..36fc9aa0 100644 --- a/src/diffpy/srfit/fitbase/calculator.py +++ b/src/diffpy/srfit/fitbase/calculator.py @@ -32,54 +32,59 @@ class Calculator(Operator, ParameterSet): """Base class for calculators. - A Calculator organizes Parameters and has a __call__ method that can - calculate a generic signal. + A Calculator organizes Parameters and has a ``__call__`` method that + can calculate a generic signal. Calculator is also an Operator + (diffpy.srfit.equation.literals.operators), so it can be used directly + in an evaluation network. - Attributes + Parameters ---------- - name - A name for this organizer. - meta - A dictionary of metadata needed by the calculator. - _calculators - A managed dictionary of Calculators, indexed by name. - _constraints - A set of constrained Parameters. Constraints can be - added using the 'constrain' methods. - _parameters - A managed OrderedDict of contained Parameters. - _parsets - A managed dictionary of ParameterSets. - _restraints - A set of Restraints. Restraints can be added using the - 'restrain' or 'confine' methods. - _eqfactory - A diffpy.srfit.equation.builder.EquationFactory - instance that is used create Equations from string. - - Operator Attributes - ------------------- - args - List of Literal arguments - nin - Number of inputs (<1 means this is variable) - nout - Number of outputs (1) - operation - Function that performs the operation, self.__call__ - symbol - Same as name - _value - The value of the Operator. - value - Property for 'get_value'. - - Properties + name : str + The name of the Calculator. It must be a valid attribute name. + + Attributes ---------- - names - Variable names (read only). See get_names. - values - Variable values (read only). See get_values. + name : str + The name of the Calculator. + meta : dict + The metadata needed by the calculator. + names : list of str + The names of the top-level Parameters (read only). See + ``get_names``. + values : list + The values of the top-level Parameters (read only). See + ``get_values``. + args : list + The Literal arguments of the operator, added with + ``add_literal``. + nin : int + The number of inputs. The class value -1 means that any number of + inputs is allowed. + nout : int + The number of outputs, which is 1. + symbol : str + The symbolic representation of the operator, which is the same as + ``name`` (read only). + value : object + The value of the operator (read only). See ``get_value``. + _value : object + The cached value of the operator, or None if it must be + recomputed. + _calculators : dict + The managed dictionary of Calculators, indexed by name. + _constraints : dict + The dictionary of Constraints, indexed by the constrained + Parameter. Constraints can be added using the ``add_constraint`` + method. + _parameters : OrderedDict + The managed OrderedDict of contained Parameters. + _parsets : OrderedDict + The managed OrderedDict of contained ParameterSets. + _restraints : set + The set of Restraints. Restraints can be added using the + ``add_soft_bounds`` method. + _eqfactory : diffpy.srfit.equation.builder.EquationFactory + The factory that is used to create Equations from strings. """ # define abstract attributes from the Operator base. @@ -97,6 +102,7 @@ def __init__(self, name): @property def symbol(self): + """The symbolic representation, which is the Calculator name.""" return self.name # Overload me! @@ -122,6 +128,9 @@ def __call__(self, *args): def operation(self, *args): """Calculate and cache the signal produced by this Calculator. + This calls ``__call__`` and stores the result as the operator + value. + Parameters ---------- *args diff --git a/src/diffpy/srfit/fitbase/fitcontribution.py b/src/diffpy/srfit/fitbase/fitcontribution.py index 73350efd..505b97ba 100644 --- a/src/diffpy/srfit/fitbase/fitcontribution.py +++ b/src/diffpy/srfit/fitbase/fitcontribution.py @@ -88,46 +88,45 @@ class FitContribution(ParameterSet): Attributes ---------- - name - A name for this FitContribution. - profile - A Profile that holds the measured (and calculated) - signal. - _calculators - A managed dictionary of Calculators, indexed by name. - _constraints - A set of constrained Parameters. Constraints can be - added using the ``constrain`` methods. - _generators - A managed dictionary of ProfileGenerators. - _parameters - A managed OrderedDict of parameters. - _restraints - A set of Restraints. Restraints can be added using the - ``restrain`` method. - _parsets - A managed dictionary of ParameterSets. - _eqfactory - A diffpy.srfit.equation.builder.EquationFactory - instance that is used to create constraints and - restraints from string - _eq - The FitContribution equation that will be optimized. - _reseq - The residual equation. - _xname - Name of the x-variable - _yname - Name of the y-variable - _dyname - Name of the dy-variable - - Properties - ---------- - names - Variable names (read only). See get_names. - values - Variable values (read only). See get_values. + name : str + The name of this FitContribution. + profile : Profile or None + The Profile that holds the measured (and calculated) + signal. It is None until ``set_profile`` is called. + names : list of str + The names of the top-level Parameters (read only). See + ``get_names``. + values : list + The values of the top-level Parameters (read only). See + ``get_values``. + _calculators : dict + The managed dictionary of Calculators, indexed by name. + _constraints : dict + The dictionary of Constraints, indexed by the constrained + Parameter. Constraints can be added using the + ``add_constraint`` method. + _generators : dict + The managed dictionary of ProfileGenerators, indexed by name. + _parameters : OrderedDict + The managed OrderedDict of parameters. + _restraints : set + The set of Restraints. Restraints can be added using the + ``add_soft_bounds`` method. + _parsets : OrderedDict + The managed OrderedDict of ParameterSets. + _eqfactory : diffpy.srfit.equation.builder.EquationFactory + The factory that is used to create constraints and + restraints from strings. + _eq : Equation or None + The profile equation that calculates the signal. + _reseq : Equation or None + The residual equation that will be optimized. + _xname : str or None + The name of the x-variable. + _yname : str or None + The name of the y-variable. + _dyname : str or None + The name of the dy-variable. """ def __init__(self, name): @@ -153,6 +152,13 @@ def __init__(self, name): def set_profile(self, profile, xname=None, yname=None, dyname=None): """Assign the Profile for this FitContribution. + The x, y and dy Parameters of the Profile are added to this + FitContribution under the names ``xname``, ``yname`` and + ``dyname``. The Profile is also assigned to every + ProfileGenerator of this FitContribution, and the residual + equation is set to ``"chiv"`` if a profile equation exists but no + residual equation has been set. + Parameters ---------- profile : Profile @@ -173,6 +179,11 @@ def set_profile(self, profile, xname=None, yname=None, dyname=None): this is None (default), then the name specified by the Profile for this parameter will be used. This variable is usable within string equations with the specified name. + + Raises + ------ + TypeError + If ``profile`` is not a Profile instance. """ # Enforce type of the profile argument if not isinstance(profile, Profile): @@ -238,8 +249,9 @@ def add_profile_generator(self, gen, name=None): gen : ProfileGenerator The ProfileGenerator instance to add. name : str, optional - A name for the calculator. If name is None (default), then - the ProfileGenerator's name attribute will be used. + The name used for the ProfileGenerator in the profile + equation. If name is None (default), then the + ProfileGenerator's name attribute will be used. Raises ------ @@ -289,14 +301,14 @@ def set_equation(self, eqstr, ns={}): Parameters ---------- eqstr : str - A string representation of the equation. Any Parameter + The string representation of the equation. Any Parameter registered by ``add_parameter`` or ``set_profile``, or function registered by ``register_calculator``, ``register_function`` or ``register_string_function`` can be used in the equation by name. Other names will be turned into Parameters of this FitContribution. ns : dict, optional - A dictionary of Parameters, indexed by name, that are used + The dictionary of Parameters, indexed by name, that are used in the eqstr, but not registered (default {}). Raises @@ -377,15 +389,13 @@ def set_residual_equation(self, eqstr): Parameters ---------- eqstr : str - A string representation of the residual. If eqstr is None - (default), then the previous residual equation will be - used, or the chi2 residual will be used if that does not - exist. + The string representation of the residual, or one of the + preset names ``"chiv"`` and ``"resv"``. Raises ------ SrFitError - If the Profile is not yet defined. + If the Profile or the profile equation is not yet defined. ValueError If eqstr depends on a Parameter that is not part of the FitContribution. @@ -478,6 +488,9 @@ def residual(self): def evaluate(self): """Evaluate the contribution equation and update profile.ycalc. + The calculated signal is stored in ``profile.ycalc`` only if a + Profile has been assigned. + Returns ------- numpy.ndarray diff --git a/src/diffpy/srfit/fitbase/fithook.py b/src/diffpy/srfit/fitbase/fithook.py index c9d4f5ce..e1dc5c5a 100644 --- a/src/diffpy/srfit/fitbase/fithook.py +++ b/src/diffpy/srfit/fitbase/fithook.py @@ -15,11 +15,11 @@ """The FitHook class for inspecting the progress of a FitRecipe refinement. -FitHooks are called by a FitRecipe during various times of the residual -is evaluation. The default FitHook simply counts the number of times the -residual is called, and reports that number every time the residual is -calculated. Depending on the verbosity, it will also report the residual -and the current variable values. +FitHooks are called by a FitRecipe at various stages of the residual +evaluation. The default FitHook of a FitRecipe, PrintFitHook, counts the +number of times the residual is called, and reports that number every +time the residual is calculated. Depending on the verbosity, it will +also report the residual and the current variable values. Custom FitHooks can be added to a FitRecipe with the FitRecipe.push_fit_hook method. @@ -37,44 +37,52 @@ class FitHook(object): """Base class for inspecting the progress of a FitRecipe refinement. - Can serve as a fithook for the FitRecipe class (see - FitRecipe.push_fit_hook method.) The methods in this class are + A FitHook can be added to a FitRecipe with the + ``FitRecipe.push_fit_hook`` method. The methods in this class are called during the preparation of the FitRecipe for refinement, and - during the residual call. See the class methods for a description of - their purpose. + during the residual call. The base implementations do nothing; derived + classes overload them as needed. """ def reset(self, recipe): """Reset the hook data. - This is called whenever FitRecipe._prepare is called, which is - whenever a configurational change to the fit hierarchy takes - place, such as adding a new ParameterSet, constraint or - restraint. + This is called by ``FitRecipe._prepare`` whenever a + configurational change to the fit hierarchy takes place, such as + adding a new ParameterSet, constraint or restraint. + + Parameters + ---------- + recipe : FitRecipe + The FitRecipe that is being prepared. """ return def precall(self, recipe): - """This is called within FitRecipe.residual, before the - calculation. + """Inspect the recipe before the residual is calculated. + + This is called within ``FitRecipe.residual``, before the + variable values are applied and the residual is calculated. Parameters ---------- recipe : FitRecipe - The FitRecipe instance. + The FitRecipe whose residual is being calculated. """ return def postcall(self, recipe, chiv): - """This is called within FitRecipe.residual, after the - calculation. + """Inspect the recipe after the residual is calculated. + + This is called within ``FitRecipe.residual``, after the residual + is calculated. Parameters ---------- recipe : FitRecipe - The FitRecipe instance. + The FitRecipe whose residual was calculated. chiv : ndarray - The residual vector. + The residual vector, including the restraint penalties. """ return @@ -90,10 +98,11 @@ class PrintFitHook(FitHook): Attributes ---------- - count + count : int The number of times the residual has been called (default 0). - verbose - An integer telling how verbose to be (default 1). + verbose : int + The verbosity level (default 1). Each level also prints what the + lower levels print. 0 Print nothing. @@ -106,30 +115,34 @@ class PrintFitHook(FitHook): """ def __init__(self): - """Initialize the attributes.""" self.count = 0 self.verbose = 1 return def reset(self, recipe): - """Reset the hook data. + """Reset the residual call count to 0. + + This is called by ``FitRecipe._prepare`` whenever a + configurational change to the fit hierarchy takes place, such as + adding a new ParameterSet, constraint or restraint. - This is called whenever FitRecipe._prepare is called, which is - whenever a configurational change to the fit hierarchy takes - place, such as adding a new ParameterSet, constraint or - restraint. + Parameters + ---------- + recipe : FitRecipe + The FitRecipe that is being prepared. """ self.count = 0 return def precall(self, recipe): - """This is called within FitRecipe.residual, before the - calculation. + """Increment the residual call count and print it. + + The count is printed when ``verbose`` is at least 1. Parameters ---------- recipe : FitRecipe - The FitRecipe instance. + The FitRecipe whose residual is being calculated. """ self.count += 1 if self.verbose > 0: @@ -137,15 +150,19 @@ def precall(self, recipe): return def postcall(self, recipe, chiv): - """This is called within FitRecipe.residual, after the - calculation. + """Print the residual and, optionally, the variable values. + + When ``verbose`` is at least 2, print the total residual and, if + the recipe has restraints, its FitContribution and restraint + parts. When ``verbose`` is at least 3, also print the name and + value of each variable. Parameters ---------- recipe : FitRecipe - The FitRecipe instance. + The FitRecipe whose residual was calculated. chiv : ndarray - The residual vector. + The residual vector, including the restraint penalties. """ if self.verbose < 2: return @@ -186,10 +203,30 @@ def _byname(nv): # TODO - Display the chi^2 on the plot during refinement. class PlotFitHook(FitHook): - """Live-plot the progress of a FitRecipe refinement.""" + """Live-plot the progress of a FitRecipe refinement. + + Each FitContribution of the recipe gets a subplot showing the + observed profile and the calculated profile, which is updated after + every residual call. This requires matplotlib. + + Attributes + ---------- + _plots : list of tuple + The (observed, calculated) line pair for each FitContribution. + It is created by ``reset``. + """ def reset(self, recipe): - """Set up the plot.""" + """Set up the plot. + + Clear the current figure, turn on interactive mode and draw one + subplot per FitContribution of the recipe. + + Parameters + ---------- + recipe : FitRecipe + The FitRecipe that is being prepared. + """ FitHook.reset(self, recipe) self._plots = [] @@ -231,17 +268,17 @@ def reset(self, recipe): return def postcall(self, recipe, chiv): - """This is called within FitRecipe.residual, after the - calculation. + """Update the plots with the latest profiles. - Find data and plot it. + This is called within ``FitRecipe.residual``, after the residual + is calculated. Parameters ---------- recipe : FitRecipe - The FitRecipe instance. + The FitRecipe whose residual was calculated. chiv : ndarray - The residual vector. + The residual vector, including the restraint penalties. """ FitHook.postcall(self, recipe, chiv) import pylab diff --git a/src/diffpy/srfit/fitbase/parameter.py b/src/diffpy/srfit/fitbase/parameter.py index f338e023..8ec6383a 100644 --- a/src/diffpy/srfit/fitbase/parameter.py +++ b/src/diffpy/srfit/fitbase/parameter.py @@ -261,15 +261,23 @@ def _validate(self): class ParameterProxy(Parameter): """A Parameter proxy for another parameter. - This allows for the same parameter to have multiple names. + This allows for the same parameter to have multiple names. The value, + constant flag, constrained flag, bounds and observers all belong to + the proxied Parameter. The ``set_value``, ``get_value``, + ``set_constant``, ``bound_range`` and ``bound_window`` methods forward + to the proxied Parameter and share its docstrings. Attributes ---------- - name - A name for this ParameterProxy. Names should be unique within a + name : str + The name of this ParameterProxy. Names should be unique within a RecipeOrganizer and should be valid attribute names. - par + par : Parameter The Parameter this is a proxy for. + constrained : bool + The flag indicating if the proxied Parameter is constrained. + bounds : list + The lower and upper bounds of the proxied Parameter. """ def __init__(self, name, par): @@ -297,7 +305,8 @@ def __init__(self, name, par): @property def constrained(self): - """A flag indicating if the proxied Parameter is constrained.""" + """The flag indicating if the proxied Parameter is + constrained.""" return self.par.constrained @constrained.setter @@ -307,7 +316,7 @@ def constrained(self, value): @property def bounds(self): - """List of lower and upper bounds of the proxied Parameter. + """The list of lower and upper bounds of the proxied Parameter. This can be used by some optimizers when the Parameter is varied. See FitRecipe.get_bounds_pairs and @@ -368,8 +377,26 @@ def _validate(self): class ParameterAdapter(Parameter): """An adapter for parameter-like objects. - This class wraps an object as a Parameter. The get_value and - set_value methods defer to the data of the wrapped object. + This class wraps an object as a Parameter. The ``get_value`` and + ``set_value`` methods defer to the data of the wrapped object. + + Attributes + ---------- + obj : object + The wrapped object. + getter : callable + The function that takes ``obj`` and returns the parameter value. + When ``attr`` is given, this is the ``getter`` passed to the + constructor (or ``getattr``) with ``attr`` bound as its second + argument. + setter : callable + The function that takes ``obj`` and a value and sets the parameter + value. When ``attr`` is given, this is the ``setter`` passed to the + constructor (or ``setattr``) with ``attr`` bound as its second + argument. + attr : str or None + The name of the attribute that contains the parameter value, or + None if ``getter`` and ``setter`` access the value directly. """ def __init__(self, name, obj, getter=None, setter=None, attr=None): @@ -433,17 +460,20 @@ def __init__(self, name, obj, getter=None, setter=None, attr=None): return def get_value(self): - """Get the value of the Parameter. + """Get the value of the Parameter from the wrapped object. Returns ------- object - The current value of the wrapped attribute. + The current value returned by ``getter``. """ return self.getter(self.obj) def set_value(self, value): - """Set the value of the Parameter. + """Set the value of the Parameter on the wrapped object. + + The value is passed to ``setter`` and observers are notified only + if it differs from the current value. Parameters ---------- @@ -453,7 +483,7 @@ def set_value(self, value): Returns ------- ParameterAdapter - Return self so that mutators can be chained. + The ParameterAdapter itself, so that mutators can be chained. """ if value != self.get_value(): self.setter(self.obj, value) diff --git a/src/diffpy/srfit/fitbase/profile.py b/src/diffpy/srfit/fitbase/profile.py index 420deb54..22452c68 100644 --- a/src/diffpy/srfit/fitbase/profile.py +++ b/src/diffpy/srfit/fitbase/profile.py @@ -72,44 +72,45 @@ class Profile(Observable, Validatable): Attributes ---------- - _xobs - A numpy array of the observed independent variable (default - None) - xobs - Read-only property of _xobs. - _yobs - A numpy array of the observed signal (default None) - yobs - Read-only property of _yobs. - _dyobs - A numpy array of the uncertainty of the observed signal - (default None, optional). - dyobs - Read-only property of _dyobs. - x - A numpy array of the calculated independent variable (default - None, property for ``xpar`` accessors). - y - The profile over the calculation range (default None, property - for ``ypar`` accessors). - dy + xobs : numpy.ndarray or None + The observed independent variable (default None, read only). + yobs : numpy.ndarray or None + The observed signal (default None, read only). + dyobs : numpy.ndarray or None + The uncertainty of the observed signal (default None, read + only). It stays None when no uncertainty was observed. + x : numpy.ndarray or None + The calculated independent variable (default None). This + property gets and sets the value of ``xpar``. + y : numpy.ndarray or None + The profile over the calculation range (default None). This + property gets and sets the value of ``ypar``. + dy : numpy.ndarray or None The uncertainty in the profile over the calculation range - (default None, property for ``dypar`` accessors). - ycalc - A numpy array of the calculated signal (default None). - xpar - A Parameter that stores x (named "x"). - ypar - A Parameter that stores y (named "y"). - dypar - A Parameter that stores dy (named "dy"). - ycpar - A Parameter that stores ycalc (named "ycalc"). This is + (default None). This property gets and sets the value of + ``dypar``. + ycalc : numpy.ndarray or None + The calculated signal (default None). This property gets and + sets the value of ``ycpar``. + xpar : Parameter + The Parameter that stores x (named "x"). + ypar : Parameter + The Parameter that stores y (named "y"). + dypar : Parameter + The Parameter that stores dy (named "dy"). + ycpar : Parameter + The Parameter that stores ycalc (named "ycalc"). This is not observed by the profile, but it is present so it can be constrained to. - meta - A dictionary of metadata. This is only set if provided by a - parser. + meta : dict + The metadata of the profile. This is only filled if provided by + a parser. + _xobs : numpy.ndarray or None + The storage for ``xobs``. + _yobs : numpy.ndarray or None + The storage for ``yobs``. + _dyobs : numpy.ndarray or None + The storage for ``dyobs``. """ def __init__(self): @@ -240,7 +241,9 @@ def set_calculation_range(self, xmin=None, xmax=None, dx=None): Adhere to the observed ``xobs`` points when ``dx`` is the same as in the data. ``xmin`` and ``xmax`` are clipped at the bounds - of the observed data. + of the observed data. ``xmin`` is always inclusive (unless + clipped). ``xmax`` is inclusive if it is within the bounds of the + observed data. Parameters ---------- @@ -256,15 +259,16 @@ def set_calculation_range(self, xmin=None, xmax=None, dx=None): The sample spacing in the independent variable. When different from the data, resample the ``x`` as anchored at ``xmin``. - Note that ``xmin`` is always inclusive (unless clipped). - ``xmax`` is inclusive if it is within the bounds of the observed data. - Raises ------ AttributeError If there is no observed data. ValueError - When xmin > xmax or if dx <= 0. Also if dx > xmax - xmin. + If ``xmin > xmax``, if ``dx <= 0`` or if + ``dx > xmax - xmin``. + ValueError + If ``xmin``, ``xmax`` or ``dx`` is a string other than + "obs". """ if self.xobs is None: raise AttributeError("No observed profile") @@ -452,23 +456,23 @@ def loadtxt(self, *args, **kw): return x, y, dy def savetxt(self, fname, **kwargs): - """Call ``numpy.savetxt`` with x, ycalc, y, dy. + """Save x, ycalc, y and dy as columns of a text file. Parameters ---------- - fname : filename or file handle + fname : str, pathlib.Path or file handle The filename or file handle passed to ``numpy.savetxt``. **kwargs The keyword arguments that are passed to ``numpy.savetxt``. - We preset file header "x ycalc y dy". Use ``header=''`` - to save data without any header. + The file header defaults to "x ycalc y dy". Use + ``header=''`` to save data without any header. Raises ------ SrFitError - When ``self.ycalc`` has not been set. + If ``ycalc`` has not been set. - See also + See Also -------- numpy.savetxt """ diff --git a/src/diffpy/srfit/fitbase/profilegenerator.py b/src/diffpy/srfit/fitbase/profilegenerator.py index 178f898c..1137c2cd 100644 --- a/src/diffpy/srfit/fitbase/profilegenerator.py +++ b/src/diffpy/srfit/fitbase/profilegenerator.py @@ -53,66 +53,64 @@ def __call__(self, x): class ProfileGenerator(Operator, ParameterSet): """Base class for profile generators. - A ProfileGenerator organizes Parameters and has a __call__ method that can - generate a profile. ProfileGenerator is also an Operator - (diffpy.srfit.equation.literals.operators), so it can be used directly in - an evaluation network. + A ProfileGenerator organizes Parameters and has a ``__call__`` method + that can generate a profile. ProfileGenerator is also an Operator + (diffpy.srfit.equation.literals.operators), so it can be used directly + in an evaluation network. - Attributes + Parameters ---------- - name - A name for this organizer. - profile - A Profile instance that contains the calculation range - and will contain the generated profile. - meta - A dictionary of metadata needed by the generator. - eq - The Equation object used to wrap this ProfileGenerator. - This is set when the ProfileGenerator is added to a - FitContribution. - _calculators - A managed dictionary of Calculators, indexed by name. - _constraints - A set of constrained Parameters. Constraints can be - added using the 'constrain' methods. - _parameters - A managed OrderedDict of contained Parameters. - _parsets - A managed dictionary of ParameterSets. - _restraints - A set of Restraints. Restraints can be added using the - 'restrain' or 'confine' methods. - _eqfactory - A diffpy.srfit.equation.builder.EquationFactory - instance that is used create Equations from string. - - Operator Attributes - ------------------- - args - List of Literal arguments, set with 'add_literal' - name - A name for this operator. e.g. "add" or "sin" - nin - Number of inputs (<1 means this is variable) - nout - Number of outputs - operation - Function that performs the operation. e.g. numpy.add. In - this case, operation is an instance method. - symbol - The symbolic representation. e.g. "+" or "sin" - _value - The value of the Operator. - value - Property for 'get_value'. - - Properties + name : str + The name of the ProfileGenerator. It must be a valid attribute + name. + + Attributes ---------- - names - Variable names (read only). See get_names. - values - Variable values (read only). See get_values. + name : str + The name of the ProfileGenerator. + profile : Profile or None + The Profile that contains the calculation range and will contain + the generated profile. It is None until ``set_profile`` is called. + meta : dict + The metadata needed by the generator. It is updated from the + Profile metadata whenever the Profile is set or changes. + names : list of str + The names of the top-level Parameters (read only). See + ``get_names``. + values : list + The values of the top-level Parameters (read only). See + ``get_values``. + args : list + The Literal arguments of the operator, added with + ``add_literal``. + nin : int + The number of inputs, which is 0. The independent variable is + taken from ``profile``. + nout : int + The number of outputs, which is 1. + symbol : str + The symbolic representation of the operator, which is the same as + ``name`` (read only). + value : object + The value of the operator (read only). See ``get_value``. + _value : object + The cached value of the operator, or None if it must be + recomputed. + _calculators : dict + The managed dictionary of Calculators, indexed by name. + _constraints : dict + The dictionary of Constraints, indexed by the constrained + Parameter. Constraints can be added using the ``add_constraint`` + method. + _parameters : OrderedDict + The managed OrderedDict of contained Parameters. + _parsets : OrderedDict + The managed OrderedDict of contained ParameterSets. + _restraints : set + The set of Restraints. Restraints can be added using the + ``add_soft_bounds`` method. + _eqfactory : diffpy.srfit.equation.builder.EquationFactory + The factory that is used to create Equations from strings. """ # define abstract attributes from the Operator base. @@ -120,7 +118,6 @@ class ProfileGenerator(Operator, ParameterSet): nout = 1 def __init__(self, name): - """Initialize the attributes.""" Operator.__init__(self) ParameterSet.__init__(self, name) self.profile = None @@ -129,6 +126,7 @@ def __init__(self, name): @property def symbol(self): + """The symbolic representation, which is the generator name.""" return self.name # Overload me! @@ -154,12 +152,12 @@ def __call__(self, x): # No need to overload anything below here def operation(self): - """Evaluate the profile. + """Evaluate the profile over the calculation points. Returns ------- ndarray - The result of ``__call__(profile.x)``. + The profile calculated by ``__call__`` at ``profile.x``. """ y = self.__call__(self.profile.x) return y @@ -167,6 +165,10 @@ def operation(self): def set_profile(self, profile): """Assign the profile. + The generator stops observing any previously assigned Profile and + starts observing the new one, so that changes to the Profile + update ``meta`` and invalidate the cached value. + Parameters ---------- profile : Profile diff --git a/src/diffpy/srfit/fitbase/profileparser.py b/src/diffpy/srfit/fitbase/profileparser.py index 7dd8ca4e..f810f2ca 100644 --- a/src/diffpy/srfit/fitbase/profileparser.py +++ b/src/diffpy/srfit/fitbase/profileparser.py @@ -30,49 +30,49 @@ from diffpy.utils._deprecator import build_deprecation_message, deprecated from diffpy.utils.parsers import load_data -removal_verison = "4.0.0" +removal_version = "4.0.0" pp_base = "diffpy.srfit.fitbase.profileparser.ProfileParser" parseFile_dep_msg = build_deprecation_message( pp_base, "parseFile", "parse_file", - removal_verison, + removal_version, ) getFormat_dep_msg = build_deprecation_message( pp_base, "getFormat", "get_format", - removal_verison, + removal_version, ) getNumBanks_dep_msg = build_deprecation_message( pp_base, "getNumBanks", "get_num_banks", - removal_verison, + removal_version, ) selectBank_dep_msg = build_deprecation_message( pp_base, "selectBank", "select_bank", - removal_verison, + removal_version, ) getData_dep_msg = build_deprecation_message( pp_base, "getData", "get_data", - removal_verison, + removal_version, ) getMetaData_dep_msg = build_deprecation_message( pp_base, "getMetaData", "get_metadata", - removal_verison, + removal_version, ) diff --git a/src/diffpy/srfit/fitbase/recipeorganizer.py b/src/diffpy/srfit/fitbase/recipeorganizer.py index 8e7cc77a..37eef2b6 100644 --- a/src/diffpy/srfit/fitbase/recipeorganizer.py +++ b/src/diffpy/srfit/fitbase/recipeorganizer.py @@ -160,8 +160,10 @@ removal_version, ) +recipeorganizer_module_base = "diffpy.srfit.fitbase.recipeorganizer" + equationFromString_deprecation_msg = build_deprecation_message( - recipeorganizer_base, + recipeorganizer_module_base, "equationFromString", "get_equation_from_string", removal_version, @@ -176,7 +178,7 @@ class RecipeContainer(Observable, Configurable, Validatable): objects. Parameters and other RecipeContainers can be found within the hierarchy with the `_locate_managed_object` method. - A RecipeContainer can manage dictionaries for that store various objects. + A RecipeContainer can manage dictionaries that store various objects. These dictionaries can be added to the RecipeContainer using the `_manage` method. RecipeContainer methods that add, remove or retrieve objects will work with any managed dictionary. This makes it easy to add new types of @@ -189,27 +191,37 @@ class RecipeContainer(Observable, Configurable, Validatable): ProfileGenerator, to detect changes in Parameters and Restraints on which it may depend. + Parameters + ---------- + name : str + The name of the RecipeContainer. It must be a valid attribute + name. + Attributes ---------- - name - A name for this RecipeContainer. Names should be unique + name : str + The name of this RecipeContainer. Names should be unique within a RecipeContainer and should be valid attribute names. - _parameters - A managed OrderedDict of contained Parameters. - __managed - A list of managed dictionaries. This is used for + names : list of str + The names of the top-level Parameters (read only). See + `get_names`. + values : list + The values of the top-level Parameters (read only). See + `get_values`. + _parameters : OrderedDict + The managed OrderedDict of contained Parameters. + __managed : list + The list of managed dictionaries. This is used for attribute access, addition and removal. - _configobjs - A set of configurable objects that must know of + _configobjs : set + The set of configurable objects that must know of configuration changes within this object. - Properties - ---------- - names - Variable names (read only). See get_names. - values - Variable values (read only). See get_values. + Raises + ------ + ValueError + If `name` is not a valid attribute name. """ names = property(lambda self: self.get_names()) @@ -719,38 +731,42 @@ class RecipeOrganizer(_recipeorganizer_interface, RecipeContainer): level and a flattened list of them can be retrieved with the `_get_constraints` and `_get_restraints` methods. + Parameters + ---------- + name : str + The name of the RecipeOrganizer. It must be a valid attribute + name. + Attributes ---------- - name - A name for this organizer. Names should be unique + name : str + The name of this organizer. Names should be unique within a RecipeOrganizer and should be valid attribute names. - _calculators - A managed dictionary of Calculators, indexed by name. - _parameters - A managed OrderedDict of contained Parameters. - _constraints - A dictionary of Constraints, indexed by the constrained + names : list of str + The names of the top-level Parameters (read only). See + `get_names`. + values : list + The values of the top-level Parameters (read only). See + `get_values`. + _calculators : dict + The managed dictionary of Calculators, indexed by name. + _parameters : OrderedDict + The managed OrderedDict of contained Parameters. + _constraints : dict + The dictionary of Constraints, indexed by the constrained Parameter. Constraints can be added using the - 'constrain' method. - _restraints - A set of Restraints. Restraints can be added using the - 'restrain' method. - _eqfactory - A diffpy.srfit.equation.builder.EquationFactory - instance that is used create Equations from string. - - Properties - ---------- - names - Variable names (read only). See get_names. - values - Variable values (read only). See get_values. + `add_constraint` method. + _restraints : set + The set of Restraints. Restraints can be added using the + `add_soft_bounds` method. + _eqfactory : diffpy.srfit.equation.builder.EquationFactory + The factory that is used to create Equations from strings. Raises ------ ValueError - If the name is not a valid attribute identifier. + If `name` is not a valid attribute name. """ def __init__(self, name): @@ -834,16 +850,18 @@ def register_calculator(self, calculator, argnames=None): This creates a function with this class that can be used within string equations. The resulting equation can be used in a string with arguments like a function or without, in which case the values of the - Parameters created from argnames will be be used to compute the value. + Parameters created from argnames will be used to compute the value. + Argument names that are not already known to this organizer are + added as new Parameters with value 0. Parameters ---------- - calculator : Calculator object + calculator : Calculator The Calculator to register. - argnames : list or None, optional - The names of the arguments to `calculator` (list or None). - If this is None, then the argument names will be - extracted from the function. + argnames : list of str or None, optional + The names of the arguments to `calculator`. If this is None + (default), then the argument names will be extracted from + the signature of the Calculator's ``__call__`` method. Returns ------- @@ -909,9 +927,9 @@ def register_function(self, function, name=None, argnames=None): Raises ------ - TypeError + ValueError If name or argnames cannot be automatically extracted. - TypeError + ValueError If an automatically extracted name is ''. ValueError If function is an Equation object and name is None. @@ -1024,11 +1042,12 @@ def register_string_function(self, function_str, name, func_params={}): Parameters ---------- function_str : str - A string equation to register. + The string equation to register. Names in the equation that + are not yet known are added as new Parameters. name : str The name of the function to be used in equations. func_params : dict, optional - A dictionary of Parameters, indexed by name, that are + The dictionary of Parameters, indexed by name, that are used in `function_str`, but not part of the FitRecipe (default {}). Returns @@ -1082,7 +1101,7 @@ def evaluate_equation(self, equation_str, func_params={}): Parameters ---------- - equation_str + equation_str : str The string equation to evaluate. The equation is evaluated at the current value of the registered Parameters. func_params : dict, optional @@ -1092,7 +1111,7 @@ def evaluate_equation(self, equation_str, func_params={}): Returns ------- - returned_value : float + returned_value : float or numpy.ndarray The value of the evaluated equation. Raises @@ -1100,6 +1119,9 @@ def evaluate_equation(self, equation_str, func_params={}): ValueError If `func_params` uses a name that is already used for a variable. + ValueError + If `equation_str` uses a name that is not a known Parameter + and is not in `func_params`. """ eq = get_equation_from_string( equation_str, self._eqfactory, func_params @@ -1125,20 +1147,22 @@ def add_constraint(self, parameter, constraint_eq, params={}): """Constrain a parameter to an equation. Note that only one constraint can exist on a Parameter at a time. + Adding a constraint to a Parameter that is already constrained + replaces the old constraint. Parameters ---------- parameter : str or Parameter The name of a Parameter or a Parameter to constrain. - constraint_eq : str or Equation - A string representation of the constraint equation or a + constraint_eq : str or Parameter + The string representation of the constraint equation or a Parameter to constrain to. A constraint equation must consist of numpy operators and "known" Parameters. Parameters are known if they are in the `params` argument, or if they are managed by this object. params : dict, optional - A dictionary of Parameters, indexed by name, that are used - in `parameter`, but not part of this object (default {}). + The dictionary of Parameters, indexed by name, that are used + in `constraint_eq`, but not part of this object (default {}). Raises ------ @@ -1209,8 +1233,8 @@ def is_constrained(self, parameter): Returns ------- bool - True if the Parameter is constrained in this object, False - otherwise. + The flag that is True if the Parameter is constrained in this + object, False otherwise. """ if isinstance(parameter, str): name = parameter @@ -1230,9 +1254,9 @@ def isConstrained(self, par): return self.is_constrained(par) def remove_constraint(self, *pars): - """Unconstrain a Parameter. + """Unconstrain one or more Parameters. - This removes any constraints on a Parameter. + This removes any constraints on the given Parameters. Parameters ---------- @@ -1242,7 +1266,9 @@ def remove_constraint(self, *pars): Raises ------ ValueError - If the Parameter is not constrained. + If a Parameter given by name cannot be found. + ValueError + If none of the given Parameters is constrained. """ update = False for parameter in pars: @@ -1281,19 +1307,19 @@ def unconstrain(self, *pars): return def get_constrained_parmeters(self, recurse=False): - """Get a list of constrained managed Parameters in this object. + """Get the constrained managed Parameters in this object. Parameters ---------- recurse : bool, optional - If False (default), only constrained Parameters in - this object are returned. If True, constrained + The flag that is False (default) to return only constrained + Parameters in this object. If True, constrained Parameters in managed sub-objects are also included. Returns ------- - constrained_params : list of Parameter - The list of constrained managed Parameters in this object. + constrained_params : dict_keys of Parameter + The constrained managed Parameters in this object. """ const = self._get_constraints(recurse) constrained_params = const.keys() @@ -1319,9 +1345,9 @@ def clear_all_constraints(self, recurse=False): Parameters ---------- recurse : bool, optional - If False (default), only constraints in this object - are cleared. If True, constraints in managed - sub-objects are also cleared. + The flag that is False (default) to clear only constraints in + this object. If True, constraints in managed sub-objects are + also cleared. """ if self._constraints: self.remove_constraint(*self._constraints) @@ -1357,8 +1383,8 @@ def add_soft_bounds( Parameters ---------- - param_or_eq : str - The equation or parameter to restrain. + param_or_eq : str or Parameter + The equation string or Parameter to restrain. lower_bound : float, optional The lower bound for the restraint evaluation (default is -inf). upper_bound : float, optional @@ -1367,8 +1393,9 @@ def add_soft_bounds( The uncertainty associated with the bounds (default is 1). Please see Notes for how this is used in the penalty calculation. scaled : bool, optional - If True, the restraint penalty is scaled by the unrestrained - point-average chi^2 (chi^2/numpoints) (default is False). + The flag that is True to scale the restraint penalty by the + unrestrained point-average chi^2 (chi^2/numpoints) + (default is False). params : dict, optional The dictionary of Parameters, indexed by name, that are used in `param_or_eq` (if an equation string is used) but are not part @@ -1378,7 +1405,16 @@ def add_soft_bounds( ------- Restraint The created Restraint object, which can be used with the - 'unrestrain' method. + `remove_soft_bounds` method. + + Raises + ------ + ValueError + If `params` contains a name that is already used + for a Parameter. + ValueError + If `param_or_eq` depends on a Parameter that is not part of the + RecipeOrganizer and is not defined in `params`. Notes ----- @@ -1405,15 +1441,6 @@ def add_soft_bounds( scaled=True, params={"b_ni": Parameter("b_ni", 3.473)} ) - - Raises - ------ - ValueError - If `params` contains a name that is already used - for a Parameter. - ValueError - If `param_or_eq` depends on a Parameter that is not part of the - RecipeOrganizer and is not defined in `params`. """ if isinstance(param_or_eq, str): eqstr = param_or_eq @@ -1452,7 +1479,7 @@ def register_soft_bounds(self, res): Parameters ---------- res : Restraint - A Restraint instance. + The Restraint to add. """ self._restraints.add(res) # Our configuration changed. Notify observers. @@ -1472,13 +1499,15 @@ def addRestraint(self, res): return def remove_soft_bounds(self, *ress): - """Remove a Restraint from the RecipeOrganizer. + """Remove Restraints from the RecipeOrganizer. + + Restraints that are not held by this organizer are ignored. Parameters ---------- *ress : Restraint - The Restraints returned from the 'add_soft_bounds' method or added - with the 'register_soft_bounds' method. + The Restraints returned from the `add_soft_bounds` method or added + with the `register_soft_bounds` method. """ update = False restuple = tuple(self._restraints) @@ -1511,8 +1540,8 @@ def clear_all_soft_bounds(self, recurse=False): Parameters ---------- recurse : bool, optional - If False (default), only restraints in this object are - cleared. If True, restraints in managed sub-objects are + The flag that is False (default) to clear only restraints in + this object. If True, restraints in managed sub-objects are also cleared. """ self.remove_soft_bounds(*self._restraints) @@ -1670,11 +1699,12 @@ def show(self, pattern="", textwidth=78): Parameters ---------- pattern : str, optional - Limit output to only those parameters that match this regular - expression (match all by default). + The regular expression that limits the output to the matching + parameters (match all by default). textwidth : int, optional - Trim formatted lines at this text width to avoid folding at - the screen width. Do not trim when negative or 0. + The text width at which formatted lines are trimmed to avoid + folding at the screen width (default 78). Do not trim when + negative or 0. """ regexp = re.compile(pattern) _pmatch_with_re = partial(_pmatch, regexp=regexp) @@ -1730,31 +1760,32 @@ def get_equation_from_string( Parameters ---------- eqstr : str - A string representation of the equation. The equation must + The string representation of the equation. The equation must consist of numpy operators and "known" Parameters. Parameters are known if they are in ns, or already defined in the factory. factory : EquationFactory - An EquationFactory instance. + The factory used to build the equation. ns : dict, optional The dictionary of Parameters indexed by name that are used in the eqstr but not already defined in the factory - (default {}). + (default {}). They are registered only while the equation is + built. buildargs : bool, optional - A flag indicating whether missing Parameters can be created - by the Factory (default False). If False, then the a ValueError + The flag indicating whether missing Parameters can be created + by the Factory (default False). If False, then a ValueError will be raised if there are undefined arguments in the eqstr. - argclass : Parameter class, optional - Class to use when creating new Arguments (default + argclass : type, optional + The class to use when creating new Arguments (default Parameter). The class constructor must accept the 'name' key word. argkw : dict, optional - Key word dictionary to pass to the argclass constructor + The keyword dictionary to pass to the argclass constructor (default {}). Returns ------- eq : Equation - An Equation instance representing the equation in eqstr. + The Equation instance representing the equation in eqstr. Raises ------ diff --git a/src/diffpy/srfit/fitbase/simplerecipe.py b/src/diffpy/srfit/fitbase/simplerecipe.py index 916a0773..bde0e331 100644 --- a/src/diffpy/srfit/fitbase/simplerecipe.py +++ b/src/diffpy/srfit/fitbase/simplerecipe.py @@ -82,63 +82,33 @@ class SimpleRecipe(FitRecipe): these are exposed through this class to facilitate the creation of a simple fit recipe. + Public FitContribution methods that SimpleRecipe does not define + itself, such as ``set_residual_equation`` or ``add_profile_generator``, + are also made available as SimpleRecipe methods. + Attributes ---------- - profile + profile : Profile The built-in Profile object. - contribution - The built-in FitContribution object. - results + contribution : FitContribution + The built-in FitContribution object, named "contribution". + results : FitResults The built-in FitResults object. - name - A name for this FitRecipe. + name : str + The name of this FitRecipe. fithooks : list The list of FitHook instances that can pass information out of the system during a refinement. By default, this is - populated by a PrintFitHook instance. - _constraints - A dictionary of Constraints, indexed by the constrained - Parameter. Constraints can be added using the - 'constrain' method. - _oconstraints - An ordered list of the constraints from this and all - sub-components. - _calculators - A managed dictionary of Calculators. - _contributions - A managed OrderedDict of FitContributions. - _parameters - A managed OrderedDict of parameters (in this case the - parameters are varied). - _parsets - A managed dictionary of ParameterSets. - _eqfactory - A diffpy.srfit.equation.builder.EquationFactory - instance that is used to create constraints and - restraints from string equations. - _fixed - A set of parameters that are not actually varied. - _restraintlist - A list of restraints from this and all sub-components. - _restraints - A set of Restraints. Restraints can be added using the - 'restrain' or 'confine' methods. - _ready - A flag indicating if all attributes are ready for the - calculation. - _tagdict - A dictionary of tags to variables. - _weights - List of weighing factors for each FitContribution. The - weights are multiplied by the residual of the - FitContribution when determining the overall residual. - - Properties - ---------- - names - Variable names (read only). See get_names. - values - Variable values (read only). See get_values. + populated by a PrintFitHook instance with verbosity 3. + names : list of str + The variable names (read only). See ``get_names``. + values : numpy.ndarray + The variable values (read only). See ``get_values``. + + See Also + -------- + FitRecipe : The base class, which documents the remaining + attributes. """ def __init__(self, name="fit", conclass=FitContribution): @@ -207,13 +177,16 @@ def set_observed_profile(self, xobs, yobs, dyobs=None): The observed signal. dyobs : ndarray, optional The uncertainty in the observed signal. If dyobs is None - (default), it will be set to 1 at each observed xobs. + (default), dyobs stays None to indicate no uncertainty was + observed, and the calculated dy will be set to 1 at each + calculation point instead. Raises ------ ValueError - If len(yobs) != len(xobs), or if dyobs is not None and - len(dyobs) != len(xobs). + If ``len(yobs) != len(xobs)``. + ValueError + If dyobs is not None and ``len(dyobs) != len(xobs)``. """ return self.profile.set_observed_profile(xobs, yobs, dyobs) @@ -233,31 +206,34 @@ def set_calculation_range(self, xmin=None, xmax=None, dx=None): Adhere to the observed ``xobs`` points when ``dx`` is the same as in the data. ``xmin`` and ``xmax`` are clipped at the bounds - of the observed data. + of the observed data. ``xmin`` is always inclusive (unless + clipped). ``xmax`` is inclusive if it is within the bounds of the + observed data. Parameters ---------- - xmin : float or `obs`, optional + xmin : float or "obs", optional The minimum value of the independent variable. Keep the current minimum when not specified. If specified as "obs" reset to the minimum observed value. - xmax : float or `obs`, optional + xmax : float or "obs", optional The maximum value of the independent variable. Keep the current maximum when not specified. If specified as "obs" reset to the maximum observed value. - dx : float or `obs`, optional + dx : float or "obs", optional The sample spacing in the independent variable. When different from the data, resample the ``x`` as anchored at ``xmin``. - Note that ``xmin`` is always inclusive (unless clipped). - ``xmax`` is inclusive if it is within the bounds of the observed data. - Raises ------ AttributeError If there is no observed data. ValueError - When xmin > xmax or if dx <= 0. Also if dx > xmax - xmin. + If ``xmin > xmax``, if ``dx <= 0`` or if + ``dx > xmax - xmin``. + ValueError + If ``xmin``, ``xmax`` or ``dx`` is a string other than + "obs". """ return self.profile.set_calculation_range(xmin, xmax, dx) @@ -298,23 +274,36 @@ def setCalculationPoints(self, x): return self.set_calculation_points(x) def loadtxt(self, *args, **kw): - """Use numpy.loadtxt to load data. + """Load data using ``numpy.loadtxt``. - Arguments are passed to numpy.loadtxt. unpack = True is - enforced. The first two arrays returned by numpy.loadtxt are + Arguments are passed to ``numpy.loadtxt``. ``unpack=True`` is + enforced. The first two arrays returned by ``numpy.loadtxt`` are assumed to be x and y. If there is a third array, it is assumed - to by dy. Any other arrays are ignored. These are passed to - set_observed_profile. + to be dy. Any other arrays are ignored. The loaded arrays are + passed to ``set_observed_profile``. - Raises - ------ - ValueError - If the call to numpy.loadtxt returns fewer than 2 arrays. + Parameters + ---------- + *args + The positional arguments passed to ``numpy.loadtxt``. + **kw + The keyword arguments passed to ``numpy.loadtxt``. Returns ------- - tuple - The x, y and dy arrays loaded from the file. + x : numpy.ndarray + The array of the independent variable loaded from the + file. + y : numpy.ndarray + The array of the observed signal loaded from the file. + dy : numpy.ndarray or None + The array of the uncertainty loaded from the file, or None + if no third column is present. + + Raises + ------ + ValueError + If the call to ``numpy.loadtxt`` returns fewer than 2 arrays. """ return self.profile.loadtxt(*args, **kw) @@ -329,9 +318,11 @@ def set_equation(self, eqstr, ns={}): Parameters ---------- eqstr : str - The string representation of the equation. Variables will - be extracted from this equation and be given an initial - value of 0. + The string representation of the equation. After the + equation is set, every top-level Parameter of the + FitContribution other than x, y and dy is added as a + variable of this recipe, unless it is one already. + Parameters without a value are given an initial value of 0. ns : dict, optional The dictionary of Parameters, indexed by name, that are used in the eqstr, but not registered (default {}). @@ -366,13 +357,23 @@ def setEquation(self, eqstr, ns={}): return def __call__(self): - """Evaluate the contribution equation.""" + """Evaluate the contribution equation. + + Returns + ------- + numpy.ndarray + The calculated signal, which is also stored in + ``profile.ycalc``. + """ return self.contribution.evaluate() # FitResults methods def print_results(self, header="", footer=""): """Format and print the results. + The results are updated from the current state of the recipe + before they are printed. + Parameters ---------- header : str, optional @@ -398,9 +399,12 @@ def printResults(self, header="", footer=""): def save_results(self, filename, header="", footer=""): """Format and save the results. + The results are updated from the current state of the recipe + before they are saved. + Parameters ---------- - filename : str + filename : str or pathlib.Path The name of the save file. header : str, optional The header to add to the output (default ""). diff --git a/tests/conftest.py b/tests/conftest.py index 115edb90..1a5f4494 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,10 +5,16 @@ from functools import lru_cache import pytest -from numpy import linspace, pi, sin +from numpy import arange, array, linspace, pi, sin import diffpy.srfit.equation.literals as literals -from diffpy.srfit import FitContribution, FitRecipe, Profile +from diffpy.srfit import ( + FitContribution, + FitRecipe, + Profile, + ProfileGenerator, + SimpleRecipe, +) logger = logging.getLogger(__name__) @@ -232,18 +238,18 @@ def _as_list(values): @pytest.fixture(scope="function") def build_recipe_with_uncertainty(): - """Helper to build a sine recipe whose profile carries the observed - uncertainties returned by ``make_dyobs``. + """Helper to build a sine recipe on 21 points in [0, pi] whose + profile carries the observed uncertainties ``dyobs``. The observed profile is a noiseless sine, so a refinement recovers ``A=1``, ``k=1`` and ``c=0`` regardless of how it is weighted. """ - def _build_recipe(make_dyobs): + def _build_recipe(dyobs): xobs = linspace(0, pi, 21) yobs = sin(xobs) profile = Profile() - profile.set_observed_profile(xobs, yobs, make_dyobs(xobs)) + profile.set_observed_profile(xobs, yobs, dyobs) contribution = FitContribution("c1") contribution.set_profile(profile) contribution.set_equation("A*sin(k*x + c)") @@ -466,3 +472,104 @@ def tag_manager(): manager.tag(3, "3", "number") manager.tag(4, "4", "number") return manager + + +@pytest.fixture(scope="session") +def build_sine_recipe(): + """Return a function that builds a recipe fitting a sine profile. + + The recipe holds the contribution ``cont`` with the equation + ``A*sin(k*x + c)``, where ``A=1``, ``k=1`` and ``c=0``, fitted to + ``sin(x)`` on ten points in [0, pi]. The calculated profile matches + the observed profile exactly, so the residual is zero. The recipe + has no variables and its default PrintFitHook is silenced. + """ + + def _build_sine_recipe(): + xobs = linspace(0, pi, 10) + profile = Profile() + profile.set_observed_profile(xobs, sin(xobs)) + contribution = FitContribution("cont") + contribution.set_profile(profile) + contribution.set_equation("A*sin(k*x + c)") + contribution.A.set_value(1) + contribution.k.set_value(1) + contribution.c.set_value(0) + recipe = FitRecipe("recipe") + recipe.fithooks[0].verbose = 0 + recipe.add_contribution(contribution) + return recipe + + return _build_sine_recipe + + +@pytest.fixture(scope="session") +def make_parameters_with_factory(): + def _make_parameters_with_factory(): + """Return the Parameters p1 = 1 and p2 = 2 and an EquationFactory + that has both registered under their names. + + Each call returns new objects, so a test can build the same + setup twice. + """ + from diffpy.srfit.equation.builder import EquationFactory + from diffpy.srfit.fitbase.parameter import Parameter + + p1 = Parameter("p1", 1) + p2 = Parameter("p2", 2) + factory = EquationFactory() + factory.register_argument("p1", p1) + factory.register_argument("p2", p2) + return p1, p2, factory + + return _make_parameters_with_factory + + +@pytest.fixture +def observed_profile(): + """Return a profile observed at x = 2, 2.5, ..., 9.5 with y = dy = + x.""" + xobs = arange(2, 9.6, 0.5) + profile = Profile() + profile.set_observed_profile(xobs, array(xobs), array(xobs)) + return profile + + +@pytest.fixture +def generator_and_profile(): + """Return a generator named "test" and its profile, whose + calculation points are x = 0, 0.1, ..., 9.9.""" + generator = ProfileGenerator("test") + profile = Profile() + profile.set_calculation_points(arange(0, 10, 0.1)) + generator.set_profile(profile) + return generator, profile + + +@pytest.fixture +def sine_recipe_with_variables(build_sine_recipe): + """Return the sine recipe with the variables A=2 (tagged "tagA"), + k=1 (tagged "tagk"), c=0 and the new variable B=0, in that order.""" + recipe = build_sine_recipe() + recipe.add_variable(recipe.cont.A, 2, tag="tagA") + recipe.add_variable(recipe.cont.k, 1, tag="tagk") + recipe.add_variable(recipe.cont.c, 0) + recipe.create_new_variable("B", 0) + return recipe + + +@pytest.fixture +def linear_recipe(): + """Return a SimpleRecipe fitting y = 2*x + 1 with A*x + B. + + The variables start at the exact solution A=2.0 and B=1.0, and the + default PrintFitHook is silenced. + """ + recipe = SimpleRecipe() + recipe.fithooks[0].verbose = 0 + xobs = linspace(0, 1, 5) + recipe.set_observed_profile(xobs, 2 * xobs + 1) + recipe.set_equation("A*x + B") + recipe.A.set_value(2.0) + recipe.B.set_value(1.0) + return recipe diff --git a/tests/deprecated/test_constraint_deprecations.py b/tests/deprecated/test_constraint_deprecations.py new file mode 100644 index 00000000..457784d8 --- /dev/null +++ b/tests/deprecated/test_constraint_deprecations.py @@ -0,0 +1,109 @@ +"""Tests for the deprecated camelCase names in +diffpy.srfit.fitbase.constraint. + +Each test calls the deprecated name, checks that it warns, and checks +that it gives the same result as the new name on an identical object. + +Remove this file in version 4.0.0, together with the deprecated names. +""" + +import re + +import pytest + +from diffpy.srfit.fitbase.constraint import Constraint +from diffpy.srfit.fitbase.recipeorganizer import get_equation_from_string + + +def expected_message(base, old_name, new_name): + return re.escape( + f"'{base}.{old_name}' is deprecated and will be removed in version " + f"4.0.0. Please use '{base}.{new_name}' instead." + ) + + +CONSTRAINT = "diffpy.srfit.fitbase.constraint.Constraint" + + +def _call_constraint_method(make_parameters_with_factory, method_name): + """Constrain p1 = 2*p2 with the given method, or remove that + constraint with it, where p1 = 1 and p2 = 2. + + Return whether the Constraint holds p1 and the equation, the + constrained flags of p1 and p2, and the value of p1 afterward. + """ + p1, p2, factory = make_parameters_with_factory() + equation = get_equation_from_string("2*p2", factory) + constraint = Constraint() + if method_name in ("constrain", "add_constraint"): + getattr(constraint, method_name)(p1, equation) + else: + constraint.add_constraint(p1, equation) + getattr(constraint, method_name)() + return ( + constraint.par is p1, + constraint.eq is equation, + p1.constrained, + p2.constrained, + p1.get_value(), + ) + + +# Constraint: each deprecated method does the same as its new name. See +# test_constraint.py::test_add_constraint. +@pytest.mark.parametrize( + "input_old_name, input_new_name", + [ + # C1: constrain constrains p1 to 2*p2. + # Expected: Warns and gives the same result as add_constraint. + ("constrain", "add_constraint"), + # C2: unconstrain removes the constraint p1 = 2*p2. + # Expected: Warns and gives the same result as remove_constraint. + ("unconstrain", "remove_constraint"), + ], +) +def test_constraint_deprecations( + make_parameters_with_factory, input_old_name, input_new_name +): + with pytest.warns( + DeprecationWarning, + match=expected_message(CONSTRAINT, input_old_name, input_new_name), + ): + actual_old_result = _call_constraint_method( + make_parameters_with_factory, input_old_name + ) + actual_new_result = _call_constraint_method( + make_parameters_with_factory, input_new_name + ) + assert actual_old_result == actual_new_result + + +# Constraint: see test_constraint.py::test_add_constraint_bad. +@pytest.mark.parametrize( + "input_parameter_state", + [ + # C1: p1 is already constrained by another Constraint. + # Expected: Warns and raises a ValueError. + "constrained", + # C2: p1 is constant. + # Expected: Warns and raises a ValueError. + "constant", + ], +) +def test_constrain_bad(make_parameters_with_factory, input_parameter_state): + p1, _, factory = make_parameters_with_factory() + if input_parameter_state == "constrained": + Constraint().add_constraint( + p1, get_equation_from_string("2*p2", factory) + ) + else: + p1.set_constant() + equation = get_equation_from_string("2*p2+1", factory) + with ( + pytest.warns( + DeprecationWarning, + match=expected_message(CONSTRAINT, "constrain", "add_constraint"), + ), + pytest.raises(ValueError), + ): + Constraint().constrain(p1, equation) diff --git a/tests/deprecated/test_equation_deprecations.py b/tests/deprecated/test_equation_deprecations.py index f1701072..a8dd6c44 100644 --- a/tests/deprecated/test_equation_deprecations.py +++ b/tests/deprecated/test_equation_deprecations.py @@ -73,6 +73,14 @@ def test_getValue(input_literal_kind): assert actual_value == expected_value +def _multiply_sums(v1, v2, v3, v4): + return (v1 + v2) * (v3 + v4) + + +def _halve(v1): + return 0.5 * v1 + + # EquationFactory: see # test_builder.py::test_register_function_replaces_existing. @pytest.mark.parametrize( @@ -93,12 +101,12 @@ def test_registerFunction(make_args, input_replacement, expected_value): factory.register_argument(argument.name, argument) factory.register_function( "g", - lambda v1, v2, v3, v4: (v1 + v2) * (v3 + v4), + _multiply_sums, ["v1", "v2", "v3", "v4"], ) equation = factory.make_equation("g") if input_replacement == "python_function": - new_function, new_arg_names = (lambda v1: 0.5 * v1), ["v1"] + new_function, new_arg_names = _halve, ["v1"] else: new_function = factory.make_equation("v1 + v2") new_arg_names = new_function.argdict.keys() diff --git a/tests/deprecated/test_fitcontribution_deprecations.py b/tests/deprecated/test_fitcontribution_deprecations.py new file mode 100644 index 00000000..60ed8a79 --- /dev/null +++ b/tests/deprecated/test_fitcontribution_deprecations.py @@ -0,0 +1,125 @@ +"""Tests for the deprecated camelCase names in +diffpy.srfit.fitbase.fitcontribution. + +Each test calls the deprecated name, checks that it warns, and checks +that it does the same as the new name. + +Remove this file in version 4.0.0, together with the deprecated names. +""" + +import re + +import pytest + +from diffpy.srfit import FitContribution, Profile, ProfileGenerator + + +def expected_message(base, old_name, new_name): + return re.escape( + f"'{base}.{old_name}' is deprecated and will be removed in version " + f"4.0.0. Please use '{base}.{new_name}' instead." + ) + + +FIT_CONTRIBUTION = "diffpy.srfit.fitbase.FitContribution" + + +def _make_contribution(method_name): + """Return a contribution to call the method on. + + set_profile is called on a contribution that has the equation + "A * x". The other methods are called on a contribution that has a + profile, and the getters and set_residual_equation also on one that + has the equation "A * sin(x + 5)". + """ + contribution = FitContribution("test") + if method_name == "set_profile": + contribution.set_equation("A * x") + return contribution + contribution.set_profile(Profile()) + if method_name in ( + "get_equation", + "set_residual_equation", + "get_residual_equation", + ): + contribution.set_equation("A * sin(x + 5)") + return contribution + + +def _make_call_args(method_name): + """Return fresh arguments for a FitContribution method.""" + if method_name == "set_profile": + return (Profile(),) + if method_name == "add_profile_generator": + return (ProfileGenerator("gen"), "I") + if method_name == "set_equation": + return ("A * x + B",) + if method_name == "set_residual_equation": + return ("2 * (eq - y)",) + return () + + +def _describe_contribution(contribution): + return ( + sorted(contribution.get_names()), + sorted(contribution._generators), + contribution.get_equation(), + contribution.get_residual_equation(), + ) + + +# FitContribution: each deprecated method does the same as its new name. +# See test_contribution.py. +@pytest.mark.parametrize( + "input_old_name, input_new_name", + [ + # C1: setProfile sets a profile on a contribution with an equation. + # Expected: Warns and gives the same contribution as set_profile. + ("setProfile", "set_profile"), + # C2: addProfileGenerator adds a generator under the name "I". + # Expected: Warns and gives the same contribution as + # add_profile_generator. + ("addProfileGenerator", "add_profile_generator"), + # C3: setEquation sets the equation "A * x + B". + # Expected: Warns and gives the same contribution as set_equation. + ("setEquation", "set_equation"), + # C4: getEquation gets the equation "A * sin(x + 5)". + # Expected: Warns and returns the same string as get_equation. + ("getEquation", "get_equation"), + # C5: setResidualEquation sets the residual "2 * (eq - y)". + # Expected: Warns and gives the same contribution as + # set_residual_equation. + ("setResidualEquation", "set_residual_equation"), + # C6: getResidualEquation gets the default chiv residual. + # Expected: Warns and returns the same string as + # get_residual_equation. + ("getResidualEquation", "get_residual_equation"), + ], +) +def test_fitcontribution_deprecations( + find_builders_with_observers, input_old_name, input_new_name +): + old_contribution = _make_contribution(input_new_name) + old_args = _make_call_args(input_new_name) + with pytest.warns( + DeprecationWarning, + match=expected_message( + FIT_CONTRIBUTION, input_old_name, input_new_name + ), + ): + old_return_value = getattr(old_contribution, input_old_name)(*old_args) + new_contribution = _make_contribution(input_new_name) + new_args = _make_call_args(input_new_name) + new_return_value = getattr(new_contribution, input_new_name)(*new_args) + actual_old_result = ( + old_return_value, + _describe_contribution(old_contribution), + ) + actual_new_result = ( + new_return_value, + _describe_contribution(new_contribution), + ) + assert actual_old_result == actual_new_result + actual_builders = find_builders_with_observers() + expected_builders = [] + assert actual_builders == expected_builders diff --git a/tests/deprecated/test_fitrecipe_deprecations.py b/tests/deprecated/test_fitrecipe_deprecations.py new file mode 100644 index 00000000..40d90565 --- /dev/null +++ b/tests/deprecated/test_fitrecipe_deprecations.py @@ -0,0 +1,388 @@ +"""Tests for the deprecated camelCase names used with FitRecipe. + +Each test calls the deprecated name, checks that it warns, and checks +that it gives the same result as the new name on an identical fresh +object. + +Remove this file in version 4.0.0, together with the deprecated names. +""" + +import re + +import pytest + +from diffpy.srfit import FitRecipe, Profile, ProfileParser +from diffpy.srfit.fitbase.fithook import FitHook +from diffpy.srfit.fitbase.parameter import Parameter + + +def expected_message(base, old_name, new_name): + return re.escape( + f"'{base}.{old_name}' is deprecated and will be removed in version " + f"4.0.0. Please use '{base}.{new_name}' instead." + ) + + +FIT_RECIPE = "diffpy.srfit.fitbase.FitRecipe" +PROFILE = "diffpy.srfit.fitbase.profile.Profile" +PROFILE_PARSER = "diffpy.srfit.fitbase.profileparser.ProfileParser" +RECIPE_ORGANIZER = "diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer" + + +def _add_variables(recipe): + recipe.add_variable(recipe.cont.A, 2) + recipe.add_variable(recipe.cont.k, 1) + recipe.add_variable(recipe.cont.c, 0) + + +def _describe_variables(recipe): + return recipe.get_names(), recipe.get_values().tolist() + + +def _call_add_variable(recipe, method_name): + method = getattr(recipe, method_name) + method(recipe.cont.A, 2, tag="tagA") + recipe.fix("tagA") + return _describe_variables(recipe), recipe.is_free(recipe.A) + + +def _call_create_new_variable(recipe, method_name): + method = getattr(recipe, method_name) + variable = method("B", 3) + return _describe_variables(recipe), variable is recipe.B + + +def _call_delete_variable(recipe, method_name): + method = getattr(recipe, method_name) + _add_variables(recipe) + method(recipe.k) + return _describe_variables(recipe), hasattr(recipe, "k") + + +def _call_is_free(recipe, method_name): + method = getattr(recipe, method_name) + _add_variables(recipe) + recipe.fix(recipe.k) + return method(recipe.A), method(recipe.k) + + +def _call_get_names(recipe, method_name): + method = getattr(recipe, method_name) + _add_variables(recipe) + return method() + + +def _call_get_values(recipe, method_name): + method = getattr(recipe, method_name) + _add_variables(recipe) + return method().tolist() + + +def _call_get_bounds_pairs(recipe, method_name): + method = getattr(recipe, method_name) + _add_variables(recipe) + recipe.A.bounds = (0, 5) + return method() + + +def _call_get_bounds_array(recipe, method_name): + method = getattr(recipe, method_name) + _add_variables(recipe) + recipe.A.bounds = (0, 5) + return [bounds.tolist() for bounds in method()] + + +def _call_convert_bounds_to_restraints(recipe, method_name): + method = getattr(recipe, method_name) + recipe.create_new_variable("var1", 1) + recipe.var1.bounds = (-1, 1) + method(sig=2, scaled=True) + return [ + ( + restraint.lower_bound, + restraint.upper_bound, + restraint.sig, + restraint.scaled, + ) + for restraint in recipe._restraints + ] + + +def _call_add_constraint(recipe, method_name): + method = getattr(recipe, method_name) + _add_variables(recipe) + recipe.create_new_variable("B", 0) + method(recipe.B, Parameter("Bpar", -1)) + return _describe_variables(recipe), recipe.scalar_residual() + + +def _call_remove_constraint(recipe, method_name): + method = getattr(recipe, method_name) + _add_variables(recipe) + recipe.add_constraint(recipe.c, "2*A") + method(recipe.c) + return ( + _describe_variables(recipe), + recipe.c.constrained, + recipe.scalar_residual(), + ) + + +def _call_add_contribution(recipe, method_name): + new_recipe = FitRecipe("recipe") + new_recipe.fithooks[0].verbose = 0 + getattr(new_recipe, method_name)(recipe.cont) + return list(new_recipe._contributions), new_recipe.scalar_residual() + + +def _call_set_weight(recipe, method_name): + method = getattr(recipe, method_name) + recipe.cont.c.set_value(1) + method(recipe.cont, 0.5) + return recipe._weights, recipe.scalar_residual() + + +def _call_push_fit_hook(recipe, method_name): + method = getattr(recipe, method_name) + method(FitHook(), 0) + return [type(fit_hook).__name__ for fit_hook in recipe.fithooks] + + +def _call_pop_fit_hook(recipe, method_name): + method = getattr(recipe, method_name) + method(recipe.fithooks[0]) + return recipe.fithooks + + +def _call_get_fit_hooks(recipe, method_name): + method = getattr(recipe, method_name) + return [type(fit_hook).__name__ for fit_hook in method()] + + +def _call_clear_fit_hooks(recipe, method_name): + method = getattr(recipe, method_name) + method() + return recipe.fithooks + + +def _call_add_parameter_set(recipe, method_name): + method = getattr(recipe, method_name) + parameter = Parameter("added_param", 1) + method(parameter) + return recipe.added_param is parameter, recipe.added_param.value + + +def _call_remove_parameter_set(recipe, method_name): + method = getattr(recipe, method_name) + parameter = Parameter("added_param", 1) + recipe.add_parameter_set(parameter) + method(parameter) + return hasattr(recipe, "added_param") + + +def _call_scalar_residual(recipe, method_name): + method = getattr(recipe, method_name) + recipe.cont.c.set_value(1) + return method() + + +_FIT_RECIPE_CALLS = { + "add_variable": _call_add_variable, + "create_new_variable": _call_create_new_variable, + "delete_variable": _call_delete_variable, + "is_free": _call_is_free, + "get_names": _call_get_names, + "get_values": _call_get_values, + "get_bounds_pairs": _call_get_bounds_pairs, + "get_bounds_array": _call_get_bounds_array, + "convert_bounds_to_restraints": _call_convert_bounds_to_restraints, + "add_constraint": _call_add_constraint, + "remove_constraint": _call_remove_constraint, + "add_contribution": _call_add_contribution, + "set_weight": _call_set_weight, + "push_fit_hook": _call_push_fit_hook, + "pop_fit_hook": _call_pop_fit_hook, + "get_fit_hooks": _call_get_fit_hooks, + "clear_fit_hooks": _call_clear_fit_hooks, + "add_parameter_set": _call_add_parameter_set, + "remove_parameter_set": _call_remove_parameter_set, + "scalar_residual": _call_scalar_residual, +} + + +def _call_fit_recipe_method(build_sine_recipe, method_name, new_name): + """Call a method of a fresh sine recipe the way its new name is + tested, and return the result or the resulting state.""" + recipe = build_sine_recipe() + return _FIT_RECIPE_CALLS[new_name](recipe, method_name) + + +# FitRecipe: each deprecated method does the same as its new name on a +# fresh sine recipe (see build_sine_recipe in conftest.py). See +# test_fitrecipe.py. +@pytest.mark.parametrize( + "input_old_name, input_new_name", + [ + # C1: addVar adds the variable A = 2 with the tag "tagA", which + # is then fixed. + # Expected: Warns and gives the same result as add_variable. + ("addVar", "add_variable"), + # C2: newVar creates the variable B = 3. + # Expected: Warns and gives the same result as + # create_new_variable. + ("newVar", "create_new_variable"), + # C3: delVar deletes the variable k. + # Expected: Warns and gives the same result as delete_variable. + ("delVar", "delete_variable"), + # C4: isFree checks the free variable A and the fixed variable k. + # Expected: Warns and gives the same result as is_free. + ("isFree", "is_free"), + # C5: getNames gets the names of the variables A, k and c. + # Expected: Warns and gives the same result as get_names. + ("getNames", "get_names"), + # C6: getValues gets the values of the variables A, k and c. + # Expected: Warns and gives the same result as get_values. + ("getValues", "get_values"), + # C7: getBounds gets the bounds of the variables, where A is + # bounded to [0, 5]. + # Expected: Warns and gives the same result as get_bounds_pairs. + ("getBounds", "get_bounds_pairs"), + # C8: getBounds2 gets the bounds of the variables as two arrays, + # where A is bounded to [0, 5]. + # Expected: Warns and gives the same result as get_bounds_array. + ("getBounds2", "get_bounds_array"), + # C9: boundsToRestraints converts the bounds [-1, 1] of a + # variable with sig=2 and scaled=True. + # Expected: Warns and gives the same result as + # convert_bounds_to_restraints. + ("boundsToRestraints", "convert_bounds_to_restraints"), + # C10: constrain constrains the variable B to a parameter. + # Expected: Warns and gives the same result as add_constraint. + ("constrain", "add_constraint"), + # C11: unconstrain removes the constraint c = 2*A. + # Expected: Warns and gives the same result as remove_constraint. + ("unconstrain", "remove_constraint"), + # C12: addContribution adds the sine contribution to an empty + # recipe. + # Expected: Warns and gives the same result as add_contribution. + ("addContribution", "add_contribution"), + # C13: setWeight sets the weight of the contribution to 0.5. + # Expected: Warns and gives the same result as set_weight. + ("setWeight", "set_weight"), + # C14: pushFitHook inserts a FitHook at the start. + # Expected: Warns and gives the same result as push_fit_hook. + ("pushFitHook", "push_fit_hook"), + # C15: popFitHook removes the default PrintFitHook. + # Expected: Warns and gives the same result as pop_fit_hook. + ("popFitHook", "pop_fit_hook"), + # C16: getFitHooks gets the default PrintFitHook. + # Expected: Warns and gives the same result as get_fit_hooks. + ("getFitHooks", "get_fit_hooks"), + # C17: clearFitHooks removes all fit hooks. + # Expected: Warns and gives the same result as clear_fit_hooks. + ("clearFitHooks", "clear_fit_hooks"), + # C18: addParameterSet adds a parameter. + # Expected: Warns and gives the same result as add_parameter_set. + ("addParameterSet", "add_parameter_set"), + # C19: removeParameterSet removes an added parameter. + # Expected: Warns and gives the same result as + # remove_parameter_set. + ("removeParameterSet", "remove_parameter_set"), + # C20: scalarResidual calculates the residual with c = 1. + # Expected: Warns and gives the same result as scalar_residual. + ("scalarResidual", "scalar_residual"), + ], +) +def test_fit_recipe_deprecations( + build_sine_recipe, input_old_name, input_new_name +): + with pytest.warns( + DeprecationWarning, + match=expected_message(FIT_RECIPE, input_old_name, input_new_name), + ): + actual_old_result = _call_fit_recipe_method( + build_sine_recipe, input_old_name, input_new_name + ) + actual_new_result = _call_fit_recipe_method( + build_sine_recipe, input_new_name, input_new_name + ) + assert actual_old_result == actual_new_result + + +def _parse_gr_file(parse_method_name, file_path, as_list): + parser = ProfileParser() + getattr(parser, parse_method_name)(str(file_path)) + return [as_list(data) for data in parser.get_data()], parser.get_metadata() + + +# ProfileParser: see test_fitrecipe.py::test_plot_recipe_labels_from_gr_file. +def test_parseFile(temp_data_files, as_list): + # C1: parseFile parses a .gr file. + # Expected: Warns, and the parser holds the same data and metadata as + # after parse_file. + file_path = temp_data_files / "gr_file.gr" + with pytest.warns( + DeprecationWarning, + match=expected_message(PROFILE_PARSER, "parseFile", "parse_file"), + ): + actual_old_result = _parse_gr_file("parseFile", file_path, as_list) + actual_new_result = _parse_gr_file("parse_file", file_path, as_list) + assert actual_old_result == actual_new_result + + +def _load_gr_file(load_method_name, file_path, as_list): + parser = ProfileParser() + parser.parse_file(str(file_path)) + profile = Profile() + getattr(profile, load_method_name)(parser) + return ( + as_list(profile.xobs), + as_list(profile.yobs), + as_list(profile.dyobs), + profile.meta, + ) + + +# Profile: see test_fitrecipe.py::test_plot_recipe_labels_from_gr_file. +def test_loadParsedData(temp_data_files, as_list): + # C1: loadParsedData loads a parsed .gr file into a profile. + # Expected: Warns, and the profile holds the same observed data and + # metadata as after load_parsed_data. + file_path = temp_data_files / "gr_file.gr" + with pytest.warns( + DeprecationWarning, + match=expected_message(PROFILE, "loadParsedData", "load_parsed_data"), + ): + actual_old_result = _load_gr_file("loadParsedData", file_path, as_list) + actual_new_result = _load_gr_file("load_parsed_data", file_path, as_list) + assert actual_old_result == actual_new_result + + +def _unconstrain_c_with_contribution(build_sine_recipe, method_name): + recipe = build_sine_recipe() + recipe.cont.add_constraint(recipe.cont.c, "2*A") + getattr(recipe.cont, method_name)(recipe.cont.c) + recipe.cont.c.set_value(0) + return recipe.cont.c.constrained, recipe.residual().tolist() + + +# RecipeOrganizer, through FitContribution: see +# test_fitrecipe.py::test_residual. +def test_unconstrain_with_contribution(build_sine_recipe): + # C1: The contribution of the sine recipe constrains c = 2*A, then + # unconstrain removes the constraint and c is reset to 0. + # Expected: Warns, and c and the residual are the same as after + # remove_constraint. + with pytest.warns( + DeprecationWarning, + match=expected_message( + RECIPE_ORGANIZER, "unconstrain", "remove_constraint" + ), + ): + actual_old_result = _unconstrain_c_with_contribution( + build_sine_recipe, "unconstrain" + ) + actual_new_result = _unconstrain_c_with_contribution( + build_sine_recipe, "remove_constraint" + ) + assert actual_old_result == actual_new_result diff --git a/tests/deprecated/test_fitresults_deprecations.py b/tests/deprecated/test_fitresults_deprecations.py new file mode 100644 index 00000000..3f8e0ab0 --- /dev/null +++ b/tests/deprecated/test_fitresults_deprecations.py @@ -0,0 +1,167 @@ +"""Tests for the deprecated camelCase names in +diffpy.srfit.fitbase.fitresults. + +Each test calls a deprecated name and checks that it warns. Where a new +name does the same job, the test also checks that both give the same +result on identical fresh objects. + +Remove this file in version 4.0.0, together with the deprecated names. +""" + +import re + +import pytest + +from diffpy.srfit import FitRecipe, FitResults +from diffpy.srfit.fitbase.fitresults import initializeRecipe, resultsDictionary + + +def expected_message(old_base, old_name, new_base, new_name): + return re.escape( + f"'{old_base}.{old_name}' is deprecated and will be removed in " + f"version 4.0.0. Please use '{new_base}.{new_name}' instead." + ) + + +FITBASE = "diffpy.srfit.fitbase" +FIT_RESULTS = "diffpy.srfit.fitbase.FitResults" +FIT_RECIPE = "diffpy.srfit.fitbase.FitRecipe" + + +def _get_results_output(recipe, method_name, capsys, tmp_path): + """Return the results text that a FitResults method gives for the + recipe, without the save time and user that a saved file starts + with.""" + recipe.fithooks[0].verbose = 0 + results = FitResults(recipe) + method = getattr(results, method_name) + if method_name in ("formatResults", "get_results_string"): + return method(header="My Custom header") + if method_name in ("printResults", "print_results"): + capsys.readouterr() + method(header="My Custom header") + return capsys.readouterr().out + results_file = tmp_path / f"{method_name}.res" + method(results_file, header="My Custom header") + return results_file.read_text().split("\n", 2)[2] + + +# FitResults: each deprecated method gives the same results text as its new +# name. See test_fitresults.py::test_results_output. +@pytest.mark.parametrize( + "input_old_name, input_new_name", + [ + # C1: formatResults returns the results text. + # Expected: Warns and gives the same text as get_results_string. + ("formatResults", "get_results_string"), + # C2: printResults prints the results text. + # Expected: Warns and prints the same text as print_results. + ("printResults", "print_results"), + # C3: saveResults saves the results text to a file. + # Expected: Warns and saves the same text as save_results. + ("saveResults", "save_results"), + ], +) +def test_fit_results_deprecations( + build_recipes_one_contribution, + capsys, + tmp_path, + input_old_name, + input_new_name, +): + old_recipe, new_recipe = build_recipes_one_contribution + with pytest.warns( + DeprecationWarning, + match=expected_message( + FIT_RESULTS, input_old_name, FIT_RESULTS, input_new_name + ), + ): + actual_old_result = _get_results_output( + old_recipe, input_old_name, capsys, tmp_path + ) + actual_new_result = _get_results_output( + new_recipe, input_new_name, capsys, tmp_path + ) + assert actual_old_result == actual_new_result + + +def test_resultsDictionary(temp_data_files): + # C1: The results are read from a saved results file. + # Expected: Warns and returns every "name number" pair in the file, with + # the numbers kept as strings. This includes pairs that are not results, + # such as "Feb 25" from the save date and "than 25" from the + # correlations heading, and misses "Reduced Chi2", which is read as + # "Chi2". + with pytest.warns( + DeprecationWarning, + match=expected_message( + FITBASE, + "resultsDictionary", + FIT_RESULTS, + "get_results_dictionary", + ), + ): + actual_results_dict = resultsDictionary( + temp_data_files / "fit_results.res" + ) + expected_results_dict = { + "than": "25", + "wave_number": "1.00000000e+00", + "phase_shift": "-1.61291146e-18", + "amplitude": "1.00000000e+00", + "Rw": "0.00000000", + "Chi2": "0.00000000", + "Restraints": "0.00000000", + "Contributions": "0.00000000", + "Residual": "0.00000000", + "Feb": "25", + } + assert actual_results_dict == expected_results_dict + + +# initializeRecipe: see +# test_fitrecipe.py::test_initialize_recipe_from_results_file. +@pytest.mark.parametrize( + "input_kind", + [ + # C1: The name of a results file. + # Expected: Warns and sets A, sig and x0 to their values in the + # file. + "file_name", + # C2: An open results file. + # Expected: Warns, sets A, sig and x0 to their values in the file, + # and leaves the file open. + "file_object", + # C3: The text of a results file. + # Expected: Warns and sets A, sig and x0 to their values in the + # text. + "results_text", + ], +) +def test_initializeRecipe(datafile, input_kind): + recipe = FitRecipe("recipe") + for name in ("A", "sig", "x0"): + recipe.create_new_variable(name, 0) + results_path = datafile("results.res") + with open(results_path, "r") as results_file: + inputs = { + "file_name": results_path, + "file_object": results_file, + "results_text": results_path.read_text(), + } + with pytest.warns( + DeprecationWarning, + match=expected_message( + FITBASE, + "initializeRecipe", + FIT_RECIPE, + "initialize_recipe_with_results", + ), + ): + initializeRecipe(recipe, inputs[input_kind]) + actual_file_closed = results_file.closed + expected_file_closed = False + assert actual_file_closed == expected_file_closed + actual_values = [recipe.A.value, recipe.sig.value, recipe.x0.value] + expected_values = [5.77619823e-01, -9.22758690e-01, 6.12422115e00] + assert actual_values == pytest.approx(expected_values) diff --git a/tests/deprecated/test_parameter_deprecations.py b/tests/deprecated/test_parameter_deprecations.py new file mode 100644 index 00000000..a27d81c1 --- /dev/null +++ b/tests/deprecated/test_parameter_deprecations.py @@ -0,0 +1,192 @@ +"""Tests for the deprecated camelCase names in +diffpy.srfit.fitbase.parameter. + +Each test calls the deprecated name, checks that it warns, and checks +that it gives the same result as the new name on an identical object. + +Remove this file in version 4.0.0, together with the deprecated names. +""" + +import re + +import numpy as np +import pytest + +from diffpy.srfit.fitbase.parameter import ( + Parameter, + ParameterAdapter, + ParameterProxy, +) + + +def expected_message(base, old_name, new_name): + return re.escape( + f"'{base}.{old_name}' is deprecated and will be removed in version " + f"4.0.0. Please use '{base}.{new_name}' instead." + ) + + +PARAMETER = "diffpy.srfit.fitbase.Parameter" + + +class _ValueHolder: + """A plain object for ParameterAdapter to wrap.""" + + def __init__(self, value): + self.value = value + + +def _make_parameter(parameter_kind): + """Return a Parameter of the given kind whose value is 3.14.""" + if parameter_kind == "parameter": + return Parameter("l", 3.14) + if parameter_kind == "proxy": + return ParameterProxy("l_proxy", Parameter("l", 3.14)) + return ParameterAdapter("l_adapted", _ValueHolder(3.14), attr="value") + + +# Parameter: getValue forwards to get_value for Parameter and both of its +# wrapping subclasses. +@pytest.mark.parametrize( + "input_parameter_kind", + [ + # C1: A Parameter stores the value itself. + # Expected: Warns and returns the same value as get_value. + "parameter", + # C2: A ParameterProxy defers to the Parameter it proxies. + # Expected: Warns and returns the same value as get_value. + "proxy", + # C3: A ParameterAdapter defers to the attribute it wraps. + # Expected: Warns and returns the same value as get_value. + "adapter", + ], +) +def test_getValue(input_parameter_kind): + with pytest.warns( + DeprecationWarning, + match=expected_message(PARAMETER, "getValue", "get_value"), + ): + actual_old_value = _make_parameter(input_parameter_kind).getValue() + actual_new_value = _make_parameter(input_parameter_kind).get_value() + assert actual_old_value == actual_new_value + + +def _call_parameter_method(method_name, args): + """Call a method of a Parameter whose value is 3.14. + + Return whether the method returned the Parameter, and the value, + constant flag and bounds the Parameter has afterward. + """ + parameter = Parameter("l", 3.14) + result = getattr(parameter, method_name)(*args) + return ( + result is parameter, + parameter.get_value(), + parameter.const, + list(parameter.bounds), + ) + + +# Parameter: setValue and setConst do the same as their new names. See +# test_parameter.py::test_set_value and test_parameter.py::test_set_constant. +@pytest.mark.parametrize( + "input_old_name, input_new_name, input_args", + [ + # C1: setValue sets the value to 2.5. + # Expected: Warns and gives the same result as set_value. + ("setValue", "set_value", (2.5,)), + # C2: setConst marks the Parameter as constant. + # Expected: Warns and gives the same result as set_constant. + ("setConst", "set_constant", ()), + ], +) +def test_parameter_deprecations(input_old_name, input_new_name, input_args): + with pytest.warns( + DeprecationWarning, + match=expected_message(PARAMETER, input_old_name, input_new_name), + ): + actual_old_result = _call_parameter_method(input_old_name, input_args) + actual_new_result = _call_parameter_method(input_new_name, input_args) + assert actual_old_result == actual_new_result + + +def _make_bounded_parameter(initial_bounds): + parameter = Parameter("a", value=5) + parameter.bounds = list(initial_bounds) + return parameter + + +# Parameter: see test_parameter.py::test_bound_range. +@pytest.mark.parametrize( + "input_initial_bounds, input_lower_bound, input_upper_bound", + [ + # C1: Both bounds are given for a Parameter with no bounds. + # Expected: Warns and sets the same bounds as bound_range. + ([-np.inf, np.inf], 1, 10), + # C2: Only a lower bound is given for a Parameter with no bounds. + # Expected: Warns and sets the same bounds as bound_range. + ([-np.inf, np.inf], 2, None), + # C3: Only an upper bound is given for a Parameter with no bounds. + # Expected: Warns and sets the same bounds as bound_range. + ([-np.inf, np.inf], None, 8), + # C4: Both bounds are given for a Parameter with bounds [0, 10]. + # Expected: Warns and sets the same bounds as bound_range. + ([0, 10], 2, 6), + ], +) +def test_boundRange( + input_initial_bounds, input_lower_bound, input_upper_bound +): + old_parameter = _make_bounded_parameter(input_initial_bounds) + with pytest.warns( + DeprecationWarning, + match=expected_message(PARAMETER, "boundRange", "bound_range"), + ): + old_result = old_parameter.boundRange( + lb=input_lower_bound, ub=input_upper_bound + ) + new_parameter = _make_bounded_parameter(input_initial_bounds) + new_result = new_parameter.bound_range( + lower_bound=input_lower_bound, upper_bound=input_upper_bound + ) + actual_old_result = (old_result is old_parameter, old_parameter.bounds) + actual_new_result = (new_result is new_parameter, new_parameter.bounds) + assert actual_old_result == actual_new_result + + +# Parameter: see test_parameter.py::test_bound_window. +@pytest.mark.parametrize( + "input_value, input_lower_radius, input_upper_radius", + [ + # C1: Only a lower radius of 2 is given, around the value 10. + # Expected: Warns and sets the same bounds as bound_window. + (10, 2, None), + # C2: A lower radius of 3 and an upper radius of 5 are given, + # around the value 10. + # Expected: Warns and sets the same bounds as bound_window. + (10, 3, 5), + # C3: A radius of 0 is given, around the value 4. + # Expected: Warns and sets the same bounds as bound_window. + (4, 0, None), + # C4: The value is changed from 5 to 20 before a radius of 2 is + # given. + # Expected: Warns and sets the same bounds as bound_window. + (20, 2, None), + ], +) +def test_boundWindow(input_value, input_lower_radius, input_upper_radius): + old_parameter = Parameter("a", value=5).set_value(input_value) + with pytest.warns( + DeprecationWarning, + match=expected_message(PARAMETER, "boundWindow", "bound_window"), + ): + old_result = old_parameter.boundWindow( + lr=input_lower_radius, ur=input_upper_radius + ) + new_parameter = Parameter("a", value=5).set_value(input_value) + new_result = new_parameter.bound_window( + lower_radius=input_lower_radius, upper_radius=input_upper_radius + ) + actual_old_result = (old_result is old_parameter, old_parameter.bounds) + actual_new_result = (new_result is new_parameter, new_parameter.bounds) + assert actual_old_result == actual_new_result diff --git a/tests/deprecated/test_parameterset_deprecations.py b/tests/deprecated/test_parameterset_deprecations.py new file mode 100644 index 00000000..07833195 --- /dev/null +++ b/tests/deprecated/test_parameterset_deprecations.py @@ -0,0 +1,103 @@ +"""Tests for the deprecated camelCase names in +diffpy.srfit.fitbase.parameterset. + +Each test calls the deprecated name, checks that it warns, and checks +that it gives the same result as the new name on an identical object. + +Remove this file in version 4.0.0, together with the deprecated names. +""" + +import re + +import pytest + +from diffpy.srfit.fitbase.parameter import Parameter +from diffpy.srfit.fitbase.parameterset import ParameterSet + + +def expected_message(base, old_name, new_name): + return re.escape( + f"'{base}.{old_name}' is deprecated and will be removed in version " + f"4.0.0. Please use '{base}.{new_name}' instead." + ) + + +PARAMETER_SET = "diffpy.srfit.fitbase.parameterset.ParameterSet" + + +def _make_call_args(parset, method_name): + """Return fresh arguments for a ParameterSet method.""" + if method_name == "add_parameter": + return (Parameter("p2", 2),) + if method_name == "new_parameter": + return ("p2", 2) + if method_name == "remove_parameter": + return (parset.p1,) + if method_name == "add_parameter_set": + return (ParameterSet("other"),) + if method_name == "remove_parameter_set": + return (parset.child,) + return () + + +def _call_parameter_set_method(method_name, args_method_name): + """Call a method of the set "test" that holds the Parameter p1 = 1 + and the ParameterSet "child". + + Return what the method returned, and the names, values and constant + flags of the Parameters and the names of the ParameterSets the set + holds afterward. + """ + parset = ParameterSet("test") + parset.new_parameter("p1", 1) + parset.add_parameter_set(ParameterSet("child")) + args = _make_call_args(parset, args_method_name) + result = getattr(parset, method_name)(*args) + returned = None if result is None else (result.name, result.value) + return ( + returned, + parset.get_names(), + parset.get_values(), + [par.const for par in parset.iterate_over_parameters()], + list(parset._parsets), + ) + + +# ParameterSet: each deprecated method does the same as its new name, on a +# set that holds the Parameter p1 = 1 and the ParameterSet "child". See +# test_parameterset.py. +@pytest.mark.parametrize( + "input_old_name, input_new_name", + [ + # C1: addParameter stores the Parameter p2 = 2. + # Expected: Warns and gives the same result as add_parameter. + ("addParameter", "add_parameter"), + # C2: newParameter creates the Parameter p2 = 2. + # Expected: Warns and gives the same result as new_parameter. + ("newParameter", "new_parameter"), + # C3: removeParameter removes p1. + # Expected: Warns and gives the same result as remove_parameter. + ("removeParameter", "remove_parameter"), + # C4: addParameterSet stores the ParameterSet "other". + # Expected: Warns and gives the same result as add_parameter_set. + ("addParameterSet", "add_parameter_set"), + # C5: removeParameterSet removes "child". + # Expected: Warns and gives the same result as remove_parameter_set. + ("removeParameterSet", "remove_parameter_set"), + # C6: setConst marks every Parameter in the set as constant. + # Expected: Warns and gives the same result as set_constant. + ("setConst", "set_constant"), + ], +) +def test_parameter_set_deprecations(input_old_name, input_new_name): + with pytest.warns( + DeprecationWarning, + match=expected_message(PARAMETER_SET, input_old_name, input_new_name), + ): + actual_old_result = _call_parameter_set_method( + input_old_name, input_new_name + ) + actual_new_result = _call_parameter_set_method( + input_new_name, input_new_name + ) + assert actual_old_result == actual_new_result diff --git a/tests/deprecated/test_profile_deprecations.py b/tests/deprecated/test_profile_deprecations.py new file mode 100644 index 00000000..7b09e2a3 --- /dev/null +++ b/tests/deprecated/test_profile_deprecations.py @@ -0,0 +1,109 @@ +"""Tests for the deprecated camelCase names in +diffpy.srfit.fitbase.profile. + +Each test calls the deprecated name, checks that it warns, and checks +that it does the same as the new name. + +Remove this file in version 4.0.0, together with the deprecated names. +""" + +import re + +import pytest +from numpy import arange + +from diffpy.srfit import Profile, ProfileParser + + +def expected_message(base, old_name, new_name): + return re.escape( + f"'{base}.{old_name}' is deprecated and will be removed in version " + f"4.0.0. Please use '{base}.{new_name}' instead." + ) + + +PROFILE = "diffpy.srfit.fitbase.profile.Profile" + + +def _make_profile(method_name): + """Return a profile to call the method on. + + set_calculation_range needs a profile observed at x = 2, 2.5, ..., + 9.5 with y = dy = x. The other methods start from an empty profile. + """ + profile = Profile() + if method_name == "set_calculation_range": + xobs = arange(2, 9.6, 0.5) + profile.set_observed_profile(xobs, xobs.copy(), xobs.copy()) + return profile + + +def _make_call_args(method_name, parser_datafiles): + """Return fresh arguments for a Profile method.""" + if method_name == "set_observed_profile": + return ([1.0, 2.0, 3.0], [2.0, 4.0, 6.0]) + if method_name == "set_calculation_range": + return (4, 7) + if method_name == "set_calculation_points": + return (arange(3, 12.2, 0.2),) + parser = ProfileParser() + parser.parse_file(parser_datafiles / "four_col.gr") + return (parser,) + + +def _describe_profile(profile, as_list): + return ( + as_list(profile.xobs), + as_list(profile.yobs), + as_list(profile.dyobs), + as_list(profile.x), + as_list(profile.y), + as_list(profile.dy), + profile.meta, + ) + + +# Profile: each deprecated method does the same as its new name. See +# test_profile.py. +@pytest.mark.parametrize( + "input_old_name, input_new_name", + [ + # C1: setObservedProfile sets x = [1, 2, 3] and y = 2*x without dy. + # Expected: Warns and gives the same profile as + # set_observed_profile. + ("setObservedProfile", "set_observed_profile"), + # C2: setCalculationRange sets the range [4, 7] on observed data. + # Expected: Warns and gives the same profile as + # set_calculation_range. + ("setCalculationRange", "set_calculation_range"), + # C3: setCalculationPoints sets x = 3, 3.2, ..., 12 without data. + # Expected: Warns and gives the same profile as + # set_calculation_points. + ("setCalculationPoints", "set_calculation_points"), + # C4: loadParsedData loads a parsed four-column file. + # Expected: Warns and gives the same profile as load_parsed_data. + ("loadParsedData", "load_parsed_data"), + ], +) +def test_profile_deprecations( + as_list, parser_datafiles, input_old_name, input_new_name +): + old_profile = _make_profile(input_new_name) + old_args = _make_call_args(input_new_name, parser_datafiles) + with pytest.warns( + DeprecationWarning, + match=expected_message(PROFILE, input_old_name, input_new_name), + ): + old_return_value = getattr(old_profile, input_old_name)(*old_args) + new_profile = _make_profile(input_new_name) + new_args = _make_call_args(input_new_name, parser_datafiles) + new_return_value = getattr(new_profile, input_new_name)(*new_args) + actual_old_result = ( + old_return_value, + _describe_profile(old_profile, as_list), + ) + actual_new_result = ( + new_return_value, + _describe_profile(new_profile, as_list), + ) + assert actual_old_result == actual_new_result diff --git a/tests/deprecated/test_profileparser_deprecations.py b/tests/deprecated/test_profileparser_deprecations.py new file mode 100644 index 00000000..3191c709 --- /dev/null +++ b/tests/deprecated/test_profileparser_deprecations.py @@ -0,0 +1,134 @@ +"""Tests for the deprecated camelCase names in +diffpy.srfit.fitbase.profileparser. + +Each test calls the deprecated name, checks that it warns, and checks +that it does the same as the new name. + +Remove this file in version 4.0.0, together with the deprecated names. +""" + +import re + +import pytest + +from diffpy.srfit import ProfileParser + + +def expected_message(base, old_name, new_name): + return re.escape( + f"'{base}.{old_name}' is deprecated and will be removed in version " + f"4.0.0. Please use '{base}.{new_name}' instead." + ) + + +PROFILE_PARSER = "diffpy.srfit.fitbase.profileparser.ProfileParser" + + +class _SubclassParser(ProfileParser): + """A parser that inherits every method from ProfileParser.""" + + +def _make_parser(parser_class, method_name, parser_datafiles): + """Return a parser to call the method on. + + parse_file is called on a new parser. The other methods are called + on a parser that has parsed a four-column file. + """ + parser = parser_class() + if method_name != "parse_file": + parser.parse_file(parser_datafiles / "four_col.gr") + return parser + + +def _make_call_args(method_name, parser_datafiles): + """Return fresh arguments for a ProfileParser method.""" + if method_name == "parse_file": + return (parser_datafiles / "two_col.txt",) + if method_name == "select_bank": + return (0,) + return () + + +def _describe_value(value, as_list): + if isinstance(value, tuple): + return tuple(as_list(array) for array in value) + return value + + +def _describe_parser(parser, as_list): + return ( + parser.get_format(), + parser.get_num_banks(), + _describe_value(parser.get_data(), as_list), + dict(parser.get_metadata()), + ) + + +# ProfileParser: each deprecated method does the same as its new name, and +# its warning names ProfileParser even when it is called on a subclass, +# since the method is shared by every parser. See test_profileparser.py. +@pytest.mark.parametrize( + "input_parser_class", + [ + # C1: The parser is a ProfileParser. + # Expected: The warning names ProfileParser. + ProfileParser, + # C2: The parser is a subclass that inherits the method. + # Expected: The warning still names ProfileParser. + _SubclassParser, + ], +) +@pytest.mark.parametrize( + "input_old_name, input_new_name", + [ + # C1: parseFile parses a two-column file. + # Expected: Warns and gives the same data and metadata as + # parse_file. + ("parseFile", "parse_file"), + # C2: getFormat gets the format of the parser. + # Expected: Warns and returns the same format as get_format. + ("getFormat", "get_format"), + # C3: getNumBanks gets the number of banks read. + # Expected: Warns and returns the same number as get_num_banks. + ("getNumBanks", "get_num_banks"), + # C4: selectBank selects bank 0. + # Expected: Warns and selects the same bank as select_bank. + ("selectBank", "select_bank"), + # C5: getData gets the data of the selected bank. + # Expected: Warns and returns the same data as get_data. + ("getData", "get_data"), + # C6: getMetaData gets the parsed metadata. + # Expected: Warns and returns the same metadata as get_metadata. + ("getMetaData", "get_metadata"), + ], +) +def test_profileparser_deprecations( + as_list, + parser_datafiles, + input_parser_class, + input_old_name, + input_new_name, +): + old_parser = _make_parser( + input_parser_class, input_new_name, parser_datafiles + ) + old_args = _make_call_args(input_new_name, parser_datafiles) + with pytest.warns( + DeprecationWarning, + match=expected_message(PROFILE_PARSER, input_old_name, input_new_name), + ): + old_return_value = getattr(old_parser, input_old_name)(*old_args) + new_parser = _make_parser( + input_parser_class, input_new_name, parser_datafiles + ) + new_args = _make_call_args(input_new_name, parser_datafiles) + new_return_value = getattr(new_parser, input_new_name)(*new_args) + actual_old_result = ( + _describe_value(old_return_value, as_list), + _describe_parser(old_parser, as_list), + ) + actual_new_result = ( + _describe_value(new_return_value, as_list), + _describe_parser(new_parser, as_list), + ) + assert actual_old_result == actual_new_result diff --git a/tests/deprecated/test_recipeorganizer_deprecations.py b/tests/deprecated/test_recipeorganizer_deprecations.py new file mode 100644 index 00000000..0cbf0355 --- /dev/null +++ b/tests/deprecated/test_recipeorganizer_deprecations.py @@ -0,0 +1,318 @@ +"""Tests for the deprecated camelCase names in +diffpy.srfit.fitbase.recipeorganizer. + +Each test calls a deprecated name, checks that it warns, and checks that +it gives the same result as its new name on an identical fresh object. + +Remove this file in version 4.0.0, together with the deprecated names. +""" + +import re +import types + +import pytest +from numpy import inf + +from diffpy.srfit.equation import Equation +from diffpy.srfit.equation.builder import EquationFactory +from diffpy.srfit.fitbase.calculator import Calculator +from diffpy.srfit.fitbase.parameter import Parameter +from diffpy.srfit.fitbase.recipeorganizer import ( + RecipeContainer, + RecipeOrganizer, + equationFromString, + get_equation_from_string, +) +from diffpy.srfit.fitbase.restraint import Restraint + + +def expected_message(base, old_name, new_name): + return re.escape( + f"'{base}.{old_name}' is deprecated and will be removed in version " + f"4.0.0. Please use '{base}.{new_name}' instead." + ) + + +RECIPEORGANIZER = "diffpy.srfit.fitbase.recipeorganizer" +RECIPE_CONTAINER = "diffpy.srfit.fitbase.recipeorganizer.RecipeContainer" +RECIPE_ORGANIZER = "diffpy.srfit.fitbase.recipeorganizer.RecipeOrganizer" + + +def _describe(result): + """Return a comparable description of what a method returned. + + Parameters are described by their name and value, Equations by their + value and Restraints by their penalty. + """ + if isinstance(result, Parameter): + return result.name, result.value + if isinstance(result, Equation): + return result() + if isinstance(result, Restraint): + return result.penalty() + if isinstance(result, (list, types.GeneratorType, type({}.keys()))): + return [_describe(item) for item in result] + return result + + +def _make_container(): + """Return the container root holding x = 1 and y = 2, and the + container child, which holds x = 3.""" + root = RecipeContainer("root") + root._containers = {} + root._manage(root._containers) + root._add_object(Parameter("x", 1), root._parameters) + root._add_object(Parameter("y", 2), root._parameters) + child = RecipeContainer("child") + child._add_object(Parameter("x", 3), child._parameters) + root._add_object(child, root._containers) + return root + + +# RecipeContainer: each deprecated method does the same as its new name on +# a container holding x = 1, y = 2 and a child container holding x = 3. +# See test_recipeorganizer.py. +@pytest.mark.parametrize( + "input_old_name, input_new_name, input_args", + [ + # C1: getNames gets the names of the container's Parameters. + # Expected: Warns and gives the same names as get_names. + ("getNames", "get_names", ()), + # C2: getValues gets the values of the container's Parameters. + # Expected: Warns and gives the same values as get_values. + ("getValues", "get_values", ()), + # C3: iterPars iterates over the Parameters named x in the + # container and its child. + # Expected: Warns and gives the same Parameters as + # iterate_over_parameters. + ("iterPars", "iterate_over_parameters", ("^x$",)), + ], +) +def test_recipe_container_deprecations( + input_old_name, input_new_name, input_args +): + with pytest.warns( + DeprecationWarning, + match=expected_message( + RECIPE_CONTAINER, input_old_name, input_new_name + ), + ): + actual_old_result = _describe( + getattr(_make_container(), input_old_name)(*input_args) + ) + actual_new_result = _describe( + getattr(_make_container(), input_new_name)(*input_args) + ) + assert actual_old_result == actual_new_result + + +class _ScaleCalculator(Calculator): + """Calculator of scale * x.""" + + def __init__(self, name): + Calculator.__init__(self, name) + self.new_parameter("scale", 2.0) + + def __call__(self, x): + return self.scale.get_value() * x + + +def _double(x): + return 2 * x + + +def _make_organizer(): + """Return an organizer holding x = 1, y = 2 and z = 3, with the + constraint y = 2*x and a soft bound of z <= 2.""" + organizer = RecipeOrganizer("organizer") + for name, value in (("x", 1), ("y", 2), ("z", 3)): + organizer._new_parameter(name, value) + organizer.add_constraint("y", "2*x") + organizer.add_soft_bounds("z", upper_bound=2) + return organizer + + +def _call_organizer_method(organizer, method_name, args): + """Call a method on an organizer from _make_organizer. + + Return what the method returned, then the Parameters, the + constrained Parameters and the total restraint penalty of the + organizer afterward. + """ + result = getattr(organizer, method_name)(*args) + constrained_names = sorted( + parameter.name for parameter in organizer._constraints + ) + total_penalty = sum( + restraint.penalty() for restraint in organizer._restraints + ) + return ( + _describe(result), + _describe(list(organizer._parameters.values())), + constrained_names, + total_penalty, + ) + + +# RecipeOrganizer: each deprecated method does the same as its new name on +# an organizer holding x = 1, y = 2 and z = 3, with the constraint y = 2*x +# and a soft bound of z <= 2. See test_recipeorganizer.py. +@pytest.mark.parametrize( + "input_old_name, input_new_name, input_args", + [ + # C1: registerFunction registers the function 2 * x. + # Expected: Warns and gives the same result as register_function. + ("registerFunction", "register_function", (_double,)), + # C2: registerStringFunction registers f = x + y. + # Expected: Warns and gives the same result as + # register_string_function. + ( + "registerStringFunction", + "register_string_function", + ("x + y", "f"), + ), + # C3: evaluateEquation evaluates "3 * x". + # Expected: Warns and gives the same result as evaluate_equation. + ("evaluateEquation", "evaluate_equation", ("3 * x",)), + # C4: constrain constrains z to "x + y". + # Expected: Warns and gives the same result as add_constraint. + ("constrain", "add_constraint", ("z", "x + y")), + # C5: isConstrained checks whether y is constrained. + # Expected: Warns and gives the same result as is_constrained. + ("isConstrained", "is_constrained", ("y",)), + # C6: unconstrain removes the constraint on y. + # Expected: Warns and gives the same result as remove_constraint. + ("unconstrain", "remove_constraint", ("y",)), + # C7: getConstrainedPars gets the constrained Parameters. + # Expected: Warns and gives the same result as + # get_constrained_parmeters. + ("getConstrainedPars", "get_constrained_parmeters", ()), + # C8: clearConstraints removes all constraints. + # Expected: Warns and gives the same result as + # clear_all_constraints. + ("clearConstraints", "clear_all_constraints", ()), + # C9: restrain adds the soft bound x <= 0. + # Expected: Warns and gives the same result as add_soft_bounds. + ("restrain", "add_soft_bounds", ("x", -inf, 0)), + # C10: clearRestraints removes all soft bounds. + # Expected: Warns and gives the same result as + # clear_all_soft_bounds. + ("clearRestraints", "clear_all_soft_bounds", ()), + ], +) +def test_recipe_organizer_deprecations( + input_old_name, input_new_name, input_args +): + with pytest.warns( + DeprecationWarning, + match=expected_message( + RECIPE_ORGANIZER, input_old_name, input_new_name + ), + ): + actual_old_result = _call_organizer_method( + _make_organizer(), input_old_name, input_args + ) + actual_new_result = _call_organizer_method( + _make_organizer(), input_new_name, input_args + ) + assert actual_old_result == actual_new_result + + +# RecipeOrganizer: the deprecated methods below take an object that is +# made for each organizer. The organizer is the same as in +# test_recipe_organizer_deprecations. + + +def test_registerCalculator(): + # C1: registerCalculator registers the calculator g = 2 * x. + # Expected: Warns and gives the same result as register_calculator. + with pytest.warns( + DeprecationWarning, + match=expected_message( + RECIPE_ORGANIZER, "registerCalculator", "register_calculator" + ), + ): + actual_old_result = _call_organizer_method( + _make_organizer(), "registerCalculator", (_ScaleCalculator("g"),) + ) + actual_new_result = _call_organizer_method( + _make_organizer(), "register_calculator", (_ScaleCalculator("g"),) + ) + assert actual_old_result == actual_new_result + + +def test_addRestraint(): + # C1: addRestraint adds a Restraint of x <= 0. + # Expected: Warns and gives the same result as register_soft_bounds. + old_organizer = _make_organizer() + old_restraint = Restraint(Equation(root=old_organizer.x), -inf, 0) + with pytest.warns( + DeprecationWarning, + match=expected_message( + RECIPE_ORGANIZER, "addRestraint", "register_soft_bounds" + ), + ): + actual_old_result = _call_organizer_method( + old_organizer, "addRestraint", (old_restraint,) + ) + new_organizer = _make_organizer() + new_restraint = Restraint(Equation(root=new_organizer.x), -inf, 0) + actual_new_result = _call_organizer_method( + new_organizer, "register_soft_bounds", (new_restraint,) + ) + assert actual_old_result == actual_new_result + + +def test_unrestrain(): + # C1: unrestrain removes the organizer's soft bound on z. + # Expected: Warns and gives the same result as remove_soft_bounds. + old_organizer = _make_organizer() + with pytest.warns( + DeprecationWarning, + match=expected_message( + RECIPE_ORGANIZER, "unrestrain", "remove_soft_bounds" + ), + ): + actual_old_result = _call_organizer_method( + old_organizer, "unrestrain", tuple(old_organizer._restraints) + ) + new_organizer = _make_organizer() + actual_new_result = _call_organizer_method( + new_organizer, "remove_soft_bounds", tuple(new_organizer._restraints) + ) + assert actual_old_result == actual_new_result + + +def _build_equation(function): + """Build "p1+p2+p3" with a factory that has p1 = 1 and p2 = 2 + registered, passing p3 = 3 in the namespace dictionary. + + Return the argument names and value of the equation, and whether p3 + is still registered with the factory afterward. + """ + factory = EquationFactory() + factory.register_argument("p1", Parameter("p1", 1)) + factory.register_argument("p2", Parameter("p2", 2)) + equation = function("p1+p2+p3", factory, {"p3": Parameter("p3", 3)}) + return ( + sorted(arg.name for arg in equation.args), + equation(), + "p3" in factory.builders, + ) + + +# recipeorganizer module: see +# test_recipeorganizer.py::test_get_equation_from_string. +def test_equationFromString(): + # C1: "p1+p2+p3" is built with p3 passed in the namespace dictionary. + # Expected: Warns and gives the same equation as + # get_equation_from_string. + with pytest.warns( + DeprecationWarning, + match=expected_message( + RECIPEORGANIZER, "equationFromString", "get_equation_from_string" + ), + ): + actual_old_result = _build_equation(equationFromString) + actual_new_result = _build_equation(get_equation_from_string) + assert actual_old_result == actual_new_result diff --git a/tests/test_builder.py b/tests/test_builder.py index 2884fb13..78efeaed 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -25,6 +25,10 @@ def _gaussian(x, sigma): return numpy.sqrt(numpy.exp(-0.5 * (x / sigma) ** 2)) +def _halve(v1): + return 0.5 * v1 + + def _make_factory_with_function(make_args): """Return a factory where g(v1, v2, v3, v4) = (v1 + v2) * (v3 + v4). @@ -210,9 +214,7 @@ def test_register_function_replaces_existing( factory, _, (v1, v2, _, _, _), _ = _make_factory_with_function(make_args) equation = factory.make_equation("g") if input_replacement == "python_function": - function_builder = factory.register_function( - "g", lambda v1: 0.5 * v1, ["v1"] - ) + function_builder = factory.register_function("g", _halve, ["v1"]) expected_args = [v1] else: replacement = factory.make_equation("v1 + v2") diff --git a/tests/test_constraint.py b/tests/test_constraint.py index 00def3c5..fba6b871 100644 --- a/tests/test_constraint.py +++ b/tests/test_constraint.py @@ -12,98 +12,84 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Tests for refinableobj module.""" +"""Tests for the constraint module.""" -import unittest +import pytest -from diffpy.srfit.equation.builder import EquationFactory from diffpy.srfit.fitbase.constraint import Constraint -from diffpy.srfit.fitbase.parameter import Parameter from diffpy.srfit.fitbase.recipeorganizer import get_equation_from_string -class TestConstraint(unittest.TestCase): - - def test_constrain_parameter(self): - """Test the Constraint class.""" - p1 = Parameter("p1", 1) - p2 = Parameter("p2", 2) - - factory = EquationFactory() - - factory.register_argument("p1", p1) - factory.register_argument("p2", p2) - - c = Constraint() - # Constrain p1 = 2*p2 - eq = get_equation_from_string("2*p2", factory) - c.add_constraint(p1, eq) - - self.assertTrue(p1.constrained) - self.assertFalse(p2.constrained) - - eq2 = get_equation_from_string("2*p2+1", factory) - c2 = Constraint() - self.assertRaises(ValueError, c2.constrain, p1, eq2) - p2.set_constant() - eq3 = get_equation_from_string("p1", factory) - self.assertRaises(ValueError, c2.constrain, p2, eq3) - - p2.set_value(2.5) - c.update() - self.assertEqual(5.0, p1.get_value()) - - p2.set_value(8.1) - self.assertEqual(5.0, p1.get_value()) - c.update() - self.assertEqual(16.2, p1.get_value()) - return - - -# ---------------------------------------------------------------------------- -# Constraint.constrain and Parameter.setConst are deprecated in favor of -# Constraint.add_constraint and Parameter.set_constant. The old names must -# still work and forward to the new implementation. - - -class TestConstraintDeprecated(unittest.TestCase): - - def test_constrain_deprecated(self): - """Test the deprecated Constraint.constrain and - Parameter.setConst methods.""" - p1 = Parameter("p1", 1) - p2 = Parameter("p2", 2) - - factory = EquationFactory() - - factory.register_argument("p1", p1) - factory.register_argument("p2", p2) - - c = Constraint() - # Constrain p1 = 2*p2 - eq = get_equation_from_string("2*p2", factory) - c.constrain(p1, eq) - - self.assertTrue(p1.constrained) - self.assertFalse(p2.constrained) - - eq2 = get_equation_from_string("2*p2+1", factory) - c2 = Constraint() - self.assertRaises(ValueError, c2.constrain, p1, eq2) - p2.setConst() - eq3 = get_equation_from_string("p1", factory) - self.assertRaises(ValueError, c2.constrain, p2, eq3) - - p2.set_value(2.5) - c.update() - self.assertEqual(5.0, p1.get_value()) - - p2.set_value(8.1) - self.assertEqual(5.0, p1.get_value()) - c.update() - self.assertEqual(16.2, p1.get_value()) - return - - -if __name__ == "__main__": - unittest.main() +# Test that add_constraint marks only the constrained Parameter and sets its +# value from the equation. +def test_add_constraint(make_parameters_with_factory): + # C1: p1 = 1 is constrained to 2*p2, where p2 = 2. + # Expected: p1 is constrained, p2 is not, and p1 takes the value 4. + p1, p2, factory = make_parameters_with_factory() + constraint = Constraint() + constraint.add_constraint(p1, get_equation_from_string("2*p2", factory)) + actual_constrained = [p1.constrained, p2.constrained] + expected_constrained = [True, False] + assert actual_constrained == expected_constrained + actual_value = p1.get_value() + expected_value = 4.0 + assert actual_value == expected_value + + +# Test that add_constraint refuses a Parameter that cannot be constrained. +@pytest.mark.parametrize( + "input_parameter_state", + [ + # C1: p1 is already constrained by another Constraint. + # Expected: Raises a ValueError. + "constrained", + # C2: p1 is constant. + # Expected: Raises a ValueError. + "constant", + ], +) +def test_add_constraint_bad( + make_parameters_with_factory, input_parameter_state +): + p1, _, factory = make_parameters_with_factory() + if input_parameter_state == "constrained": + Constraint().add_constraint( + p1, get_equation_from_string("2*p2", factory) + ) + else: + p1.set_constant() + equation = get_equation_from_string("2*p2+1", factory) + with pytest.raises(ValueError): + Constraint().add_constraint(p1, equation) + + +# Test that update sets the constrained Parameter from the current values in +# the equation, and that the Parameter is not changed until update is called. +@pytest.mark.parametrize( + "input_p2_value, input_call_update, expected_p1_value", + [ + # C1: p2 is set to 2.5 and update is called, for p1 = 2*p2. + # Expected: p1 becomes 5.0. + (2.5, True, 5.0), + # C2: p2 is set to 8.1 and update is called, for p1 = 2*p2. + # Expected: p1 becomes 16.2. + (8.1, True, 16.2), + # C3: p2 is set to 8.1 and update is not called, for p1 = 2*p2. + # Expected: p1 keeps the value 4.0 it had when it was constrained. + (8.1, False, 4.0), + ], +) +def test_update( + make_parameters_with_factory, + input_p2_value, + input_call_update, + expected_p1_value, +): + p1, p2, factory = make_parameters_with_factory() + constraint = Constraint() + constraint.add_constraint(p1, get_equation_from_string("2*p2", factory)) + p2.set_value(input_p2_value) + if input_call_update: + constraint.update() + actual_p1_value = p1.get_value() + assert actual_p1_value == expected_p1_value diff --git a/tests/test_contribution.py b/tests/test_contribution.py index 4958c12a..50d168fb 100644 --- a/tests/test_contribution.py +++ b/tests/test_contribution.py @@ -12,11 +12,8 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Tests for refinableobj module.""" +"""Tests for the fitcontribution module.""" -import unittest - -import numpy as np import pytest from numpy import arange, array_equal, dot, sin @@ -24,324 +21,461 @@ from diffpy.srfit.exceptions import SrFitError from diffpy.srfit.fitbase.parameter import Parameter - -class TestContribution(unittest.TestCase): - - def setUp(self): - self.gen = ProfileGenerator("test") - self.profile = Profile() - self.fitcontribution = FitContribution("test") - return - - def testset_profile(self): - fc = self.fitcontribution - profile = self.profile - fc.set_profile(self.profile) - # verify standard profile setup - self.assertTrue(fc.profile is profile) - self.assertTrue(fc.x.par is profile.xpar) - self.assertTrue(fc.y.par is profile.ypar) - self.assertTrue(fc.dy.par is profile.dypar) - self.assertTrue(fc._eq is None) - self.assertTrue(fc._reseq is None) - # check type checking - fc1 = FitContribution("test1") - self.assertRaises(TypeError, fc1.setProfile, "invalid") - # check if residual equation is set up when possible - fc2 = FitContribution("test2") - fc2.set_equation("A * x") - fc2.set_profile(profile) - self.assertFalse(fc2._reseq is None) - return - - def testAddProfileGenerator(self): - fc = self.fitcontribution - gen = self.gen - fc.addProfileGenerator(gen, "gen") - - xobs = arange(0, 10, 0.5) - self.assertTrue(array_equal(xobs, gen(xobs))) - - self.assertTrue(gen.profile is None) - self.assertTrue(fc._eq is not None) - return - - def test_add_profile_generator(self): - fc = self.fitcontribution - gen = self.gen - fc.add_profile_generator(gen, "gen") - - xobs = arange(0, 10, 0.5) - self.assertTrue(array_equal(xobs, gen(xobs))) - - self.assertTrue(gen.profile is None) - self.assertTrue(fc._eq is not None) - return - - def testInteraction(self): - """Test the interaction between the profile and profile - generator.""" - fc = self.fitcontribution - profile = self.profile - gen = self.gen - - # Add the calculator and profile - fc.set_profile(profile) - fc.add_profile_generator(gen, "I") - - # Check attributes are created - self.assertTrue(fc.profile is profile) - self.assertTrue(fc.x.par is profile.xpar) - self.assertTrue(fc.y.par is profile.ypar) - self.assertTrue(fc.dy.par is profile.dypar) - self.assertTrue(fc._eq is not None) - self.assertTrue(fc._reseq is not None) - self.assertTrue(gen.profile is profile) - - # create some data - xobs = arange(0, 10, 0.5) - yobs = xobs - profile.set_observed_profile(xobs, yobs) - - # Make sure this is where it's supposed to be - self.assertTrue(gen.profile.xobs is xobs) - self.assertTrue(gen.profile.yobs is yobs) - self.assertTrue(array_equal(fc.x.value, xobs)) - self.assertTrue(array_equal(gen.profile.x, xobs)) - self.assertTrue(array_equal(xobs, gen(xobs))) - - # Now evaluate the profile equation - self.assertTrue(array_equal(fc._eq(), yobs)) - # And the residual equation - self.assertAlmostEqual(0, sum(fc._reseq())) - - return - - def testReplacements(self): - """Test attribute integrity when objects get replaced.""" - fc = self.fitcontribution - xobs = arange(0, 10, 0.5) - yobs = xobs - profile = self.profile - profile.set_observed_profile(xobs, yobs) - xobs2 = arange(0, 10, 0.8) - yobs2 = 0.5 * xobs2 - profile2 = Profile() - profile2.set_observed_profile(xobs2, yobs2) - gen = self.gen - - # Validate equations - fc.set_profile(profile) - fc.add_profile_generator(gen, "I") - self.assertTrue(array_equal(gen.value, xobs)) - self.assertTrue(array_equal(fc._eq(), xobs)) - self.assertAlmostEqual(0, sum(fc._reseq())) - self.assertEqual(len(xobs), len(fc.residual())) - eq = fc._eq - reseq = fc._reseq - - # Now set a different profile - fc.set_profile(profile2) - self.assertTrue(fc.profile is profile2) - self.assertTrue(gen.profile is profile2) - self.assertTrue(fc._eq is eq) - self.assertTrue(fc._reseq is reseq) - self.assertTrue(fc._eq._value is None) - self.assertTrue(fc._reseq._value is None) - - # Validate equations - self.assertTrue(array_equal(xobs2, gen.value)) - self.assertTrue(array_equal(fc._eq(), gen.value)) - self.assertEqual(len(xobs2), len(fc.residual())) - return - - def test_get_residual_equation(self): - """Check getting the current formula for residual equation.""" - fc = self.fitcontribution - self.assertEqual("", fc.get_residual_equation()) - fc.set_profile(self.profile) - fc.set_equation("A * x + B") - self.assertEqual("((eq - y) / dy)", fc.get_residual_equation()) - fc.set_residual_equation("2 * (eq - y)") - self.assertEqual("(2 * (eq - y))", fc.get_residual_equation()) - return - - def test_getResidualEquation(self): - """Check getting the current formula for residual equation.""" - fc = self.fitcontribution - self.assertEqual("", fc.get_residual_equation()) - fc.set_profile(self.profile) - fc.set_equation("A * x + B") - self.assertEqual("((eq - y) / dy)", fc.getResidualEquation()) - fc.setResidualEquation("2 * (eq - y)") - self.assertEqual("(2 * (eq - y))", fc.get_residual_equation()) - return - - def test_releaseOldEquations(self): - """Ensure EquationFactory does not hold to obsolete - Equations.""" - fc = self.fitcontribution - self.assertEqual(0, len(fc._eqfactory.equations)) - for i in range(5): - fc.set_equation("A * x + B") - self.assertEqual(1, len(fc._eqfactory.equations)) - fc.set_profile(self.profile) - for i in range(5): - fc.set_residual_equation("chiv") - self.assertEqual(2, len(fc._eqfactory.equations)) - return - - def test_register_function(self): - """Ensure registered function works after second set_equation - call.""" - fc = self.fitcontribution - fc.register_function(_fsquare, name="fsquare") - fc.set_equation("fsquare") - fc.x.set_value(5) - self.assertEqual(25, fc.evaluate()) - fc.x << 6 - self.assertEqual(36, fc.evaluate()) - fc.set_equation("fsquare + 5") - self.assertEqual(41, fc.evaluate()) - fc.x << -1 - self.assertEqual(6, fc.evaluate()) - return +XOBS = arange(0, 10, 0.5) +YOBS_SINE = 9 * sin(XOBS) def _fsquare(x): return x**2 -def testResidual(find_builders_with_observers): - """Test the residual, which requires all other methods.""" - gen = ProfileGenerator("test") +# set_profile links the profile's x, y and dy to the contribution, and sets +# the default residual equation when a profile equation already exists. +@pytest.mark.parametrize( + "input_equation, expected_has_equation, expected_has_residual_equation", + [ + # C1: The profile is set before any equation. + # Expected: x, y and dy are linked, and neither the equation nor the + # residual equation is set. + (None, False, False), + # C2: The profile is set after the equation "A * x". + # Expected: x, y and dy are linked, and the residual equation is + # set. + ("A * x", True, True), + ], +) +def test_set_profile( + input_equation, expected_has_equation, expected_has_residual_equation +): profile = Profile() - fc = FitContribution("test") - - # Add the calculator and profile - fc.set_profile(profile) - assert fc.profile is profile - fc.add_profile_generator(gen, "I") - assert fc._eq._value is None - assert fc._reseq._value is None - assert 1 == len(fc._generators) - assert gen.name in fc._generators - - # Let's create some data) - xobs = arange(0, 10, 0.5) - yobs = xobs - profile.set_observed_profile(xobs, yobs) - - # Check our fitting equation. - assert np.array_equal(fc._eq(), gen(xobs)) - - # Now calculate the residual - chiv = fc.residual() - assert dot(chiv, chiv) == pytest.approx(0) - - # Now change the equation - fc.set_equation("2*I") - assert fc._eq._value is None - assert fc._reseq._value is None - chiv = fc.residual() - assert dot(chiv, chiv) == pytest.approx(dot(yobs, yobs)) - - # Try to add a parameter - c = Parameter("c", 2) - fc._add_parameter(c) - fc.set_equation("c*I") - assert fc._eq._value is None - assert fc._reseq._value is None - chiv = fc.residual() - assert dot(chiv, chiv) == pytest.approx(dot(yobs, yobs)) - - # Try something more complex - c.set_value(3) - fc.set_equation("c**2*sin(I)") - assert fc._eq._value is None - assert fc._reseq._value is None - xobs = arange(0, 10, 0.5) - yobs = 9 * sin(xobs) - profile.set_observed_profile(xobs, yobs) - assert fc._eq._value is None - assert fc._reseq._value is None - - chiv = fc.residual() - assert dot(chiv, chiv) == pytest.approx(0) - - # Choose a new residual. - fc.set_equation("2*I") - fc.set_residual_equation("resv") - chiv = fc.residual() - assert dot(chiv, chiv) == pytest.approx( - sum((2 * xobs - yobs) ** 2) / sum(yobs**2) + contribution = FitContribution("test") + if input_equation is not None: + contribution.set_equation(input_equation) + contribution.set_profile(profile) + actual_profile = contribution.profile + expected_profile = profile + assert actual_profile == expected_profile + actual_parameters = [ + contribution.x.par, + contribution.y.par, + contribution.dy.par, + ] + expected_parameters = [profile.xpar, profile.ypar, profile.dypar] + assert actual_parameters == expected_parameters + actual_has_equation = contribution._eq is not None + assert actual_has_equation == expected_has_equation + actual_has_residual_equation = contribution._reseq is not None + assert actual_has_residual_equation == expected_has_residual_equation + + +def test_set_profile_bad(): + # C1: A string is given instead of a Profile. + # Expected: Raises a TypeError. + contribution = FitContribution("test") + with pytest.raises(TypeError): + contribution.set_profile("invalid") + + +# add_profile_generator registers the generator, makes it the profile +# equation, and passes it the profile when there is one. +@pytest.mark.parametrize( + "input_has_profile, expected_has_residual_equation", + [ + # C1: The contribution has no profile. + # Expected: The generator is registered and has no profile, an + # equation is set, and no residual equation is set. + (False, False), + # C2: The contribution has a profile. + # Expected: The generator is registered and gets the profile, and + # both an equation and the residual equation are set. + (True, True), + ], +) +def test_add_profile_generator( + input_has_profile, expected_has_residual_equation +): + generator = ProfileGenerator("test") + profile = Profile() + contribution = FitContribution("test") + if input_has_profile: + contribution.set_profile(profile) + contribution.add_profile_generator(generator, "gen") + actual_generator_names = list(contribution._generators) + expected_generator_names = ["test"] + assert actual_generator_names == expected_generator_names + actual_has_equation = contribution._eq is not None + expected_has_equation = True + assert actual_has_equation == expected_has_equation + actual_generator_profile = generator.profile + expected_generator_profile = profile if input_has_profile else None + assert actual_generator_profile == expected_generator_profile + actual_has_residual_equation = contribution._reseq is not None + assert actual_has_residual_equation == expected_has_residual_equation + actual_generator_output = generator(XOBS) + expected_generator_output = XOBS + assert array_equal(actual_generator_output, expected_generator_output) + + +def test_profile_generator_interaction(): + # C1: A profile and a generator are added, then data with y = x is + # observed. + # Expected: The generator sees the observed data, the profile + # equation equals y, and the residual is zero. + generator = ProfileGenerator("test") + profile = Profile() + contribution = FitContribution("test") + contribution.set_profile(profile) + contribution.add_profile_generator(generator, "I") + profile.set_observed_profile(XOBS, XOBS) + actual_generator_profile_arrays = [ + generator.profile.xobs, + generator.profile.yobs, + generator.profile.x, + ] + expected_generator_profile_arrays = [XOBS, XOBS, XOBS] + for actual_array, expected_array in zip( + actual_generator_profile_arrays, expected_generator_profile_arrays + ): + assert array_equal(actual_array, expected_array) + actual_contribution_x = contribution.x.value + expected_contribution_x = XOBS + assert array_equal(actual_contribution_x, expected_contribution_x) + actual_generator_output = generator(XOBS) + expected_generator_output = XOBS + assert array_equal(actual_generator_output, expected_generator_output) + actual_profile_equation = contribution._eq() + expected_profile_equation = XOBS + assert array_equal(actual_profile_equation, expected_profile_equation) + actual_residual_sum = sum(contribution._reseq()) + expected_residual_sum = 0 + assert actual_residual_sum == expected_residual_sum + + +def test_replace_profile(): + # C1: A contribution with a generator is evaluated on one profile, then + # set to a second profile with different data. + # Expected: The generator gets the second profile, the same profile and + # residual equations are kept but their cached values are cleared, and + # they are evaluated on the second profile. + generator = ProfileGenerator("test") + profile = Profile() + profile.set_observed_profile(XOBS, XOBS) + new_xobs = arange(0, 10, 0.8) + new_profile = Profile() + new_profile.set_observed_profile(new_xobs, 0.5 * new_xobs) + contribution = FitContribution("test") + contribution.set_profile(profile) + contribution.add_profile_generator(generator, "I") + actual_initial_generator_value = generator.value + expected_initial_generator_value = XOBS + assert array_equal( + actual_initial_generator_value, expected_initial_generator_value ) - - # Make a custom residual. - fc.set_residual_equation("abs(eq-y)**0.5") - chiv = fc.residual() - assert dot(chiv, chiv) == pytest.approx(sum(abs(2 * xobs - yobs))) - - # Test configuration checks - fc1 = FitContribution("test1") - with pytest.raises(SrFitError): - fc1.set_residual_equation("chiv") - fc1.set_profile(profile) + actual_initial_profile_equation = contribution._eq() + expected_initial_profile_equation = XOBS + assert array_equal( + actual_initial_profile_equation, expected_initial_profile_equation + ) + actual_initial_residual_sum = sum(contribution._reseq()) + expected_initial_residual_sum = 0 + assert actual_initial_residual_sum == expected_initial_residual_sum + actual_initial_residual_length = len(contribution.residual()) + expected_initial_residual_length = len(XOBS) + assert actual_initial_residual_length == expected_initial_residual_length + expected_equations = [contribution._eq, contribution._reseq] + contribution.set_profile(new_profile) + actual_profiles = [contribution.profile, generator.profile] + expected_profiles = [new_profile, new_profile] + assert actual_profiles == expected_profiles + actual_equations = [contribution._eq, contribution._reseq] + assert actual_equations == expected_equations + actual_cached_values = [ + contribution._eq._value, + contribution._reseq._value, + ] + expected_cached_values = [None, None] + assert actual_cached_values == expected_cached_values + actual_generator_value = generator.value + expected_generator_value = new_xobs + assert array_equal(actual_generator_value, expected_generator_value) + actual_profile_equation = contribution._eq() + expected_profile_equation = new_xobs + assert array_equal(actual_profile_equation, expected_profile_equation) + actual_residual_length = len(contribution.residual()) + expected_residual_length = len(new_xobs) + assert actual_residual_length == expected_residual_length + + +# The residual of a contribution with a generator "I" (which returns x), +# observed at x = 0, 0.5, ..., 9.5. A parameter c is added to the +# contribution with value input_c_value. The squared residual, +# dot(residual, residual), is compared. +@pytest.mark.parametrize( + "input_equation, input_residual_equation, input_c_value, input_yobs, " + "expected_chi2", + [ + # C1: The equation is the generator, as set by + # add_profile_generator, and y = x. + # Expected: The default chiv residual is zero. + (None, None, 2, XOBS, 0), + # C2: The equation is changed so it no longer matches y = x, + # 1. to "2*I", 2. to "c*I" with c = 2. + # Expected: The chiv residual is (2*x - x)/1, so chi2 = dot(x, x). + ("2*I", None, 2, XOBS, dot(XOBS, XOBS)), + ("c*I", None, 2, XOBS, dot(XOBS, XOBS)), + # C3: The equation is "c**2*sin(I)" with c = 3, and y = 9*sin(x). + # Expected: The chiv residual is zero. + ("c**2*sin(I)", None, 3, YOBS_SINE, 0), + # C4: The equation is "2*I", y = 9*sin(x), and the residual is the + # preset "resv". + # Expected: chi2 = sum((2*x - y)**2) / sum(y**2). + ( + "2*I", + "resv", + 2, + YOBS_SINE, + sum((2 * XOBS - YOBS_SINE) ** 2) / sum(YOBS_SINE**2), + ), + # C5: The equation is "2*I", y = 9*sin(x), and the residual is the + # custom "abs(eq-y)**0.5". + # Expected: chi2 = sum(abs(2*x - y)). + ( + "2*I", + "abs(eq-y)**0.5", + 2, + YOBS_SINE, + sum(abs(2 * XOBS - YOBS_SINE)), + ), + ], +) +def test_residual( + find_builders_with_observers, + input_equation, + input_residual_equation, + input_c_value, + input_yobs, + expected_chi2, +): + generator = ProfileGenerator("test") + profile = Profile() + contribution = FitContribution("test") + contribution.set_profile(profile) + contribution.add_profile_generator(generator, "I") + contribution._add_parameter(Parameter("c", input_c_value)) + if input_equation is not None: + contribution.set_equation(input_equation) + if input_residual_equation is not None: + contribution.set_residual_equation(input_residual_equation) + profile.set_observed_profile(XOBS, input_yobs) + residual = contribution.residual() + actual_chi2 = dot(residual, residual) + assert actual_chi2 == pytest.approx(expected_chi2) + actual_builders = find_builders_with_observers() + expected_builders = [] + assert actual_builders == expected_builders + + +# The cached values of the profile and residual equations are cleared +# whenever their result may change. The contribution has a profile observed +# at x = 0, 0.5, ..., 9.5 with y = x, and a generator "I". +@pytest.mark.parametrize( + "input_change", + [ + # C1: Nothing has been evaluated yet. + # Expected: Both cached values are None. + None, + # C2: The residual is computed, then the equation is changed, + # 1. to "2*I", 2. to "c*I" with a new parameter c. + # Expected: Both cached values are cleared. + "2*I", + "c*I", + # C3: The residual is computed, then new data y = 9*sin(x) is + # observed. + # Expected: Both cached values are cleared. + "set_observed_profile", + ], +) +def test_residual_flushes_cached_values(input_change): + generator = ProfileGenerator("test") + profile = Profile() + contribution = FitContribution("test") + contribution.set_profile(profile) + contribution.add_profile_generator(generator, "I") + contribution._add_parameter(Parameter("c", 2)) + if input_change is not None: + profile.set_observed_profile(XOBS, XOBS) + contribution.residual() + if input_change == "set_observed_profile": + profile.set_observed_profile(XOBS, YOBS_SINE) + else: + contribution.set_equation(input_change) + actual_cached_values = [ + contribution._eq._value, + contribution._reseq._value, + ] + expected_cached_values = [None, None] + assert actual_cached_values == expected_cached_values + + +# set_residual_equation needs both a profile and a profile equation. +@pytest.mark.parametrize( + "input_has_profile", + [ + # C1: The contribution has neither a profile nor an equation. + # Expected: Raises an SrFitError. + False, + # C2: The contribution has a profile but no equation. + # Expected: Raises an SrFitError. + True, + ], +) +def test_set_residual_equation_bad(input_has_profile): + contribution = FitContribution("test") + if input_has_profile: + contribution.set_profile(Profile()) with pytest.raises(SrFitError): - fc1.set_residual_equation("chiv") - fc1.set_equation("A * x") - fc1.set_residual_equation("chiv") - assert find_builders_with_observers() == [] - return - - -def test_setEquation(find_builders_with_observers): - """Check replacement of removed parameters.""" - fc = FitContribution("test") - fc.setEquation("x + 5") - fc.x.set_value(2) - assert 7 == fc.evaluate() - fc.remove_parameter(fc.x) - x = arange(0, 10, 0.5) - fc.new_parameter("x", x) - assert np.array_equal(5 + x, fc.evaluate()) - assert find_builders_with_observers() == [] - return - - -def test_set_equation(find_builders_with_observers): - """Check replacement of removed parameters.""" - fc = FitContribution("test") - fc.set_equation("x + 5") - fc.x.set_value(2) - assert 7 == fc.evaluate() - fc.remove_parameter(fc.x) - x = arange(0, 10, 0.5) - fc.new_parameter("x", x) - assert np.array_equal(5 + x, fc.evaluate()) - assert find_builders_with_observers() == [] - return - - -def test_getEquation(find_builders_with_observers): - """Check getting the current profile simulation formula.""" - fc = FitContribution("test") - assert "" == fc.get_equation() - fc.set_equation("A * sin(x + 5)") - assert "(A * sin((x + 5)))" == fc.getEquation() - assert find_builders_with_observers() == [] - return - - -def test_get_equation(find_builders_with_observers): - """Check getting the current profile simulation formula.""" - fc = FitContribution("test") - assert "" == fc.get_equation() - fc.set_equation("A * sin(x + 5)") - assert "(A * sin((x + 5)))" == fc.get_equation() - assert find_builders_with_observers() == [] - return - - -if __name__ == "__main__": - unittest.main() + contribution.set_residual_equation("chiv") + + +# get_residual_equation returns the residual equation as a string, with the +# profile equation written as "eq". +@pytest.mark.parametrize( + "input_equation, input_residual_equation, expected_residual_equation", + [ + # C1: Neither a profile nor an equation is set. + # Expected: Returns an empty string. + (None, None, ""), + # C2: A profile and the equation "A * x + B" are set. + # Expected: Returns the default chiv residual. + ("A * x + B", None, "((eq - y) / dy)"), + # C3: The residual equation is then set to "2 * (eq - y)". + # Expected: Returns the new residual equation. + ("A * x + B", "2 * (eq - y)", "(2 * (eq - y))"), + ], +) +def test_get_residual_equation( + input_equation, input_residual_equation, expected_residual_equation +): + contribution = FitContribution("test") + if input_equation is not None: + contribution.set_profile(Profile()) + contribution.set_equation(input_equation) + if input_residual_equation is not None: + contribution.set_residual_equation(input_residual_equation) + actual_residual_equation = contribution.get_residual_equation() + assert actual_residual_equation == expected_residual_equation + + +# The equation factory does not hold on to equations that were replaced. +@pytest.mark.parametrize( + "input_equation_repeats, input_residual_repeats, expected_equation_count", + [ + # C1: Nothing is set. + # Expected: The factory holds no equations. + (0, 0, 0), + # C2: set_equation is called five times. + # Expected: The factory holds one equation. + (5, 0, 1), + # C3: set_equation is called five times, then a profile is set and + # set_residual_equation is called five times. + # Expected: The factory holds two equations. + (5, 5, 2), + ], +) +def test_release_old_equations( + input_equation_repeats, input_residual_repeats, expected_equation_count +): + contribution = FitContribution("test") + for _ in range(input_equation_repeats): + contribution.set_equation("A * x + B") + if input_residual_repeats > 0: + contribution.set_profile(Profile()) + for _ in range(input_residual_repeats): + contribution.set_residual_equation("chiv") + actual_equation_count = len(contribution._eqfactory.equations) + assert actual_equation_count == expected_equation_count + + +# A registered function still works after set_equation is called a second +# time. The equation is first set to "fsquare" and evaluated at x = 5. +@pytest.mark.parametrize( + "input_equation, input_x, expected_value", + [ + # C1: The equation is set to "fsquare" again. + # Expected: Returns 5**2 = 25. + ("fsquare", 5, 25), + # C2: The equation is set to "fsquare" again, and x is set to 6. + # Expected: Returns 6**2 = 36. + ("fsquare", 6, 36), + # C3: The equation is set to "fsquare + 5", 1. with x = 6, + # 2. with x = -1. + # Expected: Returns x**2 + 5, so 1. 41, 2. 6. + ("fsquare + 5", 6, 41), + ("fsquare + 5", -1, 6), + ], +) +def test_register_function(input_equation, input_x, expected_value): + contribution = FitContribution("test") + contribution.register_function(_fsquare, name="fsquare") + contribution.set_equation("fsquare") + contribution.x.set_value(5) + contribution.evaluate() + contribution.set_equation(input_equation) + contribution.x << input_x + actual_value = contribution.evaluate() + assert actual_value == expected_value + + +# set_equation picks up a parameter that replaces a removed one. +@pytest.mark.parametrize( + "input_x_replacement, expected_value", + [ + # C1: The equation "x + 5" is evaluated with x = 2. + # Expected: Returns 7. + (None, 7), + # C2: x is then removed and replaced by a new parameter x holding + # an array. + # Expected: Returns 5 + the array. + (XOBS, 5 + XOBS), + ], +) +def test_set_equation( + find_builders_with_observers, input_x_replacement, expected_value +): + contribution = FitContribution("test") + contribution.set_equation("x + 5") + contribution.x.set_value(2) + actual_value = contribution.evaluate() + if input_x_replacement is not None: + contribution.remove_parameter(contribution.x) + contribution.new_parameter("x", input_x_replacement) + actual_value = contribution.evaluate() + assert array_equal(actual_value, expected_value) + actual_builders = find_builders_with_observers() + expected_builders = [] + assert actual_builders == expected_builders + + +# get_equation returns the profile equation as a string. +@pytest.mark.parametrize( + "input_equation, expected_equation", + [ + # C1: No equation is set. + # Expected: Returns an empty string. + (None, ""), + # C2: The equation "A * sin(x + 5)" is set. + # Expected: Returns it fully parenthesized. + ("A * sin(x + 5)", "(A * sin((x + 5)))"), + ], +) +def test_get_equation( + find_builders_with_observers, input_equation, expected_equation +): + contribution = FitContribution("test") + if input_equation is not None: + contribution.set_equation(input_equation) + actual_equation = contribution.get_equation() + assert actual_equation == expected_equation + actual_builders = find_builders_with_observers() + expected_builders = [] + assert actual_builders == expected_builders diff --git a/tests/test_equation.py b/tests/test_equation.py index 9789fc2a..6552ffeb 100644 --- a/tests/test_equation.py +++ b/tests/test_equation.py @@ -88,30 +88,14 @@ def test_equation_root_and_attributes(make_args, input_root_kind): # The value can be read in three equivalent ways. @pytest.mark.parametrize("input_root_kind", ROOT_KINDS) -@pytest.mark.parametrize( - "input_read", - [ - # C1: The equation is called. - # Expected: 20. - "call", - # C2: get_value is called. - # Expected: 20. - "get_value", - # C3: The value attribute is read. - # Expected: 20. - "value", - ], -) -def test_equation_value(make_args, input_root_kind, input_read): +def test_equation_value(make_args, input_root_kind): + # C1: The equation is called, get_value is called and the value + # attribute is read. + # Expected: Each gives 20. equation, _ = _build_equation(make_args, input_root_kind) - reads = { - "call": equation, - "get_value": equation.get_value, - "value": lambda: equation.value, - } - actual_value = reads[input_read]() - expected_value = 20 - assert actual_value == expected_value + actual_values = [equation(), equation.get_value(), equation.value] + expected_values = [20, 20, 20] + assert actual_values == expected_values # Calling the equation with a keyword sets that argument before evaluating. diff --git a/tests/test_fitrecipe.py b/tests/test_fitrecipe.py index cbb92284..e8af724c 100644 --- a/tests/test_fitrecipe.py +++ b/tests/test_fitrecipe.py @@ -12,16 +12,16 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Tests for refinableobj module.""" +"""Tests for the FitRecipe class.""" -import unittest +import re import matplotlib import matplotlib.pyplot as plt import numpy as np import pytest from bg_mpl_stylesheets.styles import all_styles -from numpy import array_equal, dot, linspace, ones_like, pi, sin +from numpy import linspace, ones, pi, sin from scipy.optimize import leastsq from diffpy.srfit import ( @@ -36,373 +36,394 @@ matplotlib.use("Agg") -class TestFitRecipe(unittest.TestCase): - - def setUp(self): - self.recipe = FitRecipe("recipe") - self.recipe.fithooks[0].verbose = 0 - - # Set up the Profile - self.profile = Profile() - x = linspace(0, pi, 10) - y = sin(x) - self.profile.set_observed_profile(x, y) - - # Set up the FitContribution - self.fitcontribution = FitContribution("cont") - self.fitcontribution.set_profile(self.profile) - self.fitcontribution.set_equation("A*sin(k*x + c)") - self.fitcontribution.A.set_value(1) - self.fitcontribution.k.set_value(1) - self.fitcontribution.c.set_value(0) - - self.recipe.add_contribution(self.fitcontribution) - return - - def test_fix_free(self): - recipe = self.recipe - con = self.fitcontribution - - recipe.add_variable(con.A, 2, tag="tagA") - recipe.add_variable(con.k, 1, tag="tagk") - recipe.add_variable(con.c, 0) - recipe.create_new_variable("B", 0) - - self.assertTrue(recipe.is_free(recipe.A)) - recipe.fix("tagA") - self.assertFalse(recipe.is_free(recipe.A)) - recipe.free("tagA") - self.assertTrue(recipe.is_free(recipe.A)) - recipe.fix("A") - self.assertFalse(recipe.is_free(recipe.A)) - recipe.free("A") - self.assertTrue(recipe.is_free(recipe.A)) - recipe.fix(recipe.A) - self.assertFalse(recipe.is_free(recipe.A)) - recipe.free(recipe.A) - self.assertTrue(recipe.is_free(recipe.A)) - recipe.fix(recipe.A) - self.assertFalse(recipe.is_free(recipe.A)) - recipe.free("all") - self.assertTrue(recipe.is_free(recipe.A)) - self.assertTrue(recipe.is_free(recipe.k)) - self.assertTrue(recipe.is_free(recipe.c)) - self.assertTrue(recipe.is_free(recipe.B)) - recipe.fix(recipe.A, "tagk", c=3) - self.assertFalse(recipe.is_free(recipe.A)) - self.assertFalse(recipe.is_free(recipe.k)) - self.assertFalse(recipe.is_free(recipe.c)) - self.assertTrue(recipe.is_free(recipe.B)) - self.assertEqual(3, recipe.c.value) - recipe.fix("all") - self.assertFalse(recipe.is_free(recipe.A)) - self.assertFalse(recipe.is_free(recipe.k)) - self.assertFalse(recipe.is_free(recipe.c)) - self.assertFalse(recipe.isFree(recipe.B)) - - self.assertRaises(ValueError, recipe.free, "junk") - self.assertRaises(ValueError, recipe.fix, tagA=1) - self.assertRaises(ValueError, recipe.fix, "junk") - return - - def test_variables(self): - """Test to see if variables are added and removed properly.""" - recipe = self.recipe - con = self.fitcontribution - - recipe.add_variable(con.A, 2) - recipe.add_variable(con.k, 1) - recipe.add_variable(con.c, 0) - recipe.create_new_variable("B", 0) - - names = recipe.get_names() - self.assertEqual(names, ["A", "k", "c", "B"]) - values = recipe.get_values() - self.assertTrue((values == [2, 1, 0, 0]).all()) - - # Constrain a parameter to the B-variable to give it a value - p = Parameter("Bpar", -1) - recipe.add_constraint(recipe.B, p) - values = recipe.get_values() - self.assertTrue((values == [2, 1, 0]).all()) - recipe.delete_variable(recipe.B) - - recipe.fix(recipe.k) - - names = recipe.get_names() - self.assertEqual(names, ["A", "c"]) - values = recipe.get_values() - self.assertTrue((values == [2, 0]).all()) - - recipe.fix("all") - names = recipe.get_names() - self.assertEqual(names, []) - values = recipe.get_values() - self.assertTrue((values == []).all()) - - recipe.free("all") - names = recipe.get_names() - self.assertEqual(3, len(names)) - self.assertTrue("A" in names) - self.assertTrue("k" in names) - self.assertTrue("c" in names) - values = recipe.get_values() - self.assertEqual(3, len(values)) - self.assertTrue(0 in values) - self.assertTrue(1 in values) - self.assertTrue(2 in values) - return - - def testVars(self): - """Test to see if variables are added and removed properly.""" - recipe = self.recipe - con = self.fitcontribution - - recipe.add_variable(con.A, 2) - recipe.add_variable(con.k, 1) - recipe.addVar(con.c, 0) - recipe.newVar("B", 0) - - names = recipe.get_names() - self.assertEqual(names, ["A", "k", "c", "B"]) - values = recipe.get_values() - self.assertTrue((values == [2, 1, 0, 0]).all()) - - # Constrain a parameter to the B-variable to give it a value - p = Parameter("Bpar", -1) - recipe.constrain(recipe.B, p) - values = recipe.get_values() - self.assertTrue((values == [2, 1, 0]).all()) - recipe.delVar(recipe.B) - - recipe.fix(recipe.k) - - names = recipe.get_names() - self.assertEqual(names, ["A", "c"]) - values = recipe.get_values() - self.assertTrue((values == [2, 0]).all()) - - recipe.fix("all") - names = recipe.get_names() - self.assertEqual(names, []) - values = recipe.get_values() - self.assertTrue((values == []).all()) - - recipe.free("all") - names = recipe.getNames() - self.assertEqual(3, len(names)) - self.assertTrue("A" in names) - self.assertTrue("k" in names) - self.assertTrue("c" in names) - values = recipe.getValues() - self.assertEqual(3, len(values)) - self.assertTrue(0 in values) - self.assertTrue(1 in values) - self.assertTrue(2 in values) - return - - def testResidual(self): - """Test the residual and everything that can change it.""" - # With thing set up as they are, the residual should be 0 - res = self.recipe.residual() - self.assertAlmostEqual(0, dot(res, res)) - - # Change the c value to 1 so that the equation evaluates as sin(x+1) - x = self.profile.x - y = sin(x + 1) - self.recipe.cont.c.set_value(1) - res = self.recipe.residual() - self.assertTrue(array_equal(y - self.profile.y, res)) - - # Try some constraints - # Make c = 2*A, A = Avar - var = self.recipe.create_new_variable("Avar") - self.recipe.add_constraint( - self.fitcontribution.c, "2*A", {"A": self.fitcontribution.A} - ) - self.assertEqual(2, self.fitcontribution.c.value) - self.recipe.add_constraint(self.fitcontribution.A, var) - self.assertEqual(1, var.get_value()) - self.assertEqual(self.recipe.cont.A.get_value(), var.get_value()) - # c is constrained to a constrained parameter. - self.assertEqual(2, self.fitcontribution.c.value) - # The equation should evaluate to sin(x+2) - x = self.profile.x - y = sin(x + 2) - res = self.recipe.residual() - self.assertTrue(array_equal(y - self.profile.y, res)) - - # Now try some restraints. We want c to be exactly zero. It should give - # a penalty of (c-0)**2, which is 4 in this case - r1 = self.recipe.add_soft_bounds(self.fitcontribution.c, 0, 0, 1) - self.recipe._ready = False - res = self.recipe.residual() - chi2 = 4 + dot(y - self.profile.y, y - self.profile.y) - self.assertAlmostEqual(chi2, dot(res, res)) - - # Clear the constraint and restore the value of c to 0. This should - # give us chi2 = 0 again. - self.recipe.remove_constraint(self.fitcontribution.c) - self.fitcontribution.c.set_value(0) - res = self.recipe.residual([self.recipe.cont.A.get_value()]) - chi2 = 0 - self.assertAlmostEqual(chi2, dot(res, res)) - - # Remove the restraint and variable - self.recipe.remove_soft_bounds(r1) - self.recipe.delete_variable(self.recipe.Avar) - self.recipe._ready = False - res = self.recipe.residual() - chi2 = 0 - self.assertAlmostEqual(chi2, dot(res, res)) - - # Add constraints at the fitcontribution level. - self.fitcontribution.add_constraint(self.fitcontribution.c, "2*A") - # This should evaluate to sin(x+2) - x = self.profile.x - y = sin(x + 2) - res = self.recipe.residual() - self.assertTrue(array_equal(y - self.profile.y, res)) - - # Add a restraint at the fitcontribution level. - r1 = self.fitcontribution.add_soft_bounds( - self.fitcontribution.c, 0, 0, 1 - ) - self.recipe._ready = False - # The chi2 is the same as above, plus 4 - res = self.recipe.residual() - x = self.profile.x - y = sin(x + 2) - chi2 = 4 + dot(y - self.profile.y, y - self.profile.y) - self.assertAlmostEqual(chi2, dot(res, res)) - - # Remove those - self.fitcontribution.remove_soft_bounds(r1) - self.recipe._ready = False - self.fitcontribution.unconstrain(self.fitcontribution.c) - self.fitcontribution.c.set_value(0) - res = self.recipe.residual() - chi2 = 0 - self.assertAlmostEqual(chi2, dot(res, res)) - - # Now try to use the observed profile inside of the equation - # Set the equation equal to the data - self.fitcontribution.set_equation("y") - res = self.recipe.residual() - self.assertAlmostEqual(0, dot(res, res)) - - # Now add the uncertainty. This should give dy/dy = 1 for the residual - self.fitcontribution.set_equation("y+dy") - res = self.recipe.residual() - self.assertAlmostEqual(len(res), dot(res, res)) - - return - - -# End of class TestFitRecipe - - -# ---------------------------------------------------------------------------- -def test_boundsToRestraints(): - recipe = FitRecipe("recipe") +# Fix and then free a single variable, selecting it by tag, by name or by +# the variable itself. A variable is free when it is added. +@pytest.mark.parametrize( + "input_selection", + [ + # C1: Variable A is selected by its tag "tagA". + # Expected: A is free, then fixed, then free again. + "tag", + # C2: Variable A is selected by its name "A". + # Expected: A is free, then fixed, then free again. + "name", + # C3: Variable A is selected by passing the variable itself. + # Expected: A is free, then fixed, then free again. + "variable", + ], +) +def test_fix_and_free_one_variable( + sine_recipe_with_variables, input_selection +): + recipe = sine_recipe_with_variables + identifiers = {"tag": "tagA", "name": "A", "variable": recipe.A} + identifier = identifiers[input_selection] + actual_is_free_states = [recipe.is_free(recipe.A)] + recipe.fix(identifier) + actual_is_free_states.append(recipe.is_free(recipe.A)) + recipe.free(identifier) + actual_is_free_states.append(recipe.is_free(recipe.A)) + expected_is_free_states = [True, False, True] + assert actual_is_free_states == expected_is_free_states + + +# Fix and then free several variables at once. The recipe has the +# variables A, k, c and B, which are all free to begin with. +@pytest.mark.parametrize( + "input_fix_args, input_fix_kwargs, input_free_args, " + "expected_free_variables, expected_c_value", + [ + # C1: A is fixed, then "all" variables are freed. + # Expected: All variables are free and c keeps its value of 0. + (["A"], {}, ["all"], ["A", "k", "c", "B"], 0), + # C2: A is fixed by name, k by tag and c by name as a keyword with + # the value 3. + # Expected: Only B is free and c takes the value 3. + (["A", "tagk"], {"c": 3}, [], ["B"], 3), + # C3: "all" variables are fixed. + # Expected: No variable is free and c keeps its value of 0. + (["all"], {}, [], [], 0), + ], +) +def test_fix_and_free_several_variables( + sine_recipe_with_variables, + input_fix_args, + input_fix_kwargs, + input_free_args, + expected_free_variables, + expected_c_value, +): + recipe = sine_recipe_with_variables + recipe.fix(*input_fix_args, **input_fix_kwargs) + recipe.free(*input_free_args) + actual_free_variables = [ + name + for name in ["A", "k", "c", "B"] + if recipe.is_free(getattr(recipe, name)) + ] + actual_c_value = recipe.c.value + assert actual_free_variables == expected_free_variables + assert actual_c_value == expected_c_value - # create a bounded variable - recipe.create_new_variable("var1", 1) - expected_lower_bound = -1 - expected_upper_bound = 1 - expected_sigma = 2 - recipe.var1.bounds = (expected_lower_bound, expected_upper_bound) - - # apply restraints from bounds - recipe.boundsToRestraints(sig=expected_sigma, scaled=True) - restraints = list(recipe._restraints) - assert len(restraints) == 1 - r = restraints[0] - actual_lower_bound = r.lower_bound - actual_upper_bound = r.upper_bound - actual_sigma = r.sig - assert actual_lower_bound == expected_lower_bound - assert actual_upper_bound == expected_upper_bound - assert actual_sigma == expected_sigma - assert r.scaled is True + +# Fix or free with an identifier that does not select any variable. +@pytest.mark.parametrize( + "input_method_name, input_args, input_kwargs", + [ + # C1: An unknown name is freed. + # Expected: Raises a ValueError. + ("free", ("junk",), {}), + # C2: An unknown name is fixed. + # Expected: Raises a ValueError. + ("fix", ("junk",), {}), + # C3: A tag is fixed as if it were a variable name in a keyword. + # Expected: Raises a ValueError. + ("fix", (), {"tagA": 1}), + ], +) +def test_fix_and_free_bad( + sine_recipe_with_variables, input_method_name, input_args, input_kwargs +): + recipe = sine_recipe_with_variables + with pytest.raises(ValueError): + getattr(recipe, input_method_name)(*input_args, **input_kwargs) + + +def test_get_names_and_values(sine_recipe_with_variables): + # Get the names and values of the free variables after each change to + # the variables A=2, k=1, c=0 and B=0, which were added in that order. + # Each step changes the recipe left by the step before it. + recipe = sine_recipe_with_variables + actual_names_and_values = [] + # C1: The variables are left as they were added. + # Expected: All variables are listed in the order they were added. + actual_names_and_values.append( + (recipe.get_names(), recipe.get_values().tolist()) + ) + # C2: B is constrained to a parameter. + # Expected: B is fixed by the constraint and no longer listed. + recipe.add_constraint(recipe.B, Parameter("Bpar", -1)) + actual_names_and_values.append( + (recipe.get_names(), recipe.get_values().tolist()) + ) + # C3: B is deleted and k is fixed. + # Expected: Only A and c are listed. + recipe.delete_variable(recipe.B) + recipe.fix(recipe.k) + actual_names_and_values.append( + (recipe.get_names(), recipe.get_values().tolist()) + ) + # C4: All variables are fixed. + # Expected: Nothing is listed. + recipe.fix("all") + actual_names_and_values.append( + (recipe.get_names(), recipe.get_values().tolist()) + ) + # C5: All variables are freed. + # Expected: The remaining variables A, k and c are all listed. + recipe.free("all") + actual_names_and_values.append( + (recipe.get_names(), recipe.get_values().tolist()) + ) + expected_names_and_values = [ + (["A", "k", "c", "B"], [2, 1, 0, 0]), + (["A", "k", "c"], [2, 1, 0]), + (["A", "c"], [2, 0]), + ([], []), + (["A", "k", "c"], [2, 1, 0]), + ] + assert actual_names_and_values == expected_names_and_values + + +def test_add_constraint_to_constrained_parameter(build_sine_recipe): + # C1: c is constrained to 2*A, then A is constrained to the new + # variable Avar, which has no value yet. + # Expected: Avar takes the value of A, which is 1, and c follows the + # chain of constraints to 2. + recipe = build_sine_recipe() + variable = recipe.create_new_variable("Avar") + recipe.add_constraint(recipe.cont.c, "2*A", {"A": recipe.cont.A}) + recipe.add_constraint(recipe.cont.A, variable) + actual_values = [ + variable.get_value(), + recipe.cont.A.get_value(), + recipe.cont.c.get_value(), + ] + expected_values = [1, 1, 2] + assert actual_values == expected_values + + +# The residual of the sine recipe is (ycalc - y)/dy, followed by the square +# root of each restraint penalty. The recipe starts with ycalc = sin(x) = y +# and its profile has no uncertainties, so dy = 1. + + +# Set the phase c of the sine recipe, so that ycalc = sin(x + c). +@pytest.mark.parametrize( + "input_c_value", + [ + # C1: c is left at 0. + # Expected: The residual is sin(x) - y, which is zero. + 0, + # C2: c is set to 1. + # Expected: The residual is sin(x + 1) - y. + 1, + ], +) +def test_residual_with_parameter_value(build_sine_recipe, input_c_value): + recipe = build_sine_recipe() + profile = recipe.cont.profile + recipe.cont.c.set_value(input_c_value) + actual_residual = recipe.residual() + expected_residual = sin(profile.x + input_c_value) - profile.y + assert actual_residual == pytest.approx(expected_residual) + + +# Replace the equation of an evaluated sine recipe. +@pytest.mark.parametrize( + "input_equation, expected_residual_value", + [ + # C1: The equation is set to the observed profile "y". + # Expected: The residual is zero at every point. + ("y", 0), + # C2: The equation is set to "y+dy". + # Expected: The residual is dy/dy = 1 at every point. + ("y+dy", 1), + ], +) +def test_residual_with_new_equation( + build_sine_recipe, input_equation, expected_residual_value +): + recipe = build_sine_recipe() + profile = recipe.cont.profile + recipe.residual() + recipe.cont.set_equation(input_equation) + actual_residual = recipe.residual() + expected_residual = np.full_like(profile.y, expected_residual_value) + assert actual_residual == pytest.approx(expected_residual) + + +def test_residual_with_recipe_constraints_and_restraints(build_sine_recipe): + # Constrain and restrain c through the recipe, then undo each change. + # Each step changes the recipe left by the step before it, and the + # residual is evaluated after each step. + recipe = build_sine_recipe() + x, y = recipe.cont.profile.x, recipe.cont.profile.y + # C1: c is constrained to 2*A, and A to the new variable Avar. + # Expected: c is 2, so the residual is sin(x + 2) - y. + variable = recipe.create_new_variable("Avar") + recipe.add_constraint(recipe.cont.c, "2*A", {"A": recipe.cont.A}) + recipe.add_constraint(recipe.cont.A, variable) + actual_constrained_residual = recipe.residual() + expected_constrained_residual = sin(x + 2) - y + assert actual_constrained_residual == pytest.approx( + expected_constrained_residual + ) + # C2: c is also restrained to zero with sig=1. + # Expected: As C1, followed by a penalty term of |c - 0| = 2. + restraint = recipe.add_soft_bounds(recipe.cont.c, 0, 0, 1) + actual_restrained_residual = recipe.residual() + expected_restrained_residual = np.append(sin(x + 2) - y, 2) + assert actual_restrained_residual == pytest.approx( + expected_restrained_residual + ) + # C3: The constraint on c is removed and c is reset to 0. + # Expected: The residual is zero, followed by a penalty term of 0 for + # the restraint, which is now met. + recipe.remove_constraint(recipe.cont.c) + recipe.cont.c.set_value(0) + actual_unconstrained_residual = recipe.residual() + expected_unconstrained_residual = np.append(sin(x) - y, 0) + assert actual_unconstrained_residual == pytest.approx( + expected_unconstrained_residual + ) + # C4: The restraint and the variable Avar are removed. + # Expected: The residual is zero, with no penalty term. + recipe.remove_soft_bounds(restraint) + recipe.delete_variable(recipe.Avar) + actual_final_residual = recipe.residual() + expected_final_residual = sin(x) - y + assert actual_final_residual == pytest.approx(expected_final_residual) + + +def test_residual_with_contribution_constraints_and_restraints( + build_sine_recipe, +): + # Constrain and restrain c through the contribution of an evaluated + # recipe, then undo each change. Each step changes the recipe left by + # the step before it, and the residual is evaluated after each step. + # A change made on a contribution does not tell the recipe that its + # configuration changed, so the recipe is reset by hand after adding or + # removing a restraint. + recipe = build_sine_recipe() + x, y = recipe.cont.profile.x, recipe.cont.profile.y + recipe.residual() + # C1: The contribution constrains c to 2*A. + # Expected: c is 2, so the residual is sin(x + 2) - y. + recipe.cont.add_constraint(recipe.cont.c, "2*A") + actual_constrained_residual = recipe.residual() + expected_constrained_residual = sin(x + 2) - y + assert actual_constrained_residual == pytest.approx( + expected_constrained_residual + ) + # C2: The contribution also restrains c to zero with sig=1. + # Expected: As C1, followed by a penalty term of |c - 0| = 2. + restraint = recipe.cont.add_soft_bounds(recipe.cont.c, 0, 0, 1) + recipe._ready = False + actual_restrained_residual = recipe.residual() + expected_restrained_residual = np.append(sin(x + 2) - y, 2) + assert actual_restrained_residual == pytest.approx( + expected_restrained_residual + ) + # C3: The restraint and the constraint on c are removed and c is reset + # to 0. + # Expected: The residual is zero, with no penalty term. + recipe.cont.remove_soft_bounds(restraint) + recipe._ready = False + recipe.cont.remove_constraint(recipe.cont.c) + recipe.cont.c.set_value(0) + actual_final_residual = recipe.residual() + expected_final_residual = sin(x) - y + assert actual_final_residual == pytest.approx(expected_final_residual) def test_convert_bounds_to_restraints(): + # C1: A variable bounded to [-1, 1] is converted with sig=2 and + # scaled=True. + # Expected: The recipe holds one restraint with those bounds, sig and + # scaling. recipe = FitRecipe("recipe") - # create a bounded variable recipe.create_new_variable("var1", 1) recipe.var1.bounds = (-1, 1) - # apply restraints from bounds recipe.convert_bounds_to_restraints(sig=2, scaled=True) - restraints = list(recipe._restraints) - assert len(restraints) == 1 - r = restraints[0] - assert r.lower_bound == -1 - assert r.upper_bound == 1 - assert r.sig == 2 - assert r.scaled is True + actual_restraints = [ + ( + restraint.lower_bound, + restraint.upper_bound, + restraint.sig, + restraint.scaled, + ) + for restraint in recipe._restraints + ] + expected_restraints = [(-1, 1, 2, True)] + assert actual_restraints == expected_restraints def test_add_and_remove_parameter_set(): + # C1: A parameter is added to the recipe and then removed. + # Expected: After adding, the recipe holds the parameter under its + # name. After removing, the name is gone. recipe = FitRecipe("recipe") parameter_to_add = Parameter("added_param", 1) - # add a parset recipe.add_parameter_set(parameter_to_add) - # check that the parameter is added - assert recipe.added_param == parameter_to_add - assert recipe.added_param.value == 1 - # remove the added parameter + actual_added_parameter = recipe.added_param + actual_added_value = recipe.added_param.value + expected_added_parameter = parameter_to_add + expected_added_value = 1 + assert actual_added_parameter == expected_added_parameter + assert actual_added_value == expected_added_value recipe.remove_parameter_set(parameter_to_add) - # check that the parameter is removed - assert not hasattr(recipe, "added_param") + actual_has_parameter = hasattr(recipe, "added_param") + expected_has_parameter = False + assert actual_has_parameter == expected_has_parameter -def test_add_contribution(capturestdout): - """Check output from default PrintFitHook using add_contribution. +def _get_printed_line_labels(output): + """Return the label that starts each printed line. - Covers the same behavior as test_print_fit_hook_deprecated (see the - deprecated-API section at the bottom of this file), which uses the - deprecated addContribution instead. Once addContribution is removed - in 4.0.0, delete that test and keep this one. + A line that holds only the iteration count is labelled "count". """ - recipe = FitRecipe("recipe") - recipe.fithooks[0].verbose = 0 - - # Set up the Profile - profile = Profile() - x = linspace(0, pi, 10) - y = sin(x) - profile.set_observed_profile(x, y) - - # Set up the FitContribution - fitcontribution = FitContribution("cont") - fitcontribution.set_profile(profile) - fitcontribution.set_equation("A*sin(k*x + c)") - fitcontribution.A.set_value(1) - fitcontribution.k.set_value(1) - fitcontribution.c.set_value(0) - - recipe.add_contribution(fitcontribution) - - recipe.add_variable(fitcontribution.c) + line_labels = [] + for line in output.splitlines(): + if line.strip().isdigit(): + line_labels.append("count") + else: + line_labels.append(line.split(":")[0].split("=")[0].strip()) + return line_labels + + +# Print the progress of a refinement with the default PrintFitHook. The +# sine recipe has the variable c and the restraint c >= 5, and each case +# evaluates the scalar residual once. +@pytest.mark.parametrize( + "input_verbose, expected_line_labels", + [ + # C1: The verbosity is 0. + # Expected: Nothing is printed. + (0, []), + # C2: The verbosity is 1. + # Expected: Only the iteration count is printed. + (1, ["count"]), + # C3: The verbosity is 2. + # Expected: The count is followed by the residual, the + # contributions and the restraints. + (2, ["count", "Residual", "FitContributions", "Restraints"]), + # C4: The verbosity is 3. + # Expected: As C3, followed by the variables and their values. + ( + 3, + [ + "count", + "Residual", + "FitContributions", + "Restraints", + "Variables", + "c", + ], + ), + ], +) +def test_print_fit_hook_output( + build_sine_recipe, capturestdout, input_verbose, expected_line_labels +): + recipe = build_sine_recipe() + recipe.add_variable(recipe.cont.c) recipe.add_soft_bounds("c", lower_bound=5) - (pfh,) = recipe.get_fit_hooks() - out = capturestdout(recipe.scalar_residual) - assert "" == out - pfh.verbose = 1 - out = capturestdout(recipe.scalar_residual) - assert out.strip().isdigit() - assert "\nRestraints:" not in out - pfh.verbose = 2 - out = capturestdout(recipe.scalar_residual) - assert "\nResidual:" in out - assert "\nRestraints:" in out - assert "\nVariables" not in out - pfh.verbose = 3 - out = capturestdout(recipe.scalar_residual) - assert "\nVariables" in out - assert "c = " in out - return + (print_fit_hook,) = recipe.get_fit_hooks() + print_fit_hook.verbose = input_verbose + actual_output = capturestdout(recipe.scalar_residual) + actual_line_labels = _get_printed_line_labels(actual_output) + assert actual_line_labels == expected_line_labels def optimize_recipe(recipe): @@ -413,10 +434,10 @@ def optimize_recipe(recipe): def test_initialize_recipe_from_recipe(build_recipes_one_contribution): - # Case: User initializes a FitRecipe from a previously optimized fit - # expected: recipe is initialized with everything: - # contributions, profiles (contained in contributions), - # variables, restraints, and constraints + # C1: A FitRecipe is initialized from a previously optimized recipe. + # Expected: The recipe gets everything: contributions, profiles + # (contained in contributions), variables, restraints and + # constraints, and refines to the same variable names and values. recipe1, _ = build_recipes_one_contribution optimize_recipe(recipe1) expected_parameters_dict = recipe1._parameters @@ -424,75 +445,77 @@ def test_initialize_recipe_from_recipe(build_recipes_one_contribution): expected_restraints_set = recipe1._restraints expected_contributions_dict = recipe1._contributions expected_profiles_list = [] - for con_name, contribution in expected_contributions_dict.items(): - expected_profile = contribution.profile - expected_profiles_list.append(expected_profile) + for contribution in expected_contributions_dict.values(): + expected_profiles_list.append(contribution.profile) recipe2 = FitRecipe() - assert recipe1 != recipe2 + actual_recipes_differ = recipe1 != recipe2 + expected_recipes_differ = True + assert actual_recipes_differ == expected_recipes_differ recipe2.initialize_recipe_with_recipe(recipe1) actual_parameters_dict = recipe2._parameters actual_constraints_dict = recipe2._constraints actual_restraints_set = recipe2._restraints actual_contributions_dict = recipe2._contributions actual_profiles_list = [] - for con_name, contribution in actual_contributions_dict.items(): - actual_profile = contribution.profile - actual_profiles_list.append(actual_profile) - - assert expected_parameters_dict == actual_parameters_dict - assert expected_constraints_dict == actual_constraints_dict - assert expected_restraints_set == actual_restraints_set - assert expected_contributions_dict == actual_contributions_dict - assert expected_profiles_list == actual_profiles_list - - # Check to see if the refined values and variable names are - # the same in the results objects for each recipe + for contribution in actual_contributions_dict.values(): + actual_profiles_list.append(contribution.profile) + + assert actual_parameters_dict == expected_parameters_dict + assert actual_constraints_dict == expected_constraints_dict + assert actual_restraints_set == expected_restraints_set + assert actual_contributions_dict == expected_contributions_dict + assert actual_profiles_list == expected_profiles_list + + # Check that the refined values and variable names are the same in + # the results objects for each recipe. results1 = FitResults(recipe1) - # round to account for small numerical differences - expected_values = np.round(results1.varvals, 7) - expected_names = results1.varnames + # Round to account for small numerical differences. + expected_values = sorted(np.round(results1.varvals, 7).tolist()) + expected_names = sorted(results1.varnames) optimize_recipe(recipe2) results2 = FitResults(recipe2) - # round to account for small numerical differences - actual_values = np.round(results2.varvals, 7) - actual_names = results2.varnames + # Round to account for small numerical differences. + actual_values = sorted(np.round(results2.varvals, 7).tolist()) + actual_names = sorted(results2.varnames) - assert sorted(expected_names) == sorted(actual_names) - assert sorted(list(expected_values)) == sorted(list(actual_values)) + assert actual_names == expected_names + assert actual_values == expected_values def test_initialize_recipe_from_recipe_bad(): - # Case: User tries to initialize a FitRecipe from a non recipe object - # expected: raised ValueError with message - recipe_bad = 12345 # not a FitRecipe object + # C1: A FitRecipe is initialized from an integer instead of a recipe. + # Expected: Raises a ValueError that names the type it got. + recipe_bad = 12345 recipe2 = FitRecipe() - msg = ( + expected_error_message = ( "The input recipe_object must be a FitRecipe, " "but got ." ) - with pytest.raises(ValueError, match=msg): + with pytest.raises(ValueError, match=re.escape(expected_error_message)): recipe2.initialize_recipe_with_recipe(recipe_bad) def test_initialize_recipe_from_results_object(build_recipes_one_contribution): - # Case: User initializes a FitRecipe from a FitResults object - # expected: recipe is initialized with variables from previous fit - - # create unique recipe1 + # C1: A FitRecipe with an extra variable is initialized from the + # FitResults of a previous fit. + # Expected: The recipe refines to the variables of the previous fit, + # and keeps its extra variable. recipe1, recipe2 = build_recipes_one_contribution optimize_recipe(recipe1) results1 = FitResults(recipe1) expected_values = np.round(results1.varvals, 5) expected_names = results1.varnames - assert recipe1 != recipe2 - # create a new var that should be include in the initialized recipe + actual_recipes_differ = recipe1 != recipe2 + expected_recipes_differ = True + assert actual_recipes_differ == expected_recipes_differ + # Create a new variable that should be kept in the initialized recipe. recipe2.create_new_variable("extra_var", 5) actual_values_before_init = [val for val in recipe2.get_values()] actual_names_before_init = sorted(recipe2.get_names()) - # the three variables + the extra_var + # The three variables plus extra_var. expected_names_before_init = sorted( [ "amplitude", @@ -507,22 +530,21 @@ def test_initialize_recipe_from_results_object(build_recipes_one_contribution): recipe2.initialize_recipe_with_results(results1) optimize_recipe(recipe2) results2 = FitResults(recipe2) - actual_values = np.round(results2.varvals, 5) - actual_names = results2.varnames + actual_values = sorted(np.round(results2.varvals, 5).tolist()) + actual_names = sorted(results2.varnames) - # add the new variable name to expected names - expected_names = expected_names + ["extra_var"] - # add the value of the new variable to expected values - expected_values = list(expected_values) + [5] - assert sorted(expected_names) == sorted(actual_names) - assert sorted(expected_values) == sorted(list(actual_values)) + # Add the name and value of the new variable to the expected ones. + expected_names = sorted(expected_names + ["extra_var"]) + expected_values = sorted(expected_values.tolist() + [5]) + assert actual_names == expected_names + assert actual_values == expected_values def test_initialize_recipe_from_results_file( build_recipes_one_contribution, temp_data_files ): - # Case: User initializes a FitRecipe from a FitResults file - # expected: recipe is initialized with variables from previous fit + # C1: A FitRecipe is initialized from a FitResults file. + # Expected: The recipe takes the variable values saved in the file. results_file = temp_data_files / "fit_results.res" expected_names = ["amplitude", "phase_shift", "wave_number"] expected_values = [1, 1, 0] @@ -530,42 +552,41 @@ def test_initialize_recipe_from_results_file( recipe, _ = build_recipes_one_contribution recipe.initialize_recipe_with_results(results_file) results = FitResults(recipe) - actual_values = np.round(results.varvals, 5) - actual_names = results.varnames + actual_values = np.round(results.varvals, 5).tolist() + actual_names = sorted(results.varnames) - assert sorted(expected_names) == sorted(actual_names) - assert list(expected_values) == list(actual_values) + assert actual_names == expected_names + assert actual_values == expected_values def test_initialize_recipe_from_results_file_bad( build_recipes_one_contribution, ): - # Case: User tries to initialize a recipe with something that - # isn't a path, str, or FitResults object - # Expected: raised ValueError with message + # C1: A recipe is initialized from an integer, which is not a path, + # str or FitResults object. + # Expected: Raises a ValueError that names the type it got. recipe, _ = build_recipes_one_contribution - bad_input = 12345 # not a valid input type - msg = ( + bad_input = 12345 + expected_error_message = ( "The input results must be a FitResults object or a path to a " "results file, but got ." ) - with pytest.raises(ValueError, match=msg): + with pytest.raises(ValueError, match=re.escape(expected_error_message)): recipe.initialize_recipe_with_results(bad_input) def test_initialize_recipe_from_results_file_wrong( build_recipe_two_contributions, temp_data_files, capsys ): - # Case: User tries to initialize a FitRecipe from a results file - # that does not match params in the recipe - # expected: Warning message is printed and things proceed as - # usual with the variables in the recipe - + # C1: A FitRecipe is initialized from a results file whose + # parameters are not in the recipe. + # Expected: A warning is printed for each unknown parameter and the + # recipe keeps its own variables. results_file_from_single_contrib = temp_data_files / "fit_results.res" recipe = build_recipe_two_contributions recipe.initialize_recipe_with_results(results_file_from_single_contrib) captured = capsys.readouterr() - actual_print_msg = captured.out # .strip() + actual_print_msg = captured.out results_file_param_names = ["amplitude", "phase_shift", "wave_number"] expected_print_messages = [] @@ -617,8 +638,8 @@ def build_recipe_from_datafile(datafile): def test_plot_recipe_bad_display(build_recipes_one_contribution): recipe, _ = build_recipes_one_contribution - # Case: All plots are disabled - # expected: raised ValueError with message + # C1: The observed, fit and difference curves are all disabled. + # Expected: Raises a ValueError with a message. plt.close("all") msg = "At least one of show_observed, show_fit, or show_diff must be True" with pytest.raises(ValueError, match=msg): @@ -631,8 +652,8 @@ def test_plot_recipe_bad_display(build_recipes_one_contribution): def test_plot_recipe_no_contribution(): recipe = FitRecipe() - # Case: No contributions in recipe - # expected: raised ValueError with message + # C1: The recipe has no contributions. + # Expected: Raises a ValueError with a message. plt.close("all") msg = ( "No contributions found in recipe. " @@ -643,37 +664,40 @@ def test_plot_recipe_no_contribution(): def test_plot_recipe_before_refinement(capsys, build_recipes_one_contribution): - # Case: User tries to plot recipe before refinement - # expected: Data plotted without fit line or difference curve - # and warning message printed + # C1: The recipe is plotted before refinement. + # Expected: The observed data is plotted without the fit or + # difference curves, and a warning is printed. recipe, _ = build_recipes_one_contribution plt.close("all") before = set(plt.get_fignums()) - # include fit_label="nothing" to make sure fit line is not plotted + # Include fit_label="nothing" to make sure the fit is not plotted. fig, ax = recipe.plot_recipe( show=False, data_label="my data", fit_label="nothing", return_fig=True ) after = set(plt.get_fignums()) new_figs = after - before captured = capsys.readouterr() - actual = captured.out.strip() - expected = ( + actual_message = captured.out.strip() + expected_message = ( "Contribution 'c1' has no calculated values (ycalc is None). " "Only observed data will be plotted." ) - # get labels from the plotted line + # Get the labels from the plotted line. actual_label, actual_line_count = get_labels_and_linecount(ax) expected_line_count = 1 expected_label = ["my data"] assert actual_line_count == expected_line_count assert actual_label == expected_label - assert len(new_figs) == 1 - assert actual == expected + actual_new_figure_count = len(new_figs) + expected_new_figure_count = 1 + assert actual_new_figure_count == expected_new_figure_count + assert actual_message == expected_message def test_plot_recipe_after_refinement(build_recipes_one_contribution): - # Case: User refines recipe and then plots - # expected: Plot generates with no problem + # C1: The recipe is refined and then plotted. + # Expected: One figure with the observed, calculated and difference + # curves is created. recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") @@ -686,7 +710,9 @@ def test_plot_recipe_after_refinement(build_recipes_one_contribution): expected_line_count = 3 assert actual_line_count == expected_line_count assert actual_label == expected_label - assert len(new_figs) == 1 + actual_new_figure_count = len(new_figs) + expected_new_figure_count = 1 + assert actual_new_figure_count == expected_new_figure_count def test_plot_recipe_styles_only_its_own_figure( @@ -708,8 +734,9 @@ def test_plot_recipe_styles_only_its_own_figure( def test_plot_recipe_two_contributions(build_recipe_two_contributions): - # Case: Two contributions in recipe - # expected: two figures created + # C1: The recipe has two contributions. + # Expected: Two figures are created, each with the observed, + # calculated and difference curves. recipe = build_recipe_two_contributions optimize_recipe(recipe) plt.close("all") @@ -723,12 +750,15 @@ def test_plot_recipe_two_contributions(build_recipe_two_contributions): assert actual_label == expected_label after = set(plt.get_fignums()) new_figs = after - before - assert len(new_figs) == 2 + actual_new_figure_count = len(new_figs) + expected_new_figure_count = 2 + assert actual_new_figure_count == expected_new_figure_count def test_plot_recipe_on_existing_plot(build_recipes_one_contribution): - # Case: User passes axes to plot_recipe to plot on existing figure - # expected: User modifications are present in the final figure + # C1: The user passes existing axes, with a title and a curve, to + # plot_recipe. + # Expected: The user's title and curve are kept in the final figure. recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") @@ -740,15 +770,15 @@ def test_plot_recipe_on_existing_plot(build_recipes_one_contribution): expected_title = "User Title" actual_labels, actual_line_count = get_labels_and_linecount(ax) expected_line_count = 4 - expected_labels = ["Calculated", "Difference", "New Data", "Observed"] + expected_labels = ["New Data", "Observed", "Calculated", "Difference"] assert actual_line_count == expected_line_count - assert sorted(actual_labels) == sorted(expected_labels) + assert actual_labels == expected_labels assert actual_title == expected_title def test_plot_recipe_add_new_data(build_recipes_one_contribution): - # Case: User wants to add data to figure generated by plot_recipe - # Expected: New data is added to existing figure (check with labels) + # C1: The user adds a curve to the figure returned by plot_recipe. + # Expected: The new curve is added to the existing figure. recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") @@ -756,21 +786,23 @@ def test_plot_recipe_add_new_data(build_recipes_one_contribution): fig, ax = recipe.plot_recipe(return_fig=True, show=False) after = set(plt.get_fignums()) new_figs = after - before - # add new data to existing plot + # Add new data to the existing plot. ax.plot([0, pi], [0, 0], label="New Data") ax.legend() actual_labels, actual_line_count = get_labels_and_linecount(ax) expected_labels = ["Observed", "Calculated", "Difference", "New Data"] expected_line_count = 4 - assert len(new_figs) == 1 + actual_new_figure_count = len(new_figs) + expected_new_figure_count = 1 + assert actual_new_figure_count == expected_new_figure_count assert actual_line_count == expected_line_count - assert sorted(actual_labels) == sorted(expected_labels) + assert actual_labels == expected_labels def test_plot_recipe_add_new_data_two_figs(build_recipe_two_contributions): - # Case: User wants to add data to figure generated by plot_recipe - # with two contributions - # Expected: New data is added to existing figure (check with labels) + # C1: The user adds a curve to each figure returned by plot_recipe + # for a recipe with two contributions. + # Expected: The new curve is added to each existing figure. recipe = build_recipe_two_contributions optimize_recipe(recipe) plt.close("all") @@ -778,7 +810,7 @@ def test_plot_recipe_add_new_data_two_figs(build_recipe_two_contributions): figure, axes = recipe.plot_recipe(return_fig=True, show=False) after = set(plt.get_fignums()) new_figs = after - before - # add new data to existing plots + # Add new data to the existing plots. for ax in axes: ax.plot([0, pi], [0, 0], label="New Data") ax.legend() @@ -786,13 +818,15 @@ def test_plot_recipe_add_new_data_two_figs(build_recipe_two_contributions): expected_labels = ["Observed", "Calculated", "Difference", "New Data"] expected_line_count = 4 assert actual_line_count == expected_line_count - assert sorted(actual_labels) == sorted(expected_labels) - assert len(new_figs) == 2 + assert actual_labels == expected_labels + actual_new_figure_count = len(new_figs) + expected_new_figure_count = 2 + assert actual_new_figure_count == expected_new_figure_count def test_plot_recipe_set_title(build_recipes_one_contribution): - # Case: User sets title via plot_recipe - # Expected: Title is set correctly + # C1: The user sets the title through plot_recipe. + # Expected: The figure has that title. recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") @@ -805,7 +839,7 @@ def test_plot_recipe_set_title(build_recipes_one_contribution): def test_plot_recipe_default_title(build_recipes_one_contribution): - # Case: A single contribution is plotted with no title given. + # C1: A single contribution is plotted with no title given. # Expected: The figure is titled with the name of the contribution. recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) @@ -817,7 +851,7 @@ def test_plot_recipe_default_title(build_recipes_one_contribution): def test_plot_recipe_titles_two_contributions(build_recipe_two_contributions): - # Case: Two contributions are plotted on separate figures with no + # C1: Two contributions are plotted on separate figures with no # title given. # Expected: Each figure is titled with the name of its contribution. recipe = build_recipe_two_contributions @@ -830,7 +864,7 @@ def test_plot_recipe_titles_two_contributions(build_recipe_two_contributions): def test_plot_recipe_labels_shared_axes(build_recipe_two_contributions): - # Case: Two contributions are plotted on a single user-supplied axes. + # C1: Two contributions are plotted on a single user-supplied axes. # Expected: Legend labels are prefixed with the contribution name so # that the curves of each contribution can be told apart. recipe = build_recipe_two_contributions @@ -853,7 +887,7 @@ def test_plot_recipe_labels_shared_axes(build_recipe_two_contributions): def test_plot_recipe_label_contribution_placeholder( build_recipe_two_contributions, ): - # Case: User passes a label containing the {contribution} placeholder. + # C1: User passes a label containing the {contribution} placeholder. # Expected: The placeholder is replaced by the contribution name and no # prefix is added. recipe = build_recipe_two_contributions @@ -874,14 +908,14 @@ def test_plot_recipe_label_contribution_placeholder( def test_plot_recipe_missing_ycalc_one_contribution( build_recipe_two_contributions, ): - # Case: Only the second contribution has been evaluated, so the ycalc of + # C1: Only the second contribution has been evaluated, so the ycalc of # the first one is None. # Expected: The unevaluated contribution shows observed data only while # the other one is still plotted in full. recipe = build_recipe_two_contributions optimize_recipe(recipe) plt.close("all") - # manually set ycalc to None + # Manually set ycalc to None. recipe._contributions["c1"].profile.ycalc = None _, axes = recipe.plot_recipe(show=False, return_fig=True) actual_labels = [get_labels_and_linecount(ax)[0] for ax in axes] @@ -893,12 +927,13 @@ def test_plot_recipe_missing_ycalc_one_contribution( def test_plot_recipe_set_defaults(build_recipes_one_contribution): - # Case: user sets default plot options with set_plot_defaults - # Expected: plot_recipe uses the default options for all calls + # C1: The user sets default plot options with set_plot_defaults. + # Expected: plot_recipe uses the default options when called without + # arguments. recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") - # set new defaults + # Set new defaults. recipe.set_plot_defaults( show_observed=False, show_fit=True, @@ -908,7 +943,7 @@ def test_plot_recipe_set_defaults(build_recipes_one_contribution): diff_label="Diff Default", title="Default Title", ) - # call plot_recipe without any arguments + # Call plot_recipe without any arguments. figure, ax = recipe.plot_recipe(return_fig=True, show=False) actual_title = ax.get_title() actual_labels, actual_line_count = get_labels_and_linecount(ax) @@ -921,8 +956,8 @@ def test_plot_recipe_set_defaults(build_recipes_one_contribution): def test_plot_recipe_set_defaults_bad(capsys, build_recipes_one_contribution): - # Case: user tries to set kwargs that are not valid plot_recipe options - # Expected: Plot is shown and warning is printed + # C1: The user sets a default that is not a plot_recipe option. + # Expected: A warning is printed and the recipe is still plotted. recipe, _ = build_recipes_one_contribution optimize_recipe(recipe) plt.close("all") @@ -940,38 +975,44 @@ def test_plot_recipe_set_defaults_bad(capsys, build_recipes_one_contribution): figure, ax = recipe.plot_recipe(return_fig=True, show=False) after = set(plt.get_fignums()) new_figs = after - before - assert len(new_figs) == 1 + actual_new_figure_count = len(new_figs) + expected_new_figure_count = 1 + assert actual_new_figure_count == expected_new_figure_count +# Infer the axis labels of the plot from the extension of the data file. @pytest.mark.parametrize( - "input,expected", + "input_filename, expected_xlabel, expected_ylabel", [ - # case1: .gr file - # expected: labels are inferred from file - ("gr_file.gr", [r"r ($\mathrm{\AA}$)", r"G ($\mathrm{\AA}^{-2}$)"]), - # case2: .dat file - # expected: default empty labels - ("dat_file.dat", ["", ""]), - # case3: .cgr file - # expected: labels are inferred from file - ("cgr_file.cgr", [r"r ($\mathrm{\AA}$)", r"G ($\mathrm{\AA}^{-2}$)"]), + # C1: A .gr file. + # Expected: The labels of a PDF are used. + ("gr_file.gr", r"r ($\mathrm{\AA}$)", r"G ($\mathrm{\AA}^{-2}$)"), + # C2: A .cgr file. + # Expected: The labels of a PDF are used. + ("cgr_file.cgr", r"r ($\mathrm{\AA}$)", r"G ($\mathrm{\AA}^{-2}$)"), + # C3: A .dat file, whose kind of data is unknown. + # Expected: The labels are empty. + ("dat_file.dat", "", ""), ], ) -def test_plot_recipe_labels_from_gr_file(temp_data_files, input, expected): - gr_file = temp_data_files / input - recipe = build_recipe_from_datafile(gr_file) +def test_plot_recipe_labels_from_gr_file( + temp_data_files, input_filename, expected_xlabel, expected_ylabel +): + data_file = temp_data_files / input_filename + recipe = build_recipe_from_datafile(data_file) optimize_recipe(recipe) plt.close("all") - fig, ax = recipe.plot_recipe(return_fig=True, show=False) + _, ax = recipe.plot_recipe(return_fig=True, show=False) actual_xlabel = ax.get_xlabel() actual_ylabel = ax.get_ylabel() - expected_xlabel = expected[0] - expected_ylabel = expected[1] assert actual_xlabel == expected_xlabel assert actual_ylabel == expected_ylabel def test_plot_recipe_labels_from_gr_file_overwrite(temp_data_files): + # C1: A recipe loaded from a .gr file is plotted with xlabel and + # ylabel given. + # Expected: The given labels replace the ones inferred from the file. gr_file = temp_data_files / "gr_file.gr" recipe = build_recipe_from_datafile(gr_file) optimize_recipe(recipe) @@ -988,6 +1029,9 @@ def test_plot_recipe_labels_from_gr_file_overwrite(temp_data_files): def test_plot_recipe_reset_all_defaults(build_recipes_one_contribution): + # C1: Every plot option is given a new default with set_plot_defaults. + # Expected: plot_recipe called without arguments uses every one of the + # new defaults. expected_defaults = { "show_observed": True, "show_fit": True, @@ -1053,22 +1097,26 @@ def test_plot_recipe_reset_all_defaults(build_recipes_one_contribution): fit_line = lines_by_label[expected_defaults["fit_label"]] diff_line = lines_by_label[expected_defaults["diff_label"]] - assert data_line.get_color() == expected_defaults["data_color"] - assert fit_line.get_color() == expected_defaults["fit_color"] - assert diff_line.get_color() == expected_defaults["diff_color"] - - assert data_line.get_linestyle() == expected_defaults["data_style"] - assert fit_line.get_marker() == expected_defaults["fit_style"] - assert diff_line.get_marker() == expected_defaults["diff_style"] - - assert data_line.get_markersize() == expected_defaults["markersize"] - assert data_line.get_alpha() == expected_defaults["alpha"] + actual_line_styles = { + "data_color": data_line.get_color(), + "fit_color": fit_line.get_color(), + "diff_color": diff_line.get_color(), + "data_style": data_line.get_linestyle(), + "fit_style": fit_line.get_marker(), + "diff_style": diff_line.get_marker(), + "markersize": data_line.get_markersize(), + "alpha": data_line.get_alpha(), + } + expected_line_styles = { + key: expected_defaults[key] for key in actual_line_styles + } + assert actual_line_styles == expected_line_styles actual_xlim = ax.get_xlim() expected_xlim = (expected_defaults["xmin"], expected_defaults["xmax"]) assert actual_xlim == expected_xlim - # no legend + # No legend. actual_legend = ax.get_legend() is not None expected_legend = expected_defaults["legend"] @@ -1082,29 +1130,32 @@ def test_plot_recipe_reset_all_defaults(build_recipes_one_contribution): # (A=1, k=1, c=0) is recovered no matter how the fit is weighted. -# make_input_dyobs returns a function that evaluates dyobs on the array -# of xobs for insertion into the build_recipe_with_uncertainty fixture. +# The profile of build_recipe_with_uncertainty has 21 points. +UNIFORM_DYOBS = ones(21) +VARYING_DYOBS = linspace(0.1, 1.0, 21) + + @pytest.mark.parametrize( - "make_input_dyobs, expected_dyobs_is_set", + "input_dyobs, expected_dyobs_is_set", [ # C1: No uncertainties are observed, as for a file with no # uncertainty column. # Expected: The refinement converges and dyobs stays None. - (lambda xobs: None, False), + (None, False), # C2: Uniform uncertainties of one are observed explicitly. # Expected: The refinement converges to the same values as C1, # since an unweighted fit is weighted by one everywhere. - (lambda xobs: ones_like(xobs), True), + (UNIFORM_DYOBS, True), # C3: Uncertainties vary across the profile, so the refinement # is weighted point by point. # Expected: The refinement still converges to the known values. - (lambda xobs: linspace(0.1, 1.0, len(xobs)), True), + (VARYING_DYOBS, True), ], ) def test_refine_with_and_without_uncertainty( - build_recipe_with_uncertainty, make_input_dyobs, expected_dyobs_is_set + build_recipe_with_uncertainty, input_dyobs, expected_dyobs_is_set ): - recipe, profile = build_recipe_with_uncertainty(make_input_dyobs) + recipe, profile = build_recipe_with_uncertainty(input_dyobs) optimize_recipe(recipe) actual_dyobs_is_set = profile.dyobs is not None actual_names = recipe.get_names() @@ -1123,147 +1174,28 @@ def test_refine_with_and_without_uncertainty( # through to the residual unchanged. -# make_input_dyobs returns a function that evaluates dyobs on the array -# of xobs for insertion into the build_recipe_with_uncertainty fixture. @pytest.mark.parametrize( - "make_input_dyobs, make_expected_dy", + "input_dyobs, expected_dy", [ # C1: No uncertainties are observed. # Expected: dy falls back to one everywhere, so the residual is # unweighted. - (lambda xobs: None, lambda xobs: ones_like(xobs)), + (None, UNIFORM_DYOBS), # C2: Uniform uncertainties of one are observed explicitly. # Expected: dy is one everywhere, matching C1. - (lambda xobs: ones_like(xobs), lambda xobs: ones_like(xobs)), + (UNIFORM_DYOBS, UNIFORM_DYOBS), # C3: Uncertainties vary across the profile. # Expected: dy keeps the observed values, so the residual is # weighted point by point. - ( - lambda xobs: linspace(0.1, 1.0, len(xobs)), - lambda xobs: linspace(0.1, 1.0, len(xobs)), - ), + (VARYING_DYOBS, VARYING_DYOBS), ], ) def test_residual_is_weighted_by_uncertainty( - build_recipe_with_uncertainty, make_input_dyobs, make_expected_dy + build_recipe_with_uncertainty, input_dyobs, expected_dy ): - recipe, profile = build_recipe_with_uncertainty(make_input_dyobs) + recipe, profile = build_recipe_with_uncertainty(input_dyobs) actual_residual = recipe.residual(recipe.values) actual_dy = profile.dy - expected_dy = make_expected_dy(profile.xobs) expected_residual = (profile.ycalc - profile.y) / expected_dy assert actual_dy == pytest.approx(expected_dy) assert actual_residual == pytest.approx(expected_residual) - - -# ---------------------------------------------------------------------------- -# addContribution, addParameterSet/removeParameterSet, and -# ProfileParser.parseFile/Profile.loadParsedData are deprecated in favor of -# add_contribution, add_parameter_set/remove_parameter_set, and -# ProfileParser.parse_file/Profile.load_parsed_data. The old names must still -# work and forward to the new implementation. - - -def test_print_fit_hook_deprecated(capturestdout): - """Check output from default PrintFitHook using the deprecated - addContribution, getFitHooks, and scalarResidual methods. - - Remove this test after addContribution is removed in 4.0.0; see - test_add_contribution for the permanent replacement. - """ - recipe = FitRecipe("recipe") - recipe.fithooks[0].verbose = 0 - - # Set up the Profile - profile = Profile() - x = linspace(0, pi, 10) - y = sin(x) - profile.set_observed_profile(x, y) - - # Set up the FitContribution - fitcontribution = FitContribution("cont") - fitcontribution.set_profile(profile) - fitcontribution.set_equation("A*sin(k*x + c)") - fitcontribution.A.set_value(1) - fitcontribution.k.set_value(1) - fitcontribution.c.set_value(0) - - recipe.addContribution(fitcontribution) - - recipe.add_variable(fitcontribution.c) - recipe.add_soft_bounds("c", lower_bound=5) - (pfh,) = recipe.getFitHooks() - out = capturestdout(recipe.scalar_residual) - assert "" == out - pfh.verbose = 1 - out = capturestdout(recipe.scalar_residual) - assert out.strip().isdigit() - assert "\nRestraints:" not in out - pfh.verbose = 2 - out = capturestdout(recipe.scalar_residual) - assert "\nResidual:" in out - assert "\nRestraints:" in out - assert "\nVariables" not in out - pfh.verbose = 3 - out = capturestdout(recipe.scalarResidual) - assert "\nVariables" in out - assert "c = " in out - return - - -def test_add_and_remove_parameter_set_deprecated(): - """Test the deprecated addParameterSet and removeParameterSet - methods.""" - recipe = FitRecipe("recipe") - parameter_to_add = Parameter("added_param", 1) - # add a parset - recipe.addParameterSet(parameter_to_add) - # check that the parameter is added - assert recipe.added_param == parameter_to_add - assert recipe.added_param.value == 1 - # remove the added parameter - recipe.removeParameterSet(parameter_to_add) - # check that the parameter is removed - assert not hasattr(recipe, "added_param") - - -def build_recipe_from_datafile_deprecated(datafile): - """Duplicate of build_recipe_from_datafile to use the deprecated - ProfileParser.parseFile and Profile.loadParsedData methods. - - Remove in version 4.0.0. - """ - profile = Profile() - parser = ProfileParser() - parser.parseFile(str(datafile)) - profile.loadParsedData(parser) - - contribution = FitContribution("c") - contribution.set_profile(profile) - contribution.set_equation("m*x + b") - recipe = FitRecipe() - recipe.add_contribution(contribution) - recipe.add_variable(contribution.m, 1) - recipe.add_variable(contribution.b, 0) - return recipe - - -def test_plot_recipe_labels_from_gr_file_overwrite_deprecated(temp_data_files): - "Remove this test with version 4.0.0." - gr_file = temp_data_files / "gr_file.gr" - recipe = build_recipe_from_datafile_deprecated(gr_file) - optimize_recipe(recipe) - plt.close("all") - fig, ax = recipe.plot_recipe( - return_fig=True, show=False, xlabel="My X", ylabel="My Y" - ) - actual_xlabel = ax.get_xlabel() - actual_ylabel = ax.get_ylabel() - expected_xlabel = "My X" - expected_ylabel = "My Y" - assert actual_xlabel == expected_xlabel - assert actual_ylabel == expected_ylabel - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_fitresults.py b/tests/test_fitresults.py index ca52192d..fa00b464 100644 --- a/tests/test_fitresults.py +++ b/tests/test_fitresults.py @@ -12,19 +12,15 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Tests for fitresults module.""" +"""Tests for the fitresults module.""" -import unittest - -import numpy as np import pytest from scipy.optimize import leastsq -from diffpy.srfit import FitRecipe, FitResults -from diffpy.srfit.fitbase.fitresults import initializeRecipe, resultsDictionary +from diffpy.srfit import FitResults -# The fit results from the recipe fixture in conftest.py -expected_fitresults = """\ +# The start of the fit results of the recipe fixture in conftest.py +EXPECTED_RESULTS_HEADER = """\ My Custom header Some quantities invalid due to missing profile uncertainty Overall (Chi2 and Reduced Chi2 invalid) @@ -39,235 +35,89 @@ Variables (Uncertainties invalid) ------------------------------------------------------------------------------ """ -expected_refined_variables = ["amplitude", "wave_number", "phase_shift"] +EXPECTED_REFINED_VARIABLES = ["amplitude", "wave_number", "phase_shift"] -def optimize_recipe(recipe): +def _optimize_recipe(recipe): recipe.fithooks[0].verbose = 0 - residuals = recipe.residual - values = recipe.values - leastsq(residuals, values) - - -def test_formatResults(build_recipes_one_contribution): - recipe, _ = build_recipes_one_contribution - optimize_recipe(recipe) - results = FitResults(recipe) - actual_results_string = results.formatResults(header="My Custom header") - # Because slight variations in refinement, just check - # that the header of the results are the same. - assert expected_fitresults.strip() in actual_results_string.strip() - # check if the refined variables are in the results - for expected_var in expected_refined_variables: - assert expected_var in actual_results_string.strip() - - -def test_get_results_string(build_recipes_one_contribution): - recipe, _ = build_recipes_one_contribution - optimize_recipe(recipe) - results = FitResults(recipe) - actual_results_string = results.get_results_string( - header="My Custom header" - ) - # Because slight variations in refinement, just check - # that the header of the results are the same. - assert expected_fitresults.strip() in actual_results_string.strip() - # check if the refined variables are in the results - for expected_var in expected_refined_variables: - assert expected_var in actual_results_string.strip() - - -def test_printResults(build_recipes_one_contribution, capsys): - recipe, _ = build_recipes_one_contribution - optimize_recipe(recipe) - results = FitResults(recipe) - results.printResults(header="My Custom header") - actual_results = capsys.readouterr().out - # Because slight variations in refinement, just check - # that the header of the results are the same. - assert expected_fitresults.strip() in actual_results.strip() - # check if the refined variables are in the results - for expected_var in expected_refined_variables: - assert expected_var in actual_results.strip() - - -def test_print_results(build_recipes_one_contribution, capsys): + leastsq(recipe.residual, recipe.values) + + +def _get_results_output(results, method_name, capsys, tmp_path): + """Return the results text given by get_results_string, printed by + print_results or saved by save_results.""" + if method_name == "get_results_string": + return results.get_results_string(header="My Custom header") + if method_name == "print_results": + results.print_results(header="My Custom header") + return capsys.readouterr().out + results_file = tmp_path / "fit_results.txt" + results.save_results(results_file, header="My Custom header") + return results_file.read_text() + + +# Test the results text of a refined recipe, whether returned, printed or +# saved. +@pytest.mark.parametrize( + "input_method_name", + [ + # C1: The results text is returned by get_results_string. + # Expected: It contains the header, the overall metrics and the + # refined variables. + "get_results_string", + # C2: The results text is printed by print_results. + # Expected: It contains the header, the overall metrics and the + # refined variables. + "print_results", + # C3: The results text is saved to a file by save_results. + # Expected: It contains the header, the overall metrics and the + # refined variables. + "save_results", + ], +) +def test_results_output( + build_recipes_one_contribution, capsys, tmp_path, input_method_name +): recipe, _ = build_recipes_one_contribution - optimize_recipe(recipe) + _optimize_recipe(recipe) results = FitResults(recipe) - results.print_results(header="My Custom header") - actual_results = capsys.readouterr().out - # Because slight variations in refinement, just check - # that the header of the results are the same. - assert expected_fitresults.strip() in actual_results.strip() - # check if the refined variables are in the results - for expected_var in expected_refined_variables: - assert expected_var in actual_results.strip() - - -def test_saveResults(build_recipes_one_contribution, tmp_path): - recipe, _ = build_recipes_one_contribution - optimize_recipe(recipe) - results = FitResults(recipe) - actual_results_file = tmp_path / "fit_results.txt" - results.saveResults(actual_results_file, header="My Custom header") - assert actual_results_file.exists() - with open(actual_results_file, "r") as res_file: - actual_results = res_file.read() - # Because slight variations in refinement, just check - # that the header of the results are the same. - assert expected_fitresults.strip() in actual_results.strip() - # check if the refined variables are in the results - for expected_var in expected_refined_variables: - assert expected_var in actual_results.strip() - - -def test_save_results(build_recipes_one_contribution, tmp_path): - recipe, _ = build_recipes_one_contribution - optimize_recipe(recipe) - results = FitResults(recipe) - actual_results_file = tmp_path / "fit_results.txt" - results.save_results(actual_results_file, header="My Custom header") - assert actual_results_file.exists() - with open(actual_results_file, "r") as res_file: - actual_results = res_file.read() - # Because slight variations in refinement, just check - # that the header of the results are the same. - assert expected_fitresults.strip() in actual_results.strip() - # check if the refined variables are in the results - for expected_var in expected_refined_variables: - assert expected_var in actual_results.strip() + capsys.readouterr() + actual_results_output = _get_results_output( + results, input_method_name, capsys, tmp_path + ).strip() + # Refinements vary slightly, so only the header and the variable names + # are checked. + assert EXPECTED_RESULTS_HEADER.strip() in actual_results_output + actual_variables_present = [ + name in actual_results_output for name in EXPECTED_REFINED_VARIABLES + ] + expected_variables_present = [True] * len(EXPECTED_REFINED_VARIABLES) + assert actual_variables_present == expected_variables_present def test_get_results_dictionary(build_recipes_one_contribution): - # Case: user gets results dictionary after optimization - # expected: results dictionary contains expected keys and values + # C1: The results dictionary is taken after the recipe is refined to + # the noiseless sine it is fit to. + # Expected: It holds the refined value of each variable and overall + # metrics that are all zero within 1e-5. recipe, _ = build_recipes_one_contribution - optimize_recipe(recipe) + _optimize_recipe(recipe) results = FitResults(recipe) actual_results_dict = results.get_results_dictionary() expected_results_dict = { - "amplitude": 1.000000000060171, - "wave_number": 1.00000000012548, - "phase_shift": -1.6129114631049646e-18, - "Residual": 3.3284672708760557e-19, - "Contributions": 3.3284672708760557e-19, + "amplitude": recipe.amplitude.value, + "wave_number": recipe.wave_number.value, + "phase_shift": recipe.phase_shift.value, + "Residual": 0, + "Contributions": 0, "Restraints": 0, - "Chi2": 3.3284672708760557e-19, - "Reduced Chi2": 4.7549532441086507e-20, - "Rw": 2.7196679825449506e-10, + "Chi2": 0, + "Reduced Chi2": 0, + "Rw": 0, } - actual_values = np.round(np.array(list(actual_results_dict.values())), 5) - actual_keys = set(actual_results_dict.keys()) - expected_values = np.round( - np.array(list(expected_results_dict.values())), 5 - ) - expected_keys = set(expected_results_dict.keys()) - assert expected_keys == actual_keys - assert list(expected_values == list(actual_values)) - - -def test_resultsDictionary(temp_data_files): - # Case: user gets results dictionary from a results file - # expected: results dictionary contains expected keys and values - actual_results_dict = resultsDictionary( - temp_data_files / "fit_results.res" + assert actual_results_dict == pytest.approx( + expected_results_dict, abs=1e-5 ) - # bad behavior: values are stored as strings - expected_results_dict = { - "than": "25", # bad behavior: shouldn't be here - "wave_number": "1.00000000e+00", - "phase_shift": "-1.61291146e-18", - "amplitude": "1.00000000e+00", - "Rw": "0.00000000", - "Chi2": "0.00000000", - "Restraints": "0.00000000", - "Contributions": "0.00000000", - "Residual": "0.00000000", - "Feb": "25", # bad behavior: shouldn't be here - } - # convert values to float for comparison (with rounding) - for key in expected_results_dict: - expected_results_dict[key] = float(expected_results_dict[key]) - for key in actual_results_dict: - actual_results_dict[key] = float(actual_results_dict[key]) - - actual_keys = set(actual_results_dict.keys()) - actual_values = np.round(np.array(list(actual_results_dict.values())), 5) - expected_keys = set(expected_results_dict.keys()) - expected_values = np.round( - np.array(list(expected_results_dict.values())), 5 - ) - assert expected_keys == actual_keys - assert list(expected_values == list(actual_values)) - - -def testInitializeFromFileName(datafile): - recipe = FitRecipe("recipe") - recipe.create_new_variable("A", 0) - recipe.create_new_variable("sig", 0) - recipe.create_new_variable("x0", 0) - filename = datafile("results.res") - Aval = 5.77619823e-01 - sigval = -9.22758690e-01 - x0val = 6.12422115e00 - - assert 0 == recipe.A.value - assert 0 == recipe.sig.value - assert 0 == recipe.x0.value - initializeRecipe(recipe, filename) - assert Aval == pytest.approx(recipe.A.value) - assert sigval == pytest.approx(recipe.sig.value) - assert x0val == pytest.approx(recipe.x0.value) - return - - -def testInitializeFromFileObj(datafile): - recipe = FitRecipe("recipe") - recipe.create_new_variable("A", 0) - recipe.create_new_variable("sig", 0) - recipe.create_new_variable("x0", 0) - filename = datafile("results.res") - Aval = 5.77619823e-01 - sigval = -9.22758690e-01 - x0val = 6.12422115e00 - - assert 0 == recipe.A.value - assert 0 == recipe.sig.value - assert 0 == recipe.x0.value - infile = open(filename, "r") - initializeRecipe(recipe, infile) - assert not infile.closed - infile.close() - assert Aval == pytest.approx(recipe.A.value) - assert sigval == pytest.approx(recipe.sig.value) - assert x0val == pytest.approx(recipe.x0.value) - return - - -def testInitializeFromString(datafile): - recipe = FitRecipe("recipe") - recipe.create_new_variable("A", 0) - recipe.create_new_variable("sig", 0) - recipe.create_new_variable("x0", 0) - filename = datafile("results.res") - Aval = 5.77619823e-01 - sigval = -9.22758690e-01 - x0val = 6.12422115e00 - - assert 0 == recipe.A.value - assert 0 == recipe.sig.value - assert 0 == recipe.x0.value - infile = open(filename, "r") - resstr = infile.read() - infile.close() - initializeRecipe(recipe, resstr) - assert Aval == pytest.approx(recipe.A.value) - assert sigval == pytest.approx(recipe.sig.value) - assert x0val == pytest.approx(recipe.x0.value) - return - - -if __name__ == "__main__": - - unittest.main() + actual_amplitude = actual_results_dict["amplitude"] + expected_amplitude = 1 + assert actual_amplitude == pytest.approx(expected_amplitude, abs=1e-5) diff --git a/tests/test_parameter.py b/tests/test_parameter.py index 732a085e..52b74daa 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -12,10 +12,7 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Tests for refinableobj module.""" - -import re -import unittest +"""Tests for the parameter module.""" import numpy as np import pytest @@ -26,249 +23,241 @@ ParameterProxy, ) - -class TestParameter(unittest.TestCase): - - def testSetValue(self): - """Test initialization.""" - par_l = Parameter("l") - - par_l.set_value(3.14) - self.assertAlmostEqual(3.14, par_l.get_value()) - - # Try array - import numpy - - x = numpy.arange(0, 10, 0.1) - par_l.setValue(x) - self.assertTrue(par_l.get_value() is x) - self.assertTrue(par_l.value is x) - - # Change the array - y = numpy.arange(0, 10, 0.5) - par_l.value = y - self.assertTrue(par_l.get_value() is y) - self.assertTrue(par_l.value is y) - - # Back to scalar - par_l.set_value(1.01) - self.assertAlmostEqual(1.01, par_l.get_value()) - self.assertAlmostEqual(1.01, par_l.value) - return - - -class TestParameterProxy(unittest.TestCase): - - def testProxy(self): - """Test the ParameterProxy class.""" - par_l = Parameter("l", 3.14) - - # Try Accessor adaptation - la = ParameterProxy("l2", par_l) - - self.assertEqual("l2", la.name) - self.assertEqual(par_l.get_value(), la.get_value()) - - # Change the parameter - par_l.value = 2.3 - self.assertEqual(par_l.get_value(), la.get_value()) - self.assertEqual(par_l.value, la.value) - - # Change the proxy - la.value = 3.2 - self.assertEqual(par_l.get_value(), la.get_value()) - self.assertEqual(par_l.value, la.value) - - return - - -class TestParameterAdapter(unittest.TestCase): - - def testWrapper(self): - """Test the adapter. - - This adapts a Parameter to the Parameter interface. :) - """ - par_l = Parameter("l", 3.14) - - # Try Accessor adaptation - la = ParameterAdapter( - "l", par_l, getter=Parameter.get_value, setter=Parameter.set_value - ) - - self.assertEqual(par_l.name, la.name) - self.assertEqual(par_l.get_value(), la.get_value()) - - # Change the parameter - par_l.set_value(2.3) - self.assertEqual(par_l.get_value(), la.get_value()) - - # Change the adapter - la.set_value(3.2) - self.assertEqual(par_l.get_value(), la.get_value()) - - # Try Attribute adaptation - la = ParameterAdapter("l", par_l, attr="value") - - self.assertEqual(par_l.name, la.name) - self.assertEqual("value", la.attr) - self.assertEqual(par_l.get_value(), la.get_value()) - - # Change the parameter - par_l.set_value(2.3) - self.assertEqual(par_l.get_value(), la.get_value()) - - # Change the adapter - la.set_value(3.2) - self.assertEqual(par_l.get_value(), la.get_value()) - - return +FINE_ARRAY = np.arange(0, 10, 0.1) +COARSE_ARRAY = np.arange(0, 10, 0.5) +# Test that a value set on a Parameter, with set_value or through the value +# property, is the very object get_value and value return afterward. @pytest.mark.parametrize( - "lower, upper, expected", + "input_initial_value, input_setter, input_value", [ - # User sets both lower and upper bounds explicitly. - (1, 10, [1, 10]), - # User sets only a lower bound. - (2, None, [2, np.inf]), - # User sets only an upper bound. - (None, 8, [-np.inf, 8]), - # User overwrites existing bounds. - (2, 6, [2, 6]), + # C1: A scalar is set with set_value on a Parameter with no value. + # Expected: get_value and value return that scalar. + (None, "set_value", 3.14), + # C2: An array is set with set_value on a Parameter holding a + # scalar. + # Expected: get_value and value return that same array object. + (3.14, "set_value", FINE_ARRAY), + # C3: An array is assigned to the value property of a Parameter + # holding another array. + # Expected: get_value and value return the new array object. + (FINE_ARRAY, "value", COARSE_ARRAY), + # C4: A scalar is set with set_value on a Parameter holding an + # array. + # Expected: get_value and value return that scalar. + (FINE_ARRAY, "set_value", 1.01), ], ) -def test_bound_range(lower, upper, expected): - p = Parameter("a", value=5) - # If testing overwrite, pre-set bounds to see overwrite effect - if expected == [2, 6]: - p.bound_range(0, 10) - p.bound_range(lower_bound=lower, upper_bound=upper) - actual = p.bounds - assert actual == expected - - +def test_set_value(input_initial_value, input_setter, input_value): + parameter = Parameter("l", input_initial_value) + if input_setter == "set_value": + parameter.set_value(input_value) + else: + parameter.value = input_value + # The value is stored as given, without being copied. + actual_is_input_object = [ + parameter.get_value() is input_value, + parameter.value is input_value, + ] + expected_is_input_object = [True, True] + assert actual_is_input_object == expected_is_input_object + + +# Test that set_constant sets the constant flag of a Parameter, and sets its +# value only when a value is given. @pytest.mark.parametrize( - "lower, upper, expected", + "input_initial_const, input_args, expected_const, expected_value", [ - # User sets both lower and upper bounds explicitly. - (1, 10, [1, 10]), - # User sets only a lower bound. - (2, None, [2, np.inf]), - # User sets only an upper bound. - (None, 8, [-np.inf, 8]), - # User overwrites existing bounds. - (2, 6, [2, 6]), + # C1: A variable Parameter with value 3.14 is set constant with no + # arguments. + # Expected: The Parameter is constant and keeps the value 3.14. + (False, (), True, 3.14), + # C2: A variable Parameter with value 3.14 is set constant with the + # value 2.5. + # Expected: The Parameter is constant and has the value 2.5. + (False, (True, 2.5), True, 2.5), + # C3: A constant Parameter with value 3.14 is set not constant. + # Expected: The Parameter is variable and keeps the value 3.14. + (True, (False,), False, 3.14), ], ) -def test_boundRange(lower, upper, expected): - p = Parameter("a", value=5) - # If testing overwrite, pre-set bounds to see overwrite effect - if expected == [2, 6]: - p.boundRange(0, 10) - p.boundRange(lb=lower, ub=upper) - actual = p.bounds - assert actual == expected +def test_set_constant( + input_initial_const, input_args, expected_const, expected_value +): + parameter = Parameter("l", 3.14, const=input_initial_const) + parameter.set_constant(*input_args) + actual_const = parameter.const + assert actual_const == expected_const + actual_value = parameter.get_value() + assert actual_value == expected_value +# Test that a ParameterProxy has its own name but shares its value with the +# Parameter it proxies, whichever of the two is changed. @pytest.mark.parametrize( - "value, lower_radius, upper_radius, expected", + "input_changed_object, input_new_value, expected_value", [ - # Symmetric radius (upper_radius None, uses lower_radius) - (10, 2, None, [8, 12]), - # Asymmetric radius - (10, 3, 5, [7, 15]), - # Zero radius - (4, 0, None, [4, 4]), - # Current value updated before bounding - (20, 2, None, [18, 22]), + # C1: Neither the Parameter nor the proxy is changed. + # Expected: Both have the initial value 3.14. + (None, None, 3.14), + # C2: The value of the Parameter is changed to 2.3. + # Expected: Both have the value 2.3. + ("parameter", 2.3, 2.3), + # C3: The value of the proxy is changed to 3.2. + # Expected: Both have the value 3.2. + ("proxy", 3.2, 3.2), ], ) -def test_bound_window(value, lower_radius, upper_radius, expected): - p = Parameter("a", value=5) - if value != 5: - p.set_value(value) - p.bound_window(lower_radius=lower_radius, upper_radius=upper_radius) - actual = p.bounds - assert actual == expected - - +def test_parameter_proxy( + input_changed_object, input_new_value, expected_value +): + parameter = Parameter("l", 3.14) + proxy = ParameterProxy("l2", parameter) + changeable_objects = {"parameter": parameter, "proxy": proxy} + if input_changed_object is not None: + changeable_objects[input_changed_object].value = input_new_value + actual_name = proxy.name + expected_name = "l2" + assert actual_name == expected_name + actual_values = [ + parameter.get_value(), + parameter.value, + proxy.get_value(), + proxy.value, + ] + expected_values = [expected_value] * 4 + assert actual_values == expected_values + + +ACCESSOR_KWARGS = { + "getter": Parameter.get_value, + "setter": Parameter.set_value, +} +ATTRIBUTE_KWARGS = {"attr": "value"} + + +# Test that a ParameterAdapter wrapping a Parameter, through accessor +# functions or through an attribute name, reads and writes the value of the +# wrapped Parameter. @pytest.mark.parametrize( - "value, lower_radius, upper_radius, expected", + "input_adapter_kwargs, input_changed_object, input_new_value, " + "expected_attr, expected_value", [ - # Symmetric radius (upper_radius None, uses lower_radius) - (10, 2, None, [8, 12]), - # Asymmetric radius - (10, 3, 5, [7, 15]), - # Zero radius - (4, 0, None, [4, 4]), - # Current value updated before bounding - (20, 2, None, [18, 22]), + # C1: The adapter uses get_value and set_value, and nothing is + # changed. + # Expected: The adapter has no attr and the initial value 3.14. + (ACCESSOR_KWARGS, None, None, None, 3.14), + # C2: The adapter uses get_value and set_value, and the Parameter is + # set to 2.3. + # Expected: The adapter reads the new value 2.3. + (ACCESSOR_KWARGS, "parameter", 2.3, None, 2.3), + # C3: The adapter uses get_value and set_value, and the adapter is + # set to 3.2. + # Expected: The Parameter is set to 3.2. + (ACCESSOR_KWARGS, "adapter", 3.2, None, 3.2), + # C4: The adapter uses the attribute "value", and nothing is + # changed. + # Expected: The adapter has attr "value" and the initial value 3.14. + (ATTRIBUTE_KWARGS, None, None, "value", 3.14), + # C5: The adapter uses the attribute "value", and the Parameter is + # set to 2.3. + # Expected: The adapter reads the new value 2.3. + (ATTRIBUTE_KWARGS, "parameter", 2.3, "value", 2.3), + # C6: The adapter uses the attribute "value", and the adapter is set + # to 3.2. + # Expected: The Parameter is set to 3.2. + (ATTRIBUTE_KWARGS, "adapter", 3.2, "value", 3.2), ], ) -def test_boundWindow(value, lower_radius, upper_radius, expected): - p = Parameter("a", value=5) - if value != 5: - p.set_value(value) - p.boundWindow(lr=lower_radius, ur=upper_radius) - actual = p.bounds - assert actual == expected - - -# ---------------------------------------------------------------------------- -# getValue is deprecated in favor of get_value. The old name must still work, -# emit a DeprecationWarning naming its replacement, and return exactly what -# get_value returns, for Parameter and for both of its wrapping subclasses. - - -class _ValueHolder: - """A plain object for ParameterAdapter to wrap.""" - - def __init__(self, value): - self.value = value - - -def _make_parameter(value): - return Parameter("l", value) - - -def _make_parameter_proxy(value): - return ParameterProxy("l_proxy", Parameter("l", value)) - - -def _make_parameter_adapter(value): - return ParameterAdapter("l_adapted", _ValueHolder(value), attr="value") - - +def test_parameter_adapter( + input_adapter_kwargs, + input_changed_object, + input_new_value, + expected_attr, + expected_value, +): + parameter = Parameter("l", 3.14) + adapter = ParameterAdapter("l", parameter, **input_adapter_kwargs) + changeable_objects = {"parameter": parameter, "adapter": adapter} + if input_changed_object is not None: + changeable_objects[input_changed_object].set_value(input_new_value) + actual_name = adapter.name + expected_name = parameter.name + assert actual_name == expected_name + actual_attr = adapter.attr + assert actual_attr == expected_attr + actual_values = [parameter.get_value(), adapter.get_value()] + expected_values = [expected_value, expected_value] + assert actual_values == expected_values + + +# Test that bound_range sets the bounds it is given and leaves a bound +# unchanged when it is given None for it. @pytest.mark.parametrize( - "make_parameter, input_value", + "input_initial_bounds, input_lower_bound, input_upper_bound, " + "expected_bounds", [ - # C1: Parameter stores the value itself. - # Expected: getValue warns and returns the stored value. - (_make_parameter, 3.14), - # C2: ParameterProxy defers to the Parameter it proxies. - # Expected: getValue warns and returns the proxied value. - (_make_parameter_proxy, 3.14), - # C3: ParameterAdapter defers to the attribute it wraps. - # Expected: getValue warns and returns the wrapped value. - (_make_parameter_adapter, 3.14), + # C1: Both bounds are given for a Parameter with no bounds. + # Expected: Both bounds are set. + ([-np.inf, np.inf], 1, 10, [1, 10]), + # C2: Only a lower bound is given for a Parameter with no bounds. + # Expected: The lower bound is set and the upper bound stays inf. + ([-np.inf, np.inf], 2, None, [2, np.inf]), + # C3: Only an upper bound is given for a Parameter with no bounds. + # Expected: The upper bound is set and the lower bound stays -inf. + ([-np.inf, np.inf], None, 8, [-np.inf, 8]), + # C4: Both bounds are given for a Parameter with bounds [0, 10]. + # Expected: Both existing bounds are overwritten. + ([0, 10], 2, 6, [2, 6]), ], ) -def test_getValue_warns_and_forwards(make_parameter, input_value): - expected_msg = ( - "'diffpy.srfit.fitbase.Parameter.getValue' is deprecated and will " - "be removed in version 4.0.0. Please use " - "'diffpy.srfit.fitbase.Parameter.get_value' instead." +def test_bound_range( + input_initial_bounds, input_lower_bound, input_upper_bound, expected_bounds +): + parameter = Parameter("a", value=5) + parameter.bounds = list(input_initial_bounds) + parameter.bound_range( + lower_bound=input_lower_bound, upper_bound=input_upper_bound ) - parameter = make_parameter(input_value) - expected_value = parameter.get_value() - - with pytest.warns(DeprecationWarning, match=re.escape(expected_msg)): - actual_value = parameter.getValue() + actual_bounds = parameter.bounds + assert actual_bounds == expected_bounds - assert actual_value == expected_value - assert actual_value == input_value - -if __name__ == "__main__": - unittest.main() +# Test that bound_window sets bounds around the current value of the +# Parameter, using the lower radius for the upper one when no upper radius +# is given. +@pytest.mark.parametrize( + "input_initial_value, input_current_value, input_lower_radius, " + "input_upper_radius, expected_bounds", + [ + # C1: Only a lower radius of 2 is given, around the value 10. + # Expected: The window is symmetric, [8, 12]. + (10, 10, 2, None, [8, 12]), + # C2: A lower radius of 3 and an upper radius of 5 are given, around + # the value 10. + # Expected: The window is asymmetric, [7, 15]. + (10, 10, 3, 5, [7, 15]), + # C3: A radius of 0 is given, around the value 4. + # Expected: Both bounds equal the value, [4, 4]. + (4, 4, 0, None, [4, 4]), + # C4: The value is changed from 5 to 20 before a radius of 2 is + # given. + # Expected: The window is around the current value, [18, 22]. + (5, 20, 2, None, [18, 22]), + ], +) +def test_bound_window( + input_initial_value, + input_current_value, + input_lower_radius, + input_upper_radius, + expected_bounds, +): + parameter = Parameter("a", value=input_initial_value) + parameter.set_value(input_current_value) + parameter.bound_window( + lower_radius=input_lower_radius, upper_radius=input_upper_radius + ) + actual_bounds = parameter.bounds + assert actual_bounds == expected_bounds diff --git a/tests/test_parameterset.py b/tests/test_parameterset.py index a1e15a30..111cbffd 100644 --- a/tests/test_parameterset.py +++ b/tests/test_parameterset.py @@ -12,10 +12,7 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Tests for refinableobj module.""" - -import re -import unittest +"""Tests for the parameterset module.""" import pytest @@ -23,129 +20,124 @@ from diffpy.srfit.fitbase.parameterset import ParameterSet -class TestParameterSet(unittest.TestCase): - - def setUp(self): - self.parset = ParameterSet("test") - return - - def test_add_parameter_set(self): - """Test the add_parameter_set method.""" - parset2 = ParameterSet("parset2") - p1 = Parameter("parset2", 1) - - self.parset.add_parameter_set(parset2) - self.assertTrue(self.parset.parset2 is parset2) - - self.assertRaises(ValueError, self.parset.add_parameter_set, p1) - - p1.name = "p1" - parset2.add_parameter(p1) - - self.assertTrue(self.parset.parset2.p1 is p1) - - return - - -# ---------------------------------------------------------------------------- -# addParameterSet is deprecated in favor of add_parameter_set. The old name -# must still work and forward to the new implementation. - - -class TestParameterSetDeprecated(unittest.TestCase): - - def setUp(self): - self.parset = ParameterSet("test") - return - - def test_add_parameter_set_deprecated(self): - """Test the deprecated addParameterSet method. - - Remove this test after the addParameterSet is removed in version - 4.0.0. - """ - parset2 = ParameterSet("parset2") - p1 = Parameter("parset2", 1) - - self.parset.addParameterSet(parset2) - self.assertTrue(self.parset.parset2 is parset2) - - self.assertRaises(ValueError, self.parset.add_parameter_set, p1) - - p1.name = "p1" - parset2.add_parameter(p1) - - self.assertTrue(self.parset.parset2.p1 is p1) - - return - - -# ---------------------------------------------------------------------------- -# The camelCase Parameter accessors on ParameterSet are deprecated in favour of -# their snake_case spellings. Each old name must still work, warn with a -# message that names its replacement, and have the same effect on the set. - - -# C1: A Parameter is stored under the set with the deprecated name. -# Expected: addParameter warns and stores it as add_parameter does. -def test_add_parameter_deprecated(): - expected_msg = ( - "'diffpy.srfit.fitbase.parameterset.ParameterSet.addParameter' is " - "deprecated and will be removed in version 4.0.0. " - "Please use " - "'diffpy.srfit.fitbase.parameterset.ParameterSet.add_parameter' " - "instead." - ) - expected_names = ["p1"] +# Test that a stored ParameterSet, and a Parameter added to it afterward, +# can be reached as attributes of the parent set. +def test_add_parameter_set(): + # C1: The ParameterSet "child" is stored in "test", then the Parameter + # "p1" is added to "child". + # Expected: test.child is the stored set and test.child.p1 is p1. parset = ParameterSet("test") + child = ParameterSet("child") + parset.add_parameter_set(child) + p1 = Parameter("p1", 1) + child.add_parameter(p1) + actual_child = parset.child + expected_child = child + assert actual_child == expected_child + actual_p1 = parset.child.p1 + expected_p1 = p1 + assert actual_p1 == expected_p1 + + +# Test that a ParameterSet refuses to store an object under a name that is +# already taken. +@pytest.mark.parametrize( + "input_object", + [ + # C1: A Parameter is named like the stored ParameterSet "child". + # Expected: Raises a ValueError. + Parameter("child", 1), + # C2: Another ParameterSet is named like the stored ParameterSet + # "child". + # Expected: Raises a ValueError. + ParameterSet("child"), + ], +) +def test_add_parameter_set_name_taken(input_object): + parset = ParameterSet("test") + parset.add_parameter_set(ParameterSet("child")) + with pytest.raises(ValueError): + parset.add_parameter_set(input_object) - with pytest.warns(DeprecationWarning, match=re.escape(expected_msg)): - parset.addParameter(Parameter("p1", 1)) - actual_names = [par.name for par in parset._parameters.values()] +# Test that remove_parameter_set removes a stored ParameterSet. +def test_remove_parameter_set(): + # C1: The stored ParameterSet "child" is removed from "test". + # Expected: "test" no longer has a "child" attribute. + parset = ParameterSet("test") + child = ParameterSet("child") + parset.add_parameter_set(child) + parset.remove_parameter_set(child) + actual_has_child = hasattr(parset, "child") + expected_has_child = False + assert actual_has_child == expected_has_child + + +# Test that set_constant sets the constant flag of every Parameter in the +# set, including those in nested sets. +@pytest.mark.parametrize( + "input_initial_const, input_args, expected_consts", + [ + # C1: A set of variable Parameters is set constant with no + # arguments. + # Expected: Both p1 and the nested p2 are constant. + (False, (), [True, True]), + # C2: A set of constant Parameters is set not constant. + # Expected: Both p1 and the nested p2 are variable. + (True, (False,), [False, False]), + ], +) +def test_set_constant(input_initial_const, input_args, expected_consts): + parset = ParameterSet("test") + parset.add_parameter(Parameter("p1", 1, const=input_initial_const)) + child = ParameterSet("child") + child.add_parameter(Parameter("p2", 2, const=input_initial_const)) + parset.add_parameter_set(child) + parset.set_constant(*input_args) + actual_consts = [parset.p1.const, parset.child.p2.const] + assert actual_consts == expected_consts + + +# Test that add_parameter stores a Parameter under its name. +def test_add_parameter(): + # C1: The Parameter "p1" is added to an empty set. + # Expected: The set holds p1 under the name "p1". + parset = ParameterSet("test") + p1 = Parameter("p1", 1) + parset.add_parameter(p1) + actual_names = parset.get_names() + expected_names = ["p1"] assert actual_names == expected_names + actual_p1 = parset.p1 + expected_p1 = p1 + assert actual_p1 == expected_p1 -# C2: A Parameter is created and stored with the deprecated name. -# Expected: newParameter warns and creates it as new_parameter does. -def test_new_parameter_deprecated(): - expected_msg = ( - "'diffpy.srfit.fitbase.parameterset.ParameterSet.newParameter' is " - "deprecated and will be removed in version 4.0.0. " - "Please use " - "'diffpy.srfit.fitbase.parameterset.ParameterSet.new_parameter' " - "instead." - ) - expected_names = ["p1"] +# Test that new_parameter creates, stores and returns a Parameter. +def test_new_parameter(): + # C1: The Parameter "p1" with value 1 is created in an empty set. + # Expected: The set holds a Parameter "p1" with value 1, and it is the + # Parameter that was returned. parset = ParameterSet("test") - - with pytest.warns(DeprecationWarning, match=re.escape(expected_msg)): - parset.newParameter("p1", 1) - - actual_names = [par.name for par in parset._parameters.values()] + p1 = parset.new_parameter("p1", 1) + actual_names = parset.get_names() + expected_names = ["p1"] assert actual_names == expected_names - - -# C3: A stored Parameter is removed from the set with the deprecated name. -# Expected: removeParameter warns and removes it as remove_parameter does. -def test_remove_parameter_deprecated(): - expected_msg = ( - "'diffpy.srfit.fitbase.parameterset.ParameterSet.removeParameter' is " - "deprecated and will be removed in version 4.0.0. " - "Please use " - "'diffpy.srfit.fitbase.parameterset.ParameterSet.remove_parameter' " - "instead." - ) - expected_names = [] + actual_values = parset.get_values() + expected_values = [1] + assert actual_values == expected_values + actual_p1 = parset.p1 + expected_p1 = p1 + assert actual_p1 == expected_p1 + + +# Test that remove_parameter removes a stored Parameter from the set. +def test_remove_parameter(): + # C1: The stored Parameter "p1" is removed. + # Expected: The set holds no Parameters. parset = ParameterSet("test") p1 = parset.new_parameter("p1", 1) - - with pytest.warns(DeprecationWarning, match=re.escape(expected_msg)): - parset.removeParameter(p1) - - actual_names = [par.name for par in parset._parameters.values()] + parset.remove_parameter(p1) + actual_names = parset.get_names() + expected_names = [] assert actual_names == expected_names - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_profile.py b/tests/test_profile.py index 3d7ca91b..f67d594c 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -12,312 +12,300 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Tests for refinableobj module.""" +"""Tests for the profile module.""" import io import re -import unittest import pytest -from numpy import allclose, arange, array, array_equal, ones_like +from numpy import allclose, arange, array, array_equal from diffpy.srfit import FitContribution, Profile, ProfileParser from diffpy.srfit.exceptions import SrFitError -class TestProfile(unittest.TestCase): - - def setUp(self): - self.profile = Profile() - return - - def testInit(self): - profile = self.profile - self.assertTrue(profile.xobs is None) - self.assertTrue(profile.yobs is None) - self.assertTrue(profile.dyobs is None) - self.assertTrue(profile.x is None) - self.assertTrue(profile.y is None) - self.assertTrue(profile.dy is None) - self.assertTrue(profile.ycalc is None) - self.assertEqual(profile.meta, {}) - return - - def test_set_observed_profile(self): - """Test the set_observed_profile method.""" - # Make a profile with defined dy - - x = arange(0, 10, 0.1) - y = x - dy = x - - prof = self.profile - prof.set_observed_profile(x, y, dy) - - self.assertTrue(array_equal(x, prof.xobs)) - self.assertTrue(array_equal(y, prof.yobs)) - self.assertTrue(array_equal(dy, prof.dyobs)) +# A new profile holds no data and no metadata. +@pytest.mark.parametrize( + "input_attribute, expected_value", + [ + # C1: An observed array is read, 1. xobs, 2. yobs, 3. dyobs. + # Expected: Returns None. + ("xobs", None), + ("yobs", None), + ("dyobs", None), + # C2: A calculated array is read, 1. x, 2. y, 3. dy, 4. ycalc. + # Expected: Returns None. + ("x", None), + ("y", None), + ("dy", None), + ("ycalc", None), + # C3: The metadata is read. + # Expected: Returns an empty dict. + ("meta", {}), + ], +) +def test_init(input_attribute, expected_value): + profile = Profile() + actual_value = getattr(profile, input_attribute) + assert actual_value == expected_value - # Make a profile with undefined dy - x = arange(0, 10, 0.1) - y = x - dy = None - self.profile.set_observed_profile(x, y, dy) +# set_observed_profile stores the observed arrays and puts the calculation +# points x, y and dy on the observed grid. +@pytest.mark.parametrize( + "input_dyobs, expected_dyobs, expected_dy", + [ + # C1: Uncertainties dyobs are given. + # Expected: dyobs is stored, and dy equals dyobs. + ([0.1, 0.2, 0.3], [0.1, 0.2, 0.3], [0.1, 0.2, 0.3]), + # C2: dyobs is None. + # Expected: dyobs stays None to mark the profile as unweighted, + # and dy defaults to ones. + (None, None, [1.0, 1.0, 1.0]), + ], +) +def test_set_observed_profile( + as_list, input_dyobs, expected_dyobs, expected_dy +): + input_xobs = [1.0, 2.0, 3.0] + input_yobs = [2.0, 4.0, 6.0] + profile = Profile() + profile.set_observed_profile(input_xobs, input_yobs, input_dyobs) + actual_observed = ( + profile.xobs.tolist(), + profile.yobs.tolist(), + as_list(profile.dyobs), + ) + expected_observed = (input_xobs, input_yobs, expected_dyobs) + assert actual_observed == expected_observed + actual_calculated = (profile.x.tolist(), profile.y.tolist()) + expected_calculated = (input_xobs, input_yobs) + assert actual_calculated == expected_calculated + actual_dy = profile.dy.tolist() + assert actual_dy == expected_dy + + +# set_calculation_range needs an observed profile to work on. +@pytest.mark.parametrize( + "input_args", + [ + # C1: No observed profile is set, and set_calculation_range is called + # 1. with no arguments, 2. with xmin, 3. with xmin and xmax, + # 4. with xmin, xmax and dx. + # Expected: Raises an AttributeError. + (), + (0,), + (0, 5), + (0, 5, 0.2), + ], +) +def test_set_calculation_range_without_data_bad(input_args): + profile = Profile() + with pytest.raises(AttributeError): + profile.set_calculation_range(*input_args) - self.assertTrue(array_equal(x, prof.xobs)) - self.assertTrue(array_equal(y, prof.yobs)) - self.assertTrue(prof.dyobs is None) - # Get the ranged profile to make sure its the same - self.assertTrue(array_equal(x, prof.x)) - self.assertTrue(array_equal(y, prof.y)) - self.assertTrue(array_equal(ones_like(prof.xobs), prof.dy)) +# set_calculation_range keeps the current bound when one is not given, +# clips the bounds to the observed data, adheres to the observed points when +# dx matches the data, and resamples anchored at xmin otherwise. The profile +# is observed at x = 2, 2.5, ..., 9.5 with y = dy = x, and is first set to +# input_initial_range. +@pytest.mark.parametrize( + "input_initial_range, input_range, expected_x", + [ + # C1: No range is changed, 1. no arguments are given, + # 2. xmin is below the observed minimum, 3. xmax is above the + # observed maximum. + # Expected: The calculation points stay on the full observed grid. + ({}, {}, arange(2, 9.6, 0.5)), + ({}, {"xmin": 0}, arange(2, 9.6, 0.5)), + ({}, {"xmax": 100}, arange(2, 9.6, 0.5)), + # C2: The range is narrowed by xmin and xmax on the observed grid. + # Expected: The observed points within [4, 7] are kept. + ({}, {"xmin": 4, "xmax": 7}, arange(4, 7.1, 0.5)), + # C3: The range is [3, 5], then xmin="obs", xmax=7 and dx=0.001. + # Expected: xmin is reset to the observed minimum, and the points + # are resampled from 2 to 7 in steps of 0.001, so 5001 points. + ( + {"xmin": 3, "xmax": 5}, + {"xmin": "obs", "xmax": 7, "dx": 0.001}, + arange(2, 7.0005, 0.001), + ), + # C4: The points are resampled, then xmin=4, xmax=7 and dx="obs". + # Expected: The points return to the observed grid within [4, 7]. + ( + {"xmin": "obs", "xmax": 7, "dx": 0.001}, + {"xmin": 4, "xmax": 7, "dx": "obs"}, + arange(4, 7.1, 0.5), + ), + # C5: Only one bound is changed on the observed grid, 1. xmin=3 + # after [4, 7], 2. xmax=5.1 after [3, 7]. + # Expected: The other bound is kept. + ({"xmin": 4, "xmax": 7}, {"xmin": 3}, arange(3, 7.1, 0.5)), + ({"xmin": 3, "xmax": 7}, {"xmax": 5.1}, arange(3, 5.1, 0.5)), + # C6: The range is [3, 5], then only dx=1 is given. + # Expected: The points are resampled from 3 to 5 in steps of 1. + ({"xmin": 3, "xmax": 5}, {"dx": 1}, arange(3, 5.1)), + # C7: The points are on [3, 5] in steps of 1, then xmin=4.2, xmax=7 + # and dx=0.3, off the observed grid. + # Expected: The points are resampled from 4.2 in steps of 0.3, up + # to the largest point not above 7. + ( + {"xmin": 3, "xmax": 5, "dx": 1}, + {"xmin": 4.2, "xmax": 7, "dx": 0.3}, + arange(4.2, 6.901, 0.3), + ), + # C8: The points are resampled from 4.2 in steps of 0.3, then only + # xmin=4.2 and xmax=6.001 are given. + # Expected: The current step of 0.3 is kept. + ( + {"xmin": 4.2, "xmax": 7, "dx": 0.3}, + {"xmin": 4.2, "xmax": 6.001}, + arange(4.2, 6.001, 0.3), + ), + # C9: The points are on [4.2, 6] in steps of 0.3, then only dx=0.5 + # is given, which matches the observed step. + # Expected: The observed points within [4.2, 6] are kept. + ( + {"xmin": 4.2, "xmax": 6.001, "dx": 0.3}, + {"dx": 0.5}, + arange(4.5, 6.1, 0.5), + ), + ], +) +def test_set_calculation_range( + observed_profile, input_initial_range, input_range, expected_x +): + observed_profile.set_calculation_range(**input_initial_range) + observed_profile.set_calculation_range(**input_range) + actual_x = observed_profile.x + assert array_equal(actual_x, expected_x) + # y = dy = x in the observed data, so this holds on any grid. + actual_y = observed_profile.y + actual_dy = observed_profile.dy + assert allclose(actual_y, expected_x) + assert allclose(actual_dy, expected_x) + + +# set_calculation_range rejects a range it cannot build. The profile is +# observed at x = 2, 2.5, ..., 9.5. +@pytest.mark.parametrize( + "input_range", + [ + # C1: xmin is greater than xmax. + # Expected: Raises a ValueError. + {"xmin": 10, "xmax": 3}, + # C2: dx is greater than xmax - xmin. + # Expected: Raises a ValueError. + {"xmin": 3, "xmax": 3.9, "dx": 1.0}, + # C3: dx is not positive, 1. dx=0, 2. dx=-0.000001. + # Expected: Raises a ValueError. + {"dx": 0}, + {"dx": -0.000001}, + # C4: A string other than "obs" is given, 1. as xmin, 2. as xmax, + # 3. as dx. + # Expected: Raises a ValueError. + {"xmin": "oobs"}, + {"xmax": "oobs"}, + {"dx": "oobs"}, + ], +) +def test_set_calculation_range_bad(observed_profile, input_range): + with pytest.raises(ValueError): + observed_profile.set_calculation_range(**input_range) - return - def test_set_calculation_range(self): - """Test the set_calculation_range method.""" - x = arange(2, 9.6, 0.5) - y = array(x) - dy = array(x) - prof = self.profile - # Check call before data arrays are present - self.assertRaises(AttributeError, prof.set_calculation_range) - self.assertRaises(AttributeError, prof.set_calculation_range, 0) - self.assertRaises(AttributeError, prof.set_calculation_range, 0, 5) - self.assertRaises( - AttributeError, prof.set_calculation_range, 0, 5, 0.2 - ) - # assign data - prof.set_observed_profile(x, y, dy) - # Test normal execution w/o arguments - self.assertTrue(array_equal(x, prof.x)) - self.assertTrue(array_equal(y, prof.y)) - self.assertTrue(array_equal(dy, prof.dy)) - prof.set_calculation_range() - self.assertTrue(array_equal(x, prof.x)) - self.assertTrue(array_equal(y, prof.y)) - self.assertTrue(array_equal(dy, prof.dy)) - # Test a lower bound < xmin - prof.set_calculation_range(xmin=0) - self.assertTrue(array_equal(x, prof.x)) - self.assertTrue(array_equal(y, prof.y)) - self.assertTrue(array_equal(dy, prof.dy)) - # Test an upper bound > xmax - prof.set_calculation_range(xmax=100) - self.assertTrue(array_equal(x, prof.x)) - self.assertTrue(array_equal(y, prof.y)) - self.assertTrue(array_equal(dy, prof.dy)) - # Test xmin > xmax - self.assertRaises( - ValueError, prof.set_calculation_range, xmin=10, xmax=3 - ) - # Test xmax - xmin < dx - self.assertRaises( - ValueError, prof.set_calculation_range, xmin=3, xmax=3.9, dx=1.0 +# set_calculation_points takes the points as given, but limits them to the +# bounds of the observed data once there is any. +@pytest.mark.parametrize( + "input_xobs, expected_x", + [ + # C1: No observed profile is set. + # Expected: x is the given points, from 3 to 12 in steps of 0.2. + (None, arange(3, 12.2, 0.2)), + # C2: An observed profile on [2, 10] is set after the points. + # Expected: x is limited to the observed bounds, from 3 to 10. + (arange(2, 10.5, 0.5), arange(3, 10.1, 0.2)), + ], +) +def test_set_calculation_points(input_xobs, expected_x): + profile = Profile() + profile.set_calculation_points(arange(3, 12.2, 0.2)) + if input_xobs is not None: + profile.set_observed_profile( + input_xobs, array(input_xobs), array(input_xobs) ) - # Test dx <= 0 - self.assertRaises(ValueError, prof.set_calculation_range, dx=0) - self.assertRaises(ValueError, prof.set_calculation_range, dx=-0.000001) - # using string other than 'obs' - self.assertRaises(ValueError, prof.set_calculation_range, xmin="oobs") - self.assertRaises(ValueError, prof.set_calculation_range, xmax="oobs") - self.assertRaises(ValueError, prof.set_calculation_range, dx="oobs") - # This should be alright - prof.set_calculation_range(3, 5) - prof.set_calculation_range(xmin="obs", xmax=7, dx=0.001) - self.assertEqual(5001, len(prof.x)) - self.assertEqual(len(prof.x), len(prof.y)) - self.assertEqual(len(prof.x), len(prof.dy)) - # Test an internal bound - prof.set_calculation_range(4, 7, dx="obs") - self.assertTrue(array_equal(prof.x, arange(4, 7.1, 0.5))) - self.assertTrue(array_equal(prof.y, arange(4, 7.1, 0.5))) - self.assertTrue(array_equal(prof.y, arange(4, 7.1, 0.5))) - # test setting only one of the bounds - prof.set_calculation_range(xmin=3) - self.assertTrue(array_equal(prof.x, arange(3, 7.1, 0.5))) - self.assertTrue(array_equal(prof.x, prof.y)) - self.assertTrue(array_equal(prof.x, prof.dy)) - prof.set_calculation_range(xmax=5.1) - self.assertTrue(array_equal(prof.x, arange(3, 5.1, 0.5))) - self.assertTrue(array_equal(prof.x, prof.y)) - self.assertTrue(array_equal(prof.x, prof.dy)) - prof.set_calculation_range(dx=1) - self.assertTrue(array_equal(prof.x, arange(3, 5.1))) - self.assertTrue(array_equal(prof.x, prof.y)) - self.assertTrue(array_equal(prof.x, prof.dy)) - # Test a new grid - prof.set_calculation_range(4.2, 7, 0.3) - self.assertTrue(array_equal(prof.x, arange(4.2, 6.901, 0.3))) - self.assertTrue(allclose(prof.x, prof.y)) - self.assertTrue(allclose(prof.x, prof.dy)) - prof.set_calculation_range(xmin=4.2, xmax=6.001) - self.assertTrue(array_equal(prof.x, arange(4.2, 6.001, 0.3))) - # resample on a clipped grid - prof.set_calculation_range(dx=0.5) - self.assertTrue(array_equal(prof.x, arange(4.5, 6.1, 0.5))) - return - - def test_set_calculation_points(self): - """Test the set_calculation_points method.""" - prof = self.profile - - x = arange(2, 10.5, 0.5) - y = array(x) - dy = array(x) - - # Test without data - xcalc = arange(3, 12.2, 0.2) - prof.set_calculation_points(xcalc) - self.assertTrue(array_equal(xcalc, prof.x)) - - # Add the data. This should change the bounds of the calculation array. - prof.set_observed_profile(x, y, dy) - self.assertTrue(array_equal(arange(3, 10.1, 0.2), prof.x)) - - return - - def test_savetxt(self): - "Check the savetxt method." - prof = self.profile - self.assertRaises(SrFitError, prof.savetxt, "foo") - xobs = arange(-2, 3.01, 0.25) - yobs = xobs**2 - prof.set_observed_profile(xobs, yobs) - prof.ycalc = yobs.copy() - fp = io.BytesIO() - prof.savetxt(fp) - txt = fp.getvalue().decode() - self.assertTrue(re.match(r"^# x +ycalc +y +dy\b", txt)) - nlines = len(txt.strip().split("\n")) - self.assertEqual(22, nlines) - return - - -# ---------------------------------------------------------------------------- -# setObservedProfile, setCalculationRange, and setCalculationPoints are -# deprecated in favor of set_observed_profile, set_calculation_range, and -# set_calculation_points. The old names must still work and forward to the -# new implementation. - - -class TestProfileDeprecated(unittest.TestCase): - - def setUp(self): - self.profile = Profile() - return - - def test_set_observed_profile_deprecated(self): - """Test the deprecated setObservedProfile method. - - Remove this test when setObservedProfile is removed in 4.0.0. - """ - # Make a profile with defined dy - - x = arange(0, 10, 0.1) - y = x - dy = x - - prof = self.profile - prof.set_observed_profile(x, y, dy) - - self.assertTrue(array_equal(x, prof.xobs)) - self.assertTrue(array_equal(y, prof.yobs)) - self.assertTrue(array_equal(dy, prof.dyobs)) - - # Make a profile with undefined dy - x = arange(0, 10, 0.1) - y = x - dy = None - - self.profile.setObservedProfile(x, y, dy) - - self.assertTrue(array_equal(x, prof.xobs)) - self.assertTrue(array_equal(y, prof.yobs)) - self.assertTrue(prof.dyobs is None) - - # Get the ranged profile to make sure its the same - self.assertTrue(array_equal(x, prof.x)) - self.assertTrue(array_equal(y, prof.y)) - self.assertTrue(array_equal(ones_like(prof.xobs), prof.dy)) - - return - - def test_set_calculation_range_deprecated(self): - """Test the deprecated setCalculationRange method. - - Remove this test when setCalculationRange is removed in 4.0.0. - """ - x = arange(2, 9.6, 0.5) - y = array(x) - dy = array(x) - prof = self.profile - prof.set_observed_profile(x, y, dy) - # Test normal execution w/o arguments - self.assertTrue(array_equal(x, prof.x)) - self.assertTrue(array_equal(y, prof.y)) - self.assertTrue(array_equal(dy, prof.dy)) - prof.setCalculationRange() - self.assertTrue(array_equal(x, prof.x)) - self.assertTrue(array_equal(y, prof.y)) - self.assertTrue(array_equal(dy, prof.dy)) - return - - def test_set_calculation_points_deprecated(self): - """Test the deprecated setCalculationPoints method. - - Remove this test when setCalculationPoints is removed in 4.0.0. - """ - prof = self.profile - - x = arange(2, 10.5, 0.5) - y = array(x) - dy = array(x) - - # Test without data - xcalc = arange(3, 12.2, 0.2) - prof.setCalculationPoints(xcalc) - self.assertTrue(array_equal(xcalc, prof.x)) - - # Add the data. This should change the bounds of the calculation array. - prof.set_observed_profile(x, y, dy) - self.assertTrue(array_equal(arange(3, 10.1, 0.2), prof.x)) - - return - - -def testLoadtxt(datafile): - """Test the loadtxt method.""" - prof = Profile() - data = datafile("testdata.txt") - - def _test(p): - assert 1e-2 == pytest.approx(p.x[0]) - assert 1.105784e-1 == pytest.approx(p.y[0]) - assert 1.802192e-3 == pytest.approx(p.dy[0]) - - # Test normal load - prof.loadtxt(data, usecols=(0, 1, 3)) - _test(prof) - - # Test trying to not set unpack - prof.loadtxt(data, usecols=(0, 1, 3), unpack=False) - _test(prof) - prof.loadtxt(data, float, "#", None, None, 0, (0, 1, 3), False) - _test(prof) + actual_x = profile.x + assert array_equal(actual_x, expected_x) + + +def test_savetxt(): + # C1: A profile with observed data and ycalc is saved to a file. + # Expected: The file has a header naming the x, ycalc, y and dy + # columns, followed by one line for each of the 21 points. + profile = Profile() + xobs = arange(-2, 3.01, 0.25) + yobs = xobs**2 + profile.set_observed_profile(xobs, yobs) + profile.ycalc = yobs.copy() + file_object = io.BytesIO() + profile.savetxt(file_object) + actual_text = file_object.getvalue().decode() + assert re.match(r"^# x +ycalc +y +dy\b", actual_text) + actual_line_count = len(actual_text.strip().split("\n")) + expected_line_count = 22 + assert actual_line_count == expected_line_count + + +def test_savetxt_bad(): + # C1: A profile without ycalc is saved. + # Expected: Raises an SrFitError. + profile = Profile() + with pytest.raises(SrFitError): + profile.savetxt("foo") + + +# loadtxt reads x, y and dy with numpy.loadtxt and always unpacks the +# columns. The first point of testdata.txt is x=1e-2, y=1.105784e-1 and +# dy=1.802192e-3. +@pytest.mark.parametrize( + "input_args, input_kwargs, expected_first_point", + [ + # C1: The x, y and dy columns are selected with usecols. + # Expected: The first point is loaded with its dy. + ((), {"usecols": (0, 1, 3)}, (1e-2, 1.105784e-1, 1.802192e-3)), + # C2: unpack=False is given, 1. as a keyword, 2. as the last of + # all eight positional arguments. + # Expected: unpack is enforced, and the first point is loaded with + # its dy. + ( + (), + {"usecols": (0, 1, 3), "unpack": False}, + (1e-2, 1.105784e-1, 1.802192e-3), + ), + ( + (float, "#", None, None, 0, (0, 1, 3), False), + {}, + (1e-2, 1.105784e-1, 1.802192e-3), + ), + # C3: Only the x and y columns are selected. + # Expected: The first point is loaded, and dy defaults to 1. + ((), {"usecols": (0, 1)}, (1e-2, 1.105784e-1, 1)), + ], +) +def test_loadtxt(datafile, input_args, input_kwargs, expected_first_point): + profile = Profile() + profile.loadtxt(datafile("testdata.txt"), *input_args, **input_kwargs) + actual_first_point = (profile.x[0], profile.y[0], profile.dy[0]) + assert actual_first_point == pytest.approx(expected_first_point) - # Try not including dy - prof.loadtxt(data, usecols=(0, 1)) - assert 1e-2 == pytest.approx(prof.x[0]) - assert 1.105784e-1 == pytest.approx(prof.y[0]) - assert 1 == pytest.approx(prof.dy[0]) - # Try to include too little +def test_loadtxt_bad(datafile): + # C1: Only the x column is selected. + # Expected: Raises a ValueError, since y is required. + profile = Profile() with pytest.raises(ValueError): - prof.loadtxt(data, usecols=(0,)) - return + profile.loadtxt(datafile("testdata.txt"), usecols=(0,)) # The parsed x, y and dy arrays are copied onto the observed profile. @@ -334,7 +322,7 @@ def _test(p): [2.0, 2.1, 2.2], [0.2, 0.4, 0.6], ), - # C3: File has three columns, so uncertainties are present. + # C2: File has three columns, so uncertainties are present. # Expected: xobs, yobs and dyobs are loaded. ( "three_col.dat", @@ -342,7 +330,7 @@ def _test(p): [2.0, 2.1, 2.2], [0.2, 0.4, 0.6], ), - # C2: File has two columns, so no uncertainties are present. + # C3: File has two columns, so no uncertainties are present. # Expected: xobs and yobs are loaded and dyobs stays None, # marking the profile as unweighted. ( @@ -361,36 +349,32 @@ def test_load_parsed_data( expected_yobs, expected_dyobs, ): - """Load a parsed profile onto the observed arrays.""" parser = ProfileParser() parser.parse_file(parser_datafiles / input_filename) - prof = Profile() - prof.load_parsed_data(parser) - actual_xobs = prof.xobs.tolist() - actual_yobs = prof.yobs.tolist() + profile = Profile() + profile.load_parsed_data(parser) + actual_xobs = profile.xobs.tolist() + actual_yobs = profile.yobs.tolist() # Unavailable uncertainties are None rather than an array. - actual_dyobs = as_list(prof.dyobs) + actual_dyobs = as_list(profile.dyobs) assert actual_xobs == expected_xobs assert actual_yobs == expected_yobs assert actual_dyobs == expected_dyobs -# Case: A profile is set without uncertainties, so dyobs is None, and a -# residual is computed from the equation y = A*x with A = 2. -# Expected: The residual is (A*x - y) with unit weights, since missing -# uncertainties default to ones rather than being used as divisors. def test_residual_without_uncertainties(): - """Compute the residual for a profile with no uncertainties.""" - prof = Profile() - prof.set_observed_profile(array([1.0, 2.0, 3.0]), array([1.0, 2.0, 3.0])) + # C1: A profile is set without uncertainties, so dyobs is None, and a + # residual is computed from the equation y = A*x with A = 2. + # Expected: The residual is (A*x - y) with unit weights, since missing + # uncertainties default to ones rather than being used as divisors. + profile = Profile() + profile.set_observed_profile( + array([1.0, 2.0, 3.0]), array([1.0, 2.0, 3.0]) + ) contribution = FitContribution("test") - contribution.set_profile(prof) + contribution.set_profile(profile) contribution.set_equation("A*x") contribution.A.set_value(2) actual_residual = contribution.residual().tolist() expected_residual = [1.0, 2.0, 3.0] assert actual_residual == expected_residual - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_profilegenerator.py b/tests/test_profilegenerator.py index 02dd317c..6406b17d 100644 --- a/tests/test_profilegenerator.py +++ b/tests/test_profilegenerator.py @@ -12,80 +12,81 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Tests for profilegenerator module.""" +"""Tests for the profilegenerator module.""" import pickle -import unittest +import pytest from numpy import arange, array_equal -from diffpy.srfit import Profile, ProfileGenerator - - -class TestProfileGenerator(unittest.TestCase): - - def setUp(self): - self.gen = ProfileGenerator("test") - self.profile = Profile() - x = arange(0, 10, 0.1) - self.profile.set_calculation_points(x) - self.gen.set_profile(self.profile) - return - - def testOperation(self): - """Test the operation method.""" - gen = self.gen - prof = self.profile - - # Try the direct evaluation - val = gen.operation() - self.assertTrue(array_equal(prof.x, val)) - - # Try evaluation through __call__ - val = gen(2 * prof.x) - self.assertTrue(array_equal(2 * prof.x, val)) - return - - def testUpdate(self): - """Update and change the profile to make sure generator is - flushed.""" - gen = self.gen - prof = self.profile - - # Make sure attributes get updated with a change in the calculation - # points. - x = arange(0, 9, 0.1) - prof.set_calculation_points(x) - self.assertTrue(gen._value is None) - val = gen.value - self.assertTrue(array_equal(x, val)) - - # Verify generated value listens to changes in profile.x. - x3 = x + 3 - prof.x = x3 - self.assertTrue(array_equal(x3, gen.value)) - - # Make sure attributes get updated with a new profile. - x = arange(0, 8, 0.1) - prof = Profile() - prof.set_calculation_points(x) - gen.set_profile(prof) - self.assertTrue(gen._value is None) - self.assertTrue(array_equal(x, gen.value)) - return - - def test_pickling(self): - """Test pickling of ProfileGenerator.""" - data = pickle.dumps(self.gen) - gen2 = pickle.loads(data) - self.assertEqual("test", gen2.name) - x = self.profile.x - self.assertTrue(array_equal(x, gen2.operation())) - self.assertTrue(array_equal(3 * x, gen2(3 * x))) - return - - -# End of class TestProfileGenerator - -if __name__ == "__main__": - unittest.main() +from diffpy.srfit import Profile + + +def test_operation(generator_and_profile): + # C1: operation is called, and the generator is called with 2*x. + # Expected: The base ProfileGenerator returns its input x unchanged, + # so operation returns the profile's calculation points x and the + # call returns 2*x. + generator, profile = generator_and_profile + actual_operation_value = generator.operation() + expected_operation_value = profile.x + assert array_equal(actual_operation_value, expected_operation_value) + actual_call_value = generator(2 * profile.x) + expected_call_value = 2 * profile.x + assert array_equal(actual_call_value, expected_call_value) + + +# A change in the profile flushes the cached value of the generator, so its +# next value is computed on the new calculation points. +@pytest.mark.parametrize( + "input_update, input_new_x", + [ + # C1: The profile's calculation points are set with + # set_calculation_points. + # Expected: The cached value is cleared, and the value is the new x. + ("set_calculation_points", arange(0, 9, 0.1)), + # C2: The profile's x is assigned directly. + # Expected: The cached value is cleared, and the value is the new x. + ("assign_x", arange(0, 9, 0.1) + 3), + # C3: A new profile is assigned with set_profile. + # Expected: The cached value is cleared, and the value is the new + # profile's x. + ("set_profile", arange(0, 8, 0.1)), + ], +) +def test_update(generator_and_profile, input_update, input_new_x): + generator, profile = generator_and_profile + # Evaluate once so there is a cached value to flush. + actual_initial_value = generator.value + expected_initial_value = profile.x + assert array_equal(actual_initial_value, expected_initial_value) + if input_update == "set_calculation_points": + profile.set_calculation_points(input_new_x) + elif input_update == "assign_x": + profile.x = input_new_x + else: + new_profile = Profile() + new_profile.set_calculation_points(input_new_x) + generator.set_profile(new_profile) + actual_cached_value = generator._value + expected_cached_value = None + assert actual_cached_value == expected_cached_value + actual_value = generator.value + expected_value = input_new_x + assert array_equal(actual_value, expected_value) + + +def test_pickling(generator_and_profile): + # C1: A generator with a profile is pickled and unpickled. + # Expected: The copy keeps the name and evaluates the same profile. + generator, profile = generator_and_profile + unpickled_generator = pickle.loads(pickle.dumps(generator)) + actual_name = unpickled_generator.name + expected_name = "test" + assert actual_name == expected_name + actual_operation_value = unpickled_generator.operation() + expected_operation_value = profile.x + assert array_equal(actual_operation_value, expected_operation_value) + actual_call_value = unpickled_generator(3 * profile.x) + expected_call_value = 3 * profile.x + assert array_equal(actual_call_value, expected_call_value) diff --git a/tests/test_profileparser.py b/tests/test_profileparser.py index 7930c8a5..429175e5 100644 --- a/tests/test_profileparser.py +++ b/tests/test_profileparser.py @@ -34,8 +34,8 @@ "input_file, column_order, expected_x, " "expected_y, expected_dx, expected_dy", [ - # UC1: 4-column file (x, y, dx, dy) — all columns present - # expected: x, y, dx, dy, and metadata are all read correctly + # C1: 4-column file (x, y, dx, dy) — all columns present + # Expected: x, y, dx, dy, and metadata are all read correctly ( Path("four_col.gr"), None, @@ -44,8 +44,8 @@ [0.1, 0.3, 0.5], [0.2, 0.4, 0.6], ), - # UC2: 3-column file (x, y, dy) — dx is missing - # expected: x, y, dy, and metadata are all read correctly + # C2: 3-column file (x, y, dy) — dx is missing + # Expected: x, y, dy, and metadata are all read correctly ( Path("three_col.dat"), None, @@ -54,8 +54,8 @@ None, [0.2, 0.4, 0.6], ), - # UC3: 2-column file (x, y) — dx and dy are missing - # expected: x, y, and metadata are all read correctly + # C3: 2-column file (x, y) — dx and dy are missing + # Expected: x, y, and metadata are all read correctly ( Path("two_col.txt"), None, @@ -64,9 +64,9 @@ None, None, ), - # UC4: 4-column file in (x, dx, y, dy) order with explicit + # C4: 4-column file in (x, dx, y, dy) order with explicit # column_format - # expected: x, y, dx, dy, and metadata are all read correctly + # Expected: x, y, dx, dy, and metadata are all read correctly ( Path("four_col_reordered.txt"), ("x", "dx", "y", "dy"), @@ -75,8 +75,8 @@ [0.1, 0.3, 0.5], [0.2, 0.4, 0.6], ), - # UC5: 4-column file where dx/dy contain NaN and inf values - # expected: x, y, and metadata are read correctly; dx and dy + # C5: 4-column file where dx/dy contain NaN and inf values + # Expected: x, y, and metadata are read correctly; dx and dy # are set to None ( Path("four_col_nan_inf.gr"), @@ -112,51 +112,53 @@ def test_parse_file( assert actual_y == expected_y assert actual_dx == expected_dx assert actual_dy == expected_dy - assert actual_metadata == EXPECTED_META + expected_metadata = EXPECTED_META + assert actual_metadata == expected_metadata @pytest.mark.parametrize( "input_file, column_order, msg", [ - # UC6: Only one column — cannot form x/y pair - # expected: ParseError is raised + # C1: Only one column — cannot form x/y pair + # Expected: ParseError is raised ( "one_col.gr", None, "Data block must have at least two columns (x, y).", ), - # UC7: Five columns — ambiguous, no mapping defined - # expected: ParseError is raised + # C2: Five columns — ambiguous, no mapping defined + # Expected: ParseError is raised ("five_col.gr", None, "Expected 2 to 4 columns but found 5."), - # UC8: 3-column file but column_format expects 4 columns - # expected: ParseError is raised + # C3: 3-column file but column_format expects 4 columns + # Expected: ParseError is raised ( "three_col.dat", ("x", "y", "dx", "dy"), "column_format has 4 labels but file contains 3 columns.", ), - # UC9: 4-column file but column_format expects 5 columns - # expected: ParseError is raised + # C4: 4-column file but column_format expects 5 columns + # Expected: ParseError is raised ( "four_col.gr", ("x", "y", "dx", "dy", "extra"), "column_format has 5 labels but file contains 4 columns.", ), - # UC10: 4-column file but column_format expects only 3 columns - # expected: ParseError is raised + # C5: 4-column file but column_format expects only 3 columns + # Expected: ParseError is raised ( "four_col.gr", ("x", "y", "dy"), "column_format has 3 labels but file contains 4 columns.", ), - # UC11: column_format contains duplicate column names - # expected: ParseError is raised + # C6: column_format contains duplicate column names + # Expected: ParseError is raised ( "four_col.gr", ("x", "x", "dx", "dy"), "column_format cannot contain duplicate labels.", ), - # UC12: column_format contains invalid column names + # C7: column_format contains invalid column names + # Expected: ParseError is raised ( "four_col.gr", ("x", "y", "dx", "invalid"), @@ -204,18 +206,24 @@ def test_parse_file_uses_metadata_hook(parser_datafiles): # The custom hook replaces the generic header scan entirely, so the # name = value pairs the default would have collected are absent. actual_metadata = parser.get_metadata() - assert actual_metadata["instrument"] == "custom" + actual_instrument = actual_metadata["instrument"] + expected_instrument = "custom" + assert actual_instrument == expected_instrument assert "wavelength" not in actual_metadata - actual_x, actual_y, actual_dx, actual_dy = parser.get_data() + x, y, dx, dy = parser.get_data() + actual_x = x.tolist() + actual_y = y.tolist() + actual_dx = dx.tolist() + actual_dy = dy.tolist() expected_x = [1.0, 1.1, 1.2] expected_y = [2.0, 2.1, 2.2] expected_dx = [0.1, 0.3, 0.5] expected_dy = [0.2, 0.4, 0.6] - assert actual_x.tolist() == expected_x - assert actual_y.tolist() == expected_y - assert actual_dx.tolist() == expected_dx - assert actual_dy.tolist() == expected_dy + assert actual_x == expected_x + assert actual_y == expected_y + assert actual_dx == expected_dx + assert actual_dy == expected_dy class TwoBankParser(ProfileParser): @@ -248,32 +256,6 @@ def test_parse_file_uses_data_hook(parser_datafiles): assert actual_second_bank_y == expected_second_bank_y -def test_parse_file_deprecated_warns_about_profileparser(parser_datafiles): - """The shared parseFile reports its own name, not a subclass one. - - parseFile is inherited by every parser, so a message naming a - specific subclass would misdirect users of the others. - """ - # Case: parseFile is called on a subclass of ProfileParser. - # Expected: A DeprecationWarning is raised, and the message mentions - parser = ProfileParser() - expected_msg = ( - "'diffpy.srfit.fitbase.profileparser.ProfileParser.parseFile' is " - "deprecated and will be removed in version 4.0.0. Please use " - "'diffpy.srfit.fitbase.profileparser.ProfileParser.parse_file' " - "instead." - ) - with pytest.warns( - DeprecationWarning, - match=re.escape(expected_msg), - ): - parser.parseFile(parser_datafiles / "two_col.txt") - - actual_x = parser.get_data()[0].tolist() - expected_x = [1.0, 1.1, 1.2] - assert actual_x == expected_x - - # parse_file accepts an optional `metadata` dict, merged into the parsed # metadata after the file header is read. This lets a caller attach # information that is not present in the file itself (e.g. sample name), @@ -283,14 +265,14 @@ def test_parse_file_deprecated_warns_about_profileparser(parser_datafiles): @pytest.mark.parametrize( "input_metadata, expected_added_metadata", [ - # UC13: Supplied keys are not present in the file header + # C1: Supplied keys are not present in the file header # Expected: the supplied keys/values are added to the parsed # metadata, alongside the keys read from the file ( {"operator": "jdoe", "sample": "LaB6"}, {"operator": "jdoe", "sample": "LaB6"}, ), - # UC14: A supplied key overlaps with one already parsed from the + # C2: A supplied key overlaps with one already parsed from the # file header # Expected: the supplied value overrides the value parsed from # the file @@ -308,15 +290,17 @@ def test_parse_file_appends_metadata( parser_datafiles / "four_col.gr", metadata=input_metadata ) - actual_metadata = parser.get_metadata() - for key, expected_value in expected_added_metadata.items(): - assert actual_metadata[key] == expected_value + metadata = parser.get_metadata() + actual_added_metadata = { + key: metadata[key] for key in expected_added_metadata + } + assert actual_added_metadata == expected_added_metadata @pytest.mark.parametrize( "bad_metadata_input, expected_msg", [ - # UC15: The user supplies a metadata dict with a key that is not + # C1: The user supplies a metadata dict with a key that is not # a string. # Expected: A ParseError is raised with a message indicating that # all keys must be strings. @@ -326,7 +310,7 @@ def test_parse_file_appends_metadata( "is not a string. All keys in the metadata " "dictionary must be strings.", ), - # UC16: User supplies metadata not in the form of a dictionary. + # C2: User supplies metadata not in the form of a dictionary. # Expected: A ParseError is raised with a message indicating that # the metadata must be a dictionary. ( @@ -349,7 +333,7 @@ def test_parse_file_appends_metadata_bad( @pytest.mark.parametrize( "input_metadata, reserved_key, expected_value, expected_msg", [ - # UC17: Supplied metadata overlaps with the "filename" key, which + # C1: Supplied metadata overlaps with the "filename" key, which # parse_file normally sets itself from the file argument. # Expected: A UserWarning is raised naming the reserved key, and # the supplied value overrides the automatically parsed one. @@ -360,7 +344,7 @@ def test_parse_file_appends_metadata_bad( "'filename' is a reserved metadata key normally set by " "parse_file. The supplied value will override it.", ), - # UC18: Supplied metadata overlaps with the "bank" key, which + # C2: Supplied metadata overlaps with the "bank" key, which # parse_file normally sets from select_bank. # Expected: A UserWarning is raised naming the reserved key, and # the supplied value overrides the automatically parsed one. @@ -371,7 +355,7 @@ def test_parse_file_appends_metadata_bad( "'bank' is a reserved metadata key normally set by " "parse_file. The supplied value will override it.", ), - # UC19: Supplied metadata overlaps with the "nbanks" key, which + # C3: Supplied metadata overlaps with the "nbanks" key, which # parse_file normally sets from the number of banks read. # Expected: A UserWarning is raised naming the reserved key, and # the supplied value overrides the automatically parsed one. @@ -416,7 +400,9 @@ def test_parse_file_appends_metadata_does_not_mutate_input(parser_datafiles): input_metadata["sample"] = "added after parsing" actual_metadata = parser.get_metadata() - assert actual_metadata["operator"] == "jdoe" + actual_operator = actual_metadata["operator"] + expected_operator = "jdoe" + assert actual_operator == expected_operator assert "sample" not in actual_metadata @@ -435,10 +421,14 @@ def test_parse_file_usecols_selects_columns(parser_datafiles): parser_datafiles / "five_col.gr", ("x", "y"), usecols=(0, 1) ) - actual_x, actual_y, actual_dx, actual_dy = parser.get_data() + x, y, actual_dx, actual_dy = parser.get_data() + actual_x = x.tolist() + actual_y = y.tolist() expected_x = [1.0, 1.1, 1.2] expected_y = [2.0, 2.1, 2.2] - assert actual_x.tolist() == expected_x - assert actual_y.tolist() == expected_y - assert actual_dx is None - assert actual_dy is None + expected_dx = None + expected_dy = None + assert actual_x == expected_x + assert actual_y == expected_y + assert actual_dx == expected_dx + assert actual_dy == expected_dy diff --git a/tests/test_recipeorganizer.py b/tests/test_recipeorganizer.py index 94c227c0..6439f749 100644 --- a/tests/test_recipeorganizer.py +++ b/tests/test_recipeorganizer.py @@ -12,10 +12,10 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Tests for refinableobj module.""" +"""Tests for the recipeorganizer module.""" -import unittest import warnings +from operator import attrgetter import numpy import pytest @@ -26,650 +26,676 @@ from diffpy.srfit.fitbase.recipeorganizer import ( RecipeContainer, RecipeOrganizer, - equationFromString, get_equation_from_string, ) from diffpy.utils._deprecator import deprecated -# ---------------------------------------------------------------------------- +def _make_factory_with_parameters(): + """Return a factory with p1 = 1 and p2 = 2 registered, and the + Parameters p1 to p4, of which p3 = 3 and p4 = 4 are not + registered.""" + parameters = { + name: Parameter(name, value) + for name, value in (("p1", 1), ("p2", 2), ("p3", 3), ("p4", 4)) + } + factory = EquationFactory() + factory.register_argument("p1", parameters["p1"]) + factory.register_argument("p2", parameters["p2"]) + return factory, parameters -class TestEquationFromString(unittest.TestCase): - - def test_get_equation_from_string(self): - """Test the get_equation_from_string method.""" - p1 = Parameter("p1", 1) - p2 = Parameter("p2", 2) - p3 = Parameter("p3", 3) - p4 = Parameter("p4", 4) - - factory = EquationFactory() - - factory.register_argument("p1", p1) - factory.register_argument("p2", p2) - - # Check usage where all parameters are registered with the factory - eq = get_equation_from_string("p1+p2", factory) - - self.assertEqual(2, len(eq.args)) - self.assertTrue(p1 in eq.args) - self.assertTrue(p2 in eq.args) - self.assertEqual(3, eq()) - - # Try to use a parameter that is not registered - self.assertRaises( - ValueError, get_equation_from_string, "p1+p2+p3", factory - ) - - # Pass that argument in the ns dictionary - eq = equationFromString("p1+p2+p3", factory, {"p3": p3}) - self.assertEqual(3, len(eq.args)) - self.assertTrue(p1 in eq.args) - self.assertTrue(p2 in eq.args) - self.assertTrue(p3 in eq.args) - self.assertEqual(6, eq()) - - # Make sure that there are no remnants of p3 in the factory - self.assertTrue("p3" not in factory.builders) - - # Pass and use an unregistered parameter - self.assertRaises( - ValueError, - get_equation_from_string, - "p1+p2+p3+p4", - factory, - {"p3": p3}, - ) - - # Try to overload a registered parameter - self.assertRaises( - ValueError, get_equation_from_string, "p1+p2", factory, {"p2": p4} - ) - - return - - -# ---------------------------------------------------------------------------- - - -class TestRecipeContainer(unittest.TestCase): - - def setUp(self): - self.m = RecipeContainer("test") - - # Add another managed dictionary - self.m._containers = {} - self.m._manage(self.m._containers) - - return - - def testAccessors(self): - """Test accessors.""" - m1 = self.m - p1 = Parameter("p1", 1) - m1._add_object(p1, m1._parameters) - - m2 = RecipeContainer("m2") - p2 = Parameter("p2", 2) - m2._add_object(p2, m2._parameters) - - m1._add_object(m2, m1._containers) - - self.assertTrue(m1.m2 is m2) - self.assertTrue(m1.p1 is p1) - self.assertTrue(m2.p2 is p2) - self.assertTrue(m1.m2.p2 is p2) - - self.assertTrue(m1[0] is p1) - self.assertTrue( - m1[0:] - == [ - p1, - ] - ) - self.assertTrue(m2[0] is p2) - - self.assertEqual(1, len(m1)) - self.assertEqual(1, len(m2)) - return - - def testLocateManagedObject(self): - """Test the locateManagedObject method.""" - m1 = self.m - p1 = Parameter("p1", 1) - m1._add_object(p1, m1._parameters) - - m2 = RecipeContainer("m2") - p2 = Parameter("p2", 2) - m2._add_object(p2, m2._parameters) - - m1._add_object(m2, m1._containers) - - p3 = Parameter("p3", 3) - - # Locate m2 in m1 (m1.m2) - loc = m1._locate_managed_object(m2) - self.assertEqual(loc, [m1, m2]) - - # Locate p1 (m1.p1) - loc = m1._locate_managed_object(p1) - self.assertEqual(loc, [m1, p1]) - - # Locate p2 in m2 (m2.p2) - loc = m2._locate_managed_object(p2) - self.assertEqual(loc, [m2, p2]) - - # Locate p2 in m1 (m1.m2.p2) - loc = m1._locate_managed_object(p2) - self.assertEqual(loc, [m1, m2, p2]) - - # Locate p3 in m1 (not there) - loc = m1._locate_managed_object(p3) - self.assertEqual(loc, []) - - # Locate p3 in m2 (not there) - loc = m2._locate_managed_object(p3) - self.assertEqual(loc, []) - - return - - -# ---------------------------------------------------------------------------- - - -class TestRecipeOrganizer(unittest.TestCase): - - def setUp(self): - self.m = RecipeOrganizer("test") - # Add a managed container so we can do more in-depth tests. - self.m._containers = {} - self.m._manage(self.m._containers) - return - - def tearDown(self): - return - - def testNewParameter(self): - """Test the add_parameter method.""" - m = self.m - - p1 = Parameter("p1", 1) - m._add_parameter(p1) - - # Test duplication of Parameters - self.assertRaises(ValueError, m._new_parameter, "p1", 0) - - # Add a new Parameter - p2 = m._new_parameter("p2", 0) - self.assertTrue(p2 is m.p2) - return - - def testAddParameter(self): - """Test the add_parameter method.""" - m = self.m - - p1 = Parameter("p1", 1) - p2 = Parameter("p1", 2) - - # Check normal insert - m._add_parameter(p1) - self.assertTrue(m.p1 is p1) - self.assertTrue(p1.name in m._eqfactory.builders) - - # Try to insert another parameter with the same name - self.assertRaises(ValueError, m._add_parameter, p2) - - # Now allow this - m._add_parameter(p2, check=False) - self.assertTrue(m.p1 is p2) - self.assertTrue(p1.name in m._eqfactory.builders) - - # Try to insert a Parameter when a RecipeContainer with the same name - # is already inside. - c = RecipeContainer("test") - m._add_object(c, m._containers) - - p3 = Parameter("test", 0) - self.assertRaises(ValueError, m._add_parameter, p3) - - p4 = Parameter("xyz", 0) - m._add_parameter(p4) - - # Check order - self.assertEqual(list(m._parameters.keys()), ["p1", "xyz"]) - self.assertEqual(list(m._parameters.values()), [p2, p4]) - - return - - def testRemoveParameter(self): - """Test remove_parameter method.""" - m = self.m - - p1 = Parameter("p1", 1) - p2 = Parameter("p1", 2) - - m._add_parameter(p1) - - # Check for bad remove - self.assertRaises(ValueError, m._remove_parameter, p2) - - # Remove p1 - m._remove_parameter(p1) - self.assertTrue(p1.name not in m._eqfactory.builders) - - # Try to remove it again - self.assertRaises(ValueError, m._remove_parameter, p1) - - # Try to remove a RecipeContainer - c = RecipeContainer("test") - self.assertRaises(ValueError, m._remove_parameter, c) - return - - def test_constrain_parameter(self): - """Test the constrain method.""" - p1 = self.m._new_parameter("p1", 1) - p2 = self.m._new_parameter("p2", 2) - p3 = Parameter("p3", 3) - - self.assertFalse(p1.constrained) - self.assertEqual(0, len(self.m._constraints)) - self.m.add_constraint(p1, "2*p2") - - actual_constrained_params = self.m.getConstrainedPars() - actual_constrained_params = [p.name for p in actual_constrained_params] - expected_constrained_params = [p1.name] - - assert actual_constrained_params == expected_constrained_params - - actual_constrained_params = self.m.get_constrained_parmeters() - actual_constrained_params = [p.name for p in actual_constrained_params] - expected_constrained_params = [p1.name] - - assert actual_constrained_params == expected_constrained_params - - self.assertTrue(p1.constrained) - self.assertTrue(p1 in self.m._constraints) - self.assertEqual(1, len(self.m._constraints)) - self.assertTrue(self.m.is_constrained(p1)) - - p2.set_value(10) - self.m._constraints[p1].update() - self.assertEqual(20, p1.get_value()) - - # Check errors on unregistered parameters - self.assertRaises(ValueError, self.m.constrain, p1, "2*p3") - self.assertRaises(ValueError, self.m.constrain, p1, "2*p2", {"p2": p3}) - - # Remove the constraint - self.m.remove_constraint(p1) - self.assertFalse(p1.constrained) - self.assertEqual(0, len(self.m._constraints)) - self.assertFalse(self.m.isConstrained(p1)) - - # Try an straight constraint - self.m.add_constraint(p1, p2) - p2.set_value(7) - self.m._constraints[p1].update() - self.assertEqual(7, p1.get_value()) - - self.m.clear_all_constraints() - actual_constrained_params = self.m.get_constrained_parmeters() - actual_constrained_params = [p.name for p in actual_constrained_params] - expected_constrained_params = [] - assert actual_constrained_params == expected_constrained_params - - # add constraint back and test the old function name `clearConstraints` - self.m.add_constraint(p1, p2) - actual_constrained_params = self.m.get_constrained_parmeters() - actual_constrained_params = [p.name for p in actual_constrained_params] - expected_constrained_params = [p1.name] - assert actual_constrained_params == expected_constrained_params - - self.m.clearConstraints() - actual_constrained_params = self.m.get_constrained_parmeters() - actual_constrained_params = [p.name for p in actual_constrained_params] - expected_constrained_params = [] - assert actual_constrained_params == expected_constrained_params - - return - - def test_add_restraint(self): - """Test the restrain method.""" - p1 = Parameter("p1", 1) - p2 = Parameter("p2", 2) - p3 = Parameter("p3", 3) - self.m._eqfactory.register_argument("p1", p1) - self.m._eqfactory.register_argument("p2", p2) - - self.assertEqual(0, len(self.m._restraints)) - r = self.m.add_soft_bounds("p1+p2", upper_bound=10) - self.assertEqual(1, len(self.m._restraints)) - p2.set_value(10) - self.assertEqual(1, r.penalty()) - self.m.remove_soft_bounds(r) - self.assertEqual(0, len(self.m._restraints)) - - r = self.m.restrain(p1, ub=10) - self.assertEqual(1, len(self.m._restraints)) - p1.set_value(11) - self.assertEqual(1, r.penalty()) - - # Check errors on unregistered parameters - self.assertRaises(ValueError, self.m.restrain, "2*p3") - self.assertRaises(ValueError, self.m.restrain, "2*p2", ns={"p2": p3}) - return - - def testGetConstraints(self): - """Test the _get_constraints method.""" - m2 = RecipeOrganizer("m2") - self.m._organizers = {} - self.m._manage(self.m._organizers) - self.m._add_object(m2, self.m._organizers) - - p1 = Parameter("p1", 1) - p2 = Parameter("p2", 2) - p3 = Parameter("p3", 3) - p4 = Parameter("p4", 4) - - self.m._add_parameter(p1) - self.m._add_parameter(p2) - - m2._add_parameter(p3) - m2._add_parameter(p4) - - self.m.add_constraint(p1, "p2") - m2.add_constraint(p3, "p4") - - cons = self.m._get_constraints() - self.assertTrue(p1 in cons) - self.assertTrue(p3 in cons) - self.assertEqual(2, len(cons)) - return - - def testGetRestraints(self): - """Test the _get_restraints method.""" - m2 = RecipeOrganizer("m2") - self.m._organizers = {} - self.m._manage(self.m._organizers) - self.m._add_object(m2, self.m._organizers) - - p1 = Parameter("p1", 1) - p2 = Parameter("p2", 2) - p3 = Parameter("p3", 3) - p4 = Parameter("p4", 4) - - self.m._add_parameter(p1) - self.m._add_parameter(p2) - - m2._add_parameter(p3) - m2._add_parameter(p4) - - r1 = self.m.add_soft_bounds("p1 + p2") - r2 = m2.add_soft_bounds("2*p3 + p4") - - res = self.m._get_restraints() - self.assertTrue(r1 in res) - self.assertTrue(r2 in res) - self.assertEqual(2, len(res)) - return - - def test_register_calculator(self): - - class GCalc(Calculator): - - def __init__(self, name): - Calculator.__init__(self, name) - self.new_parameter("A", 1.0) - self.new_parameter("center", 0.0) - self.new_parameter("width", 0.1) - return - - def __call__(self, x): - A = self.A.get_value() - c = self.center.get_value() - w = self.width.get_value() - return A * numpy.exp(-0.5 * ((x - c) / w) ** 2) - - # End class GCalc - - g = GCalc("g") - - self.m.register_calculator(g) - - x = numpy.arange(0.5, 10, 0.5) - self.m.x.set_value(x) - - self.m.g.center.set_value(3.0) - - self.assertTrue( - numpy.array_equal(numpy.exp(-0.5 * ((x - 3.0) / 0.1) ** 2), g(x)) - ) - - self.m.g.center.set_value(5.0) - - self.assertTrue( - numpy.array_equal(numpy.exp(-0.5 * ((x - 5.0) / 0.1) ** 2), g(x)) - ) - - # Use this in another equation - - eq = self.m.register_string_function("g/x - 1", "pdf") - self.assertTrue(numpy.array_equal(g(x) / x - 1, eq())) - - return - - def testRegisterCalculator(self): - - class GCalc(Calculator): - - def __init__(self, name): - Calculator.__init__(self, name) - self.new_parameter("A", 1.0) - self.new_parameter("center", 0.0) - self.new_parameter("width", 0.1) - return - - def __call__(self, x): - A = self.A.get_value() - c = self.center.get_value() - w = self.width.get_value() - return A * numpy.exp(-0.5 * ((x - c) / w) ** 2) - - # End class GCalc - - g = GCalc("g") - - self.m.registerCalculator(g) - - x = numpy.arange(0.5, 10, 0.5) - self.m.x.set_value(x) - - self.m.g.center.set_value(3.0) - - self.assertTrue( - numpy.array_equal(numpy.exp(-0.5 * ((x - 3.0) / 0.1) ** 2), g(x)) - ) - - self.m.g.center.set_value(5.0) - - self.assertTrue( - numpy.array_equal(numpy.exp(-0.5 * ((x - 5.0) / 0.1) ** 2), g(x)) - ) - - # Use this in another equation - - eq = self.m.register_string_function("g/x - 1", "pdf") - self.assertTrue(numpy.array_equal(g(x) / x - 1, eq())) - - return - - def testRegisterFunction(self): - """Test registering various functions.""" - - def g1(A, c, w, x): - return A * numpy.exp(-0.5 * ((x - c) / w) ** 2) - - def g2(A): - return A + 1 - - eq = self.m.register_function(g1, "g") - - for p in eq.args: - self.assertTrue(p in self.m._parameters.values()) - - x = numpy.arange(0.5, 10, 0.5) - self.m.x.set_value(x) - self.m.A.set_value(1.0) - self.m.c.set_value(3.0) - self.m.w.set_value(0.1) - self.assertTrue( - numpy.array_equal(numpy.exp(-0.5 * ((x - 3.0) / 0.1) ** 2), eq()) - ) +# Test building an Equation from a string with the factory's Parameters and +# with Parameters passed in the namespace dictionary. +@pytest.mark.parametrize( + "input_equation_str, input_namespace_names, expected_arg_names, " + "expected_value", + [ + # C1: All Parameters in the string are registered with the factory. + # Expected: The equation uses p1 and p2 and evaluates to 3. + ("p1+p2", [], ["p1", "p2"], 3), + # C2: p3 is not registered with the factory but is passed in the + # namespace dictionary. + # Expected: The equation uses p1, p2 and p3, evaluates to 6, and p3 + # is not left registered with the factory. + ("p1+p2+p3", ["p3"], ["p1", "p2", "p3"], 6), + ], +) +def test_get_equation_from_string( + input_equation_str, + input_namespace_names, + expected_arg_names, + expected_value, +): + factory, parameters = _make_factory_with_parameters() + namespace = {name: parameters[name] for name in input_namespace_names} + equation = get_equation_from_string(input_equation_str, factory, namespace) + actual_args = set(equation.args) + expected_args = {parameters[name] for name in expected_arg_names} + assert actual_args == expected_args + actual_value = equation() + assert actual_value == expected_value + actual_registered_names = {"p1", "p2", "p3"}.intersection(factory.builders) + expected_registered_names = {"p1", "p2"} + assert actual_registered_names == expected_registered_names + + +# Test that building an Equation from a string fails when a Parameter is +# unknown or the namespace dictionary redefines a registered Parameter. +@pytest.mark.parametrize( + "input_equation_str, input_namespace", + [ + # C1: p3 is neither registered nor in the namespace dictionary. + # Expected: Raises a ValueError. + ("p1+p2+p3", {}), + # C2: p3 is in the namespace dictionary, but p4 is neither + # registered nor in the namespace dictionary. + # Expected: Raises a ValueError. + ("p1+p2+p3+p4", {"p3": "p3"}), + # C3: The namespace dictionary redefines the registered p2. + # Expected: Raises a ValueError. + ("p1+p2", {"p2": "p4"}), + ], +) +def test_get_equation_from_string_bad(input_equation_str, input_namespace): + factory, parameters = _make_factory_with_parameters() + namespace = { + name: parameters[parameter_name] + for name, parameter_name in input_namespace.items() + } + with pytest.raises(ValueError): + get_equation_from_string(input_equation_str, factory, namespace) + + +def _make_container_tree(): + """Return the container m1 holding the Parameter p1 and the + container m2, which holds the Parameter p2, along with the unmanaged + Parameter p3.""" + m1 = RecipeContainer("m1") + m1._containers = {} + m1._manage(m1._containers) + p1 = Parameter("p1", 1) + m1._add_object(p1, m1._parameters) + m2 = RecipeContainer("m2") + p2 = Parameter("p2", 2) + m2._add_object(p2, m2._parameters) + m1._add_object(m2, m1._containers) + p3 = Parameter("p3", 3) + return {"m1": m1, "m2": m2, "p1": p1, "p2": p2, "p3": p3} + + +# Test access to the managed objects of the container m1 as attributes. +@pytest.mark.parametrize( + "input_attribute_path, expected_object_name", + [ + # C1: A managed Parameter is accessed as an attribute, m1.p1. + # Expected: Returns the Parameter. + ("p1", "p1"), + # C2: A managed container is accessed as an attribute, m1.m2. + # Expected: Returns the container. + ("m2", "m2"), + # C3: A Parameter of a managed container is accessed through the + # attributes of both containers, m1.m2.p2. + # Expected: Returns the Parameter. + ("m2.p2", "p2"), + ], +) +def test_container_attribute_access( + input_attribute_path, expected_object_name +): + tree = _make_container_tree() + actual_object = attrgetter(input_attribute_path)(tree["m1"]) + expected_object = tree[expected_object_name] + assert actual_object == expected_object - # Use this in another equation - eq2 = self.m.register_string_function("g/x - 1", "pdf") - self.assertTrue(numpy.array_equal(eq() / x - 1, eq2())) - # Make sure we can swap out "g". - self.m.register_function(g2, "g") - self.assertAlmostEqual(2.0, eq()) +# Test access to the Parameters of a container by index. +@pytest.mark.parametrize( + "input_container_name, expected_parameter_name", + [ + # C1: The first Parameter of m1, which also holds a container, is + # accessed as m1[0]. + # Expected: Returns p1. + ("m1", "p1"), + # C2: The first Parameter of m2 is accessed as m2[0]. + # Expected: Returns p2. + ("m2", "p2"), + ], +) +def test_container_index_access(input_container_name, expected_parameter_name): + tree = _make_container_tree() + actual_parameter = tree[input_container_name][0] + expected_parameter = tree[expected_parameter_name] + assert actual_parameter == expected_parameter - # Try a bound method - class temp(object): - def eval(self): - return 1.23 - def __call__(self): - return 4.56 +# Test that slicing and len consider only the Parameters of a container. +@pytest.mark.parametrize( + "input_container_name, expected_parameter_names", + [ + # C1: A container holding one Parameter and one container. + # Expected: Only the Parameter is counted and sliced. + ("m1", ["p1"]), + # C2: A container holding one Parameter only. + # Expected: The Parameter is counted and sliced. + ("m2", ["p2"]), + ], +) +def test_container_slice_and_length( + input_container_name, expected_parameter_names +): + tree = _make_container_tree() + container = tree[input_container_name] + actual_parameters = container[0:] + expected_parameters = [tree[name] for name in expected_parameter_names] + assert actual_parameters == expected_parameters + actual_length = len(container) + expected_length = len(expected_parameter_names) + assert actual_length == expected_length - t = temp() - eq = self.m.register_function(t.eval, "eval") - self.assertAlmostEqual(1.23, eq()) - # Now the callable - eq2 = self.m.registerFunction(t, "eval2") - self.assertAlmostEqual(4.56, eq2()) +# Test finding the path from a container to a managed object. +@pytest.mark.parametrize( + "input_container_name, input_object_name, expected_path_names", + [ + # C1: m1 locates its own Parameter p1. + # Expected: The path is m1, p1. + ("m1", "p1", ["m1", "p1"]), + # C2: m1 locates its own container m2. + # Expected: The path is m1, m2. + ("m1", "m2", ["m1", "m2"]), + # C3: m2 locates its own Parameter p2. + # Expected: The path is m2, p2. + ("m2", "p2", ["m2", "p2"]), + # C4: m1 locates p2, which is held by its container m2. + # Expected: The path is m1, m2, p2. + ("m1", "p2", ["m1", "m2", "p2"]), + # C5: m1 or m2 locates p3, which neither of them manages. + # Expected: The path is empty. + ("m1", "p3", []), + ("m2", "p3", []), + ], +) +def test_locate_managed_object( + input_container_name, input_object_name, expected_path_names +): + tree = _make_container_tree() + container = tree[input_container_name] + actual_path = container._locate_managed_object(tree[input_object_name]) + expected_path = [tree[name] for name in expected_path_names] + assert actual_path == expected_path - return - def test_register_string_function(self): - """Test registering string functions in various ways.""" - # Make an equation. - eq1 = self.m.register_string_function("x**2 + 3", "eq1") - eq1.x.set_value(0) +def _make_organizer(): + """Return an organizer with a managed dictionary of containers.""" + organizer = RecipeOrganizer("test") + organizer._containers = {} + organizer._manage(organizer._containers) + return organizer - for p in eq1.args: - self.assertTrue(p in self.m._parameters.values()) - # Add a parameter - self.m._new_parameter("y", 3.0) +def test_new_parameter(): + # C1: A Parameter is created with a name not used in the organizer. + # Expected: The new Parameter is returned and is managed under its + # name. + organizer = _make_organizer() + new_parameter = organizer._new_parameter("p2", 0) + actual_parameter = organizer.p2 + expected_parameter = new_parameter + assert actual_parameter == expected_parameter - # Make sure that x and y are in the organizer - self.assertEqual(0, self.m.x.get_value()) - self.assertEqual(3.0, self.m.y.get_value()) - # Use eq1 in some equations +def test_new_parameter_bad(): + # C1: A Parameter is created with the name of a managed Parameter. + # Expected: Raises a ValueError. + organizer = _make_organizer() + organizer._add_parameter(Parameter("p1", 1)) + with pytest.raises(ValueError): + organizer._new_parameter("p1", 0) - # x**2 (x**2 + 3 - 3) - eq2 = self.m.register_string_function("eq1 - 3", "eq2") - # y**2 - eq3 = self.m.register_string_function("eq1(y) - 3", "eq3") - # Test these equations. - self.assertEqual(0, eq2()) - self.assertEqual(9.0, eq3()) - # Note that eq3 injects the value of y into the argument of eq1, which - # is x. Thus, calling eq3 sets x to 3. - self.assertEqual(9.0, eq2()) +# Test storing a Parameter and registering it with the equation factory. +@pytest.mark.parametrize( + "input_existing_names, input_check", + [ + # C1: The name of the Parameter is not used in the organizer. + # Expected: The Parameter is managed and registered under its name. + ([], True), + # C2: A Parameter of the same name exists and the check is off. + # Expected: The new Parameter replaces the existing one and stays + # registered under its name. + (["p1"], False), + ], +) +def test_add_parameter(input_existing_names, input_check): + organizer = _make_organizer() + for name in input_existing_names: + organizer._add_parameter(Parameter(name, 1)) + new_parameter = Parameter("p1", 2) + organizer._add_parameter(new_parameter, check=input_check) + actual_parameter = organizer.p1 + expected_parameter = new_parameter + assert actual_parameter == expected_parameter + actual_registered = "p1" in organizer._eqfactory.builders + expected_registered = True + assert actual_registered == expected_registered + + +# Test that a Parameter cannot be stored under a name already in use. +@pytest.mark.parametrize( + "input_name", + [ + # C1: A Parameter of the same name is managed. + # Expected: Raises a ValueError. + "p1", + # C2: A container of the same name is managed. + # Expected: Raises a ValueError. + "test", + ], +) +def test_add_parameter_bad(input_name): + organizer = _make_organizer() + organizer._add_parameter(Parameter("p1", 1)) + organizer._add_object(RecipeContainer("test"), organizer._containers) + with pytest.raises(ValueError): + organizer._add_parameter(Parameter(input_name, 0)) + + +def test_add_parameter_keeps_order(): + # C1: p1 is added, replaced by another p1 with the check off, then xyz + # is added. + # Expected: The replacement keeps the position of p1, and xyz follows. + organizer = _make_organizer() + organizer._add_parameter(Parameter("p1", 1)) + replacement = Parameter("p1", 2) + organizer._add_parameter(replacement, check=False) + xyz = Parameter("xyz", 0) + organizer._add_parameter(xyz) + actual_names = list(organizer._parameters.keys()) + expected_names = ["p1", "xyz"] + assert actual_names == expected_names + actual_parameters = list(organizer._parameters.values()) + expected_parameters = [replacement, xyz] + assert actual_parameters == expected_parameters + + +def test_remove_parameter(): + # C1: A managed Parameter is removed. + # Expected: Its name is no longer registered with the equation factory. + organizer = _make_organizer() + parameter = Parameter("p1", 1) + organizer._add_parameter(parameter) + organizer._remove_parameter(parameter) + actual_registered = "p1" in organizer._eqfactory.builders + expected_registered = False + assert actual_registered == expected_registered + + +# Test that only managed Parameters can be removed. +@pytest.mark.parametrize( + "input_kind", + [ + # C1: A different Parameter with the name of a managed Parameter. + # Expected: Raises a ValueError. + "same_name_parameter", + # C2: A Parameter that has already been removed. + # Expected: Raises a ValueError. + "removed_parameter", + # C3: A container rather than a Parameter. + # Expected: Raises a ValueError. + "container", + ], +) +def test_remove_parameter_bad(input_kind): + organizer = _make_organizer() + parameter = Parameter("p1", 1) + organizer._add_parameter(parameter) + if input_kind == "removed_parameter": + organizer._remove_parameter(parameter) + inputs = { + "same_name_parameter": Parameter("p1", 2), + "removed_parameter": parameter, + "container": RecipeContainer("test"), + } + with pytest.raises(ValueError): + organizer._remove_parameter(inputs[input_kind]) + + +def _get_constraint_state(organizer, parameter): + """Return whether the Parameter and the organizer say the Parameter + is constrained, the names of the constrained Parameters and the + number of constraints.""" + return ( + parameter.constrained, + organizer.is_constrained(parameter), + [ + constrained.name + for constrained in organizer.get_constrained_parmeters() + ], + len(organizer._constraints), + ) + + +def test_constraint_state(): + # Test the constraint state of p1 as a constraint is added and removed. + # Each step changes the organizer left by the step before it. + organizer = _make_organizer() + p1 = organizer._new_parameter("p1", 1) + organizer._new_parameter("p2", 2) + # C1: No constraint is added. + # Expected: p1 is not constrained. + actual_states = [_get_constraint_state(organizer, p1)] + # C2: p1 is constrained to "2*p2". + # Expected: p1 is constrained and is the only constrained Parameter. + organizer.add_constraint(p1, "2*p2") + actual_states.append(_get_constraint_state(organizer, p1)) + # C3: The constraint on p1 is removed. + # Expected: p1 is not constrained. + organizer.remove_constraint(p1) + actual_states.append(_get_constraint_state(organizer, p1)) + # C4: p1 is constrained again, then all constraints are cleared. + # Expected: p1 is not constrained. + organizer.add_constraint(p1, "2*p2") + organizer.clear_all_constraints() + actual_states.append(_get_constraint_state(organizer, p1)) + expected_states = [ + (False, False, [], 0), + (True, True, ["p1"], 1), + (False, False, [], 0), + (False, False, [], 0), + ] + assert actual_states == expected_states - # One more level of embedding - # 2*y**2 - eq4 = self.m.registerStringFunction("2*eq3", "eq4") - self.assertEqual(18.0, eq4()) - return +# Test that a constrained Parameter follows its constraint. +@pytest.mark.parametrize( + "input_constraint_kind, input_p2_value, expected_p1_value", + [ + # C1: p1 is constrained to the equation string "2*p2" and p2 is set + # to 10. + # Expected: p1 becomes 20. + ("equation_string", 10, 20), + # C2: p1 is constrained to the Parameter p2 and p2 is set to 7. + # Expected: p1 becomes 7. + ("parameter", 7, 7), + ], +) +def test_add_constraint( + input_constraint_kind, input_p2_value, expected_p1_value +): + organizer = _make_organizer() + p1 = organizer._new_parameter("p1", 1) + p2 = organizer._new_parameter("p2", 2) + constraints = {"equation_string": "2*p2", "parameter": p2} + organizer.add_constraint(p1, constraints[input_constraint_kind]) + p2.set_value(input_p2_value) + organizer._constraints[p1].update() + actual_p1_value = p1.get_value() + assert actual_p1_value == expected_p1_value + + +# Test that a constraint fails on unknown or redefined Parameters. +@pytest.mark.parametrize( + "input_constraint_eq, input_params_names", + [ + # C1: The constraint uses p3, which is not managed. + # Expected: Raises a ValueError. + ("2*p3", []), + # C2: The params dictionary redefines the managed p2. + # Expected: Raises a ValueError. + ("2*p2", ["p2"]), + ], +) +def test_add_constraint_bad(input_constraint_eq, input_params_names): + organizer = _make_organizer() + p1 = organizer._new_parameter("p1", 1) + organizer._new_parameter("p2", 2) + p3 = Parameter("p3", 3) + params = {name: p3 for name in input_params_names} + with pytest.raises(ValueError): + organizer.add_constraint(p1, input_constraint_eq, params) + + +def _make_organizer_with_registered_arguments(): + """Return an organizer whose equation factory knows p1 = 1 and + p2 = 2.""" + organizer = _make_organizer() + p1 = Parameter("p1", 1) + p2 = Parameter("p2", 2) + organizer._eqfactory.register_argument("p1", p1) + organizer._eqfactory.register_argument("p2", p2) + return organizer, p1, p2 + + +# Test the penalty of a soft bound on an equation string or a Parameter. +@pytest.mark.parametrize( + "input_restrained_kind, input_changed_name, input_changed_value", + [ + # C1: "p1+p2" has an upper bound of 10, and p2 is set to 10. + # Expected: One restraint with a penalty of 1. + ("equation_string", "p2", 10), + # C2: The Parameter p1 has an upper bound of 10, and p1 is set + # to 11. + # Expected: One restraint with a penalty of 1. + ("parameter", "p1", 11), + ], +) +def test_add_soft_bounds( + input_restrained_kind, input_changed_name, input_changed_value +): + organizer, p1, p2 = _make_organizer_with_registered_arguments() + restrained = {"equation_string": "p1+p2", "parameter": p1} + restraint = organizer.add_soft_bounds( + restrained[input_restrained_kind], upper_bound=10 + ) + {"p1": p1, "p2": p2}[input_changed_name].set_value(input_changed_value) + actual_number_of_restraints = len(organizer._restraints) + expected_number_of_restraints = 1 + assert actual_number_of_restraints == expected_number_of_restraints + actual_penalty = restraint.penalty() + expected_penalty = 1 + assert actual_penalty == expected_penalty + + +def test_remove_soft_bounds(): + # C1: A soft bound is added, then removed. + # Expected: The organizer has no restraints. + organizer, _, _ = _make_organizer_with_registered_arguments() + restraint = organizer.add_soft_bounds("p1+p2", upper_bound=10) + organizer.remove_soft_bounds(restraint) + actual_number_of_restraints = len(organizer._restraints) + expected_number_of_restraints = 0 + assert actual_number_of_restraints == expected_number_of_restraints + + +# Test that a soft bound fails on unknown or redefined Parameters. +@pytest.mark.parametrize( + "input_equation_str, input_params_names", + [ + # C1: The equation uses p3, which is not registered. + # Expected: Raises a ValueError. + ("2*p3", []), + # C2: The params dictionary redefines the registered p2. + # Expected: Raises a ValueError. + ("2*p2", ["p2"]), + ], +) +def test_add_soft_bounds_bad(input_equation_str, input_params_names): + organizer, _, _ = _make_organizer_with_registered_arguments() + p3 = Parameter("p3", 3) + params = {name: p3 for name in input_params_names} + with pytest.raises(ValueError): + organizer.add_soft_bounds(input_equation_str, params=params) + + +def _make_nested_organizers(): + """Return an organizer holding p1 and p2 and the organizer m2, which + holds p3 and p4.""" + organizer = _make_organizer() + m2 = RecipeOrganizer("m2") + organizer._organizers = {} + organizer._manage(organizer._organizers) + organizer._add_object(m2, organizer._organizers) + for name, value in (("p1", 1), ("p2", 2)): + organizer._add_parameter(Parameter(name, value)) + for name, value in (("p3", 3), ("p4", 4)): + m2._add_parameter(Parameter(name, value)) + return organizer, m2 + + +def test_get_constraints(): + # C1: p1 is constrained in the organizer and p3 in the organizer m2 + # that it holds. + # Expected: The constraints of both organizers are returned. + organizer, m2 = _make_nested_organizers() + organizer.add_constraint(organizer.p1, "p2") + m2.add_constraint(m2.p3, "p4") + actual_constrained_parameters = set(organizer._get_constraints()) + expected_constrained_parameters = {organizer.p1, m2.p3} + assert actual_constrained_parameters == expected_constrained_parameters + + +def test_get_restraints(): + # C1: A restraint is added to the organizer and another to the + # organizer m2 that it holds. + # Expected: The restraints of both organizers are returned. + organizer, m2 = _make_nested_organizers() + restraint1 = organizer.add_soft_bounds("p1 + p2") + restraint2 = m2.add_soft_bounds("2*p3 + p4") + actual_restraints = organizer._get_restraints() + expected_restraints = {restraint1, restraint2} + assert actual_restraints == expected_restraints + + +class _GaussianCalculator(Calculator): + """Calculator of a Gaussian with Parameters A, center and width.""" + + def __init__(self, name): + Calculator.__init__(self, name) + self.new_parameter("A", 1.0) + self.new_parameter("center", 0.0) + self.new_parameter("width", 0.1) + + def __call__(self, x): + amplitude = self.A.get_value() + center = self.center.get_value() + width = self.width.get_value() + return amplitude * numpy.exp(-0.5 * ((x - center) / width) ** 2) + + +# Test that a registered Calculator is managed and usable in equation +# strings. +@pytest.mark.parametrize( + "input_center", + [ + # C1: The center of the Gaussian is set to 3 through the organizer. + # Expected: The calculator and an equation string that uses it + # give the Gaussian centered at 3. + 3.0, + # C2: The center of the Gaussian is set to 5 through the organizer. + # Expected: The calculator and an equation string that uses it + # give the Gaussian centered at 5. + 5.0, + ], +) +def test_register_calculator(input_center): + organizer = _make_organizer() + calculator = _GaussianCalculator("g") + organizer.register_calculator(calculator) + x = numpy.arange(0.5, 10, 0.5) + organizer.x.set_value(x) + organizer.g.center.set_value(input_center) + actual_values = calculator(x) + expected_values = numpy.exp(-0.5 * ((x - input_center) / 0.1) ** 2) + assert numpy.array_equal(actual_values, expected_values) + equation = organizer.register_string_function("g/x - 1", "pdf") + actual_equation_values = equation() + expected_equation_values = expected_values / x - 1 + assert numpy.array_equal(actual_equation_values, expected_equation_values) - def test_release_old_equations(self): - """Verify EquationFactory does not hold temporary equations.""" - self.m._new_parameter("x", 12) - self.assertEqual(36, self.m.evaluate_equation("3 * x")) - self.assertEqual(0, len(self.m._eqfactory.equations)) - return - def test_releaseOldEquations(self): - """Verify EquationFactory does not hold temporary equations.""" - self.m._new_parameter("x", 12) - self.assertEqual(36, self.m.evaluateEquation("3 * x")) - self.assertEqual(0, len(self.m._eqfactory.equations)) - return +def _gaussian(A, c, w, x): + return A * numpy.exp(-0.5 * ((x - c) / w) ** 2) -def test_show(capturestdout): - """Verify output from the show function.""" - organizer = RecipeOrganizer("test") - # Add a managed container so we can do more in-depth tests. - organizer._containers = {} - organizer._manage(organizer._containers) +def _add_one(A): + return A + 1 + + +def test_register_function(): + # C1: A Python function of A, c, w and x is registered as "g". + # Expected: Its arguments become managed Parameters, the equation + # gives the function's value, and an equation string can use it. + organizer = _make_organizer() + equation = organizer.register_function(_gaussian, "g") + actual_args_are_managed = all( + arg is organizer.get(arg.name) for arg in equation.args + ) + expected_args_are_managed = True + assert actual_args_are_managed == expected_args_are_managed + x = numpy.arange(0.5, 10, 0.5) + organizer.x.set_value(x) + organizer.A.set_value(1.0) + organizer.c.set_value(3.0) + organizer.w.set_value(0.1) + actual_values = equation() + expected_values = numpy.exp(-0.5 * ((x - 3.0) / 0.1) ** 2) + assert numpy.array_equal(actual_values, expected_values) + string_equation = organizer.register_string_function("g/x - 1", "pdf") + actual_string_values = string_equation() + expected_string_values = expected_values / x - 1 + assert numpy.array_equal(actual_string_values, expected_string_values) + + +def test_register_function_replaces_existing(): + # C1: A function is registered as "g", then A + 1 is registered as "g" + # with A = 1. + # Expected: The equation made for the first "g" evaluates to 2. + organizer = _make_organizer() + equation = organizer.register_function(_gaussian, "g") + organizer.A.set_value(1.0) + organizer.register_function(_add_one, "g") + actual_value = equation() + expected_value = 2.0 + assert actual_value == pytest.approx(expected_value) + + +class _ValueHolder: + def eval(self): + return 1.23 + + def __call__(self): + return 4.56 + + +# Test registering callables other than plain functions. +@pytest.mark.parametrize( + "input_callable_kind, expected_value", + [ + # C1: A bound method. + # Expected: The equation gives the method's value. + ("bound_method", 1.23), + # C2: A callable object. + # Expected: The equation gives the object's value. + ("callable_object", 4.56), + ], +) +def test_register_function_callables(input_callable_kind, expected_value): + organizer = _make_organizer() + holder = _ValueHolder() + callables = {"bound_method": holder.eval, "callable_object": holder} + equation = organizer.register_function( + callables[input_callable_kind], input_callable_kind + ) + actual_value = equation() + assert actual_value == pytest.approx(expected_value) - def capture_show(*args, **kwargs): - rv = capturestdout(organizer.show, *args, **kwargs) - return rv - - assert "" == capture_show() - organizer._new_parameter("x", 1) - organizer._new_parameter("y", 2) - out1 = capture_show() - lines1 = out1.strip().split("\n") - assert 4 == len(lines1) - assert "Parameters" in lines1 - assert "Constraints" not in lines1 - assert "Restraints" not in lines1 - organizer._new_parameter("z", 7) - organizer.add_constraint("y", "3 * z") - out2 = capture_show() - lines2 = out2.strip().split("\n") - assert 9 == len(lines2) - assert "Parameters" in lines2 - assert "Constraints" in lines2 - assert "Restraints" not in lines2 - organizer.add_soft_bounds("z", lower_bound=2, upper_bound=3, sig=0.001) - out3 = capture_show() - lines3 = out3.strip().split("\n") - assert 13 == len(lines3) - assert "Parameters" in lines3 - assert "Constraints" in lines3 - assert "Restraints" in lines3 - out4 = capture_show(pattern="x") - lines4 = out4.strip().split("\n") - assert 9 == len(lines4) - out5 = capture_show(pattern="^") - assert out3 == out5 - # check output with another level of hierarchy - organizer._add_object(RecipeOrganizer("foo"), organizer._containers) - organizer.foo._new_parameter("bar", 13) - out6 = capture_show() - assert "foo.bar" in out6 - # filter out foo.bar - out7 = capture_show("^(?!foo).") - assert "foo.bar" not in out7 - assert out3 == out7 - return - - -# ---------------------------------------------------------------------------- # `register_function` extracts the name and the argument names from the code # object of the function it is given. A decorator such as `deprecated` # replaces that code object with the one of its (*args, **kwargs) wrapper, so # a decorated function must be introspected through the wrapper to give the # same result as the undecorated function. - - -def _gaussian(A, c, w, x): - return A * numpy.exp(-0.5 * ((x - c) / w) ** 2) - - @pytest.mark.parametrize( "input_function", [ @@ -695,8 +721,188 @@ def test_register_function_introspects_through_a_decorator(input_function): assert actual_argnames == expected_argnames +def _make_organizer_with_eq1(): + """Return an organizer with eq1 = x**2 + 3 registered, x = 0 and + y = 3.""" + organizer = _make_organizer() + eq1 = organizer.register_string_function("x**2 + 3", "eq1") + eq1.x.set_value(0) + organizer._new_parameter("y", 3.0) + return organizer, eq1 + + +def test_register_string_function_manages_parameters(): + # C1: "x**2 + 3" is registered as eq1 and x is set to 0 through eq1. + # Expected: x is a managed Parameter, so the organizer sees x = 0. + organizer, eq1 = _make_organizer_with_eq1() + actual_args_are_managed = all( + arg is organizer.get(arg.name) for arg in eq1.args + ) + expected_args_are_managed = True + assert actual_args_are_managed == expected_args_are_managed + actual_x_value = organizer.x.get_value() + expected_x_value = 0 + assert actual_x_value == expected_x_value + + +# Test registering string functions that use eq1 = x**2 + 3, with x = 0 and +# y = 3. +@pytest.mark.parametrize( + "input_functions, expected_value", + [ + # C1: eq2 = eq1 - 3 uses eq1 at x = 0. + # Expected: eq2 evaluates to 0. + ([("eq2", "eq1 - 3")], 0), + # C2: eq3 = eq1(y) - 3 passes y = 3 as the argument of eq1. + # Expected: eq3 evaluates to y**2 = 9. + ([("eq3", "eq1(y) - 3")], 9.0), + # C3: eq4 = 2*eq3 embeds eq1 two levels deep. + # Expected: eq4 evaluates to 2*y**2 = 18. + ([("eq3", "eq1(y) - 3"), ("eq4", "2*eq3")], 18.0), + ], +) +def test_register_string_function(input_functions, expected_value): + organizer, _ = _make_organizer_with_eq1() + for name, function_str in input_functions: + equation = organizer.register_string_function(function_str, name) + actual_value = equation() + assert actual_value == expected_value + + +def test_register_string_function_injects_arguments(): + # C1: eq2 = eq1 - 3 and eq3 = eq1(y) - 3 are registered, and eq3 is + # evaluated before eq2. + # Expected: eq3 sets x, the argument of eq1, to y = 3, so eq2 then + # evaluates to 9. + organizer, _ = _make_organizer_with_eq1() + eq2 = organizer.register_string_function("eq1 - 3", "eq2") + eq3 = organizer.register_string_function("eq1(y) - 3", "eq3") + eq3() + actual_value = eq2() + expected_value = 9.0 + assert actual_value == expected_value + + +def test_evaluate_equation(): + # C1: "3 * x" is evaluated with x = 12. + # Expected: Returns 36, and the equation factory does not keep the + # temporary equation. + organizer = _make_organizer() + organizer._new_parameter("x", 12) + actual_value = organizer.evaluate_equation("3 * x") + expected_value = 36 + assert actual_value == expected_value + actual_number_of_equations = len(organizer._eqfactory.equations) + expected_number_of_equations = 0 + assert actual_number_of_equations == expected_number_of_equations + + +_SHOW_STAGES = ["parameters", "constraints", "restraints", "child"] + + +def _make_show_organizer(stage): + """Return an organizer built up to the given stage of _SHOW_STAGES. + + Each stage adds to the previous ones: the Parameters x = 1 and + y = 2, then z = 7 with the constraint y = 3 * z, then a soft bound + on z, then a child organizer foo holding bar = 13. + """ + organizer = _make_organizer() + stages = _SHOW_STAGES[: _SHOW_STAGES.index(stage) + 1] + if "parameters" in stages: + organizer._new_parameter("x", 1) + organizer._new_parameter("y", 2) + if "constraints" in stages: + organizer._new_parameter("z", 7) + organizer.add_constraint("y", "3 * z") + if "restraints" in stages: + organizer.add_soft_bounds("z", lower_bound=2, upper_bound=3, sig=0.001) + if "child" in stages: + organizer._add_object(RecipeOrganizer("foo"), organizer._containers) + organizer.foo._new_parameter("bar", 13) + return organizer + + +def test_show_empty(capturestdout): + # C1: The organizer holds nothing. + # Expected: Nothing is printed. + organizer = _make_organizer() + actual_output = capturestdout(organizer.show) + expected_output = "" + assert actual_output == expected_output + + +# Test which sections show prints as the organizer is built up. +@pytest.mark.parametrize( + "input_stage, expected_line_count, expected_sections", + [ + # C1: The organizer holds Parameters only. + # Expected: 4 lines with a Parameters section only. + ("parameters", 4, {"Parameters"}), + # C2: A constraint is added. + # Expected: 9 lines with Parameters and Constraints sections. + ("constraints", 9, {"Parameters", "Constraints"}), + # C3: A restraint is added. + # Expected: 13 lines with all three sections. + ("restraints", 13, {"Parameters", "Constraints", "Restraints"}), + ], +) +def test_show( + capturestdout, input_stage, expected_line_count, expected_sections +): + organizer = _make_show_organizer(input_stage) + lines = capturestdout(organizer.show).strip().split("\n") + actual_line_count = len(lines) + assert actual_line_count == expected_line_count + actual_sections = { + section + for section in ("Parameters", "Constraints", "Restraints") + if section in lines + } + assert actual_sections == expected_sections + + +def test_show_pattern_filters_parameters(capturestdout): + # C1: The organizer with Parameters, a constraint and a restraint is + # shown with the pattern "x". + # Expected: 9 lines, since only the Parameter x is listed. + organizer = _make_show_organizer("restraints") + lines = capturestdout(organizer.show, pattern="x").strip().split("\n") + actual_line_count = len(lines) + expected_line_count = 9 + assert actual_line_count == expected_line_count + + +def test_show_child_parameters(capturestdout): + # C1: The organizer holds a child organizer foo with the Parameter bar. + # Expected: bar is listed by its full name foo.bar. + organizer = _make_show_organizer("child") + actual_output = capturestdout(organizer.show) + assert "foo.bar" in actual_output + + +# Test patterns that keep every line of the organizer without a child. +@pytest.mark.parametrize( + "input_stage, input_pattern", + [ + # C1: The pattern "^" matches every name. + # Expected: The same output as without a pattern. + ("restraints", "^"), + # C2: The organizer also holds foo.bar, and the pattern excludes + # names starting with foo. + # Expected: The same output as for the organizer without foo. + ("child", "^(?!foo)."), + ], +) +def test_show_pattern(capturestdout, input_stage, input_pattern): + organizer = _make_show_organizer(input_stage) + actual_output = capturestdout(organizer.show, input_pattern) + expected_output = capturestdout(_make_show_organizer("restraints").show) + assert actual_output == expected_output + + def _make_iterpars_tree(): - """Build a small hierarchy for iterPars tests.""" + """Build a small hierarchy for iterate_over_parameters tests.""" root = RecipeContainer("root") root._containers = {} root._manage(root._containers) @@ -735,48 +941,49 @@ def _make_iterpars_tree(): } +# Test leaf-name and fullname matching in iterate_over_parameters. @pytest.mark.parametrize( - ("pattern", "kwargs", "expected_values"), + "input_pattern, input_kwargs, expected_values", [ # C1: Match leaf parameter names without fullnames. - # Expected: all Biso parameters in the hierarchy are returned. + # Expected: All Biso parameters in the hierarchy are returned. (r"^Biso$", {}, [10, 20, 40, 50]), # C2: Match hierarchical names without fullnames. - # Expected: no leaf names match the hierarchical pattern. + # Expected: No leaf names match the hierarchical pattern. (r"^Ni\d+\.Biso$", {}, []), # C3: Match hierarchical names with fullnames enabled. - # Expected: matching Ni Biso parameters are returned. + # Expected: Matching Ni Biso parameters are returned. (r"^Ni\d+\.Biso$", {"fullnames": True}, [20, 40]), # C4: Match one hierarchical Uiso name. - # Expected: only Ni0.Uiso is returned. + # Expected: Only Ni0.Uiso is returned. (r"^Ni0\.Uiso$", {"fullnames": True}, [30]), # C5: Match one hierarchical Biso name outside Ni containers. - # Expected: only O0.Biso is returned. + # Expected: Only O0.Biso is returned. (r"^O0\.Biso$", {"fullnames": True}, [50]), # C6: Disable recursion while matching child fullnames. - # Expected: no child parameters are returned. + # Expected: No child parameters are returned. (r"^Ni\d+\.Biso$", {"fullnames": True, "recurse": False}, []), # C7: Disable recursion while matching root fullname. - # Expected: only the root-level Biso parameter is returned. + # Expected: Only the root-level Biso parameter is returned. (r"^Biso$", {"fullnames": True, "recurse": False}, [10]), ], ) -def test_iterpars_fullname_matching(pattern, kwargs, expected_values): - """Verify leaf-name and fullname matching in - iterate_over_parameters.""" - objs = _make_iterpars_tree() - root = objs["root"] - +def test_iterate_over_parameters_fullname_matching( + input_pattern, input_kwargs, expected_values +): + root = _make_iterpars_tree()["root"] actual_values = [ parameter.value - for parameter in root.iterate_over_parameters(pattern, **kwargs) + for parameter in root.iterate_over_parameters( + input_pattern, **input_kwargs + ) ] - assert actual_values == expected_values +# Test that fullnames are relative to the container that is iterated over. @pytest.mark.parametrize( - ("pattern", "expected_name"), + "input_pattern, expected_names", [ # C1: Match Biso relative to the called Ni0 container. # Expected: Ni0.Biso is returned without the Ni0 prefix. @@ -785,26 +992,19 @@ def test_iterpars_fullname_matching(pattern, kwargs, expected_values): # Expected: Ni0.Uiso is returned without the Ni0 prefix. (r"^Uiso$", ["Uiso"]), # C3: Match with the parent container prefix from inside Ni0. - # Expected: no parameter is returned because fullnames are relative + # Expected: No parameter is returned because fullnames are relative # to the container on which iterate_over_parameters is called. (r"^Ni0\.Biso$", []), ], ) -def test_iterpars_fullnames_are_relative_to_called_container( - pattern, - expected_name, +def test_iterate_over_parameters_fullnames_are_relative_to_called_container( + input_pattern, expected_names ): - """Verify fullname matching is relative to the called container.""" - objs = _make_iterpars_tree() - ni0 = objs["ni0"] - - actual_name = [ + ni0 = _make_iterpars_tree()["ni0"] + actual_names = [ parameter.name - for parameter in ni0.iterate_over_parameters(pattern, fullnames=True) + for parameter in ni0.iterate_over_parameters( + input_pattern, fullnames=True + ) ] - - assert actual_name == expected_name - - -if __name__ == "__main__": - unittest.main() + assert actual_names == expected_names diff --git a/tests/test_restraint.py b/tests/test_restraint.py index 57d5dbc1..defa5c24 100644 --- a/tests/test_restraint.py +++ b/tests/test_restraint.py @@ -12,62 +12,62 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Tests for refinableobj module.""" +"""Tests for the restraint module.""" -import unittest +import numpy as np +import pytest -from diffpy.srfit.equation.builder import EquationFactory -from diffpy.srfit.fitbase.parameter import Parameter from diffpy.srfit.fitbase.recipeorganizer import get_equation_from_string from diffpy.srfit.fitbase.restraint import Restraint -class TestRestraint(unittest.TestCase): - - def testRestraint(self): - """Test the Restraint class.""" - p1 = Parameter("p1", 1) - p2 = Parameter("p2", 2) - - factory = EquationFactory() - - factory.register_argument("p1", p1) - factory.register_argument("p2", p2) - - # Restrain 1 < p1 + p2 < 5 - eq = get_equation_from_string("p1 + p2", factory) - r = Restraint(eq, 1, 5) - - # This should have no penalty - p1.set_value(1) - p2.set_value(1) - self.assertEqual(0, r.penalty()) - - # Make p1 + p2 = 0 - # This should have a penalty of 1*(1 - 0)**2 = 1 - p1.set_value(-1) - p2.set_value(1) - self.assertEqual(1, r.penalty()) - - # Make p1 + p2 = 8 - # This should have a penalty of 1*(8 - 5)**2 = 9 - p1.set_value(4) - p2.set_value(4) - self.assertEqual(9, r.penalty()) - - # Set the chi^2 to get a dynamic penalty - r.scaled = True - self.assertEqual(13.5, r.penalty(1.5)) - - # Make a really large number to check the upper bound - import numpy - - r.upper_bound = numpy.inf - p1.set_value(1e100) - self.assertEqual(0, r.penalty()) - - return - - -if __name__ == "__main__": - unittest.main() +# Test the penalty of the restraint 1 < p1 + p2 < upper_bound, which is the +# squared distance of p1 + p2 outside the bounds, multiplied by the weight +# only when the restraint is scaled. +@pytest.mark.parametrize( + "input_p1, input_p2, input_upper_bound, input_scaled, input_weight, " + "expected_penalty", + [ + # C1: p1 + p2 = 2 lies inside [1, 5]. + # Expected: No penalty. + (1, 1, 5, False, 1.0, 0), + # C2: p1 + p2 = 0 lies below the lower bound 1. + # Expected: A penalty of (1 - 0)**2 = 1. + (-1, 1, 5, False, 1.0, 1), + # C3: p1 + p2 = 8 lies above the upper bound 5. + # Expected: A penalty of (8 - 5)**2 = 9. + (4, 4, 5, False, 1.0, 9), + # C4: p1 + p2 = 8 lies above the upper bound 5, the restraint is + # scaled and the weight is 1.5. + # Expected: A penalty of 1.5 * (8 - 5)**2 = 13.5. + (4, 4, 5, True, 1.5, 13.5), + # C5: p1 + p2 = 8 lies above the upper bound 5, the restraint is not + # scaled and the weight is 1.5. + # Expected: The weight is ignored, a penalty of (8 - 5)**2 = 9. + (4, 4, 5, False, 1.5, 9), + # C6: p1 + p2 is about 1e100 and the upper bound is inf. + # Expected: No penalty. + (1e100, 4, np.inf, False, 1.0, 0), + ], +) +def test_penalty( + make_parameters_with_factory, + input_p1, + input_p2, + input_upper_bound, + input_scaled, + input_weight, + expected_penalty, +): + p1, p2, factory = make_parameters_with_factory() + equation = get_equation_from_string("p1 + p2", factory) + restraint = Restraint( + equation, + lower_bound=1, + upper_bound=input_upper_bound, + scaled=input_scaled, + ) + p1.set_value(input_p1) + p2.set_value(input_p2) + actual_penalty = restraint.penalty(input_weight) + assert actual_penalty == expected_penalty diff --git a/tests/test_simplerecipe.py b/tests/test_simplerecipe.py new file mode 100644 index 00000000..385fbd10 --- /dev/null +++ b/tests/test_simplerecipe.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python +############################################################################## +# +# diffpy.srfit by DANSE Diffraction group +# Simon J. L. Billinge +# (c) 2026 The Trustees of Columbia University +# in the City of New York. All rights reserved. +# +# See AUTHORS.txt for a list of people who contributed. +# See LICENSE_DANSE.txt for license information. +# +############################################################################## +"""Tests for the SimpleRecipe class.""" + +import pytest +from numpy import arange, array_equal, linspace, ones_like + +from diffpy.srfit import ( + FitContribution, + FitResults, + Profile, + ProfileParser, + SimpleRecipe, +) + +XOBS = linspace(0, 1, 5) +YOBS = 2 * XOBS + 1 + + +class _CustomContribution(FitContribution): + pass + + +# A SimpleRecipe is built with its own Profile, FitContribution and +# FitResults, linked together. +@pytest.mark.parametrize( + "input_kwargs, expected_name, expected_contribution_class", + [ + # C1: No arguments are given. + # Expected: The recipe is named "fit" and holds a FitContribution. + ({}, "fit", FitContribution), + # C2: A custom name and contribution class are given. + # Expected: The recipe has that name and holds a contribution of + # that class. + ( + {"name": "custom", "conclass": _CustomContribution}, + "custom", + _CustomContribution, + ), + ], +) +def test_init(input_kwargs, expected_name, expected_contribution_class): + recipe = SimpleRecipe(**input_kwargs) + actual_name = recipe.name + assert actual_name == expected_name + actual_contribution_class = type(recipe.contribution) + assert actual_contribution_class == expected_contribution_class + actual_contribution_name = recipe.contribution.name + expected_contribution_name = "contribution" + assert actual_contribution_name == expected_contribution_name + actual_contributions = [recipe.contribution] + expected_contributions = list(recipe._contributions.values()) + assert actual_contributions == expected_contributions + actual_profile = recipe.contribution.profile + expected_profile = recipe.profile + assert actual_profile == expected_profile + actual_profile_class = type(recipe.profile) + expected_profile_class = Profile + assert actual_profile_class == expected_profile_class + actual_results_class = type(recipe.results) + expected_results_class = FitResults + assert actual_results_class == expected_results_class + actual_results_recipe = recipe.results.recipe + expected_results_recipe = recipe + assert actual_results_recipe == expected_results_recipe + actual_verbose = recipe.fithooks[0].verbose + expected_verbose = 3 + assert actual_verbose == expected_verbose + + +# Public FitContribution methods that SimpleRecipe does not define itself +# are made available on the recipe. +@pytest.mark.parametrize( + "input_method_name, expected_owner_name", + [ + # C1: set_residual_equation is defined only on FitContribution. + # Expected: The recipe method is the contribution's method. + ("set_residual_equation", "contribution"), + # C2: add_profile_generator is defined only on FitContribution. + # Expected: The recipe method is the contribution's method. + ("add_profile_generator", "contribution"), + # C3: set_equation is defined on both classes. + # Expected: The recipe keeps its own method. + ("set_equation", "recipe"), + # C4: residual is defined on both classes. + # Expected: The recipe keeps its own method. + ("residual", "recipe"), + ], +) +def test_contribution_methods_are_adopted( + input_method_name, expected_owner_name +): + recipe = SimpleRecipe() + owners = {"recipe": recipe, "contribution": recipe.contribution} + actual_owner = getattr(recipe, input_method_name).__self__ + expected_owner = owners[expected_owner_name] + assert actual_owner == expected_owner + + +# set_observed_profile stores the observed data in the built-in profile. +@pytest.mark.parametrize( + "input_dyobs, expected_dyobs_is_set, expected_dy", + [ + # C1: Uncertainties are given. + # Expected: dyobs is set and dy is the given uncertainties. + (0.5 * ones_like(XOBS), True, 0.5 * ones_like(XOBS)), + # C2: No uncertainties are given. + # Expected: dyobs stays None and dy is one at every point. + (None, False, ones_like(XOBS)), + ], +) +def test_set_observed_profile(input_dyobs, expected_dyobs_is_set, expected_dy): + recipe = SimpleRecipe() + recipe.set_observed_profile(XOBS, YOBS, input_dyobs) + actual_xobs = recipe.profile.xobs + expected_xobs = XOBS + assert array_equal(actual_xobs, expected_xobs) + actual_yobs = recipe.profile.yobs + expected_yobs = YOBS + assert array_equal(actual_yobs, expected_yobs) + actual_dyobs_is_set = recipe.profile.dyobs is not None + assert actual_dyobs_is_set == expected_dyobs_is_set + actual_dy = recipe.profile.dy + assert array_equal(actual_dy, expected_dy) + + +# set_observed_profile rejects arrays of different lengths. +@pytest.mark.parametrize( + "input_yobs, input_dyobs", + [ + # C1: yobs is shorter than xobs. + # Expected: Raises a ValueError. + (YOBS[:-1], None), + # C2: dyobs is shorter than xobs. + # Expected: Raises a ValueError. + (YOBS, ones_like(XOBS)[:-1]), + ], +) +def test_set_observed_profile_bad(input_yobs, input_dyobs): + recipe = SimpleRecipe() + with pytest.raises(ValueError): + recipe.set_observed_profile(XOBS, input_yobs, input_dyobs) + + +# set_calculation_range and set_calculation_points set the points of the +# built-in profile. The observed points are 0, 0.25, 0.5, 0.75 and 1. +@pytest.mark.parametrize( + "input_method_name, input_kwargs, expected_x", + [ + # C1: The range is narrowed to [0.25, 0.75]. + # Expected: The observed points within that range. + ( + "set_calculation_range", + {"xmin": 0.25, "xmax": 0.75}, + [0.25, 0.5, 0.75], + ), + # C2: The range is set beyond the observed data. + # Expected: The range is clipped to the observed points. + ( + "set_calculation_range", + {"xmin": -1, "xmax": 2}, + [0.0, 0.25, 0.5, 0.75, 1.0], + ), + # C3: The calculation points 0.1, 0.3 and 0.5 are given. + # Expected: Those points are used. + ( + "set_calculation_points", + {"x": arange(0.1, 0.6, 0.2)}, + [0.1, 0.3, 0.5], + ), + ], +) +def test_set_calculation_points(input_method_name, input_kwargs, expected_x): + recipe = SimpleRecipe() + recipe.set_observed_profile(XOBS, YOBS) + getattr(recipe, input_method_name)(**input_kwargs) + actual_x = recipe.profile.x + assert actual_x == pytest.approx(expected_x) + + +# loadtxt reads x, y and optionally dy from a text file into the built-in +# profile. +@pytest.mark.parametrize( + "input_lines, expected_dyobs_is_set, expected_dy", + [ + # C1: The file has the columns x, y and dy. + # Expected: dyobs is set and dy is the third column. + ( + ["1.0 2.0 0.1", "1.1 2.1 0.2", "1.2 2.2 0.3"], + True, + [0.1, 0.2, 0.3], + ), + # C2: The file has only the columns x and y. + # Expected: dyobs stays None and dy is one at every point. + (["1.0 2.0", "1.1 2.1", "1.2 2.2"], False, [1.0, 1.0, 1.0]), + ], +) +def test_loadtxt(tmp_path, input_lines, expected_dyobs_is_set, expected_dy): + data_file = tmp_path / "data.txt" + data_file.write_text("\n".join(input_lines) + "\n") + recipe = SimpleRecipe() + recipe.loadtxt(data_file) + actual_xobs = recipe.profile.xobs.tolist() + expected_xobs = [1.0, 1.1, 1.2] + assert actual_xobs == expected_xobs + actual_yobs = recipe.profile.yobs.tolist() + expected_yobs = [2.0, 2.1, 2.2] + assert actual_yobs == expected_yobs + actual_dyobs_is_set = recipe.profile.dyobs is not None + assert actual_dyobs_is_set == expected_dyobs_is_set + actual_dy = recipe.profile.dy.tolist() + assert actual_dy == expected_dy + + +def test_load_parsed_data(temp_data_files): + # C1: A two-column file is parsed by a ProfileParser and loaded. + # Expected: The profile holds the parsed x and y and the parser's + # metadata. + parser = ProfileParser() + parser.parse_file(temp_data_files / "gr_file.gr") + recipe = SimpleRecipe() + recipe.load_parsed_data(parser) + actual_xobs = recipe.profile.xobs.tolist() + expected_xobs = [1.0, 1.1, 1.2] + assert actual_xobs == expected_xobs + actual_yobs = recipe.profile.yobs.tolist() + expected_yobs = [2.0, 2.1, 2.2] + assert actual_yobs == expected_yobs + actual_metadata = recipe.profile.meta + expected_metadata = parser.get_metadata() + assert actual_metadata == expected_metadata + + +# set_equation sets the contribution equation and makes each of its new +# parameters, other than x, y and dy, a variable of the recipe. +@pytest.mark.parametrize( + "input_equation, expected_variables", + [ + # C1: An equation with the new parameters A and B. + # Expected: A and B become variables with the value 0. + ("A*x + B", {"A": 0, "B": 0}), + # C2: An equation that uses only x and y. + # Expected: No variables are created. + ("2*x + y", {}), + ], +) +def test_set_equation(input_equation, expected_variables): + recipe = SimpleRecipe() + recipe.set_observed_profile(XOBS, YOBS) + recipe.set_equation(input_equation) + actual_variables = dict(zip(recipe.names, recipe.values)) + assert actual_variables == expected_variables + + +def test_set_equation_keeps_existing_variables(linear_recipe): + # C1: The equation of a recipe with the variables A=2 and B=1 is set + # again to one that also uses the new parameter C. + # Expected: A and B keep their values, and C is added with the + # value 0. + linear_recipe.set_equation("A*x + B + C") + actual_variables = dict(zip(linear_recipe.names, linear_recipe.values)) + expected_variables = {"A": 2.0, "B": 1.0, "C": 0} + assert actual_variables == expected_variables + + +def test_call(linear_recipe): + # C1: The recipe for A*x + B with A=2 and B=1 is called. + # Expected: It returns 2*x + 1 on the calculation points and stores + # it in profile.ycalc. + actual_value = linear_recipe() + expected_value = 2 * linear_recipe.profile.x + 1 + assert array_equal(actual_value, expected_value) + actual_ycalc = linear_recipe.profile.ycalc + assert array_equal(actual_ycalc, expected_value) + + +EXPECTED_VARIABLE_LINES = [ + "A 2.00000000e+00 +/- 1.26491106e+00", + "B 1.00000000e+00 +/- 7.74596671e-01", +] + + +def test_print_results(linear_recipe, capsys): + # C1: The results of the recipe with A=2 and B=1 are printed with a + # header and a footer. + # Expected: The output starts with the header, ends with the footer + # and lists A and B with their values and uncertainties. + linear_recipe.print_results(header="HEADER", footer="FOOTER") + output_lines = capsys.readouterr().out.splitlines() + actual_first_line = output_lines[0] + expected_first_line = "HEADER" + assert actual_first_line == expected_first_line + actual_last_line = output_lines[-1] + expected_last_line = "FOOTER" + assert actual_last_line == expected_last_line + actual_variable_lines = sorted( + line for line in output_lines if line.startswith(("A ", "B ")) + ) + expected_variable_lines = EXPECTED_VARIABLE_LINES + assert actual_variable_lines == expected_variable_lines + + +def test_save_results(linear_recipe, tmp_path): + # C1: The results of the recipe with A=2 and B=1 are saved to a file + # with a header and a footer. + # Expected: The header follows the two lines that record when and by + # whom the file was written, the file ends with the footer, and A and B + # are listed with their values and uncertainties. + results_file = tmp_path / "results.res" + linear_recipe.save_results(results_file, header="HEADER", footer="FOOTER") + saved_lines = results_file.read_text().splitlines() + actual_header_line = saved_lines[2] + expected_header_line = "HEADER" + assert actual_header_line == expected_header_line + actual_last_line = saved_lines[-1] + expected_last_line = "FOOTER" + assert actual_last_line == expected_last_line + actual_variable_lines = sorted( + line for line in saved_lines if line.startswith(("A ", "B ")) + ) + expected_variable_lines = EXPECTED_VARIABLE_LINES + assert actual_variable_lines == expected_variable_lines