Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions EasyReflectometryApp/Backends/Mock/Analysis.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions EasyReflectometryApp/Backends/Mock/Sample.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 43 additions & 2 deletions EasyReflectometryApp/Backends/Py/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand All @@ -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.

Expand Down Expand Up @@ -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,
)
Expand Down
89 changes: 88 additions & 1 deletion EasyReflectometryApp/Backends/Py/logic/fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}'
)
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
20 changes: 20 additions & 0 deletions EasyReflectometryApp/Backends/Py/logic/minimizers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
32 changes: 30 additions & 2 deletions EasyReflectometryApp/Backends/Py/logic/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
}
)
Expand All @@ -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'])
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Expand All @@ -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]``.

Expand Down
Loading
Loading