diff --git a/.github/github-instructions.md b/.github/github-instructions.md new file mode 100644 index 00000000..821bf82d --- /dev/null +++ b/.github/github-instructions.md @@ -0,0 +1,233 @@ +# EasyReflectometryApp Project Gist + +## Project Purpose + +EasyReflectometryApp is the Qt/QML desktop application for EasyReflectometry. It provides a GUI for modelling, simulation, fitting, and reporting of reflectometry data. The app focuses on an intuitive workflow around projects, samples, experiments, analysis, and summaries. + +Core user-facing capabilities include: + +- Loading reflectometry datasets and project files. +- Building layered sample structures with materials, models, assemblies, and constraints. +- Simulating reflectivity and SLD profiles. +- Fitting single and multiple experiments through EasyReflectometryLib and EasyScience fitting backends. +- Switching calculators/minimizers where supported. +- Producing HTML/PDF reports and plot exports. +- Packaging cross-platform installers for Windows, macOS, and Linux. + +## Repository Shape + +Important paths: + +- `EasyReflectometryApp/main.py`: runtime entry point. Registers `PyBackend` as the QML singleton module `Backends` and loads `Gui/ApplicationWindow.qml`. +- `EasyReflectometryApp/Gui/`: Qt6/QML UI. Pages follow the workflow: Project, Sample, Experiment, Analysis, Summary. +- `EasyReflectometryApp/Gui/Globals/BackendWrapper.qml`: the GUI-facing wrapper around the active backend. QML should access backend data and methods through this wrapper. +- `EasyReflectometryApp/Backends/Py/`: Python backend adapters exposed to QML through PySide6 properties, slots, and signals. +- `EasyReflectometryApp/Backends/Py/logic/`: app-specific backend logic without PySide/QML dependencies. Prefer adding business logic here. +- `EasyReflectometryApp/Backends/Py/workers/`: threaded/background workers, especially fitting. +- `EasyReflectometryApp/Backends/Mock/`: QML mock backend for UI development without the Python backend. +- `tests/`: pytest coverage for Python backend logic, workers, QML-facing adapters, and selected QML UI behavior. +- `src_qt5/`: legacy Qt5 implementation. Use as a migration reference only; do not extend it for new Qt6 behavior. +- `tools/Scripts/`: installer and CI helper scripts. +- `.github/workflows/`: installer, docs, and snap workflows. + +## Main Architecture + +The application is a PySide6/QML frontend over a Python backend that uses `easyreflectometry` from EasyReflectometryLib and EasyScience/core underneath. + +Backend layering matters: + +- Root modules in `Backends/Py/*.py` are QML API adapters. They expose `Property`, `Signal`, and `Slot` definitions and should stay thin. +- Domain behavior belongs in `Backends/Py/logic/*.py` where it can be tested without QML or PySide. +- `PyBackend` owns one shared `easyreflectometry.Project` instance and constructs page-specific backend adapters: `Home`, `Project`, `Sample`, `Experiment`, `Analysis`, `Summary`, `Status`, and `Plotting1d`. +- `PyBackend._connect_backend_parts()` is the cross-page signal hub. When sample, experiment, analysis, or project state changes, make sure the relevant status, summary, parameter cache, and plot refresh signals are emitted. +- `Gui/Globals/BackendWrapper.qml` intentionally flattens backend access for QML. When adding a backend property or method that QML uses, add the Python backend API, the BackendWrapper bridge, and the mock backend equivalent. + +Typical data flow: + +1. QML calls a `Globals.BackendWrapper.*` function. +2. The wrapper delegates to `activeBackend`, normally `PyBackend` or `MockBackend`. +3. A root Python adapter validates/coerces QML-friendly values and delegates to a logic module. +4. Logic mutates or queries the shared EasyReflectometryLib project. +5. The adapter emits signals so QML bindings, status text, summaries, and plots refresh. + +## Local Multi-Repository Context + +This workspace usually contains related repositories side by side: + +- `EasyReflectometryApp`: this GUI app. +- `EasyApplication`: shared QML/GUI shell and components. +- `reflectometry-lib`: EasyReflectometryLib domain package. +- `core`: EasyScience core framework and fitting internals. + +`pyproject.toml` currently depends on: + +- `easyapplication` +- `easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@interim_updates` +- `PySide6`, `toml`, and `asteval` + +When debugging runtime behavior, verify the active Python environment imports the intended local editable packages. Fitting progress and minimizer behavior can depend on matching changes in `core` and `reflectometry-lib`. + +Useful local environment note from this workspace: the `era` conda environment is commonly used. If fit-progress support looks stale, reinstall local core with the selected environment, for example: + +```powershell +C:/Users/piotrrozyczko/.conda/envs/era/python.exe -m pip install -e C:/projects/easy/ERA/core +``` + +## Development Commands + +Install for development from the app repo: + +```powershell +pip install -e . +``` + +Install test extras: + +```powershell +pip install -e .[test] +``` + +Run the app locally: + +```powershell +python EasyReflectometryApp/main.py +``` + +Run in test mode: + +```powershell +python EasyReflectometryApp/main.py --testmode +``` + +Run tests: + +```powershell +pytest +pytest --cov=EasyReflectometryApp --cov-report=term-missing +``` + +Run Ruff formatting/linting: + +```powershell +python -m ruff . +python -m ruff . --fix +python -m ruff format . +``` + +## Code Style + +Follow the project `pyproject.toml` settings: + +- Python 3.11+. +- Line length: 127. +- Single quotes for Python strings. +- Ruff is the formatter/linter. +- Imports are sorted by Ruff/isort with forced single-line imports. +- Test assertions are allowed in `test_*.py` files. + +General style: + +- Keep QML-facing adapters small and explicit. +- Put testable behavior in `Backends/Py/logic`. +- Avoid PySide imports in logic modules. +- Prefer meaningful names and explicit state transitions over hidden side effects. +- Use existing EasyApp QML components and project layout patterns rather than inventing new UI primitives. +- Keep legacy `src_qt5` as read-only reference unless an explicit migration task says otherwise. + +## QML And Backend Contracts + +When adding or changing a GUI feature, usually update all of these together: + +- Python adapter in `EasyReflectometryApp/Backends/Py/*.py`. +- Logic module in `EasyReflectometryApp/Backends/Py/logic/*.py` if behavior is non-trivial. +- `Gui/Globals/BackendWrapper.qml` bridge. +- `Backends/Mock/*.qml` equivalent for mock mode. +- Affected page/component under `Gui/Pages/...`. +- Tests under `tests/`. + +QML binding guidance: + +- Access backend state through `Globals.BackendWrapper`, not directly through `PyBackend`, unless the local pattern already requires direct signal connections. +- Prefer backend signals over polling when state changes originate in Python. +- Avoid binding loops by making ownership of writable state clear. Writable backend values should normally have explicit setter functions in `BackendWrapper.qml`. +- Dynamic chart series need careful timing. If QML creates series dynamically, ensure series are registered with the backend before Python plotting code attempts to populate them. +- Keep Mock backend APIs in sync with PyBackend APIs so UI work can continue without the Python backend. + +## Fitting And Plotting Notes + +Fitting is one of the highest-risk areas: + +- `Analysis` orchestrates fitting state and worker lifecycle. +- `Backends/Py/logic/fitting.py` prepares data, models, weights, minimizer options, and fit result state. +- `Backends/Py/workers/fitter_worker.py` runs fitting off the UI thread and emits result/failure/progress signals. +- Multi-experiment fitting uses EasyReflectometryLib `MultiFitter`, then often calls into the underlying EasyScience/core fitter. +- Avoid UI-thread reads of worker-mutated model state during a fit. Prefer immutable snapshots or explicit preview state for interim plotting. +- Cancellation has historically been tricky. Do not assume forceful `QThread.terminate()` is safe; prefer cooperative callback-based cancellation where the minimizer supports it. +- Always clear stale fit result state on failure or cancellation. +- If fitting behavior changes, add tests for success, failure, zero-variance data, cancellation, and repeated start/stop cycles. + +Plotting notes: + +- `Plotting1d` owns chart references, range properties, and QML-callable data point methods. +- Analysis, experiment, sample, SLD, residuals, and multi-experiment views share refresh paths. A change in one chart path can affect others. +- Residuals should be computed from aligned linear-space measured/model values, not from log-display values returned for the main analysis chart. +- Multi-experiment plotting should use separate series per experiment rather than concatenating datasets into one line. + +## Tests To Add With Changes + +Use focused tests proportional to risk: + +- Logic-only changes: add or update `tests/test_logic_*.py`. +- Backend adapter changes: add or update `tests/test_py_*.py`. +- Worker/fitting changes: add or update `tests/test_workers_fitter_worker.py` and `tests/test_logic_fitting.py`. +- QML bridge or visible UI changes: add tests like `tests/test_qml_fitting_progress_ui.py` where feasible, and manually run the app if the change is visual. +- Plotting changes: cover empty data, single experiment, multi-experiment, range fallbacks, and mode toggles such as `R(q) x q^4`. + +The existing `tests/conftest.py` provides a `QCoreApplication` fixture for PySide tests. + +## CI And Packaging + +Installer workflow: + +- `.github/workflows/installer.yml` builds on Ubuntu 22.04, Ubuntu 24.04, Windows 2022, and macOS 14. +- It uses Python 3.12 in CI and installs dependencies before freezing with PyInstaller and building a Qt Installer Framework package. +- `utils.py --update` injects additional release/CI metadata into `pyproject.toml` during the workflow. +- Windows signing uses DigiCert Software Trust Manager when secrets are available. +- Non-master branch pushes publish draft prereleases named by branch; master publishes using release metadata. + +Documentation workflow: + +- `.github/workflows/documentation-build.yml` builds Sphinx docs on version tags and pushes to `gh-pages`. +- Docs dependencies are in `pyproject.toml` under `docs`. + +Installer pitfall: + +- Linux QtIFW installer scripts must create `@HomeDir@/.local/share/applications` before copying `.desktop` files. Do not rely on workflow-side `mkdir -p` masking installer script bugs. + +## Common Pitfalls + +- Forgetting to update `BackendWrapper.qml` after adding a Python property or slot. +- Forgetting to update `Backends/Mock/*.qml`, which breaks mock/UI development mode. +- Adding PySide dependencies to logic modules, which makes unit testing harder. +- Reading or mutating shared project/model state from both the UI thread and fitting worker. +- Leaving cached analysis parameter lists stale after sample/model/experiment changes. +- Emitting only final fit signals when status bars or charts need interim state. +- Treating `src_qt5` as active code instead of migration reference. +- Assuming the installed `easyreflectometry` or `easyscience` package is the local workspace version. +- Making chart refresh depend on dynamic QML series before those series have been created and registered. + +## Release And Branch Notes + +The package version and release metadata live in `pyproject.toml`. Release docs also mention updating `README.md`, `INSTALLATION.md`, and `CHANGELOG.md` when bumping versions. + +The repository default branch may be `master`, while contribution documentation refers to `develop` for active development. Check the active branch and target branch before opening PRs or comparing behavior. + +## Recommended First Steps For A New Feature Or Bug Fix + +1. Reproduce or locate the behavior in the relevant page under `Gui/Pages` and its `BackendWrapper` calls. +2. Trace the corresponding Python adapter under `Backends/Py`. +3. Move business logic into or update the matching module under `Backends/Py/logic`. +4. Wire any new signals/properties through `PyBackend`, `BackendWrapper.qml`, and the mock backend. +5. Add focused tests before or alongside the implementation. +6. Run the smallest relevant pytest target first, then the broader suite if the change touches shared behavior. +7. For visual/QML work, run the app and verify the affected workflow manually. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8a7f0c..4deec11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,17 @@ only the first visible channel was shown. A channel the model cannot calculate (spin-flip on a non-magnetic sample) shows measured points only and contributes no residuals. +- Density materials (formula + mass density, as loaded from ORSO) now have a + detail panel on the Sample page: an editable chemical formula and density, + and a **"SLD computed from formula and density"** checkbox. Checked + (default), SLD/iSLD are read-only and derived; unchecked, they become + ordinary fittable parameters while density and the scattering lengths are + greyed out as unused. The molecular weight is a formula constant and never + appears as a fittable parameter. A ρ badge marks density materials in the + materials table. Re-checking the box recalculates SLD/iSLD, discarding any + manually entered or fitted values and any constraint on them. +- Parameter table rows where the minimizer produced no error bars (e.g. some + gradient-free methods) now show `n/a` instead of a blank/error cell. # Version 1.4.0 (3 Aug 2026) diff --git a/EasyReflectometryApp/Backends/Mock/Sample.qml b/EasyReflectometryApp/Backends/Mock/Sample.qml index ba46a28c..409f1cc8 100644 --- a/EasyReflectometryApp/Backends/Mock/Sample.qml +++ b/EasyReflectometryApp/Backends/Mock/Sample.qml @@ -27,17 +27,29 @@ QtObject { { 'label': 'label 1', 'sld': '1.23456', - 'isld': '-1.23456' + 'isld': '-1.23456', + 'kind': 'sld', + 'formula': '', + 'density': '', + 'sld_coupled': true }, { 'label': 'label 2', 'sld': '2.34567', - 'isld': '-2.34567' + 'isld': '-2.34567', + 'kind': 'sld', + 'formula': '', + 'density': '', + 'sld_coupled': true }, { - 'label': 'label 3', + 'label': 'SiO2 density', 'sld': '3.45678', - 'isld': '-3.45678' + 'isld': '-3.45678', + 'kind': 'density', + 'formula': 'SiO2', + 'density': '2.196', + 'sld_coupled': true }, ] readonly property var materialNames: materials.map(function (item) { return item.label }) @@ -55,6 +67,15 @@ QtObject { function setCurrentMaterialISld(value) { console.debug(`setCurrentMaterialISld ${value}`) } + function setMaterialSldCoupledAtIndex(index, value) { + console.debug(`setMaterialSldCoupledAtIndex ${index} ${value}`) + } + function setMaterialFormulaAtIndex(index, value) { + console.debug(`setMaterialFormulaAtIndex ${index} ${value}`) + } + function setMaterialDensityAtIndex(index, value) { + console.debug(`setMaterialDensityAtIndex ${index} ${value}`) + } // Table functions diff --git a/EasyReflectometryApp/Backends/Py/logic/material.py b/EasyReflectometryApp/Backends/Py/logic/material.py index d5ba815c..474c1672 100644 --- a/EasyReflectometryApp/Backends/Py/logic/material.py +++ b/EasyReflectometryApp/Backends/Py/logic/material.py @@ -1,8 +1,33 @@ +import logging from typing import Union from easyreflectometry import Project as ProjectLib from easyreflectometry.sample import MaterialCollection +logger = logging.getLogger(__name__) + + +def _is_density_material(material) -> bool: + """Density materials (``MaterialDensity``) expose the ``sld_coupled`` + toggle; duck-typed so test fakes and future material types work.""" + return hasattr(material, 'sld_coupled') + + +def _parameter_is_writable(parameter) -> bool: + """A coupled density material derives sld/isld from density — writing to + the dependent parameter would raise, so the setters refuse instead. + Checked per-parameter (not just `sld`): the lib guards sld and isld + individually since they can disagree mid-toggle.""" + return getattr(parameter, 'independent', True) + + +# A density material's fittable input knobs; cleared (free = False) when the +# material's sld/isld are decoupled from them so an already-ticked knob +# doesn't keep entering the fit after its row goes inactive in the GUI. +# molecular_weight is deliberately absent: it is a DescriptorNumber (a +# formula constant, never fittable) and has no `free` flag. +_DENSITY_KNOB_NAMES = ('density', 'scattering_length_real', 'scattering_length_imag') + class Material: def __init__(self, project_lib: ProjectLib): @@ -66,36 +91,114 @@ def set_name_at_index(self, index: int, new_value: str) -> bool: return False def set_sld_at_current_index(self, new_value: float) -> bool: - if self._materials[self.index].sld.value != new_value: - self._materials[self.index].sld.value = new_value + material = self._materials[self.index] + if not _parameter_is_writable(material.sld): + return False + if material.sld.value != new_value: + material.sld.value = new_value return True return False def set_sld_at_index(self, index: int, new_value: float) -> bool: if not (0 <= index < len(self._materials)): return False - if self._materials[index].sld.value != new_value: - self._materials[index].sld.value = new_value + material = self._materials[index] + if not _parameter_is_writable(material.sld): + return False + if material.sld.value != new_value: + material.sld.value = new_value return True return False def set_isld_at_current_index(self, new_value: float) -> bool: - if self._materials[self.index].isld.value != new_value: - self._materials[self.index].isld.value = new_value + material = self._materials[self.index] + if not _parameter_is_writable(material.isld): + return False + if material.isld.value != new_value: + material.isld.value = new_value return True return False def set_isld_at_index(self, index: int, new_value: float) -> bool: if not (0 <= index < len(self._materials)): return False - if self._materials[index].isld.value != new_value: - self._materials[index].isld.value = new_value + material = self._materials[index] + if not _parameter_is_writable(material.isld): + return False + if material.isld.value != new_value: + material.isld.value = new_value return True return False + def set_sld_coupled_at_index(self, index: int, coupled: bool) -> bool: + if not (0 <= index < len(self._materials)): + return False + material = self._materials[index] + if not _is_density_material(material): + return False + if bool(material.sld_coupled) == bool(coupled): + return False + material.sld_coupled = bool(coupled) + if not coupled: + # The GUI greys these rows out (kind: 'inactive') but that is + # display-only — the fitter reads Parameter.free, so a knob + # ticked before decoupling would otherwise keep entering the + # minimizer after it stops affecting the reflectivity. + for knob_name in _DENSITY_KNOB_NAMES: + knob = getattr(material, knob_name, None) + if knob is not None: + knob.free = False + return True + + def set_formula_at_index(self, index: int, formula: str) -> bool: + if not (0 <= index < len(self._materials)): + return False + material = self._materials[index] + if not _is_density_material(material): + return False + formula = formula.strip() + if not formula or material.chemical_structure == formula: + return False + try: + material.chemical_structure = formula + except Exception: + logger.warning('Rejected invalid chemical formula %r', formula) + return False + return True + + def set_density_at_index(self, index: int, new_value: float) -> bool: + if not (0 <= index < len(self._materials)): + return False + material = self._materials[index] + if not _is_density_material(material): + return False + try: + value = float(new_value) + except (TypeError, ValueError): + return False + if material.density.value == value: + return False + try: + material.density.value = value + except Exception: + logger.warning('Rejected out-of-bounds density %r', value) + return False + return True + def _from_materials_collection_to_list_of_dicts(materials_collection: MaterialCollection) -> list[dict[str, str]]: materials_list = [] for material in materials_collection: - materials_list.append({'label': material.name, 'sld': str(material.sld.value), 'isld': str(material.isld.value)}) + is_density = _is_density_material(material) + materials_list.append( + { + 'label': material.name, + 'sld': str(material.sld.value), + 'isld': str(material.isld.value), + 'kind': 'density' if is_density else 'sld', + 'formula': material.chemical_structure if is_density else '', + 'density': str(material.density.value) if is_density else '', + 'sld_coupled': bool(material.sld_coupled) if is_density else True, + } + ) return materials_list diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index e98b4630..4dd9875f 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -161,6 +161,13 @@ def _get_current_parameter(self) -> Parameter: return enabled_params[self._current_index] return None + def _get_current_parameter_entry(self) -> dict[str, Any] | None: + """Get the current row (with 'kind'/'object') from the enabled rows.""" + enabled_entries = [p for p in self.parameters if p.get('enabled', True)] + if 0 <= self._current_index < len(enabled_entries): + return enabled_entries[self._current_index] + return None + def set_current_parameter_value(self, new_value: str) -> bool: parameter = self._get_current_parameter() if parameter is None: @@ -213,9 +220,15 @@ def set_current_parameter_max(self, new_value: str) -> bool: return False def set_current_parameter_fit(self, new_value: bool) -> bool: - parameter = self._get_current_parameter() - if parameter is None: + entry = self._get_current_parameter_entry() + if entry is None: + return False + if entry.get('kind') == 'inactive': + # Same class of bug the Select-All skip fixed: an inactive + # density knob no longer affects the reflectivity and must not + # be tickable into the fit through any caller, QML or not. return False + parameter = entry['object'] if bool(new_value) != parameter.free: parameter.free = bool(new_value) return True @@ -374,6 +387,12 @@ 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) + # A density material's input knobs (density, mw, scattering + # lengths) stop affecting the reflectivity once the material's + # sld/isld are decoupled — shown greyed with a note, never hidden + # (enabled must stay True: _parameter_matches_filters drops + # enabled=False rows from the list entirely). + is_inactive = _is_inactive_density_knob(parameter, path) parameter_list.append( { 'name': prefixed_display_name, @@ -382,22 +401,27 @@ def _is_per_layer_parameter(param: Parameter) -> bool: 'alias': alias, 'unique_name': parameter.unique_name, 'value': param_value, - 'error': float(parameter.error), + # None means the minimizer produced no error bars (e.g. lmfit's + # gradient-free methods); the GUI renders it as 'n/a', distinct + # from a numeric zero which is shown as an empty cell. + 'error': float(parameter.error) if parameter.error is not None else None, 'max': float(parameter.max), 'min': float(parameter.min), 'units': parameter.unit, - 'fit': False if is_derived else parameter.free, + 'fit': False if (is_derived or is_inactive) else parameter.free, 'independent': parameter.independent, 'dependency': ( - _DERIVED_DESCRIPTIONS.get(parameter.name, 'derived') + _INACTIVE_KNOB_NOTE + if is_inactive + else _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, + 'kind': 'derived' if is_derived else 'inactive' if is_inactive else 'parameter', + 'readOnly': is_derived or is_inactive, 'enabled': parameter.enabled if hasattr(parameter, 'enabled') else True, 'object': parameter, # Direct reference to the Parameter object } @@ -410,6 +434,26 @@ def _is_per_layer_parameter(param: Parameter) -> bool: 'total_thickness': 'Σ film layer thicknesses', } +# The input knobs of a density material (MaterialDensity); inactive when the +# material's sld/isld are decoupled from them. molecular_weight is not listed: +# it is a DescriptorNumber, so the Parameter tree walk never yields it and it +# never appears in this table at all. +_DENSITY_KNOB_NAMES = {'density', 'scattering_length_real', 'scattering_length_imag'} +_INACTIVE_KNOB_NOTE = 'unused (SLD is fitted directly)' + + +def _is_inactive_density_knob(parameter: Parameter, path) -> bool: + """True for a density-material knob whose material is decoupled. + + Duck-typed on the owner's ``sld_coupled`` (only ``MaterialDensity`` + carries it); the getattr default True keeps every other owner active. + """ + if path is None or len(path) < 2: + return False + if parameter.name not in _DENSITY_KNOB_NAMES: + return False + return getattr(path[-2], 'sld_coupled', True) is False + def _is_derived_parameter(parameter: Parameter, model) -> bool: """True for the model-owned computed parameters (currently ``Model.total_thickness``).""" diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 1d2ae6b1..1281bb30 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -198,6 +198,58 @@ def setMaterialISldAtIndex(self, index: int, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + def _emitDensityMaterialChanged(self) -> None: + """Signal fan-out for density-material edits (formula, density, the + sld_coupled toggle). Beyond the usual material-edit trio, these change + which parameters are fittable/inactive (rebuilds the Analysis table via + the modelsTableChanged wiring in py_backend) and can invalidate user + constraints referencing the material's sld/isld. + """ + self.materialsTableChanged.emit() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + self.modelsTableChanged.emit() + self._scheduleConstraintsChanged() + + @Slot(int, bool) + def setMaterialSldCoupledAtIndex(self, index: int, coupled: bool) -> None: + if coupled: + self._drop_stale_sld_constraint_states(index) + if self._material_logic.set_sld_coupled_at_index(index, coupled): + self._emitDensityMaterialChanged() + + def _drop_stale_sld_constraint_states(self, index: int) -> None: + """Re-coupling calls MaterialDensity._setup_sld_constraints(), which + overwrites sld/isld's dependency directly (bypassing addConstraint/ + removeConstraint) and silently discards any user constraint on those + parameters. Without this, `_constraint_states` keeps the stale entry + and the constraints table keeps showing an expression that no longer + reflects the actual (now density-derived) dependency. + """ + materials = self._material_logic._materials + if not (0 <= index < len(materials)): + return + material = materials[index] + if getattr(material, 'sld_coupled', True): + return # already coupled: no user constraint to have been overwritten + for parameter in (getattr(material, 'sld', None), getattr(material, 'isld', None)): + unique_name = getattr(parameter, 'unique_name', None) + if unique_name and self._constraint_states.pop(unique_name, None) is not None: + logger.warning( + 'Dropped stale user constraint on %s: re-coupling SLD to formula/density overwrites it.', + unique_name, + ) + + @Slot(int, str) + def setMaterialFormulaAtIndex(self, index: int, formula: str) -> None: + if self._material_logic.set_formula_at_index(index, formula): + self._emitDensityMaterialChanged() + + @Slot(int, str) + def setMaterialDensityAtIndex(self, index: int, new_value: str) -> None: + if self._material_logic.set_density_at_index(index, new_value): + self._emitDensityMaterialChanged() + # Actions @Slot(str) def removeMaterial(self, value: str) -> None: diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index ff98d8c1..426ceaae 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -118,6 +118,9 @@ QtObject { function sampleSetMaterialSldAtIndex(index, value) { activeBackend.sample.setMaterialSldAtIndex(index, value) } function sampleSetCurrentMaterialISld(value) { activeBackend.sample.setCurrentMaterialISld(value) } function sampleSetMaterialISldAtIndex(index, value) { activeBackend.sample.setMaterialISldAtIndex(index, value) } + function sampleSetMaterialSldCoupledAtIndex(index, value) { activeBackend.sample.setMaterialSldCoupledAtIndex(index, value) } + function sampleSetMaterialFormulaAtIndex(index, value) { activeBackend.sample.setMaterialFormulaAtIndex(index, value) } + function sampleSetMaterialDensityAtIndex(index, value) { activeBackend.sample.setMaterialDensityAtIndex(index, value) } function sampleRemoveMaterial(value) { activeBackend.sample.removeMaterial(value) } function sampleAddNewMaterial() { activeBackend.sample.addNewMaterial() } function sampleDuplicateSelectedMaterial() { activeBackend.sample.duplicateSelectedMaterial() } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml index c7e22cb9..deda65f6 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml @@ -296,11 +296,14 @@ EaElements.GroupBox { EaComponents.TableViewLabel { width: EaStyle.Sizes.fontPixelSize * 5 // Derived (computed, read-only) parameters carry an ƒ badge; the - // tooltip explains what they are computed from. + // tooltip explains what they are computed from. Inactive rows are + // a decoupled density material's unused knobs. readonly property bool derived: Globals.BackendWrapper.analysisFitableParameters[index].kind === 'derived' + readonly property bool inactive: Globals.BackendWrapper.analysisFitableParameters[index].kind === 'inactive' text: (derived ? 'ƒ ' : '') + Globals.BackendWrapper.analysisFitableParameters[index].name textFormat: Text.PlainText - color: (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? + color: !inactive && + (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? Globals.BackendWrapper.analysisFitableParameters[index].independent : true) ? EaStyle.Colors.themeForeground : EaStyle.Colors.themeForegroundDisabled // The embedded TableViewLabel tooltip only appears while the @@ -310,13 +313,18 @@ EaElements.GroupBox { ? qsTr("%1 — derived, read-only: %2") .arg(text) .arg(Globals.BackendWrapper.analysisFitableParameters[index].dependency || '') + : inactive + ? qsTr("%1 — %2") + .arg(text) + .arg(Globals.BackendWrapper.analysisFitableParameters[index].dependency || '') : text } EaComponents.TableViewParameter { id: valueColumn - enabled: Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? - Globals.BackendWrapper.analysisFitableParameters[index].independent : true + enabled: Globals.BackendWrapper.analysisFitableParameters[index].kind !== 'inactive' && + (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? + Globals.BackendWrapper.analysisFitableParameters[index].independent : true) selected: index === Globals.BackendWrapper.analysisCurrentParameterIndex text: EaLogic.Utils.toMaxPrecision(Globals.BackendWrapper.analysisFitableParameters[index].value, 3) onEditingFinished: { @@ -346,8 +354,10 @@ EaElements.GroupBox { EaComponents.TableViewLabel { // A constrained (dependent) parameter derives its value from another - // parameter, so it has no error of its own to report. - text: (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? + // parameter, so it has no error of its own to report; an inactive + // knob's error is stale (the parameter no longer enters the fit). + text: Globals.BackendWrapper.analysisFitableParameters[index].kind !== 'inactive' && + (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? Globals.BackendWrapper.analysisFitableParameters[index].independent : true) ? formatError(Globals.BackendWrapper.analysisFitableParameters[index].error) : '' color: EaStyle.Colors.themeForegroundDisabled @@ -359,8 +369,11 @@ EaElements.GroupBox { // anything enforces. Showing them next to editable bounds only // invites reading them as physical limits, so leave the cells empty. EaComponents.TableViewParameter { + // Locked rows: derived (computed bounds) and inactive (a decoupled + // density material's unused knobs — bounds are real but unused). readonly property bool derived: Globals.BackendWrapper.analysisFitableParameters[index].kind === 'derived' - enabled: !derived && + readonly property bool inactive: Globals.BackendWrapper.analysisFitableParameters[index].kind === 'inactive' + enabled: !derived && !inactive && (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? Globals.BackendWrapper.analysisFitableParameters[index].independent : true) text: derived ? '' : @@ -375,7 +388,8 @@ EaElements.GroupBox { EaComponents.TableViewParameter { readonly property bool derived: Globals.BackendWrapper.analysisFitableParameters[index].kind === 'derived' - enabled: !derived && + readonly property bool inactive: Globals.BackendWrapper.analysisFitableParameters[index].kind === 'inactive' + enabled: !derived && !inactive && (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? Globals.BackendWrapper.analysisFitableParameters[index].independent : true) text: derived ? '' : @@ -390,7 +404,11 @@ EaElements.GroupBox { EaComponents.TableViewCheckBox { id: fitColumn + // The kind check matters here: an inactive density knob IS + // independent, but fitting it would silently do nothing (it no + // longer affects the reflectivity while SLD is fitted directly). enabled: Globals.BackendWrapper.analysisExperimentsAvailable.length && + Globals.BackendWrapper.analysisFitableParameters[index].kind !== 'inactive' && (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? Globals.BackendWrapper.analysisFitableParameters[index].independent : true) checked: Globals.BackendWrapper.analysisFitableParameters[index].fit @@ -461,6 +479,7 @@ EaElements.GroupBox { enabled: !Globals.BackendWrapper.analysisFittingRunning && Globals.BackendWrapper.analysisFitableParameters.length > 0 && + Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].kind !== 'inactive' && (Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].independent !== undefined ? Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].independent : true) width: tableView.width - EaStyle.Sizes.fontPixelSize * 14 @@ -532,7 +551,11 @@ EaElements.GroupBox { } function formatError(value) { - if (value === undefined || value === 0 || isNaN(value)) return '' + // A Python None (the minimizer completed but produced no error bars, e.g. + // lmfit's gradient-free powell/cobyla) crosses the PySide6 QVariant + // boundary as undefined, not null — check both. + if (value === undefined || value === null) return 'n/a' + if (value === 0 || isNaN(value)) return '' var s = Number(value.toPrecision(2)).toString() if (s.length <= 6) return s return value.toExponential(1) @@ -546,7 +569,9 @@ EaElements.GroupBox { for (let i = 0; i < params.length; i++) { const parameter = params[i] const independent = parameter.independent !== undefined ? parameter.independent : true - if (!independent) { + // Inactive rows (a decoupled density material's unused knobs) are + // independent but never fittable — skip them like dependent rows. + if (!independent || parameter.kind === 'inactive') { continue } if (!parameter.fit) { @@ -569,7 +594,7 @@ EaElements.GroupBox { for (let i = 0; i < params.length; i++) { const parameter = params[i] const independent = parameter.independent !== undefined ? parameter.independent : true - if (!independent) { + if (!independent || parameter.kind === 'inactive') { continue } if (!!parameter.fit === targetFit) { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml index 7b8a31dc..968081be 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml @@ -57,8 +57,16 @@ EaElements.GroupBox { delegate: EaComponents.TableViewDelegate { EaComponents.TableViewLabel { - text: index + 1 + // Density materials carry a ρ badge: their SLD is derived from + // formula & density, and selecting the row opens the density + // detail panel below the table. + readonly property bool density: Globals.BackendWrapper.sampleMaterials[index].kind === 'density' + text: (index + 1) + (density ? ' ρ' : '') color: EaStyle.Colors.themeForegroundMinor + ToolTip.text: density ? + qsTr("Density material (%1) — select the row to edit formula, density and SLD coupling below") + .arg(Globals.BackendWrapper.sampleMaterials[index].formula) : + '' } EaComponents.TableViewTextInput { @@ -67,11 +75,28 @@ EaElements.GroupBox { } EaComponents.TableViewTextInput { + // A coupled density material derives SLD from formula & density. + readonly property bool sldLocked: Globals.BackendWrapper.sampleMaterials[index].kind === 'density' && + Globals.BackendWrapper.sampleMaterials[index].sld_coupled + // readOnly rather than enabled:false: a disabled item receives no + // hover events, so its ToolTip could never explain the lock. + readOnly: sldLocked + ToolTip.text: sldLocked ? + qsTr("Derived from formula and density — uncheck 'SLD computed from formula and density' below to edit") : + '' text: Number(Globals.BackendWrapper.sampleMaterials[index].sld).toFixed(3) onEditingFinished: Globals.BackendWrapper.sampleSetMaterialSldAtIndex(index, text) } EaComponents.TableViewTextInput { + readonly property bool sldLocked: Globals.BackendWrapper.sampleMaterials[index].kind === 'density' && + Globals.BackendWrapper.sampleMaterials[index].sld_coupled + // readOnly rather than enabled:false: a disabled item receives no + // hover events, so its ToolTip could never explain the lock. + readOnly: sldLocked + ToolTip.text: sldLocked ? + qsTr("Derived from formula and density — uncheck 'SLD computed from formula and density' below to edit") : + '' text: Number(Globals.BackendWrapper.sampleMaterials[index].isld).toFixed(3) onEditingFinished: Globals.BackendWrapper.sampleSetMaterialISldAtIndex(index, text) } @@ -127,5 +152,112 @@ EaElements.GroupBox { onClicked: Globals.BackendWrapper.sampleMoveSelectedMaterialDown() } } + + // Density-material detail: formula and density are the physical inputs; + // the checkbox decouples sld/isld for direct entry and fitting + // (see SLD_CHECKBOX_PLAN.md). Visible only when the selected material + // is a density material. + Column { + id: densityMaterialSection + + readonly property var densityMaterial: + Globals.BackendWrapper.sampleMaterials[Globals.BackendWrapper.sampleCurrentMaterialIndex] + readonly property bool isDensity: + densityMaterial !== undefined && densityMaterial.kind === 'density' + // Formula and density drive the SLD only while coupled. Once the user + // takes the SLD over, they are inert inputs (the Analysis table marks + // the matching parameters 'inactive' too), so show them read-only + // instead of inviting edits that change nothing. + readonly property bool sldCoupled: + isDensity ? densityMaterial.sld_coupled : true + + visible: isDensity + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.Label { + color: EaStyle.Colors.themeForegroundMinor + text: densityMaterialSection.isDensity ? + qsTr("Density material '%1'").arg(densityMaterialSection.densityMaterial.label) : '' + } + + Row { + spacing: EaStyle.Sizes.fontPixelSize + + EaElements.TextField { + id: formulaField + width: (EaStyle.Sizes.sideBarContentWidth - parent.spacing) / 2 + topInset: formulaLabel.height + topPadding: topInset + padding + horizontalAlignment: TextInput.AlignLeft + readOnly: !densityMaterialSection.sldCoupled + ToolTip.text: densityMaterialSection.sldCoupled ? + '' : + qsTr("Unused while SLD is set directly — check 'SLD computed from formula and density' below to edit") + text: densityMaterialSection.isDensity ? densityMaterialSection.densityMaterial.formula : '' + onEditingFinished: { + Globals.BackendWrapper.sampleSetMaterialFormulaAtIndex( + Globals.BackendWrapper.sampleCurrentMaterialIndex, text) + // Typing broke the declarative binding; re-establish it so + // the field follows the backend (which may have rejected an + // invalid formula) and later selection changes. + text = Qt.binding(function () { + return densityMaterialSection.isDensity ? + densityMaterialSection.densityMaterial.formula : '' + }) + } + EaElements.Label { + id: formulaLabel + text: qsTr('Chemical formula') + } + } + + EaElements.TextField { + id: densityField + width: formulaField.width + topInset: densityLabel.height + topPadding: topInset + padding + horizontalAlignment: TextInput.AlignLeft + readOnly: !densityMaterialSection.sldCoupled + ToolTip.text: densityMaterialSection.sldCoupled ? + '' : + qsTr("Unused while SLD is set directly — check 'SLD computed from formula and density' below to edit") + text: densityMaterialSection.isDensity ? densityMaterialSection.densityMaterial.density : '' + onEditingFinished: { + Globals.BackendWrapper.sampleSetMaterialDensityAtIndex( + Globals.BackendWrapper.sampleCurrentMaterialIndex, text) + text = Qt.binding(function () { + return densityMaterialSection.isDensity ? + densityMaterialSection.densityMaterial.density : '' + }) + } + EaElements.Label { + id: densityLabel + text: qsTr('Density (g/cm³)') + } + } + } + + EaElements.CheckBox { + text: qsTr("SLD computed from formula and density") + checked: densityMaterialSection.isDensity ? + densityMaterialSection.densityMaterial.sld_coupled : true + ToolTip.text: qsTr("When re-enabled, SLD/iSLD are recalculated from the formula and density; manually entered or fitted SLD values, and any constraint on SLD/iSLD, are discarded.") + // toggled() also fires on the programmatic `checked` rebind below + // (materialsTableChanged from our own backend call, or a row + // selection change) — this only stays a no-op loop because + // set_sld_coupled_at_index() in the backend refuses to re-emit + // when the state already matches. Don't drop that guard. + onToggled: { + Globals.BackendWrapper.sampleSetMaterialSldCoupledAtIndex( + Globals.BackendWrapper.sampleCurrentMaterialIndex, checked) + // The click already moved the box and broke the binding; + // follow the backend's state instead of assuming. + checked = Qt.binding(function () { + return densityMaterialSection.isDensity ? + densityMaterialSection.densityMaterial.sld_coupled : true + }) + } + } + } } } diff --git a/docs/src/tutorials/model_def.md b/docs/src/tutorials/model_def.md index 61d8495d..9be7fcee 100644 --- a/docs/src/tutorials/model_def.md +++ b/docs/src/tutorials/model_def.md @@ -20,6 +20,47 @@ The materials are added by the real and imaginary components of the scattering l - **B**: Duplicating the last clicked material. - **C**: Changes the ordering of materials. +### Density materials +A material can also be defined by its **chemical formula and mass density** instead of a +numeric SLD. Such *density materials* enter a project when a sample is loaded from an ORSO +model that defines a material this way (`Load a sample`), and they are marked with a **ρ** +badge next to their row number in the Material editor table. + +Selecting a ρ row reveals a detail panel below the table with the material's chemical +formula, its mass density (in g/cm³) and the checkbox **SLD computed from formula and +density**: + + + +- **Checked** (the default): the SLD is physics, not an input - it is computed as + `SLD = Nᴀ · ρ · b / M` from the density ρ, the formula's coherent neutron scattering + length *b* and molecular weight *M*. The SLD/iSLD cells in the table are therefore + read-only, and on the `Analysis` page the material's `sld`/`isld` parameters are shown + greyed as dependent while **`density` is the parameter to fit**. Editing the formula + updates the scattering length and molecular weight (an invalid formula is rejected and + the field snaps back). +- **Unchecked**: `sld`/`isld` become ordinary independent parameters - editable here and + fittable on the `Analysis` page exactly like a plain material's (they arrive fixed, so + tick their `Fit` box). The `density` and scattering-length rows are greyed with the + note `unused (SLD is fitted directly)`, because they no longer affect the + reflectivity - they cannot be fitted or edited until the box is checked again. + +The molecular weight never appears in the `Analysis` table: it is a constant of the +chemical formula (recomputed whenever the formula is edited), not a fittable parameter - +fitting it alongside density would be degenerate, since only their ratio enters the SLD. + +```{warning} +Re-checking the box restores the coupling by **recalculating** SLD/iSLD from the current +formula and density - manually entered or fitted SLD values are discarded. +``` + +The choice is per material and is saved with the project; projects saved before this +feature load with the coupling enabled. Note that toggling the coupling is not undoable. +If you add a constraint on `sld`/`isld` while unchecked and then re-check the box, that +constraint is silently discarded - re-checking always recomputes SLD/iSLD from the +formula and density. The `Active Constraints` table refreshes after a toggle, but it does +not flag rows that the toggle invalidated, so review it yourself after switching modes. + ### Model creation and editing For creating new models, the `Models selector` tab is used, and then for setting the assemblies in the model the `Model editor` is used. diff --git a/tests/test_logic_material.py b/tests/test_logic_material.py index a31fd8c9..cf83ba44 100644 --- a/tests/test_logic_material.py +++ b/tests/test_logic_material.py @@ -1,3 +1,7 @@ +import pytest +from easyreflectometry.sample import MaterialDensity +from numpy.testing import assert_almost_equal + from EasyReflectometryApp.Backends.Py.logic.material import Material from EasyReflectometryApp.Backends.Py.logic.material import _from_materials_collection_to_list_of_dicts from tests.factories import make_material @@ -5,6 +9,10 @@ from tests.factories import make_project +def make_density_material(formula='Si', density=2.33, name='SiDensity'): + return MaterialDensity(chemical_structure=formula, density=density, name=name) + + def test_from_materials_collection_to_list_of_dicts_serializes_values(): materials = make_material_collection( make_material('Air', sld=0.0, isld=0.0), @@ -14,11 +22,97 @@ def test_from_materials_collection_to_list_of_dicts_serializes_values(): result = _from_materials_collection_to_list_of_dicts(materials) assert result == [ - {'label': 'Air', 'sld': '0.0', 'isld': '0.0'}, - {'label': 'Si', 'sld': '2.07', 'isld': '0.1'}, + {'label': 'Air', 'sld': '0.0', 'isld': '0.0', 'kind': 'sld', 'formula': '', 'density': '', 'sld_coupled': True}, + {'label': 'Si', 'sld': '2.07', 'isld': '0.1', 'kind': 'sld', 'formula': '', 'density': '', 'sld_coupled': True}, ] +def test_from_materials_collection_marks_density_materials(): + materials = make_material_collection(make_density_material('Si', 2.33)) + + (row,) = _from_materials_collection_to_list_of_dicts(materials) + + assert row['kind'] == 'density' + assert row['formula'] == 'Si' + assert row['density'] == '2.33' + assert row['sld_coupled'] is True + + +def test_set_sld_refused_on_coupled_density_material(): + materials = make_material_collection(make_density_material()) + project = make_project(materials=materials) + logic = Material(project) + coupled_sld = materials[0].sld.value + + assert logic.set_sld_at_index(0, 9.9) is False + assert logic.set_isld_at_index(0, 9.9) is False + assert logic.set_sld_at_current_index(9.9) is False + assert logic.set_isld_at_current_index(9.9) is False + assert_almost_equal(materials[0].sld.value, coupled_sld) + + assert logic.set_sld_coupled_at_index(0, False) is True + assert logic.set_sld_at_index(0, 9.9) is True + assert materials[0].sld.value == 9.9 + + +def test_set_sld_coupled_at_index_change_state_and_guards(): + materials = make_material_collection(make_density_material(), make_material('Air')) + project = make_project(materials=materials) + logic = Material(project) + + assert logic.set_sld_coupled_at_index(0, True) is False # already coupled + assert logic.set_sld_coupled_at_index(0, False) is True + assert materials[0].sld_coupled is False + assert logic.set_sld_coupled_at_index(0, False) is False # no change + + assert logic.set_sld_coupled_at_index(1, False) is False # not a density material + assert logic.set_sld_coupled_at_index(5, False) is False # out of bounds + + +def test_set_formula_at_index_updates_derived_sld(): + materials = make_material_collection(make_density_material('Co', 8.9)) + project = make_project(materials=materials) + logic = Material(project) + + assert logic.set_formula_at_index(0, 'B') is True + assert materials[0].chemical_structure == 'B' + # sld follows the new formula's scattering length AND molecular weight + assert_almost_equal(materials[0].molecular_weight.value, 10.81) + assert_almost_equal(materials[0].sld.value, 26.277925961998147) + + assert logic.set_formula_at_index(0, 'B') is False # unchanged + assert logic.set_formula_at_index(0, ' ') is False # blank + assert logic.set_formula_at_index(0, '###') is False # invalid, rejected + assert materials[0].chemical_structure == 'B' + + +def test_set_density_at_index(): + materials = make_material_collection(make_density_material('Si', 2.33)) + project = make_project(materials=materials) + logic = Material(project) + original_sld = materials[0].sld.value + + assert logic.set_density_at_index(0, '4.66') is True + assert materials[0].density.value == pytest.approx(4.66) + assert_almost_equal(materials[0].sld.value, 2 * original_sld) + + assert logic.set_density_at_index(0, 4.66) is False # unchanged + assert logic.set_density_at_index(0, 'abc') is False # not a number + + +def test_set_density_at_index_clamps_below_the_min_bound(): + # The core's Parameter.value setter clamps out-of-bounds writes to + # min/max rather than raising (unlike __init__, which raises) — so a + # negative density silently becomes 0.0, not rejected. The setter's + # try/except guards a hypothetical raise without assuming one. + materials = make_material_collection(make_density_material('Si', 2.33)) + project = make_project(materials=materials) + logic = Material(project) + + assert logic.set_density_at_index(0, '-1') is True + assert materials[0].density.value == pytest.approx(0.0) + + def test_material_logic_add_duplicate_move_and_remove(): materials = make_material_collection( make_material('Air', sld=0.0), diff --git a/tests/test_logic_parameters.py b/tests/test_logic_parameters.py index 9566c29e..4a8eeb62 100644 --- a/tests/test_logic_parameters.py +++ b/tests/test_logic_parameters.py @@ -32,6 +32,51 @@ def _patch_tree_types(monkeypatch): monkeypatch.setattr(parameters_module, 'ModelBase', FakeNode) +def test_from_parameters_to_list_of_dicts_marks_decoupled_density_knobs_inactive(monkeypatch): + _patch_tree_types(monkeypatch) + + density = make_parameter(name='density', unique_name='density', value=2.33, free=True, enabled=True) + # molecular_weight is intentionally absent: in the lib it is a + # DescriptorNumber (a formula constant), so the Parameter tree walk + # never yields it. The scattering length stands in as the second knob. + b_real = make_parameter(name='scattering_length_real', unique_name='b_real', value=4.15, free=False, enabled=True) + thickness = make_parameter(name='thickness', unique_name='thickness', value=20.0, free=True, enabled=True) + + def build_model(sld_coupled): + model = make_model(name='M1 internal', unique_name='m1', user_data={'original_name': 'M1'}) + layer = FakeNode('Layer', 'm1_layer', thickness=thickness) + assembly = FakeNode('LayerA', 'm1_asm', layers=[layer]) + model.sample = [assembly] + # Density-material knobs live under the material node, which carries + # the sld_coupled toggle (duck-typed stand-in for MaterialDensity). + material = FakeNode('SiDensity', 'm1_mat', density=density, scattering_length_real=b_real) + material.sld_coupled = sld_coupled + model.material = material + return model + + decoupled = parameters_module._from_parameters_to_list_of_dicts( + [density, b_real, thickness], make_model_collection(build_model(sld_coupled=False)) + ) + rows = {entry['display_name']: entry for entry in decoupled} + for label in ('SiDensity density', 'SiDensity scattering_length_real'): + assert rows[label]['kind'] == 'inactive' + assert rows[label]['fit'] is False + assert rows[label]['readOnly'] is True + assert rows[label]['enabled'] is True # greyed, never filtered out + assert rows[label]['dependency'] == 'unused (SLD is fitted directly)' + # Non-knob parameters are untouched. + assert rows['M1 LayerA thickness']['kind'] == 'parameter' + assert rows['M1 LayerA thickness']['fit'] is True + + coupled = parameters_module._from_parameters_to_list_of_dicts( + [density, b_real, thickness], make_model_collection(build_model(sld_coupled=True)) + ) + rows = {entry['display_name']: entry for entry in coupled} + assert rows['SiDensity density']['kind'] == 'parameter' + assert rows['SiDensity density']['fit'] is True + assert rows['SiDensity scattering_length_real']['kind'] == 'parameter' + + def test_from_parameters_to_list_of_dicts_prefixes_layers_and_deduplicates_shared_params(monkeypatch): _patch_tree_types(monkeypatch) @@ -196,6 +241,29 @@ def test_parameters_filtering_metadata_and_current_parameter_updates(monkeypatch assert free_parameter.free is False +def test_set_current_parameter_fit_refuses_inactive_row(monkeypatch): + """An inactive density knob must not be tickable into the fit through + any caller, QML checkbox or not — same class of bug the Select-All skip + fixed for the fittables table.""" + project = make_project() + logic = parameters_module.Parameters(project) + inactive_parameter = make_parameter(name='Density', unique_name='density', value=2.33, free=False) + mocked_parameters = [ + { + 'display_name': 'SiDensity density', + 'unique_name': 'density', + 'kind': 'inactive', + 'enabled': True, + 'object': inactive_parameter, + }, + ] + monkeypatch.setattr(logic, 'all_parameters', lambda: mocked_parameters) + + logic.set_current_index(0) + assert logic.set_current_parameter_fit(True) is False + assert inactive_parameter.free is False + + def test_add_constraint_supports_arithmetic_and_constant_dependencies(): independent = make_parameter(name='Scale', unique_name='scale', value=2.0) dependent = make_parameter(name='Background', unique_name='background', value=0.5) diff --git a/tests/test_py_sample_density_material.py b/tests/test_py_sample_density_material.py new file mode 100644 index 00000000..f33de9ce --- /dev/null +++ b/tests/test_py_sample_density_material.py @@ -0,0 +1,133 @@ +"""Qt-level tests for the density-material slots on the Sample backend. + +These exercise the real reflectometry library because the sld_coupled +toggle lives in the parameter dependency graph. +""" + +import pytest +from easyreflectometry import Project +from easyreflectometry.sample import MaterialDensity +from easyscience import global_object + +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 backend_with_density_material(qcore_application): + project = Project() + backend = Sample(project) # installs the default model + project._materials.add_material(MaterialDensity(chemical_structure='Si', density=2.33, name='SiDensity')) + return project, backend, len(project._materials) - 1 + + +def _spy(signal): + calls = [] + signal.connect(lambda *args: calls.append(args)) + return calls + + +def test_set_material_sld_coupled_slot_toggles_and_emits(backend_with_density_material): + project, backend, index = backend_with_density_material + emitted = { + 'materials': _spy(backend.materialsTableChanged), + 'plot': _spy(backend.externalRefreshPlot), + 'sample': _spy(backend.externalSampleChanged), + 'models': _spy(backend.modelsTableChanged), + } + + backend.setMaterialSldCoupledAtIndex(index, False) + assert project._materials[index].sld_coupled is False + assert {name: len(calls) for name, calls in emitted.items()} == { + 'materials': 1, + 'plot': 1, + 'sample': 1, + 'models': 1, + } + + # A no-op toggle must not emit again. + backend.setMaterialSldCoupledAtIndex(index, False) + assert all(len(calls) == 1 for calls in emitted.values()) + + +def test_density_and_formula_slots_update_material(backend_with_density_material): + project, backend, index = backend_with_density_material + material = project._materials[index] + original_sld = material.sld.value + + backend.setMaterialDensityAtIndex(index, '4.66') + assert material.density.value == pytest.approx(4.66) + assert material.sld.value == pytest.approx(2 * original_sld) + + backend.setMaterialFormulaAtIndex(index, 'SiO2') + assert material.chemical_structure == 'SiO2' + + # Invalid input is rejected without touching the material. + backend.setMaterialFormulaAtIndex(index, '###') + assert material.chemical_structure == 'SiO2' + + row = backend.materials[index] + assert row['kind'] == 'density' + assert row['formula'] == 'SiO2' + assert row['sld_coupled'] is True + + +def test_sld_slot_refused_while_coupled(backend_with_density_material): + project, backend, index = backend_with_density_material + material = project._materials[index] + coupled_sld = material.sld.value + materials_calls = _spy(backend.materialsTableChanged) + + backend.setMaterialSldAtIndex(index, 9.9) + assert material.sld.value == pytest.approx(coupled_sld) + assert len(materials_calls) == 0 + + backend.setMaterialSldCoupledAtIndex(index, False) + backend.setMaterialSldAtIndex(index, 9.9) + assert material.sld.value == 9.9 + + +def test_decouple_clears_free_on_density_knobs(backend_with_density_material): + """A knob ticked 'Fit' while coupled must not keep entering the fit once + its row goes inactive — the GUI overlay (kind: 'inactive') is display + only, `Parameter.free` is what the minimizer actually reads.""" + project, backend, index = backend_with_density_material + material = project._materials[index] + material.density.free = True + material.scattering_length_real.free = True + + backend.setMaterialSldCoupledAtIndex(index, False) + + assert material.density.free is False + assert material.scattering_length_real.free is False + assert material.scattering_length_imag.free is False + # None of the cleared knobs are independent+free, whatever model they + # end up wired into — the predicate `count_free_parameters` itself uses. + assert not any( + parameter.independent and parameter.free + for parameter in ( + material.density, + material.scattering_length_real, + material.scattering_length_imag, + ) + ) + + +def test_molecular_weight_is_a_descriptor_not_a_parameter(backend_with_density_material): + """mw is a constant of the formula: as a DescriptorNumber it never enters + project.parameters, so it cannot be freed into the fit (it is fully + degenerate with density) and needs no app-side gating.""" + project, backend, index = backend_with_density_material + material = project._materials[index] + + assert not hasattr(material.molecular_weight, 'free') + assert material.molecular_weight not in project.parameters + # The formula setter still refreshes it through the descriptor. + backend.setMaterialFormulaAtIndex(index, 'B') + assert material.molecular_weight.value == pytest.approx(10.81)