diff --git a/EasyReflectometryApp/Backends/Mock/Analysis.qml b/EasyReflectometryApp/Backends/Mock/Analysis.qml index d1e835ba..4bb43811 100644 --- a/EasyReflectometryApp/Backends/Mock/Analysis.qml +++ b/EasyReflectometryApp/Backends/Mock/Analysis.qml @@ -39,6 +39,9 @@ QtObject { // Bayesian sampling readonly property bool isBayesianSelected: false + readonly property bool minimizerSupportsInequalities: true + readonly property string inequalityConstraintsWarning: '' + readonly property bool fitInfeasible: false readonly property int bayesianSamples: 10000 readonly property int bayesianBurnIn: 2000 readonly property int bayesianPopulation: 10 diff --git a/EasyReflectometryApp/Backends/Mock/Sample.qml b/EasyReflectometryApp/Backends/Mock/Sample.qml index cbe39e47..a62c13eb 100644 --- a/EasyReflectometryApp/Backends/Mock/Sample.qml +++ b/EasyReflectometryApp/Backends/Mock/Sample.qml @@ -396,6 +396,48 @@ QtObject { } } + // Inequality constraints (BUMPS-only fit penalties) + readonly property int inequalityConstraintsCount: 0 + readonly property var violatedInequalityConstraints: [] + function setInequalityConstraintEnabled(index, enabled) { console.debug(`setInequalityConstraintEnabled ${index} ${enabled}`) } + + // Physics-constraint recipes + property var physicsConstraintRecipes: [ + { id: 'conformal_roughness', assemblyIndex: 1, assemblyName: 'Multi-layer 1', assemblyType: 'Multi-layer', + title: 'Conformal roughness', description: 'Every interface of the assembly shares the roughness of its first layer.', + available: true, active: false, toggleable: true, reason: '', requires: [] }, + { id: 'constant_period', assemblyIndex: 1, assemblyName: 'Multi-layer 1', assemblyType: 'Multi-layer', + title: 'Constant period Λ', description: 'The summed thickness of the layers stays constant.', + available: true, active: true, toggleable: true, reason: '', requires: [] }, + { id: 'equal_apm', assemblyIndex: 2, assemblyName: 'Surfactant', assemblyType: 'Surfactant Layer', + title: 'Equal head/tail area per molecule', description: 'The head layer takes the area per molecule of the tail layer.', + available: true, active: false, toggleable: true, reason: '', requires: [] }, + { id: 'solvent_roughness', assemblyIndex: 2, assemblyName: 'Surfactant', assemblyType: 'Surfactant Layer', + title: 'Solvent roughness follows the surfactant', description: 'The roughness of the layer below follows the tail roughness.', + available: false, active: false, toggleable: false, reason: 'Requires conformal roughness on the surfactant layer.', requires: ['conformal_roughness'] } + ] + function _setRecipeActive(assemblyIndex, recipeId, active) { + var recipes = physicsConstraintRecipes.slice() + for (let i = 0; i < recipes.length; i++) { + if (recipes[i].assemblyIndex !== assemblyIndex) { + continue + } + if (recipes[i].id === recipeId) { + recipes[i] = Object.assign({}, recipes[i], { active: active }) + } else if (!active && recipes[i].requires.indexOf(recipeId) !== -1) { + // Mirror the Py backend's cascade: removing a recipe also + // removes the recipes that require it (e.g. solvent roughness + // needs conformal roughness). + recipes[i] = Object.assign({}, recipes[i], { active: false }) + } + } + physicsConstraintRecipes = recipes + constraintsChanged() + return { success: true, message: '' } + } + function applyPhysicsConstraint(assemblyIndex, recipeId) { return _setRecipeActive(assemblyIndex, recipeId, true) } + function removePhysicsConstraint(assemblyIndex, recipeId) { return _setRecipeActive(assemblyIndex, recipeId, false) } + // Q Range property double q_min: 4. property double q_max: 5. diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index 4ed9171e..5e560982 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -29,6 +29,13 @@ class Analysis(QObject): minimizerChanged = Signal() + # The inequality-constraint notices depend on the selected minimizer *and* + # on which inequality constraints are enabled; this fires for both events + # (minimizerChanged is forwarded in __init__, the sample backend's + # constraintsChanged is forwarded by PyBackend). A dedicated signal keeps + # constraint edits from re-notifying every minimizer-bound property, which + # would reset e.g. the minimizer combo box model on every layer change. + inequalityContextChanged = Signal() calculatorChanged = Signal() # Emitted with the reason when a calculator cannot be selected. calculatorChangeRejected = Signal(str) @@ -61,6 +68,8 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._fitter_thread = None # Connect stopFit signal to slot self.stopFit.connect(self._onStopFit) + # A minimizer switch changes the inequality-constraint notices too. + self.minimizerChanged.connect(self.inequalityContextChanged) # Add support for multiple selected experiments - initialize to empty first to avoid binding loops self._selected_experiment_indices = [] # Initialize selected experiments after construction to avoid binding loops @@ -184,6 +193,25 @@ def fitResults(self) -> dict: def isBayesianSelected(self) -> bool: return self._minimizers_logic.is_bayesian_selected() + # ------------------------------------------------------------------ + # Inequality constraints (BUMPS-only fit penalties) + # ------------------------------------------------------------------ + + @Property(bool, notify=inequalityContextChanged) + def minimizerSupportsInequalities(self) -> bool: + """True when the selected engine (or Bayesian sampling) enforces inequality constraints.""" + return self._minimizers_logic.supports_inequalities() + + @Property(str, notify=inequalityContextChanged) + def inequalityConstraintsWarning(self) -> str: + """Notice shown next to the minimizer / constraints when inequalities are active.""" + return self._fitting_logic.inequality_constraints_warning(self._minimizers_logic) + + @Property(bool, notify=fittingChanged) + def fitInfeasible(self) -> bool: + """Whether the last progress report came from the BUMPS penalty plateau.""" + return self._fitting_logic.fit_infeasible + # ------------------------------------------------------------------ # Bayesian sampling progress properties # ------------------------------------------------------------------ @@ -455,12 +483,15 @@ def _start_threaded_fit(self) -> None: self.fitFailed.emit(self._fitting_logic.fit_error_message) return - # Create and configure worker + # Create and configure worker. The inequality constraints are snapshotted + # here so edits made while the fit runs cannot change what it enforces. + fit_kwargs = {'weights': weights, 'method': method} + fit_kwargs.update(self._constraints_kwargs()) self._fitter_thread = FitterWorker( fitter=fitter, method_name='fit', args=(x_data, y_data), - kwargs={'weights': weights, 'method': method}, + kwargs=fit_kwargs, parent=self, ) self._fitter_thread.finished.connect(self._on_fit_finished) @@ -470,6 +501,15 @@ def _start_threaded_fit(self) -> None: self._fitter_thread.failed.connect(self._fitter_thread.deleteLater) self._fitter_thread.start() + def _constraints_kwargs(self) -> dict: + """``{'constraints_factory': ...}`` for the worker when inequality constraints are active, else ``{}``. + + The factory is built *now* (a snapshot of the enabled constraints) so + that edits made while the fit runs cannot change what it enforces. + """ + factory = self._fitting_logic.snapshot_constraints_factory() + return {'constraints_factory': factory} if factory is not None else {} + def _is_stale_worker_signal(self) -> bool: """Return True when a worker signal comes from a superseded worker. @@ -606,6 +646,7 @@ def _start_threaded_sample(self) -> None: 'thin': self._bayesian_logic.thin, 'population': self._bayesian_logic.population, 'initializer': self._bayesian_logic.initializer, + **self._constraints_kwargs(), }, parent=self, ) diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index 50ba9692..4550cafc 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -32,6 +32,7 @@ def __init__(self, project_lib: ProjectLib): self._fit_iteration = 0 self._fit_interim_chi2 = 0.0 self._fit_interim_reduced_chi2 = 0.0 + self._fit_infeasible = False self._fit_running_message = '' self._fit_preview_parameter_values: dict = {} self._fit_has_preview_update = False @@ -94,6 +95,11 @@ def fit_interim_chi2(self) -> float: def fit_interim_reduced_chi2(self) -> float: return self._fit_interim_reduced_chi2 + @property + def fit_infeasible(self) -> bool: + """True while the optimizer sits on the BUMPS inequality-penalty plateau.""" + return self._fit_infeasible + @property def fit_progress_message(self) -> str: return self._fit_running_message @@ -144,8 +150,15 @@ def on_fit_progress(self, payload: dict) -> None: self._fit_preview_parameter_values = dict(payload.get('parameter_values', {}) or {}) self._fit_has_preview_update = bool(payload.get('refresh_plots', False)) self._fit_has_interim_update = True + # While an inequality constraint is violated BUMPS skips the model and + # reports the 1e12 penalty as chi2 — meaningless, so don't show it. + self._fit_infeasible = bool(payload.get('infeasible', False)) - if self._fit_iteration > 0: + if self._fit_infeasible: + self._fit_running_message = ( + f'Fitting... iter {self._fit_iteration}, outside the inequality constraints' + ) + elif self._fit_iteration > 0: self._fit_running_message = ( f'Fitting... iter {self._fit_iteration}, Chi2 = {self._fit_interim_chi2:.6g}' ) @@ -156,6 +169,7 @@ def clear_fit_progress(self) -> None: self._fit_iteration = 0 self._fit_interim_chi2 = 0.0 self._fit_interim_reduced_chi2 = 0.0 + self._fit_infeasible = False self._fit_running_message = '' self._fit_preview_parameter_values = {} self._fit_has_preview_update = False @@ -236,6 +250,58 @@ def _has_polarized_experiments(self) -> bool: getattr(experiment, 'available_channels', None) is not None for experiment in self._ordered_experiments() ) + # ------------------------------------------------------------------ + # Inequality constraints (BUMPS penalties) + # ------------------------------------------------------------------ + + def inequality_constraints_error(self, minimizers_logic: 'Minimizers') -> str | None: + """Reason a fit must not start because of the project's inequality constraints. + + Returns ``None`` when the fit may proceed: no enabled inequality, or the + selected engine enforces them and the current parameter values satisfy + them (a fit started from an infeasible point would begin on the BUMPS + penalty plateau where only the penalty slope guides the optimizer). + """ + active = [spec for spec in self._project_lib.inequality_constraints if spec.enabled] + if not active: + return None + if not minimizers_logic.supports_inequalities(): + return ( + 'Inequality constraints are only supported by the BUMPS minimizers (and Bayesian sampling). ' + 'Switch the minimizer or remove the inequality constraints.' + ) + violated = self._project_lib.violated_inequality_constraints() + if violated: + names = ', '.join(spec.name or str(spec) for spec in violated) + return ( + f'The current parameter values violate the inequality constraint(s): {names}. ' + 'Adjust the values so every constraint holds before fitting.' + ) + return None + + def inequality_constraints_warning(self, minimizers_logic: 'Minimizers') -> str: + """Non-blocking notice about how inequalities will be enforced.""" + active = [spec for spec in self._project_lib.inequality_constraints if spec.enabled] + if not active: + return '' + if not minimizers_logic.supports_inequalities(): + return 'Inequality constraints are not enforced by the selected minimizer; fits are refused.' + if minimizers_logic.enforces_inequalities_weakly(): + return ( + 'Bumps_lm enforces inequality constraints only weakly (the penalty is spread over the residuals); ' + 'prefer Bumps (amoeba) or Bumps_newton.' + ) + return '' + + def snapshot_constraints_factory(self): + """Build the BUMPS ``constraints_factory`` from the enabled inequality constraints *now*. + + Taken when the fit worker starts so that constraints edited while the + fit runs cannot change what the worker enforces. Returns ``None`` when + no inequality constraint is enabled. + """ + return self._project_lib.build_constraints_factory() + def prepare_threaded_fit(self, minimizers_logic: 'Minimizers') -> tuple: """Prepare data for threaded fitting. @@ -253,6 +319,18 @@ def prepare_threaded_fit(self, minimizers_logic: 'Minimizers') -> tuple: self._show_results_dialog = True return None, None, None, None, None + constraints_error = self.inequality_constraints_error(minimizers_logic) + if constraints_error: + logger.warning('Fit refused: %s', constraints_error) + self._fit_error_message = constraints_error + self._running = False + self._finished = True + self._show_results_dialog = True + return None, None, None, None, None + constraints_warning = self.inequality_constraints_warning(minimizers_logic) + if constraints_warning: + logger.warning(constraints_warning) + # One fit function per dataset. Polarized experiment contains # one per measured spin channel. All are sharing a single model, so # structural parameters stay common and the magnetic params are @@ -381,6 +459,15 @@ def prepare_threaded_sample(self, minimizers_logic: 'Minimizers') -> tuple: self._show_results_dialog = True return None, None + constraints_error = self.inequality_constraints_error(minimizers_logic) + if constraints_error: + logger.warning('Sampling refused: %s', constraints_error) + self._fit_error_message = constraints_error + self._running = False + self._finished = True + self._show_results_dialog = True + return None, None + models = [experiment.model for experiment in experiments] multi_fitter = MultiFitter(*models) diff --git a/EasyReflectometryApp/Backends/Py/logic/minimizers.py b/EasyReflectometryApp/Backends/Py/logic/minimizers.py index fea2b05c..3c2cd8a1 100644 --- a/EasyReflectometryApp/Backends/Py/logic/minimizers.py +++ b/EasyReflectometryApp/Backends/Py/logic/minimizers.py @@ -35,6 +35,26 @@ def minimizer_current_index(self) -> int: def is_bayesian_selected(self) -> bool: return self._list_available_minimizers[self._minimizer_current_index] is None + def _selected_package(self) -> str: + return getattr(self.selected_minimizer_enum(), 'package', '') + + def supports_inequalities(self) -> bool: + """Whether the engine that will actually run the fit can enforce inequality constraints. + + Inequalities are BUMPS penalties: every ``Bumps*`` method and the DREAM + sampler (the Bayesian sentinel resolves to ``Bumps_simplex``) qualify; + LMFit and DFO-LS do not. + """ + if self.is_bayesian_selected(): + return True + return self._selected_package() == 'bumps' + + def enforces_inequalities_weakly(self) -> bool: + """``Bumps_lm`` spreads the penalty over the residuals instead of skipping the model.""" + if self.is_bayesian_selected(): + return False + return self._selected_package() == 'bumps' and getattr(self.selected_minimizer_enum(), 'method', '') == 'lm' + def selected_minimizer_enum(self): """Return the AvailableMinimizers enum for the currently selected minimizer. diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index e3623a78..e98b4630 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -109,6 +109,7 @@ def constraint_context(self) -> list[dict[str, Any]]: 'display_name': parameter['display_name'], 'group': parameter.get('group', ''), 'independent': parameter['independent'], + 'kind': parameter.get('kind', 'parameter'), 'object': parameter['object'], } ) @@ -127,6 +128,7 @@ def constraint_metadata(self) -> list[dict[str, Any]]: 'displayName': entry['display_name'], 'group': entry.get('group', ''), 'independent': entry['independent'], + 'kind': entry.get('kind', 'parameter'), } ) metadata.sort(key=lambda item: item['displayName']) @@ -371,6 +373,7 @@ def _is_per_layer_parameter(param: Parameter) -> bool: alias = _make_alias(prefixed_display_name or parameter.name) param_value = float(parameter.value) + is_derived = _is_derived_parameter(parameter, model) parameter_list.append( { 'name': prefixed_display_name, @@ -383,9 +386,18 @@ def _is_per_layer_parameter(param: Parameter) -> bool: 'max': float(parameter.max), 'min': float(parameter.min), 'units': parameter.unit, - 'fit': parameter.free, + 'fit': False if is_derived else parameter.free, 'independent': parameter.independent, - 'dependency': _get_dependency_expression(parameter, paths), + 'dependency': ( + _DERIVED_DESCRIPTIONS.get(parameter.name, 'derived') + if is_derived + else _get_dependency_expression(parameter, paths) + ), + # Derived "calculation" parameters (e.g. the model's total film + # thickness) are computed from the layers: shown read-only, never + # fitted, but usable as aliases in constraint expressions. + 'kind': 'derived' if is_derived else 'parameter', + 'readOnly': is_derived, 'enabled': parameter.enabled if hasattr(parameter, 'enabled') else True, 'object': parameter, # Direct reference to the Parameter object } @@ -394,6 +406,22 @@ def _is_per_layer_parameter(param: Parameter) -> bool: return parameter_list +_DERIVED_DESCRIPTIONS = { + 'total_thickness': 'Σ film layer thicknesses', +} + + +def _is_derived_parameter(parameter: Parameter, model) -> bool: + """True for the model-owned computed parameters (currently ``Model.total_thickness``).""" + derived = getattr(type(model), 'total_thickness', None) + if derived is None: + return False + try: + return model.total_thickness is parameter + except Exception: # noqa: BLE001 + return False + + def _build_param_object_paths(model) -> dict: """Map each parameter's ``unique_name`` to its object chain ``[model, ..., parameter]``. diff --git a/EasyReflectometryApp/Backends/Py/logic/physics_constraints.py b/EasyReflectometryApp/Backends/Py/logic/physics_constraints.py new file mode 100644 index 00000000..41718031 --- /dev/null +++ b/EasyReflectometryApp/Backends/Py/logic/physics_constraints.py @@ -0,0 +1,460 @@ +# SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +# SPDX-License-Identifier: BSD-3-Clause +# © 2026 Contributors to the EasyReflectometry project + +"""Physics-constraint recipes: one-click groups of parameter dependencies. + +The library already knows how to tie parameters according to physics — +conformal roughness/thickness across an assembly, equal head/tail area per +molecule, bilayer head coupling, solvent roughness following a surfactant — +and, with derived parameters, a constant multilayer period. This module turns +those into a declarative list of *recipes* per assembly of the current model, +so the GUI can render toggles without knowing any library API, and reports +which underlying parameters each active recipe owns so the constraints list +can show one row per recipe instead of N cryptic ties. + +Every recipe is detected from the parameter graph (not from remembered +state), so recipes applied in a script or restored from a project file show +as active too. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any +from typing import Callable +from typing import Optional + +from easyreflectometry import Project as ProjectLib +from easyreflectometry.constraints import clamp_sum_partners +from easyreflectometry.constraints import constrain_to_sum +from easyreflectometry.constraints import is_constrained_to_sum +from easyreflectometry.constraints import restore_sum_partners +from easyreflectometry.constraints import unconstrain +from easyreflectometry.sample import BaseAssembly +from easyreflectometry.sample import Bilayer +from easyreflectometry.sample import GradientLayer +from easyreflectometry.sample import MaterialMixture +from easyreflectometry.sample import MaterialSolvated +from easyreflectometry.sample import Multilayer +from easyreflectometry.sample import RepeatingMultilayer +from easyreflectometry.sample import SurfactantLayer +from easyreflectometry.sample.assemblies.base_assembly import follows_equal +from easyscience.variable import Parameter + +logger = logging.getLogger(__name__) + + +def _follows(follower: Parameter, leader: Parameter) -> bool: + """Exact ``follower = leader`` tie (the conformal / equal-parameter idiom).""" + return follows_equal(follower, leader) + + +def _layers(assembly: BaseAssembly) -> list: + return list(assembly.layers) + + +@dataclass +class Recipe: + id: str + title: str + description: str + applies_to: Callable[[BaseAssembly], bool] + available: Callable[[BaseAssembly, Any], tuple[bool, str]] + active: Callable[[BaseAssembly, Any], bool] + owned: Callable[[BaseAssembly, Any], list[Parameter]] + apply: Optional[Callable[[BaseAssembly, Any], None]] + remove: Optional[Callable[[BaseAssembly, Any], None]] + toggleable: bool = True + requires: tuple[str, ...] = () + + +# --------------------------------------------------------------------------- conformal roughness + + +def _conformal_roughness_available(assembly, ctx): + if isinstance(assembly, GradientLayer): + return True, 'Always on for a gradient layer.' + if len(_layers(assembly)) < 2: + return False, 'Needs an assembly with at least two layers.' + return True, '' + + +def _conformal_roughness_owned(assembly, ctx): + layers = _layers(assembly) + if len(layers) < 2: + return [] + leader = layers[0].roughness + return [layer.roughness for layer in layers[1:] if _follows(layer.roughness, leader)] + + +def _conformal_roughness_active(assembly, ctx): + layers = _layers(assembly) + if len(layers) < 2: + return False + if isinstance(assembly, GradientLayer): + return True + return bool(assembly.conformal_roughness) + + +def _set_conformal_roughness(status): + def _set(assembly, ctx): + # Every assembly persists this itself: SurfactantLayer/Bilayer always + # did, Multilayer/RepeatingMultilayer serialize the flag since the + # `improved_constraints` lib branch. + assembly.conformal_roughness = status + + return _set + + +# --------------------------------------------------------------------------- conformal thickness + + +def _conformal_thickness_available(assembly, ctx): + if isinstance(assembly, GradientLayer): + return True, 'Always on for a gradient layer.' + if len(_layers(assembly)) < 2: + return False, 'Needs an assembly with at least two layers.' + return True, '' + + +def _conformal_thickness_owned(assembly, ctx): + layers = _layers(assembly) + if len(layers) < 2: + return [] + leader = layers[0].thickness + return [layer.thickness for layer in layers[1:] if _follows(layer.thickness, leader)] + + +def _conformal_thickness_active(assembly, ctx): + if isinstance(assembly, GradientLayer): + return True + return bool(assembly.conformal_thickness) + + +def _set_conformal_thickness(status): + def _set(assembly, ctx): + assembly.conformal_thickness = status # persisted by the assembly itself + + return _set + + +# --------------------------------------------------------------------------- surfactant APM + + +def _apm_owned(assembly, ctx): + head = assembly.head_layer.area_per_molecule_parameter + return [head] if _follows(head, assembly.tail_layer.area_per_molecule_parameter) else [] + + +# --------------------------------------------------------------------------- bilayer heads + + +def _heads_owned(assembly, ctx): + owned = [] + front, back = assembly.front_head_layer, assembly.back_head_layer + for name in ('thickness', 'area_per_molecule_parameter'): + follower = getattr(back, name, None) + leader = getattr(front, name, None) + if follower is not None and leader is not None and _follows(follower, leader): + owned.append(follower) + return owned + + +# --------------------------------------------------------------------------- solvent roughness + + +def _solvent_parameter(assembly, ctx) -> Optional[Parameter]: + """Roughness of the layer right after the surfactant (its solvent side).""" + sample = ctx['sample'] + for index, candidate in enumerate(sample): + if candidate is assembly: + if index + 1 < len(sample) and len(_layers(sample[index + 1])): + return _layers(sample[index + 1])[0].roughness + return None + return None + + +def _solvent_roughness_available(assembly, ctx): + if not assembly.conformal_roughness: + return False, 'Requires conformal roughness on the surfactant layer.' + if _solvent_parameter(assembly, ctx) is None: + return False, 'There is no layer below the surfactant to act as solvent.' + return True, '' + + +def _solvent_roughness_owned(assembly, ctx): + solvent = _solvent_parameter(assembly, ctx) + if solvent is not None and _follows(solvent, assembly.tail_layer.roughness): + return [solvent] + return [] + + +def _solvent_roughness_apply(assembly, ctx): + solvent = _solvent_parameter(assembly, ctx) + if solvent is None: + raise ValueError('There is no layer below the surfactant to act as solvent.') + assembly.constrain_solvent_roughness(solvent) + + +def _solvent_roughness_remove(assembly, ctx): + solvent = _solvent_parameter(assembly, ctx) + if solvent is not None: + unconstrain(solvent) + + +# --------------------------------------------------------------------------- constant period + + +def _period_available(assembly, ctx): + if len(_layers(assembly)) < 2: + return False, 'Needs at least two layers whose thicknesses form a period.' + if assembly.conformal_thickness: + return False, 'Not compatible with conformal thickness.' + return True, '' + + +def _period_owned(assembly, ctx): + layers = _layers(assembly) + if len(layers) < 2: + return [] + last = layers[-1].thickness + thicknesses = [layer.thickness for layer in layers] + return [last] if is_constrained_to_sum(last, thicknesses) else [] + + +def _period_apply(assembly, ctx): + layers = _layers(assembly) + thicknesses = [layer.thickness for layer in layers] + constrain_to_sum(thicknesses[-1], thicknesses) # the last layer absorbs the remainder + # On its own the constraint lets a fit push the free layers past the + # period and drive the remainder to a negative thickness; the lib caps + # the free maxima (and, with the project, persists the originals). + clamp_sum_partners(thicknesses[:-1], float(thicknesses[-1].value)) + + +def _period_remove(assembly, ctx): + layers = _layers(assembly) + unconstrain(layers[-1].thickness) + restore_sum_partners([layer.thickness for layer in layers[:-1]]) + + +# --------------------------------------------------------------------------- mixtures (informational) + + +def _mixture_layers(assembly, ctx): + return [layer for layer in _layers(assembly) if isinstance(layer.material, (MaterialMixture, MaterialSolvated))] + + +def _mixture_available(assembly, ctx): + if _mixture_layers(assembly, ctx): + return True, '' + return False, 'No layer of this assembly uses a material mixture or solvated material.' + + +RECIPES: list[Recipe] = [ + Recipe( + id='conformal_roughness', + title='Conformal roughness', + description='Every interface of the assembly shares the roughness of its first layer.', + applies_to=lambda a: isinstance(a, (Multilayer, RepeatingMultilayer, GradientLayer, SurfactantLayer, Bilayer)), + available=_conformal_roughness_available, + active=_conformal_roughness_active, + owned=_conformal_roughness_owned, + apply=_set_conformal_roughness(True), + remove=_set_conformal_roughness(False), + ), + Recipe( + id='conformal_thickness', + title='Conformal thickness', + description='Every layer of the assembly shares the thickness of its first layer.', + applies_to=lambda a: isinstance(a, (Multilayer, RepeatingMultilayer, GradientLayer)) + and not isinstance(a, (SurfactantLayer, Bilayer)), + available=_conformal_thickness_available, + active=_conformal_thickness_active, + owned=_conformal_thickness_owned, + apply=_set_conformal_thickness(True), + remove=_set_conformal_thickness(False), + ), + Recipe( + id='equal_apm', + title='Equal head/tail area per molecule', + description='The head layer takes the area per molecule of the tail layer.', + applies_to=lambda a: isinstance(a, SurfactantLayer), + available=lambda a, c: (True, ''), + active=lambda a, c: bool(a.constrain_area_per_molecule), + owned=_apm_owned, + apply=lambda a, c: setattr(a, 'constrain_area_per_molecule', True), + remove=lambda a, c: setattr(a, 'constrain_area_per_molecule', False), + ), + Recipe( + id='bilayer_heads', + title='Symmetric head groups', + description='The back head layer follows the front head layer thickness and area per molecule.', + applies_to=lambda a: isinstance(a, Bilayer), + available=lambda a, c: (True, ''), + active=lambda a, c: bool(a.constrain_heads), + owned=_heads_owned, + apply=lambda a, c: setattr(a, 'constrain_heads', True), + remove=lambda a, c: setattr(a, 'constrain_heads', False), + ), + Recipe( + id='solvent_roughness', + title='Solvent roughness follows the surfactant', + description='The roughness of the first layer of the assembly below the surfactant follows the tail roughness.', + applies_to=lambda a: isinstance(a, SurfactantLayer), + available=_solvent_roughness_available, + active=lambda a, c: bool(_solvent_roughness_owned(a, c)), + owned=_solvent_roughness_owned, + apply=_solvent_roughness_apply, + remove=_solvent_roughness_remove, + requires=('conformal_roughness',), + ), + Recipe( + id='constant_period', + title='Constant period Λ', + description='The summed thickness of the layers stays constant: the last layer absorbs ' + 'whatever the others change by.', + applies_to=lambda a: isinstance(a, (Multilayer, RepeatingMultilayer)) + and not isinstance(a, (SurfactantLayer, Bilayer, GradientLayer)), + available=_period_available, + active=lambda a, c: bool(_period_owned(a, c)), + owned=_period_owned, + apply=_period_apply, + remove=_period_remove, + ), + Recipe( + id='mixture_fractions', + title='Mixture fractions sum to 1', + description='Material mixtures and solvated materials keep their fractions normalised internally.', + applies_to=lambda a: True, + available=_mixture_available, + active=lambda a, c: bool(_mixture_layers(a, c)), + owned=lambda a, c: [], + apply=None, + remove=None, + toggleable=False, + ), +] + +RECIPES_BY_ID = {recipe.id: recipe for recipe in RECIPES} + + +class PhysicsConstraints: + def __init__(self, project_lib: ProjectLib): + self._project_lib = project_lib + + # ----- helpers ----- + + @property + def _sample(self): + models = self._project_lib.models + if not len(models): + return None + return models[self._project_lib.current_model_index].sample + + def _context(self): + return {'sample': self._sample} + + def _assembly(self, index: int) -> BaseAssembly: + sample = self._sample + if sample is None or not 0 <= index < len(sample): + raise IndexError(f'No assembly at index {index}.') + return sample[index] + + # ----- API ----- + + def recipes(self) -> list[dict[str, Any]]: + """Declarative recipe list for every assembly of the current model.""" + sample = self._sample + if sample is None: + return [] + ctx = self._context() + rows = [] + for assembly_index, assembly in enumerate(sample): + for recipe in RECIPES: + if not recipe.applies_to(assembly): + continue + try: + available, reason = recipe.available(assembly, ctx) + # Report the graph truth even for unavailable recipes: a tie + # left behind by a script must not be hidden as "inactive". + active = bool(recipe.active(assembly, ctx)) + except Exception as error: # noqa: BLE001 - a broken model must not hide the panel + logger.debug('Recipe %s unavailable for %s: %s', recipe.id, assembly.name, error) + available, reason, active = False, str(error), False + toggleable = recipe.toggleable and not isinstance(assembly, GradientLayer) + rows.append( + { + 'id': recipe.id, + 'assemblyIndex': assembly_index, + 'assemblyName': assembly.name, + 'assemblyType': assembly.type, + 'title': recipe.title, + 'description': recipe.description, + 'available': bool(available), + 'active': bool(active), + 'toggleable': bool(toggleable and available), + 'reason': reason, + 'requires': list(recipe.requires), + } + ) + return rows + + def apply(self, assembly_index: int, recipe_id: str) -> bool: + recipe = RECIPES_BY_ID[recipe_id] + assembly = self._assembly(assembly_index) + ctx = self._context() + if recipe.apply is None: + return False + available, reason = recipe.available(assembly, ctx) + if not available: + raise ValueError(reason or f"'{recipe.title}' is not available for {assembly.name}.") + if recipe.active(assembly, ctx): + return False + recipe.apply(assembly, ctx) + return True + + def remove(self, assembly_index: int, recipe_id: str) -> bool: + recipe = RECIPES_BY_ID[recipe_id] + assembly = self._assembly(assembly_index) + ctx = self._context() + if recipe.remove is None or isinstance(assembly, GradientLayer): + return False + if not recipe.active(assembly, ctx): + return False + # Dependents first: e.g. solvent roughness needs conformal roughness. + for other in RECIPES: + if recipe.id in other.requires and other.applies_to(assembly) and other.active(assembly, ctx): + other.remove(assembly, ctx) + recipe.remove(assembly, ctx) + return True + + def owned_parameters(self) -> dict[str, dict[str, Any]]: + """``unique_name -> group info`` for every parameter an active recipe owns in the current model.""" + sample = self._sample + if sample is None: + return {} + ctx = self._context() + owned: dict[str, dict[str, Any]] = {} + for assembly_index, assembly in enumerate(sample): + for recipe in RECIPES: + if not recipe.applies_to(assembly): + continue + try: + parameters = recipe.owned(assembly, ctx) + except Exception: # noqa: BLE001 + continue + for parameter in parameters: + owned.setdefault( + parameter.unique_name, + { + 'recipeId': recipe.id, + 'title': recipe.title, + 'assemblyIndex': assembly_index, + 'assemblyName': assembly.name, + 'count': len(parameters), + }, + ) + return owned diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index bcb73518..7ab19ff9 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -244,6 +244,9 @@ def _connect_sample_page(self) -> None: self._sample.modelsTableChanged.connect(self._analysis.experimentsChanged) # Connect sample changes to multi-experiment selection signal self._sample.modelsTableChanged.connect(self.multiExperimentSelectionChanged) + # Adding/removing/toggling an inequality constraint changes the + # engine-support notices shown on the Analysis and Sample pages. + self._sample.constraintsChanged.connect(self._analysis.inequalityContextChanged) def _connect_experiment_page(self) -> None: self._experiment.externalExperimentChanged.connect(self._relay_experiment_page_experiment_changed) diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 581f6496..5eb8d90a 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -9,9 +9,13 @@ import numpy as np from asteval import Interpreter from easyreflectometry import Project as ProjectLib +from easyreflectometry.inequality_constraints import InequalitySpec +from easyreflectometry.inequality_constraints import check_units +from easyreflectometry.inequality_constraints import evaluate_spec from easyscience.variable.descriptor_number import DescriptorNumber from PySide6.QtCore import Property from PySide6.QtCore import QObject +from PySide6.QtCore import QTimer from PySide6.QtCore import Signal from PySide6.QtCore import Slot @@ -21,6 +25,7 @@ from .logic.material import Material as MaterialLogic from .logic.models import Models as ModelsLogic from .logic.parameters import Parameters as ParametersLogic +from .logic.physics_constraints import PhysicsConstraints as PhysicsConstraintsLogic from .logic.project import Project as ProjectLogic logger = logging.getLogger(__name__) @@ -103,14 +108,30 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._chached_layers = None self._constraint_states: Dict[str, dict[str, Any]] = {} + # Child of self (not QTimer.singleShot) so a pending notification can + # never outlive this backend or keep it - and the project - alive. + self._constraints_notify_timer = QTimer(self) + self._constraints_notify_timer.setSingleShot(True) + self._constraints_notify_timer.setInterval(0) + self._constraints_notify_timer.timeout.connect(self.constraintsChanged) + self._physics_constraints_logic = PhysicsConstraintsLogic(project_lib) self.connect_logic() def connect_logic(self) -> None: self.assembliesIndexChanged.connect(self.layersConnectChanges) + # Inequality rows carry a "satisfied" flag evaluated at the current + # values, so parameter edits must refresh the constraints list too. + # `constraintsList` is expensive (asteval of every expression, recipe + # ownership across all assemblies), so bursts of layersChange within one + # event-loop turn are coalesced into a single constraintsChanged. + self.layersChange.connect(self._scheduleConstraintsChanged) # The magnetism table lists the current assembly's layers. self.assembliesIndexChanged.connect(self.magnetismChanged) + def _scheduleConstraintsChanged(self) -> None: + self._constraints_notify_timer.start() + # # # # Materials # # # @@ -751,6 +772,23 @@ def _extract_dependency_map( used_aliases[alias] = parameter return used_aliases + @staticmethod + def _make_interpreter(aliases: Dict[str, DescriptorNumber], numeric: bool = False) -> Interpreter: + """A sandboxed interpreter with the globals and parameter aliases in scope. + + With `numeric`, aliases resolve to plain float values instead of the + unit-carrying parameter objects. + """ + interpreter = Interpreter(config=_ASTEVAL_CONFIG) + for name, value in _GLOBAL_SYMBOLS.items(): + interpreter.symtable[name] = value + if isinstance(value, numbers.Number): + interpreter.readonly_symbols.add(name) + for alias, dependency in aliases.items(): + interpreter.symtable[alias] = float(dependency.value) if numeric else dependency + interpreter.readonly_symbols.add(alias) + return interpreter + def _evaluate_constraint_expression( self, expression: str, @@ -758,20 +796,10 @@ def _evaluate_constraint_expression( all_aliases: Dict[str, DescriptorNumber] | None = None, ) -> DescriptorNumber | numbers.Number: """Evaluate constraint expression with all available parameter aliases in scope.""" - interpreter = Interpreter(config=_ASTEVAL_CONFIG) - - # Add global symbols (numpy, etc.) - for name, value in _GLOBAL_SYMBOLS.items(): - interpreter.symtable[name] = value - if isinstance(value, numbers.Number): - interpreter.readonly_symbols.add(name) - # Add ALL parameter aliases to the symbol table (not just dependencies) # This allows validation to work even if we haven't detected the parameter yet aliases_to_add = all_aliases if all_aliases is not None else dependency_map - for alias, dependency in aliases_to_add.items(): - interpreter.symtable[alias] = dependency - interpreter.readonly_symbols.add(alias) + interpreter = self._make_interpreter(aliases_to_add) try: result = interpreter.eval(expression, raise_errors=True) @@ -783,6 +811,22 @@ def _evaluate_constraint_expression( raise return result + def _evaluate_constraint_expression_numeric( + self, + expression: str, + all_aliases: Dict[str, DescriptorNumber], + ) -> numbers.Number: + """Evaluate a constraint expression over plain parameter *values*. + + Fallback for inequality expressions mixing literals with parameters + ('90 - t_b'), which the unit-carrying evaluation cannot represent. + """ + interpreter = self._make_interpreter(all_aliases, numeric=True) + result = interpreter.eval(expression, raise_errors=True) + if not isinstance(result, numbers.Number): + raise TypeError('Expression must evaluate to a numeric value.') + return result + @staticmethod def _to_float(value: DescriptorNumber | numbers.Number) -> float: if isinstance(value, DescriptorNumber): @@ -862,7 +906,25 @@ def _prepare_constraint_instruction( except SyntaxError as error: raise SyntaxError(str(error).split('\n')[-1]) from None except Exception as error: - raise RuntimeError(str(error)) from None + # Inequality expressions may mix numeric literals with parameters + # ('90 - t_b'), which the unit-carrying arithmetic rejects. They are + # evaluated numerically during the fit anyway (literals read in the + # unit of the dependent parameter), so always retry any inequality + # that references parameters with plain values — the numeric path is + # strictly more permissive and `check_units` still verifies the + # resulting spec. Equality constraints go through + # `make_dependent_on`, which needs the unit-carrying form — no + # fallback there. + if relation == '=' or not dependency_map: + raise RuntimeError(str(error)) from None + try: + evaluation_result = self._evaluate_constraint_expression_numeric( + expression_text, all_aliases=alias_lookup + ) + except Exception: + # Report the original, unit-aware error: it names the actual + # conflict instead of the fallback's symptom. + raise RuntimeError(str(error)) from None pretty_expression = self._pretty_expression(expression_text, display_lookup) @@ -886,7 +948,15 @@ def _prepare_constraint_instruction( } if dependency_map: - raise ValueError('Inequality constraints cannot reference other parameters.') + # Cross-parameter inequality: a fit penalty (BUMPS engines only), + # not a bound on the parameter itself. + return self._prepare_inequality_instruction( + dependent_entry=independent_entries[dependent_index], + relation=relation, + expression_text=expression_text, + dependency_map=dependency_map, + pretty_expression=pretty_expression, + ) numeric_value = self._to_float(evaluation_result) mode = 'lower_bound' if relation == '>' else 'upper_bound' @@ -897,6 +967,97 @@ def _prepare_constraint_instruction( 'relation': relation, } + # The GUI's '>' / '<' relations read as ≥ / ≤. + _INEQUALITY_OPS = {'>': '>=', '<': '<='} + + def _prepare_inequality_instruction( + self, + dependent_entry: dict[str, Any], + relation: str, + expression_text: str, + dependency_map: Dict[str, DescriptorNumber], + pretty_expression: str, + ) -> dict[str, Any]: + dependent = dependent_entry['object'] + lhs_alias = dependent_entry.get('alias') or 'lhs' + if dependent in dependency_map.values(): + raise ValueError('The expression cannot reference the constrained parameter itself.') + lhs_path = self._project_lib.parameter_path(dependent) + if lhs_path is None: + raise ValueError('The dependent parameter cannot be addressed in the project.') + rhs_paths: Dict[str, str] = {} + for alias, parameter in dependency_map.items(): + path = self._project_lib.parameter_path(parameter) + if path is None: + raise ValueError(f"Parameter '{alias}' cannot be addressed in the project.") + rhs_paths[alias] = path + spec = InequalitySpec( + lhs_expression=lhs_alias, + op=self._INEQUALITY_OPS[relation], + rhs_expression=expression_text, + lhs_paths={lhs_alias: lhs_path}, + rhs_paths=rhs_paths, + name=f"{dependent_entry.get('display_name', lhs_alias)} {self._INEQUALITY_OPS[relation]} {pretty_expression}", + ) + check_units(spec, self._project_lib.resolve_parameter_path) + evaluation = evaluate_spec(spec, self._project_lib.resolve_parameter_path) + return { + 'mode': 'inequality', + 'expression': expression_text, + 'dependency_map': dependency_map, + 'pretty_expression': pretty_expression, + 'relation': relation, + 'spec': spec, + 'satisfied': evaluation.satisfied, + 'warning': ( + '' + if evaluation.satisfied + else ( + f'Current values violate this constraint ({self._format_numeric(evaluation.lhs)} vs ' + f'{self._format_numeric(evaluation.rhs)}); fits will not start until they do. ' + 'Inequality constraints are enforced by the BUMPS minimizers only.' + ) + ), + } + + def _inequality_constraint_rows(self, display_lookup: Dict[str, str]) -> list[dict[str, Any]]: + """Rows of `constraintsList` describing the project's inequality constraints.""" + rows: list[dict[str, Any]] = [] + context = self._parameters_logic.constraint_context() + display_by_object = {id(entry['object']): entry['display_name'] for entry in context} + relation_text = {'<=': '≤', '<': '<', '>=': '≥', '>': '>'} + for index, spec in enumerate(self._project_lib.inequality_constraints): + alias_display: Dict[str, str] = {} + for alias, path in spec.paths.items(): + try: + parameter = self._project_lib.resolve_parameter_path(path) + alias_display[alias] = display_by_object.get(id(parameter), display_lookup.get(alias, alias)) + except KeyError: + # The parameter behind this alias no longer exists (e.g. its + # layer was removed); keep the alias so the user can tell + # which term broke, but label it clearly. + alias_display[alias] = f'{alias} (missing)' + lhs_display = self._pretty_expression(spec.lhs_expression, alias_display) + rhs_display = self._pretty_expression(spec.rhs_expression, alias_display) + try: + satisfied = evaluate_spec(spec, self._project_lib.resolve_parameter_path).satisfied + except Exception: # noqa: BLE001 - unresolved path: shown, flagged, never crashes the list + satisfied = False + rows.append( + { + 'dependentName': lhs_display, + 'uniqueName': f'inequality:{index}', + 'inequalityIndex': index, + 'expression': rhs_display, + 'rawExpression': str(spec), + 'relation': relation_text.get(spec.op, spec.op), + 'type': 'inequality', + 'enabled': bool(spec.enabled), + 'satisfied': bool(satisfied), + } + ) + return rows + @staticmethod def _ensure_parameter_independent(parameter: DescriptorNumber) -> None: try: @@ -1029,9 +1190,38 @@ def constraintsList(self) -> list[dict[str, str]]: """Get the list of active constraints with display metadata.""" constraints: list[dict[str, str]] = [] context, _, display_lookup = self._build_constraint_context() + owned = self._physics_constraints_logic.owned_parameters() + recipe_rows: dict[tuple, dict[str, Any]] = {} for entry in context: parameter_obj = entry['object'] + if entry.get('kind') == 'derived': + # Model-owned calculations (total thickness) are not user constraints. + continue + group = owned.get(getattr(parameter_obj, 'unique_name', None)) + if group is not None: + # One row per active physics recipe instead of N cryptic ties. + key = (group['recipeId'], group['assemblyIndex']) + if key not in recipe_rows: + recipe_rows[key] = { + 'dependentName': group['assemblyName'], + 'uniqueName': f"recipe:{group['recipeId']}:{group['assemblyIndex']}", + 'recipeId': group['recipeId'], + 'assemblyIndex': group['assemblyIndex'], + 'expression': group['title'], + 'rawExpression': f"{group['title']} ({group['count']} tied parameter(s))", + 'relation': '', + 'type': 'recipe', + 'enabled': True, + # Recipe ties are identities and hold by construction; + # this is not a feasibility check like the inequality + # rows' flag — do not repurpose it as one. + 'satisfied': True, + 'count': group['count'], + 'members': [], + } + recipe_rows[key]['members'].append(entry['display_name']) + continue state = self._resolve_constraint_state(parameter_obj, display_lookup) if state is None: continue @@ -1060,11 +1250,76 @@ def constraintsList(self) -> list[dict[str, str]]: 'rawExpression': raw_expression, 'relation': relation, 'type': mode, + 'enabled': True, + 'satisfied': True, } ) + constraints.extend(recipe_rows.values()) + constraints.extend(self._inequality_constraint_rows(display_lookup)) return constraints + # ----- physics-constraint recipes ----- + + @Property('QVariantList', notify=constraintsChanged) + def physicsConstraintRecipes(self) -> list[dict[str, Any]]: + """Declarative recipe list (per assembly of the current model) for the GUI.""" + try: + return self._physics_constraints_logic.recipes() + except Exception: # noqa: BLE001 + logger.exception('Failed to build the physics-constraint recipes') + return [] + + @Slot(int, str, result='QVariant') + def applyPhysicsConstraint(self, assembly_index: int, recipe_id: str): + try: + changed = self._physics_constraints_logic.apply(int(assembly_index), recipe_id) + except Exception as error: # noqa: BLE001 + return {'success': False, 'message': str(error)} + if changed: + self._emit_constraints_changed() + return {'success': True, 'message': ''} + + @Slot(int, str, result='QVariant') + def removePhysicsConstraint(self, assembly_index: int, recipe_id: str): + try: + changed = self._physics_constraints_logic.remove(int(assembly_index), recipe_id) + except Exception as error: # noqa: BLE001 + return {'success': False, 'message': str(error)} + if changed: + self._emit_constraints_changed() + return {'success': True, 'message': ''} + + def _emit_constraints_changed(self) -> None: + """Constraints move parameter values, so the curves and tables change too. + + `layersChange` already forwards (coalesced) to `constraintsChanged`, so + it is not emitted here as well — that would evaluate the expensive + constraints list twice per change. + """ + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + self.layersChange.emit() + + @Property(int, notify=constraintsChanged) + def inequalityConstraintsCount(self) -> int: + return len([spec for spec in self._project_lib.inequality_constraints if spec.enabled]) + + @Property('QVariantList', notify=constraintsChanged) + def violatedInequalityConstraints(self) -> list[str]: + """Names of enabled inequality constraints the current values violate.""" + try: + return [spec.name or str(spec) for spec in self._project_lib.violated_inequality_constraints()] + except Exception: # noqa: BLE001 + return [] + + @Slot(int, bool) + def setInequalityConstraintEnabled(self, index: int, enabled: bool) -> None: + specs = self._project_lib.inequality_constraints + if 0 <= index < len(specs): + specs[index].enabled = bool(enabled) + self.constraintsChanged.emit() + @Slot(int) def removeConstraintByIndex(self, index: int) -> None: """Remove constraint by index by making the parameter independent.""" @@ -1078,6 +1333,19 @@ def removeConstraintByIndex(self, index: int) -> None: if index >= len(constraints_list): return + row = constraints_list[index] + if row.get('type') == 'inequality': + self._project_lib.remove_inequality_constraint(int(row['inequalityIndex'])) + self.constraintsChanged.emit() + return + if row.get('type') == 'recipe': + result = self.removePhysicsConstraint(int(row['assemblyIndex']), str(row['recipeId'])) + if isinstance(result, dict) and not result.get('success', True): + logger.warning( + 'Removing physics constraint %s failed: %s', row.get('recipeId'), result.get('message', '') + ) + return + # Resolve by unique_name (parameter identity), not display name, so two # parameters sharing a display name don't collide (issue #328). unique_name = constraints_list[index].get('uniqueName') @@ -1094,11 +1362,7 @@ def removeConstraintByIndex(self, index: int) -> None: self._restore_parameter_state(param_obj, state['previous']) else: self._make_parameter_independent(param_obj) - self.constraintsChanged.emit() - # Constraints move parameter values, so the curves change too. - self.externalRefreshPlot.emit() - self.externalSampleChanged.emit() - self.layersChange.emit() + self._emit_constraints_changed() def _find_parameter_object_by_unique_name(self, unique_name: str): """Find a parameter object by its unique_name (stable identity).""" @@ -1127,6 +1391,7 @@ def validateConstraintExpression(self, dependent_index: int, relation: str, expr 'preview': instruction.get('pretty_expression', ''), 'relation': instruction.get('relation', '='), 'type': instruction.get('mode', ''), + 'warning': instruction.get('warning', ''), } @Slot(int, str, str, result='QVariant') @@ -1136,12 +1401,26 @@ def addConstraint(self, dependent_index: int, relation: str, expression: str): except Exception as error: # noqa: BLE001 return {'success': False, 'message': str(error)} + mode = instruction['mode'] + if mode == 'inequality': + try: + self._project_lib.add_inequality_constraint(instruction['spec'], validate=False) + except Exception as error: # noqa: BLE001 + return {'success': False, 'message': str(error)} + self.constraintsChanged.emit() + return { + 'success': True, + 'message': '', + 'preview': instruction.get('pretty_expression', ''), + 'relation': instruction.get('relation', '='), + 'type': mode, + 'warning': instruction.get('warning', ''), + } + dependent = self._get_independent_parameter_entries()[dependent_index]['object'] previous_state = self._capture_parameter_state(dependent) self._ensure_parameter_independent(dependent) - mode = instruction['mode'] - try: if mode == 'dynamic': dependent.make_dependent_on( @@ -1191,11 +1470,7 @@ def addConstraint(self, dependent_index: int, relation: str, expression: str): ) self._constraint_states[unique_name] = state - self.constraintsChanged.emit() - # Constraints move parameter values, so the curves change too. - self.externalRefreshPlot.emit() - self.externalSampleChanged.emit() - self.layersChange.emit() + self._emit_constraints_changed() return { 'success': True, @@ -1288,11 +1563,7 @@ def constrainModelsParameters(self, model_indices: list) -> None: continue if constraints_added > 0: - self.constraintsChanged.emit() - # Constraints move parameter values, so the curves change too. - self.externalRefreshPlot.emit() - self.externalSampleChanged.emit() - self.layersChange.emit() + self._emit_constraints_changed() def _build_model_parameters_map(self, model) -> Dict[str, DescriptorNumber]: """Build a map of relative parameter paths to parameter objects for a model. diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index f5dd831f..45434b53 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -264,6 +264,16 @@ QtObject { function sampleRemoveConstraintByIndex(value) { activeBackend.sample.removeConstraintByIndex(value) } function sampleConstrainModelsParameters(modelIndices) { activeBackend.sample.constrainModelsParameters(modelIndices) } + // Inequality constraints (BUMPS-only fit penalties) and physics-constraint recipes + readonly property int sampleInequalityConstraintsCount: activeBackend.sample.inequalityConstraintsCount + readonly property var sampleViolatedInequalityConstraints: activeBackend.sample.violatedInequalityConstraints + readonly property var samplePhysicsConstraintRecipes: activeBackend.sample.physicsConstraintRecipes + // Note: the backends also expose setInequalityConstraintEnabled(index, enabled) + // (disable-without-delete); wrap it here once the constraints table grows a + // per-row enable control. + function sampleApplyPhysicsConstraint(assemblyIndex, recipeId) { return activeBackend.sample.applyPhysicsConstraint(assemblyIndex, recipeId) } + function sampleRemovePhysicsConstraint(assemblyIndex, recipeId) { return activeBackend.sample.removePhysicsConstraint(assemblyIndex, recipeId) } + // Q range readonly property var sampleQMin: activeBackend.sample.q_min function sampleSetQMin(value) { activeBackend.sample.setQMin(value) } @@ -399,6 +409,9 @@ QtObject { // Bayesian sampling readonly property bool analysisIsBayesianSelected: activeBackend.analysis.isBayesianSelected + readonly property bool analysisMinimizerSupportsInequalities: activeBackend.analysis.minimizerSupportsInequalities + readonly property string analysisInequalityConstraintsWarning: activeBackend.analysis.inequalityConstraintsWarning + readonly property bool analysisFitInfeasible: activeBackend.analysis.fitInfeasible readonly property int bayesianSamples: activeBackend.analysis.bayesianSamples readonly property int bayesianBurnIn: activeBackend.analysis.bayesianBurnIn diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml index 613e2086..48dd728b 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml @@ -19,6 +19,17 @@ EaElements.GroupBox { width: parent.width spacing: 0 + // Inequality constraints are BUMPS penalties: tell the user when the + // selected engine cannot (or only weakly can) enforce the ones defined. + EaElements.Label { + width: EaStyle.Sizes.sideBarContentWidth + visible: Globals.BackendWrapper.sampleInequalityConstraintsCount > 0 && + Globals.BackendWrapper.analysisInequalityConstraintsWarning.length > 0 + text: qsTr("⚠ %1").arg(Globals.BackendWrapper.analysisInequalityConstraintsWarning) + wrapMode: Text.Wrap + color: EaStyle.Colors.themeAccent + } + EaElements.GroupRow{ EaElements.ComboBox { width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 2 diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml index 96717998..c7e22cb9 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml @@ -295,11 +295,22 @@ EaElements.GroupBox { EaComponents.TableViewLabel { width: EaStyle.Sizes.fontPixelSize * 5 - text: Globals.BackendWrapper.analysisFitableParameters[index].name + // Derived (computed, read-only) parameters carry an ƒ badge; the + // tooltip explains what they are computed from. + readonly property bool derived: Globals.BackendWrapper.analysisFitableParameters[index].kind === 'derived' + text: (derived ? 'ƒ ' : '') + Globals.BackendWrapper.analysisFitableParameters[index].name + textFormat: Text.PlainText color: (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? Globals.BackendWrapper.analysisFitableParameters[index].independent : true) ? EaStyle.Colors.themeForeground : EaStyle.Colors.themeForegroundDisabled - ToolTip.text: textFormat === Text.PlainText ? text : '' + // The embedded TableViewLabel tooltip only appears while the + // text is elided, so a hover on a truncated name (e.g. + // 'Ni on Si L... roughness') reveals the full name. + ToolTip.text: derived + ? qsTr("%1 — derived, read-only: %2") + .arg(text) + .arg(Globals.BackendWrapper.analysisFitableParameters[index].dependency || '') + : text } EaComponents.TableViewParameter { @@ -342,10 +353,18 @@ EaElements.GroupBox { color: EaStyle.Colors.themeForegroundDisabled } + // A derived row is computed from other parameters and never fitted: + // the core recomputes its bounds by interval arithmetic over the + // operands, so they are an envelope, not limits anyone set or that + // anything enforces. Showing them next to editable bounds only + // invites reading them as physical limits, so leave the cells empty. EaComponents.TableViewParameter { - enabled: Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? - Globals.BackendWrapper.analysisFitableParameters[index].independent : true - text: EaLogic.Utils.toDefaultPrecision(Globals.BackendWrapper.analysisFitableParameters[index].min).replace('Infinity', 'inf') + readonly property bool derived: Globals.BackendWrapper.analysisFitableParameters[index].kind === 'derived' + enabled: !derived && + (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? + Globals.BackendWrapper.analysisFitableParameters[index].independent : true) + text: derived ? '' : + EaLogic.Utils.toDefaultPrecision(Globals.BackendWrapper.analysisFitableParameters[index].min).replace('Infinity', 'inf') onEditingFinished: { focus = false console.debug("*** Editing 'min' field of fittable on Analysis page ***") @@ -355,9 +374,12 @@ EaElements.GroupBox { } EaComponents.TableViewParameter { - enabled: Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? - Globals.BackendWrapper.analysisFitableParameters[index].independent : true - text: EaLogic.Utils.toDefaultPrecision(Globals.BackendWrapper.analysisFitableParameters[index].max).replace('Infinity', 'inf') + readonly property bool derived: Globals.BackendWrapper.analysisFitableParameters[index].kind === 'derived' + enabled: !derived && + (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? + Globals.BackendWrapper.analysisFitableParameters[index].independent : true) + text: derived ? '' : + EaLogic.Utils.toDefaultPrecision(Globals.BackendWrapper.analysisFitableParameters[index].max).replace('Infinity', 'inf') onEditingFinished: { focus = false console.debug("*** Editing 'max' field of fittable on Analysis page ***") diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml index 0bbd5f51..a4cbb554 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml @@ -30,11 +30,25 @@ EaElements.GroupBox { Component.onCompleted: Globals.References.pages.analysis.sidebar.basic.popups.startFittingButton = this } + // Inequality constraints that the selected engine cannot enforce, or that + // the current values violate: the fit will be refused, say so up front. + EaElements.Label { + visible: Globals.BackendWrapper.sampleInequalityConstraintsCount > 0 && + (!Globals.BackendWrapper.analysisMinimizerSupportsInequalities || + Globals.BackendWrapper.sampleViolatedInequalityConstraints.length > 0) + width: parent.width + text: !Globals.BackendWrapper.analysisMinimizerSupportsInequalities + ? qsTr("⚠ Inequality constraints need a BUMPS minimizer.") + : qsTr("⚠ Current values violate an inequality constraint.") + color: EaStyle.Colors.themeAccent + wrapMode: Text.WordWrap + } + // Progress message shown during fitting or sampling EaElements.Label { visible: Globals.BackendWrapper.analysisFitProgressMessage !== '' text: Globals.BackendWrapper.analysisFitProgressMessage - color: EaStyle.Colors.themeForegroundMinor + color: Globals.BackendWrapper.analysisFitInfeasible ? EaStyle.Colors.themeAccent : EaStyle.Colors.themeForegroundMinor wrapMode: Text.WordWrap } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml index 4586aed9..ec5a2049 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml @@ -14,10 +14,33 @@ EaElements.GroupBox { property bool expressionValid: false property string validationMessage: "" + property string validationWarning: "" property string expressionPreview: "" property string lastConstraintType: "" property bool validationDirty: false + // Alias of the model's derived total film thickness (empty when absent). + readonly property string totalThicknessAlias: { + const params = Globals.BackendWrapper.sampleConstraintParametersMetadata || [] + for (let i = 0; i < params.length; i++) { + if (params[i].kind === 'derived' && params[i].alias && params[i].alias.indexOf('total_thickness') !== -1) { + return params[i].alias + } + } + return "" + } + + function typeTag(type) { + switch (type) { + case 'inequality': return qsTr("≤ ≥") + case 'recipe': return qsTr("physics") + case 'lower_bound': + case 'upper_bound': return qsTr("bound") + case 'static': return qsTr("value") + default: return qsTr("expr") + } + } + function currentRelationValue() { if (relationalOperator.currentIndex === -1 || typeof relationalOperator.currentValue === 'undefined') { return '=' @@ -27,6 +50,7 @@ EaElements.GroupBox { function resetValidation() { validationMessage = "" + validationWarning = "" expressionPreview = "" lastConstraintType = "" expressionValid = false @@ -68,11 +92,13 @@ EaElements.GroupBox { if (result && result.valid) { expressionValid = true validationMessage = "" + validationWarning = result.warning || "" expressionPreview = result.preview || expr lastConstraintType = result.type || 'expression' } else { expressionValid = false expressionPreview = "" + validationWarning = "" lastConstraintType = "" validationMessage = result && result.message ? result.message : qsTr("Expression is not valid.") // Debug: show available parameters when validation fails @@ -132,11 +158,35 @@ EaElements.GroupBox { EaElements.Label { width: parent.width - text: qsTr("Create numeric or symbolic relationships between parameters.") + text: qsTr("Create numeric or symbolic relationships between parameters. '=' ties the parameter to the expression; '≤' / '≥' against other parameters become inequality constraints enforced by the BUMPS minimizers during fitting.") wrapMode: Text.Wrap color: EaStyle.Colors.themeForegroundMinor } + // Engine badge: inequalities exist but the selected minimizer cannot enforce them + EaElements.Label { + id: engineBadge + width: parent.width + visible: Globals.BackendWrapper.sampleInequalityConstraintsCount > 0 && + (!Globals.BackendWrapper.analysisMinimizerSupportsInequalities || + Globals.BackendWrapper.analysisInequalityConstraintsWarning.length > 0) + text: !Globals.BackendWrapper.analysisMinimizerSupportsInequalities + ? qsTr("⚠ Inequality constraints need a BUMPS minimizer (Analysis › Minimization method). Fits are refused until you switch the minimizer or remove them.") + : Globals.BackendWrapper.analysisInequalityConstraintsWarning + wrapMode: Text.Wrap + color: EaStyle.Colors.themeAccent + } + + // Start-point feasibility: fits are refused while any inequality is violated + EaElements.Label { + width: parent.width + visible: Globals.BackendWrapper.sampleViolatedInequalityConstraints.length > 0 + text: qsTr("⚠ Current values violate: %1. Fits will not start until they hold.").arg( + Globals.BackendWrapper.sampleViolatedInequalityConstraints.join('; ')) + wrapMode: Text.Wrap + color: EaStyle.Colors.themeAccent + } + Row { id: parameterRow spacing: EaStyle.Sizes.fontPixelSize * 0.5 @@ -156,7 +206,13 @@ EaElements.GroupBox { width: EaStyle.Sizes.fontPixelSize * 4 valueRole: "value" textRole: "text" - displayText: currentIndex === -1 ? qsTr("=") : currentText + displayText: { + if (currentIndex === -1) { + return qsTr("=") + } + const entry = model && model[currentIndex] + return entry && entry.text ? entry.text : qsTr("=") + } model: Globals.BackendWrapper.sampleRelationOperators onCurrentIndexChanged: constraintsGroup.scheduleValidation() Component.onCompleted: { @@ -164,6 +220,22 @@ EaElements.GroupBox { currentIndex = 0 } } + + // The backend model is a QVariantList of {value, text} maps. The + // default EasyApp ComboBox delegate resolves the text via JS-array + // indexing or per-key roles, and a Python QVariantList supports + // neither — every row would render empty (TypeError on 'split'). + // Index the model directly instead, like parameterInsert below. + delegate: EaElements.MenuItem { + width: relationalOperator.width + height: EaStyle.Sizes.comboBoxHeight + text: { + const entry = relationalOperator.model[index] + return entry && entry.text ? entry.text : '' + } + highlighted: relationalOperator.highlightedIndex === index + hoverEnabled: relationalOperator.hoverEnabled + } } } @@ -214,15 +286,44 @@ EaElements.GroupBox { } } + // Quick action: insert the model's derived total film thickness alias + EaElements.SideBarButton { + visible: constraintsGroup.totalThicknessAlias.length > 0 + wide: true + fontIcon: "layer-group" + text: qsTr("Insert total film thickness") + ToolTip.text: qsTr("Read-only sum of all layer thicknesses between superphase and subphase; usable in '=' and inequality expressions.") + onClicked: constraintsGroup.insertAlias(constraintsGroup.totalThicknessAlias) + } + EaElements.Label { id: previewLabel width: parent.width visible: constraintsGroup.expressionValid && constraintsGroup.expressionPreview.length > 0 - text: qsTr("Preview: %1 %2").arg(constraintsGroup.currentRelationValue()).arg(constraintsGroup.expressionPreview) + text: { + const relation = constraintsGroup.currentRelationValue() + const shown = relation === '>' ? '≥' : (relation === '<' ? '≤' : relation) + const base = qsTr("Preview: %1 %2").arg(shown).arg(constraintsGroup.expressionPreview) + if (constraintsGroup.lastConstraintType !== 'inequality') { + return base + } + // e.g. '90 - t_B': the 90 is read in the dependent's unit (Å vs nm footgun) + const literalHint = /(^|[^\w.])\d/.test(expressionEditor.text) + ? qsTr("; numeric literals read in the dependent parameter's unit") : "" + return base + qsTr(" (inequality — enforced by BUMPS minimizers%1)").arg(literalHint) + } color: EaStyle.Colors.themeForegroundMinor wrapMode: Text.Wrap } + EaElements.Label { + width: parent.width + visible: constraintsGroup.expressionValid && constraintsGroup.validationWarning.length > 0 + text: constraintsGroup.validationWarning + color: EaStyle.Colors.themeAccent + wrapMode: Text.Wrap + } + EaElements.Label { id: validationLabel width: parent.width @@ -290,15 +391,21 @@ EaElements.GroupBox { text: qsTr("No.") } + EaComponents.TableViewLabel { + width: EaStyle.Sizes.fontPixelSize * 4 + horizontalAlignment: Text.AlignHCenter + text: qsTr("Type") + } + EaComponents.TableViewLabel { id: dependentNameHeaderColumn - width: EaStyle.Sizes.fontPixelSize * 12 + width: EaStyle.Sizes.fontPixelSize * 10 horizontalAlignment: Text.AlignHCenter text: qsTr("Parameter") } EaComponents.TableViewLabel { - width: EaStyle.Sizes.fontPixelSize * 19 + width: EaStyle.Sizes.fontPixelSize * 17 horizontalAlignment: Text.AlignHCenter text: qsTr("Expression") } @@ -319,9 +426,45 @@ EaElements.GroupBox { color: EaStyle.Colors.themeForegroundMinor } + EaComponents.TableViewLabel { + id: typeColumn + width: EaStyle.Sizes.fontPixelSize * 4 + horizontalAlignment: Text.AlignHCenter + text: { + const constraint = Globals.BackendWrapper.sampleConstraintsList[index] + return constraint ? constraintsGroup.typeTag(constraint.type) : "" + } + color: { + const constraint = Globals.BackendWrapper.sampleConstraintsList[index] + if (constraint && constraint.type === 'inequality' && constraint.satisfied === false) { + return EaStyle.Colors.themeAccent + } + return EaStyle.Colors.themeForegroundMinor + } + ToolTip.visible: hovered && Globals.BackendWrapper.sampleConstraintsList[index] !== undefined + ToolTip.text: { + const constraint = Globals.BackendWrapper.sampleConstraintsList[index] + if (!constraint) return "" + if (constraint.type === 'inequality') { + return constraint.satisfied === false + ? qsTr("Inequality constraint — currently violated") + : qsTr("Inequality constraint (BUMPS penalty)") + } + if (constraint.type === 'recipe') { + // A manual tie matching a recipe's pattern is grouped + // under the recipe row; listing the members shows + // exactly which parameters this row stands for. + const members = constraint.members && constraint.members.length + ? "\n" + constraint.members.join(", ") : "" + return qsTr("Physics constraint: %1 tied parameter(s)").arg(constraint.count || 0) + members + } + return qsTr("Equality constraint") + } + } + EaComponents.TableViewLabel { id: dependentNameColumn - width: EaStyle.Sizes.fontPixelSize * 12 + width: EaStyle.Sizes.fontPixelSize * 10 horizontalAlignment: Text.AlignLeft text: { const constraint = Globals.BackendWrapper.sampleConstraintsList[index] @@ -332,7 +475,7 @@ EaElements.GroupBox { EaComponents.TableViewLabel { id: expressionColumn - width: EaStyle.Sizes.fontPixelSize * 15 + width: EaStyle.Sizes.fontPixelSize * 13 horizontalAlignment: Text.AlignLeft text: { const constraint = Globals.BackendWrapper.sampleConstraintsList[index] @@ -342,6 +485,11 @@ EaElements.GroupBox { const prefix = constraint.relation ? constraint.relation + ' ' : '' return prefix + constraint.expression } + color: { + const constraint = Globals.BackendWrapper.sampleConstraintsList[index] + return (constraint && constraint.type === 'inequality' && constraint.satisfied === false) + ? EaStyle.Colors.themeAccent : EaStyle.Colors.themeForeground + } elide: Text.ElideRight ToolTip.visible: hovered && Globals.BackendWrapper.sampleConstraintsList[index] && Globals.BackendWrapper.sampleConstraintsList[index].rawExpression ToolTip.text: Globals.BackendWrapper.sampleConstraintsList[index] ? Globals.BackendWrapper.sampleConstraintsList[index].rawExpression : "" diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PhysicsConstraints.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PhysicsConstraints.qml new file mode 100644 index 00000000..0cbfc6e0 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PhysicsConstraints.qml @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Globals as EaGlobals +import EasyApplication.Gui.Elements as EaElements + +import Gui.Globals as Globals + +// One-click physics constraints ("recipes") per assembly of the current model. +// The list is declarative and comes from the backend: which recipes apply to +// which assembly, whether they are available right now (and why not), whether +// they are active, and whether they can be toggled. Applying a recipe creates a +// named group of parameter ties that shows as a single row in the constraints +// table below. +EaElements.GroupBox { + id: physicsGroup + title: qsTr("Physics constraints") + collapsible: true + collapsed: true + last: false + + property string lastMessage: "" + + readonly property var recipes: Globals.BackendWrapper.samplePhysicsConstraintRecipes || [] + + // Assemblies that have at least one recipe row, in sample order. + readonly property var assemblyNames: { + const seen = [] + const names = [] + for (let i = 0; i < recipes.length; i++) { + const idx = recipes[i].assemblyIndex + if (seen.indexOf(idx) === -1) { + seen.push(idx) + names.push({ index: idx, name: recipes[i].assemblyName, type: recipes[i].assemblyType }) + } + } + return names + } + + function recipesFor(assemblyIndex) { + const rows = [] + for (let i = 0; i < recipes.length; i++) { + if (recipes[i].assemblyIndex === assemblyIndex) { + rows.push(recipes[i]) + } + } + return rows + } + + function toggle(recipe, checked) { + if (!recipe || !recipe.toggleable) { + return + } + const result = checked + ? Globals.BackendWrapper.sampleApplyPhysicsConstraint(recipe.assemblyIndex, recipe.id) + : Globals.BackendWrapper.sampleRemovePhysicsConstraint(recipe.assemblyIndex, recipe.id) + physicsGroup.lastMessage = (result && !result.success && result.message) ? result.message : "" + } + + Column { + width: parent ? parent.width : undefined + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.Label { + width: parent.width + wrapMode: Text.Wrap + color: EaStyle.Colors.themeForegroundMinor + text: qsTr("Apply physically motivated constraints to an assembly with one click. Each active recipe appears as one row in the constraints table.") + } + + EaElements.Label { + width: parent.width + visible: physicsGroup.recipes.length === 0 + wrapMode: Text.Wrap + color: EaStyle.Colors.themeForegroundMinor + text: qsTr("No assemblies in the current model.") + } + + Repeater { + model: physicsGroup.assemblyNames + + Column { + id: assemblyBlock + width: parent.width + spacing: 0 + + readonly property var assembly: modelData + readonly property var assemblyRecipes: physicsGroup.recipesFor(modelData.index) + + EaElements.Label { + width: parent.width + text: assemblyBlock.assembly.name + " · " + assemblyBlock.assembly.type + elide: Text.ElideRight + font.bold: true + } + + Repeater { + model: assemblyBlock.assemblyRecipes + + Row { + id: recipeRow + width: parent.width + spacing: EaStyle.Sizes.fontPixelSize * 0.25 + readonly property var recipe: modelData + + EaElements.CheckBox { + id: recipeBox + width: parent.width - infoLabel.width - recipeRow.spacing + text: recipeRow.recipe.title + enabled: recipeRow.recipe.toggleable + // Bind to the backend state; user clicks go through toggle() and the + // backend re-emits the recipe list, which re-evaluates this binding. + checked: recipeRow.recipe.active + onToggled: physicsGroup.toggle(recipeRow.recipe, checked) + // Only set the attached text: EaElements.CheckBox shows it via its + // embedded styled tooltip. Setting ToolTip.visible as well would pop + // up a second, unstyled tooltip on top of it. + ToolTip.text: recipeRow.recipe.description + + (recipeRow.recipe.reason ? "\n" + recipeRow.recipe.reason : "") + } + + EaElements.Label { + id: infoLabel + anchors.verticalCenter: parent.verticalCenter + width: implicitWidth + color: EaStyle.Colors.themeForegroundMinor + text: { + const r = recipeRow.recipe + if (!r.available) return qsTr("n/a") + if (!r.toggleable && r.active) return qsTr("always on") + return "" + } + + // Plain labels have no embedded tooltip, so use the styled + // EaElements one explicitly to match the checkbox tooltip. + HoverHandler { + id: infoHover + } + EaElements.ToolTip { + text: recipeRow.recipe.reason ? recipeRow.recipe.reason : "" + visible: text !== "" && + infoLabel.text !== "" && + infoHover.hovered && + EaGlobals.Vars.showToolTips + } + } + } + } + } + } + + EaElements.Label { + width: parent.width + visible: physicsGroup.lastMessage.length > 0 + wrapMode: Text.Wrap + color: EaStyle.Colors.themeAccent + text: physicsGroup.lastMessage + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir index bfa12ec7..0d00a926 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir @@ -3,5 +3,6 @@ module Groups CalculationEngine 1.0 CalculationEngine.qml Constraints 1.0 Constraints.qml ModelConstraints 1.0 ModelConstraints.qml +PhysicsConstraints 1.0 PhysicsConstraints.qml PlotControl 1.0 PlotControl.qml -QRange 1.0 QRange.qml \ No newline at end of file +QRange 1.0 QRange.qml diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml index e4673a81..ef880fa5 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml @@ -18,6 +18,9 @@ EaComponents.SideBarColumn { collapsed: true enabled: Globals.BackendWrapper.analysisIsFitFinished } + Groups.PhysicsConstraints{ + enabled: Globals.BackendWrapper.analysisIsFitFinished + } Groups.Constraints{ enabled: Globals.BackendWrapper.analysisIsFitFinished } diff --git a/examples/datasets/README.md b/examples/datasets/README.md new file mode 100644 index 00000000..ef81201a --- /dev/null +++ b/examples/datasets/README.md @@ -0,0 +1,128 @@ +# Demo datasets for the constraints functionality + +Four simulated neutron reflectometry datasets (ORSO `.ort`, 4 % noise, 5 % dQ/Q +resolution) for demonstrating the constraint features in the application: +inequality constraints, the derived total film thickness, and the physics-constraint +recipes. Every file was simulated from a **known structure** — the ground truth is +recorded in each file's header (`sample.description`) and in the tables below — so +each demo has a right answer to compare against. + +Regenerate with `python examples/datasets/generate_datasets.py` (reproducible seeds). + +The files are **self-describing**: each header also carries the sample structure in +the ORSO model language, so you have two ways in — + +- **Sample › Load a sample › Load sample from file** builds the layer stack for you + (superphase / one "Loaded layer" assembly with the film layers / subphase), with + the true thicknesses, roughnesses and SLDs as starting values. Change the starting + values before fitting so there is something to find. +- **Experiment › Import experiment data** loads the reflectivity curve (works with a + hand-built sample too). + +For dataset 3 the loaded stack arrives flattened (8 × [Ti | Ni] becomes 16 layers in +one assembly); rebuild it as a `RepeatingMultilayer` by hand for the constant-period +recipe demo. For dataset 4 the loaded stack is the slab-equivalent of the surfactant; +replace it with a Surfactant Layer assembly for the recipe demo. + +--- + +## 1. `two_layer_film.ort` — thickness budget + derived total thickness + +| layer | material (SLD / 10⁻⁶ Å⁻²) | truth | +|---|---|---| +| superphase | air (0.0) | ∞ | +| Film A | MatA (3.0) | **35 Å**, roughness 3 Å | +| Film B | MatB (5.0) | **55 Å**, roughness 3 Å | +| subphase | Si (2.07) | ∞, roughness 2 Å | + +The total film thickness is **exactly 90 Å** — think of it as known from +ellipsometry or a QCM measurement. + +**Demo script** +1. Load the sample from the file (or build it by hand); set both thicknesses to a + deliberately wrong 50 / 50 (fit only the two thicknesses, fix everything else; + scale 1, background 1e-7). +2. *Sample › Advanced › Single constraints*: note the read-only **total_thickness** + parameter (ƒ badge in the Analysis table) and the "Insert total film thickness" + button in the expression editor. +3. Add two inequality constraints: + - ordering: dependent `Film A thickness`, relation **≤**, expression + `model_film_b_thickness`; + - budget (`t_A + t_B ≤ 90` rearranged for the editor): dependent + `Film A thickness`, relation **≤**, expression `90 - model_film_b_thickness` + (the literal `90` is read in Å, the unit of the dependent parameter). +4. The feasibility warning appears while 50 + 50 > 90 — the fit refuses to start. + Set the thicknesses to 30 / 50 to make the start point feasible. +5. Select a **Bumps** minimizer (with LMFit the warning badge shows and the fit is + refused) and fit: the result lands on the 90 Å boundary at the true 35 / 55 split. + +Verified through the app backend: starting from 30 / 50 under both constraints, the +BUMPS fit returns t_A = 35.0, t_B = 55.0 (sum 90.0). + +## 2. `swapped_layers.ort` — layer-ordering inequality + +| layer | material (SLD) | truth | +|---|---|---| +| superphase | air (0.0) | ∞ | +| Top | TopMat (2.5) | **20 Å**, roughness 3 Å | +| Bottom | BottomMat (4.2) | **60 Å**, roughness 3 Å | +| subphase | Si (2.07) | ∞, roughness 2 Å | + +**Demo script**: start the fit from the *swapped* guess (60 / 20). Without +constraints the optimizer can wander into an unphysical local minimum; with +`Top thickness ≤ Bottom thickness` the feasibility check first makes you swap the +start values back, and the fit then converges to 20 / 60. Good for showing that +inequalities encode prior knowledge ("the capping layer is thin"). + +## 3. `ni_ti_multilayer.ort` — physics recipes on a repeating multilayer + +| layer | material (SLD) | truth | +|---|---|---| +| superphase | air (0.0) | ∞ | +| [Ti / Ni] × 8 | Ti (−1.95), Ni (9.41) | Ti **30 Å**, Ni **70 Å**, period **Λ = 100 Å**, conformal roughness 4 Å | +| subphase | Si (2.07) | ∞, roughness 4 Å | + +The first-order Bragg peak at q ≈ 2π/Λ ≈ 0.063 Å⁻¹ pins the period. + +**Demo script**: build a `RepeatingMultilayer` (2 layers, 8 repetitions). In +*Sample › Advanced › Physics constraints* toggle **Constant period Λ** (the Ni +thickness becomes dependent and absorbs whatever Ti changes by — one grouped row in +the constraints table) and **Conformal roughness**. Fit only the Ti thickness and +the roughness: the period stays at its set 100 Å while the Ti/Ni split refines to +30 / 70. Also a good dataset for showing `total_thickness` (800 Å of film). + +## 4. `dppc_monolayer.ort` — surfactant recipes + +| layer | truth | +|---|---| +| superphase | air, ∞ | +| DPPC tails (C₃₂D₆₄) | default surfactant-layer geometry | +| DPPC heads (C₁₀H₁₈NO₈P) | — | +| subphase | D2O (6.36), roughness 3 Å | + +Simulated from the default `SurfactantLayer` (DPPC) with **area per molecule +48 Ų shared by head and tail**, conformal roughness 3 Å extended to the D2O +subphase. + +**Demo script**: build a Surfactant Layer assembly between air and D2O. In +*Physics constraints* toggle **Equal head/tail area per molecule**, **Conformal +roughness**, then **Solvent roughness follows the surfactant** (note it is +unavailable until conformal roughness is on). Fit the tail APM and roughness: +they refine to 48 Ų / 3 Å, and the constraints table shows three grouped recipe +rows instead of many individual ties. The "Mixture fractions sum to 1" card shows +as always-on because the solvated head material normalises internally. + +--- + +## Cross-cutting things to show with any of the datasets + +- **Persistence**: save the project after setting up constraints, reload — the + inequality rows, recipe toggles and the derived parameter all come back. +- **Engine screening**: with any inequality active, selecting an LMFit/DFO + minimizer shows the warning badge and the fit is refused with a clear message; + `Bumps_lm` warns that enforcement is weak. +- **Infeasible progress**: force a start just inside the boundary and watch the + progress line switch to "outside the inequality constraints" when the optimizer + probes the forbidden region (the meaningless penalty χ² is not displayed). +- **Bayesian**: the DREAM sampler honours the same constraints — the posterior is + cut off at the constraint boundary (visible in the marginal of t_A + t_B). diff --git a/examples/datasets/dppc_monolayer.ort b/examples/datasets/dppc_monolayer.ort new file mode 100644 index 00000000..ffd7035b --- /dev/null +++ b/examples/datasets/dppc_monolayer.ort @@ -0,0 +1,231 @@ +# # ORSO reflectivity data file | 1.2 standard | YAML encoding | https://www.reflectometry.org/ +# data_source: +# owner: +# name: EasyReflectometry +# affiliation: EasyScience +# experiment: +# title: DPPC monolayer at the air/D2O interface +# instrument: simulation +# start_date: 2026-08-24T00:00:00 +# probe: neutron +# sample: +# name: air / DPPC tail / DPPC head / D2O +# description: 'TRUTH: default DPPC surfactant layer, area per molecule 48 A^2 shared +# by head and tail, conformal roughness 3 A extended to the D2O subphase. Demo: +# physics recipes "Equal head/tail area per molecule", "Conformal roughness" and +# "Solvent roughness follows the surfactant".' +# model: +# stack: ambient | tails | heads | subphase +# origin: simulated ground truth +# layers: +# ambient: +# thickness: {magnitude: 0.0, unit: angstrom} +# roughness: {magnitude: 0.0, unit: angstrom} +# material: air +# tails: +# thickness: {magnitude: 16.0, unit: angstrom} +# roughness: {magnitude: 3.0, unit: angstrom} +# material: TailMat +# heads: +# thickness: {magnitude: 10.0, unit: angstrom} +# roughness: {magnitude: 3.0, unit: angstrom} +# material: HeadMat +# subphase: +# thickness: {magnitude: 0.0, unit: angstrom} +# roughness: {magnitude: 3.0, unit: angstrom} +# material: D2O +# materials: +# air: +# sld: {magnitude: 0.0, unit: 1/angstrom^2} +# TailMat: +# sld: {magnitude: 8.326416666666667e-06, unit: 1/angstrom^2} +# HeadMat: +# sld: {magnitude: 2.272923333333334e-06, unit: 1/angstrom^2} +# D2O: +# sld: {magnitude: 6.36e-06, unit: 1/angstrom^2} +# globals: +# roughness: {magnitude: 0.3, unit: nm} +# length_unit: angstrom +# mass_density_unit: g/cm^3 +# number_density_unit: 1/nm^3 +# sld_unit: 1/angstrom^2 +# magnetic_moment_unit: muB +# slice_resolution: {magnitude: 1.0, unit: nm} +# default_solvent: +# formula: H2O +# mass_density: {magnitude: 1.0, unit: g/cm^3} +# measurement: +# instrument_settings: +# incident_angle: {min: 0.1, max: 3.0, unit: deg} +# wavelength: {magnitude: 6.0, unit: angstrom} +# polarization: unpolarized +# data_files: [] +# reduction: +# software: {name: easyreflectometry (simulated)} +# data_set: 0 +# columns: +# - {name: Qz, unit: 1/angstrom, physical_quantity: normal wavevector transfer} +# - {name: R, physical_quantity: reflectivity} +# - {error_of: R, error_type: uncertainty, value_is: sigma} +# - {error_of: Qz, error_type: resolution, value_is: sigma} +# # Qz (1/angstrom) R sR sQz +1.0000000000000000e-02 9.7388638096783153e-01 3.9998296674389450e-02 2.1231422505307856e-04 +1.1823899371069183e-02 9.9296852276714287e-01 3.9998296674389415e-02 2.5103820320741366e-04 +1.3647798742138364e-02 1.0665030426517004e+00 3.9998296674389436e-02 2.8976218136174875e-04 +1.5471698113207547e-02 1.0263217041097823e+00 3.9998296674389429e-02 3.2848615951608379e-04 +1.7295597484276729e-02 9.1485761740892013e-01 3.9165789535725255e-02 3.6721013767041888e-04 +1.9119496855345912e-02 2.3372601485574346e-01 9.3510068245810114e-03 4.0593411582475402e-04 +2.0943396226415091e-02 9.4249496919511830e-02 3.8664228550793038e-03 4.4465809397908901e-04 +2.2767295597484277e-02 5.2874840639103596e-02 2.1025136334045015e-03 4.8338207213342415e-04 +2.4591194968553456e-02 3.0052286644304309e-02 1.2847566647268000e-03 5.2210605028775925e-04 +2.6415094339622643e-02 2.1255698549304126e-02 8.4210405881911265e-04 5.6083002844209439e-04 +2.8238993710691822e-02 1.4616793077878075e-02 5.7923805964920575e-04 5.9955400659642932e-04 +3.0062893081761008e-02 1.0973466887725200e-02 4.1293352317443415e-04 6.3827798475076458e-04 +3.1886792452830187e-02 7.6626349871623247e-03 3.0269156841976569e-04 6.7700196290509961e-04 +3.3710691823899366e-02 5.7884311508343399e-03 2.2692305373157794e-04 7.1572594105943454e-04 +3.5534591194968553e-02 4.0736423491277204e-03 1.7331699377270344e-04 7.5444991921376969e-04 +3.7358490566037732e-02 3.6643516896320452e-03 1.3447650225632794e-04 7.9317389736810473e-04 +3.9182389937106918e-02 2.4410579721411171e-03 1.0576678582332377e-04 8.3189787552243987e-04 +4.1006289308176097e-02 2.1967903228987343e-03 8.4181557083140170e-05 8.7062185367677491e-04 +4.2830188679245283e-02 1.6699934915366810e-03 6.7713295341004061e-05 9.0934583183111017e-04 +4.4654088050314462e-02 1.3257676750934525e-03 5.4987695921420982e-05 9.4806980998544510e-04 +4.6477987421383649e-02 1.0960254043546481e-03 4.5043470351461550e-05 9.8679378813978035e-04 +4.8301886792452828e-02 9.0438989778716158e-04 3.7195430937215835e-05 1.0255177662941153e-03 +5.0125786163522014e-02 7.8494036008411566e-04 3.0946986140618869e-05 1.0642417444484506e-03 +5.1949685534591193e-02 6.4496710913926520e-04 2.5932849790080123e-05 1.1029657226027856e-03 +5.3773584905660372e-02 5.7895778427031043e-04 2.1880722257027532e-05 1.1416897007571205e-03 +5.5597484276729559e-02 4.3012570571938371e-04 1.8585168166962685e-05 1.1804136789114556e-03 +5.7421383647798738e-02 3.9668856316760860e-04 1.5889500726316174e-05 1.2191376570657906e-03 +5.9245283018867924e-02 3.2912847038079848e-04 1.3673022779815433e-05 1.2578616352201257e-03 +6.1069182389937103e-02 3.0473563627181289e-04 1.1841913168958990e-05 1.2965856133744609e-03 +6.2893081761006289e-02 2.3570185915334571e-04 1.0322632590978154e-05 1.3353095915287960e-03 +6.4716981132075468e-02 2.2281452515568223e-04 9.0570959139851196e-06 1.3740335696831309e-03 +6.6540880503144648e-02 2.0115866937032214e-04 7.9990990125917766e-06 1.4127575478374661e-03 +6.8364779874213827e-02 1.6673559351717762e-04 7.1116475417543390e-06 1.4514815259918010e-03 +7.0188679245283006e-02 1.6489436872699543e-04 6.3649411797107447e-06 1.4902055041461362e-03 +7.2012578616352199e-02 1.4389587693049890e-04 5.7348393648862766e-06 1.5289294823004715e-03 +7.3836477987421378e-02 1.3477945024803232e-04 5.2016839805264289e-06 1.5676534604548065e-03 +7.5660377358490558e-02 1.2279027058487266e-04 4.7493891374510566e-06 1.6063774386091416e-03 +7.7484276729559737e-02 1.0434150795524104e-04 4.3647324386502469e-06 1.6451014167634765e-03 +7.9308176100628916e-02 9.7199445342238268e-05 4.0367994735060854e-06 1.6838253949178115e-03 +8.1132075471698109e-02 9.2649813822754914e-05 3.7565456636181622e-06 1.7225493730721468e-03 +8.2955974842767288e-02 9.0041378191561727e-05 3.5164486489492095e-06 1.7612733512264820e-03 +8.4779874213836467e-02 8.5072672499841054e-05 3.3102309687929581e-06 1.7999973293808169e-03 +8.6603773584905647e-02 7.5592755595223492e-05 3.1326376455060649e-06 1.8387213075351521e-03 +8.8427672955974826e-02 7.2172426791150543e-05 2.9792569284913164e-06 1.8774452856894870e-03 +9.0251572327044019e-02 6.8388475000940229e-05 2.8463751322648515e-06 1.9161692638438219e-03 +9.2075471698113198e-02 6.6175992224124582e-05 2.7308585679169417e-06 1.9548932419981568e-03 +9.3899371069182377e-02 6.4625675208811916e-05 2.6300570910036629e-06 1.9936172201524922e-03 +9.5723270440251557e-02 6.2708087365154333e-05 2.5417249986828500e-06 2.0323411983068276e-03 +9.7547169811320736e-02 6.6170000924447542e-05 2.4639559050601174e-06 2.0710651764611625e-03 +9.9371069182389929e-02 5.8165321070872908e-05 2.3951289249654296e-06 2.1097891546154974e-03 +1.0119496855345911e-01 5.7172757741298263e-05 2.3338640464334810e-06 2.1485131327698324e-03 +1.0301886792452829e-01 5.7519717059396353e-05 2.2789850115268418e-06 2.1872371109241673e-03 +1.0484276729559747e-01 5.3112340574572236e-05 2.2294883229881281e-06 2.2259610890785026e-03 +1.0666666666666666e-01 5.3307660333464296e-05 2.1845173088493599e-06 2.2646850672328380e-03 +1.0849056603773584e-01 5.3112054607321366e-05 2.1433403427737797e-06 2.3034090453871729e-03 +1.1031446540880502e-01 5.3763147315288989e-05 2.1053325074993859e-06 2.3421330235415079e-03 +1.1213836477987420e-01 4.8524904661998903e-05 2.0699601173371082e-06 2.3808570016958428e-03 +1.1396226415094338e-01 5.3212555062353222e-05 2.0367676192390277e-06 2.4195809798501777e-03 +1.1578616352201257e-01 4.8927355815458702e-05 2.0053664801082003e-06 2.4583049580045135e-03 +1.1761006289308175e-01 4.9220313000439945e-05 1.9754257376723005e-06 2.4970289361588485e-03 +1.1943396226415093e-01 4.9815594975004889e-05 1.9466639485015308e-06 2.5357529143131834e-03 +1.2125786163522011e-01 4.8739015143158248e-05 1.9188423090958937e-06 2.5744768924675183e-03 +1.2308176100628930e-01 4.8797780494554980e-05 1.8917587713207929e-06 2.6132008706218537e-03 +1.2490566037735848e-01 4.6454130914799745e-05 1.8652429960308937e-06 2.6519248487761886e-03 +1.2672955974842767e-01 4.5442728495202615e-05 1.8391520173451297e-06 2.6906488269305240e-03 +1.2855345911949684e-01 4.5408054380005086e-05 1.8133665118067972e-06 2.7293728050848589e-03 +1.3037735849056603e-01 4.2414561968031088e-05 1.7877875831586662e-06 2.7680967832391943e-03 +1.3220125786163522e-01 4.5698153276754389e-05 1.7623339845130803e-06 2.8068207613935292e-03 +1.3402515723270439e-01 4.1576693762810786e-05 1.7369397173752807e-06 2.8455447395478641e-03 +1.3584905660377358e-01 4.0131825930559906e-05 1.7115519521334895e-06 2.8842687177021990e-03 +1.3767295597484278e-01 4.5120601557138671e-05 1.6861292245583891e-06 2.9229926958565344e-03 +1.3949685534591194e-01 4.0789410837327210e-05 1.6606398697563497e-06 2.9617166740108693e-03 +1.4132075471698113e-01 3.8448575824505172e-05 1.6350606606918047e-06 3.0004406521652047e-03 +1.4314465408805030e-01 4.2715388315352618e-05 1.6093756232078722e-06 3.0391646303195392e-03 +1.4496855345911949e-01 3.8567250418623824e-05 1.5835750035608748e-06 3.0778886084738746e-03 +1.4679245283018869e-01 4.0095706188813842e-05 1.5576543679548239e-06 3.1166125866282099e-03 +1.4861635220125785e-01 3.6519889023450391e-05 1.5316138165107706e-06 3.1553365647825449e-03 +1.5044025157232704e-01 3.6761904720711131e-05 1.5054572967598797e-06 3.1940605429368802e-03 +1.5226415094339624e-01 3.3982140974157465e-05 1.4791920036713626e-06 3.2327845210912156e-03 +1.5408805031446540e-01 3.3047336088015373e-05 1.4528278544386181e-06 3.2715084992455501e-03 +1.5591194968553460e-01 3.3766661870220613e-05 1.4263770294976103e-06 3.3102324773998854e-03 +1.5773584905660376e-01 3.4387096065356822e-05 1.3998535705636342e-06 3.3489564555542199e-03 +1.5955974842767295e-01 3.4927677970894234e-05 1.3732730287119285e-06 3.3876804337085553e-03 +1.6138364779874215e-01 3.6433578984596346e-05 1.3466521565722962e-06 3.4264044118628911e-03 +1.6320754716981131e-01 3.1138147901275678e-05 1.3200086387523788e-06 3.4651283900172252e-03 +1.6503144654088051e-01 3.3139074628331776e-05 1.2933608560955155e-06 3.5038523681715610e-03 +1.6685534591194967e-01 3.0402656571422964e-05 1.2667276796595617e-06 3.5425763463258950e-03 +1.6867924528301886e-01 3.0314051358170075e-05 1.2401282906327522e-06 3.5813003244802308e-03 +1.7050314465408806e-01 2.8042003062495444e-05 1.2135820233435231e-06 3.6200243026345662e-03 +1.7232704402515722e-01 2.7421989573728500e-05 1.1871082284703038e-06 3.6587482807889011e-03 +1.7415094339622642e-01 2.7614574271430218e-05 1.1607261541221796e-06 3.6974722589432360e-03 +1.7597484276729558e-01 2.6507840944808759e-05 1.1344548427274924e-06 3.7361962370975710e-03 +1.7779874213836477e-01 2.7203886669391355e-05 1.1083130418773616e-06 3.7749202152519059e-03 +1.7962264150943397e-01 2.5797633346613806e-05 1.0823191275258607e-06 3.8136441934062417e-03 +1.8144654088050313e-01 2.7233626203375742e-05 1.0564910381412638e-06 3.8523681715605762e-03 +1.8327044025157233e-01 2.5428895254957726e-05 1.0308462185686692e-06 3.8910921497149120e-03 +1.8509433962264149e-01 2.4801170048077418e-05 1.0054015725103777e-06 3.9298161278692465e-03 +1.8691823899371068e-01 2.5119219318225850e-05 9.8017342265881936e-07 3.9685401060235818e-03 +1.8874213836477988e-01 2.3874326111016550e-05 9.5517747762995779e-07 4.0072640841779172e-03 +1.9056603773584904e-01 2.2725984441705214e-05 9.3042880494450273e-07 4.0459880623322517e-03 +1.9238993710691824e-01 2.2066355148368703e-05 9.0594180939167070e-07 4.0847120404865871e-03 +1.9421383647798743e-01 2.1436611336650881e-05 8.8173021618713000e-07 4.1234360186409224e-03 +1.9603773584905659e-01 2.1868796153947614e-05 8.5780705840418639e-07 4.1621599967952569e-03 +1.9786163522012579e-01 2.1007854720734368e-05 8.3418466821576149e-07 4.2008839749495923e-03 +1.9968553459119495e-01 2.0068580390834376e-05 8.1087467147718412e-07 4.2396079531039268e-03 +2.0150943396226415e-01 1.9826587661566503e-05 7.8788798529818201e-07 4.2783319312582630e-03 +2.0333333333333334e-01 1.8214174620863171e-05 7.6523481843784434e-07 4.3170559094125975e-03 +2.0515723270440250e-01 1.8452768302340699e-05 7.4292467406963294e-07 4.3557798875669329e-03 +2.0698113207547170e-01 1.6505913808907929e-05 7.2096635444646724e-07 4.3945038657212674e-03 +2.0880503144654086e-01 1.7926437059418063e-05 6.9936796765615479e-07 4.4332278438756027e-03 +2.1062893081761005e-01 1.6514488456555864e-05 6.7813693624699371e-07 4.4719518220299381e-03 +2.1245283018867925e-01 1.6052540722069450e-05 6.5728000677122418e-07 4.5106758001842726e-03 +2.1427672955974841e-01 1.5801771118464841e-05 6.3680326122936377e-07 4.5493997783386080e-03 +2.1610062893081761e-01 1.4911815223952792e-05 6.1671212944932050e-07 4.5881237564929424e-03 +2.1792452830188677e-01 1.4273579750596546e-05 5.9701140221132723e-07 4.6268477346472778e-03 +2.1974842767295596e-01 1.4629975967004368e-05 5.7770524597013390e-07 4.6655717128016132e-03 +2.2157232704402516e-01 1.3534611587166681e-05 5.5879721737857155e-07 4.7042956909559485e-03 +2.2339622641509432e-01 1.2512420427757925e-05 5.4029027960081310e-07 4.7430196691102830e-03 +2.2522012578616352e-01 1.2784202323161070e-05 5.2218681806316144e-07 4.7817436472646184e-03 +2.2704402515723271e-01 1.1252002520897584e-05 5.0448865776411651e-07 4.8204676254189538e-03 +2.2886792452830187e-01 1.2486502444901757e-05 4.8719708013747458e-07 4.8591916035732891e-03 +2.3069182389937107e-01 1.1177335859516368e-05 4.7031284076152218e-07 4.8979155817276236e-03 +2.3251572327044023e-01 1.0866728436556312e-05 4.5383618733791128e-07 4.9366395598819590e-03 +2.3433962264150943e-01 1.1432549058128322e-05 4.3776687738532263e-07 4.9753635380362935e-03 +2.3616352201257862e-01 1.0960863861487509e-05 4.2210419685766986e-07 5.0140875161906297e-03 +2.3798742138364778e-01 9.7182900551795301e-06 4.0684697829473874e-07 5.0528114943449633e-03 +2.3981132075471698e-01 9.5447840992968712e-06 3.9199361908359564e-07 5.0915354724992996e-03 +2.4163522012578614e-01 9.3501412986761171e-06 3.7754210011310799e-07 5.1302594506536341e-03 +2.4345911949685534e-01 9.1986479249110188e-06 3.6349000409521756e-07 5.1689834288079694e-03 +2.4528301886792453e-01 8.2000025202564876e-06 3.4983453374422542e-07 5.2077074069623048e-03 +2.4710691823899369e-01 7.9598381260109962e-06 3.3657253014329354e-07 5.2464313851166393e-03 +2.4893081761006289e-01 7.9889269710253961e-06 3.2370049088474365e-07 5.2851553632709746e-03 +2.5075471698113205e-01 7.3642526863798727e-06 3.1121458800071591e-07 5.3238793414253100e-03 +2.5257861635220125e-01 7.2034461119833583e-06 2.9911068569259659e-07 5.3626033195796445e-03 +2.5440251572327044e-01 6.8598730365334438e-06 2.8738435792961712e-07 5.4013272977339799e-03 +2.5622641509433958e-01 6.0875018726899377e-06 2.7603090583268703e-07 5.4400512758883135e-03 +2.5805031446540877e-01 5.8984760267496923e-06 2.6504537476513678e-07 5.4787752540426497e-03 +2.5987421383647796e-01 5.1885480589199540e-06 2.5442257115806310e-07 5.5174992321969851e-03 +2.6169811320754716e-01 5.5404166649724207e-06 2.4415707905260363e-07 5.5562232103513196e-03 +2.6352201257861635e-01 5.6696955742399462e-06 2.3424327634156160e-07 5.5949471885056558e-03 +2.6534591194968554e-01 4.8676723532043518e-06 2.2467535069327158e-07 5.6336711666599903e-03 +2.6716981132075468e-01 5.1520025459840356e-06 2.1544731514689490e-07 5.6723951448143248e-03 +2.6899371069182387e-01 4.8702848681398540e-06 2.0655302336477796e-07 5.7111191229686602e-03 +2.7081761006289307e-01 4.4387966178396397e-06 1.9798618452493770e-07 5.7498431011229955e-03 +2.7264150943396226e-01 4.2420652100331548e-06 1.8974037784788328e-07 5.7885670792773309e-03 +2.7446540880503145e-01 4.0602864847554886e-06 1.8180906674879792e-07 5.8272910574316663e-03 +2.7628930817610059e-01 3.7046513635863371e-06 1.7418561260682639e-07 5.8660150355859999e-03 +2.7811320754716978e-01 3.6244716239457655e-06 1.6686328814422649e-07 5.9047390137403353e-03 +2.7993710691823898e-01 3.4884396407643656e-06 1.5983529040916633e-07 5.9434629918946706e-03 +2.8176100628930817e-01 3.4210533227780205e-06 1.5309475335682635e-07 5.9821869700490060e-03 +2.8358490566037736e-01 3.2819762872103269e-06 1.4663476002435889e-07 6.0209109482033413e-03 +2.8540880503144656e-01 2.9068925203971979e-06 1.4044835429606557e-07 6.0596349263576767e-03 +2.8723270440251569e-01 2.9742957998445966e-06 1.3452855225584079e-07 6.0983589045120103e-03 +2.8905660377358489e-01 2.6214659326395636e-06 1.2886835312554113e-07 6.1370828826663466e-03 +2.9088050314465408e-01 2.5656641829121365e-06 1.2346074978901171e-07 6.1758068608206811e-03 +2.9270440251572327e-01 2.5011804900806952e-06 1.1829873889952966e-07 6.2145308389750164e-03 +2.9452830188679247e-01 2.3318149278918286e-06 1.1337533057079195e-07 6.2532548171293518e-03 +2.9635220125786160e-01 2.2032903865635935e-06 1.0868355765193794e-07 6.2919787952836863e-03 +2.9817610062893080e-01 2.1334923353879206e-06 1.0421648458753701e-07 6.3307027734380216e-03 +2.9999999999999999e-01 1.9439186147930241e-06 9.9967215863971512e-08 6.3694267515923561e-03 diff --git a/examples/datasets/generate_datasets.py b/examples/datasets/generate_datasets.py new file mode 100644 index 00000000..583494f4 --- /dev/null +++ b/examples/datasets/generate_datasets.py @@ -0,0 +1,299 @@ +# SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Generate the demo datasets for the constraints functionality. + +Each ``.ort`` file in this directory is simulated from a *known* structure +(documented in the file header and in ``README.md``) with reproducible 4 % +noise, so every constraint demo has a ground truth to compare against: + +- ``two_layer_film.ort`` inequality budget + derived total thickness +- ``swapped_layers.ort`` layer-ordering inequality (t_top < t_bottom) +- ``ni_ti_multilayer.ort`` constant-period recipe on a repeating multilayer +- ``dppc_monolayer.ort`` surfactant recipes (equal APM, conformal / solvent roughness) + +Re-run from the repository root to regenerate:: + + python examples/datasets/generate_datasets.py +""" + +import datetime +from pathlib import Path + +import numpy as np +from orsopy import fileio +from orsopy.fileio import model_language + +from easyreflectometry.calculators import CalculatorFactory +from easyreflectometry.model import Model +from easyreflectometry.model import PercentageFwhm +from easyreflectometry.sample import Layer +from easyreflectometry.sample import Material +from easyreflectometry.sample import Multilayer +from easyreflectometry.sample import RepeatingMultilayer +from easyreflectometry.sample import Sample +from easyreflectometry.sample import SurfactantLayer + +OUTPUT_DIR = Path(__file__).parent +RESOLUTION_PERCENT = 5.0 +NOISE_RELATIVE = 0.04 +BACKGROUND = 1e-7 + + +def simulate(model: Model, q: np.ndarray, seed: int) -> tuple[np.ndarray, np.ndarray]: + """Reflectivity of `model` at `q` with reproducible multiplicative noise.""" + interface = CalculatorFactory() + model.interface = interface + reflectivity = interface.fit_func(q, model.unique_name) + rng = np.random.default_rng(seed) + sigma = NOISE_RELATIVE * reflectivity + 0.2 * BACKGROUND + measured = np.clip(reflectivity + rng.normal(0.0, sigma), 0.1 * BACKGROUND, None) + return measured, sigma + + +def orso_sample_model(stack: str, layer_definitions: dict, material_slds: dict) -> model_language.SampleModel: + """ORSO model-language description of the simulated structure. + + This is what the application's *Sample > Load a sample* import parses to + rebuild the layer stack (``load_orso_model``), so the demo files are + self-describing: importing one also sets up the matching sample. + + ``layer_definitions``: name -> (material name, thickness / angstrom, roughness / angstrom) + ``material_slds``: material name -> SLD in 1e-6 / angstrom^2 (written in absolute units) + """ + materials = { + name: model_language.Material(sld=fileio.Value(sld * 1e-6, '1/angstrom^2')) + for name, sld in material_slds.items() + } + layers = { + name: model_language.Layer( + thickness=fileio.Value(thickness, 'angstrom'), + roughness=fileio.Value(roughness, 'angstrom'), + material=material, + ) + for name, (material, thickness, roughness) in layer_definitions.items() + } + return model_language.SampleModel( + stack=stack, + layers=layers, + materials=materials, + globals=model_language.ModelParameters(length_unit='angstrom'), + origin='simulated ground truth', + ) + + +def write_ort(filename: str, title: str, sample_name: str, description: str, q, r, sr, sample_model=None) -> Path: + """Write one ORSO file with the ground truth recorded in the header.""" + header = fileio.Orso( + data_source=fileio.DataSource( + owner=fileio.Person(name='EasyReflectometry', affiliation='EasyScience'), + experiment=fileio.Experiment( + title=title, + instrument='simulation', + start_date=datetime.datetime(2026, 8, 24, 0, 0, 0), + probe='neutron', + ), + sample=fileio.Sample(name=sample_name, description=description, model=sample_model), + measurement=fileio.Measurement( + instrument_settings=fileio.InstrumentSettings( + incident_angle=fileio.ValueRange(0.1, 3.0, 'deg'), + wavelength=fileio.Value(6.0, 'angstrom'), + ), + data_files=[], + ), + ), + reduction=fileio.Reduction(software=fileio.Software(name='easyreflectometry (simulated)')), + columns=[ + fileio.Column('Qz', '1/angstrom', 'normal wavevector transfer'), + fileio.Column('R', None, 'reflectivity'), + fileio.ErrorColumn('R', 'uncertainty', 'sigma'), + fileio.ErrorColumn('Qz', 'resolution', 'sigma'), + ], + data_set=0, + ) + # Gaussian sigma of the dQ/Q resolution (FWHM -> sigma). + sq = (RESOLUTION_PERCENT / 100.0) * q / 2.355 + dataset = fileio.OrsoDataset(header, np.array([q, r, sr, sq]).T) + path = OUTPUT_DIR / filename + fileio.save_orso([dataset], str(path)) + print(f'wrote {path.name}: {len(q)} points') + return path + + +def main() -> None: + q = np.linspace(0.008, 0.30, 180) + + # ------------------------------------------------------------------ 1 + # Two-layer film: budget + derived total thickness. + # Truth: t_A = 35 A (SLD 3.0) on t_B = 55 A (SLD 5.0), total exactly 90 A. + film_a = Multilayer(Layer(Material(3.0, 0.0, 'MatA'), thickness=35.0, roughness=3.0, name='A'), name='Film A') + film_b = Multilayer(Layer(Material(5.0, 0.0, 'MatB'), thickness=55.0, roughness=3.0, name='B'), name='Film B') + model = Model( + sample=Sample( + Multilayer(Layer(Material(0.0, 0.0, 'Air'), thickness=0.0, roughness=0.0, name='Air'), name='Superphase'), + film_a, + film_b, + Multilayer(Layer(Material(2.07, 0.0, 'Si'), thickness=0.0, roughness=2.0, name='Si'), name='Subphase'), + populate_if_none=False, + ), + scale=1.0, + background=BACKGROUND, + resolution_function=PercentageFwhm(RESOLUTION_PERCENT), + ) + r, sr = simulate(model, q, seed=1) + write_ort( + 'two_layer_film.ort', + 'Two-layer film with a 90 A thickness budget', + 'air / MatA / MatB / Si', + 'TRUTH: t_A = 35 A (SLD 3.0), t_B = 55 A (SLD 5.0), roughness 3 A, ' + 'total film thickness exactly 90 A. Demo: derived total_thickness, ' + 'inequality constraints t_A < t_B and t_A + t_B <= 90 (BUMPS only).', + q, r, sr, + sample_model=orso_sample_model( + stack='ambient | filmA | filmB | substrate', + layer_definitions={ + 'ambient': ('air', 0.0, 0.0), + 'filmA': ('MatA', 35.0, 3.0), + 'filmB': ('MatB', 55.0, 3.0), + 'substrate': ('Si', 0.0, 2.0), + }, + material_slds={'air': 0.0, 'MatA': 3.0, 'MatB': 5.0, 'Si': 2.07}, + ), + ) + + # ------------------------------------------------------------------ 2 + # Ordering: a thin low-SLD layer on a thick high-SLD layer. + # Truth: t_top = 20 A (SLD 2.5) above t_bottom = 60 A (SLD 4.2). + top = Multilayer(Layer(Material(2.5, 0.0, 'TopMat'), thickness=20.0, roughness=3.0, name='Top'), name='Top layer') + bottom = Multilayer( + Layer(Material(4.2, 0.0, 'BottomMat'), thickness=60.0, roughness=3.0, name='Bottom'), name='Bottom layer' + ) + model = Model( + sample=Sample( + Multilayer(Layer(Material(0.0, 0.0, 'Air'), thickness=0.0, roughness=0.0, name='Air'), name='Superphase'), + top, + bottom, + Multilayer(Layer(Material(2.07, 0.0, 'Si'), thickness=0.0, roughness=2.0, name='Si'), name='Subphase'), + populate_if_none=False, + ), + scale=1.0, + background=BACKGROUND, + resolution_function=PercentageFwhm(RESOLUTION_PERCENT), + ) + r, sr = simulate(model, q, seed=2) + write_ort( + 'swapped_layers.ort', + 'Layer ordering: thin capping layer on a thick layer', + 'air / thin TopMat / thick BottomMat / Si', + 'TRUTH: t_top = 20 A (SLD 2.5), t_bottom = 60 A (SLD 4.2), roughness 3 A. ' + 'Demo: start the fit from swapped thicknesses (60 / 20) and use the ' + 'inequality t_top < t_bottom to keep the physical assignment.', + q, r, sr, + sample_model=orso_sample_model( + stack='ambient | top | bottom | substrate', + layer_definitions={ + 'ambient': ('air', 0.0, 0.0), + 'top': ('TopMat', 20.0, 3.0), + 'bottom': ('BottomMat', 60.0, 3.0), + 'substrate': ('Si', 0.0, 2.0), + }, + material_slds={'air': 0.0, 'TopMat': 2.5, 'BottomMat': 4.2, 'Si': 2.07}, + ), + ) + + # ------------------------------------------------------------------ 3 + # Repeating multilayer with a fixed period. + # Truth: [Ti 30 A / Ni 70 A] x 8, period exactly 100 A, conformal roughness 4 A. + ti = Layer(Material(-1.95, 0.0, 'Ti'), thickness=30.0, roughness=4.0, name='Ti') + ni = Layer(Material(9.41, 0.0, 'Ni'), thickness=70.0, roughness=4.0, name='Ni') + stack = RepeatingMultilayer([ti, ni], repetitions=8, name='Ti/Ni stack') + model = Model( + sample=Sample( + Multilayer(Layer(Material(0.0, 0.0, 'Air'), thickness=0.0, roughness=0.0, name='Air'), name='Superphase'), + stack, + Multilayer(Layer(Material(2.07, 0.0, 'Si'), thickness=0.0, roughness=4.0, name='Si'), name='Subphase'), + populate_if_none=False, + ), + scale=1.0, + background=BACKGROUND, + resolution_function=PercentageFwhm(RESOLUTION_PERCENT), + ) + r, sr = simulate(model, np.linspace(0.008, 0.35, 220), seed=3) + write_ort( + 'ni_ti_multilayer.ort', + 'Ti/Ni repeating multilayer with a 100 A period', + 'air / [Ti 30 / Ni 70] x8 / Si', + 'TRUTH: period exactly 100 A (Ti 30 A, SLD -1.95; Ni 70 A, SLD 9.41), 8 repetitions, ' + 'conformal roughness 4 A. Demo: physics recipes "Constant period" and ' + '"Conformal roughness" on the repeating multilayer; the Bragg peak position ' + 'pins the period while the Ti/Ni split is fitted.', + np.linspace(0.008, 0.35, 220), r, sr, + sample_model=orso_sample_model( + # The repetitions are resolved to 16 individual layers on import; + # rebuild a RepeatingMultilayer by hand for the constant-period demo. + stack='ambient | 8 ( layerTi | layerNi ) | substrate', + layer_definitions={ + 'ambient': ('air', 0.0, 0.0), + 'layerTi': ('Ti', 30.0, 4.0), + 'layerNi': ('Ni', 70.0, 4.0), + 'substrate': ('Si', 0.0, 4.0), + }, + material_slds={'air': 0.0, 'Ti': -1.95, 'Ni': 9.41, 'Si': 2.07}, + ), + ) + + # ------------------------------------------------------------------ 4 + # DPPC monolayer at the air/D2O interface. + # Truth: default DPPC surfactant layer, equal head/tail APM (48 A^2), + # conformal roughness 3 A shared with the D2O subphase. + surfactant = SurfactantLayer(name='DPPC') + surfactant.tail_layer.area_per_molecule_parameter.value = 48.0 + surfactant.constrain_area_per_molecule = True + surfactant.conformal_roughness = True + d2o_layer = Layer(Material(6.36, 0.0, 'D2O'), thickness=0.0, roughness=3.0, name='D2O') + model = Model( + sample=Sample( + Multilayer(Layer(Material(0.0, 0.0, 'Air'), thickness=0.0, roughness=0.0, name='Air'), name='Superphase'), + surfactant, + Multilayer(d2o_layer, name='Subphase'), + populate_if_none=False, + ), + scale=1.0, + background=5e-7, + resolution_function=PercentageFwhm(RESOLUTION_PERCENT), + ) + surfactant.layers[0].roughness.value = 3.0 + surfactant.constrain_solvent_roughness(d2o_layer.roughness) + q_surf = np.linspace(0.01, 0.30, 160) + r, sr = simulate(model, q_surf, seed=4) + tail, head = surfactant.tail_layer, surfactant.head_layer + write_ort( + 'dppc_monolayer.ort', + 'DPPC monolayer at the air/D2O interface', + 'air / DPPC tail / DPPC head / D2O', + 'TRUTH: default DPPC surfactant layer, area per molecule 48 A^2 shared by head ' + 'and tail, conformal roughness 3 A extended to the D2O subphase. Demo: physics ' + 'recipes "Equal head/tail area per molecule", "Conformal roughness" and ' + '"Solvent roughness follows the surfactant".', + q_surf, r, sr, + # Slab-equivalent of the surfactant (effective solvated SLDs); replace it + # with a SurfactantLayer assembly for the physics-recipe demo. + sample_model=orso_sample_model( + stack='ambient | tails | heads | subphase', + layer_definitions={ + 'ambient': ('air', 0.0, 0.0), + 'tails': ('TailMat', float(tail.thickness.value), float(tail.roughness.value)), + 'heads': ('HeadMat', float(head.thickness.value), float(head.roughness.value)), + 'subphase': ('D2O', 0.0, float(d2o_layer.roughness.value)), + }, + material_slds={ + 'air': 0.0, + 'TailMat': float(getattr(tail.material.sld, 'value', tail.material.sld)), + 'HeadMat': float(getattr(head.material.sld, 'value', head.material.sld)), + 'D2O': 6.36, + }, + ), + ) + + +if __name__ == '__main__': + main() diff --git a/examples/datasets/ni_ti_multilayer.ort b/examples/datasets/ni_ti_multilayer.ort new file mode 100644 index 00000000..c4f9cbe3 --- /dev/null +++ b/examples/datasets/ni_ti_multilayer.ort @@ -0,0 +1,291 @@ +# # ORSO reflectivity data file | 1.2 standard | YAML encoding | https://www.reflectometry.org/ +# data_source: +# owner: +# name: EasyReflectometry +# affiliation: EasyScience +# experiment: +# title: Ti/Ni repeating multilayer with a 100 A period +# instrument: simulation +# start_date: 2026-08-24T00:00:00 +# probe: neutron +# sample: +# name: air / [Ti 30 / Ni 70] x8 / Si +# description: 'TRUTH: period exactly 100 A (Ti 30 A, SLD -1.95; Ni 70 A, SLD 9.41), +# 8 repetitions, conformal roughness 4 A. Demo: physics recipes "Constant period" +# and "Conformal roughness" on the repeating multilayer; the Bragg peak position +# pins the period while the Ti/Ni split is fitted.' +# model: +# stack: ambient | 8 ( layerTi | layerNi ) | substrate +# origin: simulated ground truth +# layers: +# ambient: +# thickness: {magnitude: 0.0, unit: angstrom} +# roughness: {magnitude: 0.0, unit: angstrom} +# material: air +# layerTi: +# thickness: {magnitude: 30.0, unit: angstrom} +# roughness: {magnitude: 4.0, unit: angstrom} +# material: Ti +# layerNi: +# thickness: {magnitude: 70.0, unit: angstrom} +# roughness: {magnitude: 4.0, unit: angstrom} +# material: Ni +# substrate: +# thickness: {magnitude: 0.0, unit: angstrom} +# roughness: {magnitude: 4.0, unit: angstrom} +# material: Si +# materials: +# air: +# sld: {magnitude: 0.0, unit: 1/angstrom^2} +# Ti: +# sld: {magnitude: -1.95e-06, unit: 1/angstrom^2} +# Ni: +# sld: {magnitude: 9.41e-06, unit: 1/angstrom^2} +# Si: +# sld: {magnitude: 2.0699999999999997e-06, unit: 1/angstrom^2} +# globals: +# roughness: {magnitude: 0.3, unit: nm} +# length_unit: angstrom +# mass_density_unit: g/cm^3 +# number_density_unit: 1/nm^3 +# sld_unit: 1/angstrom^2 +# magnetic_moment_unit: muB +# slice_resolution: {magnitude: 1.0, unit: nm} +# default_solvent: +# formula: H2O +# mass_density: {magnitude: 1.0, unit: g/cm^3} +# measurement: +# instrument_settings: +# incident_angle: {min: 0.1, max: 3.0, unit: deg} +# wavelength: {magnitude: 6.0, unit: angstrom} +# polarization: unpolarized +# data_files: [] +# reduction: +# software: {name: easyreflectometry (simulated)} +# data_set: 0 +# columns: +# - {name: Qz, unit: 1/angstrom, physical_quantity: normal wavevector transfer} +# - {name: R, physical_quantity: reflectivity} +# - {error_of: R, error_type: uncertainty, value_is: sigma} +# - {error_of: Qz, error_type: resolution, value_is: sigma} +# # Qz (1/angstrom) R sR sQz +8.0000000000000002e-03 1.0815897727106292e+00 3.9998280674389458e-02 1.6985138004246286e-04 +9.5616438356164388e-03 8.9773430413943633e-01 3.9998280429870157e-02 2.0300730011924500e-04 +1.1123287671232877e-02 1.0166229412780079e+00 3.9996045629075994e-02 2.3616322019602711e-04 +1.2684931506849314e-02 9.7697438139579529e-01 3.9987134438648451e-02 2.6931914027280922e-04 +1.4246575342465753e-02 9.8049907611773446e-01 3.9943193372904418e-02 3.0247506034959139e-04 +1.5808219178082193e-02 9.8214526667288993e-01 3.9627574371500572e-02 3.3563098042637356e-04 +1.7369863013698628e-02 8.0460149767313360e-01 3.5013120627123286e-02 3.6878690050315562e-04 +1.8931506849315067e-02 1.1399529307560943e-01 4.6025307591100132e-03 4.0194282057993773e-04 +2.0493150684931506e-02 2.5072901648826668e-01 1.0388718876228973e-02 4.3509874065671990e-04 +2.2054794520547944e-02 1.2089140944071923e-01 4.2683300322648868e-03 4.6825466073350202e-04 +2.3616438356164383e-02 1.8502712635227545e-02 7.3350389103524769e-04 5.0141058081028418e-04 +2.5178082191780821e-02 7.7429832701286463e-02 3.1415252498230295e-03 5.3456650088706630e-04 +2.6739726027397256e-02 6.5888614327377804e-02 2.6655560684726454e-03 5.6772242096384841e-04 +2.8301369863013695e-02 1.4419705683287029e-02 5.9264479492303400e-04 6.0087834104063053e-04 +2.9863013698630134e-02 1.0805150480369140e-02 4.5127243349249701e-04 6.3403426111741264e-04 +3.1424657534246572e-02 3.4107537453684354e-02 1.3859873058904469e-03 6.6719018119419475e-04 +3.2986301369863011e-02 3.2935108775230176e-02 1.2925076273841026e-03 7.0034610127097687e-04 +3.4547945205479449e-02 1.0681025362358215e-02 4.3137727871962343e-04 7.3350202134775909e-04 +3.6109589041095888e-02 4.3914736112470529e-03 1.6919695038024486e-04 7.6665794142454120e-04 +3.7671232876712327e-02 1.6071420865149001e-02 6.4805615458358003e-04 7.9981386150132332e-04 +3.9232876712328765e-02 2.2505540889690246e-02 8.9936890364976905e-04 8.3296978157810543e-04 +4.0794520547945204e-02 1.4130802490060384e-02 5.3233624114564762e-04 8.6612570165488754e-04 +4.2356164383561642e-02 3.5910715079749632e-03 1.4059724688884697e-04 8.9928162173166966e-04 +4.3917808219178081e-02 6.7701719975529866e-03 2.7641295052210441e-04 9.3243754180845199e-04 +4.5479452054794513e-02 1.6240172179368940e-02 6.5441297506775131e-04 9.6559346188523389e-04 +4.7041095890410951e-02 1.7363530831902441e-02 6.7986193480076731e-04 9.9874938196201600e-04 +4.8602739726027390e-02 8.9386083739842574e-03 3.3187596649647728e-04 1.0319053020387983e-03 +5.0164383561643829e-02 3.8369701388176070e-03 1.5517230991242382e-04 1.0650612221155804e-03 +5.1726027397260267e-02 1.1415316752198540e-02 4.6112511100500494e-04 1.0982171421923623e-03 +5.3287671232876706e-02 2.2410695427529537e-02 8.6189235970256239e-04 1.1313730622691445e-03 +5.4849315068493144e-02 1.9549582290168788e-02 8.1075123122055753e-04 1.1645289823459266e-03 +5.6410958904109583e-02 1.0367607340934995e-02 4.1962076836071099e-04 1.1976849024227089e-03 +5.7972602739726022e-02 1.3994384232717936e-02 5.4070754999401179e-04 1.2308408224994910e-03 +5.9534246575342460e-02 4.7924575921463125e-02 1.8735113433213108e-03 1.2639967425762731e-03 +6.1095890410958899e-02 1.0217890599388417e-01 4.0722690143524705e-03 1.2971526626530552e-03 +6.2657534246575330e-02 1.5485679289881854e-01 6.0325930412390514e-03 1.3303085827298374e-03 +6.4219178082191769e-02 1.5158744576804392e-01 6.8369590227892766e-03 1.3634645028066195e-03 +6.5780821917808208e-02 1.6220695699157930e-01 6.2336399216695029e-03 1.3966204228834016e-03 +6.7342465753424646e-02 1.1041834538665514e-01 4.5930621358727438e-03 1.4297763429601837e-03 +6.8904109589041085e-02 6.2038229741316446e-02 2.6590252836709480e-03 1.4629322630369658e-03 +7.0465753424657523e-02 2.8953331150847614e-02 1.1454866489661205e-03 1.4960881831137479e-03 +7.2027397260273962e-02 8.9305715495471734e-03 3.4750514393328425e-04 1.5292441031905300e-03 +7.3589041095890401e-02 2.4921535525229770e-03 1.0151211347822682e-04 1.5624000232673122e-03 +7.5150684931506839e-02 1.9839060529953912e-03 8.2947655763265674e-05 1.5955559433440943e-03 +7.6712328767123278e-02 2.0540924213453401e-03 8.2097905089655211e-05 1.6287118634208764e-03 +7.8273972602739716e-02 1.3232458156725096e-03 5.3061787284893764e-05 1.6618677834976587e-03 +7.9835616438356155e-02 6.0175790090969967e-04 2.2807962808612219e-05 1.6950237035744406e-03 +8.1397260273972594e-02 2.6386058100582552e-04 1.0267463860221613e-05 1.7281796236512229e-03 +8.2958904109589032e-02 2.4646842516889430e-04 9.8027400309849950e-06 1.7613355437280053e-03 +8.4520547945205471e-02 2.5766591876263164e-04 9.8870077077745554e-06 1.7944914638047872e-03 +8.6082191780821909e-02 1.7097418468024474e-04 6.9158218189229932e-06 1.8276473838815693e-03 +8.7643835616438348e-02 8.4528762795726812e-05 3.5319601268529184e-06 1.8608033039583512e-03 +8.9205479452054787e-02 5.0916321375533422e-05 2.0097015395472250e-06 1.8939592240351335e-03 +9.0767123287671225e-02 4.8438894369281826e-05 1.9129803918751806e-06 1.9271151441119158e-03 +9.2328767123287664e-02 4.6039787540051358e-05 1.8777271043710713e-06 1.9602710641886977e-03 +9.3890410958904102e-02 3.5682015275698649e-05 1.4940632315758722e-06 1.9934269842654798e-03 +9.5452054794520541e-02 3.2463911853324101e-05 1.3065801563211580e-06 2.0265829043422624e-03 +9.7013698630136980e-02 3.7883026832322263e-05 1.7054495036688400e-06 2.0597388244190441e-03 +9.8575342465753418e-02 5.8254551948807929e-05 2.2870481352059066e-06 2.0928947444958266e-03 +1.0013698630136986e-01 6.0815067072617120e-05 2.4053266362731200e-06 2.1260506645726083e-03 +1.0169863013698630e-01 5.0368693457916506e-05 2.1774916480429421e-06 2.1592065846493909e-03 +1.0326027397260273e-01 6.2739798241369689e-05 2.5233991538623025e-06 2.1923625047261730e-03 +1.0482191780821917e-01 9.4027294300421353e-05 3.9327544621674940e-06 2.2255184248029551e-03 +1.0638356164383561e-01 1.4215933443776659e-04 5.5386151398036460e-06 2.2586743448797372e-03 +1.0794520547945205e-01 1.3962933989886865e-04 6.1016441932379788e-06 2.2918302649565193e-03 +1.0950684931506849e-01 1.4377654121088698e-04 5.9901811667802219e-06 2.3249861850333014e-03 +1.1106849315068493e-01 1.8731608308627379e-04 7.3052956592764883e-06 2.3581421051100835e-03 +1.1263013698630137e-01 2.9393992829866364e-04 1.1256897604279265e-05 2.3912980251868656e-03 +1.1419178082191780e-01 3.6777106048176426e-04 1.6122543876328040e-05 2.4244539452636478e-03 +1.1575342465753424e-01 4.8806540623556546e-04 1.9939849838132531e-05 2.4576098653404299e-03 +1.1731506849315068e-01 6.8019813723661381e-04 2.6875299954339280e-05 2.4907657854172120e-03 +1.1887671232876712e-01 1.2204261607891152e-03 5.0056864416266949e-05 2.5239217054939945e-03 +1.2043835616438356e-01 2.7792704140946209e-03 1.0453943154103215e-04 2.5570776255707762e-03 +1.2200000000000000e-01 4.5995728271582426e-03 1.9320914881434188e-04 2.5902335456475588e-03 +1.2356164383561644e-01 7.5362841432267480e-03 2.9725589725707719e-04 2.6233894657243404e-03 +1.2512328767123287e-01 9.1359756170798709e-03 3.8145584103612735e-04 2.6565453858011226e-03 +1.2668493150684931e-01 1.0900470669059347e-02 4.1282231185942866e-04 2.6897013058779051e-03 +1.2824657534246575e-01 9.4841129717518537e-03 3.7971336931545447e-04 2.7228572259546868e-03 +1.2980821917808219e-01 7.3318136985419651e-03 2.9772569018523566e-04 2.7560131460314693e-03 +1.3136986301369863e-01 4.6333169466996321e-03 1.9903160199043047e-04 2.7891690661082510e-03 +1.3293150684931507e-01 3.0393992980002017e-03 1.1393145843284558e-04 2.8223249861850336e-03 +1.3449315068493151e-01 1.4807790964863712e-03 5.7519195109548753e-05 2.8554809062618157e-03 +1.3605479452054794e-01 7.2905601999992994e-04 2.8328351936183352e-05 2.8886368263385978e-03 +1.3761643835616438e-01 4.2196179896552969e-04 1.6162817283493214e-05 2.9217927464153799e-03 +1.3917808219178082e-01 2.8181360033801287e-04 1.1136970963116329e-05 2.9549486664921620e-03 +1.4073972602739726e-01 1.9541364022350178e-04 8.0421831415345019e-06 2.9881045865689441e-03 +1.4230136986301370e-01 1.3612723412197917e-04 5.6458097538801082e-06 3.0212605066457262e-03 +1.4386301369863014e-01 9.8552215907969727e-05 4.0931005968434549e-06 3.0544164267225084e-03 +1.4542465753424658e-01 8.5899776556848419e-05 3.2764330502583145e-06 3.0875723467992905e-03 +1.4698630136986301e-01 6.4277861493151726e-05 2.7518653571586864e-06 3.1207282668760726e-03 +1.4854794520547945e-01 5.3811913825859124e-05 2.2255669643930973e-06 3.1538841869528547e-03 +1.5010958904109589e-01 4.1922263731479576e-05 1.7189790114515594e-06 3.1870401070296372e-03 +1.5167123287671233e-01 3.3624195721231880e-05 1.3528131262136922e-06 3.2201960271064189e-03 +1.5323287671232877e-01 2.8587318540689806e-05 1.1373185210036696e-06 3.2533519471832015e-03 +1.5479452054794521e-01 2.2840142482010126e-05 9.8270547822743290e-07 3.2865078672599832e-03 +1.5635616438356165e-01 1.8594423880503562e-05 8.2056015978005554e-07 3.3196637873367657e-03 +1.5791780821917806e-01 1.5930528768028374e-05 6.5733721629648731e-07 3.3528197074135469e-03 +1.5947945205479450e-01 1.3366871364604761e-05 5.2899603644234459e-07 3.3859756274903295e-03 +1.6104109589041093e-01 1.0946298097177249e-05 4.4439462266327906e-07 3.4191315475671116e-03 +1.6260273972602737e-01 9.1223717304814322e-06 3.8160314849782023e-07 3.4522874676438933e-03 +1.6416438356164381e-01 7.3835943621189366e-06 3.1939570044763122e-07 3.4854433877206754e-03 +1.6572602739726025e-01 5.9857101960034773e-06 2.5642075955991293e-07 3.5185993077974575e-03 +1.6728767123287669e-01 4.5218232803271142e-06 2.0284732607238030e-07 3.5517552278742401e-03 +1.6884931506849313e-01 3.7367990942714053e-06 1.6410757225271298e-07 3.5849111479510222e-03 +1.7041095890410957e-01 2.8025869785679183e-06 1.3643923048588826e-07 3.6180670680278039e-03 +1.7197260273972600e-01 2.3656944112328026e-06 1.1401555874638674e-07 3.6512229881045860e-03 +1.7353424657534244e-01 1.9071189444022208e-06 9.6713314341149456e-08 3.6843789081813685e-03 +1.7509589041095888e-01 1.8801432294554672e-06 9.3180508998791004e-08 3.7175348282581506e-03 +1.7665753424657532e-01 2.5973322078582554e-06 1.2278995415352930e-07 3.7506907483349332e-03 +1.7821917808219176e-01 5.5742867833622025e-06 2.2048200169436313e-07 3.7838466684117144e-03 +1.7978082191780820e-01 1.1175446805994117e-05 4.4060526515632194e-07 3.8170025884884970e-03 +1.8134246575342464e-01 2.1979561161117480e-05 8.4838978553069643e-07 3.8501585085652791e-03 +1.8290410958904108e-01 3.3711830923693427e-05 1.4900301231168766e-06 3.8833144286420612e-03 +1.8446575342465751e-01 5.7400499649911742e-05 2.3479822822020691e-06 3.9164703487188438e-03 +1.8602739726027395e-01 8.0179409981343192e-05 3.3077004639177460e-06 3.9496262687956250e-03 +1.8758904109589039e-01 1.0592621834074008e-04 4.1682285190154812e-06 3.9827821888724071e-03 +1.8915068493150683e-01 1.0646772104676621e-04 4.7078845893306446e-06 4.0159381089491901e-03 +1.9071232876712327e-01 1.2456819202996805e-04 4.7782459394443841e-06 4.0490940290259722e-03 +1.9227397260273971e-01 1.1349550456493325e-04 4.3731759523276373e-06 4.0822499491027543e-03 +1.9383561643835615e-01 8.5509829839194903e-05 3.6294238760150227e-06 4.1154058691795364e-03 +1.9539726027397258e-01 6.5778096100330160e-05 2.7591212037832144e-06 4.1485617892563185e-03 +1.9695890410958902e-01 4.6852815565799077e-05 1.9568226852513656e-06 4.1817177093331007e-03 +1.9852054794520546e-01 3.2926723465236025e-05 1.3347573577509058e-06 4.2148736294098828e-03 +2.0008219178082190e-01 2.2908498307238552e-05 9.1293337714010044e-07 4.2480295494866649e-03 +2.0164383561643834e-01 1.7140451719298772e-05 6.5219336118153878e-07 4.2811854695634470e-03 +2.0320547945205478e-01 1.1832576597729186e-05 4.9723009631395932e-07 4.3143413896402291e-03 +2.0476712328767122e-01 9.9048682301423193e-06 4.0379808661538505e-07 4.3474973097170112e-03 +2.0632876712328765e-01 8.1761499787379378e-06 3.4490184358092520e-07 4.3806532297937933e-03 +2.0789041095890409e-01 7.6811883584017161e-06 3.0572536977881636e-07 4.4138091498705755e-03 +2.0945205479452053e-01 6.6537881582242089e-06 2.7790165054513857e-07 4.4469650699473576e-03 +2.1101369863013697e-01 6.2719482825746971e-06 2.5681913365662896e-07 4.4801209900241397e-03 +2.1257534246575341e-01 5.2518191313783530e-06 2.4043729767108238e-07 4.5132769101009218e-03 +2.1413698630136985e-01 5.1553341274555202e-06 2.2796634156648921e-07 4.5464328301777039e-03 +2.1569863013698629e-01 4.7897216395928141e-06 2.1870800737729945e-07 4.5795887502544860e-03 +2.1726027397260272e-01 5.1146731182886540e-06 2.1183490888344697e-07 4.6127446703312690e-03 +2.1882191780821916e-01 4.8066675899898962e-06 2.0682602179634930e-07 4.6459005904080503e-03 +2.2038356164383560e-01 4.5292467334817117e-06 2.0365564472996750e-07 4.6790565104848324e-03 +2.2194520547945204e-01 4.4697759254248486e-06 2.0245521637619874e-07 4.7122124305616145e-03 +2.2350684931506848e-01 4.6778956078619852e-06 2.0317691571019562e-07 4.7453683506383975e-03 +2.2506849315068492e-01 4.4984196975686367e-06 2.0570894409476275e-07 4.7785242707151796e-03 +2.2663013698630136e-01 4.5601076775481586e-06 2.1022980793025072e-07 4.8116801907919608e-03 +2.2819178082191779e-01 5.0383779177349283e-06 2.1735089896758175e-07 4.8448361108687429e-03 +2.2975342465753423e-01 5.7628672160365351e-06 2.2804635715675311e-07 4.8779920309455251e-03 +2.3131506849315067e-01 5.5357199788212607e-06 2.4382938285636509e-07 4.9111479510223080e-03 +2.3287671232876711e-01 6.0397887932852566e-06 2.6754020963224629e-07 4.9443038710990901e-03 +2.3443835616438355e-01 6.7602996979066949e-06 3.0468536165758060e-07 4.9774597911758714e-03 +2.3599999999999999e-01 8.1372729045452859e-06 3.6498106502628267e-07 5.0106157112526535e-03 +2.3756164383561643e-01 1.1331603265917672e-05 4.6353030508149782e-07 5.0437716313294365e-03 +2.3912328767123286e-01 1.5540585956513061e-05 6.2050629282865083e-07 5.0769275514062186e-03 +2.4068493150684930e-01 2.0947735211486439e-05 8.5759468078639664e-07 5.1100834714830007e-03 +2.4224657534246574e-01 2.9644363114968154e-05 1.1899446439415731e-06 5.1432393915597820e-03 +2.4380821917808218e-01 4.0058610132450559e-05 1.6148568580918673e-06 5.1763953116365649e-03 +2.4536986301369862e-01 5.2339452670602454e-05 2.1019204485760652e-06 5.2095512317133471e-03 +2.4693150684931506e-01 6.0323020512446700e-05 2.5910979225212441e-06 5.2427071517901292e-03 +2.4849315068493150e-01 7.3199977878744371e-05 3.0030288100447653e-06 5.2758630718669113e-03 +2.5005479452054791e-01 8.1362591206025648e-05 3.2599668941559596e-06 5.3090189919436925e-03 +2.5161643835616437e-01 7.9648629180277127e-05 3.3096246918011648e-06 5.3421749120204755e-03 +2.5317808219178078e-01 7.6547362138290110e-05 3.1417677497019048e-06 5.3753308320972568e-03 +2.5473972602739725e-01 6.6976541378562589e-05 2.7904915735806805e-06 5.4084867521740397e-03 +2.5630136986301366e-01 5.6772952415037635e-05 2.3218918246563630e-06 5.4416426722508210e-03 +2.5786301369863013e-01 4.6378095244650446e-05 1.8132479370461362e-06 5.4747985923276040e-03 +2.5942465753424654e-01 3.2270988903656982e-05 1.3325104589320736e-06 5.5079545124043852e-03 +2.6098630136986301e-01 2.2484396285652809e-05 9.2506949431893017e-07 5.5411104324811682e-03 +2.6254794520547942e-01 1.5255434894830785e-05 6.1035123555031036e-07 5.5742663525579494e-03 +2.6410958904109588e-01 9.4096193253492641e-06 3.8641872173527093e-07 5.6074222726347324e-03 +2.6567123287671229e-01 5.8640953077759062e-06 2.3839842015537893e-07 5.6405781927115145e-03 +2.6723287671232876e-01 2.8625057381295740e-06 1.4677332757139411e-07 5.6737341127882967e-03 +2.6879452054794517e-01 1.9101482943884512e-06 9.3211178494631464e-08 5.7068900328650779e-03 +2.7035616438356164e-01 1.0540506875230536e-06 6.3384806848368287e-08 5.7400459529418609e-03 +2.7191780821917805e-01 6.9284895341920960e-07 4.7458268516590512e-08 5.7732018730186430e-03 +2.7347945205479451e-01 5.1611387531684173e-07 3.9326763333094819e-08 5.8063577930954251e-03 +2.7504109589041092e-01 4.2566964602064877e-07 3.5489041205685207e-08 5.8395137131722064e-03 +2.7660273972602739e-01 3.8655631834348766e-07 3.4046790524367439e-08 5.8726696332489893e-03 +2.7816438356164380e-01 3.5547077555076046e-07 3.4007837297472876e-08 5.9058255533257715e-03 +2.7972602739726027e-01 4.2777039126953823e-07 3.4865920786798261e-08 5.9389814734025544e-03 +2.8128767123287668e-01 3.9902249592126679e-07 3.6372600173892624e-08 5.9721373934793357e-03 +2.8284931506849315e-01 4.5528335856415829e-07 3.8428040690249271e-08 6.0052933135561178e-03 +2.8441095890410956e-01 5.5877238677304467e-07 4.1038721439972380e-08 6.0384492336328999e-03 +2.8597260273972602e-01 5.8333198854683821e-07 4.4310543995543408e-08 6.0716051537096829e-03 +2.8753424657534243e-01 8.1641318758378729e-07 4.8467204114813442e-08 6.1047610737864641e-03 +2.8909589041095890e-01 9.0262581444978549e-07 5.3907324611405525e-08 6.1379169938632462e-03 +2.9065753424657531e-01 1.1666617013033334e-06 6.1329398973792401e-08 6.1710729139400275e-03 +2.9221917808219178e-01 1.2967114543043600e-06 7.1944976509988101e-08 6.2042288340168105e-03 +2.9378082191780819e-01 1.6609502055793508e-06 8.7783157119098682e-08 6.2373847540935926e-03 +2.9534246575342465e-01 2.3194167546268647e-06 1.1202810732447079e-07 6.2705406741703756e-03 +2.9690410958904107e-01 3.3414669210379364e-06 1.4927259131887806e-07 6.3036965942471568e-03 +2.9846575342465753e-01 4.5168698045887506e-06 2.0550353073163511e-07 6.3368525143239389e-03 +3.0002739726027394e-01 6.7982322795189892e-06 2.8756178273015359e-07 6.3700084344007210e-03 +3.0158904109589041e-01 9.5407318030252876e-06 4.0189941731728035e-07 6.4031643544775040e-03 +3.0315068493150682e-01 1.4208320717047832e-05 5.5261859716229493e-07 6.4363202745542853e-03 +3.0471232876712329e-01 1.7488356769747357e-05 7.3912710568830777e-07 6.4694761946310674e-03 +3.0627397260273970e-01 2.4350402293729893e-05 9.5409076053206654e-07 6.5026321147078495e-03 +3.0783561643835611e-01 2.8303180759536388e-05 1.1825841426683348e-06 6.5357880347846307e-03 +3.0939726027397257e-01 3.3230052519780461e-05 1.4031174029603295e-06 6.5689439548614137e-03 +3.1095890410958898e-01 3.8139634331598630e-05 1.5907826535806415e-06 6.6020998749381950e-03 +3.1252054794520545e-01 4.0498913170977444e-05 1.7219333562655720e-06 6.6352557950149780e-03 +3.1408219178082186e-01 4.4238731801511959e-05 1.7791341948595093e-06 6.6684117150917601e-03 +3.1564383561643833e-01 4.5186119576667657e-05 1.7550482834680751e-06 6.7015676351685430e-03 +3.1720547945205474e-01 4.1118571644674828e-05 1.6538726305708223e-06 6.7347235552453243e-03 +3.1876712328767121e-01 3.7684872266620126e-05 1.4901806037600757e-06 6.7678794753221064e-03 +3.2032876712328762e-01 3.3732879458037057e-05 1.2853955973194545e-06 6.8010353953988877e-03 +3.2189041095890408e-01 2.6367408629011904e-05 1.0632125251301977e-06 6.8341913154756706e-03 +3.2345205479452049e-01 2.0795494369133284e-05 8.4522155737833704e-07 6.8673472355524519e-03 +3.2501369863013696e-01 1.5516481798975562e-05 6.4778651636365694e-07 6.9005031556292349e-03 +3.2657534246575337e-01 1.0744345948800583e-05 4.8069692264277991e-07 6.9336590757060170e-03 +3.2813698630136984e-01 8.4506299115083763e-06 3.4746586272407076e-07 6.9668149957828000e-03 +3.2969863013698625e-01 5.2343045885680789e-06 2.4670530818454438e-07 6.9999709158595812e-03 +3.3126027397260271e-01 3.9636362987194882e-06 1.7400275210225636e-07 7.0331268359363642e-03 +3.3282191780821913e-01 2.5893018288513378e-06 1.2364289746616687e-07 7.0662827560131454e-03 +3.3438356164383559e-01 1.8492288195835134e-06 8.9916502880634841e-08 7.0994386760899275e-03 +3.3594520547945200e-01 1.1926233975199007e-06 6.7888970028179890e-08 7.1325945961667088e-03 +3.3750684931506847e-01 7.9848078729605774e-07 5.3707719622927921e-08 7.1657505162434918e-03 +3.3906849315068488e-01 6.3076946497780143e-07 4.4592375833918088e-08 7.1989064363202739e-03 +3.4063013698630135e-01 4.4506208179014020e-07 3.8669034898939382e-08 7.2320623563970569e-03 +3.4219178082191776e-01 3.6214761901123895e-07 3.4737343132870037e-08 7.2652182764738381e-03 +3.4375342465753422e-01 3.0287091908205119e-07 3.2061139757308805e-08 7.2983741965506211e-03 +3.4531506849315063e-01 2.5094554566039505e-07 3.0200359814723264e-08 7.3315301166274023e-03 +3.4687671232876710e-01 2.1691490344590215e-07 2.8894812390406096e-08 7.3646860367041853e-03 +3.4843835616438351e-01 1.7661899454948095e-07 2.7996998216060895e-08 7.3978419567809674e-03 +3.4999999999999998e-01 1.8075016850912641e-07 2.7437581686393181e-08 7.4309978768577487e-03 diff --git a/examples/datasets/swapped_layers.ort b/examples/datasets/swapped_layers.ort new file mode 100644 index 00000000..6c33c9ce --- /dev/null +++ b/examples/datasets/swapped_layers.ort @@ -0,0 +1,250 @@ +# # ORSO reflectivity data file | 1.2 standard | YAML encoding | https://www.reflectometry.org/ +# data_source: +# owner: +# name: EasyReflectometry +# affiliation: EasyScience +# experiment: +# title: 'Layer ordering: thin capping layer on a thick layer' +# instrument: simulation +# start_date: 2026-08-24T00:00:00 +# probe: neutron +# sample: +# name: air / thin TopMat / thick BottomMat / Si +# description: 'TRUTH: t_top = 20 A (SLD 2.5), t_bottom = 60 A (SLD 4.2), roughness +# 3 A. Demo: start the fit from swapped thicknesses (60 / 20) and use the inequality +# t_top < t_bottom to keep the physical assignment.' +# model: +# stack: ambient | top | bottom | substrate +# origin: simulated ground truth +# layers: +# ambient: +# thickness: {magnitude: 0.0, unit: angstrom} +# roughness: {magnitude: 0.0, unit: angstrom} +# material: air +# top: +# thickness: {magnitude: 20.0, unit: angstrom} +# roughness: {magnitude: 3.0, unit: angstrom} +# material: TopMat +# bottom: +# thickness: {magnitude: 60.0, unit: angstrom} +# roughness: {magnitude: 3.0, unit: angstrom} +# material: BottomMat +# substrate: +# thickness: {magnitude: 0.0, unit: angstrom} +# roughness: {magnitude: 2.0, unit: angstrom} +# material: Si +# materials: +# air: +# sld: {magnitude: 0.0, unit: 1/angstrom^2} +# TopMat: +# sld: {magnitude: 2.4999999999999998e-06, unit: 1/angstrom^2} +# BottomMat: +# sld: {magnitude: 4.2e-06, unit: 1/angstrom^2} +# Si: +# sld: {magnitude: 2.0699999999999997e-06, unit: 1/angstrom^2} +# globals: +# roughness: {magnitude: 0.3, unit: nm} +# length_unit: angstrom +# mass_density_unit: g/cm^3 +# number_density_unit: 1/nm^3 +# sld_unit: 1/angstrom^2 +# magnetic_moment_unit: muB +# slice_resolution: {magnitude: 1.0, unit: nm} +# default_solvent: +# formula: H2O +# mass_density: {magnitude: 1.0, unit: g/cm^3} +# measurement: +# instrument_settings: +# incident_angle: {min: 0.1, max: 3.0, unit: deg} +# wavelength: {magnitude: 6.0, unit: angstrom} +# polarization: unpolarized +# data_files: [] +# reduction: +# software: {name: easyreflectometry (simulated)} +# data_set: 0 +# columns: +# - {name: Qz, unit: 1/angstrom, physical_quantity: normal wavevector transfer} +# - {name: R, physical_quantity: reflectivity} +# - {error_of: R, error_type: uncertainty, value_is: sigma} +# - {error_of: Qz, error_type: resolution, value_is: sigma} +# # Qz (1/angstrom) R sR sQz +8.0000000000000002e-03 1.0075183270871562e+00 3.9998280674389437e-02 1.6985138004246286e-04 +9.6312849162011171e-03 9.7837357421908167e-01 3.9970748835097901e-02 2.0448587932486449e-04 +1.1262569832402234e-02 2.5083792836902746e-01 1.0202101787333936e-02 2.3912037860726612e-04 +1.2893854748603353e-02 1.0678356220686762e-01 4.7336439837709519e-03 2.7375487788966783e-04 +1.4525139664804468e-02 7.6549400163743245e-02 2.8563707457902603e-03 3.0838937717206941e-04 +1.6156424581005586e-02 5.0497933730890225e-02 1.9315373830649295e-03 3.4302387645447109e-04 +1.7787709497206705e-02 3.4431237801147617e-02 1.3954337524613887e-03 3.7765837573687278e-04 +1.9418994413407820e-02 2.7133358246029173e-02 1.0527687539954567e-03 4.1229287501927430e-04 +2.1050279329608935e-02 2.0699027580122754e-02 8.1877121513217600e-04 4.4692737430167593e-04 +2.2681564245810054e-02 1.5917432170252068e-02 6.5114197871270023e-04 4.8156187358407767e-04 +2.4312849162011173e-02 1.3679994955850157e-02 5.2662725182365589e-04 5.1619637286647924e-04 +2.5944134078212288e-02 1.0652691417535543e-02 4.3148771002750205e-04 5.5083087214888088e-04 +2.7575418994413407e-02 8.8104957381836650e-03 3.5713723998885143e-04 5.8546537143128261e-04 +2.9206703910614522e-02 7.2125404975361953e-03 2.9796283150935527e-04 6.2009987071368425e-04 +3.0837988826815640e-02 6.3672221463518842e-03 2.5015645787082120e-04 6.5473436999608577e-04 +3.2469273743016752e-02 5.2548889194191581e-03 2.1105299862768547e-04 6.8936886927848729e-04 +3.4100558659217878e-02 4.5653904846773251e-03 1.7873708674043362e-04 7.2400336856088914e-04 +3.5731843575418989e-02 3.7023362277406879e-03 1.5180028766590702e-04 7.5863786784329066e-04 +3.7363128491620108e-02 3.2455342078530338e-03 1.2918599305905380e-04 7.9327236712569240e-04 +3.8994413407821227e-02 2.6534571768491327e-03 1.1008741270869788e-04 8.2790686640809403e-04 +4.0625698324022345e-02 2.4254645117461312e-03 9.3878753172081680e-05 8.6254136569049577e-04 +4.2256983240223457e-02 2.0162487645761389e-03 8.0067728888410435e-05 8.9717586497289729e-04 +4.3888268156424576e-02 1.7286187406925159e-03 6.8262130377447138e-05 9.3181036425529881e-04 +4.5519553072625694e-02 1.4770156835012030e-03 5.8145863165942015e-05 9.6644486353770055e-04 +4.7150837988826813e-02 1.1860437955963153e-03 4.9461494901470919e-05 1.0010793628201023e-03 +4.8782122905027932e-02 1.0823254330352024e-03 4.1997356074765991e-05 1.0357138621025039e-03 +5.0413407821229043e-02 9.6212009257595375e-04 3.5577878762203475e-05 1.0703483613849056e-03 +5.2044692737430162e-02 7.0166132576013666e-04 3.0056271969768478e-05 1.1049828606673072e-03 +5.3675977653631281e-02 5.8845314557586154e-04 2.5308906336622190e-05 1.1396173599497088e-03 +5.5307262569832399e-02 4.9832509071738714e-04 2.1230964523137749e-05 1.1742518592321104e-03 +5.6938547486033511e-02 4.5774761784971203e-04 1.7733039753641484e-05 1.2088863585145119e-03 +5.8569832402234630e-02 3.6985836704664143e-04 1.4738451900803185e-05 1.2435208577969137e-03 +6.0201117318435748e-02 3.1716320826094926e-04 1.2181111931588847e-05 1.2781553570793153e-03 +6.1832402234636867e-02 2.5682229155056264e-04 1.0003809236692055e-05 1.3127898563617170e-03 +6.3463687150837986e-02 2.0513828907144557e-04 8.1568276436515631e-06 1.3474243556441188e-03 +6.5094972067039097e-02 1.6629422342626031e-04 6.5968190076312142e-06 1.3820588549265202e-03 +6.6726256983240223e-02 1.3074966650846560e-04 5.2858800051618662e-06 1.4166933542089221e-03 +6.8357541899441335e-02 1.0790929455345911e-04 4.1907903937194536e-06 1.4513278534913235e-03 +6.9988826815642446e-02 7.7851349064208077e-05 3.2823802573424757e-06 1.4859623527737251e-03 +7.1620111731843572e-02 6.1805613649357072e-05 2.5350010486439524e-06 1.5205968520561270e-03 +7.3251396648044698e-02 4.8119932671636806e-05 1.9260805152118169e-06 1.5552313513385286e-03 +7.4882681564245795e-02 3.7980027884925609e-05 1.4357458169160653e-06 1.5898658506209300e-03 +7.6513966480446921e-02 2.4862545399354162e-05 1.0465023558982082e-06 1.6245003499033316e-03 +7.8145251396648047e-02 1.7272263153569231e-05 7.4295840776695585e-07 1.6591348491857335e-03 +7.9776536312849144e-02 1.2001515548040415e-05 5.1158744834432471e-07 1.6937693484681347e-03 +8.1407821229050270e-02 8.3431048184998368e-06 3.4052185790991354e-07 1.7284038477505365e-03 +8.3039106145251396e-02 4.9327637721084845e-06 2.1937270261135582e-07 1.7630383470329386e-03 +8.4670391061452493e-02 3.1609634832101075e-06 1.3907138819188173e-07 1.7976728463153396e-03 +8.6301675977653619e-02 1.6214769676977481e-06 9.1729744907154644e-08 1.8323073455977416e-03 +8.7932960893854745e-02 1.3424717785807039e-06 7.0515725997006751e-08 1.8669418448801435e-03 +8.9564245810055870e-02 1.3105268763615197e-06 6.9542392101278559e-08 1.9015763441625449e-03 +9.1195530726256968e-02 1.4753688387742154e-06 8.3768323403137675e-08 1.9362108434449465e-03 +9.2826815642458094e-02 2.2394232936022432e-06 1.0890788063343707e-07 1.9708453427273480e-03 +9.4458100558659219e-02 3.2055982628977547e-06 1.4135003513776025e-07 2.0054798420097498e-03 +9.6089385474860317e-02 3.9677745345558388e-06 1.7808469993151526e-07 2.0401143412921512e-03 +9.7720670391061443e-02 5.1324636632495855e-06 2.1663570561047747e-07 2.0747488405745531e-03 +9.9351955307262568e-02 6.4805870768240372e-06 2.5499966808732900e-07 2.1093833398569549e-03 +1.0098324022346367e-01 6.8696296689978572e-06 2.9159014888388728e-07 2.1440178391393559e-03 +1.0261452513966479e-01 7.5384884628744029e-06 3.2518660138536285e-07 2.1786523384217577e-03 +1.0424581005586592e-01 8.0985549315148681e-06 3.5488767354789199e-07 2.2132868377041596e-03 +1.0587709497206702e-01 9.2480215043918649e-06 3.8006850247113212e-07 2.2479213369865610e-03 +1.0750837988826814e-01 9.4297830833594728e-06 4.0034169324952865e-07 2.2825558362689628e-03 +1.0913966480446927e-01 9.8137702825446010e-06 4.1552173136045406e-07 2.3171903355513647e-03 +1.1077094972067039e-01 1.0095016337503118e-05 4.2559256466218180e-07 2.3518248348337665e-03 +1.1240223463687149e-01 1.0546839466142918e-05 4.3067819118641807e-07 2.3864593341161680e-03 +1.1403351955307262e-01 9.8157914436934325e-06 4.3101603668349473e-07 2.4210938333985698e-03 +1.1566480446927374e-01 9.5201699346888812e-06 4.2693298698261365e-07 2.4557283326809712e-03 +1.1729608938547484e-01 8.9512316996075757e-06 4.1882389689918449e-07 2.4903628319633726e-03 +1.1892737430167596e-01 1.0166329186472999e-05 4.0713245444528710e-07 2.5249973312457740e-03 +1.2055865921787709e-01 9.3373080933364033e-06 3.9233425721667124e-07 2.5596318305281759e-03 +1.2218994413407819e-01 9.4392262864637853e-06 3.7492197343871376e-07 2.5942663298105773e-03 +1.2382122905027931e-01 8.3816286124979973e-06 3.5539246453426222e-07 2.6289008290929792e-03 +1.2545251396648044e-01 7.6078388103244943e-06 3.3423574775369917e-07 2.6635353283753810e-03 +1.2708379888268156e-01 7.4472204062254668e-06 3.1192567939969464e-07 2.6981698276577828e-03 +1.2871508379888266e-01 6.7006786935843297e-06 2.8891224094385393e-07 2.7328043269401843e-03 +1.3034636871508379e-01 5.8072516068922766e-06 2.6561531151227785e-07 2.7674388262225861e-03 +1.3197765363128491e-01 5.3459376340375641e-06 2.4241981234502868e-07 2.8020733255049880e-03 +1.3360893854748604e-01 5.3799148931501303e-06 2.1967211009616139e-07 2.8367078247873894e-03 +1.3524022346368714e-01 4.5119862886026898e-06 1.9767756503008694e-07 2.8713423240697908e-03 +1.3687150837988826e-01 3.9910528700797441e-06 1.7669910960355998e-07 2.9059768233521926e-03 +1.3850279329608939e-01 3.3805124108506116e-06 1.5695676293368098e-07 2.9406113226345945e-03 +1.4013407821229049e-01 2.8700844658507206e-06 1.3862795514393526e-07 2.9752458219169955e-03 +1.4176536312849161e-01 2.6548613200766781e-06 1.2184857324495182e-07 3.0098803211993973e-03 +1.4339664804469274e-01 2.1567004153112176e-06 1.0671462351271044e-07 3.0445148204817992e-03 +1.4502793296089384e-01 1.7612973401225937e-06 9.3284411751305742e-08 3.0791493197642006e-03 +1.4665921787709496e-01 1.5285864136166936e-06 8.1581150903769923e-08 3.1137838190466024e-03 +1.4829050279329609e-01 1.2250601722531939e-06 7.1595914528933410e-08 3.1484183183290043e-03 +1.4992178770949718e-01 1.0942990870768630e-06 6.3290837074794014e-08 3.1830528176114052e-03 +1.5155307262569831e-01 9.7897971431582679e-07 5.6602504109759596e-08 3.2176873168938071e-03 +1.5318435754189944e-01 7.4312338242503143e-07 5.1445446276911043e-08 3.2523218161762089e-03 +1.5481564245810056e-01 7.6105578936294127e-07 4.7715673974177841e-08 3.2869563154586108e-03 +1.5644692737430166e-01 6.0211557561247426e-07 4.5294195359727912e-08 3.3215908147410122e-03 +1.5807821229050278e-01 6.0801944668654590e-07 4.4050462796795962e-08 3.3562253140234136e-03 +1.5970949720670391e-01 5.5947050694408466e-07 4.3845698328752218e-08 3.3908598133058159e-03 +1.6134078212290501e-01 6.0350396554678370e-07 4.4536057749984721e-08 3.4254943125882169e-03 +1.6297206703910613e-01 6.5156939175751159e-07 4.5975597822147458e-08 3.4601288118706192e-03 +1.6460335195530726e-01 6.7960228582081331e-07 4.8019017188915112e-08 3.4947633111530206e-03 +1.6623463687150836e-01 7.2759087595673119e-07 5.0524147506939141e-08 3.5293978104354220e-03 +1.6786592178770948e-01 7.9768636626505746e-07 5.3354177043893482e-08 3.5640323097178234e-03 +1.6949720670391061e-01 8.6319319637352664e-07 5.6379594451690433e-08 3.5986668090002252e-03 +1.7112849162011173e-01 8.9360802373329420e-07 5.9479845569785995e-08 3.6333013082826275e-03 +1.7275977653631283e-01 1.0471701674427119e-06 6.2544700924794442e-08 3.6679358075650281e-03 +1.7439106145251396e-01 1.1631493698517710e-06 6.5475336050721653e-08 3.7025703068474304e-03 +1.7602234636871508e-01 1.2665677832106473e-06 6.8185131152365475e-08 3.7372048061298318e-03 +1.7765363128491618e-01 1.3106906064133434e-06 7.0600200949715173e-08 3.7718393054122336e-03 +1.7928491620111731e-01 1.4950408871294489e-06 7.2659665496180088e-08 3.8064738046946350e-03 +1.8091620111731843e-01 1.3815750170848313e-06 7.4315678999573098e-08 3.8411083039770373e-03 +1.8254748603351953e-01 1.3538625280856807e-06 7.5533236154453164e-08 3.8757428032594379e-03 +1.8417877094972065e-01 1.5500310706323631e-06 7.6289779104409803e-08 3.9103773025418401e-03 +1.8581005586592178e-01 1.3341751225048266e-06 7.6574617915854529e-08 3.9450118018242416e-03 +1.8744134078212288e-01 1.4836851120657607e-06 7.6388194252033301e-08 3.9796463011066430e-03 +1.8907262569832400e-01 1.3211866089229985e-06 7.5741217465510444e-08 4.0142808003890453e-03 +1.9070391061452513e-01 1.3927777609588049e-06 7.4653680890899002e-08 4.0489152996714475e-03 +1.9233519553072626e-01 1.1848492523537076e-06 7.3153798032597484e-08 4.0835497989538489e-03 +1.9396648044692735e-01 1.3460193295144459e-06 7.1276876346116784e-08 4.1181842982362504e-03 +1.9559776536312848e-01 1.2156744002774018e-06 6.9064145463913380e-08 4.1528187975186518e-03 +1.9722905027932960e-01 1.0996289809373888e-06 6.6561575365910191e-08 4.1874532968010532e-03 +1.9886033519553070e-01 1.2025817186427895e-06 6.3818688662511800e-08 4.2220877960834546e-03 +2.0049162011173183e-01 1.0687854883022903e-06 6.0887401223734336e-08 4.2567222953658560e-03 +2.0212290502793295e-01 9.4817107014970954e-07 5.7820896800178829e-08 4.2913567946482583e-03 +2.0375418994413405e-01 8.2605867056877449e-07 5.4672558720611785e-08 4.3259912939306597e-03 +2.0538547486033518e-01 7.8514126890465850e-07 5.1494968965572193e-08 4.3606257932130611e-03 +2.0701675977653630e-01 7.0055053265816274e-07 4.8338987322095223e-08 4.3952602924954634e-03 +2.0864804469273743e-01 6.6412122499391708e-07 4.5252918923754361e-08 4.4298947917778657e-03 +2.1027932960893853e-01 5.9078860371731512e-07 4.2281782499568516e-08 4.4645292910602662e-03 +2.1191061452513965e-01 4.6031279297069286e-07 3.9466676725692289e-08 4.4991637903426685e-03 +2.1354189944134078e-01 4.0086525660858598e-07 3.6844256253080697e-08 4.5337982896250699e-03 +2.1517318435754187e-01 3.4283024273133178e-07 3.4446317354615248e-08 4.5684327889074713e-03 +2.1680446927374300e-01 2.6390493697942810e-07 3.2299487711768506e-08 4.6030672881898728e-03 +2.1843575418994413e-01 2.4262693700754029e-07 3.0425024697189706e-08 4.6377017874722742e-03 +2.2006703910614522e-01 2.1830304586302628e-07 2.8838719043715474e-08 4.6723362867546756e-03 +2.2169832402234635e-01 2.0780871125423534e-07 2.7550894679979143e-08 4.7069707860370779e-03 +2.2332960893854747e-01 1.9924036479824859e-07 2.6566500117550966e-08 4.7416052853194801e-03 +2.2496089385474857e-01 1.2620790470074812e-07 2.5885286782430682e-08 4.7762397846018807e-03 +2.2659217877094970e-01 1.5160267608330349e-07 2.5502065460013416e-08 4.8108742838842830e-03 +2.2822346368715082e-01 1.2388342846994565e-07 2.5407030259589096e-08 4.8455087831666844e-03 +2.2985474860335195e-01 1.9274959891371101e-07 2.5586141992326597e-08 4.8801432824490867e-03 +2.3148603351955305e-01 1.4927117063248278e-07 2.6021561333493831e-08 4.9147777817314872e-03 +2.3311731843575417e-01 1.8070587999740013e-07 2.6692121829181424e-08 4.9494122810138895e-03 +2.3474860335195530e-01 1.6353224935967035e-07 2.7573833134276727e-08 4.9840467802962909e-03 +2.3637988826815640e-01 1.9279163029236865e-07 2.8640404168191991e-08 5.0186812795786923e-03 +2.3801117318435752e-01 2.5261779955409031e-07 2.9863776645537405e-08 5.0533157788610946e-03 +2.3964245810055865e-01 2.6837836944165694e-07 3.1214659940501186e-08 5.0879502781434960e-03 +2.4127374301675975e-01 3.2811595082338863e-07 3.2663058592961290e-08 5.1225847774258974e-03 +2.4290502793296087e-01 3.0108835955419892e-07 3.4178784374314820e-08 5.1572192767082988e-03 +2.4453631284916200e-01 4.1695336385940001e-07 3.5731945532940098e-08 5.1918537759907011e-03 +2.4616759776536312e-01 3.9879047865409914e-07 3.7293406615165327e-08 5.2264882752731025e-03 +2.4779888268156422e-01 5.3777100691176334e-07 3.8835213091222823e-08 5.2611227745555040e-03 +2.4943016759776535e-01 4.9664023188482901e-07 4.0330975890798300e-08 5.2957572738379054e-03 +2.5106145251396644e-01 5.8972292768476705e-07 4.1756211853791917e-08 5.3303917731203068e-03 +2.5269273743016757e-01 5.1710799530712778e-07 4.3088637013854100e-08 5.3650262724027091e-03 +2.5432402234636869e-01 6.2671618982762831e-07 4.4308410541025262e-08 5.3996607716851113e-03 +2.5595530726256982e-01 5.9464314013965540e-07 4.5398328062216138e-08 5.4342952709675128e-03 +2.5758659217877089e-01 6.3603472911090172e-07 4.6343963942156507e-08 5.4689297702499133e-03 +2.5921787709497207e-01 6.7950339333983432e-07 4.7133762931706173e-08 5.5035642695323165e-03 +2.6084916201117314e-01 6.7883384970946657e-07 4.7759082366870805e-08 5.5381987688147170e-03 +2.6248044692737427e-01 7.1809554433906156e-07 4.8214186968827388e-08 5.5728332680971184e-03 +2.6411173184357539e-01 7.4866341340151890e-07 4.8496198687066762e-08 5.6074677673795198e-03 +2.6574301675977652e-01 7.4685358507405850e-07 4.8605004256988399e-08 5.6421022666619221e-03 +2.6737430167597764e-01 7.0933148874704411e-07 4.8543124530610548e-08 5.6767367659443235e-03 +2.6900558659217877e-01 6.4201384349704622e-07 4.8315549512353963e-08 5.7113712652267258e-03 +2.7063687150837984e-01 6.5967226554771373e-07 4.7929543442622628e-08 5.7460057645091264e-03 +2.7226815642458096e-01 6.7558286332666368e-07 4.7394424647949258e-08 5.7806402637915286e-03 +2.7389944134078209e-01 5.6889860836085912e-07 4.6721325184465670e-08 5.8152747630739300e-03 +2.7553072625698322e-01 6.8261108130696209e-07 4.5922933380457635e-08 5.8499092623563323e-03 +2.7716201117318434e-01 6.1454375088048185e-07 4.5013225555338474e-08 5.8845437616387337e-03 +2.7879329608938547e-01 5.8911993989637687e-07 4.4007191859526878e-08 5.9191782609211352e-03 +2.8042458100558659e-01 6.1371885405147953e-07 4.2920560616618846e-08 5.9538127602035374e-03 +2.8205586592178766e-01 5.7213642016699696e-07 4.1769524632944380e-08 5.9884472594859380e-03 +2.8368715083798879e-01 5.2322795769254960e-07 4.0570473751571600e-08 6.0230817587683394e-03 +2.8531843575418991e-01 4.4410127125393801e-07 3.9339738926147402e-08 6.0577162580507417e-03 +2.8694972067039104e-01 4.6415874077956426e-07 3.8093349724482445e-08 6.0923507573331440e-03 +2.8858100558659217e-01 4.3392573059381561e-07 3.6846807735530272e-08 6.1269852566155454e-03 +2.9021229050279329e-01 3.5886093450754074e-07 3.5614881269607024e-08 6.1616197558979477e-03 +2.9184357541899436e-01 3.9618737967084604e-07 3.4411421442783146e-08 6.1962542551803473e-03 +2.9347486033519549e-01 3.4811681127308363e-07 3.3249198775847675e-08 6.2308887544627496e-03 +2.9510614525139661e-01 2.4019094236706486e-07 3.2139768545108067e-08 6.2655232537451510e-03 +2.9673743016759774e-01 2.8112584517336710e-07 3.1093361076069625e-08 6.3001577530275533e-03 +2.9836871508379886e-01 2.1487864438059661e-07 3.0118792851217442e-08 6.3347922523099547e-03 +2.9999999999999999e-01 2.6379226639321670e-07 2.9223410494579879e-08 6.3694267515923561e-03 diff --git a/examples/datasets/two_layer_film.ort b/examples/datasets/two_layer_film.ort new file mode 100644 index 00000000..512de748 --- /dev/null +++ b/examples/datasets/two_layer_film.ort @@ -0,0 +1,250 @@ +# # ORSO reflectivity data file | 1.2 standard | YAML encoding | https://www.reflectometry.org/ +# data_source: +# owner: +# name: EasyReflectometry +# affiliation: EasyScience +# experiment: +# title: Two-layer film with a 90 A thickness budget +# instrument: simulation +# start_date: 2026-08-24T00:00:00 +# probe: neutron +# sample: +# name: air / MatA / MatB / Si +# description: 'TRUTH: t_A = 35 A (SLD 3.0), t_B = 55 A (SLD 5.0), roughness 3 A, +# total film thickness exactly 90 A. Demo: derived total_thickness, inequality +# constraints t_A < t_B and t_A + t_B <= 90 (BUMPS only).' +# model: +# stack: ambient | filmA | filmB | substrate +# origin: simulated ground truth +# layers: +# ambient: +# thickness: {magnitude: 0.0, unit: angstrom} +# roughness: {magnitude: 0.0, unit: angstrom} +# material: air +# filmA: +# thickness: {magnitude: 35.0, unit: angstrom} +# roughness: {magnitude: 3.0, unit: angstrom} +# material: MatA +# filmB: +# thickness: {magnitude: 55.0, unit: angstrom} +# roughness: {magnitude: 3.0, unit: angstrom} +# material: MatB +# substrate: +# thickness: {magnitude: 0.0, unit: angstrom} +# roughness: {magnitude: 2.0, unit: angstrom} +# material: Si +# materials: +# air: +# sld: {magnitude: 0.0, unit: 1/angstrom^2} +# MatA: +# sld: {magnitude: 3.0e-06, unit: 1/angstrom^2} +# MatB: +# sld: {magnitude: 4.9999999999999996e-06, unit: 1/angstrom^2} +# Si: +# sld: {magnitude: 2.0699999999999997e-06, unit: 1/angstrom^2} +# globals: +# roughness: {magnitude: 0.3, unit: nm} +# length_unit: angstrom +# mass_density_unit: g/cm^3 +# number_density_unit: 1/nm^3 +# sld_unit: 1/angstrom^2 +# magnetic_moment_unit: muB +# slice_resolution: {magnitude: 1.0, unit: nm} +# default_solvent: +# formula: H2O +# mass_density: {magnitude: 1.0, unit: g/cm^3} +# measurement: +# instrument_settings: +# incident_angle: {min: 0.1, max: 3.0, unit: deg} +# wavelength: {magnitude: 6.0, unit: angstrom} +# polarization: unpolarized +# data_files: [] +# reduction: +# software: {name: easyreflectometry (simulated)} +# data_set: 0 +# columns: +# - {name: Qz, unit: 1/angstrom, physical_quantity: normal wavevector transfer} +# - {name: R, physical_quantity: reflectivity} +# - {error_of: R, error_type: uncertainty, value_is: sigma} +# - {error_of: Qz, error_type: resolution, value_is: sigma} +# # Qz (1/angstrom) R sR sQz +8.0000000000000002e-03 1.0137792903705753e+00 3.9998280674389437e-02 1.6985138004246286e-04 +9.6312849162011171e-03 1.0322044714149361e+00 3.9974449535987888e-02 2.0448587932486449e-04 +1.1262569832402234e-02 3.1898252172297709e-01 1.2592874760258152e-02 2.3912037860726612e-04 +1.2893854748603353e-02 1.5390687589597299e-01 6.4948473262960785e-03 2.7375487788966783e-04 +1.4525139664804468e-02 1.0752783047800438e-01 4.1508146435594072e-03 3.0838937717206941e-04 +1.6156424581005586e-02 7.4012647899648454e-02 2.9085930370619595e-03 3.4302387645447109e-04 +1.7787709497206705e-02 5.2579152801148638e-02 2.1493501323453408e-03 3.7765837573687278e-04 +1.9418994413407820e-02 4.2062168042119631e-02 1.6442857529053677e-03 4.1229287501927430e-04 +2.1050279329608935e-02 3.2685954527672381e-02 1.2886657033724432e-03 4.4692737430167593e-04 +2.2681564245810054e-02 2.5997703933746695e-02 1.0278353660551224e-03 4.8156187358407767e-04 +2.4312849162011173e-02 2.0787398686204963e-02 8.3057167910046011e-04 5.1619637286647924e-04 +2.5944134078212288e-02 1.7315103238288561e-02 6.7780161179102237e-04 5.5083087214888088e-04 +2.7575418994413407e-02 1.3520437082474572e-02 5.5725313731787285e-04 5.8546537143128261e-04 +2.9206703910614522e-02 1.1441811843490671e-02 4.6069454269929988e-04 6.2009987071368425e-04 +3.0837988826815640e-02 9.3754298413555905e-03 3.8241192054599418e-04 6.5473436999608577e-04 +3.2469273743016752e-02 8.1482112878741679e-03 3.1832338145975779e-04 6.8936886927848729e-04 +3.4100558659217878e-02 6.6460548804461016e-03 2.6544044106863976e-04 7.2400336856088914e-04 +3.5731843575418989e-02 5.4729156107057794e-03 2.2152812019946878e-04 7.5863786784329066e-04 +3.7363128491620108e-02 4.4770264148735221e-03 1.8488353666984544e-04 7.9327236712569240e-04 +3.8994413407821227e-02 3.8145360487949111e-03 1.5418767691586886e-04 8.2790686640809403e-04 +4.0625698324022345e-02 3.2106394094234929e-03 1.2840375691421853e-04 8.6254136569049577e-04 +4.2256983240223457e-02 2.6377426066653544e-03 1.0670604409726252e-04 8.9717586497289729e-04 +4.3888268156424576e-02 2.3246595613967500e-03 8.8429068165702483e-05 9.3181036425529881e-04 +4.5519553072625694e-02 1.8987910453265137e-03 7.3030767823717039e-05 9.6644486353770055e-04 +4.7150837988826813e-02 1.3382866390785266e-03 6.0065341578048224e-05 1.0010793628201023e-03 +4.8782122905027932e-02 1.1357047719218803e-03 4.9162971015231073e-05 1.0357138621025039e-03 +5.0413407821229043e-02 9.9286872010199262e-04 4.0014485417234945e-05 1.0703483613849056e-03 +5.2044692737430162e-02 7.9482877935715750e-04 3.2359628163600230e-05 1.1049828606673072e-03 +5.3675977653631281e-02 6.5449956850222469e-04 2.5977982180806072e-05 1.1396173599497088e-03 +5.5307262569832399e-02 5.2104163653568113e-04 2.0681880413878372e-05 1.1742518592321104e-03 +5.6938547486033511e-02 4.4181403046200700e-04 1.6310814237717240e-05 1.2088863585145119e-03 +5.8569832402234630e-02 3.0352190090015033e-04 1.2726982800844689e-05 1.2435208577969137e-03 +6.0201117318435748e-02 2.4108802679413029e-04 9.8117192443731963e-06 1.2781553570793153e-03 +6.1832402234636867e-02 2.0130929295187423e-04 7.4625965075252126e-06 1.3127898563617170e-03 +6.3463687150837986e-02 1.4289234745708972e-04 5.5910635951266500e-06 1.3474243556441188e-03 +6.5094972067039097e-02 1.0524463049455496e-04 4.1204991376196558e-06 1.3820588549265202e-03 +6.6726256983240223e-02 7.2580780721096170e-05 2.9845952682350901e-06 1.4166933542089221e-03 +6.8357541899441335e-02 4.9146305157473008e-05 2.1260048377473356e-06 1.4513278534913235e-03 +6.9988826815642446e-02 3.7130385027473532e-05 1.4951996718744462e-06 1.4859623527737251e-03 +7.1620111731843572e-02 2.5851892997749271e-05 1.0494993115054464e-06 1.5205968520561270e-03 +7.3251396648044698e-02 1.7382692413569976e-05 7.5223813747525371e-07 1.5552313513385286e-03 +7.4882681564245795e-02 1.3410303937956790e-05 5.7204563058099199e-07 1.5898658506209300e-03 +7.6513966480446921e-02 1.1520752806861748e-05 4.8221974767686093e-07 1.6245003499033316e-03 +7.8145251396648047e-02 1.0569686548102938e-05 4.6017760343372679e-07 1.6591348491857335e-03 +7.9776536312849144e-02 1.1626409148283665e-05 4.8697054913970406e-07 1.6937693484681347e-03 +8.1407821229050270e-02 1.3223558774515351e-05 5.4685374094985853e-07 1.7284038477505365e-03 +8.3039106145251396e-02 1.5194856188260186e-05 6.2690188436770191e-07 1.7630383470329386e-03 +8.4670391061452493e-02 1.7053775255502797e-05 7.1666466386505218e-07 1.7976728463153396e-03 +8.6301675977653619e-02 2.0176078121911902e-05 8.0785659309919180e-07 1.8323073455977416e-03 +8.7932960893854745e-02 2.2648697291524181e-05 8.9407701601087738e-07 1.8669418448801435e-03 +8.9564245810055870e-02 2.4075320563479874e-05 9.7055676286515896e-07 1.9015763441625449e-03 +9.1195530726256968e-02 2.4502225897190189e-05 1.0339287046521225e-06 1.9362108434449465e-03 +9.2826815642458094e-02 2.7342158701030765e-05 1.0820198560888855e-06 1.9708453427273480e-03 +9.4458100558659219e-02 2.6783142947142730e-05 1.1136631282699393e-06 2.0054798420097498e-03 +9.6089385474860317e-02 2.8705334866610756e-05 1.1285271302799624e-06 2.0401143412921512e-03 +9.7720670391061443e-02 2.6466203187949051e-05 1.1269627053952748e-06 2.0747488405745531e-03 +9.9351955307262568e-02 2.8261560484103695e-05 1.1098650131858073e-06 2.1093833398569549e-03 +1.0098324022346367e-01 2.6442114596377511e-05 1.0785501615437667e-06 2.1440178391393559e-03 +1.0261452513966479e-01 2.4074124812312355e-05 1.0346454887301454e-06 2.1786523384217577e-03 +1.0424581005586592e-01 2.3692197586534773e-05 9.7999267073641259e-07 2.2132868377041596e-03 +1.0587709497206702e-01 2.2463660358108207e-05 9.1656288868771091e-07 2.2479213369865610e-03 +1.0750837988826814e-01 2.0890469443814874e-05 8.4638333600557746e-07 2.2825558362689628e-03 +1.0913966480446927e-01 1.8029126081423242e-05 7.7147436152019195e-07 2.3171903355513647e-03 +1.1077094972067039e-01 1.6076622619174320e-05 6.9379656962364873e-07 2.3518248348337665e-03 +1.1240223463687149e-01 1.5002965799405180e-05 6.1520719832493350e-07 2.3864593341161680e-03 +1.1403351955307262e-01 1.2684785064801597e-05 5.3742512137214509e-07 2.4210938333985698e-03 +1.1566480446927374e-01 1.1158898988587107e-05 4.6200378022811236e-07 2.4557283326809712e-03 +1.1729608938547484e-01 9.5542346485409375e-06 3.9031142020204883e-07 2.4903628319633726e-03 +1.1892737430167596e-01 7.0545361466784899e-06 3.2351793738507691e-07 2.5249973312457740e-03 +1.2055865921787709e-01 6.1314914245198469e-06 2.6258768947098442e-07 2.5596318305281759e-03 +1.2218994413407819e-01 4.9620071310599316e-06 2.0827762287215964e-07 2.5942663298105773e-03 +1.2382122905027931e-01 3.4805583302712098e-06 1.6114007310899829e-07 2.6289008290929792e-03 +1.2545251396648044e-01 2.4397022140237427e-06 1.2152960768933588e-07 2.6635353283753810e-03 +1.2708379888268156e-01 1.8077436054346318e-06 8.9613302084340268e-08 2.6981698276577828e-03 +1.2871508379888266e-01 1.1511677458188232e-06 6.5383857397804003e-08 2.7328043269401843e-03 +1.3034636871508379e-01 7.6048194958177687e-07 4.8674993864340509e-08 2.7674388262225861e-03 +1.3197765363128491e-01 4.6593956483892457e-07 3.9178585116752348e-08 2.8020733255049880e-03 +1.3360893854748604e-01 3.5754407191683233e-07 3.6463026006753945e-08 2.8367078247873894e-03 +1.3524022346368714e-01 4.9540932693547452e-07 3.9992356667000653e-08 2.8713423240697908e-03 +1.3687150837988826e-01 7.0673213353784478e-07 4.9145706931501906e-08 2.9059768233521926e-03 +1.3850279329608939e-01 1.1299460721003491e-06 6.3236686505025923e-08 2.9406113226345945e-03 +1.4013407821229049e-01 1.5540956167094890e-06 8.1532331167626869e-08 2.9752458219169955e-03 +1.4176536312849161e-01 1.9133630686008454e-06 1.0327132092238719e-07 3.0098803211993973e-03 +1.4339664804469274e-01 2.5394297771217437e-06 1.2768118459913906e-07 3.0445148204817992e-03 +1.4502793296089384e-01 3.4859551379503557e-06 1.5399426769844739e-07 3.0791493197642006e-03 +1.4665921787709496e-01 4.1599086683129485e-06 1.8146227837958277e-07 3.1137838190466024e-03 +1.4829050279329609e-01 4.6001842040547441e-06 2.0936925933475993e-07 3.1484183183290043e-03 +1.4992178770949718e-01 5.4258238834212881e-06 2.3704289972803283e-07 3.1830528176114052e-03 +1.5155307262569831e-01 6.2141732675996140e-06 2.6386409618199447e-07 3.2176873168938071e-03 +1.5318435754189944e-01 6.8673662263106144e-06 2.8927474721849391e-07 3.2523218161762089e-03 +1.5481564245810056e-01 7.5936690296370535e-06 3.1278378708529876e-07 3.2869563154586108e-03 +1.5644692737430166e-01 7.9349460141177438e-06 3.3397148513119022e-07 3.3215908147410122e-03 +1.5807821229050278e-01 8.2788754847365130e-06 3.5249206888513846e-07 3.3562253140234136e-03 +1.5970949720670391e-01 8.6065936857742345e-06 3.6807476505625147e-07 3.3908598133058159e-03 +1.6134078212290501e-01 9.4148188373300757e-06 3.8052336151887262e-07 3.4254943125882169e-03 +1.6297206703910613e-01 8.3656699948610526e-06 3.8971441398561181e-07 3.4601288118706192e-03 +1.6460335195530726e-01 9.3350046485094347e-06 3.9559423583533777e-07 3.4947633111530206e-03 +1.6623463687150836e-01 9.4675103174854918e-06 3.9817482028049606e-07 3.5293978104354220e-03 +1.6786592178770948e-01 8.8716039490849351e-06 3.9752885137216690e-07 3.5640323097178234e-03 +1.6949720670391061e-01 9.4756557748916763e-06 3.9378396443568056e-07 3.5986668090002252e-03 +1.7112849162011173e-01 8.9257888680680473e-06 3.8711641761872937e-07 3.6333013082826275e-03 +1.7275977653631283e-01 9.2693919994036036e-06 3.7774433454935046e-07 3.6679358075650281e-03 +1.7439106145251396e-01 8.6020601069840042e-06 3.6592067388031514e-07 3.7025703068474304e-03 +1.7602234636871508e-01 8.5336443557556177e-06 3.5192607527858559e-07 3.7372048061298318e-03 +1.7765363128491618e-01 8.3111497235355484e-06 3.3606172172283367e-07 3.7718393054122336e-03 +1.7928491620111731e-01 7.5880761859491730e-06 3.1864234424135948e-07 3.8064738046946350e-03 +1.8091620111731843e-01 6.7370301432839540e-06 2.9998949134642746e-07 3.8411083039770373e-03 +1.8254748603351953e-01 6.0859761292061161e-06 2.8042516739995013e-07 3.8757428032594379e-03 +1.8417877094972065e-01 6.4629942266598207e-06 2.6026592359584545e-07 3.9103773025418401e-03 +1.8581005586592178e-01 5.4687472464876076e-06 2.3981748240275682e-07 3.9450118018242416e-03 +1.8744134078212288e-01 4.8331985324840903e-06 2.1936995989733362e-07 3.9796463011066430e-03 +1.8907262569832400e-01 4.5085778597484669e-06 1.9919371020616375e-07 4.0142808003890453e-03 +1.9070391061452513e-01 3.9540311698429168e-06 1.7953585466615621e-07 4.0489152996714475e-03 +1.9233519553072626e-01 3.6523061014691192e-06 1.6061748612675658e-07 4.0835497989538489e-03 +1.9396648044692735e-01 3.0706279359420833e-06 1.4263154827004364e-07 4.1181842982362504e-03 +1.9559776536312848e-01 2.6452644016083702e-06 1.2574142037738370e-07 4.1528187975186518e-03 +1.9722905027932960e-01 2.1733417083898013e-06 1.1008010887524612e-07 4.1874532968010532e-03 +1.9886033519553070e-01 1.9387140959173504e-06 9.5750115842650598e-08 4.2220877960834546e-03 +2.0049162011173183e-01 1.4849668319860927e-06 8.2823825144860476e-08 4.2567222953658560e-03 +2.0212290502793295e-01 1.3311195227270157e-06 7.1344479489967582e-08 4.2913567946482583e-03 +2.0375418994413405e-01 1.1266487703467937e-06 6.1327575121534668e-08 4.3259912939306597e-03 +2.0538547486033518e-01 7.3862126783125883e-07 5.2762713625107356e-08 4.3606257932130611e-03 +2.0701675977653630e-01 5.2789550768325793e-07 4.5615779011712185e-08 4.3952602924954634e-03 +2.0864804469273743e-01 5.2035607464936355e-07 3.9831397275285144e-08 4.4298947917778657e-03 +2.1027932960893853e-01 4.7342241059662012e-07 3.5335633125861021e-08 4.4645292910602662e-03 +2.1191061452513965e-01 2.6890178713217918e-07 3.2038809091192667e-08 4.4991637903426685e-03 +2.1354189944134078e-01 2.0864193442040346e-07 2.9838429253481856e-08 4.5337982896250699e-03 +2.1517318435754187e-01 2.3241065388164625e-07 2.8622124470000718e-08 4.5684327889074713e-03 +2.1680446927374300e-01 1.8299624769877881e-07 2.8270556599808117e-08 4.6030672881898728e-03 +2.1843575418994413e-01 2.0200325556886136e-07 2.8660242751068608e-08 4.6377017874722742e-03 +2.2006703910614522e-01 2.3132877035083261e-07 2.9666244326877083e-08 4.6723362867546756e-03 +2.2169832402234635e-01 2.9569660259522216e-07 3.1164677172826718e-08 4.7069707860370779e-03 +2.2332960893854747e-01 3.1248613469402398e-07 3.3035012124672154e-08 4.7416052853194801e-03 +2.2496089385474857e-01 3.8882432023274794e-07 3.5162134655654148e-08 4.7762397846018807e-03 +2.2659217877094970e-01 4.2934439525490547e-07 3.7438138876708844e-08 4.8108742838842830e-03 +2.2822346368715082e-01 4.6050870155892791e-07 3.9763842822447750e-08 4.8455087831666844e-03 +2.2985474860335195e-01 5.3780167992523125e-07 4.2050015156430320e-08 4.8801432824490867e-03 +2.3148603351955305e-01 5.6343268097802113e-07 4.4218309916685203e-08 4.9147777817314872e-03 +2.3311731843575417e-01 6.5534880899749164e-07 4.6201912167702549e-08 4.9494122810138895e-03 +2.3474860335195530e-01 6.4476274870054900e-07 4.7945901108252640e-08 4.9840467802962909e-03 +2.3637988826815640e-01 6.8118661553410327e-07 4.9407344986883848e-08 5.0186812795786923e-03 +2.3801117318435752e-01 8.3753555715031314e-07 5.0555145616778782e-08 5.0533157788610946e-03 +2.3964245810055865e-01 7.8150927377484840e-07 5.1369653148997345e-08 5.0879502781434960e-03 +2.4127374301675975e-01 7.9325748992196227e-07 5.1842075311436918e-08 5.1225847774258974e-03 +2.4290502793296087e-01 8.2592913806881310e-07 5.1973707747120265e-08 5.1572192767082988e-03 +2.4453631284916200e-01 7.7258546846928593e-07 5.1775013823763251e-08 5.1918537759907011e-03 +2.4616759776536312e-01 7.6989881344286108e-07 5.1264583353526961e-08 5.2264882752731025e-03 +2.4779888268156422e-01 7.8315640843175231e-07 5.0468000080904993e-08 5.2611227745555040e-03 +2.4943016759776535e-01 7.4937223420796210e-07 4.9416647602179904e-08 5.2957572738379054e-03 +2.5106145251396644e-01 6.4784600567647247e-07 4.8146482615434176e-08 5.3303917731203068e-03 +2.5269273743016757e-01 7.0633451309077426e-07 4.6696803117003160e-08 5.3650262724027091e-03 +2.5432402234636869e-01 6.0109198344857942e-07 4.5109037415881966e-08 5.3996607716851113e-03 +2.5595530726256982e-01 5.3977860384028971e-07 4.3425577693923002e-08 5.4342952709675128e-03 +2.5758659217877089e-01 5.0467736758083566e-07 4.1688679361896293e-08 5.4689297702499133e-03 +2.5921787709497207e-01 4.8288795373092717e-07 3.9939444716849178e-08 5.5035642695323165e-03 +2.6084916201117314e-01 5.1761304312736825e-07 3.8216906460529690e-08 5.5381987688147170e-03 +2.6248044692737427e-01 3.7095625470054506e-07 3.6557223332483490e-08 5.5728332680971184e-03 +2.6411173184357539e-01 3.8042646327153796e-07 3.4992997120997707e-08 5.6074677673795198e-03 +2.6574301675977652e-01 2.6708813410330149e-07 3.3552718084149724e-08 5.6421022666619221e-03 +2.6737430167597764e-01 3.0645798937330464e-07 3.2260341566871246e-08 5.6767367659443235e-03 +2.6900558659217877e-01 3.0638289961421128e-07 3.1134996108278319e-08 5.7113712652267258e-03 +2.7063687150837984e-01 2.4762545367516501e-07 3.0190820542419813e-08 5.7460057645091264e-03 +2.7226815642458096e-01 2.1739684337517798e-07 2.9436924674522878e-08 5.7806402637915286e-03 +2.7389944134078209e-01 2.2862207403286728e-07 2.8877464856663523e-08 5.8152747630739300e-03 +2.7553072625698322e-01 2.3275836185625008e-07 2.8511830161603731e-08 5.8499092623563323e-03 +2.7716201117318434e-01 2.2717781910719086e-07 2.8334925257435264e-08 5.8845437616387337e-03 +2.7879329608938547e-01 2.6433346474757185e-07 2.8337536589592850e-08 5.9191782609211352e-03 +2.8042458100558659e-01 2.1863188819726297e-07 2.8506767983745055e-08 5.9538127602035374e-03 +2.8205586592178766e-01 2.0358621738439327e-07 2.8826533888021087e-08 5.9884472594859380e-03 +2.8368715083798879e-01 2.2826406330331091e-07 2.9278099783937580e-08 6.0230817587683394e-03 +2.8531843575418991e-01 2.4385279277519668e-07 2.9840647853798000e-08 6.0577162580507417e-03 +2.8694972067039104e-01 2.6561199637469127e-07 3.0491855670865959e-08 6.0923507573331440e-03 +2.8858100558659217e-01 2.7927485216539765e-07 3.1208478981737441e-08 6.1269852566155454e-03 +2.9021229050279329e-01 3.0473422866777223e-07 3.1966922916360204e-08 6.1616197558979477e-03 +2.9184357541899436e-01 2.6388471281626860e-07 3.2743786841828224e-08 6.1962542551803473e-03 +2.9347486033519549e-01 3.6571564332084564e-07 3.3516379379441504e-08 6.2308887544627496e-03 +2.9510614525139661e-01 3.3688729310133809e-07 3.4263187702085556e-08 6.2655232537451510e-03 +2.9673743016759774e-01 3.3308868147575935e-07 3.4964293876063716e-08 6.3001577530275533e-03 +2.9836871508379886e-01 4.1274861177147912e-07 3.5601742371550679e-08 6.3347922523099547e-03 +2.9999999999999999e-01 4.5163020098051376e-07 3.6159836326587067e-08 6.3694267515923561e-03 diff --git a/pyproject.toml b/pyproject.toml index c3984c88..fc5d1417 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ requires-python = '>=3.12' dependencies = [ 'easyapplication', - 'easyreflectometry @ git+https://github.com/easyscience/reflectometry-lib.git@cffcd18d5428928124f2da32dd988abe7a2ce080', + 'easyreflectometry @ git+https://github.com/easyscience/reflectometry-lib.git@develop', #'easyreflectometry', 'asteval', 'PySide6', diff --git a/tests/factories.py b/tests/factories.py index 53557d51..b2e24f3a 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -362,8 +362,16 @@ def __init__( self.q_max = 0.5 self.q_resolution = 200 self.parameters = [] + # Inequality-constraint API of the real Project (BUMPS fit penalties). + self.inequality_constraints = [] self.calls = [] + def violated_inequality_constraints(self): + return [spec for spec in self.inequality_constraints if getattr(spec, 'violated', False)] + + def build_constraints_factory(self): + return None + def default_model(self): self.calls.append(('default_model',)) diff --git a/tests/test_analysis_bayesian.py b/tests/test_analysis_bayesian.py index 53099983..333e3a68 100644 --- a/tests/test_analysis_bayesian.py +++ b/tests/test_analysis_bayesian.py @@ -72,6 +72,9 @@ def __init__(self): self.fit_cancelled = False self.fit_success = False + def snapshot_constraints_factory(self): + return None + def prepare_for_threaded_sample(self): pass diff --git a/tests/test_logic_parameters.py b/tests/test_logic_parameters.py index de922a8a..9566c29e 100644 --- a/tests/test_logic_parameters.py +++ b/tests/test_logic_parameters.py @@ -167,9 +167,21 @@ def test_parameters_filtering_metadata_and_current_parameter_updates(monkeypatch metadata = logic.constraint_metadata() assert metadata == [ - {'alias': 'hidden_background', 'displayName': 'Hidden background', 'group': 'Experiment', 'independent': True}, - {'alias': 'instrument_scale', 'displayName': 'Instrument scale', 'group': 'Instrument', 'independent': True}, - {'alias': 'layer_thickness', 'displayName': 'Layer thickness', 'group': 'Layer', 'independent': False}, + { + 'alias': 'hidden_background', + 'displayName': 'Hidden background', + 'group': 'Experiment', + 'independent': True, + 'kind': 'parameter', + }, + { + 'alias': 'instrument_scale', + 'displayName': 'Instrument scale', + 'group': 'Instrument', + 'independent': True, + 'kind': 'parameter', + }, + {'alias': 'layer_thickness', 'displayName': 'Layer thickness', 'group': 'Layer', 'independent': False, 'kind': 'parameter'}, ] logic.set_variability_filter_criteria('all') diff --git a/tests/test_py_backend.py b/tests/test_py_backend.py index 9d326c0a..3d3ca60e 100644 --- a/tests/test_py_backend.py +++ b/tests/test_py_backend.py @@ -37,6 +37,7 @@ class StubSample(QObject): assembliesIndexChanged = Signal() qRangeChanged = Signal() magnetismChanged = Signal() + constraintsChanged = Signal() def __init__(self, _project_lib): super().__init__() @@ -65,6 +66,7 @@ class StubAnalysis(QObject): externalExperimentChanged = Signal() experimentsChanged = Signal() parametersChanged = Signal() + inequalityContextChanged = Signal() def __init__(self, _project_lib, parent=None): super().__init__(parent) diff --git a/tests/test_py_sample_constraints.py b/tests/test_py_sample_constraints.py new file mode 100644 index 00000000..d8b193b5 --- /dev/null +++ b/tests/test_py_sample_constraints.py @@ -0,0 +1,347 @@ +"""Backend tests for inequality constraints, derived parameters and physics recipes. + +These exercise the real reflectometry library (not the fakes in +``tests/factories.py``) because the features under test live in the +parameter graph and the project's structural paths. +""" + +import json + +import pytest +from easyreflectometry import Project +from easyreflectometry.sample import Layer +from easyreflectometry.sample import Material +from easyreflectometry.sample import Multilayer +from easyreflectometry.sample import SurfactantLayer +from easyscience import global_object + +from EasyReflectometryApp.Backends.Py.logic.fitting import Fitting +from EasyReflectometryApp.Backends.Py.logic.minimizers import Minimizers +from EasyReflectometryApp.Backends.Py.sample import Sample + + +@pytest.fixture(autouse=True) +def clear_global_map(): + global_object.map._clear() + yield + global_object.map._clear() + + +@pytest.fixture +def project_and_backend(qcore_application): + project = Project() + backend = Sample(project) # installs the default model + model = project.models[0] + film_a = Multilayer(Layer(Material(3.0, 0.0, 'A'), thickness=40.0, roughness=3.0, name='A'), name='Film A') + film_b = Multilayer( + [ + Layer(Material(5.0, 0.0, 'B1'), thickness=30.0, roughness=3.0, name='B1'), + Layer(Material(4.0, 0.0, 'B2'), thickness=30.0, roughness=3.0, name='B2'), + ], + name='Film B', + ) + substrate = model.sample[-1] + model.remove_assembly(len(model.sample) - 1) + model.remove_assembly(len(model.sample) - 1) + model.add_assemblies(film_a, film_b, SurfactantLayer(name='Surf'), substrate) + return project, backend + + +def _dependent_index(backend, text): + names = backend.dependentParameterNames + return next(i for i, name in enumerate(names) if all(part in name for part in text.split())) + + +def _alias(backend, text, kind=None): + for entry in backend.constraintParametersMetadata: + if all(part in entry['displayName'] for part in text.split()) and (kind is None or entry['kind'] == kind): + return entry['alias'] + raise AssertionError(f'no alias for {text}') + + +class TestDerivedParameterMetadata: + def test_total_thickness_is_listed_read_only_with_alias(self, project_and_backend): + project, backend = project_and_backend + entries = [p for p in backend._parameters_logic.all_parameters() if p['kind'] == 'derived'] + assert len(entries) == 1 + entry = entries[0] + assert entry['readOnly'] is True + assert entry['independent'] is False + assert entry['fit'] is False + assert entry['value'] == pytest.approx(project.models[0].total_thickness.value) + assert 'total_thickness' in entry['alias'] + assert [m for m in backend.constraintParametersMetadata if m['kind'] == 'derived'] + + def test_derived_parameter_is_not_a_constraint_row(self, project_and_backend): + _, backend = project_and_backend + assert all('total_thickness' not in row['dependentName'] for row in backend.constraintsList) + + +class TestInequalityConstraints: + def test_validation_reports_type_and_feasibility(self, project_and_backend): + project, backend = project_and_backend + idx = _dependent_index(backend, 'Film A thickness') + alias_b = _alias(backend, 'Film B thickness') + result = backend.validateConstraintExpression(idx, '<', f'{alias_b} * 2') + assert result['valid'] and result['type'] == 'inequality' and result['warning'] == '' + + violated = backend.validateConstraintExpression(idx, '>', f'{alias_b} * 2') + assert violated['valid'] and 'violate' in violated['warning'] + + def test_mixed_literals_fall_back_to_numeric_for_inequalities(self, project_and_backend): + # '90 - t_B' cannot be evaluated with units, but is a perfectly good + # inequality expression (literals read in the dependent's unit). + _, backend = project_and_backend + idx = _dependent_index(backend, 'Film A thickness') + alias_b = _alias(backend, 'Film B thickness') + result = backend.validateConstraintExpression(idx, '<', f'90 - {alias_b}') + assert result['valid'] and result['type'] == 'inequality' + assert backend.addConstraint(idx, '<', f'90 - {alias_b}')['success'] + # equality constraints keep the strict unit-carrying behaviour + equality = backend.validateConstraintExpression(idx, '=', f'90 - {alias_b}') + assert not equality['valid'] + + def test_unit_mismatch_is_rejected(self, project_and_backend): + _, backend = project_and_backend + idx = _dependent_index(backend, 'Film A thickness') + alias_sld = _alias(backend, 'B1 sld') + result = backend.validateConstraintExpression(idx, '<', alias_sld) + assert not result['valid'] and 'Incompatible units' in result['message'] + + def test_self_reference_is_rejected(self, project_and_backend): + _, backend = project_and_backend + idx = _dependent_index(backend, 'Film A thickness') + alias_a = _alias(backend, 'Film A thickness') + result = backend.validateConstraintExpression(idx, '<', f'{alias_a} * 2') + assert not result['valid'] + + def test_add_list_remove_and_persist(self, project_and_backend): + project, backend = project_and_backend + idx = _dependent_index(backend, 'Film A thickness') + alias_b = _alias(backend, 'Film B thickness') + alias_total = _alias(backend, 'total_thickness', kind='derived') + + assert backend.addConstraint(idx, '<', f'{alias_b} * 2')['success'] + assert backend.addConstraint(idx, '<', f'{alias_total} / 2')['success'] + assert backend.inequalityConstraintsCount == 2 + rows = [row for row in backend.constraintsList if row['type'] == 'inequality'] + assert [row['relation'] for row in rows] == ['≤', '≤'] + assert rows[0]['dependentName'].endswith('Film A thickness') + assert 'Film B thickness * 2' in rows[0]['expression'] + assert all(row['satisfied'] for row in rows) + # The parameter itself is untouched: it stays independent (no dependency is created). + t_a = project.models[0].sample[1].layers[0].thickness + assert t_a.independent + + project_dict = json.loads(json.dumps(project.as_dict())) + global_object.map._clear() + reloaded_project = Project() + reloaded_backend = Sample(reloaded_project) + reloaded_project.from_dict(project_dict) + rows = [row for row in reloaded_backend.constraintsList if row['type'] == 'inequality'] + assert len(rows) == 2 and 'Film B thickness * 2' in rows[0]['expression'] + + reloaded_backend.removeConstraintByIndex(reloaded_backend.constraintsList.index(rows[0])) + assert reloaded_backend.inequalityConstraintsCount == 1 + + def test_enable_toggle_and_violation_listing(self, project_and_backend): + project, backend = project_and_backend + idx = _dependent_index(backend, 'Film A thickness') + alias_b = _alias(backend, 'Film B thickness') + backend.addConstraint(idx, '>', f'{alias_b} * 2') # 40 >= 60 is violated + assert backend.violatedInequalityConstraints + backend.setInequalityConstraintEnabled(0, False) + assert backend.inequalityConstraintsCount == 0 + assert backend.violatedInequalityConstraints == [] + + def test_enable_toggle_out_of_range_is_a_no_op(self, project_and_backend): + _, backend = project_and_backend + idx = _dependent_index(backend, 'Film A thickness') + alias_b = _alias(backend, 'Film B thickness') + backend.addConstraint(idx, '<', f'{alias_b} * 2') + backend.setInequalityConstraintEnabled(5, False) + backend.setInequalityConstraintEnabled(-1, False) + assert backend.inequalityConstraintsCount == 1 + + +class TestFitScreening: + def _with_inequality(self, project_and_backend, relation='<'): + project, backend = project_and_backend + idx = _dependent_index(backend, 'Film A thickness') + alias_b = _alias(backend, 'Film B thickness') + assert backend.addConstraint(idx, relation, f'{alias_b} * 2')['success'] + return project, Minimizers(project), Fitting(project) + + def _select(self, minimizers, name): + names = minimizers.minimizers_available() + minimizers.set_minimizer_current_index(names.index(name)) + + def test_non_bumps_engine_is_refused(self, project_and_backend): + project, minimizers, fitting = self._with_inequality(project_and_backend) + self._select(minimizers, 'LMFit_leastsq') + assert minimizers.supports_inequalities() is False + assert 'BUMPS' in fitting.inequality_constraints_error(minimizers) + assert fitting.inequality_constraints_warning(minimizers) + + def test_bumps_and_bayesian_are_accepted(self, project_and_backend): + project, minimizers, fitting = self._with_inequality(project_and_backend) + self._select(minimizers, 'Bumps_simplex') + assert minimizers.supports_inequalities() and fitting.inequality_constraints_error(minimizers) is None + assert fitting.inequality_constraints_warning(minimizers) == '' + minimizers.set_minimizer_current_index(0) # Bayesian sentinel + assert minimizers.is_bayesian_selected() and minimizers.supports_inequalities() + assert fitting.inequality_constraints_error(minimizers) is None + assert callable(fitting.snapshot_constraints_factory()) + + def test_bumps_lm_only_warns(self, project_and_backend): + project, minimizers, fitting = self._with_inequality(project_and_backend) + self._select(minimizers, 'Bumps_lm') + assert minimizers.enforces_inequalities_weakly() + assert fitting.inequality_constraints_error(minimizers) is None + assert 'Bumps_lm' in fitting.inequality_constraints_warning(minimizers) + + def test_infeasible_start_point_is_refused(self, project_and_backend): + project, minimizers, fitting = self._with_inequality(project_and_backend, relation='>') + self._select(minimizers, 'Bumps_simplex') + assert 'violate' in fitting.inequality_constraints_error(minimizers) + + def test_no_constraints_means_no_factory(self, project_and_backend): + project, backend = project_and_backend + fitting = Fitting(project) + minimizers = Minimizers(project) + assert fitting.inequality_constraints_error(minimizers) is None + assert fitting.snapshot_constraints_factory() is None + + def test_progress_payload_infeasible_flag(self, project_and_backend): + project, _ = project_and_backend + fitting = Fitting(project) + fitting.on_fit_progress({'iteration': 3, 'chi2': 1e12, 'infeasible': True}) + assert fitting.fit_infeasible is True + assert 'outside' in fitting.fit_progress_message + fitting.on_fit_progress({'iteration': 4, 'chi2': 2.0, 'infeasible': False}) + assert fitting.fit_infeasible is False + fitting.on_fit_progress({'iteration': 5, 'chi2': 1e12, 'infeasible': True}) + fitting.clear_fit_progress() + assert fitting.fit_infeasible is False + assert fitting.fit_progress_message == '' + + +class TestPhysicsRecipes: + def test_recipe_availability_matrix(self, project_and_backend): + _, backend = project_and_backend + recipes = backend.physicsConstraintRecipes + by_key = {(r['assemblyName'], r['id']): r for r in recipes} + assert by_key[('Film A', 'conformal_roughness')]['available'] is False + assert 'two layers' in by_key[('Film A', 'conformal_roughness')]['reason'] + assert by_key[('Film B', 'conformal_roughness')]['toggleable'] is True + assert by_key[('Film B', 'constant_period')]['available'] is True + assert by_key[('Surf', 'equal_apm')]['available'] is True + assert by_key[('Surf', 'solvent_roughness')]['available'] is False # needs conformal roughness first + assert by_key[('Surf', 'mixture_fractions')]['toggleable'] is False + assert by_key[('Surf', 'mixture_fractions')]['active'] is True + assert ('Surf', 'conformal_thickness') not in by_key + + def test_apply_remove_and_grouped_rows(self, project_and_backend): + project, backend = project_and_backend + film_b = project.models[0].sample[2] + assert backend.applyPhysicsConstraint(2, 'conformal_roughness')['success'] + assert backend.applyPhysicsConstraint(2, 'constant_period')['success'] + assert film_b.layers[1].roughness.independent is False + assert film_b.layers[1].thickness.independent is False + + rows = [row for row in backend.constraintsList if row['type'] == 'recipe'] + assert sorted(row['expression'] for row in rows) == ['Conformal roughness', 'Constant period Λ'] + assert all(row['dependentName'] == 'Film B' for row in rows) + # No raw per-parameter rows leak for owned parameters + assert not any(row['type'] == 'dynamic' and 'Film B' in row['dependentName'] for row in backend.constraintsList) + recipes = {(r['assemblyName'], r['id']): r for r in backend.physicsConstraintRecipes} + assert recipes[('Film B', 'conformal_roughness')]['active'] is True + assert recipes[('Film B', 'constant_period')]['active'] is True + assert recipes[('Film B', 'conformal_thickness')]['active'] is False + + # Period: the last layer absorbs the change of the first + total = film_b.layers[0].thickness.value + film_b.layers[1].thickness.value + film_b.layers[0].thickness.value = 45.0 + assert film_b.layers[0].thickness.value + film_b.layers[1].thickness.value == pytest.approx(total) + + # Removing the grouped row removes the recipe + period_row = next(row for row in backend.constraintsList if row.get('recipeId') == 'constant_period') + backend.removeConstraintByIndex(backend.constraintsList.index(period_row)) + assert film_b.layers[1].thickness.independent is True + assert backend.removePhysicsConstraint(2, 'conformal_roughness')['success'] + assert film_b.layers[1].roughness.independent is True + + def test_constant_period_clamps_the_free_layers(self, project_and_backend): + project, backend = project_and_backend + film_b = project.models[0].sample[2] + first, second = film_b.layers[0].thickness, film_b.layers[1].thickness + assert backend.applyPhysicsConstraint(2, 'constant_period')['success'] + + # The period is the whole budget, so the free layer cannot exceed it and + # the tied layer can never be driven to a negative thickness. + assert first.max == pytest.approx(60.0) + first.value = 1.0e6 + assert first.value == pytest.approx(60.0) + assert second.value == pytest.approx(0.0) + assert second.min >= 0.0 + assert project.models[0].total_thickness.min >= 0.0 + + # Removing the recipe hands the original bound back + assert backend.removePhysicsConstraint(2, 'constant_period')['success'] + assert first.max == float('inf') + + def test_constant_period_clamp_survives_reload_and_restores(self, project_and_backend): + project, backend = project_and_backend + film_b = project.models[0].sample[2] + assert backend.applyPhysicsConstraint(2, 'constant_period')['success'] + assert film_b.layers[0].thickness.max == pytest.approx(60.0) + + project_dict = json.loads(json.dumps(project.as_dict())) + global_object.map._clear() + reloaded = Project() + reloaded_backend = Sample(reloaded) + reloaded.from_dict(project_dict) + + # The clamp survives the round-trip, and removing the recipe afterwards + # still hands back the original (pre-clamp) bound. + reloaded_first = reloaded.models[0].sample[2].layers[0].thickness + assert reloaded_first.max == pytest.approx(60.0) + assert reloaded_backend.removePhysicsConstraint(2, 'constant_period')['success'] + assert reloaded_first.max == float('inf') + + def test_solvent_roughness_requires_and_follows_conformal(self, project_and_backend): + project, backend = project_and_backend + surf = project.models[0].sample[3] + substrate_roughness = project.models[0].sample[4].layers[0].roughness + assert not backend.applyPhysicsConstraint(3, 'solvent_roughness')['success'] + assert backend.applyPhysicsConstraint(3, 'conformal_roughness')['success'] + assert backend.applyPhysicsConstraint(3, 'solvent_roughness')['success'] + assert substrate_roughness.independent is False + surf.tail_layer.roughness.value = 7.0 + assert substrate_roughness.value == pytest.approx(7.0) + # Removing conformal roughness also drops the dependent solvent recipe + assert backend.removePhysicsConstraint(3, 'conformal_roughness')['success'] + assert substrate_roughness.independent is True + + def test_recipes_survive_reload(self, project_and_backend): + project, backend = project_and_backend + backend.applyPhysicsConstraint(2, 'conformal_roughness') + backend.applyPhysicsConstraint(2, 'constant_period') + backend.applyPhysicsConstraint(3, 'equal_apm') + project_dict = json.loads(json.dumps(project.as_dict())) + + global_object.map._clear() + reloaded = Project() + reloaded_backend = Sample(reloaded) + reloaded.from_dict(project_dict) + + rows = sorted((row['dependentName'], row['expression']) for row in reloaded_backend.constraintsList if row['type'] == 'recipe') + assert rows == [ + ('Film B', 'Conformal roughness'), + ('Film B', 'Constant period Λ'), + ('Surf', 'Equal head/tail area per molecule'), + ] + film_b = reloaded.models[0].sample[2] + total = film_b.layers[0].thickness.value + film_b.layers[1].thickness.value + film_b.layers[0].thickness.value = 20.0 + assert film_b.layers[0].thickness.value + film_b.layers[1].thickness.value == pytest.approx(total)