diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0d4ac4..25eab1bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,79 @@ +# Unreleased + +- Enabling magnetism no longer jumps to another page. Ticking a layer's + "Magn." box while the current engine cannot model magnetism now asks whether + to switch the project to refl1d and does both in one step. + The calculation engine has its own group on the Sample page. +- Selecting an engine that cannot model magnetism, while the sample already has + magnetic layers, is refused with a message on both pages. +- Fixed the calculation-engine selector showing the wrong engine after loading + a project that used a different one. +- Magnetic depth profiles on the SLD chart (Sample and Analysis share the + chart): + - Once a layer is magnetic, the chart adds the spin-up and spin-down + potentials ρ ± ρM·cos(θM − A) for each magnetic model, dashed in the + model's colour. ρM and the moment angle θM each have a checkbox; θM uses + its own right-hand axis. The y-range covers every visible curve and grows + when a curve is switched on, so ρ + ρM is not clipped. θM is only defined + where there is a moment, and that curve is drawn in pieces, not joined + across the gaps. + - The switches are in a new "Magnetic profile" group under Magnetism on the + Sample page, and in Analysis under "Plot control". Both use the same + selection. + - If no model is magnetic, the chart, legend and sidebar are unchanged. +- Spin-asymmetry view, SA(q) = (R↑↑ − R↓↓)/(R↑↑ + R↓↓): + - A "Spin asymmetry" tab on the Experiment page (measured data) and a third + tab on the Analysis page's lower panel (measured data plus the model). + Neither is shown unless the experiment measured both non-spin-flip + channels. Without one, Experiment has no tab strip. + - Error bars come from the channel uncertainties. Points where R↑↑ + R↓↓ is + not significantly above zero (the background-dominated tail) are dropped, + with a note of how many, so they do not wreck the axis. Same for points + where the two channels do not share the same q. The axis shows the full + [−1, 1] window and expands if background-subtracted data go outside it. +- Polarized (spin-channel) experiment import and display: + - New "Load polarized experiment (file per channel)" flow: multi-select one + file per spin channel, then review and edit the automatic assignment (from + the ORSO `polarization` header or the file name). Any number of channels + can be assigned, including a single channel. Files can be marked "not + used". Duplicate or unknown channels, missing files, or nothing assigned + are rejected with a message. + - The experiment chart draws one measured series (plus error bounds) per + visible spin channel in a fixed palette (↑↑ pp, ↑↓ pm, ↓↑ mp, ↓↓ mm), with + a channel selector in the sidebar and a per-channel legend. At least one + measured channel stays visible. With several experiments selected, each + polarized experiment contributes one series per visible channel, using the + experiment colour as the hue base. + - Experiment lists mark polarized experiments with a `⇅N` badge for the + number of measured spin channels. + - Report figures plot each channel's spin cross-section in its channel + colour. A channel that cannot be calculated (for example spin-flip on a + non-magnetic model) is shown as measured data only. + - **Limitations:** one resolution function per polarized experiment (taken + from the first assigned channel; the import dialog says so); Bayesian + sampling of polarized experiments is not supported yet. + - Project save/load now fully supports polarized experiments. +- Magnetism editing and polarized fitting: + - New "Magnetism" group on the Sample page: one row per layer of the current + assembly, with a magnetic on/off checkbox, magnetic SLD (ρM) and in-plane + moment angle (θM). Only refl1d can model magnetic layers. The group says + so and offers to switch the project's calculation engine when a layer is + made magnetic (see above). + - ρM and θM appear in the Analysis parameter table like other layer + parameters, named after their assembly and model (`Model Fe rho_m`), with + default limits, fit checkboxes and constraints. The name filter accepts + "magnetic" as a keyword. + - Fitting a polarized experiment fits all its measured spin channels at once + against the shared model. Thickness, roughness, nuclear SLD, scale and + background are common to every channel; ρM and θM are constrained by all + of them. Polarized and ordinary experiments can be fitted together. This + used to report "not supported yet". + - The analysis and residual charts draw one measured/calculated pair per + visible spin channel, each with that channel's cross-section. Previously + 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. + # Version 1.4.0 (3 Aug 2026) - Added Bayesian analysis: run MCMC sampling alongside classical fitting, with posterior median and credibility intervals on the main chart, trace/corner-style plots per parameter, a dedicated status display with cancellation support, and plot export. diff --git a/EasyReflectometryApp/Backends/Mock/Analysis.qml b/EasyReflectometryApp/Backends/Mock/Analysis.qml index fbf8ace1..d1e835ba 100644 --- a/EasyReflectometryApp/Backends/Mock/Analysis.qml +++ b/EasyReflectometryApp/Backends/Mock/Analysis.qml @@ -11,6 +11,9 @@ QtObject { readonly property var experimentsAvailable: ['experiment_1', 'experiment_2', 'experiment_3'] readonly property int experimentCurrentIndex: 2 + // Polarization badge / channel count columns of the experiment lists + readonly property var experimentsPolarized: [false, false, false] + readonly property var experimentsChannelCount: [0, 0, 0] // Minimizer readonly property double minimizerTolerance: 1.0 diff --git a/EasyReflectometryApp/Backends/Mock/Experiment.qml b/EasyReflectometryApp/Backends/Mock/Experiment.qml index 6bdbb428..6091823a 100644 --- a/EasyReflectometryApp/Backends/Mock/Experiment.qml +++ b/EasyReflectometryApp/Backends/Mock/Experiment.qml @@ -22,4 +22,32 @@ QtObject { function load(path) { console.debug(`Loading experiment from ${path}`) } + + // Filename-token suggestion, so the assignment dialog can be exercised + // against the mock backend as well. + function suggestPolarizedChannels(paths) { + console.debug(`Suggesting polarized channels for ${paths}`) + const list = Array.isArray(paths) ? paths : String(paths).split(',') + const tokens = {uu: 'pp', pp: 'pp', dd: 'mm', mm: 'mm', ud: 'pm', pm: 'pm', du: 'mp', mp: 'mp'} + return list.filter(path => path !== '').map(path => { + const name = String(path).split(/[\\/]/).pop() + let channel = '' + for (const token in tokens) { + if (name.toLowerCase().indexOf('_' + token) !== -1) { + channel = tokens[token] + break + } + } + return {path: path, name: name, channel: channel} + }) + } + + function loadPolarized(assignments) { + console.debug(`Loading polarized experiment from ${assignments.length} file(s)`) + } + + // Emitted with a user-facing message when an import is rejected. + signal loadFailed(string message) + // Emitted with the list position of a newly imported experiment. + signal experimentLoaded(int index) } diff --git a/EasyReflectometryApp/Backends/Mock/Plotting.qml b/EasyReflectometryApp/Backends/Mock/Plotting.qml index b50bc353..0c537ac0 100644 --- a/EasyReflectometryApp/Backends/Mock/Plotting.qml +++ b/EasyReflectometryApp/Backends/Mock/Plotting.qml @@ -28,6 +28,63 @@ QtObject { property int modelCount: 1 + // Polarized experiment (spin channel) support + property bool currentExperimentIsPolarized: false + property var experimentChannelList: [] + signal channelSelectionChanged() + signal experimentChannelsChanged() + function setChannelVisible(channel, visible) { + console.debug(`setChannelVisible ${channel} ${visible}`) + } + function getExperimentChannels(index) { + return [] + } + function getExperimentChannelDataPoints(index, channel) { + return [] + } + + // Magnetic depth profiles (no magnetic model in the mock) + property bool anyModelHasMagnetism: false + property var visibleSldCurves: ['spin_up', 'spin_down'] + property double sldThetaMinY: 0 + property double sldThetaMaxY: 360 + signal magneticProfileChanged() + function modelHasMagnetism(index) { + return false + } + function getMagneticSldDataPointsForModel(index, curve) { + return [] + } + function getMagneticSldSegmentsForModel(index, curve) { + return [] + } + function getMagneticSldSegment(index, curve, segment) { + return [] + } + function sldCurveVisible(curve) { + return visibleSldCurves.indexOf(curve) !== -1 + } + function setSldCurveVisible(curve, visible) { + console.debug(`setSldCurveVisible ${curve} ${visible}`) + } + + // Spin asymmetry (no polarized experiment in the mock) + property bool spinAsymmetryAvailable: false + property bool spinAsymmetryCalculatedAvailable: false + property int spinAsymmetryMaskedPoints: 0 + property int spinAsymmetryOutOfOverlapPoints: 0 + property double spinAsymmetryMinX: 0 + property double spinAsymmetryMaxX: 1 + property double spinAsymmetryMinY: -1 + property double spinAsymmetryMaxY: 1 + signal spinAsymmetryChanged() + function getSpinAsymmetryPoints(index) { + return [] + } + function getSpinAsymmetryCalculatedPoints(index) { + return [] + } + // Plot mode properties property bool plotRQ4: false property string yMainAxisTitle: 'R(q)' diff --git a/EasyReflectometryApp/Backends/Mock/Sample.qml b/EasyReflectometryApp/Backends/Mock/Sample.qml index 3314b0b2..cbe39e47 100644 --- a/EasyReflectometryApp/Backends/Mock/Sample.qml +++ b/EasyReflectometryApp/Backends/Mock/Sample.qml @@ -3,6 +3,20 @@ pragma Singleton import QtQuick QtObject { + + // Calculation engine (project-wide setting, also shown on the Sample page) + property var calculationEngines: ['refnx', 'refl1d'] + property int calculationEngineIndex: 0 + property var calculationEnginesSupportingMagnetism: ['refl1d'] + signal calculationEngineChanged() + signal magnetismNeedsEngine(int index, string engine) + function setCalculationEngineIndex(value) { + console.debug(`setCalculationEngineIndex ${value}`) + } + function enableMagnetismWithEngineAtIndex(index, engine) { + console.debug(`enableMagnetismWithEngineAtIndex ${index} ${engine}`) + } + // Signals to match the Python backend signal constraintsChanged // MATERIALS @@ -250,6 +264,23 @@ QtObject { console.debug(`setCurrentLayerSolvation ${value}`) } + // Layer magnetism (polarized analysis) + readonly property bool magnetismSupported: true + readonly property var layersMagnetism: [ + { 'label': 'label 1', 'magnetic': 'True', 'rho_m': '5.0', 'theta_m': '40.0' }, + { 'label': 'label 2', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0' }, + { 'label': 'label 3', 'magnetic': 'False', 'rho_m': '0.0', 'theta_m': '270.0' }, + ] + function setLayerMagneticAtIndex(index, value) { + console.debug(`setLayerMagneticAtIndex ${index} ${value}`) + } + function setLayerRhoMAtIndex(index, value) { + console.debug(`setLayerRhoMAtIndex ${index} ${value}`) + } + function setLayerThetaMAtIndex(index, value) { + console.debug(`setLayerThetaMAtIndex ${index} ${value}`) + } + // Table functions function removeLayer(value) { console.debug(`removeLayer ${value}`) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index 808a43ea..4ed9171e 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -1,7 +1,6 @@ import logging import os import time - from typing import List from typing import Optional @@ -14,7 +13,11 @@ from .logic.bayesian import Bayesian as BayesianLogic from .logic.calculators import Calculators as CalculatorsLogic +from .logic.experiments import CHANNEL_LABELS from .logic.experiments import Experiments as ExperimentLogic +from .logic.experiments import channel_shade +from .logic.experiments import experiment_channel_values +from .logic.experiments import flatten_polarized from .logic.fitting import Fitting as FittingLogic from .logic.helpers import get_original_name from .logic.minimizers import Minimizers as MinimizersLogic @@ -27,6 +30,8 @@ class Analysis(QObject): minimizerChanged = Signal() calculatorChanged = Signal() + # Emitted with the reason when a calculator cannot be selected. + calculatorChangeRejected = Signal(str) experimentsChanged = Signal() parametersChanged = Signal() parametersIndexChanged = Signal() @@ -650,11 +655,9 @@ def _compute_and_publish_posterior_predictive(self) -> None: """Compute posterior predictive reflectivity and SLD, publish to plotting.""" if self._plotting is None: return - from easyreflectometry.analysis.bayesian import ( - posterior_predictive_reflectivity, - posterior_predictive_sld_profile, - ) import numpy as np + from easyreflectometry.analysis.bayesian import posterior_predictive_reflectivity + from easyreflectometry.analysis.bayesian import posterior_predictive_sld_profile posterior = self._bayesian_logic.posterior if posterior is None: @@ -818,8 +821,8 @@ def sampleProgressTotalSteps(self) -> int: def _plot_file_path(self, stem: str, ext: str = 'png'): """Return a stable temporary file path for a rendered Bayesian plot.""" - from pathlib import Path import tempfile + from pathlib import Path out_dir = Path(tempfile.gettempdir()) / 'EasyReflectometryApp' / 'bayesian' out_dir.mkdir(parents=True, exist_ok=True) @@ -908,9 +911,8 @@ def _render_trace_plot(self) -> None: self._bayesian_logic.trace_plot_url = '' return try: - from easyreflectometry.analysis.bayesian import plot_trace - import numpy as np + from easyreflectometry.analysis.bayesian import plot_trace draws = np.asarray(posterior['draws']) if draws.ndim == 2: draws = draws[np.newaxis, ...] # (1, n_draws, n_params) @@ -1028,7 +1030,16 @@ def calculatorCurrentIndex(self) -> int: @Slot(int) def setCalculatorCurrentIndex(self, new_value: int) -> None: - if self._calculators_logic.set_current_index(new_value): + try: + changed = self._calculators_logic.set_current_index(new_value) + except NotImplementedError as exception: + # A calculator that cannot model the sample's magnetism would raise + # deep inside the library; report it and keep the current engine. + logger.warning('Cannot change the calculator: %s', exception) + self.calculatorChangeRejected.emit(str(exception)) + self.calculatorChanged.emit() + return + if changed: self.calculatorChanged.emit() self.externalCalculatorChanged.emit() @@ -1038,6 +1049,16 @@ def setCalculatorCurrentIndex(self, new_value: int) -> None: def experimentsAvailable(self) -> List[str]: return self._experiments_logic.available() + @Property('QVariantList', notify=experimentsChanged) + def experimentsPolarized(self) -> List[bool]: + """Per-experiment flag: True for polarized (per-channel) experiments.""" + return self._experiments_logic.polarized_flags() + + @Property('QVariantList', notify=experimentsChanged) + def experimentsChannelCount(self) -> List[int]: + """Per-experiment number of measured spin channels (0 when unpolarized).""" + return self._experiments_logic.channel_counts() + @Property(int, notify=experimentsChanged) def experimentCurrentIndex(self) -> int: return self._experiments_logic.current_index() @@ -1123,6 +1144,15 @@ def selectedExperimentIndices(self) -> List[int]: """Return the list of selected experiment indices.""" return self._selected_experiment_indices + @Slot(int) + def selectExperimentAtIndex(self, index: int) -> None: + """Make one experiment the current and only selected one. + + Used after an import so the charts show the experiment that was just + loaded instead of staying on the previously selected one. + """ + self.setSelectedExperimentIndices([index]) + @Slot('QVariantList') def setSelectedExperimentIndices(self, indices: List[int]) -> None: """Set multiple selected experiment indices.""" @@ -1160,10 +1190,14 @@ def get_concatenated_experiment_data(self): return DataSet1D(name='No experiments selected', x=np.empty(0), y=np.empty(0), ye=np.empty(0), xe=np.empty(0)) all_x, all_y, all_ye, all_xe = [], [], [], [] + visible_channels = self._visible_channels() for exp_idx in self._selected_experiment_indices: try: - data = self._experiments_logic._project_lib.experimental_data_for_model_at_index(exp_idx) + data = flatten_polarized( + self._experiments_logic._project_lib.experimental_data_for_model_at_index(exp_idx), + visible_channels, + ) if data.x.size > 0: # Only include non-empty datasets all_x.extend(data.x) all_y.extend(data.y) @@ -1193,10 +1227,17 @@ def get_concatenated_experiment_data(self): name=combined_name, x=np.array(x_sorted), y=np.array(y_sorted), ye=np.array(ye_sorted), xe=np.array(xe_sorted) ) - def get_individual_experiment_data_list(self): + def get_individual_experiment_data_list(self, expand_channels: bool = False): """ Get individual experiment data for each selected experiment. Returns a list of dictionaries with data, name, and color for each experiment. + + With `expand_channels`, a polarized experiment contributes one entry per + visible measured channel (each carrying its `channel` and a channel + shade of the experiment color) instead of being flattened to a single + one — used by the experiment chart, which draws per-channel series. + Consumers that are not channel aware yet (analysis, residuals) keep the + flat one-entry-per-experiment list. """ if not self._selected_experiment_indices: @@ -1218,24 +1259,57 @@ def get_individual_experiment_data_list(self): '#7BB8B8', # Soft Cyan ] + visible_channels = self._visible_channels() + for idx, exp_idx in enumerate(self._selected_experiment_indices): try: - data = self._experiments_logic._project_lib.experimental_data_for_model_at_index(exp_idx) - if data.x.size > 0: # Only include non-empty datasets - exp_name = ( - self._experiments_logic.available()[exp_idx] - if exp_idx < len(self._experiments_logic.available()) - else f'Experiment {exp_idx + 1}' - ) - color = color_palette[exp_idx % len(color_palette)] + experiment = self._experiments_logic._project_lib.experimental_data_for_model_at_index(exp_idx) + exp_name = ( + self._experiments_logic.available()[exp_idx] + if exp_idx < len(self._experiments_logic.available()) + else f'Experiment {exp_idx + 1}' + ) + color = color_palette[exp_idx % len(color_palette)] + + # A polarized experiment contributes one entry per visible + # measured channel, so nothing the user selected is dropped. + channels = ( + [channel for channel in experiment_channel_values(experiment) if channel in visible_channels] + if expand_channels + else [] + ) + if not channels: + data = flatten_polarized(experiment, visible_channels) + if data.x.size > 0: # Only include non-empty datasets + experiment_data_list.append( + {'data': data, 'name': exp_name, 'color': color, 'index': exp_idx, 'channel': ''} + ) + continue - experiment_data_list.append({'data': data, 'name': exp_name, 'color': color, 'index': exp_idx}) - except (IndexError, AttributeError) as e: + for channel in channels: + data = experiment[channel] + if data.x.size == 0: + continue + experiment_data_list.append( + { + 'data': data, + 'name': f'{exp_name} ({CHANNEL_LABELS[channel]} {channel})', + 'color': channel_shade(color, channel), + 'index': exp_idx, + 'channel': channel, + } + ) + except (IndexError, AttributeError, KeyError) as e: logger.warning('Error accessing experiment %s: %s', exp_idx, e) continue return experiment_data_list + def _visible_channels(self) -> set: + """Channels the user kept visible (all of them when plotting is unavailable).""" + visible = getattr(self._plotting, '_visible_channels', None) + return set(visible) if visible else set(CHANNEL_LABELS) + @Property('QVariantList', notify=experimentsChanged) def selectedExperimentDataList(self) -> List[dict]: """Return individual experiment data for plotting separate lines.""" @@ -1438,6 +1512,6 @@ def saveBayesianPlot(self, source_url: str) -> bool: shutil.copy2(str(source_path), save_path) logger.info('Bayesian plot saved to %s', save_path) return True - except OSError as exc: + except OSError: logger.exception('Failed to save Bayesian plot to %s', save_path) return False \ No newline at end of file diff --git a/EasyReflectometryApp/Backends/Py/experiment.py b/EasyReflectometryApp/Backends/Py/experiment.py index 7977ffcf..0e74b932 100644 --- a/EasyReflectometryApp/Backends/Py/experiment.py +++ b/EasyReflectometryApp/Backends/Py/experiment.py @@ -1,18 +1,37 @@ +import os + +from EasyApplication.Logic.Logging import console from easyreflectometry import Project as ProjectLib from PySide6.QtCore import Property from PySide6.QtCore import QObject from PySide6.QtCore import Signal from PySide6.QtCore import Slot +from PySide6.QtQml import QJSValue from .helpers import IO from .logic.models import Models as ModelsLogic from .logic.project import Project as ProjectLogic +def _from_qml(value): + """Unwrap a QJSValue handed over by QML into plain Python data.""" + if isinstance(value, QJSValue): + return value.toVariant() + return value + + +# Spin channels accepted by the polarized import, in canonical order. +_CHANNELS = ('pp', 'pm', 'mp', 'mm') + + class Experiment(QObject): experimentChanged = Signal() externalExperimentChanged = Signal() qRangeUpdated = Signal() + # Emitted with a user-facing message when an import is rejected. + loadFailed = Signal(str) + # Emitted with the list position of a newly imported experiment. + experimentLoaded = Signal(int) def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) @@ -79,3 +98,91 @@ def load(self, paths: str) -> None: self.externalExperimentChanged.emit() if q_range_changed: self.qRangeUpdated.emit() + + @Slot('QVariant', result='QVariantList') + def suggestPolarizedChannels(self, paths) -> list: + """Suggested spin-channel assignment for the selected files. + + Returns one ``{'path': ..., 'name': ..., 'channel': ...}`` row per file + (``channel`` is '' when undetected) for the assignment dialog to edit. + """ + paths = _from_qml(paths) + if isinstance(paths, str): + paths = paths.split(',') + generalized = [IO.generalizePath(path) for path in paths] + suggestion = self._project_logic.suggest_polarized_channel_assignment(generalized) + return [ + {'path': path, 'name': os.path.basename(path), 'channel': channel} + for path, channel in suggestion.items() + ] + + @Slot('QVariant') + def loadPolarized(self, assignments) -> None: + """Load one polarized experiment from dialog rows ``[{'path','channel'},...]``. + + Rows with an empty channel are excluded ('not used' in the dialog). The + assignment must be unambiguous: every remaining row needs an existing + file and a known channel ('pp', 'pm', 'mp', 'mm'), and no channel may + appear twice. Invalid input is rejected with a message rather than + silently dropped — the dialog is not a trust boundary, and this slot is + also called directly by tests and automation. + + Raises + ------ + ValueError + The assignment is malformed, incomplete or ambiguous. + """ + assignments = _from_qml(assignments) + channel_to_path = self._validated_channel_assignment(assignments) + new_index, q_range_changed = self._project_logic.load_polarized_experiment(channel_to_path) + self.experimentChanged.emit() + self.externalExperimentChanged.emit() + if q_range_changed: + self.qRangeUpdated.emit() + if new_index >= 0: + # Show what was just imported: without this the new experiment is + # only added to the list while the chart stays on the previous one. + self.experimentLoaded.emit(new_index) + + def _validated_channel_assignment(self, assignments) -> dict: + """Turn dialog rows into a validated ``{channel: path}`` mapping.""" + if not isinstance(assignments, (list, tuple)): + self._reject_assignment(f'Expected a list of channel assignments, got {type(assignments).__name__}.') + + channel_to_path: dict = {} + for row in assignments: + row = _from_qml(row) + if not isinstance(row, dict) or 'channel' not in row or 'path' not in row: + self._reject_assignment(f"Malformed channel assignment row: {row!r} (expected 'path' and 'channel').") + channel = (row['channel'] or '').strip() + if not channel: + # 'not used': the file is deliberately left out. + continue + if channel not in _CHANNELS: + self._reject_assignment( + f"Unknown spin channel '{channel}'; expected one of {', '.join(_CHANNELS)}." + ) + if channel in channel_to_path: + self._reject_assignment( + f"Channel '{channel}' is assigned to more than one file; each channel needs exactly one file." + ) + # Rows from `suggestPolarizedChannels` are already platform paths; + # only a file:// URL (direct automation call) needs converting — + # `generalizePath` is not idempotent on Windows (it would eat the + # drive letter of an already-converted path). + path = row['path'] or '' + if path.startswith('file:'): + path = IO.generalizePath(path) + if not path or not os.path.isfile(path): + self._reject_assignment(f"No such file for channel '{channel}': {row['path']!r}.") + channel_to_path[channel] = path + + if not channel_to_path: + self._reject_assignment('Assign at least one file to a spin channel.') + return channel_to_path + + def _reject_assignment(self, message: str) -> None: + """Report an invalid channel assignment and abort the load.""" + console.error(f'Polarized import rejected: {message}') + self.loadFailed.emit(message) + raise ValueError(message) diff --git a/EasyReflectometryApp/Backends/Py/logic/calculators.py b/EasyReflectometryApp/Backends/Py/logic/calculators.py index fd734133..5ca50f7c 100644 --- a/EasyReflectometryApp/Backends/Py/logic/calculators.py +++ b/EasyReflectometryApp/Backends/Py/logic/calculators.py @@ -5,18 +5,56 @@ class Calculators: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib self._list_available_calculators = self._project_lib._calculator.available_interfaces - self._current_index = 0 def available(self) -> list[str]: return self._list_available_calculators def current_index(self) -> int: - return self._current_index + """Position of the project's active calculator in the list. + + Derived rather than cached: the engine also changes when a project is + loaded, or when enabling magnetism switches it from the Sample page, and + a cached index would then show the wrong engine. + """ + return self.index_of(self._project_lib.calculator) + + def index_of(self, name: str) -> int: + """Position of a calculator in the list (-1 when it is not available). + + 0 is a real engine (typically refnx), so it must not stand in for + "not found" — callers rely on a negative result to detect a missing + engine (see `enableMagnetismWithEngineAtIndex`). + """ + if name in self._list_available_calculators: + return self._list_available_calculators.index(name) + return -1 + + def supporting_magnetism(self) -> list[str]: + """Available calculators that can model magnetic layers.""" + return list(self._project_lib.calculators_supporting_magnetism) def set_current_index(self, new_value: int) -> bool: - if new_value != self._current_index: - self._current_index = new_value - new_calculator = self._list_available_calculators[new_value] - self._project_lib.calculator = new_calculator - return True - return False + if not 0 <= new_value < len(self._list_available_calculators): + return False + new_calculator = self._list_available_calculators[new_value] + if new_calculator == self._project_lib.calculator: + return False + self._reject_if_magnetism_would_break(new_calculator) + self._project_lib.calculator = new_calculator + return True + + def _reject_if_magnetism_would_break(self, new_calculator: str) -> None: + """Refuse a calculator that cannot carry the sample's magnetism. + + Binding a magnetic layer to such a calculator raises deep inside the + library, and doing that from a QML-invoked slot takes the application + down instead of reporting anything. + """ + if not self._project_lib.models_have_magnetism: + return + if new_calculator in self._project_lib.calculators_supporting_magnetism: + return + raise NotImplementedError( + f'The {new_calculator} calculation engine cannot model magnetic layers, and this sample has ' + 'some. Remove the magnetism from the sample first (Sample page, Magnetism group).' + ) diff --git a/EasyReflectometryApp/Backends/Py/logic/experiments.py b/EasyReflectometryApp/Backends/Py/logic/experiments.py index 6d7e304f..149ecc90 100644 --- a/EasyReflectometryApp/Backends/Py/logic/experiments.py +++ b/EasyReflectometryApp/Backends/Py/logic/experiments.py @@ -1,9 +1,62 @@ +import colorsys import logging from easyreflectometry import Project as ProjectLib logger = logging.getLogger(__name__) +# Fixed per-channel colors for polarized experiments (pp, pm, mp, mm), matching +# the channel order used across the app and the report. +CHANNEL_COLORS = {'pp': '#0173B2', 'pm': '#029E73', 'mp': '#CC78BC', 'mm': '#DE8F05'} +CHANNEL_LABELS = {'pp': '↑↑', 'pm': '↑↓', 'mp': '↓↑', 'mm': '↓↓'} + +# When several experiments share a chart, the experiment color carries the hue +# and the channel is distinguished by lightness — so a channel is still +# recognisable without two experiments ending up with the same color. +_CHANNEL_LIGHTNESS_SHIFT = {'pp': -0.12, 'pm': 0.0, 'mp': 0.12, 'mm': 0.24} + + +def channel_shade(base_color: str, channel: str) -> str: + """A per-channel variant of an experiment color (same hue, shifted lightness).""" + shift = _CHANNEL_LIGHTNESS_SHIFT.get(channel) + color = base_color.lstrip('#') + if shift is None or len(color) != 6: + return base_color + try: + red, green, blue = (int(color[i : i + 2], 16) / 255 for i in (0, 2, 4)) + except ValueError: + return base_color + hue, lightness, saturation = colorsys.rgb_to_hls(red, green, blue) + lightness = min(0.88, max(0.18, lightness + shift)) + red, green, blue = colorsys.hls_to_rgb(hue, lightness, saturation) + return '#{:02X}{:02X}{:02X}'.format(round(red * 255), round(green * 255), round(blue * 255)) + + +def flatten_polarized(experiment, visible_channels=None): + """A flat ``DataSet1D`` for consumers that expect one x/y/ye series. + + Unpolarized experiments are returned unchanged. For a `PolarizedDataSet` + the first measured channel is returned — restricted to `visible_channels` + (channel-value strings) when given and matching. Fully per-channel display + goes through the dedicated channel-aware code paths instead. + """ + channels = getattr(experiment, 'available_channels', None) + if channels is None: + return experiment + if visible_channels: + for channel in channels: + if channel.value in visible_channels: + return experiment[channel] + return experiment[channels[0]] + + +def experiment_channel_values(experiment) -> list[str]: + """Measured channel-value strings of an experiment ([] when unpolarized).""" + channels = getattr(experiment, 'available_channels', None) + if channels is None: + return [] + return [channel.value for channel in channels] + class Experiments: def __init__(self, project_lib: ProjectLib): @@ -49,6 +102,16 @@ def available(self) -> list[str]: pass return experiments_name + def polarized_flags(self) -> list[bool]: + """Per-experiment flag: True when the experiment carries per-channel (polarized) data.""" + return [ + getattr(exp, 'available_channels', None) is not None for _, exp in self._ordered_experiment_items() + ] + + def channel_counts(self) -> list[int]: + """Per-experiment number of measured spin channels (0 when unpolarized).""" + return [len(experiment_channel_values(exp)) for _, exp in self._ordered_experiment_items()] + def current_index(self) -> int: return self._project_lib._current_experiment_index diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index 1e26edf4..50ba9692 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -226,6 +226,16 @@ def _ordered_experiments(self) -> list: return list(experiments) + _POLARIZED_SAMPLE_MESSAGE = ( + 'Bayesian sampling of polarized experiments is not supported yet.' + ) + + def _has_polarized_experiments(self) -> bool: + """Whether any loaded experiment carries per-channel (polarized) data.""" + return any( + getattr(experiment, 'available_channels', None) is not None for experiment in self._ordered_experiments() + ) + def prepare_threaded_fit(self, minimizers_logic: 'Minimizers') -> tuple: """Prepare data for threaded fitting. @@ -243,9 +253,11 @@ def prepare_threaded_fit(self, minimizers_logic: 'Minimizers') -> tuple: self._show_results_dialog = True return None, None, None, None, None - # Create MultiFitter with all models - models = [experiment.model for experiment in experiments] - multi_fitter = MultiFitter(*models) + # 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 + # constrained by every channel at once. + multi_fitter = MultiFitter.for_experiments(experiments) # Apply the user-selected minimizer to the new fitter selected_minimizer = minimizers_logic.selected_minimizer_enum() @@ -268,16 +280,18 @@ def prepare_threaded_fit(self, minimizers_logic: 'Minimizers') -> tuple: x_data = [] y_data = [] weights = [] - for idx, experiment in enumerate(experiments): - x_vals = np.asarray(experiment.x) - y_vals = np.asarray(experiment.y) - ye_vals = np.asarray(experiment.ye) + # `fit_datasets` is the simple, per-channel dataset list matching the + # fit functions; for unpolarized data it is just the experiments. + for idx, dataset in enumerate(multi_fitter.fit_datasets): + x_vals = np.asarray(dataset.x) + y_vals = np.asarray(dataset.y) + ye_vals = np.asarray(dataset.ye) # Mask out points with zero variance (same as MultiFitter.fit in EasyReflectometryLib) valid = ye_vals > 0 num_masked = int(np.sum(~valid)) if num_masked > 0: - exp_name = experiment.name if hasattr(experiment, 'name') else f'index {idx}' + exp_name = dataset.name if hasattr(dataset, 'name') else f'index {idx}' logger.warning( 'Masked %d data point(s) in experiment %s due to zero variance.', num_masked, @@ -321,6 +335,9 @@ def collect_all_experiments_datagroup(self) -> 'sc.DataGroup': import numpy as np import scipp as sc + if self._has_polarized_experiments(): + raise ValueError(self._POLARIZED_SAMPLE_MESSAGE) + experiments = self._ordered_experiments() coords = {} data = {} @@ -502,7 +519,13 @@ def start_stop(self) -> None: try: # This needs extension to support multiple data sets exp_data = self._project_lib.experimental_data_for_model_at_index(0) - self._result = self._project_lib.fitter.fit_single_data_set_1d(exp_data) + if getattr(exp_data, 'available_channels', None) is not None: + # All measured spin channels against the one shared model. + channel_results = self._project_lib.fitter.fit_polarized(exp_data) + self._results = list(channel_results.values()) + self._result = self._results[0] if self._results else None + else: + self._result = self._project_lib.fitter.fit_single_data_set_1d(exp_data) except FitError as e: # Handle fit failure - create a failed result self._result = None diff --git a/EasyReflectometryApp/Backends/Py/logic/layers.py b/EasyReflectometryApp/Backends/Py/logic/layers.py index 215b023c..3a6f55da 100644 --- a/EasyReflectometryApp/Backends/Py/logic/layers.py +++ b/EasyReflectometryApp/Backends/Py/logic/layers.py @@ -1,10 +1,22 @@ +import logging +from typing import Optional from typing import Union from easyreflectometry import Project as ProjectLib from easyreflectometry.sample import LayerAreaPerMolecule from easyreflectometry.sample import LayerCollection +from easyreflectometry.sample import LayerMagnetism from easyreflectometry.sample import Material from easyreflectometry.sample import Sample +from easyreflectometry.sample.elements.layers.layer_magnetism import DEFAULTS as MAGNETISM_DEFAULTS + +logger = logging.getLogger(__name__) + +# Shown in the magnetism fields of a layer that is not magnetic (yet): the +# values attaching magnetism would start from. Taken from the library so the +# preview cannot drift away from what `LayerMagnetism()` actually creates. +_DEFAULT_RHO_M = MAGNETISM_DEFAULTS['rho_m']['value'] +_DEFAULT_THETA_M = MAGNETISM_DEFAULTS['theta_m']['value'] class Layers: @@ -197,6 +209,109 @@ def set_formula_at_index(self, index: int, new_value: str) -> bool: return True return False + # # # + # Magnetism + # # # + + @property + def magnetism_supported(self) -> bool: + """Whether the active calculator can model magnetic layers (refl1d only).""" + return bool(self._project_lib.calculator_supports_magnetism) + + @property + def magnetism(self) -> list[dict[str, str]]: + """One row per layer of the current assembly, describing its magnetism. + + ``magnetic`` is 'True'/'False'; ``rho_m``/``theta_m`` carry the defaults + of a fresh :class:`LayerMagnetism` for non-magnetic layers so the fields + show what attaching magnetism would start from. + """ + rows = [] + for layer in self._layers: + magnetism = getattr(layer, 'magnetism', None) + rows.append( + { + 'label': layer.name, + 'magnetic': str(magnetism is not None), + 'rho_m': str(magnetism.rho_m.value if magnetism is not None else _DEFAULT_RHO_M), + 'theta_m': str(magnetism.theta_m.value if magnetism is not None else _DEFAULT_THETA_M), + } + ) + return rows + + def set_magnetic_at_index(self, index: int, new_value: bool) -> bool: + """Attach or remove :class:`LayerMagnetism` on one layer. + + Attaching turns calculator magnetism on; removing the last magnetic + layer turns it off again (both handled by the library). + """ + if not self._has_valid_layer_index(index): + return False + layer = self._layers[index] + is_magnetic = getattr(layer, 'magnetism', None) is not None + if bool(new_value) == is_magnetic: + return False + if new_value and not self.magnetism_supported: + engines = ', '.join(self._project_lib.calculators_supporting_magnetism) or 'none of the available engines' + raise NotImplementedError( + f'The {self._project_lib.calculator} calculation engine cannot model magnetic layers; ' + f'magnetism needs {engines}.' + ) + layer.magnetism = LayerMagnetism() if new_value else None + if new_value: + self._ensure_calculator_magnetism() + # Bring the fresh parameters under the project's limit/enablement + # policy, exactly as a newly created layer would be. + self._project_lib._sync_parameter_states() + return True + + def _ensure_calculator_magnetism(self) -> None: + """Make sure attaching magnetism actually reached the calculator. + + `Layer.magnetism` can only switch magnetism on through its own + interface, which is None for a layer that was never bound (a sample + assigned wholesale rather than edited in place). The calculator would + then still be unpolarized and every spin channel would fail to + calculate, so re-propagate the model's interface over the sample tree. + """ + model = self._project_lib._models[self._project_lib.current_model_index] + interface = getattr(model, 'interface', None) + if interface is None or interface().include_magnetism: + return + logger.debug('Re-propagating the calculator interface to bind new magnetism') + model.interface = interface + + def set_rho_m_at_index(self, index: int, new_value: float) -> bool: + return self._set_magnetism_value_at_index(index, 'rho_m', new_value) + + def set_theta_m_at_index(self, index: int, new_value: float) -> bool: + return self._set_magnetism_value_at_index(index, 'theta_m', new_value) + + def _set_magnetism_value_at_index(self, index: int, attribute: str, new_value: float) -> bool: + """Set one magnetic parameter, ignoring edits to a non-magnetic layer.""" + magnetism = self.magnetism_at_index(index) + if magnetism is None: + return False + try: + value = float(new_value) + except (TypeError, ValueError): + return False + parameter = getattr(magnetism, attribute) + if parameter.value == value: + return False + try: + parameter.value = value + except (ValueError, TypeError): + logger.exception('Failed to set %s to %s', attribute, value) + return False + return True + + def magnetism_at_index(self, index: int) -> Optional[LayerMagnetism]: + """The layer's magnetism, or None when the layer is not magnetic/valid.""" + if not self._has_valid_layer_index(index): + return None + return getattr(self._layers[index], 'magnetism', None) + def _from_layers_collection_to_list_of_dicts( layers_collection: LayerCollection, assembly_type: str = 'regular' diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index 8a563757..fbd7f403 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -88,6 +88,10 @@ def _parameter_matches_filters(self, parameter: dict[str, Any]) -> bool: return not _is_experiment_parameter(parameter) if normalized == 'experiment': return _is_experiment_parameter(parameter) + if normalized in {'magnetic', 'magnetism'}: + # The magnetic parameters are named after the physics (rho_m/theta_m), + # so neither word appears in their text; match them explicitly. + return 'rho_m' in searchable_text or 'theta_m' in searchable_text if normalized in {'cell', 'atom_site'}: return normalized in searchable_text if normalized == 'b_iso': @@ -264,6 +268,9 @@ def _from_parameters_to_list_of_dicts(parameters: List[Parameter], models) -> li # Layer parameter names that need model prefix LAYER_PARAMS = {'thickness', 'roughness'} + # Magnetism sits one level below the layer (Layer -> LayerMagnetism -> param), + # but belongs to the layer just as much, so it is named the same way. + MAGNETISM_PARAMS = {'rho_m', 'theta_m'} def _make_alias(name: str) -> str: base = re.sub(r'[^0-9A-Za-z]+', '_', name).strip('_').lower() @@ -293,6 +300,11 @@ def _get_parameter_display_data(param: Parameter, path: list) -> Tuple[str, str] # Use the assembly name (path[-4]) instead of the layer name (path[-2]) if _is_layer_parameter(param) and len(path) >= 4: parent_name = path[-4].name + elif _is_magnetism_parameter(param) and len(path) >= 5: + # ... -> Assembly -> LayerCollection -> Layer -> LayerMagnetism -> param: + # one level deeper, so the assembly is path[-5]. Without this the + # group would read 'EasyLayerMagnetism', which names nothing. + parent_name = path[-5].name else: parent_name = path[-2].name return f'{parent_name} {param_name}', parent_name @@ -319,6 +331,14 @@ def _is_layer_parameter(param: Parameter) -> bool: """Check if parameter is a layer parameter (thickness or roughness).""" return param.name.lower() in LAYER_PARAMS + def _is_magnetism_parameter(param: Parameter) -> bool: + """Check if parameter is a layer magnetism parameter (rho_m or theta_m).""" + return param.name.lower() in MAGNETISM_PARAMS + + def _is_per_layer_parameter(param: Parameter) -> bool: + """Per-layer parameters exist once per model and carry the model prefix.""" + return _is_layer_parameter(param) or _is_magnetism_parameter(param) + parameter_list = [] # Process parameters for each model @@ -335,7 +355,7 @@ def _is_layer_parameter(param: Parameter) -> bool: continue # For non-layer parameters, skip if already processed (they're shared across models) - is_layer_param = _is_layer_parameter(parameter) + is_layer_param = _is_per_layer_parameter(parameter) if not is_layer_param: if parameter.unique_name in processed_unique_names: continue diff --git a/EasyReflectometryApp/Backends/Py/logic/project.py b/EasyReflectometryApp/Backends/Py/logic/project.py index 329a03a3..32b6a883 100644 --- a/EasyReflectometryApp/Backends/Py/logic/project.py +++ b/EasyReflectometryApp/Backends/Py/logic/project.py @@ -144,6 +144,33 @@ def load_all_experiments_from_file(self, path: str) -> tuple[int, bool]: q_max_changed = self._sync_q_max_with_loaded_experiments() return loaded_count, q_max_changed + def suggest_polarized_channel_assignment(self, paths: list[str]) -> dict[str, str]: + """Suggested spin channel per file, as channel-value strings ('' when undetected).""" + suggestion = self._project_lib.suggest_polarized_channel_assignment(paths) + return {path: (channel.value if channel is not None else '') for path, channel in suggestion.items()} + + def load_polarized_experiment(self, channel_to_path: dict[str, str]) -> tuple[int, bool]: + """Load one polarized experiment from a channel → file mapping. + + :return: (list position of the new experiment, whether q_max changed). + """ + key = self._project_lib.load_polarized_experiment(channel_to_path) + q_max_changed = self._sync_q_max_with_loaded_experiments() + return self._position_of_experiment(key), q_max_changed + + def _position_of_experiment(self, key) -> int: + """Position of an experiment key in the ordered experiment list (-1 if unknown). + + The UI addresses experiments by list position, which only matches the + storage key while the keys are contiguous. + """ + experiments = self._project_lib._experiments + try: + keys = sorted(experiments.keys()) if hasattr(experiments, 'keys') else list(range(len(experiments))) + return keys.index(key) + except (AttributeError, TypeError, ValueError): + return -1 + def _sync_q_max_with_loaded_experiments(self) -> bool: """Set model q_max to the largest q value found in loaded experiments. @@ -160,7 +187,15 @@ def _sync_q_max_with_loaded_experiments(self) -> bool: experiment_iterable = experiments q_max_candidates = [] + datasets = [] for experiment in experiment_iterable: + channels = getattr(experiment, 'channels', None) + if channels is not None: + # A polarized experiment: one dataset per spin channel. + datasets.extend(channels.values()) + else: + datasets.append(experiment) + for experiment in datasets: x_values = getattr(experiment, 'x', None) if x_values is None: continue diff --git a/EasyReflectometryApp/Backends/Py/logic/summary.py b/EasyReflectometryApp/Backends/Py/logic/summary.py index 617377ad..c55fbae8 100644 --- a/EasyReflectometryApp/Backends/Py/logic/summary.py +++ b/EasyReflectometryApp/Backends/Py/logic/summary.py @@ -6,6 +6,10 @@ from easyreflectometry import Project as ProjectLib from easyreflectometry.summary import Summary as SummaryLib +from .experiments import CHANNEL_COLORS + +logger = logging.getLogger(__name__) + class Summary: def __init__(self, project_lib: ProjectLib): @@ -94,6 +98,23 @@ def _gridspec(self): return gridspec + @staticmethod + def _calculated_curve(model, x, channel=None): + """Calculated reflectivity for one model, per spin channel when given. + + Returns None when the channel cannot be calculated (a spin-flip channel + of a non-magnetic model, or a calculator without magnetism support): no + overlay is better than the wrong one. + """ + calculator = model.interface() + if channel is None: + return np.asarray(calculator.reflectity_profile(x, model.unique_name)) + try: + return np.asarray(calculator.reflectivity_profile_channel(x, model.unique_name, channel)) + except (ValueError, NotImplementedError, AttributeError) as exception: + logger.warning('No calculated curve for channel %s: %s', channel.value, exception) + return None + def make_plot(self, width_cm: float, height_cm: float): plt = self._plt() gridspec = self._gridspec() @@ -112,33 +133,65 @@ def make_plot(self, width_cm: float, height_cm: float): experiments = self._ordered_experiments() if experiments: for offset, (experiment_index, experiment) in enumerate(experiments): - x = np.asarray(experiment.x) - y = np.asarray(experiment.y) - if x.size == 0 or y.size == 0: - continue - - ye = np.asarray(experiment.ye) if getattr(experiment, 'ye', None) is not None else None - model = experiment.model - model.interface = self._project_lib._calculator - y_calc = np.asarray(model.interface().reflectity_profile(x, model.unique_name)) - scale_factor = 10**offset - - color = getattr(model, 'color', None) or '#1f77b4' - if ye is not None and ye.size == y.size: - ax_reflectivity.errorbar( - x, - y * scale_factor, - ye * scale_factor, - marker='', - ls='', - color=color, - alpha=0.45, - ) + experiment_name = experiment.name or f'Experiment {experiment_index + 1}' + channels = getattr(experiment, 'available_channels', None) + if channels is None: + datasets = [(experiment_name, experiment, None)] else: - ax_reflectivity.plot(x, y * scale_factor, ls='', marker='.', color=color, alpha=0.45) - - label_name = experiment.name or f'Experiment {experiment_index + 1}' - ax_reflectivity.plot(x, y_calc * scale_factor, ls='-', color=color, zorder=10, label=label_name) + # Polarized experiment: one series per measured spin channel. + datasets = [ + (f'{experiment_name} ({channel.value})', experiment[channel], channel) for channel in channels + ] + for label_name, dataset, channel in datasets: + x = np.asarray(dataset.x) + y = np.asarray(dataset.y) + if x.size == 0 or y.size == 0: + continue + + ye = np.asarray(dataset.ye) if getattr(dataset, 'ye', None) is not None else None + model = experiment.model + model.interface = self._project_lib._calculator + # Each channel needs its own spin cross-section; the + # channel-agnostic call would repeat one curve under four + # channel labels. None means "cannot be calculated" (e.g. a + # spin-flip channel of a non-magnetic model) — then the + # measured data is shown without a calculated overlay. + y_calc = self._calculated_curve(model, x, channel) + scale_factor = 10**offset + + color = CHANNEL_COLORS[channel.value] if channel is not None else ( + getattr(model, 'color', None) or '#1f77b4' + ) + # Without a calculated curve the measured series carries the + # legend entry, so the channel is still identifiable. + measured_label = label_name if y_calc is None else None + if ye is not None and ye.size == y.size: + # ye holds variances (sigma**2); errorbar() needs one + # standard deviation, same convention as the fitter + # weights and the analysis-chart residuals. + sigma = np.sqrt(np.clip(ye, 0.0, None)) + ax_reflectivity.errorbar( + x, + y * scale_factor, + sigma * scale_factor, + marker='', + ls='', + color=color, + alpha=0.45, + ) + if measured_label is not None: + ax_reflectivity.plot( + x, y * scale_factor, ls='', marker='.', color=color, alpha=0.45, label=measured_label + ) + else: + ax_reflectivity.plot( + x, y * scale_factor, ls='', marker='.', color=color, alpha=0.45, label=measured_label + ) + + if y_calc is not None: + ax_reflectivity.plot( + x, y_calc * scale_factor, ls='-', color=color, zorder=10, label=label_name + ) else: for model_index, model in enumerate(self._project_lib.models): sample_data = self._project_lib.sample_data_for_model_at_index(model_index) diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 738accc4..3f7347be 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -1,4 +1,11 @@ +import inspect + import numpy as np + +# Registers the QtCharts wrapper types with shiboken: without this, a series +# passed in from QML arrives as a bare QObject (no append/replaceNp methods) +# and every one-call series fill silently falls back to the slow path. +import PySide6.QtCharts # noqa: F401 from EasyApplication.Logic.Logging import console from easyreflectometry import Project as ProjectLib from easyreflectometry.data import DataSet1D @@ -8,6 +15,10 @@ from PySide6.QtCore import Slot from .helpers import IO +from .logic.experiments import CHANNEL_COLORS +from .logic.experiments import CHANNEL_LABELS +from .logic.experiments import experiment_channel_values +from .logic.experiments import flatten_polarized PLOT_BACKEND = 'QtCharts' @@ -31,6 +42,36 @@ class Plotting1d(QObject): posteriorPredictiveDataChanged = Signal() posteriorPredictiveSldDataChanged = Signal() + # Polarized-experiment channel selection. + # channelSelectionChanged: the visible-channel set changed. + # experimentChannelsChanged: the current experiment (and therefore its + # polarization state and channel list) changed. QML properties depending + # on the current experiment must be notified by this one; it is emitted + # from PyBackend whenever experiment selection/addition/removal happens. + channelSelectionChanged = Signal() + experimentChannelsChanged = Signal() + + # Magnetic depth profiles (Phase 5a). + # magneticProfileChanged: which magnetic curves are drawn, or whether any + # model is magnetic at all, changed — the SLD chart rebuilds its series. + magneticProfileChanged = Signal() + # Spin asymmetry (Phase 5b/5c): availability or content of the SA charts. + spinAsymmetryChanged = Signal() + + # Class-level default so instances constructed without __init__ (test stubs) + # still have a channel selection; setChannelVisible replaces it per instance. + _visible_channels: frozenset = frozenset({'pp', 'pm', 'mp', 'mm'}) + # Magnetic SLD curves drawn on top of the nuclear profile. The spin-up and + # spin-down potentials are on by default: where a layer is non-magnetic they + # collapse onto the nuclear curve, so a weakly magnetic sample still looks + # like the familiar chart. rho_m/theta_m are parameter views and are opt-in. + _visible_sld_curves: frozenset = frozenset({'spin_up', 'spin_down'}) + # Why the magnetic profiles of a magnetic model could not be computed + # ('' = no failure). Class-level default for test stubs without __init__. + _magnetic_profile_error: str = '' + # Cached result of the library channel-API check (None = not checked yet). + _channel_api_error = None + def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) self._project_lib = project_lib @@ -48,6 +89,17 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._bkg_shown = False self._residual_range_cache = None + # Spin channels shown for polarized experiments (channel-value strings). + self._visible_channels = frozenset({'pp', 'pm', 'mp', 'mm'}) + # Magnetic profile curves shown on the SLD chart (both pages share it). + self._visible_sld_curves = frozenset({'spin_up', 'spin_down'}) + # Spin asymmetry per experiment index; cleared with the other plot data. + self._spin_asymmetry_cache: dict = {} + # Magnetic depth profiles per model index; a refl1d evaluation each, and + # every chart refresh reads them several times. + self._magnetic_profile_cache: dict = {} + self._magnetic_profile_error = '' + # Posterior predictive state self._posterior_q: list = [] self._posterior_median: list = [] @@ -83,6 +135,8 @@ def reset_data(self): self._model_data = {} self._sld_data = {} self._residual_range_cache = None + self._spin_asymmetry_cache = {} + self._magnetic_profile_cache = {} console.debug(IO.formatMsg('sub', 'Sample and SLD data cleared')) def _apply_rq4(self, x, y): @@ -213,7 +267,9 @@ def _get_reference_line_data(self, param_attr: str, default_log: float, use_anal return [] else: exp_idx = self._project_lib.current_experiment_index - exp_data = self._project_lib.experimental_data_for_model_at_index(exp_idx) + exp_data = flatten_polarized( + self._project_lib.experimental_data_for_model_at_index(exp_idx), self._visible_channels + ) if exp_data.x is None or len(exp_data.x) == 0: return [] x_min, x_max = float(exp_data.x[0]), float(exp_data.x[-1]) @@ -310,9 +366,13 @@ def experiment_data(self) -> DataSet1D: if len(selected_indices) > 1: # Return concatenated data for multiple experiments (legacy support) return self._proxy._analysis.get_concatenated_experiment_data() - # Default single experiment behavior + # Default single experiment behavior. Polarized experiments are + # flattened to the first visible channel here; the experiment page + # uses the channel-aware slots for full per-channel display. current_index = self._project_lib.current_experiment_index - data = self._project_lib.experimental_data_for_model_at_index(current_index) + data = flatten_polarized( + self._project_lib.experimental_data_for_model_at_index(current_index), self._visible_channels + ) except IndexError: data = DataSet1D( name='Experiment Data empty', @@ -336,9 +396,17 @@ def is_multi_experiment_mode(self) -> bool: @property def individual_experiment_data_list(self) -> list: """Get individual experiment data for multi-experiment plotting.""" + return self._individual_experiment_data_list(expand_channels=False) + + @property + def individual_experiment_channel_data_list(self) -> list: + """Like `individual_experiment_data_list`, one entry per visible spin channel.""" + return self._individual_experiment_data_list(expand_channels=True) + + def _individual_experiment_data_list(self, expand_channels: bool) -> list: try: if hasattr(self._proxy, '_analysis'): - return self._proxy._analysis.get_individual_experiment_data_list() + return self._proxy._analysis.get_individual_experiment_data_list(expand_channels=expand_channels) except Exception as e: console.debug(f'Error getting individual experiment data: {e}') return [] @@ -412,6 +480,30 @@ def sldMaxY(self): def sldMinY(self): return self._get_all_models_sld_range()[2] + def _get_all_models_theta_range(self) -> tuple: + """(min, max) of theta_m over the magnetic models, for its own axis.""" + values = [] + for idx in range(len(self._project_lib.models)): + profile = self._magnetic_sld_profiles(idx).get('theta_m') + if profile is not None and profile.y.size > 0: + values.extend([float(profile.y.min()), float(profile.y.max())]) + if not values: + return (0.0, 360.0) + low, high = min(values), max(values) + if high - low < 1e-6: + # A single-valued angle (every layer at the same theta_m, the usual + # case) would collapse the axis. + return (max(0.0, low - 10.0), min(360.0, high + 10.0)) + return (low, high) + + @Property(float, notify=magneticProfileChanged) + def sldThetaMinY(self) -> float: + return self._get_all_models_theta_range()[0] + + @Property(float, notify=magneticProfileChanged) + def sldThetaMaxY(self) -> float: + return self._get_all_models_theta_range()[1] + def _get_all_models_sld_range(self): """Get combined X/Y ranges for all models' SLD data.""" min_x, max_x = float('inf'), float('-inf') @@ -419,13 +511,23 @@ def _get_all_models_sld_range(self): for idx in range(len(self._project_lib.models)): try: - data = self._project_lib.sld_data_for_model_at_index(idx) - if data.x.size > 0: - min_x = min(min_x, data.x.min()) - max_x = max(max_x, data.x.max()) - if data.y.size > 0: - min_y = min(min_y, data.y.min()) - max_y = max(max_y, data.y.max()) + datasets = [self._project_lib.sld_data_for_model_at_index(idx)] + # The magnetic curves are drawn on the same axes and rho + rhoM + # exceeds rho, so they must be part of the range or they end up + # silently clipped. theta_m lives on its own right-hand axis. + profiles = self._magnetic_sld_profiles(idx) + datasets += [ + profiles[curve] + for curve in self._visible_sld_curves + if curve in profiles and curve != 'theta_m' + ] + for data in datasets: + if data.x.size > 0: + min_x = min(min_x, data.x.min()) + max_x = max(max_x, data.x.max()) + if data.y.size > 0: + min_y = min(min_y, data.y.min()) + max_y = max(max_y, data.y.max()) except (IndexError, ValueError): continue @@ -442,40 +544,63 @@ def _get_all_models_sld_range(self): return (min_x, max_x, min_y, max_y) # Experiment ranges + def _experiment_range_datasets(self) -> list: + """Datasets the experiment chart actually draws for the current selection. + + A polarized experiment shows one series per visible measured channel, + and channel files need not share a q grid — so the axes must span all of + them, not just the flattened first one. Multi-experiment selection keeps + using the concatenated data. + """ + try: + if self.is_multi_experiment_mode: + return [self.experiment_data] + current_index = self._project_lib.current_experiment_index + experiment = self._project_lib.experimental_data_for_model_at_index(current_index) + channels = [ + channel for channel in experiment_channel_values(experiment) if channel in self._visible_channels + ] + if channels: + return [experiment[channel] for channel in channels] + except (IndexError, KeyError, AttributeError) as e: + console.debug(f'Falling back to the flat experiment data for chart ranges: {e}') + return [self.experiment_data] + @Property(float, notify=experimentChartRangesChanged) def experimentMaxX(self): - data = self.experiment_data - return data.x.max() if data.x.size > 0 else 1.0 + values = [data.x.max() for data in self._experiment_range_datasets() if data.x.size > 0] + return max(values) if values else 1.0 @Property(float, notify=experimentChartRangesChanged) def experimentMinX(self): - data = self.experiment_data - return data.x.min() if data.x.size > 0 else 0.0 + values = [data.x.min() for data in self._experiment_range_datasets() if data.x.size > 0] + return min(values) if values else 0.0 @Property(float, notify=experimentChartRangesChanged) def experimentMaxY(self): - data = self.experiment_data - if data.y.size == 0: - return 1.0 - y_values = self._apply_rq4(data.x, data.y) - y_values = y_values[y_values > 0] - if y_values.size == 0: - return 1.0 - return np.log10(y_values.max()) + values = [] + for data in self._experiment_range_datasets(): + if data.y.size == 0: + continue + y_values = self._apply_rq4(data.x, data.y) + y_values = y_values[y_values > 0] + if y_values.size > 0: + values.append(np.log10(y_values.max())) + return max(values) if values else 1.0 @Property(float, notify=experimentChartRangesChanged) def experimentMinY(self): - data = self.experiment_data - valid_y = data.y[data.y > 0] if data.y.size > 0 else np.array([1e-10]) - if valid_y.size == 0: - return -10.0 - valid_x = data.x[data.y > 0] if data.y.size > 0 else np.array([1.0]) - valid_y = self._apply_rq4(valid_x, valid_y) - # Filter again after transformation to avoid log of zero/negative - valid_y = valid_y[valid_y > 0] - if valid_y.size == 0: - return -10.0 - return np.log10(valid_y.min()) + values = [] + for data in self._experiment_range_datasets(): + if data.y.size == 0: + continue + positive = data.y > 0 + valid_y = self._apply_rq4(data.x[positive], data.y[positive]) + # Filter again after transformation to avoid log of zero/negative + valid_y = valid_y[valid_y > 0] + if valid_y.size > 0: + values.append(np.log10(valid_y.min())) + return min(values) if values else -10.0 # Residual ranges def _invalidate_residual_range_cache(self): @@ -529,21 +654,34 @@ def _get_residual_range(self) -> tuple: for exp_idx in indices: try: - aligned = self._get_aligned_analysis_values(exp_idx) - for item in aligned: - q = item['q'] - residual = self._compute_residual( - item['calculated'], item['measured'], item['sigma']) - if min_x == float('inf'): - min_x = q - else: - min_x = min(min_x, q) - if max_x == float('-inf'): - max_x = q - else: - max_x = max(max_x, q) - min_y = min(min_y, residual) - max_y = max(max_y, residual) + # A polarized experiment draws one residual curve per visible + # spin channel (see ResidualsView.qml); the range must cover + # every one of them, not just the flattened first channel. + experiment = self._project_lib.experimental_data_for_model_at_index(exp_idx) + channels = [ + channel for channel in experiment_channel_values(experiment) + if channel in self._visible_channels + ] + for channel in channels or ['']: + aligned = self._get_aligned_analysis_values(exp_idx, channel) + for item in aligned: + if not item['has_calculated']: + # No cross-section to compare against: ResidualsView + # does not draw a point here either. + continue + q = item['q'] + residual = self._compute_residual( + item['calculated'], item['measured'], item['sigma']) + if min_x == float('inf'): + min_x = q + else: + min_x = min(min_x, q) + if max_x == float('-inf'): + max_x = q + else: + max_x = max(max_x, q) + min_y = min(min_y, residual) + max_y = max(max_y, residual) except Exception as e: console.debug(f'Residual range error for experiment {exp_idx}: {e}') continue @@ -592,7 +730,34 @@ def isMultiExperimentMode(self) -> bool: @Property('QVariantList', notify=experimentDataChanged) def individualExperimentDataList(self) -> list: """Return list of individual experiment data for multi-experiment plotting.""" - data_list = self.individual_experiment_data_list + return self._qml_experiment_data_list(self.individual_experiment_data_list) + + @Property('QVariantList', notify=experimentChannelsChanged) + def individualExperimentChannelDataList(self) -> list: + """Multi-experiment list with polarized experiments split per visible channel. + + Used by the experiment and analysis charts, which draw one series per + channel; each row's `channel` selects the matching per-channel points. + """ + return self._qml_experiment_data_list(self.individual_experiment_channel_data_list) + + @Property(bool, notify=experimentChannelsChanged) + def analysisUsesChannelSeries(self) -> bool: + """Whether the analysis chart must draw one series per spin channel. + + True as soon as any selected experiment is polarized: its measured data + and calculated curve exist per channel, so the single measured/ + calculated pair of the ordinary path cannot represent it. + """ + try: + selected = getattr(self._proxy._analysis, '_selected_experiment_indices', None) or [] + return any(self._project_lib.experiment_is_polarized_at_index(index) for index in selected) + except Exception as exception: # noqa: BLE001 - a chart flag must never raise into QML + console.debug(f'Error resolving analysis channel mode: {exception}') + return False + + @staticmethod + def _qml_experiment_data_list(data_list: list) -> list: # Convert to QML-friendly format qml_data_list = [] for exp_data in data_list: @@ -601,6 +766,9 @@ def individualExperimentDataList(self) -> list: 'name': exp_data['name'], 'color': exp_data['color'], 'index': exp_data['index'], + # Spin channel of a polarized experiment ('' when unpolarized); + # QML fetches the matching per-channel points with it. + 'channel': exp_data.get('channel', ''), 'hasData': exp_data['data'].x.size > 0, } ) @@ -642,6 +810,325 @@ def getSldDataPointsForModel(self, model_index: int) -> list: console.debug(f'Error getting SLD data points for model {model_index}: {e}') return [] + # One-call fills for QML-owned series: a JS append() loop crosses the + # QML/C++ boundary and re-signals per point; replaceNp() repaints once. + @Slot('QVariant', int) + def fillSampleSeriesForModel(self, series, model_index: int) -> None: + """Fill a QML-owned series with a model's reflectivity in one call.""" + if series is None: + return + points = self.getSampleDataPointsForModel(model_index) + self._replace_series_points(series, ((point['x'], point['y']) for point in points)) + + @Slot('QVariant', int) + def fillSldSeriesForModel(self, series, model_index: int) -> None: + """Fill a QML-owned series with a model's nuclear SLD profile in one call.""" + if series is None: + return + points = self.getSldDataPointsForModel(model_index) + self._replace_series_points(series, ((point['x'], point['y']) for point in points)) + + @Slot('QVariant', int, str, int) + def fillMagneticSldSegmentSeries(self, series, model_index: int, curve: str, segment: int) -> None: + """Fill a QML-owned series with one piece of a magnetic curve in one call.""" + if series is None: + return + points = self.getMagneticSldSegment(model_index, curve, segment) + self._replace_series_points(series, ((point['x'], point['y']) for point in points)) + + # Magnetic depth profiles (Phase 5a) + MAGNETIC_SLD_CURVES = ('spin_up', 'spin_down', 'rho_m', 'theta_m') + + def _magnetic_sld_profiles(self, model_index: int) -> dict: + """Magnetic profiles of one model, or {} when it has none. + + A non-magnetic model, a calculator without magnetism, or a model index + that no longer exists are all "nothing to draw" — the chart simply keeps + the nuclear curve it draws today. + + Cached per model: one chart refresh reads the curves and four range + properties, and each miss is a full refl1d profile evaluation. The cache + is dropped with the other plot data and on every magnetic notification, + so a parameter change is picked up immediately. + """ + cache = getattr(self, '_magnetic_profile_cache', None) + if cache is None: + cache = self._magnetic_profile_cache = {} + if model_index in cache: + return cache[model_index] + + try: + has_magnetism = bool(self._project_lib.model_has_magnetism_at_index(model_index)) + except AttributeError: + has_magnetism = False + if not has_magnetism: + profiles = {} + else: + try: + profiles = self._project_lib.magnetic_sld_data_for_model_at_index(model_index) + self._magnetic_profile_error = '' + except (IndexError, KeyError, ValueError, NotImplementedError, AttributeError) as e: + # A magnetic model without curves is a real problem, and the GUI + # user cannot read this log — record it for the sidebar too. + console.error(f'No magnetic SLD profile for model {model_index}: {e}') + self._magnetic_profile_error = str(e) + profiles = {} + cache[model_index] = profiles + return profiles + + @Slot(int, result=bool) + def modelHasMagnetism(self, model_index: int) -> bool: + """Whether a model carries magnetism (drives the magnetic curves and controls).""" + try: + return bool(self._project_lib.model_has_magnetism_at_index(model_index)) + except AttributeError: + return False + + @Property(bool, notify=magneticProfileChanged) + def anyModelHasMagnetism(self) -> bool: + """Whether any model is magnetic — the capability gate for the magnetic UI.""" + try: + return any(self.modelHasMagnetism(index) for index in range(len(self._project_lib.models))) + except (TypeError, AttributeError): + return False + + @Slot(int, str, result='QVariantList') + def getMagneticSldDataPointsForModel(self, model_index: int, curve: str) -> list: + """Points of one magnetic profile curve of a model, [] when not applicable. + + `curve` is one of 'spin_up', 'spin_down', 'rho_m', 'theta_m'. + """ + if curve not in self.MAGNETIC_SLD_CURVES: + console.error(f'Unknown magnetic SLD curve {curve!r}.') + return [] + profiles = self._magnetic_sld_profiles(model_index) + data = profiles.get(curve) + if data is None: + return [] + return [{'x': float(x), 'y': float(y)} for x, y in zip(data.x, data.y)] + + def _magnetic_sld_segments(self, model_index: int, curve: str) -> list: + """One point list per contiguous piece of a magnetic curve. + + `theta_m` exists only where there is a moment, so a sample with two + magnetic layers separated by a spacer produces two pieces. Drawing them + as one series would connect the ends with a line across the spacer, + implying a moment rotation where there is no moment at all. + """ + if curve not in self.MAGNETIC_SLD_CURVES: + console.error(f'Unknown magnetic SLD curve {curve!r}.') + return [] + data = self._magnetic_sld_profiles(model_index).get(curve) + if data is None or data.x.size == 0: + return [] + + x = np.asarray(data.x, dtype=float) + y = np.asarray(data.y, dtype=float) + # The profile is on a uniform z grid; a gap is a step much larger than + # the usual one. + steps = np.diff(x) + breaks = np.flatnonzero(steps > 2.0 * np.median(steps)) + 1 if steps.size else np.array([], dtype=int) + return [ + [{'x': float(px), 'y': float(py)} for px, py in zip(piece_x, piece_y)] + for piece_x, piece_y in zip(np.split(x, breaks), np.split(y, breaks)) + if piece_x.size > 0 + ] + + @Slot(int, str, result='QVariantList') + def getMagneticSldSegmentsForModel(self, model_index: int, curve: str) -> list: + """The pieces of one magnetic curve, as lists of points.""" + return self._magnetic_sld_segments(model_index, curve) + + @Slot(int, str, int, result='QVariantList') + def getMagneticSldSegment(self, model_index: int, curve: str, segment: int) -> list: + """Points of one piece of a magnetic curve ([] when it does not exist).""" + segments = self._magnetic_sld_segments(model_index, curve) + if 0 <= segment < len(segments): + return segments[segment] + return [] + + @Property('QVariantList', notify=magneticProfileChanged) + def visibleSldCurves(self) -> list: + """Magnetic profile curves the user asked to see.""" + return [curve for curve in self.MAGNETIC_SLD_CURVES if curve in self._visible_sld_curves] + + @Property(str, notify=magneticProfileChanged) + def magneticProfileError(self) -> str: + """Why a magnetic model has no curves ('' when everything computed).""" + return self._magnetic_profile_error + + @Slot(str, result=bool) + def sldCurveVisible(self, curve: str) -> bool: + """Whether one magnetic profile curve is shown.""" + return curve in self._visible_sld_curves + + @Slot(str, bool) + def setSldCurveVisible(self, curve: str, visible: bool) -> None: + """Show or hide one magnetic profile curve on both SLD tabs. + + The spin-up and spin-down potentials are a pair: they are only + meaningful together, so one checkbox toggles both. + """ + curves = {'spin_up', 'spin_down'} if curve in ('spin_up', 'spin_down') else {curve} + unknown = curves - set(self.MAGNETIC_SLD_CURVES) + if unknown: + console.error(f'Unknown magnetic SLD curve(s) {sorted(unknown)}.') + return + + visible_curves = set(self._visible_sld_curves) + visible_curves |= curves if visible else set() + visible_curves -= set() if visible else curves + if visible_curves != set(self._visible_sld_curves): + self._visible_sld_curves = frozenset(visible_curves) + self.magneticProfileChanged.emit() + self.sldChartRangesChanged.emit() + + # Spin asymmetry (Phase 5b/5c) + def _spin_asymmetry(self, experiment_index: int = None) -> dict: + """SA of an experiment as ``{measured, calculated, masked_points}``, or {}. + + Empty when the experiment has no pp/mm pair — the charts are hidden in + that case, so this is "nothing to draw", not an error. + """ + if experiment_index is None: + experiment_index = self._project_lib.current_experiment_index + cached = getattr(self, '_spin_asymmetry_cache', None) + if cached is None: + cached = self._spin_asymmetry_cache = {} + if experiment_index in cached: + return cached[experiment_index] + + try: + result = self._project_lib.spin_asymmetry_for_experiment_at_index(experiment_index) + except (IndexError, KeyError, ValueError) as e: + console.debug(f'No spin asymmetry for experiment {experiment_index}: {e}') + result = {} + except Exception as e: + console.error(f'Failed to compute the spin asymmetry of experiment {experiment_index}: {e!r}') + result = {} + cached[experiment_index] = result + return result + + @Property(bool, notify=spinAsymmetryChanged) + def spinAsymmetryAvailable(self) -> bool: + """Whether the current experiment measured both pp and mm, so SA exists.""" + try: + return bool( + self._project_lib.experiment_supports_spin_asymmetry_at_index( + self._project_lib.current_experiment_index + ) + ) + except (IndexError, KeyError, AttributeError): + return False + + @Property(bool, notify=spinAsymmetryChanged) + def spinAsymmetryCalculatedAvailable(self) -> bool: + """Whether a model SA curve can be drawn (needs a magnetic model).""" + return self._spin_asymmetry().get('calculated') is not None + + @Property(int, notify=spinAsymmetryChanged) + def spinAsymmetryMaskedPoints(self) -> int: + """Measured points dropped because SA was noise over noise there.""" + return int(self._spin_asymmetry().get('masked_points', 0)) + + @Property(int, notify=spinAsymmetryChanged) + def spinAsymmetryOutOfOverlapPoints(self) -> int: + """Measured points dropped because the two channels do not cover the same q.""" + return int(self._spin_asymmetry().get('out_of_overlap_points', 0)) + + @Slot(int, result='QVariantList') + def getSpinAsymmetryPoints(self, experiment_index: int) -> list: + """Measured SA points with error bounds, ready for QML series.""" + data = self._spin_asymmetry(experiment_index).get('measured') + if data is None or data.x.size == 0: + return [] + # ye holds variances; the chart needs one standard deviation. + sigma = np.sqrt(np.clip(np.asarray(data.ye, dtype=float), 0.0, None)) + if sigma.size != data.y.size: + sigma = np.zeros_like(data.y) + return [ + { + 'x': float(x), + 'y': float(y), + 'errorUpper': float(y + error), + 'errorLower': float(y - error), + } + for x, y, error in zip(data.x, data.y, sigma) + ] + + @Slot(int, result='QVariantList') + def getSpinAsymmetryCalculatedPoints(self, experiment_index: int) -> list: + """Model SA points, [] when the model is not magnetic.""" + data = self._spin_asymmetry(experiment_index).get('calculated') + if data is None or data.x.size == 0: + return [] + return [{'x': float(x), 'y': float(y)} for x, y in zip(data.x, data.y)] + + def _spin_asymmetry_range(self) -> tuple: + """(min_x, max_x, min_y, max_y) over the drawn SA curves.""" + result = self._spin_asymmetry() + measured = result.get('measured') + if measured is None or measured.x.size == 0: + return (0.0, 1.0, -1.0, 1.0) + sigma = np.sqrt(np.clip(np.asarray(measured.ye, dtype=float), 0.0, None)) + if sigma.size != measured.y.size: + sigma = np.zeros_like(measured.y) + min_y = float(np.min(measured.y - sigma)) + max_y = float(np.max(measured.y + sigma)) + calculated = result.get('calculated') + if calculated is not None and calculated.y.size > 0: + min_y = min(min_y, float(np.min(calculated.y))) + max_y = max(max_y, float(np.max(calculated.y))) + # SA lies in [-1, 1] only while both reflectivities are positive, which + # background-subtracted data need not be. Start from that window so a + # normal dataset always gets the same, comparable axis, but expand it + # rather than drawing retained points off-canvas. + return ( + float(np.min(measured.x)), + float(np.max(measured.x)), + min(-1.05, min_y), + max(1.05, max_y), + ) + + @Property(float, notify=spinAsymmetryChanged) + def spinAsymmetryMinX(self) -> float: + return self._spin_asymmetry_range()[0] + + @Property(float, notify=spinAsymmetryChanged) + def spinAsymmetryMaxX(self) -> float: + return self._spin_asymmetry_range()[1] + + @Property(float, notify=spinAsymmetryChanged) + def spinAsymmetryMinY(self) -> float: + return self._spin_asymmetry_range()[2] + + @Property(float, notify=spinAsymmetryChanged) + def spinAsymmetryMaxY(self) -> float: + return self._spin_asymmetry_range()[3] + + @Slot() + def notifySpinAsymmetryChanged(self) -> None: + """Recompute the spin asymmetry and tell QML. + + Connected to everything that changes the data or the model: the SA + depends on both the measured channels and (for the model curve) every + fitted parameter. + """ + self._spin_asymmetry_cache = {} + self.spinAsymmetryChanged.emit() + + @Slot() + def notifyMagneticProfileChanged(self) -> None: + """Recompute the magnetic profiles and tell QML. + + Connected to the sample/parameter change relays: making a layer magnetic + (or removing its magnetism) adds or removes curves and changes the SLD + y-range, and moving rho_m/theta_m changes the curves themselves. + """ + self._magnetic_profile_cache = {} + self.magneticProfileChanged.emit() + self.sldChartRangesChanged.emit() + @Slot(int, result=str) def getModelColor(self, model_index: int) -> str: """Get the color for a specific model.""" @@ -655,34 +1142,201 @@ def modelCount(self) -> int: """Return the number of models.""" return len(self._project_lib.models) + def _measured_points_from_dataset(self, data) -> list: + """Log-space measured points with error bands from one flat dataset.""" + points = [] + for point in data.data_points(): + q = point[0] + r = point[1] + if r <= 0: + continue + error_var = point[2] + error_lower_linear = max(r - np.sqrt(error_var), 1e-10) + r_val = self._apply_rq4(q, r) + error_upper = self._apply_rq4(q, r + np.sqrt(error_var)) + error_lower = self._apply_rq4(q, error_lower_linear) + points.append( + { + 'x': float(q), + 'y': float(np.log10(r_val)), + 'errorUpper': float(np.log10(error_upper)), + 'errorLower': float(np.log10(error_lower)), + } + ) + return points + @Slot(int, result='QVariantList') def getExperimentDataPoints(self, experiment_index: int) -> list: - """Get data points for a specific experiment for plotting.""" + """Get data points for a specific experiment for plotting. + + For a polarized experiment this returns the first visible channel; + per-channel series use `getExperimentChannelDataPoints` instead. + """ try: - data = self._project_lib.experimental_data_for_model_at_index(experiment_index) - points = [] - for point in data.data_points(): - q = point[0] - r = point[1] - if r <= 0: - continue - error_var = point[2] - error_lower_linear = max(r - np.sqrt(error_var), 1e-10) - r_val = self._apply_rq4(q, r) - error_upper = self._apply_rq4(q, r + np.sqrt(error_var)) - error_lower = self._apply_rq4(q, error_lower_linear) - points.append( - { - 'x': float(q), - 'y': float(np.log10(r_val)), - 'errorUpper': float(np.log10(error_upper)), - 'errorLower': float(np.log10(error_lower)), - } - ) - return points + data = flatten_polarized( + self._project_lib.experimental_data_for_model_at_index(experiment_index), self._visible_channels + ) + except (IndexError, KeyError) as e: + # Expected: no experiment loaded at this index. + console.debug(f'No experiment data for index {experiment_index}: {e}') + return [] except Exception as e: - console.debug(f'Error getting experiment data points for index {experiment_index}: {e}') + # Anything else is a defect or an incompatible library, not "no data". + console.error(f'Failed to read experiment {experiment_index}: {e!r}') + return [] + return self._measured_points_from_dataset(data) + + @Slot(int, str, result='QVariantList') + def getExperimentChannelDataPoints(self, experiment_index: int, channel: str) -> list: + """Get data points of one spin channel of a polarized experiment.""" + try: + self._require_channel_api() + data = self._project_lib.experimental_data_for_model_at_index(experiment_index, channel=channel) + except (IndexError, KeyError) as e: + # Expected: no experiment at this index, or the channel was not measured. + console.debug(f'No {channel} channel data for index {experiment_index}: {e}') + return [] + except Exception as e: + # A TypeError here means the library predates the channel argument; + # silently returning [] would draw an empty chart instead. + console.error(f'Failed to read {channel} channel of experiment {experiment_index}: {e!r}') + return [] + return self._measured_points_from_dataset(data) + + def _require_channel_api(self) -> None: + """Fail loudly when the installed library has no per-channel experiment API. + + The app and `easyreflectometry` must ship the same polarized API; an + older library would otherwise turn every polarized chart into an empty + one with no visible cause. Both halves are checked: the polarization + predicate and the accessor's `channel` argument. + """ + if self._channel_api_error is None: + self._channel_api_error = self._check_channel_api() + if self._channel_api_error: + raise RuntimeError(self._channel_api_error) + + def _check_channel_api(self) -> str: + """Return an error message when the library lacks the channel API, '' otherwise.""" + missing = 'The installed easyreflectometry library does not provide the per-channel experiment API' + advice = ( + 'Polarized data cannot be displayed; please install a library version that ' + 'supports polarized experiments.' + ) + if not hasattr(self._project_lib, 'experiment_is_polarized_at_index'): + return f'{missing} (experiment_is_polarized_at_index is missing). {advice}' + accessor = getattr(self._project_lib, 'experimental_data_for_model_at_index', None) + try: + parameters = inspect.signature(accessor).parameters + except (TypeError, ValueError): # builtins/C callables: assume it is fine + return '' + accepts_channel = 'channel' in parameters or any( + parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() + ) + if not accepts_channel: + return f'{missing} (experimental_data_for_model_at_index has no channel argument). {advice}' + return '' + + @Slot(int, result='QVariantList') + def getExperimentChannels(self, experiment_index: int) -> list: + """Measured channels of an experiment as ``{channel, label, color, visible}`` rows. + + Empty for unpolarized experiments. + """ + return [ + { + 'channel': channel, + 'label': CHANNEL_LABELS[channel], + 'color': CHANNEL_COLORS[channel], + 'visible': channel in self._visible_channels, + } + for channel in self._measured_channels(experiment_index) + ] + + def _measured_channels(self, experiment_index: int = None) -> list: + """Measured channel values of an experiment ([] when unpolarized or missing).""" + if experiment_index is None: + experiment_index = self._project_lib.current_experiment_index + try: + experiment = self._project_lib.experimental_data_for_model_at_index(experiment_index) + except (IndexError, KeyError): return [] + return experiment_channel_values(experiment) + + @Property(bool, notify=experimentChannelsChanged) + def currentExperimentIsPolarized(self) -> bool: + """Whether the current experiment carries per-channel (polarized) data.""" + return bool(self._measured_channels()) + + @Property('QVariantList', notify=experimentChannelsChanged) + def experimentChannelList(self) -> list: + """Channel rows of the current experiment for the channel selector UI.""" + return self.getExperimentChannels(self._project_lib.current_experiment_index) + + @Slot() + def notifyExperimentChannelsChanged(self) -> None: + """Tell QML the current experiment (and so its channel state) changed. + + Connected to every path that can change the current experiment — + selection, load, removal, project open — so `currentExperimentIsPolarized` + and `experimentChannelList` never keep a previous experiment's value. + The visible-channel set is renormalized first, so a selection made on a + previous experiment cannot leave the new one with nothing to draw. + """ + if self._renormalize_visible_channels(): + self.channelSelectionChanged.emit() + self.experimentChannelsChanged.emit() + + def _renormalize_visible_channels(self) -> bool: + """Keep at least one measured channel of the current experiment visible. + + The selection is global (one selector for the whole app), so hiding + channels on one experiment can leave another experiment with none of its + measured channels selected — an empty chart the user cannot fix, because + the last-visible guard only runs while *hiding*. When that happens, all + measured channels of the new current experiment are switched back on. + + Returns True when the visible set changed. + """ + measured = self._measured_channels() + if not measured or any(channel in self._visible_channels for channel in measured): + return False + self._visible_channels = self._visible_channels | frozenset(measured) + console.debug(f'No visible channel for the current experiment; showing {", ".join(measured)} again.') + return True + + @Slot(str, bool) + def setChannelVisible(self, channel: str, visible: bool) -> None: + """Show or hide one spin channel on the experiment/analysis charts. + + At least one *measured* channel of the current experiment always stays + visible: a two-channel pp/mm experiment must not be blanked by hiding + pp and mm just because unmeasured pm/mp are still in the global set. + """ + visible_channels = set(self._visible_channels) + if visible: + visible_channels.add(channel) + else: + measured = self._measured_channels() + if measured: + still_visible = [name for name in measured if name in visible_channels and name != channel] + if not still_visible: + console.debug(f'Refusing to hide {channel}: it is the last visible measured channel.') + # The checkbox has already toggled itself; re-publish the + # channel rows so it rebinds to the unchanged state. + self.experimentChannelsChanged.emit() + return + elif len(visible_channels) <= 1: + # Unpolarized/no experiment: keep the old global invariant. + self.experimentChannelsChanged.emit() + return + visible_channels.discard(channel) + + if visible_channels != set(self._visible_channels): + self._visible_channels = frozenset(visible_channels) + self.channelSelectionChanged.emit() + self.experimentChannelsChanged.emit() + self.experimentDataChanged.emit() def _get_experiment_model_index(self, experiment_index: int, exp_data=None) -> int: """Resolve the model index used by a given experiment.""" @@ -694,9 +1348,19 @@ def _get_experiment_model_index(self, experiment_index: int, exp_data=None) -> i return experiment_index return 0 - def _get_aligned_analysis_values(self, experiment_index: int) -> list[dict]: - """Return measured, calculated and sigma values aligned on experiment q points.""" - exp_data = self._project_lib.experimental_data_for_model_at_index(experiment_index) + def _get_aligned_analysis_values(self, experiment_index: int, channel: str = '') -> list[dict]: + """Return measured, calculated and sigma values aligned on experiment q points. + + With `channel`, both the measured points and the calculated curve come + from that spin channel — the calculated curve must be the channel's own + cross-section, not the channel-agnostic one. Without it a polarized + experiment falls back to its first visible channel. + """ + experiment = self._project_lib.experimental_data_for_model_at_index(experiment_index) + if channel: + exp_data = experiment[channel] + else: + exp_data = flatten_polarized(experiment, self._visible_channels) q_values = np.asarray(getattr(exp_data, 'x', np.empty(0)), dtype=float) measured_values = np.asarray(getattr(exp_data, 'y', np.empty(0)), dtype=float) sigma_values = np.asarray(getattr(exp_data, 'ye', np.zeros_like(measured_values)), dtype=float) @@ -707,16 +1371,31 @@ def _get_aligned_analysis_values(self, experiment_index: int) -> list[dict]: q_mask = (q_values >= self._project_lib.q_min) & (q_values <= self._project_lib.q_max) q_filtered = q_values[q_mask] measured_filtered = measured_values[q_mask] - sigma_filtered = sigma_values[q_mask] if sigma_values.size else np.zeros_like(measured_filtered) + variance_filtered = sigma_values[q_mask] if sigma_values.size else np.zeros_like(measured_filtered) + # ye holds variances (sigma**2), same convention as prepare_threaded_fit + # and getSpinAsymmetryPoints; convert to one standard deviation here so + # every consumer of 'sigma' (residuals, report error bars) agrees. + sigma_filtered = np.sqrt(np.clip(variance_filtered, 0.0, None)) model_index = self._get_experiment_model_index(experiment_index, exp_data) - try: - calc_data = self._project_lib.model_data_for_model_at_index(model_index, q_filtered) - except TypeError: - calc_data = self._project_lib.model_data_for_model_at_index(model_index) + if channel: + # A channel curve must be that channel's own cross-section: if it + # cannot be computed (e.g. spin-flip on a non-magnetic model), show + # the measured points alone rather than another channel's curve. + try: + calc_data = self._project_lib.model_data_for_model_at_index(model_index, q_filtered, channel=channel) + except Exception as exception: # noqa: BLE001 - any backend refusal means "no curve" + console.debug(f'No calculated curve for channel {channel}: {exception}') + calc_data = None + else: + try: + calc_data = self._project_lib.model_data_for_model_at_index(model_index, q_filtered) + except TypeError: + calc_data = self._project_lib.model_data_for_model_at_index(model_index) calc_values = np.asarray(getattr(calc_data, 'y', np.empty(0)), dtype=float) calc_q_values = np.asarray(getattr(calc_data, 'x', np.empty(0)), dtype=float) + has_calculated = calc_values.size > 0 if calc_values.size == q_filtered.size: calculated_filtered = calc_values @@ -746,16 +1425,24 @@ def _get_aligned_analysis_values(self, experiment_index: int) -> list[dict]: 'measured': float(measured_value), 'calculated': float(calculated_value), 'sigma': float(sigma_value), + # False when there is no cross-section to show (see above); + # 'calculated' then mirrors 'measured' and must not be drawn. + 'has_calculated': has_calculated, } ) return points @Slot(int, result='QVariantList') - def getAnalysisDataPoints(self, experiment_index: int) -> list: - """Get measured and calculated data points for a specific experiment for analysis plotting.""" + @Slot(int, str, result='QVariantList') + def getAnalysisDataPoints(self, experiment_index: int, channel: str = '') -> list: + """Get measured and calculated data points for a specific experiment for analysis plotting. + + `channel` selects one spin channel of a polarized experiment; both the + measured points and the calculated curve are then that channel's. + """ try: points = [] - for point in self._get_aligned_analysis_values(experiment_index): + for point in self._get_aligned_analysis_values(experiment_index, channel): measured = point['measured'] calculated = point['calculated'] points.append( @@ -763,6 +1450,7 @@ def getAnalysisDataPoints(self, experiment_index: int) -> list: 'x': point['q'], 'measured': float(np.log10(measured)) if measured > 0 else -10.0, 'calculated': float(np.log10(calculated)) if calculated > 0 else -10.0, + 'hasCalculated': bool(point['has_calculated']), } ) return points @@ -771,11 +1459,16 @@ def getAnalysisDataPoints(self, experiment_index: int) -> list: return [] @Slot(int, result='QVariantList') - def getResidualDataPoints(self, experiment_index: int) -> list: - """Get residual data points for a specific experiment.""" + @Slot(int, str, result='QVariantList') + def getResidualDataPoints(self, experiment_index: int, channel: str = '') -> list: + """Get residual data points for a specific experiment (optionally one spin channel).""" try: points = [] - for point in self._get_aligned_analysis_values(experiment_index): + for point in self._get_aligned_analysis_values(experiment_index, channel): + if not point['has_calculated']: + # No cross-section: a residual of zero would look like a + # perfect fit, so report nothing at all. + continue residual = self._compute_residual( point['calculated'], point['measured'], point['sigma']) points.append({'x': point['q'], 'y': float(residual)}) @@ -812,16 +1505,31 @@ def drawCalculatedOnSampleChart(self): if PLOT_BACKEND == 'QtCharts': self.qtchartsReplaceCalculatedOnSampleChartAndRedraw() + @staticmethod + def _replace_series_points(series, points) -> int: + """Replace a QtCharts series' content in one call. + + One ``replaceNp()`` re-signals and repaints once; per-point + ``append()`` crosses the QML/C++ boundary and re-signals for every + single point. (The ``replace(QList)`` overload is not + exposed by PySide6 — only the numpy variants are.) + """ + pts = list(points) + if not pts: + series.clear() + return 0 + x = np.fromiter((point[0] for point in pts), dtype=np.float64, count=len(pts)) + y = np.fromiter((point[1] for point in pts), dtype=np.float64, count=len(pts)) + series.replaceNp(x, y) + return len(pts) + def qtchartsReplaceCalculatedOnSampleChartAndRedraw(self): if not self._clear_qtcharts_series('samplePage', 'sampleSerie'): return series = self._qtcharts_series_ref('samplePage', 'sampleSerie') - nr_points = 0 - for point in self.sample_data.data_points(): - if point[1] <= 0: - continue - series.append(point[0], np.log10(point[1])) - nr_points = nr_points + 1 + nr_points = self._replace_series_points( + series, ((point[0], np.log10(point[1])) for point in self.sample_data.data_points() if point[1] > 0) + ) console.debug(IO.formatMsg('sub', 'Calc curve', f'{nr_points} points', 'on sample page', 'replaced')) @Slot() @@ -833,21 +1541,13 @@ def qtchartsReplaceCalculatedOnSldChartAndRedraw(self): # Draw on sample page series = self._chartRefs['QtCharts']['samplePage']['sldSerie'] if series is not None: - series.clear() - nr_points = 0 - for point in self.sld_data.data_points(): - series.append(point[0], point[1]) - nr_points = nr_points + 1 + nr_points = self._replace_series_points(series, self.sld_data.data_points()) console.debug(IO.formatMsg('sub', 'Sld curve', f'{nr_points} points', 'on sample page', 'replaced')) # Draw on analysis page analysis_series = self._chartRefs['QtCharts']['analysisPage']['sldSerie'] if analysis_series is not None: - analysis_series.clear() - nr_points = 0 - for point in self.sld_data.data_points(): - analysis_series.append(point[0], point[1]) - nr_points = nr_points + 1 + nr_points = self._replace_series_points(analysis_series, self.sld_data.data_points()) console.debug(IO.formatMsg('sub', 'Sld curve', f'{nr_points} points', 'on analysis page', 'replaced')) @Slot() @@ -865,7 +1565,9 @@ def qtchartsReplaceMeasuredOnExperimentChartAndRedraw(self): series_measured = self._qtcharts_series_ref('experimentPage', 'measuredSerie') series_error_upper = self._qtcharts_series_ref('experimentPage', 'errorUpperSerie') series_error_lower = self._qtcharts_series_ref('experimentPage', 'errorLowerSerie') - nr_points = 0 + measured_points = [] + error_upper_points = [] + error_lower_points = [] for point in self.experiment_data.data_points(): q = point[0] r = point[1] @@ -876,12 +1578,14 @@ def qtchartsReplaceMeasuredOnExperimentChartAndRedraw(self): r_val = self._apply_rq4(q, r) error_upper = self._apply_rq4(q, r + np.sqrt(error_var)) error_lower = self._apply_rq4(q, error_lower_linear) - series_measured.append(q, np.log10(r_val)) - series_error_upper.append(q, np.log10(error_upper)) - series_error_lower.append(q, np.log10(error_lower)) - nr_points = nr_points + 1 + measured_points.append((q, np.log10(r_val))) + error_upper_points.append((q, np.log10(error_upper))) + error_lower_points.append((q, np.log10(error_lower))) + self._replace_series_points(series_measured, measured_points) + self._replace_series_points(series_error_upper, error_upper_points) + self._replace_series_points(series_error_lower, error_lower_points) - console.debug(IO.formatMsg('sub', 'Measured curve', f'{nr_points} points', 'on experiment page', 'replaced')) + console.debug(IO.formatMsg('sub', 'Measured curve', f'{len(measured_points)} points', 'on experiment page', 'replaced')) def qtchartsReplaceMultiExperimentChartAndRedraw(self): """Draw multiple experiment series with distinct colors.""" @@ -919,22 +1623,20 @@ def qtchartsReplaceCalculatedAndMeasuredOnAnalysisChartAndRedraw(self): series_measured = self._qtcharts_series_ref('analysisPage', 'measuredSerie') series_calculated = self._qtcharts_series_ref('analysisPage', 'calculatedSerie') - nr_points = 0 - for point in self.experiment_data.data_points(): - q = point[0] - r_meas = point[1] - if r_meas <= 0: - continue - r_meas = self._apply_rq4(q, r_meas) - series_measured.append(q, np.log10(r_meas)) - nr_points = nr_points + 1 + nr_points = self._replace_series_points( + series_measured, + ( + (point[0], np.log10(self._apply_rq4(point[0], point[1]))) + for point in self.experiment_data.data_points() + if point[1] > 0 + ), + ) console.debug(IO.formatMsg('sub', 'Measured curve', f'{nr_points} points', 'on analysis page', 'replaced')) - for point in self.model_data.data_points(): - q = point[0] - r_calc = self._apply_rq4(q, point[1]) - series_calculated.append(q, np.log10(r_calc)) - nr_points = nr_points + 1 + nr_points = self._replace_series_points( + series_calculated, + ((point[0], np.log10(self._apply_rq4(point[0], point[1]))) for point in self.model_data.data_points()), + ) console.debug(IO.formatMsg('sub', 'Calculated curve', f'{nr_points} points', 'on analysis page', 'replaced')) # ------------------------------------------------------------------ diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index b59235f6..bcb73518 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -126,20 +126,93 @@ def plottingIndividualExperimentDataList(self) -> list: """Return list of individual experiment data for multi-experiment plotting.""" return self._plotting_1d.individualExperimentDataList + @Property('QVariantList', notify=multiExperimentSelectionChanged) + def plottingIndividualExperimentChannelDataList(self) -> list: + """Multi-experiment data with polarized experiments split per visible spin channel.""" + return self._plotting_1d.individualExperimentChannelDataList + @Slot(int, result='QVariantList') def plottingGetExperimentDataPoints(self, experiment_index: int) -> list: """Get data points for a specific experiment for plotting.""" return self._plotting_1d.getExperimentDataPoints(experiment_index) @Slot(int, result='QVariantList') - def plottingGetAnalysisDataPoints(self, experiment_index: int) -> list: - """Get measured and calculated data points for a specific experiment for analysis plotting.""" - return self._plotting_1d.getAnalysisDataPoints(experiment_index) + @Slot(int, str, result='QVariantList') + def plottingGetAnalysisDataPoints(self, experiment_index: int, channel: str = '') -> list: + """Get measured and calculated data points for a specific experiment for analysis plotting. + + `channel` picks one spin channel of a polarized experiment. + """ + return self._plotting_1d.getAnalysisDataPoints(experiment_index, channel) @Slot(int, result='QVariantList') - def plottingGetResidualDataPoints(self, experiment_index: int) -> list: + @Slot(int, str, result='QVariantList') + def plottingGetResidualDataPoints(self, experiment_index: int, channel: str = '') -> list: """Get residual data points for a specific experiment for residual plotting.""" - return self._plotting_1d.getResidualDataPoints(experiment_index) + return self._plotting_1d.getResidualDataPoints(experiment_index, channel) + + @Property(bool, notify=multiExperimentSelectionChanged) + def plottingAnalysisUsesChannelSeries(self) -> bool: + """Whether the analysis/residual charts must draw one series per spin channel.""" + return self._plotting_1d.analysisUsesChannelSeries + + # Polarized experiment support + @Slot(int, str, result='QVariantList') + def plottingGetExperimentChannelDataPoints(self, experiment_index: int, channel: str) -> list: + """Get data points of one spin channel of a polarized experiment.""" + return self._plotting_1d.getExperimentChannelDataPoints(experiment_index, channel) + + @Slot(int, result='QVariantList') + def plottingGetExperimentChannels(self, experiment_index: int) -> list: + """Measured spin channels of an experiment ({channel, label, color, visible} rows).""" + return self._plotting_1d.getExperimentChannels(experiment_index) + + @Slot(str, bool) + def plottingSetChannelVisible(self, channel: str, visible: bool) -> None: + """Show or hide one spin channel on the charts.""" + self._plotting_1d.setChannelVisible(channel, visible) + + ######### Magnetic depth profiles (SLD chart, both pages) + @Slot(int, result=bool) + def plottingModelHasMagnetism(self, model_index: int) -> bool: + """Whether one model carries magnetism.""" + return self._plotting_1d.modelHasMagnetism(model_index) + + @Slot(int, str, result='QVariantList') + def plottingGetMagneticSldDataPointsForModel(self, model_index: int, curve: str) -> list: + """Points of one magnetic profile curve ('spin_up', 'spin_down', 'rho_m', 'theta_m').""" + return self._plotting_1d.getMagneticSldDataPointsForModel(model_index, curve) + + @Slot(int, str, result='QVariantList') + def plottingGetMagneticSldSegmentsForModel(self, model_index: int, curve: str) -> list: + """The contiguous pieces of one magnetic profile curve.""" + return self._plotting_1d.getMagneticSldSegmentsForModel(model_index, curve) + + @Slot(int, str, int, result='QVariantList') + def plottingGetMagneticSldSegment(self, model_index: int, curve: str, segment: int) -> list: + """Points of one piece of a magnetic profile curve.""" + return self._plotting_1d.getMagneticSldSegment(model_index, curve, segment) + + @Slot(str, result=bool) + def plottingSldCurveVisible(self, curve: str) -> bool: + """Whether one magnetic profile curve is shown.""" + return self._plotting_1d.sldCurveVisible(curve) + + @Slot(str, bool) + def plottingSetSldCurveVisible(self, curve: str, visible: bool) -> None: + """Show or hide one magnetic profile curve on both SLD tabs.""" + self._plotting_1d.setSldCurveVisible(curve, visible) + + ######### Spin asymmetry + @Slot(int, result='QVariantList') + def plottingGetSpinAsymmetryPoints(self, experiment_index: int) -> list: + """Measured spin-asymmetry points with error bounds.""" + return self._plotting_1d.getSpinAsymmetryPoints(experiment_index) + + @Slot(int, result='QVariantList') + def plottingGetSpinAsymmetryCalculatedPoints(self, experiment_index: int) -> list: + """Calculated spin-asymmetry points ([] without a magnetic model).""" + return self._plotting_1d.getSpinAsymmetryCalculatedPoints(experiment_index) ######### Connections to relay info between the backend parts def _connect_backend_parts(self) -> None: @@ -162,6 +235,10 @@ def _connect_project_page(self) -> None: def _connect_sample_page(self) -> None: self._sample.externalSampleChanged.connect(self._relay_sample_page_sample_changed) + # Enabling magnetism can switch the project's calculation engine; the + # Analysis page's selector and every calculated curve must follow. + self._sample.calculationEngineChanged.connect(self._analysis.calculatorChanged) + self._sample.calculationEngineChanged.connect(self._analysis.externalCalculatorChanged) self._sample.externalRefreshPlot.connect(self._refresh_plots) self._sample.modelsTableChanged.connect(self._analysis._clearCacheAndEmitParametersChanged) self._sample.modelsTableChanged.connect(self._analysis.experimentsChanged) @@ -171,6 +248,12 @@ def _connect_sample_page(self) -> None: def _connect_experiment_page(self) -> None: self._experiment.externalExperimentChanged.connect(self._relay_experiment_page_experiment_changed) self._experiment.externalExperimentChanged.connect(self._refresh_plots) + # Loading/removing an experiment can change whether the current one is + # polarized and which channels it has. + self._experiment.externalExperimentChanged.connect(self._plotting_1d.notifyExperimentChannelsChanged) + # A freshly imported experiment becomes the current (and only selected) + # one, so the charts show what was just loaded. + self._experiment.experimentLoaded.connect(self._analysis.selectExperimentAtIndex) if hasattr(self._experiment, 'qRangeUpdated') and hasattr(self._sample, 'qRangeChanged'): self._experiment.qRangeUpdated.connect(self._sample.qRangeChanged) @@ -180,11 +263,17 @@ def _connect_analysis_page(self) -> None: self._analysis.externalParametersChanged.connect(self._relay_analysis_page) self._analysis.externalParametersChanged.connect(self._refresh_plots) self._analysis.externalFittingChanged.connect(self._refresh_plots) + self._analysis.externalFittingChanged.connect(self._sample.magnetismChanged) + # A finished fit updates the goodness-of-fit; refresh the Summary tab's # HTML binding so it stops showing the stale pre-fit value. self._analysis.externalFittingChanged.connect(self._summary.summaryChanged) self._analysis.externalExperimentChanged.connect(self._relay_experiment_page_experiment_changed) self._analysis.externalExperimentChanged.connect(self._refresh_plots) + # Selecting another experiment changes the polarization state and the + # channel list QML binds to, and with it whether spin asymmetry exists. + self._analysis.experimentsChanged.connect(self._plotting_1d.notifyExperimentChannelsChanged) + self._analysis.experimentsChanged.connect(self._plotting_1d.notifySpinAsymmetryChanged) # Update status bar when parameters change (e.g. fit checkbox toggle, post-fit) self._analysis.parametersChanged.connect(self._status.statusChanged) # Connect multi-experiment selection changes @@ -215,15 +304,18 @@ def _relay_project_page_project_changed(self): self._analysis.experimentsChanged.emit() self._status.statusChanged.emit() self._summary.summaryChanged.emit() - self._plotting_1d.reset_data() + # _refresh_plots drops the plot caches itself before recomputing. self._refresh_plots() def _relay_sample_page_sample_changed(self): - self._plotting_1d.reset_data() + # Non-plot consequences of a sample edit only. Every edit that changes + # a curve also emits externalRefreshPlot (handled by _refresh_plots, + # which invalidates and recomputes everything once); when this relay + # also dropped the plot caches and re-notified the magnetic/spin + # asymmetry charts, each edit computed every refl1d curve twice. self._analysis._clearCacheAndEmitParametersChanged() self._status.statusChanged.emit() self._summary.summaryChanged.emit() - self._plotting_1d.samplePageResetAxes.emit() def _relay_experiment_page_experiment_changed(self): self._analysis.experimentsChanged.emit() @@ -237,8 +329,19 @@ def _relay_analysis_page(self): self._experiment.experimentChanged.emit() self._summary.summaryChanged.emit() self._plotting_1d.samplePageResetAxes.emit() + # Switching the calculator changes whether magnetism can be modelled at + # all, which gates the Sample page's magnetism editor. + self._sample.magnetismChanged.emit() def _refresh_plots(self): + # The single invalidate-and-recompute pass: drop every plot cache + # first, then notify each chart exactly once. + self._plotting_1d.reset_data() + # The magnetic profile and the spin asymmetry follow both the sample + # (a layer became magnetic, a parameter moved) and the data, so they are + # refreshed wherever the ordinary plots are. + self._plotting_1d.notifyMagneticProfileChanged() + self._plotting_1d.notifySpinAsymmetryChanged() self._plotting_1d.sampleChartRangesChanged.emit() self._plotting_1d.sldChartRangesChanged.emit() self._plotting_1d.experimentChartRangesChanged.emit() diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 7c4dcd93..581f6496 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -16,6 +16,7 @@ from PySide6.QtCore import Slot from .logic.assemblies import Assemblies as AssembliesLogic +from .logic.calculators import Calculators as CalculatorsLogic from .logic.layers import Layers as LayersLogic from .logic.material import Material as MaterialLogic from .logic.models import Models as ModelsLogic @@ -67,6 +68,20 @@ class Sample(QObject): layersChange = Signal() layersIndexChanged = Signal() + # Per-layer magnetism (rho_m / theta_m) and calculator capability. + magnetismChanged = Signal() + magnetismFailed = Signal(str) + # Emitted with (layer index, engine name) when making a layer magnetic needs + # a different calculation engine; the UI asks before anything is changed. + magnetismNeedsEngine = Signal(int, str) + # Emitted when the Sample page changed the project's calculation engine, so + # the Analysis page's selector and the plots follow. + calculationEngineChanged = Signal() + # Emitted with the reason when a requested engine switch is refused (e.g. + # the sample has magnetic layers the engine cannot model). Logs are not + # visible in the GUI, so the engine selector shows this in a dialog. + calculationEngineRejected = Signal(str) + qRangeChanged = Signal() constraintsChanged = Signal() @@ -78,6 +93,9 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._project_lib = project_lib self._material_logic = MaterialLogic(project_lib) self._models_logic = ModelsLogic(project_lib) + # The engine is a project-wide setting; this logic derives its index + # from the project, so the Analysis page's selector cannot disagree. + self._calculators_logic = CalculatorsLogic(project_lib) self._assemblies_logic = AssembliesLogic(project_lib) self._layers_logic = LayersLogic(project_lib) self._project_logic = ProjectLogic(project_lib) @@ -90,6 +108,8 @@ def __init__(self, project_lib: ProjectLib, parent=None): def connect_logic(self) -> None: self.assembliesIndexChanged.connect(self.layersConnectChanges) + # The magnetism table lists the current assembly's layers. + self.assembliesIndexChanged.connect(self.magnetismChanged) # # # # Materials @@ -159,6 +179,7 @@ def setMaterialISldAtIndex(self, index: int, new_value: float) -> None: def removeMaterial(self, value: str) -> None: self._material_logic.remove_at_index(value) self.materialsTableChanged.emit() + self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @Slot() @@ -581,6 +602,126 @@ def moveSelectedLayerDown(self) -> None: def _clearCacheAndEmitLayersChanged(self): self._chached_layers = None self.layersChange.emit() + # The magnetism table lists the same layers, so it goes stale whenever + # a layer is added, removed, reordered or renamed. + self.magnetismChanged.emit() + + # # # + # Calculation engine (shared project setting, shown here because magnetism + # depends on it) + # # # + @Property('QVariantList', notify=calculationEngineChanged) + def calculationEngines(self) -> list[str]: + """Available calculation engines.""" + return self._calculators_logic.available() + + @Property(int, notify=calculationEngineChanged) + def calculationEngineIndex(self) -> int: + """The project's active calculation engine.""" + return self._calculators_logic.current_index() + + @Property('QVariantList', notify=calculationEngineChanged) + def calculationEnginesSupportingMagnetism(self) -> list[str]: + """Engines that can model magnetic layers.""" + return self._calculators_logic.supporting_magnetism() + + @Slot(int) + def setCalculationEngineIndex(self, new_value: int) -> None: + """Switch the project's calculation engine from the Sample page.""" + try: + changed = self._calculators_logic.set_current_index(new_value) + except NotImplementedError as exception: + logger.warning('Cannot change the calculation engine: %s', exception) + self.calculationEngineRejected.emit(str(exception)) + self.calculationEngineChanged.emit() + return + if changed: + self.calculationEngineChanged.emit() + self.magnetismChanged.emit() + + # # # + # Layer magnetism + # # # + @Property(bool, notify=magnetismChanged) + def magnetismSupported(self) -> bool: + """Whether the active calculator can model magnetic layers (refl1d only).""" + return self._layers_logic.magnetism_supported + + @Property('QVariantList', notify=magnetismChanged) + def layersMagnetism(self) -> list[dict[str, str]]: + """Per-layer magnetism of the current assembly, one row per layer.""" + return self._layers_logic.magnetism + + @Slot(int, bool) + def setLayerMagneticAtIndex(self, index: int, new_value: bool) -> None: + """Attach or remove magnetism on one layer. + + Magnetism is only modelled by some calculation engines. Rather than + refusing and pointing at another page, ask the UI to confirm the switch + (`magnetismNeedsEngine`); nothing is changed until it comes back through + `enableMagnetismWithEngineAtIndex`. A calculator that cannot model + magnetism still reports the reason instead of raising out of a + QML-invoked slot (which would abort the process). + """ + if new_value and not self._layers_logic.magnetism_supported: + engines = self._calculators_logic.supporting_magnetism() + if engines: + self.magnetismNeedsEngine.emit(index, engines[0]) + return + try: + changed = self._layers_logic.set_magnetic_at_index(index, new_value) + except NotImplementedError as exception: + logger.warning('Cannot change layer magnetism: %s', exception) + self.magnetismFailed.emit(str(exception)) + return + if changed: + self._emitMagnetismChanged() + + @Slot(int, str) + def enableMagnetismWithEngineAtIndex(self, index: int, engine: str) -> None: + """Switch the calculation engine, then make the layer magnetic. + + The confirmed half of `magnetismNeedsEngine`: the user has been told + that the engine changes, so both steps happen as one action. If + attaching magnetism fails after the engine switch, the switch is + rolled back rather than left half-applied (engine changed, layer + still non-magnetic, with no indication to the user). + """ + engine_index = self._calculators_logic.index_of(engine) + if engine_index < 0: + self.magnetismFailed.emit(f'The {engine} calculation engine is not available.') + return + previous_calculator = self._project_lib.calculator + engine_switched = False + try: + engine_switched = self._calculators_logic.set_current_index(engine_index) + changed = self._layers_logic.set_magnetic_at_index(index, True) + except NotImplementedError as exception: + if engine_switched: + self._project_lib.calculator = previous_calculator + logger.warning('Cannot enable magnetism: %s', exception) + self.magnetismFailed.emit(str(exception)) + return + if engine_switched: + self.calculationEngineChanged.emit() + if changed: + self._emitMagnetismChanged() + + @Slot(int, float) + def setLayerRhoMAtIndex(self, index: int, new_value: float) -> None: + if self._layers_logic.set_rho_m_at_index(index, new_value): + self._emitMagnetismChanged() + + @Slot(int, float) + def setLayerThetaMAtIndex(self, index: int, new_value: float) -> None: + if self._layers_logic.set_theta_m_at_index(index, new_value): + self._emitMagnetismChanged() + + def _emitMagnetismChanged(self) -> None: + """Magnetism edits change the model, its parameters and every curve.""" + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() # # # # Constraints @@ -954,6 +1095,8 @@ def removeConstraintByIndex(self, index: int) -> None: 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() @@ -1049,6 +1192,8 @@ 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() @@ -1144,6 +1289,8 @@ def constrainModelsParameters(self, model_indices: list) -> None: 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() diff --git a/EasyReflectometryApp/Backends/Py/summary.py b/EasyReflectometryApp/Backends/Py/summary.py index 567743c8..d15ab2de 100644 --- a/EasyReflectometryApp/Backends/Py/summary.py +++ b/EasyReflectometryApp/Backends/Py/summary.py @@ -2,6 +2,10 @@ # SPDX-License-Identifier: BSD-3-Clause # © 2026 Contributors to the EasyApp project +import logging +from html import escape + +from EasyApplication.Logic.Logging import console from easyreflectometry import Project as ProjectLib from PySide6.QtCore import Property from PySide6.QtCore import QObject @@ -11,6 +15,8 @@ from .helpers import IO from .logic.summary import Summary as SummaryLogic +logger = logging.getLogger(__name__) + class Summary(QObject): createdChanged = Signal() @@ -67,7 +73,16 @@ def plotExportFormats(self): @Property(str, notify=summaryChanged) def asHtml(self): - return self._logic.as_html + # QML reads this property, so an exception here escapes into Qt's C++ + # signal delivery and aborts the process with no traceback (an access + # violation on Windows). Report the failure in the report itself + # instead: a broken summary must not take the application down. + try: + return self._logic.as_html + except Exception as exception: # noqa: BLE001 - never let the report kill the app + console.error(f'Failed to compile the HTML summary: {exception}') + logger.exception('Failed to compile the HTML summary') + return f'

The summary could not be generated

{escape(str(exception))}

' @Property('QVariant', notify=summaryChanged) def exportFormats(self): diff --git a/EasyReflectometryApp/Gui/ApplicationWindow.qml b/EasyReflectometryApp/Gui/ApplicationWindow.qml index e0f7cb09..13777efa 100644 --- a/EasyReflectometryApp/Gui/ApplicationWindow.qml +++ b/EasyReflectometryApp/Gui/ApplicationWindow.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls +import EasyApplication.Gui.Style as EaStyle import EasyApplication.Gui.Globals as EaGlobals import EasyApplication.Gui.Elements as EaElements import EasyApplication.Gui.Components as EaComponents @@ -206,6 +207,39 @@ EaComponents.ApplicationWindow { } } + // A refused calculation engine switch only reaches the log otherwise, + // which the GUI user cannot see. One dialog here rather than one per + // engine selector (Sample and Analysis pages), so it cannot stack. + Connections { + target: Globals.BackendWrapper.activeBackend ? Globals.BackendWrapper.activeBackend.sample : null + enabled: target !== null + ignoreUnknownSignals: true + function onCalculationEngineRejected(message) { + engineRejectionDialog.message = message + engineRejectionDialog.open() + } + } + + EaElements.Dialog { + id: engineRejectionDialog + + property string message: '' + + title: qsTr("Cannot change the calculation engine") + standardButtons: Dialog.Ok + closePolicy: Popup.CloseOnEscape + + // The Column caps the dialog at the label's wrapped width; a bare + // label would size the dialog to the unwrapped text instead. + Column { + EaElements.Label { + wrapMode: Text.WordWrap + width: EaStyle.Sizes.fontPixelSize * 26 + text: engineRejectionDialog.message + } + } + } + Component.onCompleted: { console.debug(`Application window loaded ::: ${this}`) if (Globals.BackendWrapper.testMode) { diff --git a/EasyReflectometryApp/Gui/CalculationEngineControl.qml b/EasyReflectometryApp/Gui/CalculationEngineControl.qml new file mode 100644 index 00000000..04071b69 --- /dev/null +++ b/EasyReflectometryApp/Gui/CalculationEngineControl.qml @@ -0,0 +1,43 @@ +// 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.Elements as EaElements + +import Gui.Globals as Globals + +// The project's calculation engine. Shown on the Analysis page (where fitting +// happens) and on the Sample page (where magnetism is edited, which only some +// engines can model). One backend property, so the two cannot disagree. +Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.ComboBox { + id: engineBox + + width: EaStyle.Sizes.sideBarContentWidth + model: Globals.BackendWrapper.sampleCalculationEngines + currentIndex: Globals.BackendWrapper.sampleCalculationEngineIndex + onActivated: { + Globals.BackendWrapper.sampleSetCalculationEngineIndex(currentIndex) + // The backend refuses an engine that cannot carry the sample's + // magnetism; follow its state rather than the click. + currentIndex = Qt.binding(function () { return Globals.BackendWrapper.sampleCalculationEngineIndex }) + } + } + + EaElements.Label { + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.WordWrap + width: EaStyle.Sizes.sideBarContentWidth + text: qsTr("Magnetic layers can only be modelled by %1.") + .arg(Globals.BackendWrapper.sampleCalculationEnginesSupportingMagnetism.join(', ')) + } + + // A refused switch is reported by a dialog in ApplicationWindow.qml: this + // control exists on two pages, and per-instance dialogs would stack. +} diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index d8fc7fba..e19196b4 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -140,6 +140,43 @@ QtObject { function sampleMoveSelectedModelUp() { activeBackend.sample.moveSelectedModelUp() } function sampleMoveSelectedModelDown() { activeBackend.sample.moveSelectedModelDown() } + // Calculation engine (project-wide; also exposed on the Analysis page) + readonly property var sampleCalculationEngines: { + try { + return activeBackend.sample.calculationEngines || [] + } catch (e) { + return [] + } + } + readonly property int sampleCalculationEngineIndex: { + try { + return activeBackend.sample.calculationEngineIndex || 0 + } catch (e) { + return 0 + } + } + readonly property var sampleCalculationEnginesSupportingMagnetism: { + try { + return activeBackend.sample.calculationEnginesSupportingMagnetism || [] + } catch (e) { + return [] + } + } + function sampleSetCalculationEngineIndex(value) { + try { + activeBackend.sample.setCalculationEngineIndex(value) + } catch (e) { + console.warn("sampleSetCalculationEngineIndex failed:", e) + } + } + function sampleEnableMagnetismWithEngineAtIndex(index, engine) { + try { + activeBackend.sample.enableMagnetismWithEngineAtIndex(index, engine) + } catch (e) { + console.warn("sampleEnableMagnetismWithEngineAtIndex failed:", e) + } + } + // Sample readonly property var sampleAssemblies: activeBackend.sample.assemblies readonly property string sampleCurrentAssemblyName: activeBackend.sample.currentAssemblyName @@ -192,6 +229,27 @@ QtObject { function sampleSetCurrentLayerSolvation(value) { activeBackend.sample.setCurrentLayerSolvation(value) } function sampleSetLayerSolvationAtIndex(index, value) { activeBackend.sample.setLayerSolvationAtIndex(index, value) } + // Layer magnetism (polarized analysis). Only the refl1d calculator can + // model magnetic layers, so the editor is gated on sampleMagnetismSupported. + readonly property bool sampleMagnetismSupported: { + try { + return activeBackend.sample.magnetismSupported || false + } catch (e) { + return false + } + } + readonly property var sampleLayersMagnetism: { + try { + return activeBackend.sample.layersMagnetism || [] + } catch (e) { + console.warn("sampleLayersMagnetism failed:", e) + return [] + } + } + function sampleSetLayerMagneticAtIndex(index, value) { activeBackend.sample.setLayerMagneticAtIndex(index, value) } + function sampleSetLayerRhoMAtIndex(index, value) { activeBackend.sample.setLayerRhoMAtIndex(index, value) } + function sampleSetLayerThetaMAtIndex(index, value) { activeBackend.sample.setLayerThetaMAtIndex(index, value) } + // Constraints readonly property var sampleEnabledParameterNames: activeBackend.sample.enabledParameterNames readonly property var sampleParameterNames: activeBackend.sample.parameterNames @@ -226,12 +284,42 @@ QtObject { readonly property var experimentResolution: activeBackend.experiment.resolution function experimentSetResolution(value) { activeBackend.experiment.setResolution(value) } function experimentLoad(value) { activeBackend.experiment.load(value) } + // Polarized experiment import (one file per spin channel) + function experimentSuggestPolarizedChannels(value) { return activeBackend.experiment.suggestPolarizedChannels(value) } + // Returns '' on success, or the reason the import was rejected. The backend + // validates the rows itself and raises, which would otherwise surface only + // as an uncaught slot exception. + function experimentLoadPolarized(value) { + try { + activeBackend.experiment.loadPolarized(value) + } catch (e) { + console.warn("experimentLoadPolarized failed:", e) + return (e && e.message) ? e.message : qsTr("The polarized experiment could not be loaded.") + } + return '' + } /////////////// // Analysis page /////////////// readonly property var analysisExperimentsAvailable: activeBackend.analysis.experimentsAvailable + readonly property var analysisExperimentsPolarized: { + try { + return activeBackend.analysis.experimentsPolarized || [] + } catch (e) { + return [] + } + } + // Measured spin channels per experiment (0 when unpolarized), shown next to + // the polarization badge in the experiment lists. + readonly property var analysisExperimentsChannelCount: { + try { + return activeBackend.analysis.experimentsChannelCount || [] + } catch (e) { + return [] + } + } readonly property int analysisExperimentsCurrentIndex: activeBackend.analysis.experimentCurrentIndex function analysisSetExperimentsCurrentIndex(value) { activeBackend.analysis.setExperimentCurrentIndex(value) } function analysisRemoveExperiment(value) { activeBackend.analysis.removeExperiment(value) } @@ -537,6 +625,32 @@ QtObject { return [] } } + // One-call series fills (false = backend cannot fill, caller falls back + // to an append() loop — e.g. the mock backend). + function plottingFillSampleSeriesForModel(series, index) { + try { + activeBackend.plotting.fillSampleSeriesForModel(series, index) + return true + } catch (e) { + return false + } + } + function plottingFillSldSeriesForModel(series, index) { + try { + activeBackend.plotting.fillSldSeriesForModel(series, index) + return true + } catch (e) { + return false + } + } + function plottingFillMagneticSldSegmentSeries(series, index, curve, segment) { + try { + activeBackend.plotting.fillMagneticSldSegmentSeries(series, index, curve, segment) + return true + } catch (e) { + return false + } + } function plottingGetModelColor(index) { try { return activeBackend.plotting.getModelColor(index) @@ -558,6 +672,10 @@ QtObject { signal posteriorPredictiveDataChanged() // Signal for posterior predictive SLD (Bayesian) overlay data updates signal posteriorPredictiveSldDataChanged() + // Magnetic profile curves / magnetic state of the models changed + signal magneticProfileChanged() + // Spin-asymmetry availability or content changed + signal spinAsymmetryChanged() // Connect to backend signal (called from Component.onCompleted in QML items) function connectSamplePageDataChanged() { @@ -579,6 +697,12 @@ QtObject { if (activeBackend && activeBackend.plotting && activeBackend.plotting.posteriorPredictiveSldDataChanged) { activeBackend.plotting.posteriorPredictiveSldDataChanged.connect(posteriorPredictiveSldDataChanged) } + if (activeBackend && activeBackend.plotting && activeBackend.plotting.magneticProfileChanged) { + activeBackend.plotting.magneticProfileChanged.connect(magneticProfileChanged) + } + if (activeBackend && activeBackend.plotting && activeBackend.plotting.spinAsymmetryChanged) { + activeBackend.plotting.spinAsymmetryChanged.connect(spinAsymmetryChanged) + } } Component.onCompleted: { @@ -602,6 +726,16 @@ QtObject { return [] } } + // Same list, but a polarized experiment appears once per visible spin + // channel — for charts that draw per-channel series. + readonly property var plottingIndividualExperimentChannelDataList: { + try { + return activeBackend.plottingIndividualExperimentChannelDataList || [] + } catch (e) { + console.warn("plottingIndividualExperimentChannelDataList failed:", e) + return [] + } + } function plottingGetExperimentDataPoints(index) { try { return activeBackend.plottingGetExperimentDataPoints(index) @@ -610,22 +744,229 @@ QtObject { return [] } } - function plottingGetAnalysisDataPoints(index) { + // `channel` is optional: pass a spin channel ('pp'/'pm'/'mp'/'mm') to get + // that cross-section of a polarized experiment, '' for the ordinary curve. + function plottingGetAnalysisDataPoints(index, channel) { try { - return activeBackend.plottingGetAnalysisDataPoints(index) + return activeBackend.plottingGetAnalysisDataPoints(index, channel || "") } catch (e) { console.warn("plottingGetAnalysisDataPoints failed:", e) return [] } } - function plottingGetResidualDataPoints(index) { + function plottingGetResidualDataPoints(index, channel) { try { - return activeBackend.plottingGetResidualDataPoints(index) + return activeBackend.plottingGetResidualDataPoints(index, channel || "") } catch (e) { console.warn("plottingGetResidualDataPoints failed:", e) return [] } } + // True when the analysis/residual charts must draw one series per channel. + readonly property bool plottingAnalysisUsesChannelSeries: { + try { + return activeBackend.plottingAnalysisUsesChannelSeries || false + } catch (e) { + return false + } + } + + // Polarized experiment (spin channel) plotting support + readonly property bool plottingCurrentExperimentIsPolarized: { + try { + return activeBackend.plotting.currentExperimentIsPolarized || false + } catch (e) { + console.warn("plottingCurrentExperimentIsPolarized failed:", e) + return false + } + } + readonly property var plottingExperimentChannelList: { + try { + return activeBackend.plotting.experimentChannelList || [] + } catch (e) { + console.warn("plottingExperimentChannelList failed:", e) + return [] + } + } + function plottingGetExperimentChannels(index) { + try { + return activeBackend.plottingGetExperimentChannels(index) + } catch (e) { + console.warn("plottingGetExperimentChannels failed:", e) + return [] + } + } + function plottingGetExperimentChannelDataPoints(index, channel) { + try { + return activeBackend.plottingGetExperimentChannelDataPoints(index, channel) + } catch (e) { + console.warn("plottingGetExperimentChannelDataPoints failed:", e) + return [] + } + } + function plottingSetChannelVisible(channel, visible) { + try { + activeBackend.plottingSetChannelVisible(channel, visible) + } catch (e) { + console.warn("plottingSetChannelVisible failed:", e) + } + } + + // Magnetic depth profiles on the shared SLD chart (Sample and Analysis) + readonly property bool plottingAnyModelHasMagnetism: { + try { + return activeBackend.plotting.anyModelHasMagnetism || false + } catch (e) { + return false + } + } + readonly property var plottingVisibleSldCurves: { + try { + return activeBackend.plotting.visibleSldCurves || [] + } catch (e) { + return [] + } + } + readonly property string plottingMagneticProfileError: { + try { + return activeBackend.plotting.magneticProfileError || '' + } catch (e) { + return '' + } + } + readonly property var plottingSldThetaMinY: { + try { + return activeBackend.plotting.sldThetaMinY + } catch (e) { + return 0 + } + } + readonly property var plottingSldThetaMaxY: { + try { + return activeBackend.plotting.sldThetaMaxY + } catch (e) { + return 360 + } + } + function plottingModelHasMagnetism(index) { + try { + return activeBackend.plottingModelHasMagnetism(index) + } catch (e) { + return false + } + } + function plottingGetMagneticSldDataPointsForModel(index, curve) { + try { + return activeBackend.plottingGetMagneticSldDataPointsForModel(index, curve) + } catch (e) { + console.warn("plottingGetMagneticSldDataPointsForModel failed:", e) + return [] + } + } + function plottingGetMagneticSldSegmentsForModel(index, curve) { + try { + return activeBackend.plottingGetMagneticSldSegmentsForModel(index, curve) + } catch (e) { + console.warn("plottingGetMagneticSldSegmentsForModel failed:", e) + return [] + } + } + function plottingGetMagneticSldSegment(index, curve, segment) { + try { + return activeBackend.plottingGetMagneticSldSegment(index, curve, segment) + } catch (e) { + console.warn("plottingGetMagneticSldSegment failed:", e) + return [] + } + } + function plottingSldCurveVisible(curve) { + try { + return activeBackend.plottingSldCurveVisible(curve) + } catch (e) { + return false + } + } + function plottingSetSldCurveVisible(curve, visible) { + try { + activeBackend.plottingSetSldCurveVisible(curve, visible) + } catch (e) { + console.warn("plottingSetSldCurveVisible failed:", e) + } + } + + // Spin asymmetry + readonly property bool plottingSpinAsymmetryAvailable: { + try { + return activeBackend.plotting.spinAsymmetryAvailable || false + } catch (e) { + return false + } + } + readonly property bool plottingSpinAsymmetryCalculatedAvailable: { + try { + return activeBackend.plotting.spinAsymmetryCalculatedAvailable || false + } catch (e) { + return false + } + } + readonly property int plottingSpinAsymmetryMaskedPoints: { + try { + return activeBackend.plotting.spinAsymmetryMaskedPoints || 0 + } catch (e) { + return 0 + } + } + readonly property int plottingSpinAsymmetryOutOfOverlapPoints: { + try { + return activeBackend.plotting.spinAsymmetryOutOfOverlapPoints || 0 + } catch (e) { + return 0 + } + } + readonly property var plottingSpinAsymmetryMinX: { + try { + return activeBackend.plotting.spinAsymmetryMinX + } catch (e) { + return 0 + } + } + readonly property var plottingSpinAsymmetryMaxX: { + try { + return activeBackend.plotting.spinAsymmetryMaxX + } catch (e) { + return 1 + } + } + readonly property var plottingSpinAsymmetryMinY: { + try { + return activeBackend.plotting.spinAsymmetryMinY + } catch (e) { + return -1 + } + } + readonly property var plottingSpinAsymmetryMaxY: { + try { + return activeBackend.plotting.spinAsymmetryMaxY + } catch (e) { + return 1 + } + } + function plottingGetSpinAsymmetryPoints(index) { + try { + return activeBackend.plottingGetSpinAsymmetryPoints(index) + } catch (e) { + console.warn("plottingGetSpinAsymmetryPoints failed:", e) + return [] + } + } + function plottingGetSpinAsymmetryCalculatedPoints(index) { + try { + return activeBackend.plottingGetSpinAsymmetryCalculatedPoints(index) + } catch (e) { + console.warn("plottingGetSpinAsymmetryCalculatedPoints failed:", e) + return [] + } + } // Bayesian sampling progress readonly property int analysisSampleProgressStep: activeBackend.analysis.sampleProgressStep diff --git a/EasyReflectometryApp/Gui/Globals/References.qml b/EasyReflectometryApp/Gui/Globals/References.qml index 1133778a..e8d30935 100644 --- a/EasyReflectometryApp/Gui/Globals/References.qml +++ b/EasyReflectometryApp/Gui/Globals/References.qml @@ -49,6 +49,8 @@ QtObject { 'basic': { 'popups': { 'loadExperimentFileDialog': null, + 'loadPolarizedExperimentFilesDialog': null, + 'polarizedChannelAssignmentDialog': null, } } } diff --git a/EasyReflectometryApp/Gui/MagneticProfileControl.qml b/EasyReflectometryApp/Gui/MagneticProfileControl.qml new file mode 100644 index 00000000..6f6b19bd --- /dev/null +++ b/EasyReflectometryApp/Gui/MagneticProfileControl.qml @@ -0,0 +1,62 @@ +// 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.Elements as EaElements + +import Gui.Globals as Globals + +// Which magnetic depth profiles the shared SLD chart draws. One control, one +// piece of state in the backend, so the Sample and Analysis SLD tabs always +// agree. Only meaningful once a layer is magnetic; the groups embedding this +// component hide themselves otherwise. +Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingVisibleSldCurves.indexOf('spin_up') !== -1 + text: qsTr("Show ρ↑ and ρ↓") + ToolTip.text: qsTr("ρ ± ρM·cos(θM − A) — the effective SLD each spin state sees") + onToggled: Globals.BackendWrapper.plottingSetSldCurveVisible('spin_up', checked) + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingVisibleSldCurves.indexOf('rho_m') !== -1 + text: qsTr("Show ρM") + ToolTip.text: qsTr("Magnetic SLD profile") + onToggled: Globals.BackendWrapper.plottingSetSldCurveVisible('rho_m', checked) + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingVisibleSldCurves.indexOf('theta_m') !== -1 + text: qsTr("Show θM") + ToolTip.text: qsTr("In-plane moment angle, plotted on the right-hand axis") + onToggled: Globals.BackendWrapper.plottingSetSldCurveVisible('theta_m', checked) + } + + EaElements.Label { + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.WordWrap + width: EaStyle.Sizes.sideBarContentWidth + text: qsTr("For non-magnetic layers, ρ↑ and ρ↓ collapse onto the nuclear SLD.") + } + + // The magnetic profiles failed to compute (e.g. the calculator lost its + // magnetism binding): without this, the curves silently vanish and the + // reason is only in a log the GUI user cannot see. + EaElements.Label { + visible: Globals.BackendWrapper.plottingMagneticProfileError !== '' + color: EaStyle.Colors.red + wrapMode: Text.WordWrap + width: EaStyle.Sizes.sideBarContentWidth + text: qsTr("The magnetic profiles could not be computed: %1") + .arg(Globals.BackendWrapper.plottingMagneticProfileError) + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml deleted file mode 100644 index 74baab0f..00000000 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ /dev/null @@ -1,775 +0,0 @@ -// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors -// SPDX-License-Identifier: BSD-3-Clause -// © 2026 Contributors to the EasyReflectometry project - -import QtQuick -import QtQuick.Controls -import QtCharts - -import EasyApplication.Gui.Style as EaStyle -import EasyApplication.Gui.Globals as EaGlobals -import EasyApplication.Gui.Elements as EaElements -import EasyApplication.Gui.Charts as EaCharts - -import Gui.Globals as Globals -import "../../../Logic/MeasuredScatter.js" as MeasuredScatter - - -Rectangle { - id: container - - color: EaStyle.Colors.chartBackground - EaCharts.QtCharts1dMeasVsCalc { - id: chartView - - property alias calculated: chartView.calcSerie - property alias measured: chartView.measSerie - bkgSerie.color: measSerie.color - measSerie.color: Globals.Variables.experimentColor( - Globals.BackendWrapper.analysisExperimentsCurrentIndex - ) - measSerie.width: 2 - measSerie.opacity: 0.95 - measSerie.style: Qt.DotLine - bkgSerie.width: 1 - bkgSerie.style: Qt.DotLine - - // Track current experiment color for scatter series - property color currentExperimentColor: Globals.Variables.experimentColor( - Globals.BackendWrapper.analysisExperimentsCurrentIndex - ) - onCurrentExperimentColorChanged: MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) - - anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 - - useOpenGL: EaGlobals.Vars.useOpenGL - - // Disable built-in Qt Charts legend - we use our custom legend instead - legend.visible: false - - // Background reference line series - LineSeries { - id: backgroundRefLine - axisX: chartView.axisX - axisY: chartView.axisY - useOpenGL: chartView.useOpenGL - color: "#888888" - width: 1 - style: Qt.DashLine - visible: Globals.BackendWrapper.plottingBkgShown - } - - // Scale reference line series - LineSeries { - id: scaleRefLine - axisX: chartView.axisX - axisY: chartView.axisY - useOpenGL: chartView.useOpenGL - color: "#666666" - width: 1 - style: Qt.DotLine - visible: Globals.BackendWrapper.plottingScaleShown - } - - // Update reference lines when visibility changes - Connections { - target: Globals.BackendWrapper.activeBackend?.plotting ?? null - enabled: target !== null - function onReferenceLineVisibilityChanged() { - chartView.updateReferenceLines() - } - } - - function updateReferenceLines() { - Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, true) - } - - // Posterior predictive overlay (Bayesian) - LineSeries { - id: ppUpperSerie - axisX: chartView.currentXAxis() - axisY: chartView.axisY - visible: Globals.BackendWrapper.bayesianResultAvailable - } - - LineSeries { - id: ppLowerSerie - axisX: chartView.currentXAxis() - axisY: chartView.axisY - visible: Globals.BackendWrapper.bayesianResultAvailable - } - - LineSeries { - id: ppMedianSerie - name: qsTr("Posterior median") - axisX: chartView.currentXAxis() - axisY: chartView.axisY - color: "#E67E22" - width: 5 - visible: Globals.BackendWrapper.bayesianResultAvailable - } - - AreaSeries { - id: ppBandSerie - name: qsTr("95% credible interval") - axisX: chartView.currentXAxis() - axisY: chartView.axisY - color: Qt.rgba(0.902, 0.494, 0.133, 0.25) // orange with alpha - borderWidth: 0 - upperSeries: ppUpperSerie - lowerSeries: ppLowerSerie - visible: Globals.BackendWrapper.bayesianResultAvailable - } - - Connections { - target: Globals.BackendWrapper - function onPosteriorPredictiveDataChanged() { - chartView.refreshPosteriorPredictiveOverlay() - } - } - - function refreshPosteriorPredictiveOverlay() { - ppMedianSerie.clear() - ppUpperSerie.clear() - ppLowerSerie.clear() - const q = Globals.BackendWrapper.posteriorPredictiveQ - const m = Globals.BackendWrapper.posteriorPredictiveMedian - const lo = Globals.BackendWrapper.posteriorPredictiveLower - const hi = Globals.BackendWrapper.posteriorPredictiveUpper - console.log("AnalysisView.refreshPosteriorPredictiveOverlay: q.length=" + (q ? q.length : "null") - + " m.length=" + (m ? m.length : "null") - + " bayesianResultAvailable=" + Globals.BackendWrapper.bayesianResultAvailable) - if (!q || !m || !lo || !hi) { - console.warn("AnalysisView.refreshPosteriorPredictiveOverlay: posterior data is null/empty") - return - } - if (q.length === 0) { - console.warn("AnalysisView.refreshPosteriorPredictiveOverlay: posterior data arrays are empty") - return - } - for (let i = 0; i < q.length; ++i) { - ppMedianSerie.append(q[i], m[i]) - ppLowerSerie.append(q[i], lo[i]) - ppUpperSerie.append(q[i], hi[i]) - } - console.log("AnalysisView.refreshPosteriorPredictiveOverlay: appended " + q.length + " points") - } - - // Scatter series for measured data (single experiment, linear mode) - property var measuredScatterSerie: null - - // Multi-experiment support - property var multiExperimentSeries: [] - property bool isMultiExperimentMode: { - try { - return Globals.BackendWrapper.plottingIsMultiExperimentMode || false - } catch (e) { - return false - } - } - - // Watch for changes in multi-experiment mode property - onIsMultiExperimentModeChanged: { - console.log("Analysis: isMultiExperimentMode changed to: " + isMultiExperimentMode) - updateMultiExperimentSeries() - } - - // Watch for changes in multi-experiment selection - Connections { - target: Globals.BackendWrapper.activeBackend ?? null - enabled: target !== null - function onMultiExperimentSelectionChanged() { - console.log("Analysis: Multi-experiment selection changed - updating series") - chartView.updateMultiExperimentSeries() - } - } - - // Watch for plot mode changes (R(q)×q⁴ toggle) - Connections { - target: Globals.BackendWrapper - function onPlotModeChanged() { - console.debug("AnalysisView: Plot mode changed, refreshing chart") - Globals.BackendWrapper.plottingRefreshAnalysis() - // Delay resetAxes to allow axis range properties to update first - analysisResetAxesTimer.start() - } - function onChartAxesResetRequested() { - // Reset axes when model is loaded (e.g., from ORSO file) - analysisResetAxesTimer.start() - } - function onSamplePageResetAxes() { - analysisResetAxesTimer.start() - } - } - - // Recreate series when marker style changes - Connections { - target: Globals.Variables - function onExperimentMarkerStyleChanged() { - chartView.recreateSeriesForCurrentMode() - } - } - - Timer { - id: analysisResetAxesTimer - interval: 75 - repeat: false - onTriggered: chartView.resetAxes() - } - - property double xRange: Globals.BackendWrapper.plottingAnalysisMaxX - Globals.BackendWrapper.plottingAnalysisMinX - axisX.title: "q (Å⁻¹)" - axisX.min: Globals.BackendWrapper.plottingAnalysisMinX - xRange * 0.01 - axisX.max: Globals.BackendWrapper.plottingAnalysisMaxX + xRange * 0.01 - axisX.minAfterReset: Globals.BackendWrapper.plottingAnalysisMinX - xRange * 0.01 - axisX.maxAfterReset: Globals.BackendWrapper.plottingAnalysisMaxX + xRange * 0.01 - - // Logarithmic axis control - property bool useLogQAxis: Globals.Variables.logarithmicQAxis - axisX.visible: !useLogQAxis - - LogValueAxis { - id: axisXLog - visible: chartView.useLogQAxis - titleText: "q (Å⁻¹)" - property double minAfterReset: Math.max(Globals.BackendWrapper.plottingAnalysisMinX, 1e-6) - property double maxAfterReset: Globals.BackendWrapper.plottingAnalysisMaxX * 1.1 - base: 10 - color: EaStyle.Colors.chartAxis - gridLineColor: EaStyle.Colors.chartGridLine - minorGridLineColor: EaStyle.Colors.chartMinorGridLine - labelsColor: EaStyle.Colors.chartLabels - titleBrush: EaStyle.Colors.chartLabels - Component.onCompleted: { - min = minAfterReset - max = maxAfterReset - } - } - - // Dynamic series for log mode (single experiment) - property var logModeSeries: null - - function currentXAxis() { - return useLogQAxis ? axisXLog : chartView.axisX - } - - onUseLogQAxisChanged: { - recreateForLogMode() - } - - function recreateForLogMode() { - // Clean up previous log mode series - if (logModeSeries) { - chartView.removeSeries(logModeSeries.measuredSerie) - chartView.removeSeries(logModeSeries.calculatedSerie) - logModeSeries = null - } - - if (isMultiExperimentMode) { - // Multi-experiment mode: recreate all with the correct axis - updateMultiExperimentSeries() - } else if (useLogQAxis) { - // Single experiment, log mode: create dynamic series on log axis - measured.visible = false - if (measuredScatterSerie) measuredScatterSerie.visible = false - calculated.visible = false - - var newMeasured = MeasuredScatter.create(chartView, ChartView, ScatterSeries, - "measured_log", axisXLog, chartView.axisY, - measured.color, Globals.Variables.experimentMarkerStyle) - - var newCalculated = chartView.createSeries(ChartView.SeriesTypeLine, "calculated_log", axisXLog, chartView.axisY) - newCalculated.color = calculated.color - newCalculated.width = calculated.width - newCalculated.useOpenGL = chartView.useOpenGL - - logModeSeries = { - measuredSerie: newMeasured, - calculatedSerie: newCalculated - } - - // Register new series with backend and refresh - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', newMeasured) - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', newCalculated) - Globals.BackendWrapper.plottingRefreshAnalysis() - } else { - // Single experiment, linear mode: restore scatter series - measured.visible = false - if (!measuredScatterSerie) { - console.warn("AnalysisView.recreateForLogMode: measuredScatterSerie is null - linear mode will render no measured points") - } else { - measuredScatterSerie.visible = true - } - calculated.visible = true - - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) - Globals.BackendWrapper.plottingRefreshAnalysis() - } - - updateReferenceLines() - Qt.callLater(resetAxes) - Qt.callLater(refreshPosteriorPredictiveOverlay) - } - - function resetAxes() { - if (useLogQAxis) { - if (axisXLog) { - axisXLog.min = axisXLog.minAfterReset - axisXLog.max = axisXLog.maxAfterReset - } - } else { - if (chartView.axisX) { - chartView.axisX.min = chartView.axisX.minAfterReset - chartView.axisX.max = chartView.axisX.maxAfterReset - } - } - if (chartView.axisY) { - chartView.axisY.min = chartView.axisY.minAfterReset - chartView.axisY.max = chartView.axisY.maxAfterReset - } - } - - property double yRange: Globals.BackendWrapper.plottingAnalysisMaxY - Globals.BackendWrapper.plottingAnalysisMinY - axisY.title: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle - axisY.min: Globals.BackendWrapper.plottingAnalysisMinY - yRange * 0.01 - axisY.max: Globals.BackendWrapper.plottingAnalysisMaxY + yRange * 0.01 - axisY.minAfterReset: Globals.BackendWrapper.plottingAnalysisMinY - yRange * 0.01 - axisY.maxAfterReset: Globals.BackendWrapper.plottingAnalysisMaxY + yRange * 0.01 - - calcSerie.onHovered: (point, state) => showMainTooltip(chartView, point, state) - calcSerie.color: { - const colors = Globals.BackendWrapper.modelColorsForExperiment - const idx = Globals.BackendWrapper.analysisExperimentsCurrentIndex - - if (colors && idx >= 0 && idx < colors.length) { - return colors[idx] - } - - return undefined - } - - // Multi-experiment series management - function updateMultiExperimentSeries() { - console.log("Analysis: updateMultiExperimentSeries called, isMultiExperimentMode=" + isMultiExperimentMode) - - // Clear existing multi-experiment series - clearMultiExperimentSeries() - - if (!isMultiExperimentMode) { - // Show default series for single experiment - console.log("Analysis: Single experiment mode - showing default series") - measured.visible = false - if (!measuredScatterSerie) { - console.warn("AnalysisView.updateMultiExperimentSeries: measuredScatterSerie is null - single mode will render no measured points") - } else { - measuredScatterSerie.visible = true - MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) - } - calculated.visible = true - - // Re-register scatter series and refresh data - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) - Globals.BackendWrapper.plottingRefreshAnalysis() - return - } - - // Get experiment data list - var experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentDataList - console.log("Analysis: experimentDataList length=" + experimentDataList.length) - - // If no data available yet, keep default series visible as fallback - if (experimentDataList.length === 0) { - console.log("Analysis: No experiment data available - keeping default series visible") - measured.visible = false - if (!measuredScatterSerie) { - console.warn("AnalysisView.updateMultiExperimentSeries: measuredScatterSerie is null - no-data fallback will render no measured points") - } else { - measuredScatterSerie.visible = true - } - calculated.visible = true - - // Re-register the scatter series and refresh so the chart - // matches what the single-experiment branch above does. - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) - Globals.BackendWrapper.plottingRefreshAnalysis() - return - } - - // Hide default series in multi-experiment mode (only after we have data) - measured.visible = false - if (measuredScatterSerie) measuredScatterSerie.visible = false - calculated.visible = false - console.log("Analysis: Hidden default series, creating " + experimentDataList.length + " experiment series") - - // Create series for each experiment - for (var i = 0; i < experimentDataList.length; i++) { - var expData = experimentDataList[i] - console.log("Analysis: Creating series for experiment " + expData.index + " (" + expData.name + ") with color " + expData.color) - if (expData.hasData) { - createExperimentSeries(expData.index, expData.name, expData.color) - } - } - } - - function clearMultiExperimentSeries() { - // Remove all dynamically created series - for (var i = 0; i < multiExperimentSeries.length; i++) { - var seriesSet = multiExperimentSeries[i] - if (seriesSet.measuredSerie) { - chartView.removeSeries(seriesSet.measuredSerie) - } - if (seriesSet.calculatedSerie) { - chartView.removeSeries(seriesSet.calculatedSerie) - } - } - multiExperimentSeries = [] - } - - function createExperimentSeries(expIndex, expName, color) { - var xAxis = currentXAxis() - - // Look up the model color for this experiment - var modelColors = Globals.BackendWrapper.modelColorsForExperiment - var modelColor = (modelColors && expIndex >= 0 && expIndex < modelColors.length) - ? modelColors[expIndex] - : color - - // Create measured data series (scatter points) - var measuredSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, - `${expName} - Measured`, - xAxis, chartView.axisY, - color, Globals.Variables.experimentMarkerStyle) - - // Create calculated data series using the model's own color - var calculatedSerie = chartView.createSeries(ChartView.SeriesTypeLine, - `${expName} - Calculated`, - xAxis, chartView.axisY) - calculatedSerie.color = modelColor - calculatedSerie.width = 2 - calculatedSerie.capStyle = Qt.RoundCap - calculatedSerie.useOpenGL = chartView.useOpenGL - - // Store references - var seriesSet = { - measuredSerie: measuredSerie, - calculatedSerie: calculatedSerie, - expIndex: expIndex, - expName: expName, - color: color - } - multiExperimentSeries.push(seriesSet) - - // Populate with data - populateExperimentSeries(seriesSet) - } - - function populateExperimentSeries(seriesSet) { - // Get data points from backend (includes both measured and calculated) - var dataPoints = Globals.BackendWrapper.plottingGetAnalysisDataPoints(seriesSet.expIndex) - console.log("Analysis: populateExperimentSeries for exp " + seriesSet.expIndex + " got " + dataPoints.length + " points") - - // Clear existing points - seriesSet.measuredSerie.clear() - seriesSet.calculatedSerie.clear() - - // Add data points - for (var i = 0; i < dataPoints.length; i++) { - var point = dataPoints[i] - seriesSet.measuredSerie.append(point.x, point.measured) - seriesSet.calculatedSerie.append(point.x, point.calculated) - } - - console.log("Analysis: Added " + dataPoints.length + " points to series for " + seriesSet.expName) - } - - // Tool buttons - Row { - id: toolButtons - - x: chartView.plotArea.x + chartView.plotArea.width - width - y: chartView.plotArea.y - height - EaStyle.Sizes.fontPixelSize - - spacing: 0.25 * EaStyle.Sizes.fontPixelSize - - EaElements.TabButton { - checked: Globals.Variables.showLegendOnAnalysisPage - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "align-left" - ToolTip.text: Globals.Variables.showLegendOnAnalysisPage ? - qsTr("Hide legend") : - qsTr("Show legend") - onClicked: Globals.Variables.showLegendOnAnalysisPage = checked - } - - EaElements.TabButton { - checked: chartView.allowHover - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "comment-alt" - ToolTip.text: qsTr("Show coordinates tooltip on hover") - onClicked: chartView.allowHover = !chartView.allowHover - } - - Item { height: 1; width: 0.5 * EaStyle.Sizes.fontPixelSize } // spacer - - EaElements.TabButton { - checked: !chartView.allowZoom - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "arrows-alt" - ToolTip.text: qsTr("Enable pan") - onClicked: chartView.allowZoom = !chartView.allowZoom - } - - EaElements.TabButton { - checked: chartView.allowZoom - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "expand" - ToolTip.text: qsTr("Enable box zoom") - onClicked: chartView.allowZoom = !chartView.allowZoom - } - - EaElements.TabButton { - checkable: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "home" - ToolTip.text: qsTr("Reset axes") - onClicked: chartView.resetAxes() - } - - } - // Tool buttons - - // Legend - Rectangle { - visible: Globals.Variables.showLegendOnAnalysisPage - - x: chartView.plotArea.x + chartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize - y: chartView.plotArea.y + EaStyle.Sizes.fontPixelSize - width: childrenRect.width - height: childrenRect.height - - color: EaStyle.Colors.mainContentBackgroundHalfTransparent - border.color: EaStyle.Colors.chartGridLine - - Column { - leftPadding: EaStyle.Sizes.fontPixelSize - rightPadding: EaStyle.Sizes.fontPixelSize - topPadding: EaStyle.Sizes.fontPixelSize * 0.5 - bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 - spacing: EaStyle.Sizes.fontPixelSize * 0.25 - - // Single experiment legend - EaElements.Label { - visible: !chartView.isMultiExperimentMode - text: Globals.Variables.lineStyleSymbol(chartView.measSerie.style) + ' I (Measured)' - color: chartView.measSerie.color - } - EaElements.Label { - visible: !chartView.isMultiExperimentMode - text: Globals.Variables.lineStyleSymbol(chartView.calcSerie.style) + ' (Calculated)' - color: chartView.calcSerie.color - } - - // Bayesian posterior predictive legend - Row { - visible: !chartView.isMultiExperimentMode && Globals.BackendWrapper.bayesianResultAvailable - spacing: EaStyle.Sizes.fontPixelSize * 0.3 - Rectangle { - width: EaStyle.Sizes.fontPixelSize * 1.2 - height: 2 - color: "#E67E22" - anchors.verticalCenter: parent.verticalCenter - } - EaElements.Label { - text: qsTr("Posterior median") - color: EaStyle.Colors.themeForegroundMinor - } - } - Row { - visible: !chartView.isMultiExperimentMode && Globals.BackendWrapper.bayesianResultAvailable - spacing: EaStyle.Sizes.fontPixelSize * 0.3 - Rectangle { - width: EaStyle.Sizes.fontPixelSize * 1.2 - height: EaStyle.Sizes.fontPixelSize * 0.6 - color: Qt.rgba(0.902, 0.494, 0.133, 0.25) - anchors.verticalCenter: parent.verticalCenter - } - EaElements.Label { - text: qsTr("95% credible interval") - color: EaStyle.Colors.themeForegroundMinor - } - } - - // Multi-experiment legend - Column { - visible: chartView.isMultiExperimentMode - spacing: EaStyle.Sizes.fontPixelSize * 0.2 - - EaElements.Label { - text: qsTr("Multi-experiment view:") - font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 - font.bold: true - color: EaStyle.Colors.themeForeground - } - - Repeater { - model: chartView.isMultiExperimentMode ? Globals.BackendWrapper.plottingIndividualExperimentDataList : [] - delegate: Row { - spacing: EaStyle.Sizes.fontPixelSize * 0.3 - - Rectangle { - width: EaStyle.Sizes.fontPixelSize * 0.8 - height: 3 - color: modelData.color || "#1f77b4" - anchors.verticalCenter: parent.verticalCenter - } - - EaElements.Label { - text: modelData.name || `Exp ${index + 1}` - font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.8 - color: EaStyle.Colors.themeForeground - anchors.verticalCenter: parent.verticalCenter - } - } - } - - Rectangle { - width: parent.width - 2 * EaStyle.Sizes.fontPixelSize - height: 1 - color: EaStyle.Colors.chartGridLine - } - - EaElements.Label { - text: Globals.Variables.lineStyleSymbol(chartView.measSerie.style) + ' ' + qsTr("Measured") - font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 - color: EaStyle.Colors.themeForegroundMinor - } - EaElements.Label { - text: Globals.Variables.lineStyleSymbol(chartView.calcSerie.style) + ' ' + qsTr("Calculated") - font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 - color: EaStyle.Colors.themeForegroundMinor - } - } - } - } - // Legend - - EaElements.ToolTip { - id: dataToolTip - - arrowLength: 0 - textFormat: Text.RichText - } - - function recreateSeriesForCurrentMode() { - if (isMultiExperimentMode) { - // Multi-experiment mode: recreate all multi-experiment series - updateMultiExperimentSeries() - } else if (useLogQAxis) { - // Single experiment, log mode: recreate log mode series - recreateForLogMode() - } else { - // Single experiment, linear mode: recreate scatter series - if (measuredScatterSerie) { - chartView.removeSeries(measuredScatterSerie) - measuredScatterSerie = null - } - measuredScatterSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, - "measured_scatter", - chartView.axisX, chartView.axisY, - measured.color, Globals.Variables.experimentMarkerStyle) - if (measuredScatterSerie) { - measuredScatterSerie.visible = true - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) - Globals.BackendWrapper.plottingRefreshAnalysis() - } - } - Qt.callLater(refreshPosteriorPredictiveOverlay) - } - - // Data is set in python backend (plotting_1d.py) - Component.onCompleted: { - // Create scatter series for measured data (single experiment, linear mode) - measuredScatterSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, - "measured_scatter", - chartView.axisX, chartView.axisY, - measured.color, Globals.Variables.experimentMarkerStyle) - if (!measuredScatterSerie) { - console.warn("AnalysisView: failed to create measuredScatterSerie - measured data will not render") - } - measured.visible = false - - Globals.References.pages.analysis.mainContent.analysisView = chartView - - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', - 'measuredSerie', - measuredScatterSerie) - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', - 'calculatedSerie', - calculated) - Globals.BackendWrapper.plottingRefreshAnalysis() - - // Initialize multi-experiment support - updateMultiExperimentSeries() - - // Initialize reference lines - updateReferenceLines() - - // Initialize posterior predictive overlay - refreshPosteriorPredictiveOverlay() - } - - // Update series when chart becomes visible - onVisibleChanged: { - if (visible) { - if (isMultiExperimentMode) { - updateMultiExperimentSeries() - } else { - // Ensure scatter series has correct color and data after tab switch - if (!measuredScatterSerie) { - console.warn("AnalysisView.onVisibleChanged: measuredScatterSerie is null - tab switch will render no measured points") - } else { - MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) - measuredScatterSerie.visible = true - } - measured.visible = false - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) - Globals.BackendWrapper.plottingRefreshAnalysis() - } - updateReferenceLines() - refreshPosteriorPredictiveOverlay() - } - } - } - - // Logic - - function showMainTooltip(chart, point, state) { - if (!chartView.allowHover) { - return - } - const pos = chart.mapToPosition(Qt.point(point.x, point.y)) - dataToolTip.x = pos.x - dataToolTip.y = pos.y - dataToolTip.text = `

x: ${point.x.toFixed(3)}y: ${point.y.toFixed(3)}

` - dataToolTip.parent = chart - dataToolTip.visible = state - } -} diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianPosteriorView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianPosteriorView.qml index db8d43dd..e4c5f8a7 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianPosteriorView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianPosteriorView.qml @@ -46,33 +46,33 @@ Rectangle { } // Subtab bar - TabBar { + EaElements.TabBar { id: subtabBar Layout.fillWidth: true Layout.preferredHeight: EaStyle.Sizes.toolButtonHeight background: Rectangle { color: EaStyle.Colors.chartBackground } - TabButton { + EaElements.TabButton { text: qsTr("Marginals") font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 implicitHeight: EaStyle.Sizes.toolButtonHeight } - TabButton { + EaElements.TabButton { text: qsTr("Corner Plot") font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 implicitHeight: EaStyle.Sizes.toolButtonHeight } - TabButton { + EaElements.TabButton { text: qsTr("Traces") font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 implicitHeight: EaStyle.Sizes.toolButtonHeight } - TabButton { + EaElements.TabButton { text: qsTr("2D Heatmap") font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 implicitHeight: EaStyle.Sizes.toolButtonHeight } - TabButton { + EaElements.TabButton { text: qsTr("Diagnostics") font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 implicitHeight: EaStyle.Sizes.toolButtonHeight diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index ae8fa414..b57cc89c 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -76,8 +76,28 @@ Rectangle { } } - // Watch for changes in multi-experiment mode property - onIsMultiExperimentModeChanged: { + // A polarized experiment has measured data and a calculated curve per + // spin channel, which the single measured/calculated pair cannot show, + // so it uses the same dynamic per-series path as a multi-experiment + // selection — even when only one experiment is selected. + property bool usesChannelSeries: Globals.BackendWrapper.plottingAnalysisUsesChannelSeries + readonly property bool useDynamicSeries: isMultiExperimentMode || usesChannelSeries + + // Rows to draw: one per visible channel when any selected experiment is + // polarized, otherwise one per experiment. + readonly property var seriesDataList: { + try { + return usesChannelSeries + ? Globals.BackendWrapper.plottingIndividualExperimentChannelDataList + : Globals.BackendWrapper.plottingIndividualExperimentDataList + } catch (e) { + console.warn("CombinedView.seriesDataList failed:", e) + return [] + } + } + + // Watch for changes in multi-experiment mode / channel-series property + onUseDynamicSeriesChanged: { updateMultiExperimentSeries() } @@ -90,6 +110,20 @@ Rectangle { } } + // Toggling a spin channel changes which series must exist. + Connections { + target: Globals.BackendWrapper.activeBackend?.plotting ?? null + enabled: target !== null + function onChannelSelectionChanged() { + if (analysisChartView.useDynamicSeries) { + analysisChartView.updateMultiExperimentSeries() + } + } + function onExperimentChannelsChanged() { + analysisChartView.updateMultiExperimentSeries() + } + } + // Watch for plot mode changes (R(q)×q⁴ toggle) Connections { target: Globals.BackendWrapper @@ -221,18 +255,10 @@ Rectangle { // Multi-experiment series management function updateMultiExperimentSeries() { - // Always get the latest value from backend - var isMultiExp = false - try { - isMultiExp = Globals.BackendWrapper.plottingIsMultiExperimentMode || false - } catch (e) { - isMultiExp = false - } - // Clear existing multi-experiment series clearMultiExperimentSeries() - if (!isMultiExp) { + if (!useDynamicSeries) { // Show default scatter series for single experiment measured.visible = false if (!measuredScatterSerie) { @@ -250,8 +276,8 @@ Rectangle { return } - // Get experiment data list - var experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentDataList + // Get the rows to draw (one per experiment, or per spin channel) + var experimentDataList = seriesDataList // If no data available yet, keep default series visible as fallback if (experimentDataList.length === 0) { @@ -280,7 +306,7 @@ Rectangle { for (var i = 0; i < experimentDataList.length; i++) { var expData = experimentDataList[i] if (expData.hasData) { - createExperimentSeries(expData.index, expData.name, expData.color) + createExperimentSeries(expData.index, expData.name, expData.color, expData.channel || "") } } } @@ -299,14 +325,18 @@ Rectangle { multiExperimentSeries = [] } - function createExperimentSeries(expIndex, expName, color) { + function createExperimentSeries(expIndex, expName, color, channel) { var xAxis = currentXAxis() - // Look up the model color for this experiment + // Look up the model color for this experiment. A per-channel series + // keeps its own channel shade instead: one model colour for four + // overlapping cross-sections would make them indistinguishable. var modelColors = Globals.BackendWrapper.modelColorsForExperiment - var modelColor = (modelColors && expIndex >= 0 && expIndex < modelColors.length) - ? modelColors[expIndex] - : color + var modelColor = channel + ? color + : ((modelColors && expIndex >= 0 && expIndex < modelColors.length) + ? modelColors[expIndex] + : color) // Create measured data series (scatter points) var measuredSerie = MeasuredScatter.create(analysisChartView, ChartView, ScatterSeries, @@ -329,7 +359,8 @@ Rectangle { calculatedSerie: calculatedSerie, expIndex: expIndex, expName: expName, - color: color + color: color, + channel: channel || "" } multiExperimentSeries.push(seriesSet) @@ -338,8 +369,9 @@ Rectangle { } function populateExperimentSeries(seriesSet) { - // Get data points from backend (includes both measured and calculated) - var dataPoints = Globals.BackendWrapper.plottingGetAnalysisDataPoints(seriesSet.expIndex) + // Get data points from backend (includes both measured and calculated); + // for a polarized experiment both belong to seriesSet.channel. + var dataPoints = Globals.BackendWrapper.plottingGetAnalysisDataPoints(seriesSet.expIndex, seriesSet.channel) // Clear existing points seriesSet.measuredSerie.clear() @@ -349,7 +381,11 @@ Rectangle { for (var i = 0; i < dataPoints.length; i++) { var point = dataPoints[i] seriesSet.measuredSerie.append(point.x, point.measured) - seriesSet.calculatedSerie.append(point.x, point.calculated) + // A channel the model cannot calculate (spin-flip on a + // non-magnetic model) has no curve; measured points still show. + if (point.hasCalculated !== false) { + seriesSet.calculatedSerie.append(point.x, point.calculated) + } } } @@ -401,7 +437,7 @@ Rectangle { logModeSeries = null } - if (isMultiExperimentMode) { + if (useDynamicSeries) { updateMultiExperimentSeries() } else if (useLogQAxis) { measured.visible = false @@ -579,19 +615,19 @@ Rectangle { // Single experiment legend EaElements.Label { - visible: !analysisChartView.isMultiExperimentMode + visible: !analysisChartView.useDynamicSeries text: Globals.Variables.lineStyleSymbol(analysisChartView.measSerie.style) + ' I (Measured)' color: analysisChartView.measSerie.color } EaElements.Label { - visible: !analysisChartView.isMultiExperimentMode + visible: !analysisChartView.useDynamicSeries text: Globals.Variables.lineStyleSymbol(analysisChartView.calcSerie.style) + ' (Calculated)' color: analysisChartView.calcSerie.color } // Bayesian posterior predictive legend Row { - visible: !analysisChartView.isMultiExperimentMode && Globals.BackendWrapper.bayesianResultAvailable + visible: !analysisChartView.useDynamicSeries && Globals.BackendWrapper.bayesianResultAvailable spacing: EaStyle.Sizes.fontPixelSize * 0.3 Rectangle { width: EaStyle.Sizes.fontPixelSize * 1.2 @@ -605,7 +641,7 @@ Rectangle { } } Row { - visible: !analysisChartView.isMultiExperimentMode && Globals.BackendWrapper.bayesianResultAvailable + visible: !analysisChartView.useDynamicSeries && Globals.BackendWrapper.bayesianResultAvailable spacing: EaStyle.Sizes.fontPixelSize * 0.3 Rectangle { width: EaStyle.Sizes.fontPixelSize * 1.2 @@ -619,9 +655,9 @@ Rectangle { } } - // Multi-experiment legend + // Multi-experiment / channel legend Column { - visible: analysisChartView.isMultiExperimentMode + visible: analysisChartView.useDynamicSeries spacing: EaStyle.Sizes.fontPixelSize * 0.2 EaElements.Label { @@ -632,7 +668,7 @@ Rectangle { } Repeater { - model: analysisChartView.isMultiExperimentMode ? Globals.BackendWrapper.plottingIndividualExperimentDataList : [] + model: analysisChartView.useDynamicSeries ? analysisChartView.seriesDataList : [] delegate: Row { spacing: EaStyle.Sizes.fontPixelSize * 0.3 @@ -712,8 +748,8 @@ Rectangle { } function recreateSeriesForCurrentMode() { - if (isMultiExperimentMode) { - // Multi-experiment mode: recreate all multi-experiment series + if (useDynamicSeries) { + // Multi-experiment / channel-series mode: recreate all dynamic series updateMultiExperimentSeries() } else if (useLogQAxis) { // Single experiment, log mode: recreate log mode series diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml index 58f3cdd4..5f66f9ce 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml @@ -161,7 +161,7 @@ Rectangle { color: Globals.Variables.experimentColor( Globals.BackendWrapper.analysisExperimentsCurrentIndex ) - visible: !isMultiExperimentMode + visible: !useDynamicSeries onHovered: (point, state) => showMainTooltip(chartView, dataToolTip, point, state) } @@ -253,14 +253,14 @@ Rectangle { // Single experiment EaElements.Label { - visible: !isMultiExperimentMode + visible: !useDynamicSeries text: '━ ' + qsTr('Residual') color: singleResidualSerie.color } // Multi-experiment Repeater { - model: isMultiExperimentMode ? Globals.BackendWrapper.plottingIndividualExperimentDataList : [] + model: useDynamicSeries ? seriesDataList : [] delegate: Row { spacing: EaStyle.Sizes.fontPixelSize * 0.3 @@ -397,6 +397,25 @@ Rectangle { catch (e) { return false } } + // A polarized experiment has one residual curve per spin channel, so it + // uses the same per-series path as a multi-experiment selection. + property bool usesChannelSeries: Globals.BackendWrapper.plottingAnalysisUsesChannelSeries + readonly property bool useDynamicSeries: isMultiExperimentMode || usesChannelSeries + + // One row per experiment, or per visible channel when polarized. + readonly property var seriesDataList: { + try { + return usesChannelSeries + ? Globals.BackendWrapper.plottingIndividualExperimentChannelDataList + : Globals.BackendWrapper.plottingIndividualExperimentDataList + } catch (e) { + console.warn("ResidualsView.seriesDataList failed:", e) + return [] + } + } + + onUseDynamicSeriesChanged: refreshResidualChart() + // Re-populate charts when backend signals a data/range refresh Connections { target: Globals.BackendWrapper.activeBackend?.plotting ?? null @@ -404,6 +423,13 @@ Rectangle { function onSampleChartRangesChanged() { refreshResidualChart() } + // Showing/hiding a spin channel changes which residual curves exist. + function onChannelSelectionChanged() { + refreshResidualChart() + } + function onExperimentChannelsChanged() { + refreshResidualChart() + } } Component.onCompleted: { @@ -419,7 +445,7 @@ Rectangle { zeroLine.append(Globals.BackendWrapper.plottingResidualMinX, 0) zeroLine.append(Globals.BackendWrapper.plottingResidualMaxX, 0) - if (isMultiExperimentMode) { + if (useDynamicSeries) { _refreshMultiExperiment() } else { _refreshSingleExperiment() @@ -453,7 +479,7 @@ Rectangle { } } else { // Linear mode: use static series - singleResidualSerie.visible = !isMultiExperimentMode + singleResidualSerie.visible = !useDynamicSeries singleResidualSerie.clear() for (let i = 0; i < points.length; i++) { singleResidualSerie.append(points[i].x, points[i].y) @@ -473,7 +499,7 @@ Rectangle { } var xAxisToUse = chartView.currentXAxis() - const experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentDataList + const experimentDataList = seriesDataList for (let i = 0; i < experimentDataList.length; i++) { const expData = experimentDataList[i] if (!expData.hasData) continue @@ -486,7 +512,7 @@ Rectangle { serie.useOpenGL = EaGlobals.Vars.useOpenGL serie.hovered.connect((point, state) => showMainTooltip(chartView, dataToolTip, point, state)) - const points = Globals.BackendWrapper.plottingGetResidualDataPoints(expData.index) + const points = Globals.BackendWrapper.plottingGetResidualDataPoints(expData.index, expData.channel || "") for (let j = 0; j < points.length; j++) { serie.append(points[j].x, points[j].y) } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml index a49e7dab..3ae9e0ce 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml @@ -7,6 +7,7 @@ import QtQuick.Controls import QtQuick.Layouts import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements import Gui as Gui import Gui.Globals as Globals @@ -18,38 +19,66 @@ Item { // Expose the SLD chartView so existing Globals.References remain valid readonly property alias sldChartView: sldChart.chartView - // Called by CombinedView to reset both lower tabs together + // Spin asymmetry only exists for an experiment with both pp and mm; the + // tab is absent otherwise, costing nothing for ordinary work. + readonly property bool spinAsymmetryAvailable: Globals.BackendWrapper.plottingSpinAsymmetryAvailable + + // Called by CombinedView to reset all lower tabs together function resetAllAxes() { sldChart.chartView.resetAxes() residualsView.chartView.resetAxes() + spinAsymmetryChart.chartView.resetAxes() } // Called by CombinedView to sync pan/zoom mode from the top toolbar function setAllowZoom(value) { sldChart.chartView.allowZoom = value residualsView.chartView.allowZoom = value + spinAsymmetryChart.chartView.allowZoom = value + } + + // Never leave the hidden tab selected when SA disappears (e.g. the user + // switches to an unpolarized experiment). + onSpinAsymmetryAvailableChanged: { + if (!spinAsymmetryAvailable && tabBar.currentIndex === 2) { + tabBar.currentIndex = 0 + } } ColumnLayout { anchors.fill: parent spacing: 0 - TabBar { + EaElements.TabBar { id: tabBar Layout.fillWidth: true Layout.preferredHeight: EaStyle.Sizes.toolButtonHeight background: Rectangle { color: EaStyle.Colors.chartBackground } - TabButton { + // All visible tabs share the width equally: 33% each with the spin + // asymmetry tab, 50%/50% without it. + readonly property int visibleTabCount: root.spinAsymmetryAvailable ? 3 : 2 + readonly property real tabWidth: (width - spacing * (visibleTabCount - 1)) / visibleTabCount + + EaElements.TabButton { text: qsTr("SLD") font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 implicitHeight: EaStyle.Sizes.toolButtonHeight + width: tabBar.tabWidth } - TabButton { + EaElements.TabButton { text: qsTr("Residuals") font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 implicitHeight: EaStyle.Sizes.toolButtonHeight + width: tabBar.tabWidth + } + EaElements.TabButton { + text: qsTr("Spin asymmetry") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + implicitHeight: EaStyle.Sizes.toolButtonHeight + visible: root.spinAsymmetryAvailable + width: visible ? tabBar.tabWidth : 0 } } @@ -69,6 +98,12 @@ Item { showLegend: Globals.Variables.showLegendOnAnalysisResidualsTab onShowLegendChanged: Globals.Variables.showLegendOnAnalysisResidualsTab = showLegend } + + Gui.SpinAsymmetryChart { + id: spinAsymmetryChart + // The Analysis page has a model, so it also draws the model SA. + showCalculated: true + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Calculator.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Calculator.qml index 2ff7e8f9..b718aaa2 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Calculator.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Calculator.qml @@ -8,18 +8,13 @@ import QtQuick.Controls import EasyApplication.Gui.Style as EaStyle import EasyApplication.Gui.Elements as EaElements +import Gui as Gui import Gui.Globals as Globals EaElements.GroupBox { title: qsTr("Calculation engine") icon: 'calculator' - EaElements.GroupRow { - - EaElements.ComboBox { - width: EaStyle.Sizes.sideBarContentWidth - model: Globals.BackendWrapper.analysisCalculatorsAvailable - currentIndex: Globals.BackendWrapper.analysisCalculatorCurrentIndex - onCurrentIndexChanged: Globals.BackendWrapper.analysisSetCalculatorCurrentIndex(currentIndex) - } - } + // The same control as the Sample page's group: one engine, one place that + // decides what happens when it cannot be changed. + Gui.CalculationEngineControl {} } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml index 53250624..67638fac 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml @@ -8,11 +8,20 @@ import EasyApplication.Gui.Style as EaStyle import EasyApplication.Gui.Elements as EaElements import Gui as Gui +import Gui.Globals as Globals EaElements.GroupBox { title: qsTr("Plot control") collapsed: true - Gui.PlotControlRefLines {} + EaElements.GroupColumn { + Gui.PlotControlRefLines {} + + // Magnetic profile switches for the SLD tab, next to the other chart + // controls. Absent while no model is magnetic. + Gui.MagneticProfileControl { + visible: Globals.BackendWrapper.plottingAnyModelHasMagnetism + } + } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml index 36a2df21..428bb3e0 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml @@ -136,8 +136,14 @@ EaElements.GroupBox { EaComponents.TableViewLabel { id: noLabel - width: EaStyle.Sizes.fontPixelSize * 2.5 - text: index + 1 + width: EaStyle.Sizes.fontPixelSize * 3.5 + // '⇅N' badge marks polarized experiments and their measured channel count + text: (index + 1) + (Globals.BackendWrapper.analysisExperimentsPolarized[index] + ? ' ⇅' + (Globals.BackendWrapper.analysisExperimentsChannelCount[index] ?? '') + : '') + ToolTip.text: Globals.BackendWrapper.analysisExperimentsPolarized[index] + ? qsTr("Polarized experiment: %1 spin channel(s)").arg(Globals.BackendWrapper.analysisExperimentsChannelCount[index] ?? 0) + : "" Rectangle { visible: isSelected diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Layout.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Layout.qml index 527cec9a..a8528990 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Layout.qml @@ -14,7 +14,10 @@ EaComponents.ContentPage { mainView: EaComponents.MainContent { items: [ Loader { - source: `MainContent/ExperimentView.qml` + // Reflectivity chart, plus a spin-asymmetry tab when the current + // experiment has both non-spin-flip channels. Without one the + // page is the single chart it has always been. + source: `MainContent/ExperimentTabs.qml` onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) } ] diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentTabs.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentTabs.qml new file mode 100644 index 00000000..13fe8020 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentTabs.qml @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +import Gui as Gui +import Gui.Globals as Globals + +// The Experiment page's main content: the reflectivity chart, plus a spin +// asymmetry tab for an experiment that measured both non-spin-flip channels. +// While SA is unavailable the tab strip has no height and no buttons, so the +// page looks exactly as it did before polarized support existed. +Item { + id: root + + readonly property bool spinAsymmetryAvailable: Globals.BackendWrapper.plottingSpinAsymmetryAvailable + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + EaElements.TabBar { + id: tabBar + + visible: root.spinAsymmetryAvailable + Layout.fillWidth: true + Layout.preferredHeight: root.spinAsymmetryAvailable ? EaStyle.Sizes.toolButtonHeight : 0 + + background: Rectangle { color: EaStyle.Colors.chartBackground } + + EaElements.TabButton { + text: qsTr("Reflectivity") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + implicitHeight: EaStyle.Sizes.toolButtonHeight + } + EaElements.TabButton { + text: qsTr("Spin asymmetry") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + implicitHeight: EaStyle.Sizes.toolButtonHeight + } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + // Falling back to the reflectivity chart also covers the case where + // SA disappears while its tab is selected. + currentIndex: root.spinAsymmetryAvailable ? tabBar.currentIndex : 0 + + ExperimentView {} + + Gui.SpinAsymmetryChart { + // Measured only: the model curve belongs on the Analysis page, + // this is the "do I have a magnetic signal?" view. + showCalculated: false + showLegend: Globals.Variables.showLegendOnExperimentPage + onShowLegendChanged: Globals.Variables.showLegendOnExperimentPage = showLegend + } + } + } + + onSpinAsymmetryAvailableChanged: { + if (!spinAsymmetryAvailable) { + tabBar.currentIndex = 0 + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 04225ae6..6e91b75b 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -94,6 +94,9 @@ Rectangle { if (isMultiExperimentMode) { // Multi-experiment mode: recreate all multi-experiment series updateMultiExperimentSeries() + } else if (isPolarizedMode) { + // Polarized experiment: recreate per-channel series + updateChannelSeries() } else if (useLogQAxis) { // Single experiment, log mode: recreate log mode series recreateForLogMode() @@ -127,6 +130,142 @@ Rectangle { return false } } + + // Polarized experiment (spin channel) support: one series set per + // visible channel of the current experiment. + property var channelSeries: [] + property bool isPolarizedMode: { + try { + return (Globals.BackendWrapper.plottingCurrentExperimentIsPolarized && !isMultiExperimentMode) || false + } catch (e) { + return false + } + } + + onIsPolarizedModeChanged: updateChannelSeries() + + Connections { + target: Globals.BackendWrapper.activeBackend?.plotting ?? null + enabled: target !== null + function onChannelSelectionChanged() { + if (chartView.isPolarizedMode) { + chartView.updateChannelSeries() + } else if (chartView.isMultiExperimentMode) { + // Multi-experiment mode draws one series per visible channel + // of each polarized experiment; rebuild them too. + chartView.updateMultiExperimentSeries() + } + } + + // The current experiment (or its channel list) changed. The + // per-channel series are QML-owned, so a backend refresh does not + // touch them: switching between two polarized experiments would + // otherwise keep the previous one's points on screen. + function onExperimentChannelsChanged() { + if (chartView.isPolarizedMode) { + chartView.updateChannelSeries() + } else if (chartView.isMultiExperimentMode) { + chartView.updateMultiExperimentSeries() + } else { + chartView.clearChannelSeries() + } + } + } + + Connections { + target: Globals.BackendWrapper.activeBackend?.experiment ?? null + enabled: target !== null + function onExperimentChanged() { + if (chartView.isPolarizedMode) { + chartView.updateChannelSeries() + } + } + } + + function clearChannelSeries() { + for (var i = 0; i < channelSeries.length; i++) { + var seriesSet = channelSeries[i] + if (seriesSet.measuredSerie) { + chartView.removeSeries(seriesSet.measuredSerie) + } + if (seriesSet.errorUpperSerie) { + chartView.removeSeries(seriesSet.errorUpperSerie) + } + if (seriesSet.errorLowerSerie) { + chartView.removeSeries(seriesSet.errorLowerSerie) + } + } + channelSeries = [] + } + + function updateChannelSeries() { + clearChannelSeries() + + if (!isPolarizedMode) { + // Back to the regular display (single or multi experiment). + updateMultiExperimentSeries() + return + } + + // Hide the default single-experiment series. + measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false + errorUpper.visible = false + errorLower.visible = false + + var expIndex = Globals.BackendWrapper.analysisExperimentsCurrentIndex + var channels = Globals.BackendWrapper.plottingGetExperimentChannels(expIndex) + for (var i = 0; i < channels.length; i++) { + if (channels[i].visible) { + createChannelSeries(expIndex, channels[i]) + } + } + } + + function createChannelSeries(expIndex, channelRow) { + var xAxis = currentXAxis() + var name = `${channelRow.label} ${channelRow.channel}` + + var measuredSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + `${name} - Data`, + xAxis, chartView.axisY, + channelRow.color, Globals.Variables.experimentMarkerStyle) + + var errorColor = Qt.darker(channelRow.color, 1.3) + + var errorUpperSerie = chartView.createSeries(ChartView.SeriesTypeLine, + `${name} - Error Upper`, + xAxis, chartView.axisY) + errorUpperSerie.color = errorColor + errorUpperSerie.width = 1 + errorUpperSerie.style = Qt.DashLine + errorUpperSerie.useOpenGL = chartView.useOpenGL + + var errorLowerSerie = chartView.createSeries(ChartView.SeriesTypeLine, + `${name} - Error Lower`, + xAxis, chartView.axisY) + errorLowerSerie.color = errorColor + errorLowerSerie.width = 1 + errorLowerSerie.style = Qt.DashLine + errorLowerSerie.useOpenGL = chartView.useOpenGL + + var seriesSet = { + measuredSerie: measuredSerie, + errorUpperSerie: errorUpperSerie, + errorLowerSerie: errorLowerSerie, + channel: channelRow.channel, + color: channelRow.color + } + channelSeries.push(seriesSet) + + var dataPoints = Globals.BackendWrapper.plottingGetExperimentChannelDataPoints(expIndex, channelRow.channel) + for (var j = 0; j < dataPoints.length; j++) { + var point = dataPoints[j] + seriesSet.measuredSerie.append(point.x, point.y) + seriesSet.errorUpperSerie.append(point.x, point.errorUpper) + seriesSet.errorLowerSerie.append(point.x, point.errorLower) + } + } property bool useStaggeredPlotting: { try { return Globals.Variables.useStaggeredPlotting || false @@ -307,6 +446,9 @@ Rectangle { if (isMultiExperimentMode) { // Multi-experiment mode: recreate all multi-experiment series with the correct axis updateMultiExperimentSeries() + } else if (isPolarizedMode) { + // Polarized experiment: recreate per-channel series on the correct axis + updateChannelSeries() } else if (useLogQAxis) { // Single experiment, log mode: create dynamic series on log axis measured.visible = false @@ -398,6 +540,12 @@ Rectangle { clearMultiExperimentSeries() if (!isMultiExperimentMode) { + if (isPolarizedMode) { + // Polarized experiment: one series set per visible spin channel. + updateChannelSeries() + return + } + clearChannelSeries() // Show default series for single experiment measured.visible = false if (!measuredScatterSerie) { @@ -417,8 +565,9 @@ Rectangle { return } - // Get experiment data list - var experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentDataList + // Get experiment data list; polarized experiments are expanded + // into one entry per visible spin channel. + var experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentChannelDataList // If no data available yet, keep default series visible as fallback if (experimentDataList.length === 0) { console.log("No experiment data available - keeping default series visible") @@ -440,17 +589,20 @@ Rectangle { return } - // Hide default series in multi-experiment mode (only after we have data) + // Hide default and per-channel series in multi-experiment mode + // (only after we have data) + clearChannelSeries() measured.visible = false if (measuredScatterSerie) measuredScatterSerie.visible = false errorUpper.visible = false errorLower.visible = false - // Create series for each experiment + // Create series for each experiment; a polarized experiment + // contributes one entry per visible spin channel. for (var i = 0; i < experimentDataList.length; i++) { var expData = experimentDataList[i] if (expData.hasData) { - createExperimentSeries(expData.index, expData.name, expData.color) + createExperimentSeries(expData.index, expData.name, expData.color, expData.channel ?? "") } } } @@ -472,7 +624,7 @@ Rectangle { multiExperimentSeries = [] } - function createExperimentSeries(expIndex, expName, color) { + function createExperimentSeries(expIndex, expName, color, channel) { // console.log(` Creating series for experiment ${expIndex}: ${expName} (${color})`) var xAxis = currentXAxis() @@ -509,7 +661,8 @@ Rectangle { errorLowerSerie: errorLowerSerie, expIndex: expIndex, expName: expName, - color: color + color: color, + channel: channel ?? "" } multiExperimentSeries.push(seriesSet) @@ -518,8 +671,11 @@ Rectangle { } function populateExperimentSeries(seriesSet) { - // Get data points from backend - var dataPoints = Globals.BackendWrapper.plottingGetExperimentDataPoints(seriesSet.expIndex) + // Get data points from backend: the requested spin channel for a + // polarized experiment, the plain experiment data otherwise. + var dataPoints = seriesSet.channel + ? Globals.BackendWrapper.plottingGetExperimentChannelDataPoints(seriesSet.expIndex, seriesSet.channel) + : Globals.BackendWrapper.plottingGetExperimentDataPoints(seriesSet.expIndex) // Clear existing points seriesSet.measuredSerie.clear() @@ -529,7 +685,11 @@ Rectangle { // Calculate staggering offset if enabled var yOffset = 0 if (useStaggeredPlotting && isMultiExperimentMode && multiExperimentSeries.length > 1) { - var experimentIndex = seriesSet.expIndex + // Offset by the series' own position, not by the experiment + // index: the spin channels of one polarized experiment share an + // index and would otherwise be stacked on top of each other. + var seriesPosition = multiExperimentSeries.indexOf(seriesSet) + var experimentIndex = seriesPosition >= 0 ? seriesPosition : seriesSet.expIndex var totalExperiments = multiExperimentSeries.length // Find the individual experiment's data range @@ -660,16 +820,51 @@ Rectangle { // Single experiment legend EaElements.Label { - visible: !chartView.isMultiExperimentMode + visible: !chartView.isMultiExperimentMode && !chartView.isPolarizedMode text: Globals.Variables.lineStyleSymbol(chartView.calcSerie.style) + ' I (Measured)' color: chartView.calcSerie.color } EaElements.Label { - visible: !chartView.isMultiExperimentMode + visible: !chartView.isMultiExperimentMode && !chartView.isPolarizedMode text: Globals.Variables.lineStyleSymbol(chartView.measSerie.style) + ' Error' color: chartView.measSerie.color } - + + // Polarized experiment legend: one entry per visible spin channel + Column { + visible: chartView.isPolarizedMode + spacing: EaStyle.Sizes.fontPixelSize * 0.2 + + EaElements.Label { + text: qsTr("Spin channels:") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + font.bold: true + color: EaStyle.Colors.themeForeground + } + + Repeater { + model: chartView.isPolarizedMode ? Globals.BackendWrapper.plottingExperimentChannelList : [] + delegate: Row { + visible: modelData.visible + spacing: EaStyle.Sizes.fontPixelSize * 0.3 + + Rectangle { + width: EaStyle.Sizes.fontPixelSize * 0.8 + height: 3 + color: modelData.color || "#1f77b4" + anchors.verticalCenter: parent.verticalCenter + } + + EaElements.Label { + text: `${modelData.label} ${modelData.channel}` + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.8 + color: EaStyle.Colors.themeForeground + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + // Multi-experiment legend Column { visible: chartView.isMultiExperimentMode @@ -683,7 +878,7 @@ Rectangle { } Repeater { - model: chartView.isMultiExperimentMode ? Globals.BackendWrapper.plottingIndividualExperimentDataList : [] + model: chartView.isMultiExperimentMode ? Globals.BackendWrapper.plottingIndividualExperimentChannelDataList : [] delegate: Row { spacing: EaStyle.Sizes.fontPixelSize * 0.3 diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PolarizationChannels.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PolarizationChannels.qml new file mode 100644 index 00000000..d68a58dc --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PolarizationChannels.qml @@ -0,0 +1,42 @@ +// 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.Elements as EaElements + +import Gui.Globals as Globals + + +// Spin-channel selector for polarized experiments: one checkbox per measured +// channel of the current experiment. Only shown when the current experiment is +// polarized. +EaElements.GroupBox { + title: qsTr("Polarization channels") + collapsible: false + visible: Globals.BackendWrapper.plottingCurrentExperimentIsPolarized + + Row { + spacing: EaStyle.Sizes.fontPixelSize + + Repeater { + model: Globals.BackendWrapper.plottingExperimentChannelList + + delegate: EaElements.CheckBox { + text: `${modelData.label} ${modelData.channel}` + checked: modelData.visible + ToolTip.text: qsTr("Show the %1 channel on the charts").arg(modelData.channel) + onToggled: { + Globals.BackendWrapper.plottingSetChannelVisible(modelData.channel, checked) + // Clicking replaced the binding with a plain value. The + // backend refuses to hide the last visible measured + // channel, so follow its state instead of the click. + checked = Qt.binding(function() { return modelData.visible }) + } + } + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml index e79a1221..db17011d 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml @@ -7,6 +7,7 @@ import QtQuick import EasyApplication.Gui.Elements as EaElements import EasyApplication.Gui.Components as EaComponents +import Gui.Globals as Globals import "./Groups" as Groups @@ -14,4 +15,8 @@ EaComponents.SideBarColumn { Groups.PlotControl {} + Groups.PolarizationChannels { + enabled: Globals.BackendWrapper.analysisIsFitFinished + } + } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalData.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalData.qml index 64f79cf0..142af185 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalData.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalData.qml @@ -12,7 +12,7 @@ EaElements.GroupBox { collapsible: false enabled: Globals.Constants.proxy.fitter.isFitFinished - Row { + Column { spacing: EaStyle.Sizes.fontPixelSize EaElements.SideBarButton { @@ -30,6 +30,25 @@ EaElements.GroupBox { source: '../Popups/OpenExperimentFile.qml' } } + + EaElements.SideBarButton { + enabled: true + wide: true + fontIcon: "magnet" + text: qsTr("Load polarized experiment (file per channel)") + + onClicked: { + console.debug(`Clicking '${text}' button ::: ${this}`) + Globals.References.pages.experiment.sidebar.basic.popups.loadPolarizedExperimentFilesDialog.open() + } + + Loader { + source: '../Popups/OpenPolarizedExperimentFiles.qml' + } + Loader { + source: '../Popups/PolarizedChannelAssignment.qml' + } + } } Component.onCompleted: Globals.Variables.experimentalDataGroup = this diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml index e1b4710d..bd923b03 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml @@ -206,9 +206,15 @@ EaElements.GroupBox { EaComponents.TableViewLabel { id: noLabel - width: EaStyle.Sizes.fontPixelSize * 2.5 - text: index + 1 - + width: EaStyle.Sizes.fontPixelSize * 3.5 + // '⇅N' badge marks polarized experiments and their measured channel count + text: (index + 1) + (Globals.BackendWrapper.analysisExperimentsPolarized[index] + ? ' ⇅' + (Globals.BackendWrapper.analysisExperimentsChannelCount[index] ?? '') + : '') + ToolTip.text: Globals.BackendWrapper.analysisExperimentsPolarized[index] + ? qsTr("Polarized experiment: %1 spin channel(s)").arg(Globals.BackendWrapper.analysisExperimentsChannelCount[index] ?? 0) + : "" + // Selection background overlay - placed as child to avoid layout interference Rectangle { visible: isSelected diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Popups/OpenPolarizedExperimentFiles.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Popups/OpenPolarizedExperimentFiles.qml new file mode 100644 index 00000000..80c607de --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Popups/OpenPolarizedExperimentFiles.qml @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs + +import Gui.Globals as Globals + + +// Multi-selects the per-channel data files of one polarized experiment, asks the +// backend for a suggested spin-channel assignment (ORSO header or filename), and +// hands the editable rows to the channel-assignment dialog. +FileDialog { + + id: openPolarizedExperimentFilesDialog + + title: qsTr("Select one data file per spin channel") + fileMode: FileDialog.OpenFiles + nameFilters: [ 'Experiment files (*.dat *.txt *.ort)'] + + onAccepted: { + const paths = [] + for (let i = 0; i < selectedFiles.length; i++) { + paths.push(selectedFiles[i].toString()) + } + const rows = Globals.BackendWrapper.experimentSuggestPolarizedChannels(paths) + Globals.References.pages.experiment.sidebar.basic.popups.polarizedChannelAssignmentDialog.openWith(rows) + } + + Component.onCompleted: { + Globals.References.pages.experiment.sidebar.basic.popups.loadPolarizedExperimentFilesDialog = openPolarizedExperimentFilesDialog + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Popups/PolarizedChannelAssignment.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Popups/PolarizedChannelAssignment.qml new file mode 100644 index 00000000..e42cb33d --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Popups/PolarizedChannelAssignment.qml @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +import Gui.Globals as Globals + + +// Editable file → spin-channel assignment for one polarized experiment. +// Rows come pre-filled from the backend detection (ORSO header or filename +// tokens); the user can adjust each channel or exclude a file ("not used"). +EaElements.Dialog { + id: dialog + + title: qsTr("Assign spin channels") + standardButtons: Dialog.Ok | Dialog.Cancel + closePolicy: Popup.CloseOnEscape + + // Rows: [{path, name, channel}] — channel is '' when the file is not used. + property var assignmentRows: [] + // Incremented on every edit so we can re-evaluate validation bindings. + property int editRevision: 0 + + readonly property var channelValues: ['', 'pp', 'pm', 'mp', 'mm'] + readonly property var channelTexts: [qsTr('not used'), 'pp ↑↑', 'pm ↑↓', 'mp ↓↑', 'mm ↓↓'] + + readonly property bool hasAssignment: { + editRevision // dependency + for (let i = 0; i < assignmentRows.length; i++) { + if (assignmentRows[i].channel !== '') return true + } + return false + } + + readonly property bool hasDuplicates: { + editRevision // dependency + const seen = {} + for (let i = 0; i < assignmentRows.length; i++) { + const channel = assignmentRows[i].channel + if (channel === '') continue + if (seen[channel]) return true + seen[channel] = true + } + return false + } + + readonly property bool isValid: hasAssignment && !hasDuplicates + + // Keep the OK button disabled while the assignment is invalid, so the + // dialog can never be accepted into a no-op that just closes it. + function updateOkEnabled() { + const okButton = standardButton(Dialog.Ok) + if (okButton) { + okButton.enabled = isValid + } + } + + onIsValidChanged: updateOkEnabled() + // The footer buttons only exist once the dialog is shown. + onOpened: updateOkEnabled() + + // Message from a backend rejection. + property string loadError: '' + + function openWith(rows) { + assignmentRows = rows + loadError = '' + editRevision += 1 + updateOkEnabled() + open() + } + + onAccepted: { + // OK is disabled while invalid. The backend + // validates the rows again before loading anything. + if (!isValid) { + assignmentRows = [] + return + } + const error = Globals.BackendWrapper.experimentLoadPolarized(assignmentRows) + if (error) { + // Keep the rows and reopen with the reason, so the user can fix the + // assignment instead of losing it to a dialog that just closed. + loadError = error + open() + return + } + loadError = '' + assignmentRows = [] + } + + onRejected: { + assignmentRows = [] + loadError = '' + } + + Component.onCompleted: { + Globals.References.pages.experiment.sidebar.basic.popups.polarizedChannelAssignmentDialog = dialog + } + + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.Label { + text: qsTr("One file per spin channel. Channels were pre-assigned from the\nORSO header or the file name. Adjust them if needed.\nAny number of channels may be assigned (a single one is allowed);\nfiles set to 'not used' are ignored.") + } + + EaElements.Label { + // Polarized data loads and displays in any engine, but the + // calculated cross-sections need magnetism: say so here rather + // than leaving the user to wonder why only one channel is fitted. + visible: !Globals.BackendWrapper.sampleMagnetismSupported + && Globals.BackendWrapper.sampleCalculationEnginesSupportingMagnetism.length > 0 + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.WordWrap + width: EaStyle.Sizes.fontPixelSize * 28 + text: qsTr("Note: the channels will be loaded and displayed, but modelling them needs a magnetic sample, which only %1 can calculate. Switch the calculation engine on the Sample page when you get there.") + .arg(Globals.BackendWrapper.sampleCalculationEnginesSupportingMagnetism.join(', ')) + } + + EaElements.Label { + // Model.resolution_function is per experiment. + color: EaStyle.Colors.themeForegroundMinor + text: qsTr("Note: one resolution function is used for the whole experiment,\ntaken from the first assigned channel. Differing per-channel\nresolution metadata in the other files is ignored.") + } + + Repeater { + model: dialog.assignmentRows.length + + delegate: Row { + id: rowDelegate + + property int rowIndex: index + + spacing: EaStyle.Sizes.fontPixelSize + + EaElements.Label { + width: EaStyle.Sizes.fontPixelSize * 18 + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideLeft + text: dialog.assignmentRows[rowDelegate.rowIndex].name + ToolTip.text: dialog.assignmentRows[rowDelegate.rowIndex].path + } + + EaElements.ComboBox { + width: EaStyle.Sizes.fontPixelSize * 8 + model: dialog.channelTexts + currentIndex: dialog.channelValues.indexOf(dialog.assignmentRows[rowDelegate.rowIndex].channel) + onActivated: { + dialog.assignmentRows[rowDelegate.rowIndex].channel = dialog.channelValues[currentIndex] + dialog.editRevision += 1 + } + } + } + } + + EaElements.Label { + visible: dialog.hasDuplicates + color: EaStyle.Colors.red + text: qsTr("Each spin channel may be assigned to only one file.") + } + + EaElements.Label { + visible: !dialog.hasAssignment + color: EaStyle.Colors.red + text: qsTr("Assign at least one file to a spin channel.") + } + + EaElements.Label { + visible: dialog.loadError !== '' + color: EaStyle.Colors.red + wrapMode: Text.WordWrap + width: EaStyle.Sizes.fontPixelSize * 28 + text: dialog.loadError + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml index f7c8494f..6ba41216 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -461,12 +461,18 @@ Rectangle { // Refresh sample series for (let i = 0; i < sampleSeries.length && i < models.length; i++) { const series = sampleSeries[i] - if (series) { - series.clear() - const points = Globals.BackendWrapper.plottingGetSampleDataPointsForModel(i) - for (let p = 0; p < points.length; p++) { - series.append(points[p].x, points[p].y) - } + if (!series) { + continue + } + // The backend fills the series in one call; the append() loop is + // the fallback for a backend without the fill API (mock). + if (Globals.BackendWrapper.plottingFillSampleSeriesForModel(series, i)) { + continue + } + series.clear() + const points = Globals.BackendWrapper.plottingGetSampleDataPointsForModel(i) + for (let p = 0; p < points.length; p++) { + series.append(points[p].x, points[p].y) } } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/CalculationEngine.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/CalculationEngine.qml new file mode 100644 index 00000000..224b5bc3 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/CalculationEngine.qml @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick + +import EasyApplication.Gui.Elements as EaElements + +import Gui as Gui + +// The engine is a project-wide setting owned by the Analysis page, repeated +// here because magnetism depends on it: the Sample page is where a layer is +// made magnetic, and the Analysis page is not always reachable yet. +EaElements.GroupBox { + title: qsTr("Calculation engine") + icon: 'calculator' + collapsible: true + collapsed: true + + Gui.CalculationEngineControl {} +} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir index 002327b3..bfa12ec7 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir @@ -1,5 +1,6 @@ module Groups +CalculationEngine 1.0 CalculationEngine.qml Constraints 1.0 Constraints.qml ModelConstraints 1.0 ModelConstraints.qml PlotControl 1.0 PlotControl.qml diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml index b1d80713..e4673a81 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml @@ -14,6 +14,10 @@ EaComponents.SideBarColumn { } Groups.PlotControl{ } + Groups.CalculationEngine { + collapsed: true + enabled: Globals.BackendWrapper.analysisIsFitFinished + } Groups.Constraints{ enabled: Globals.BackendWrapper.analysisIsFitFinished } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MagneticProfile.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MagneticProfile.qml new file mode 100644 index 00000000..faae8824 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MagneticProfile.qml @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick + +import EasyApplication.Gui.Elements as EaElements + +import Gui as Gui +import Gui.Globals as Globals + +// Display controls for the magnetic depth profiles, directly below the +// Magnetism group: make a layer magnetic and the control for showing it appears +// right underneath. Absent entirely while no model is magnetic. +EaElements.GroupBox { + title: qsTr("Magnetic profile") + collapsible: true + visible: Globals.BackendWrapper.plottingAnyModelHasMagnetism + + Gui.MagneticProfileControl {} +} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml new file mode 100644 index 00000000..76a0be5d --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Magnetism.qml @@ -0,0 +1,216 @@ +// 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.Elements as EaElements +import EasyApplication.Gui.Components as EaComponents + +import Gui.Globals as Globals + + +// Per-layer magnetism of the current assembly: a magnetic layer carries a +// magnetic SLD (ρM) and an in-plane moment angle (θM), both fittable and +// visible in the Analysis parameter table once the layer is magnetic. +// Only refl1d can model magnetism, so the whole group is disabled otherwise. +EaElements.GroupBox { + id: magnetismGroup + + title: qsTr("Magnetism: " + Globals.BackendWrapper.sampleCurrentAssemblyName) + collapsible: true + collapsed: true + + readonly property bool supported: Globals.BackendWrapper.sampleMagnetismSupported + readonly property string magneticEngine: { + const engines = Globals.BackendWrapper.sampleCalculationEnginesSupportingMagnetism + return engines.length > 0 ? engines[0] : '' + } + property string errorMessage: '' + + EaElements.GroupColumn { + + EaElements.Label { + visible: !magnetismGroup.supported && magnetismGroup.magneticEngine !== '' + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.WordWrap + width: EaStyle.Sizes.sideBarContentWidth + text: qsTr("Magnetic layers are modelled by %1. Ticking 'Magn.' offers to switch this project to it.") + .arg(magnetismGroup.magneticEngine) + } + + EaElements.Label { + visible: !magnetismGroup.supported && magnetismGroup.magneticEngine === '' + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.WordWrap + width: EaStyle.Sizes.sideBarContentWidth + text: qsTr("None of the available calculation engines can model magnetic layers.") + } + + EaComponents.TableView { + id: magnetismView + // Not disabled when the engine cannot model magnetism: ticking + // 'Magn.' is how the user asks for the engine that can. + enabled: magnetismGroup.supported || magnetismGroup.magneticEngine !== '' + tallRows: false + defaultInfoText: qsTr("No Layers Added") + model: Globals.BackendWrapper.sampleLayersMagnetism.length + + header: EaComponents.TableViewHeader { + EaComponents.TableViewLabel { + id: noLabel + text: qsTr('No.') + width: EaStyle.Sizes.fontPixelSize * 2.5 + } + + EaComponents.TableViewLabel { + width: EaStyle.Sizes.sideBarContentWidth - (noLabel.width + rhoLabel.width + thetaLabel.width + magneticLabel.width + 5 * EaStyle.Sizes.tableColumnSpacing) + horizontalAlignment: Text.AlignLeft + text: qsTr('Layer') + } + + EaComponents.TableViewLabel { + id: rhoLabel + text: qsTr('ρM/10⁻⁶Å⁻²') + width: EaStyle.Sizes.fontPixelSize * 9.0 + } + + EaComponents.TableViewLabel { + id: thetaLabel + text: qsTr('θM/°') + width: EaStyle.Sizes.fontPixelSize * 7.0 + } + + EaComponents.TableViewLabel { + id: magneticLabel + text: qsTr('Magn.') + width: EaStyle.Sizes.fontPixelSize * 4.0 + } + } + + delegate: EaComponents.TableViewDelegate { + // Guard every access: the row model length and the backing list + // are refreshed by separate signals, so a delegate can outlive + // its row for one frame after a layer is removed. + readonly property var rowData: Globals.BackendWrapper.sampleLayersMagnetism[index] ?? null + readonly property bool rowIsMagnetic: rowData !== null && rowData.magnetic === "True" + + EaComponents.TableViewLabel { + color: EaStyle.Colors.themeForegroundMinor + text: index + 1 + } + + EaComponents.TableViewLabel { + horizontalAlignment: Text.AlignLeft + text: rowData ? rowData.label : '' + } + + EaComponents.TableViewTextInput { + horizontalAlignment: Text.AlignHCenter + enabled: rowIsMagnetic + text: rowData ? Number(rowData.rho_m).toFixed(3) : '--' + onEditingFinished: Globals.BackendWrapper.sampleSetLayerRhoMAtIndex(index, text) + } + + EaComponents.TableViewTextInput { + horizontalAlignment: Text.AlignHCenter + enabled: rowIsMagnetic + text: rowData ? Number(rowData.theta_m).toFixed(2) : '--' + onEditingFinished: Globals.BackendWrapper.sampleSetLayerThetaMAtIndex(index, text) + } + + EaComponents.TableViewCheckBox { + checked: rowIsMagnetic + ToolTip.text: magnetismGroup.supported ? + qsTr("Make this layer magnetic") : + qsTr("Make this layer magnetic (asks to switch the calculation engine)") + onToggled: { + magnetismGroup.errorMessage = '' + Globals.BackendWrapper.sampleSetLayerMagneticAtIndex(index, checked) + // The click already moved the box; the backend may ask + // first (engine switch) or refuse, so follow its state. + checked = Qt.binding(function () { return rowIsMagnetic }) + } + } + + mouseArea.onPressed: { + if (Globals.BackendWrapper.sampleCurrentLayerIndex !== index) { + Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) + } + } + } + } + + EaElements.Label { + visible: magnetismGroup.supported + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.WordWrap + width: EaStyle.Sizes.sideBarContentWidth + text: qsTr("θM = 270° aligns the moment with the guide field, giving no spin-flip.\nρM and θM appear in the Analysis parameter table and can be fitted.") + } + + EaElements.Label { + visible: magnetismGroup.errorMessage !== '' + color: EaStyle.Colors.red + wrapMode: Text.WordWrap + width: EaStyle.Sizes.sideBarContentWidth + text: magnetismGroup.errorMessage + } + } + + // The backend refuses to attach magnetism on a calculator that cannot model + // it; show the reason rather than leaving the checkbox silently unchanged. + Connections { + target: Globals.BackendWrapper.activeBackend ? Globals.BackendWrapper.activeBackend.sample : null + enabled: target !== null + ignoreUnknownSignals: true + function onMagnetismFailed(message) { + magnetismGroup.errorMessage = message + } + // Magnetism needs another engine: ask before changing a project-wide + // setting that also invalidates the current fit. + function onMagnetismNeedsEngine(index, engine) { + engineSwitchDialog.layerIndex = index + engineSwitchDialog.engine = engine + engineSwitchDialog.open() + } + } + + EaElements.Dialog { + id: engineSwitchDialog + + property int layerIndex: -1 + property string engine: '' + + title: qsTr("Switch calculation engine?") + standardButtons: Dialog.Ok | Dialog.Cancel + closePolicy: Popup.CloseOnEscape + + onAccepted: { + Globals.BackendWrapper.sampleEnableMagnetismWithEngineAtIndex(layerIndex, engine) + layerIndex = -1 + } + onRejected: layerIndex = -1 + + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.Label { + wrapMode: Text.WordWrap + width: EaStyle.Sizes.fontPixelSize * 26 + text: qsTr("Magnetic layers can only be modelled by %1, and this project uses %2.") + .arg(engineSwitchDialog.engine) + .arg(Globals.BackendWrapper.sampleCalculationEngines[ + Globals.BackendWrapper.sampleCalculationEngineIndex] ?? '') + } + + EaElements.Label { + wrapMode: Text.WordWrap + width: EaStyle.Sizes.fontPixelSize * 26 + text: qsTr("Switching changes the calculation engine: reflectivity is recalculated and any fit results become stale. The sample and the data are unaffected, and the engine can be changed back once no layer is magnetic.") + } + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/qmldir b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/qmldir index 133dd677..a732466f 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/qmldir +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/qmldir @@ -3,6 +3,8 @@ module Groups AssemblyEditor AssemblyEditor.qml LoadSample LoadSample.qml +MagneticProfile MagneticProfile.qml +Magnetism Magnetism.qml MaterialEditor MaterialEditor.qml ModelEditor ModelEditor.qml ModelSelector ModelSelector.qml diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml index 628c7e87..d80562a1 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml @@ -24,4 +24,12 @@ EaComponents.SideBarColumn { collapsed: true enabled: Globals.BackendWrapper.analysisIsFitFinished } + Groups.Magnetism { + collapsed: true + enabled: Globals.BackendWrapper.analysisIsFitFinished + } + Groups.MagneticProfile { + collapsed: false + enabled: Globals.BackendWrapper.analysisIsFitFinished + } } diff --git a/EasyReflectometryApp/Gui/SldChart.qml b/EasyReflectometryApp/Gui/SldChart.qml index c39fdcfe..d856a118 100644 --- a/EasyReflectometryApp/Gui/SldChart.qml +++ b/EasyReflectometryApp/Gui/SldChart.qml @@ -35,6 +35,58 @@ Rectangle { // Store dynamically created series property var sldSeries: [] + // Magnetic profile series: one entry {series, modelIndex, curve} per drawn + // magnetic curve. Empty unless a model carries magnetism, so a non-magnetic + // project draws exactly the nuclear curves it always did. + property var magneticSeries: [] + property var visibleMagneticCurves: Globals.BackendWrapper.plottingVisibleSldCurves + property bool anyModelMagnetic: Globals.BackendWrapper.plottingAnyModelHasMagnetism + + // Which series *should* exist: model, curve, and how many pieces the curve + // comes in. Making a second model magnetic does not change + // `anyModelMagnetic`, and a moment going to zero changes only the number of + // theta_m pieces — neither would rebuild the series without comparing this. + function magneticSeriesSignature() { + const models = Globals.BackendWrapper.sampleModels + const curves = Globals.BackendWrapper.plottingVisibleSldCurves + let parts = [] + for (let i = 0; i < models.length; i++) { + if (!Globals.BackendWrapper.plottingModelHasMagnetism(i)) { + continue + } + for (let c = 0; c < curves.length; c++) { + const pieces = Globals.BackendWrapper.plottingGetMagneticSldSegmentsForModel(i, curves[c]).length + parts.push(i + ':' + curves[c] + ':' + pieces) + } + } + return parts.join('|') + } + + property string drawnMagneticSignature: '' + + // Line style per magnetic curve; the model colour carries the identity. + function magneticCurveStyle(curve) { + switch (curve) { + case 'spin_up': return {dash: Qt.DashLine, width: 1.5, label: qsTr("ρ↑ (spin-up potential)")} + case 'spin_down': return {dash: Qt.DashLine, width: 1.5, label: qsTr("ρ↓ (spin-down potential)")} + case 'rho_m': return {dash: Qt.DotLine, width: 1.5, label: qsTr("ρM (magnetic SLD)")} + case 'theta_m': return {dash: Qt.DashDotLine, width: 1.0, label: qsTr("θM (moment angle)")} + } + return {dash: Qt.SolidLine, width: 1.0, label: curve} + } + + // Slight shade variations of the model colour, one per magnetic curve: the + // hue still says "which model", the shade helps tell the curves apart. + function magneticCurveColor(curve, baseColor) { + switch (curve) { + case 'spin_up': return Qt.lighter(baseColor, 1.15) + case 'spin_down': return Qt.darker(baseColor, 1.2) + case 'rho_m': return Qt.lighter(baseColor, 1.35) + case 'theta_m': return Qt.darker(baseColor, 1.4) + } + return baseColor + } + ChartView { id: chartView @@ -88,6 +140,21 @@ Rectangle { } } + // Right-hand axis for the moment angle, which shares neither the units + // nor the range of the SLD curves. Only shown while θM is drawn. + ValueAxis { + id: axisThetaM + titleText: "θM (°)" + visible: root.visibleMagneticCurves.indexOf('theta_m') !== -1 && root.anyModelMagnetic + min: Globals.BackendWrapper.plottingSldThetaMinY - 5 + max: Globals.BackendWrapper.plottingSldThetaMaxY + 5 + color: EaStyle.Colors.chartAxis + // The SLD axis already draws the grid; a second one would clutter. + gridVisible: false + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + } + function resetAxes() { axisX.min = axisX.minAfterReset axisX.max = axisX.maxAfterReset @@ -189,6 +256,21 @@ Rectangle { color: Globals.BackendWrapper.sampleModels[index].color } } + + // One row per drawn magnetic curve; absent for a non-magnetic + // project, where this Repeater has an empty model. + Repeater { + model: root.magneticSeries.length + EaElements.Label { + readonly property var entry: root.magneticSeries[index] + visible: entry.inLegend + height: visible ? implicitHeight : 0 + text: '┄ ' + root.magneticCurveStyle(entry.curve).label + ' ' + + (Globals.BackendWrapper.sampleModels[entry.modelIndex] + ? Globals.BackendWrapper.sampleModels[entry.modelIndex].label : '') + color: entry.series ? entry.series.color : EaStyle.Colors.chartLabels + } + } } } @@ -343,17 +425,61 @@ Rectangle { Qt.callLater(recreateAllSeries) } - // Refresh all chart series when data changes + // A curve was switched on/off, or a layer became (non-)magnetic: the set of + // series changes, so they are rebuilt rather than just refilled. + onVisibleMagneticCurvesChanged: Qt.callLater(rebuildAndFitAxis) + onAnyModelMagneticChanged: Qt.callLater(rebuildAndFitAxis) + + // Adding a curve changes the range bindings, but the live axis keeps the + // values it was last given — a taller curve would simply be drawn outside + // it. Rebuild, then grow the axis to cover the new range; a zoom that still + // contains everything is left alone. + function rebuildAndFitAxis() { + recreateAllSeries() + Qt.callLater(growAxisToVisibleRange) + } + + function growAxisToVisibleRange() { + const low = axisY.minAfterReset + const high = axisY.maxAfterReset + if (low < axisY.min) { + axisY.min = low + } + if (high > axisY.max) { + axisY.max = high + } + } + + // Refresh all chart series when data changes. One backend pass emits + // several of these signals back to back; Qt.callLater collapses the + // pending calls so the chart is rebuilt or refilled once per burst. + function scheduleChartRefresh() { + Qt.callLater(dispatchChartRefresh) + } + + function dispatchChartRefresh() { + // A model may have become (non-)magnetic without changing whether + // *any* model is: then the series set itself has to be rebuilt. + if (magneticSeriesSignature() !== drawnMagneticSignature) { + recreateAllSeries() + } else { + refreshAllCharts() + } + } + Connections { target: Globals.BackendWrapper function onSamplePageDataChanged() { - refreshAllCharts() + root.scheduleChartRefresh() + } + function onMagneticProfileChanged() { + root.scheduleChartRefresh() } function onSamplePageResetAxes() { resetAxesTimer.start() } function onPlotModeChanged() { - refreshAllCharts() + root.scheduleChartRefresh() resetAxesTimer.start() } function onChartAxesResetRequested() { @@ -381,8 +507,14 @@ Rectangle { } } sldSeries = [] + clearMagneticSeries() + + // Build into local arrays and assign once: mutating an array held by a + // `property var` does not notify its bindings, so a push()ed legend row + // would never appear. + let newSldSeries = [] + let newMagneticSeries = [] - // Create new series for each model const models = Globals.BackendWrapper.sampleModels for (let k = 0; k < models.length; k++) { const line = chartView.createSeries(ChartView.SeriesTypeLine, models[k].label, axisX, axisY) @@ -390,22 +522,93 @@ Rectangle { line.width = 2 line.useOpenGL = EaGlobals.Vars.useOpenGL line.hovered.connect((point, state) => showMainTooltip(point, state)) - sldSeries.push(line) + newSldSeries.push(line) + + newMagneticSeries = newMagneticSeries.concat(magneticSeriesForModel(k, models[k])) } + sldSeries = newSldSeries + magneticSeries = newMagneticSeries + drawnMagneticSignature = magneticSeriesSignature() refreshAllCharts() } + function clearMagneticSeries() { + for (let i = 0; i < magneticSeries.length; i++) { + if (magneticSeries[i].series) { + chartView.removeSeries(magneticSeries[i].series) + } + } + magneticSeries = [] + } + + // Magnetic curves of one model. Nothing is created for a non-magnetic + // model, so the chart of an ordinary sample is unchanged. A curve that comes + // in several pieces (theta_m exists only where there is a moment) gets one + // series per piece, so no line is drawn across the gap between two magnetic + // regions; only the first piece carries a legend row. + function magneticSeriesForModel(modelIndex, model) { + let created = [] + if (!Globals.BackendWrapper.plottingModelHasMagnetism(modelIndex)) { + return created + } + const curves = Globals.BackendWrapper.plottingVisibleSldCurves + for (let c = 0; c < curves.length; c++) { + const curve = curves[c] + const style = magneticCurveStyle(curve) + const yAxis = curve === 'theta_m' ? axisThetaM : axisY + const segments = Globals.BackendWrapper.plottingGetMagneticSldSegmentsForModel(modelIndex, curve) + for (let s = 0; s < segments.length; s++) { + const line = chartView.createSeries(ChartView.SeriesTypeLine, + model.label + ' ' + curve + ' ' + s, + axisX, yAxis) + // Same hue as the model, different dash and shade: colour stays + // "which model", the pattern and shade say "which curve". + line.color = magneticCurveColor(curve, model.color) + line.width = style.width + line.style = style.dash + line.useOpenGL = EaGlobals.Vars.useOpenGL + line.hovered.connect((point, state) => showMainTooltip(point, state)) + created.push({series: line, modelIndex: modelIndex, curve: curve, segment: s, inLegend: s === 0}) + } + } + return created + } + function refreshAllCharts() { const models = Globals.BackendWrapper.sampleModels for (let i = 0; i < sldSeries.length && i < models.length; i++) { const series = sldSeries[i] - if (series) { - series.clear() - const points = Globals.BackendWrapper.plottingGetSldDataPointsForModel(i) - for (let p = 0; p < points.length; p++) { - series.append(points[p].x, points[p].y) - } + if (!series) { + continue + } + // The backend fills the series in one call; the append() loop is + // the fallback for a backend without the fill API (mock). + if (Globals.BackendWrapper.plottingFillSldSeriesForModel(series, i)) { + continue + } + series.clear() + const points = Globals.BackendWrapper.plottingGetSldDataPointsForModel(i) + for (let p = 0; p < points.length; p++) { + series.append(points[p].x, points[p].y) + } + } + + for (let m = 0; m < magneticSeries.length; m++) { + const entry = magneticSeries[m] + if (!entry.series) { + continue + } + if (Globals.BackendWrapper.plottingFillMagneticSldSegmentSeries(entry.series, entry.modelIndex, + entry.curve, entry.segment)) { + continue + } + entry.series.clear() + const magneticPoints = Globals.BackendWrapper.plottingGetMagneticSldSegment(entry.modelIndex, + entry.curve, + entry.segment) + for (let q = 0; q < magneticPoints.length; q++) { + entry.series.append(magneticPoints[q].x, magneticPoints[q].y) } } } diff --git a/EasyReflectometryApp/Gui/SpinAsymmetryChart.qml b/EasyReflectometryApp/Gui/SpinAsymmetryChart.qml new file mode 100644 index 00000000..d03be0cc --- /dev/null +++ b/EasyReflectometryApp/Gui/SpinAsymmetryChart.qml @@ -0,0 +1,297 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtCharts + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Globals as EaGlobals +import EasyApplication.Gui.Elements as EaElements + +import Gui.Globals as Globals + +// Spin asymmetry SA(q) = (R↑↑ − R↓↓) / (R↑↑ + R↓↓) of the current experiment. +// Shared by the Experiment page (measured only — "do I have a magnetic +// signal?") and the Analysis page (measured plus the model curve — "does the +// magnetic part of the fit match?"). Both pages only show it when the current +// experiment measured both non-spin-flip channels. +Rectangle { + id: root + + color: EaStyle.Colors.chartBackground + + // Draw the model SA on top of the data (Analysis page). + property bool showCalculated: false + property bool showLegend: false + + readonly property alias chartView: chartView + + readonly property int experimentIndex: Globals.BackendWrapper.analysisExperimentsCurrentIndex + readonly property int maskedPoints: Globals.BackendWrapper.plottingSpinAsymmetryMaskedPoints + readonly property int outOfOverlapPoints: Globals.BackendWrapper.plottingSpinAsymmetryOutOfOverlapPoints + readonly property bool calculatedAvailable: root.showCalculated + && Globals.BackendWrapper.plottingSpinAsymmetryCalculatedAvailable + + ChartView { + id: chartView + + anchors.fill: parent + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + anchors.margins: -12 + + antialiasing: true + legend.visible: false + backgroundRoundness: 0 + backgroundColor: EaStyle.Colors.chartBackground + plotAreaColor: EaStyle.Colors.chartPlotAreaBackground + + property bool allowZoom: true + property bool allowHover: true + + property double xRange: Globals.BackendWrapper.plottingSpinAsymmetryMaxX + - Globals.BackendWrapper.plottingSpinAsymmetryMinX + + ValueAxis { + id: axisX + titleText: "q (1/Å)" + property double minAfterReset: Globals.BackendWrapper.plottingSpinAsymmetryMinX - chartView.xRange * 0.02 + property double maxAfterReset: Globals.BackendWrapper.plottingSpinAsymmetryMaxX + chartView.xRange * 0.02 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + property double yRange: Globals.BackendWrapper.plottingSpinAsymmetryMaxY + - Globals.BackendWrapper.plottingSpinAsymmetryMinY + + ValueAxis { + id: axisY + titleText: "Spin asymmetry" + property double minAfterReset: Globals.BackendWrapper.plottingSpinAsymmetryMinY - chartView.yRange * 0.05 + property double maxAfterReset: Globals.BackendWrapper.plottingSpinAsymmetryMaxY + chartView.yRange * 0.05 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + function resetAxes() { + axisX.min = axisX.minAfterReset + axisX.max = axisX.maxAfterReset + axisY.min = axisY.minAfterReset + axisY.max = axisY.maxAfterReset + } + + // SA = 0 is the "no magnetism" reference line. + LineSeries { + id: zeroLine + axisX: axisX + axisY: axisY + color: EaStyle.Colors.chartGridLine + width: 1 + style: Qt.DashLine + useOpenGL: false + } + + ScatterSeries { + id: measuredSerie + axisX: axisX + axisY: axisY + markerSize: 6 + borderWidth: 0 + color: EaStyle.Colors.chartForegrounds[1] + useOpenGL: EaGlobals.Vars.useOpenGL + onHovered: (point, state) => root.showMainTooltip(point, state) + } + + LineSeries { + id: errorUpperSerie + axisX: axisX + axisY: axisY + color: measuredSerie.color + width: 1 + style: Qt.DotLine + useOpenGL: EaGlobals.Vars.useOpenGL + } + + LineSeries { + id: errorLowerSerie + axisX: axisX + axisY: axisY + color: measuredSerie.color + width: 1 + style: Qt.DotLine + useOpenGL: EaGlobals.Vars.useOpenGL + } + + LineSeries { + id: calculatedSerie + axisX: axisX + axisY: axisY + color: "#E67E22" + width: 2 + visible: root.calculatedAvailable + useOpenGL: EaGlobals.Vars.useOpenGL + } + + // Tool buttons + Row { + z: 1 + x: chartView.plotArea.x + chartView.plotArea.width - width + y: chartView.plotArea.y - height - EaStyle.Sizes.fontPixelSize + spacing: 0.25 * EaStyle.Sizes.fontPixelSize + + EaElements.TabButton { + checked: root.showLegend + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "align-left" + ToolTip.text: root.showLegend ? qsTr("Hide legend") : qsTr("Show legend") + onClicked: root.showLegend = checked + } + + EaElements.TabButton { + checkable: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "home" + ToolTip.text: qsTr("Reset axes") + onClicked: chartView.resetAxes() + } + } + + // Legend + Rectangle { + visible: root.showLegend + + x: chartView.plotArea.x + chartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize + y: chartView.plotArea.y + EaStyle.Sizes.fontPixelSize + width: childrenRect.width + height: childrenRect.height + + color: EaStyle.Colors.mainContentBackgroundHalfTransparent + border.color: EaStyle.Colors.chartGridLine + + Column { + leftPadding: EaStyle.Sizes.fontPixelSize + rightPadding: EaStyle.Sizes.fontPixelSize + topPadding: EaStyle.Sizes.fontPixelSize * 0.5 + bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.Label { + text: '● ' + qsTr("Measured SA") + color: measuredSerie.color + } + EaElements.Label { + // Connected upper/lower curves, the convention this app + // already uses for the reflectivity charts - not per-point + // error bars. + text: '┈ ' + qsTr("Uncertainty envelope (1σ)") + color: errorUpperSerie.color + } + EaElements.Label { + visible: root.calculatedAvailable + text: '━ ' + qsTr("Calculated SA") + color: calculatedSerie.color + } + } + } + + EaElements.ToolTip { + id: dataToolTip + arrowLength: 0 + textFormat: Text.RichText + } + } + + // Points the backend could not turn into a meaningful SA — say so rather + // than truncating silently. + Column { + x: chartView.plotArea.x + EaStyle.Sizes.fontPixelSize + y: chartView.plotArea.y + EaStyle.Sizes.fontPixelSize + spacing: EaStyle.Sizes.fontPixelSize * 0.2 + + EaElements.Label { + visible: root.maskedPoints > 0 + color: EaStyle.Colors.themeForegroundMinor + text: qsTr("%1 point(s) hidden: R↑↑ + R↓↓ is not significantly above zero there").arg(root.maskedPoints) + } + + EaElements.Label { + visible: root.outOfOverlapPoints > 0 + color: EaStyle.Colors.themeForegroundMinor + text: qsTr("%1 point(s) hidden: the ↑↑ and ↓↓ channels do not cover the same q there") + .arg(root.outOfOverlapPoints) + } + } + + Component.onCompleted: Qt.callLater(refresh) + + onExperimentIndexChanged: Qt.callLater(refresh) + onShowCalculatedChanged: Qt.callLater(refresh) + + Connections { + target: Globals.BackendWrapper + function onSpinAsymmetryChanged() { + // Qt.callLater collapses a burst of notifications into one refresh. + Qt.callLater(root.refresh) + } + } + + function refresh() { + measuredSerie.clear() + errorUpperSerie.clear() + errorLowerSerie.clear() + calculatedSerie.clear() + zeroLine.clear() + + const points = Globals.BackendWrapper.plottingGetSpinAsymmetryPoints(root.experimentIndex) + for (let i = 0; i < points.length; i++) { + measuredSerie.append(points[i].x, points[i].y) + errorUpperSerie.append(points[i].x, points[i].errorUpper) + errorLowerSerie.append(points[i].x, points[i].errorLower) + } + + if (points.length > 0) { + zeroLine.append(points[0].x, 0) + zeroLine.append(points[points.length - 1].x, 0) + } + + if (root.showCalculated) { + const calculated = Globals.BackendWrapper.plottingGetSpinAsymmetryCalculatedPoints(root.experimentIndex) + for (let c = 0; c < calculated.length; c++) { + calculatedSerie.append(calculated[c].x, calculated[c].y) + } + } + + Qt.callLater(chartView.resetAxes) + } + + function showMainTooltip(point, state) { + if (!chartView.allowHover) { + return + } + const pos = chartView.mapToPosition(Qt.point(point.x, point.y)) + dataToolTip.x = pos.x + dataToolTip.y = pos.y + dataToolTip.text = `

q: ${point.x.toFixed(4)}SA: ${point.y.toFixed(4)}

` + dataToolTip.parent = chartView + dataToolTip.visible = state + } +} diff --git a/EasyReflectometryApp/Gui/qmldir b/EasyReflectometryApp/Gui/qmldir index 3c7bbf56..018d661c 100644 --- a/EasyReflectometryApp/Gui/qmldir +++ b/EasyReflectometryApp/Gui/qmldir @@ -1,7 +1,10 @@ module Gui ApplicationWindow ApplicationWindow.qml +CalculationEngineControl CalculationEngineControl.qml +MagneticProfileControl MagneticProfileControl.qml PlotControlRefLines PlotControlRefLines.qml +SpinAsymmetryChart SpinAsymmetryChart.qml SideBarWithFooter SideBarWithFooter.qml SldChart SldChart.qml StatusBar StatusBar.qml diff --git a/EasyReflectometryApp/main.py b/EasyReflectometryApp/main.py index 0854aaf5..93562d9a 100644 --- a/EasyReflectometryApp/main.py +++ b/EasyReflectometryApp/main.py @@ -22,6 +22,7 @@ # Suppress matplotlib debug/verbose logging (especially font lookup spam) import logging as _logging + _logging.getLogger('matplotlib').setLevel(_logging.WARNING) _logging.getLogger('matplotlib.font_manager').setLevel(_logging.WARNING) diff --git a/pyproject.toml b/pyproject.toml index 47eb19f9..c3984c88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,8 +31,8 @@ classifiers = [ requires-python = '>=3.12' dependencies = [ 'easyapplication', - #'easyreflectometry @ git+https://github.com/easyscience/reflectometry-lib.git@develop', - 'easyreflectometry', + 'easyreflectometry @ git+https://github.com/easyscience/reflectometry-lib.git@cffcd18d5428928124f2da32dd988abe7a2ce080', + #'easyreflectometry', 'asteval', 'PySide6', 'toml', diff --git a/tests/factories.py b/tests/factories.py index d13be5e4..6e01406c 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -341,6 +341,11 @@ def __init__( self.current_layer_index = 0 self._calculator = FakeCalculatorController(calculator_interfaces or ['refnx', 'refl1d']) self._calculator_name = calculator_name + # Engine capability, as the real Project reports it. + self.calculators_supporting_magnetism = [ + name for name in (calculator_interfaces or ['refnx', 'refl1d']) if name == 'refl1d' + ] + self.models_have_magnetism = False self.minimizer = FakeMinimizerValue(minimizer_name) self._fitter = None self.fitter = None @@ -394,12 +399,28 @@ def replace_models_from_orso(self, sample): self.calls.append(('replace_models_from_orso', sample)) self.models[:] = [sample] - def experimental_data_for_model_at_index(self, index): - self.calls.append(('experimental_data_for_model_at_index', index)) + def experimental_data_for_model_at_index(self, index, channel=None): + self.calls.append(('experimental_data_for_model_at_index', index, channel)) if index >= len(self.models): raise IndexError(index) return object() + def suggest_polarized_channel_assignment(self, paths): + self.calls.append(('suggest_polarized_channel_assignment', tuple(paths))) + # Simple deterministic stub: uu/dd tokens resolve, everything else does not. + suggestion = {} + for path in paths: + if '_uu' in path: + suggestion[path] = SimpleNamespace(value='pp') + elif '_dd' in path: + suggestion[path] = SimpleNamespace(value='mm') + else: + suggestion[path] = None + return suggestion + + def load_polarized_experiment(self, channel_to_path): + self.calls.append(('load_polarized_experiment', dict(channel_to_path))) + def set_path_project_parent(self, path): self.calls.append(('set_path_project_parent', path)) diff --git a/tests/test_analysis_bayesian.py b/tests/test_analysis_bayesian.py index edf2fa59..53099983 100644 --- a/tests/test_analysis_bayesian.py +++ b/tests/test_analysis_bayesian.py @@ -3,8 +3,6 @@ """Exhaustive tests for Bayesian functionality in the analysis backend layer.""" import logging -import os -import tempfile from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock @@ -18,7 +16,6 @@ from EasyReflectometryApp.Backends.Py import analysis as analysis_module from tests.factories import make_project - # --------------------------------------------------------------------------- # Stub helpers (reuse + extend the stubs from test_analysis.py) # --------------------------------------------------------------------------- @@ -210,7 +207,6 @@ def analysis(monkeypatch): monkeypatch.setattr(analysis_module, 'MinimizersLogic', StubMinimizersLogic) monkeypatch.setattr(analysis_module, 'FitterWorker', StubWorker) # Replace the fitting logic with our stub - from EasyReflectometryApp.Backends.Py.logic.fitting import Fitting monkeypatch.setattr(analysis_module, 'FittingLogic', lambda _project_lib: StubFittingLogic()) project = make_project() diff --git a/tests/test_logic_fitting.py b/tests/test_logic_fitting.py index c086836c..eac6a6a4 100644 --- a/tests/test_logic_fitting.py +++ b/tests/test_logic_fitting.py @@ -36,6 +36,31 @@ class FakeMultiFitter: def __init__(self, *models): self.models = models self.easy_science_multi_fitter = FakeEasyScienceMultiFitter() + self.fit_datasets = [] + self.fit_channels = [] + + @classmethod + def for_experiments(cls, experiments, objective='hybrid'): + """Mirror the library factory: one dataset per measured spin channel.""" + models = [] + datasets = [] + channels = [] + for experiment in experiments: + model = experiment.model + if not any(model is known for known in models): + models.append(model) + experiment_channels = getattr(experiment, 'available_channels', None) + if experiment_channels is None: + datasets.append(experiment) + channels.append(None) + continue + for channel in experiment_channels: + datasets.append(experiment[channel]) + channels.append(channel) + fitter = cls(*models) + fitter.fit_datasets = datasets + fitter.fit_channels = channels + return fitter def install_fake_multifitter(monkeypatch): diff --git a/tests/test_logic_summary.py b/tests/test_logic_summary.py index b721baee..fbce26e9 100644 --- a/tests/test_logic_summary.py +++ b/tests/test_logic_summary.py @@ -265,3 +265,100 @@ def test_summary_make_plot_skips_empty_series_and_does_not_add_legend_without_re assert reflectivity_axis.plot_calls == [] assert reflectivity_axis.errorbar_calls == [] assert reflectivity_axis.legend_called is False + + +class FakePolarizedCalculatorRuntime(FakeCalculatorRuntime): + """Calculator with genuinely different spin cross-sections.""" + + CHANNEL_LEVEL = {'pp': 0.9, 'pm': 0.3, 'mp': 0.2, 'mm': 0.6} + + def reflectivity_profile_channel(self, x, unique_name, channel): + level = self.CHANNEL_LEVEL.get(getattr(channel, 'value', channel)) + if level is None: + raise ValueError(f'Unknown channel {channel}') + return np.asarray(x) * 0 + level + + +class FakePolarizedCalculatorFactory: + def __init__(self, runtime_class=FakePolarizedCalculatorRuntime): + self._runtime_class = runtime_class + + def __call__(self): + return self._runtime_class() + + +class FakeChannelKey(str): + @property + def value(self): + return str(self) + + +class FakePolarizedExperiment: + def __init__(self, name, model, channels): + self.name = name + self.model = model + self._channels = {FakeChannelKey(key): value for key, value in channels.items()} + + @property + def available_channels(self): + return list(self._channels.keys()) + + def __getitem__(self, channel): + return self._channels[FakeChannelKey(channel)] + + +def _polarized_summary_project(tmp_path, calculator_factory): + models = make_model_collection(make_model(name='Model <1>', unique_name='m1', color='#123456')) + project = make_project(models=models) + project.path = tmp_path / 'polarized-report' + project._calculator = calculator_factory + channels = { + name: make_experiment( + name, model=models[0], x=np.array([0.1, 0.2]), y=np.array([1.0, 2.0]), ye=np.array([0.1, 0.2]) + ) + for name in ('pp', 'mm') + } + project.experiments = {0: FakePolarizedExperiment('Polarized <1>', models[0], channels)} + project._experiments = project.experiments + project.sample_data_for_model_at_index = lambda index: SimpleNamespace(x=np.array([0.1]), y=np.array([1.0])) + project.sld_data_for_model_at_index = lambda index: SimpleNamespace(x=np.array([1.0, 2.0]), y=np.array([3.0, 4.0])) + return project + + +def _plot_polarized(tmp_path, monkeypatch, calculator_factory): + monkeypatch.setattr(summary_module, 'SummaryLib', FakeSummaryLib) + project = _polarized_summary_project(tmp_path, calculator_factory) + logic = summary_module.Summary(project) + monkeypatch.setattr(logic, '_plt', lambda: FakePyplot()) + monkeypatch.setattr(logic, '_gridspec', lambda: FakeGridSpecModule) + return logic.make_plot(10.0, 8.0) + + +def test_summary_plots_a_distinct_calculation_per_channel(tmp_path, monkeypatch): + """Each channel label must carry its own cross-section, not one repeated curve.""" + figure = _plot_polarized(tmp_path, monkeypatch, FakePolarizedCalculatorFactory()) + reflectivity_axis = figure.axes[0] + + labelled = [call for call in reflectivity_axis.plot_calls if call[1].get('label')] + assert [call[1]['label'] for call in labelled] == ['Polarized <1> (pp)', 'Polarized <1> (mm)'] + + # Different y values (pp = 0.9, mm = 0.6) and different channel colors. + y_values = [float(np.asarray(call[0][1])[0]) for call in labelled] + assert y_values[0] != y_values[1] + assert labelled[0][1]['color'] != labelled[1][1]['color'] + + +def test_summary_omits_the_overlay_when_a_channel_cannot_be_calculated(tmp_path, monkeypatch): + """A non-magnetic model has no spin cross-sections: show data, no wrong curve.""" + + class NoChannelSupport(FakeCalculatorRuntime): + def reflectivity_profile_channel(self, x, unique_name, channel): + raise ValueError('requires magnetism') + + figure = _plot_polarized(tmp_path, monkeypatch, FakePolarizedCalculatorFactory(NoChannelSupport)) + reflectivity_axis = figure.axes[0] + + labelled = [call for call in reflectivity_axis.plot_calls if call[1].get('label')] + # Both channels still appear in the legend, as measured-only series. + assert [call[1]['label'] for call in labelled] == ['Polarized <1> (pp)', 'Polarized <1> (mm)'] + assert all(call[1].get('ls') == '' for call in labelled) diff --git a/tests/test_magnetic_display.py b/tests/test_magnetic_display.py new file mode 100644 index 00000000..250b84a4 --- /dev/null +++ b/tests/test_magnetic_display.py @@ -0,0 +1,455 @@ +"""Phase 5a/5b/5c: magnetic depth profiles and spin asymmetry in the plotting backend. + +The regression that matters most here is the one at the top: a project without +magnetism and without polarized data must behave exactly as it did before. +""" + +from pathlib import Path + +import numpy as np +import pytest +from easyscience import global_object + +from EasyReflectometryApp.Backends.Py.plotting_1d import Plotting1d + +ROOT = Path(__file__).resolve().parents[1] + + +def _plain_project(): + from easyreflectometry import Project as RealProject + + global_object.map._clear() + project = RealProject() + project.calculator = 'refl1d' + project.default_model() + return project + + +def _magnetic_project(rho_m: float = 3.0, theta_m: float = 270.0): + from easyreflectometry.sample import LayerMagnetism + + project = _plain_project() + # The middle assembly is the only real layer of the default model. + project.models[0].sample[1].layers[0].magnetism = LayerMagnetism(rho_m=rho_m, theta_m=theta_m) + return project + + +def _polarized_project(tmp_path, magnetic: bool = False): + """A project with one pp/mm experiment; optionally with a magnetic model.""" + project = _magnetic_project() if magnetic else _plain_project() + q = np.linspace(0.01, 0.2, 25) + paths = {} + for channel, scale in (('pp', 1.2), ('mm', 0.8)): + path = tmp_path / f'run_{channel}.dat' + reflectivity = scale * np.exp(-q * 30) + np.savetxt(path, np.column_stack([q, reflectivity, 0.001 * reflectivity])) + paths[channel] = str(path) + project.load_polarized_experiment(paths) + return project + + +class TestNonMagneticProjectIsUnchanged: + """The gate: nothing new appears for ordinary, unpolarized work.""" + + def test_no_magnetic_curves_and_no_spin_asymmetry(self, qcore_application): + plotting = Plotting1d(project_lib=_plain_project(), parent=None) + + assert plotting.anyModelHasMagnetism is False + assert plotting.modelHasMagnetism(0) is False + assert plotting.getMagneticSldDataPointsForModel(0, 'spin_up') == [] + assert plotting.spinAsymmetryAvailable is False + assert plotting.getSpinAsymmetryPoints(0) == [] + assert plotting.getSpinAsymmetryCalculatedPoints(0) == [] + + def test_sld_range_is_the_nuclear_range(self, qcore_application): + project = _plain_project() + plotting = Plotting1d(project_lib=project, parent=None) + + nuclear = project.sld_data_for_model_at_index(0) + min_x, max_x, min_y, max_y = plotting._get_all_models_sld_range() + + assert (min_x, max_x) == (nuclear.x.min(), nuclear.x.max()) + assert (min_y, max_y) == (nuclear.y.min(), nuclear.y.max()) + + +class TestMagneticSldCurves: + def test_curves_are_available_for_a_magnetic_model(self, qcore_application): + plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) + + assert plotting.anyModelHasMagnetism is True + assert plotting.modelHasMagnetism(0) is True + for curve in ('spin_up', 'spin_down', 'rho_m', 'theta_m'): + points = plotting.getMagneticSldDataPointsForModel(0, curve) + assert len(points) > 0 + assert set(points[0]) == {'x', 'y'} + + def test_spin_potentials_straddle_the_nuclear_profile(self, qcore_application): + project = _magnetic_project() + plotting = Plotting1d(project_lib=project, parent=None) + + up = np.array([point['y'] for point in plotting.getMagneticSldDataPointsForModel(0, 'spin_up')]) + down = np.array([point['y'] for point in plotting.getMagneticSldDataPointsForModel(0, 'spin_down')]) + nuclear = project.sld_data_for_model_at_index(0).y + + assert up.max() > nuclear.max() + assert down.min() < nuclear.min() + + def test_unknown_curve_is_rejected(self, qcore_application): + plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) + + assert plotting.getMagneticSldDataPointsForModel(0, 'nonsense') == [] + + def test_visible_curves_default_and_toggle(self, qcore_application): + plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) + emitted = {'count': 0} + plotting.magneticProfileChanged.connect(lambda: emitted.__setitem__('count', emitted['count'] + 1)) + + # The spin potentials are the default; rho_m/theta_m are opt-in. + assert plotting.visibleSldCurves == ['spin_up', 'spin_down'] + assert plotting.sldCurveVisible('rho_m') is False + + plotting.setSldCurveVisible('rho_m', True) + assert plotting.sldCurveVisible('rho_m') is True + assert emitted['count'] == 1 + + # The two potentials are one control: hiding one hides both. + plotting.setSldCurveVisible('spin_down', False) + assert plotting.visibleSldCurves == ['rho_m'] + + plotting.setSldCurveVisible('spin_up', True) + assert plotting.visibleSldCurves == ['spin_up', 'spin_down', 'rho_m'] + + def test_unknown_curve_cannot_be_toggled(self, qcore_application): + plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) + + plotting.setSldCurveVisible('nonsense', True) + + assert plotting.visibleSldCurves == ['spin_up', 'spin_down'] + + def test_sld_range_covers_the_visible_magnetic_curves(self, qcore_application): + """rho + rhoM exceeds rho: without this the new curves are clipped.""" + project = _magnetic_project() + plotting = Plotting1d(project_lib=project, parent=None) + + nuclear = project.sld_data_for_model_at_index(0) + _, _, min_y, max_y = plotting._get_all_models_sld_range() + + assert max_y > nuclear.y.max() + assert min_y < nuclear.y.min() + + # Hiding them again restores the nuclear-only range. + plotting.setSldCurveVisible('spin_up', False) + _, _, min_hidden, max_hidden = plotting._get_all_models_sld_range() + assert (min_hidden, max_hidden) == (nuclear.y.min(), nuclear.y.max()) + + def test_theta_axis_range(self, qcore_application): + plotting = Plotting1d(project_lib=_magnetic_project(theta_m=200.0), parent=None) + + low, high = plotting.sldThetaMinY, plotting.sldThetaMaxY + + assert low <= 200.0 <= high + assert high > low # a collapsed axis would draw nothing + + +class TestSpinAsymmetry: + def test_available_for_a_pp_mm_experiment(self, qcore_application, tmp_path): + plotting = Plotting1d(project_lib=_polarized_project(tmp_path), parent=None) + + assert plotting.spinAsymmetryAvailable is True + points = plotting.getSpinAsymmetryPoints(0) + assert len(points) == 25 + assert set(points[0]) == {'x', 'y', 'errorUpper', 'errorLower'} + # pp is 1.2x, mm 0.8x of the same curve: SA = 0.4/2.0 = 0.2 everywhere. + assert all(abs(point['y'] - 0.2) < 1e-9 for point in points) + assert points[0]['errorUpper'] > points[0]['y'] > points[0]['errorLower'] + + def test_axis_range_follows_the_data(self, qcore_application, tmp_path): + plotting = Plotting1d(project_lib=_polarized_project(tmp_path), parent=None) + + assert plotting.spinAsymmetryMinX == pytest.approx(0.01) + assert plotting.spinAsymmetryMaxX == pytest.approx(0.2) + assert -1.05 <= plotting.spinAsymmetryMinY <= plotting.spinAsymmetryMaxY <= 1.05 + + def test_no_calculated_curve_without_a_magnetic_model(self, qcore_application, tmp_path): + plotting = Plotting1d(project_lib=_polarized_project(tmp_path), parent=None) + + assert plotting.spinAsymmetryCalculatedAvailable is False + assert plotting.getSpinAsymmetryCalculatedPoints(0) == [] + + def test_calculated_curve_for_a_magnetic_model(self, qcore_application, tmp_path): + plotting = Plotting1d(project_lib=_polarized_project(tmp_path, magnetic=True), parent=None) + + assert plotting.spinAsymmetryCalculatedAvailable is True + calculated = plotting.getSpinAsymmetryCalculatedPoints(0) + assert len(calculated) == len(plotting.getSpinAsymmetryPoints(0)) + # A magnetic model has a real asymmetry, not the flat zero of a + # non-magnetic one. + assert max(abs(point['y']) for point in calculated) > 1e-3 + + def test_unpolarized_experiment_has_no_asymmetry(self, qcore_application, tmp_path): + project = _plain_project() + q = np.linspace(0.01, 0.2, 25) + path = tmp_path / 'plain.dat' + np.savetxt(path, np.column_stack([q, np.exp(-q * 30), 0.01 * np.exp(-q * 30)])) + project.load_experiment_for_model_at_index(str(path), 0) + plotting = Plotting1d(project_lib=project, parent=None) + + assert plotting.spinAsymmetryAvailable is False + assert plotting.getSpinAsymmetryPoints(0) == [] + + def test_result_is_cached_until_invalidated(self, qcore_application, tmp_path): + plotting = Plotting1d(project_lib=_polarized_project(tmp_path), parent=None) + + first = plotting._spin_asymmetry(0) + assert plotting._spin_asymmetry(0) is first + + plotting.notifySpinAsymmetryChanged() + + assert plotting._spin_asymmetry(0) is not first + + +class TestReviewFixes: + """CR1_PHASE5 findings that are visible at the backend boundary.""" + + def test_magnetic_profiles_are_cached_until_invalidated(self, qcore_application): + """Mo3: each miss is a full refl1d profile evaluation.""" + plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) + + first = plotting._magnetic_sld_profiles(0) + assert plotting._magnetic_sld_profiles(0) is first + + plotting.notifyMagneticProfileChanged() + + assert plotting._magnetic_sld_profiles(0) is not first + + def test_spin_asymmetry_axis_expands_beyond_the_default_window(self, qcore_application, tmp_path): + """Mo2: background-subtracted data can legitimately exceed |SA| = 1.""" + from easyreflectometry.data import DataSet1D + from easyreflectometry.data import PolarizedDataSet + + project = _plain_project() + q = np.linspace(0.01, 0.2, 10) + # R-- slightly negative after background subtraction: SA > 1. + channels = { + 'pp': DataSet1D(name='pp', x=q, y=np.full_like(q, 1.0), ye=np.full_like(q, 1e-12)), + 'mm': DataSet1D(name='mm', x=q, y=np.full_like(q, -0.5), ye=np.full_like(q, 1e-12)), + } + project._experiments[0] = PolarizedDataSet(name='subtracted', channels=channels, model=project.models[0]) + plotting = Plotting1d(project_lib=project, parent=None) + + points = plotting.getSpinAsymmetryPoints(0) + + assert points, 'the points are significant and must be kept' + assert points[0]['y'] == pytest.approx(3.0) + # The axis follows them instead of cutting them off at 1.05. + assert plotting.spinAsymmetryMaxY >= 3.0 + + def test_ordinary_spin_asymmetry_keeps_the_full_default_window(self, qcore_application, tmp_path): + plotting = Plotting1d(project_lib=_polarized_project(tmp_path), parent=None) + + assert plotting.spinAsymmetryMinY == pytest.approx(-1.05) + assert plotting.spinAsymmetryMaxY == pytest.approx(1.05) + + def test_out_of_overlap_points_are_reported(self, qcore_application, tmp_path): + """M2: channels that do not cover the same q must not be extrapolated.""" + from easyreflectometry.data import DataSet1D + from easyreflectometry.data import PolarizedDataSet + + project = _plain_project() + q_pp = np.linspace(0.01, 0.30, 30) + q_mm = np.linspace(0.01, 0.20, 20) + channels = { + 'pp': DataSet1D(name='pp', x=q_pp, y=np.full_like(q_pp, 0.6), ye=np.full_like(q_pp, 1e-12)), + 'mm': DataSet1D(name='mm', x=q_mm, y=np.full_like(q_mm, 0.2), ye=np.full_like(q_mm, 1e-12)), + } + project._experiments[0] = PolarizedDataSet(name='partial', channels=channels, model=project.models[0]) + plotting = Plotting1d(project_lib=project, parent=None) + + assert plotting.spinAsymmetryOutOfOverlapPoints > 0 + assert plotting.spinAsymmetryMaxX <= 0.20 + + def test_theta_curve_is_restricted_to_the_magnetic_region(self, qcore_application): + """m1: no moment, no meaningful angle.""" + plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) + + theta = plotting.getMagneticSldDataPointsForModel(0, 'theta_m') + rho_m = plotting.getMagneticSldDataPointsForModel(0, 'rho_m') + + assert 0 < len(theta) < len(rho_m) + + +class TestCr2Fixes: + """CR2_PHASE5 findings that are visible at the backend/QML-source boundary.""" + + def test_theta_curve_comes_in_pieces_that_are_not_joined(self, qcore_application): + """Two magnetic layers with a gap must not be joined across the spacer.""" + from easyreflectometry.model import Model + from easyreflectometry.model import ModelCollection + from easyreflectometry.model import PercentageFwhm + from easyreflectometry.sample import Layer + from easyreflectometry.sample import LayerMagnetism + from easyreflectometry.sample import Material + from easyreflectometry.sample import Multilayer + from easyreflectometry.sample import Sample + + global_object.map._clear() + from easyreflectometry import Project as RealProject + + vacuum = Material(sld=0, isld=0, name='Vacuum') + iron = Material(sld=8.0, isld=0, name='Fe') + spacer = Material(sld=4.0, isld=0, name='Spacer') + si = Material(sld=2.047, isld=0, name='Si') + layers = [ + Layer(material=vacuum, thickness=0, roughness=0, name='Vacuum Superphase'), + Layer(material=iron, thickness=80, roughness=2, magnetism=LayerMagnetism(rho_m=4.0), name='Fe top'), + Layer(material=spacer, thickness=120, roughness=2, name='Spacer'), + Layer(material=iron, thickness=80, roughness=2, magnetism=LayerMagnetism(rho_m=4.0), name='Fe bottom'), + Layer(material=si, thickness=0, roughness=2, name='Si Subphase'), + ] + sample = Sample(*[Multilayer(layer) for layer in layers], name='Multilayer') + model = Model(sample=sample, scale=1, background=0, name='Two magnetic layers') + model.resolution_function = PercentageFwhm(0) + project = RealProject() + project.calculator = 'refl1d' + project.models = ModelCollection(model) + plotting = Plotting1d(project_lib=project, parent=None) + + segments = plotting.getMagneticSldSegmentsForModel(0, 'theta_m') + + assert len(segments) == 2 + assert all(len(segment) > 0 for segment in segments) + # The gap between the pieces is the non-magnetic spacer. + assert segments[1][0]['x'] - segments[0][-1]['x'] > 50 + + def test_continuous_curves_are_a_single_piece(self, qcore_application): + plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) + + for curve in ('spin_up', 'spin_down', 'rho_m'): + assert len(plotting.getMagneticSldSegmentsForModel(0, curve)) == 1 + + def test_segment_accessor_is_bounds_checked(self, qcore_application): + plotting = Plotting1d(project_lib=_magnetic_project(), parent=None) + + assert plotting.getMagneticSldSegment(0, 'rho_m', 0) + assert plotting.getMagneticSldSegment(0, 'rho_m', 7) == [] + assert plotting.getMagneticSldSegment(0, 'nonsense', 0) == [] + + def test_sld_chart_applies_the_new_range_to_the_live_axis(self): + """Enabling a curve must not leave it drawn outside the current axis.""" + chart = (ROOT / 'EasyReflectometryApp' / 'Gui' / 'SldChart.qml').read_text(encoding='utf-8') + + assert 'onVisibleMagneticCurvesChanged: Qt.callLater(rebuildAndFitAxis)' in chart + assert 'function growAxisToVisibleRange()' in chart + assert 'axisY.min = low' in chart and 'axisY.max = high' in chart + + def test_sld_chart_assigns_series_arrays_instead_of_mutating_them(self): + """A push() into a `property var` does not notify the legend Repeater.""" + chart = (ROOT / 'EasyReflectometryApp' / 'Gui' / 'SldChart.qml').read_text(encoding='utf-8') + + assert 'magneticSeries = newMagneticSeries' in chart + assert 'sldSeries = newSldSeries' in chart + assert 'magneticSeries.push(' not in chart + + def test_spin_asymmetry_chart_names_the_envelope_honestly(self): + chart = (ROOT / 'EasyReflectometryApp' / 'Gui' / 'SpinAsymmetryChart.qml').read_text(encoding='utf-8') + + assert 'Uncertainty envelope' in chart + + def test_non_magnetic_pages_keep_their_qml_gates(self): + """The §5.6 promise, pinned in the QML sources the app has no harness for.""" + experiment_tabs = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Experiment' / 'MainContent' / 'ExperimentTabs.qml' + ).read_text(encoding='utf-8') + analysis_tabs = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Analysis' / 'MainContent' / 'SldView.qml' + ).read_text(encoding='utf-8') + magnetic_group = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Sample' / 'Sidebar' / 'Basic' / 'Groups' + / 'MagneticProfile.qml' + ).read_text(encoding='utf-8') + + # Experiment page: no tab strip and no height without spin asymmetry. + assert 'visible: root.spinAsymmetryAvailable' in experiment_tabs + assert 'Layout.preferredHeight: root.spinAsymmetryAvailable ? EaStyle.Sizes.toolButtonHeight : 0' in experiment_tabs + assert 'currentIndex: root.spinAsymmetryAvailable ? tabBar.currentIndex : 0' in experiment_tabs + # Analysis page: the third tab is absent and never stays selected. + assert 'visible: root.spinAsymmetryAvailable' in analysis_tabs + assert 'tabBar.currentIndex = 0' in analysis_tabs + # Sample page: the magnetic controls do not exist without magnetism. + assert 'visible: Globals.BackendWrapper.plottingAnyModelHasMagnetism' in magnetic_group + + def test_series_signature_tracks_the_number_of_pieces(self): + """A moment going to zero changes the piece count, not the curve set.""" + chart = (ROOT / 'EasyReflectometryApp' / 'Gui' / 'SldChart.qml').read_text(encoding='utf-8') + + # The signature must include the piece count, or the chart only refills + # and never creates/removes the series for a region that appeared or + # disappeared. + assert 'plottingGetMagneticSldSegmentsForModel(i, curves[c]).length' in chart + assert "parts.push(i + ':' + curves[c] + ':' + pieces)" in chart + + +class TestCalculationEngineUx: + """Enabling magnetism must not point at a page the user cannot reach.""" + + def test_magnetism_group_offers_the_switch_instead_of_the_analysis_page(self): + group = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Sample' / 'Sidebar' / 'Basic' / 'Groups' + / 'Magnetism.qml' + ).read_text(encoding='utf-8') + + # The old dead end pointed at a tab that is disabled until the user has + # been through the Experiment page. + assert 'Analysis page' not in group + assert "Ticking 'Magn.' offers to switch this project to it" in group + assert 'function onMagnetismNeedsEngine(index, engine)' in group + assert 'sampleEnableMagnetismWithEngineAtIndex' in group + # The checkbox follows the backend, since confirming happens later. + assert 'checked = Qt.binding(function () {' in group + + def test_engine_selector_is_available_on_the_sample_page(self): + layout = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Sample' / 'Sidebar' / 'Advanced' / 'Layout.qml' + ).read_text(encoding='utf-8') + group = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Sample' / 'Sidebar' / 'Advanced' / 'Groups' + / 'CalculationEngine.qml' + ).read_text(encoding='utf-8') + analysis_group = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Analysis' / 'Sidebar' / 'Advanced' / 'Groups' + / 'Calculator.qml' + ).read_text(encoding='utf-8') + + assert 'Groups.CalculationEngine' in layout + # Both pages drive the same control, so they cannot disagree. + assert 'Gui.CalculationEngineControl' in group + assert 'Gui.CalculationEngineControl' in analysis_group + + def test_engine_rejection_is_shown_in_a_dialog(self): + """Logs are invisible in the GUI, so a refused switch must say why. + + The dialog lives in the application window: the engine selector is + instantiated on two pages, and per-instance dialogs would stack. + """ + window = (ROOT / 'EasyReflectometryApp' / 'Gui' / 'ApplicationWindow.qml').read_text(encoding='utf-8') + control = (ROOT / 'EasyReflectometryApp' / 'Gui' / 'CalculationEngineControl.qml').read_text(encoding='utf-8') + + assert 'function onCalculationEngineRejected(message)' in window + assert 'engineRejectionDialog.open()' in window + assert 'onCalculationEngineRejected' not in control + + def test_profile_failure_is_shown_in_the_sidebar(self): + """A magnetic model losing its curves must not be a silent debug log.""" + control = (ROOT / 'EasyReflectometryApp' / 'Gui' / 'MagneticProfileControl.qml').read_text(encoding='utf-8') + wrapper = (ROOT / 'EasyReflectometryApp' / 'Gui' / 'Globals' / 'BackendWrapper.qml').read_text(encoding='utf-8') + + assert 'plottingMagneticProfileError' in control + assert 'plottingMagneticProfileError' in wrapper + + def test_import_dialog_mentions_the_engine_limitation(self): + dialog = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Experiment' / 'Sidebar' / 'Basic' / 'Popups' + / 'PolarizedChannelAssignment.qml' + ).read_text(encoding='utf-8') + + assert 'sampleCalculationEnginesSupportingMagnetism' in dialog diff --git a/tests/test_polarized_analysis.py b/tests/test_polarized_analysis.py new file mode 100644 index 00000000..c9357b51 --- /dev/null +++ b/tests/test_polarized_analysis.py @@ -0,0 +1,446 @@ +"""Phase 4: magnetism editing, magnetic parameters, polarized fitting and analysis charts. + +These run against the real library rather than stubs: the point is that the +calculator ends up in the right state and that each spin channel really gets its +own cross-section, which a stub cannot demonstrate. +""" + +import numpy as np +import pytest +from easyreflectometry import Project as RealProject +from easyreflectometry.sample import LayerMagnetism + +from EasyReflectometryApp.Backends.Py.logic.fitting import Fitting as FittingLogic +from EasyReflectometryApp.Backends.Py.logic.layers import Layers as LayersLogic +from EasyReflectometryApp.Backends.Py.logic.parameters import Parameters as ParametersLogic +from EasyReflectometryApp.Backends.Py.plotting_1d import Plotting1d + + +def _project(calculator='refl1d'): + project = RealProject() + project.calculator = calculator + project.default_model() + project.current_model_index = 0 + # Assembly 1 is the only one with an editable (non superphase/subphase) layer. + project.current_assembly_index = 1 + return project + + +def _magnetic_project(rho_m=5.0, theta_m=40.0): + project = _project() + layer = project.models[0].sample[1].layers[0] + layer.magnetism = LayerMagnetism(rho_m=rho_m, theta_m=theta_m) + project._sync_parameter_states() + return project + + +def _write_channels(tmp_path, model_project=None, names=('run_uu.dat', 'run_dd.dat')): + """Write one plain-text file per channel, detectable by its filename token.""" + q = np.linspace(0.01, 0.2, 20) + reflectivity = np.exp(-q * 30) + paths = [] + for name in names: + path = tmp_path / name + np.savetxt(path, np.column_stack([q, reflectivity, 0.01 * reflectivity])) + paths.append(str(path)) + return paths + + +class TestLayerMagnetismEditing: + def test_rows_describe_every_layer_of_the_assembly(self): + logic = LayersLogic(_project()) + + rows = logic.magnetism + + assert len(rows) == len(logic.layers) + assert rows[0]['magnetic'] == 'False' + # A non-magnetic layer previews what attaching magnetism would create. + assert float(rows[0]['rho_m']) == 0.0 + assert float(rows[0]['theta_m']) == 270.0 + + def test_toggle_on_attaches_magnetism_and_enables_the_calculator(self): + project = _project() + logic = LayersLogic(project) + + assert logic.set_magnetic_at_index(0, True) is True + + assert logic.magnetism[0]['magnetic'] == 'True' + assert project.models[0].has_magnetism is True + assert project._calculator().include_magnetism is True + + def test_toggle_off_removes_magnetism_and_disables_the_calculator(self): + project = _project() + logic = LayersLogic(project) + logic.set_magnetic_at_index(0, True) + + assert logic.set_magnetic_at_index(0, False) is True + + assert logic.magnetism[0]['magnetic'] == 'False' + assert project.models[0].has_magnetism is False + assert project._calculator().include_magnetism is False + + def test_toggling_to_the_current_state_is_a_no_op(self): + logic = LayersLogic(_project()) + + assert logic.set_magnetic_at_index(0, False) is False + logic.set_magnetic_at_index(0, True) + assert logic.set_magnetic_at_index(0, True) is False + + def test_attached_magnetism_gets_the_project_default_limits(self): + project = _project() + logic = LayersLogic(project) + + logic.set_magnetic_at_index(0, True) + + # `set_magnetic_at_index` runs the project's parameter-state sync, so a + # freshly attached rho_m is bounded like every other SLD. + rho_m = logic.magnetism_at_index(0).rho_m + assert rho_m.min == -1.0 + assert rho_m.max == 10.0 + + def test_values_are_written_to_the_parameters(self): + logic = LayersLogic(_project()) + logic.set_magnetic_at_index(0, True) + + assert logic.set_rho_m_at_index(0, 4.5) is True + assert logic.set_theta_m_at_index(0, 35.0) is True + + magnetism = logic.magnetism_at_index(0) + assert magnetism.rho_m.value == 4.5 + assert magnetism.theta_m.value == 35.0 + assert float(logic.magnetism[0]['rho_m']) == 4.5 + + def test_values_on_a_non_magnetic_layer_are_ignored(self): + logic = LayersLogic(_project()) + + assert logic.set_rho_m_at_index(0, 4.5) is False + assert logic.set_theta_m_at_index(0, 35.0) is False + assert logic.magnetism_at_index(0) is None + + def test_unchanged_and_invalid_values_report_no_change(self): + logic = LayersLogic(_project()) + logic.set_magnetic_at_index(0, True) + logic.set_rho_m_at_index(0, 4.5) + + assert logic.set_rho_m_at_index(0, 4.5) is False + assert logic.set_rho_m_at_index(0, 'not-a-number') is False + + def test_out_of_range_index_is_ignored(self): + logic = LayersLogic(_project()) + + assert logic.set_magnetic_at_index(99, True) is False + assert logic.set_rho_m_at_index(99, 1.0) is False + assert logic.magnetism_at_index(99) is None + + def test_calculator_without_magnetism_refuses_and_says_why(self): + project = _project(calculator='refnx') + logic = LayersLogic(project) + + assert logic.magnetism_supported is False + with pytest.raises(NotImplementedError, match='refnx'): + logic.set_magnetic_at_index(0, True) + # The refusal must leave the layer untouched, not half-magnetic. + assert logic.magnetism_at_index(0) is None + + def test_unbound_sample_still_reaches_the_calculator(self): + """A sample assigned wholesale has no interface on its layers. + + `Layer.magnetism` can then not switch magnetism on by itself, which + would leave every spin channel uncalculable. + """ + from easyreflectometry.sample import Layer + from easyreflectometry.sample import Material + from easyreflectometry.sample import Multilayer + from easyreflectometry.sample import Sample + + project = _project() + project.models[0].sample = Sample( + Multilayer(Layer(material=Material(sld=0.0, isld=0.0, name='Vac'), thickness=0, roughness=0)), + Multilayer(Layer(material=Material(sld=8.0, isld=0.0, name='Fe'), thickness=200, roughness=5)), + Multilayer(Layer(material=Material(sld=2.07, isld=0.0, name='Sub'), thickness=0, roughness=3)), + ) + logic = LayersLogic(project) + + logic.set_magnetic_at_index(0, True) + + assert project._calculator().include_magnetism is True + # The magnetic parameter is live on the backend, not just on the model. + q = np.linspace(0.01, 0.2, 10) + logic.set_rho_m_at_index(0, 5.0) + first = project.model_data_for_model_at_index(0, q, channel='pp').y.copy() + logic.set_rho_m_at_index(0, 1.0) + assert not np.allclose(first, project.model_data_for_model_at_index(0, q, channel='pp').y) + + +class TestMagneticParametersInTheTable: + def test_named_and_grouped_like_the_other_layer_parameters(self): + logic = ParametersLogic(_magnetic_project()) + + rows = {row['name']: row for row in logic.all_parameters()} + + # Not 'EasyLayerMagnetism rho_m': the assembly names it, and the model + # prefix distinguishes the same layer in different models. + assert 'Model D2O rho_m' in rows + assert 'Model D2O theta_m' in rows + assert rows['Model D2O rho_m']['group'] == 'D2O' + assert rows['Model D2O rho_m']['value'] == 5.0 + assert rows['Model D2O theta_m']['value'] == 40.0 + + def test_magnetic_parameters_are_fittable_and_enabled(self): + logic = ParametersLogic(_magnetic_project()) + + rows = {row['name']: row for row in logic.all_parameters()} + + assert rows['Model D2O rho_m']['enabled'] is True + assert rows['Model D2O rho_m']['min'] == -1.0 + assert rows['Model D2O rho_m']['max'] == 10.0 + assert rows['Model D2O theta_m']['min'] == 0.0 + assert rows['Model D2O theta_m']['max'] == 360.0 + + def test_magnetic_filter_keyword_selects_them(self): + logic = ParametersLogic(_magnetic_project()) + + logic.set_name_filter_criteria('magnetic') + + assert sorted(row['name'] for row in logic.parameters) == ['Model D2O rho_m', 'Model D2O theta_m'] + + def test_they_are_not_mistaken_for_experiment_parameters(self): + logic = ParametersLogic(_magnetic_project()) + + logic.set_name_filter_criteria('model') + + assert 'Model D2O rho_m' in [row['name'] for row in logic.parameters] + + +class TestPolarizedFitting: + def _polarized_project(self, tmp_path): + project = _project() + paths = _write_channels(tmp_path) + project.load_polarized_experiment({'pp': paths[0], 'mm': paths[1]}) + LayersLogic(project).set_magnetic_at_index(0, True) + return project + + def test_channels_become_separate_fit_datasets(self, tmp_path): + project = self._polarized_project(tmp_path) + logic = FittingLogic(project) + + fitter, x_data, y_data, weights, method = logic.prepare_threaded_fit(_StubMinimizers()) + + assert logic.fit_error_message == '' + # One dataset per measured channel, not one per experiment. + assert len(x_data) == 2 + assert len(y_data) == 2 + assert len(weights) == 2 + assert method is None + + def test_mixed_polarized_and_ordinary_experiments(self, tmp_path): + project = self._polarized_project(tmp_path) + plain = _write_channels(tmp_path, names=('plain.dat',))[0] + project.load_new_experiment(plain) + logic = FittingLogic(project) + + _fitter, x_data, _y, _w, _m = logic.prepare_threaded_fit(_StubMinimizers()) + + # Two channels of the polarized experiment plus the ordinary one. + assert len(x_data) == 3 + + def test_simultaneous_channel_fit_recovers_the_magnetic_sld(self): + """The whole chain: magnetism from the Sample page, fit from the Analysis page.""" + from easyreflectometry.data import DataSet1D + from easyreflectometry.data import PolarizedDataSet + + project = _project() + model = project.models[0] + layer = model.sample[1].layers[0] + layer.material.sld.value = 8.024 + layer.thickness.value = 200.0 + layer.roughness.value = 0.0 + model.sample[0].layers[0].material.sld.value = 0.0 + model.sample[2].layers[0].roughness.value = 0.0 + model.background.value = 0.0 + + layers_logic = LayersLogic(project) + layers_logic.set_magnetic_at_index(0, True) + magnetism = layers_logic.magnetism_at_index(0) + + # Synthesise the truth with rho_m = 5, then start the fit away from it. + layers_logic.set_rho_m_at_index(0, 5.0) + q = np.linspace(0.01, 0.25, 60) + truth = { + channel: project.model_data_for_model_at_index(0, q, channel=channel).y.copy() + for channel in ('pp', 'mm') + } + layers_logic.set_rho_m_at_index(0, 2.0) + magnetism.rho_m.fixed = False + magnetism.rho_m.bounds = (0.0, 8.0) + + project._experiments = { + 0: PolarizedDataSet( + name='synthetic', + channels={ + channel: DataSet1D(name=channel, x=q, y=values, ye=(0.01 * values) ** 2) + for channel, values in truth.items() + }, + model=model, + ) + } + + logic = FittingLogic(project) + fitter, x_data, y_data, weights, _method = logic.prepare_threaded_fit(_StubMinimizers()) + results = fitter.fit(x_data, y_data, weights=weights) + + assert all(result.success for result in results) + assert magnetism.rho_m.value == pytest.approx(5.0, abs=0.05) + + def test_synchronous_start_stop_fits_every_channel(self, tmp_path): + """The single-experiment path routes polarized data to `fit_polarized`.""" + project = self._polarized_project(tmp_path) + logic = FittingLogic(project) + + logic.start_stop() + + assert logic.fit_error_message == '' + # One FitResults per measured channel, not one for the experiment. + assert len(logic.last_fit_results) == 2 + assert logic.fit_finished is True + + def test_bayesian_sampling_still_refuses_polarized_data(self, tmp_path): + """Out of scope for now — but the message must say so, not fail obscurely.""" + project = self._polarized_project(tmp_path) + logic = FittingLogic(project) + + with pytest.raises(ValueError, match='Bayesian sampling'): + logic.collect_all_experiments_datagroup() + + +class TestAnalysisChartChannels: + def _project_with_channels(self, tmp_path, magnetic=True): + project = _project() + paths = _write_channels(tmp_path, names=('run_uu.dat', 'run_dd.dat', 'run_ud.dat')) + project.load_polarized_experiment({'pp': paths[0], 'mm': paths[1], 'pm': paths[2]}) + if magnetic: + layers_logic = LayersLogic(project) + layers_logic.set_magnetic_at_index(0, True) + layers_logic.set_rho_m_at_index(0, 5.0) + layers_logic.set_theta_m_at_index(0, 40.0) + return project + + def test_each_channel_gets_its_own_calculated_curve(self, qcore_application, tmp_path): + plotting = Plotting1d(project_lib=self._project_with_channels(tmp_path), parent=None) + + pp_points = plotting.getAnalysisDataPoints(0, 'pp') + mm_points = plotting.getAnalysisDataPoints(0, 'mm') + + assert pp_points and mm_points + assert all(point['hasCalculated'] for point in pp_points) + # pp sees rho + rhoM and mm sees rho - rhoM: the curves must differ. + assert [point['calculated'] for point in pp_points] != [point['calculated'] for point in mm_points] + + def test_channel_without_a_cross_section_is_flagged(self, qcore_application, tmp_path): + """A spin-flip channel of a non-magnetic model has no curve to draw.""" + plotting = Plotting1d(project_lib=self._project_with_channels(tmp_path, magnetic=False), parent=None) + + points = plotting.getAnalysisDataPoints(0, 'pm') + + assert points, 'measured points must still be reported' + assert all(point['hasCalculated'] is False for point in points) + # ... and a residual of zero would look like a perfect fit, so: nothing. + assert plotting.getResidualDataPoints(0, 'pm') == [] + + def test_without_a_channel_the_old_behaviour_is_kept(self, qcore_application, tmp_path): + plotting = Plotting1d(project_lib=self._project_with_channels(tmp_path), parent=None) + + flat = plotting.getAnalysisDataPoints(0) + + # Falls back to the first visible channel, as before Phase 4. + assert flat == plotting.getAnalysisDataPoints(0, 'pp') + + def test_analysis_switches_to_channel_series_for_a_polarized_experiment(self, qcore_application, tmp_path): + project = self._project_with_channels(tmp_path) + plotting = Plotting1d(project_lib=project, parent=None) + plotting._proxy = type('P', (), {'_analysis': type('A', (), {'_selected_experiment_indices': [0]})()})() + + assert plotting.analysisUsesChannelSeries is True + + def test_ordinary_experiment_keeps_the_single_series_path(self, qcore_application, tmp_path): + project = _project() + project.load_new_experiment(_write_channels(tmp_path, names=('plain.dat',))[0]) + plotting = Plotting1d(project_lib=project, parent=None) + plotting._proxy = type('P', (), {'_analysis': type('A', (), {'_selected_experiment_indices': [0]})()})() + + assert plotting.analysisUsesChannelSeries is False + + +class TestSampleBackendSlots: + def test_slots_emit_and_report_failure(self, qcore_application): + from EasyReflectometryApp.Backends.Py.sample import Sample + + backend = Sample(project_lib=_project()) + changed = [] + backend.magnetismChanged.connect(lambda: changed.append(True)) + + backend.setLayerMagneticAtIndex(0, True) + backend.setLayerRhoMAtIndex(0, 3.0) + backend.setLayerThetaMAtIndex(0, 45.0) + + assert backend.magnetismSupported is True + assert backend.layersMagnetism[0]['magnetic'] == 'True' + assert float(backend.layersMagnetism[0]['rho_m']) == 3.0 + assert len(changed) == 3 + + def test_unsupported_calculator_offers_the_engine_that_can(self, qcore_application): + """The Analysis page may not even be reachable yet: ask, do not point at it.""" + from EasyReflectometryApp.Backends.Py.sample import Sample + + backend = Sample(project_lib=_project(calculator='refnx')) + failures = [] + requests = [] + backend.magnetismFailed.connect(failures.append) + backend.magnetismNeedsEngine.connect(lambda index, engine: requests.append((index, engine))) + + backend.setLayerMagneticAtIndex(0, True) + + # Nothing has changed yet — the UI confirms first. + assert backend.magnetismSupported is False + assert requests == [(0, 'refl1d')] + assert failures == [] + assert backend.layersMagnetism[0]['magnetic'] == 'False' + + def test_confirmed_switch_changes_the_engine_and_attaches_magnetism(self, qcore_application): + from EasyReflectometryApp.Backends.Py.sample import Sample + + backend = Sample(project_lib=_project(calculator='refnx')) + engine_changes = [] + backend.calculationEngineChanged.connect(lambda: engine_changes.append(True)) + + backend.enableMagnetismWithEngineAtIndex(0, 'refl1d') + + assert backend.magnetismSupported is True + assert backend.calculationEngines[backend.calculationEngineIndex] == 'refl1d' + assert backend.layersMagnetism[0]['magnetic'] == 'True' + assert engine_changes == [True] + + def test_engine_that_cannot_carry_the_magnetism_is_refused(self, qcore_application): + """Binding a magnetic layer to refnx raises deep in the library.""" + from EasyReflectometryApp.Backends.Py.sample import Sample + + backend = Sample(project_lib=_project(calculator='refl1d')) + backend.setLayerMagneticAtIndex(0, True) + rejections = [] + backend.calculationEngineRejected.connect(rejections.append) + + backend.setCalculationEngineIndex(backend.calculationEngines.index('refnx')) + + # The refusal goes to the engine selector's dialog, not the log. + assert len(rejections) == 1 and 'refnx' in rejections[0] + assert backend.calculationEngines[backend.calculationEngineIndex] == 'refl1d' + assert backend.layersMagnetism[0]['magnetic'] == 'True' + + +class _StubMinimizers: + tolerance = None + max_iterations = None + + @staticmethod + def selected_minimizer_enum(): + return None diff --git a/tests/test_polarized_display.py b/tests/test_polarized_display.py new file mode 100644 index 00000000..ff9b3b7d --- /dev/null +++ b/tests/test_polarized_display.py @@ -0,0 +1,592 @@ +"""Tests for polarized-experiment support in the plotting backend and logic wrappers.""" + +from types import SimpleNamespace + +import numpy as np +import pytest +from PySide6.QtCore import QObject + +from EasyReflectometryApp.Backends.Py.logic.experiments import experiment_channel_values +from EasyReflectometryApp.Backends.Py.logic.experiments import flatten_polarized +from EasyReflectometryApp.Backends.Py.logic.project import Project as ProjectLogic +from EasyReflectometryApp.Backends.Py.plotting_1d import Plotting1d + + +class FakeChannel(str): + """Channel key behaving like PolarizationChannel (has .value).""" + + @property + def value(self): + return str(self) + + +class FakeDataset: + def __init__(self, x, y, ye=None): + self.x = np.asarray(x) + self.y = np.asarray(y) + self.ye = np.asarray(ye if ye is not None else np.zeros_like(self.x)) + + def data_points(self): + return list(zip(self.x, self.y, self.ye)) + + +class FakePolarizedExperiment: + def __init__(self, channels): + self._channels = {FakeChannel(name): dataset for name, dataset in channels.items()} + self.name = 'polarized' + + @property + def available_channels(self): + return list(self._channels.keys()) + + @property + def channels(self): + return self._channels + + def __getitem__(self, channel): + for key, dataset in self._channels.items(): + if str(key) == str(channel): + return dataset + raise KeyError(channel) + + +def _polarized_experiment(): + return FakePolarizedExperiment( + { + 'pp': FakeDataset([0.1, 0.2], [1e-2, 1e-3], [1e-8, 1e-9]), + 'mm': FakeDataset([0.1, 0.3], [2e-2, 2e-3], [1e-8, 1e-9]), + } + ) + + +class FakeProjectLib: + def __init__(self, experiment): + self._experiments = {0: experiment} + self.current_experiment_index = 0 + self._current_model_index = 0 + self.q_min = 0.0 + self.q_max = 1.0 + self.models = [SimpleNamespace()] + + def experimental_data_for_model_at_index(self, index, channel=None): + experiment = self._experiments[index] + if channel is None: + return experiment + return experiment[channel] + + def experiment_is_polarized_at_index(self, index=0): + # Part of the per-channel library API the plotting backend requires. + return hasattr(self._experiments.get(index), 'available_channels') + + +class TestFlattenPolarized: + def test_unpolarized_passthrough(self): + dataset = FakeDataset([0.1], [1.0]) + assert flatten_polarized(dataset) is dataset + assert experiment_channel_values(dataset) == [] + + def test_first_visible_channel_wins(self): + experiment = _polarized_experiment() + assert flatten_polarized(experiment, {'mm'}) is experiment['mm'] + assert flatten_polarized(experiment, {'pp', 'mm'}) is experiment['pp'] + # No visible channel measured: fall back to the first measured one. + assert flatten_polarized(experiment, {'pm'}) is experiment['pp'] + assert experiment_channel_values(experiment) == ['pp', 'mm'] + + +class TestPlottingChannels: + def _plotting(self, qcore_application): + return Plotting1d(project_lib=FakeProjectLib(_polarized_experiment()), parent=None) + + def test_get_experiment_channels_rows(self, qcore_application): + plotting = self._plotting(qcore_application) + rows = plotting.getExperimentChannels(0) + assert [row['channel'] for row in rows] == ['pp', 'mm'] + assert all(row['visible'] for row in rows) + assert rows[0]['label'] == '↑↑' and rows[1]['label'] == '↓↓' + assert rows[0]['color'] != rows[1]['color'] + + def test_current_experiment_is_polarized(self, qcore_application): + plotting = self._plotting(qcore_application) + assert plotting.currentExperimentIsPolarized is True + assert [row['channel'] for row in plotting.experimentChannelList] == ['pp', 'mm'] + + def test_set_channel_visible_updates_and_keeps_one(self, qcore_application): + plotting = self._plotting(qcore_application) + emitted = {'count': 0} + plotting.channelSelectionChanged.connect(lambda: emitted.__setitem__('count', emitted['count'] + 1)) + + plotting.setChannelVisible('pp', False) + assert 'pp' not in plotting._visible_channels + assert emitted['count'] == 1 + + plotting.setChannelVisible('pm', False) + plotting.setChannelVisible('mp', False) + # The last visible channel cannot be hidden. + plotting.setChannelVisible('mm', False) + assert plotting._visible_channels == frozenset({'mm'}) + + plotting.setChannelVisible('pp', True) + assert 'pp' in plotting._visible_channels + + def test_last_measured_channel_cannot_be_hidden(self, qcore_application): + """Only the channels the UI offers matter: pp/mm here, pm/mp are not measured.""" + plotting = self._plotting(qcore_application) + + # Exactly the two clicks a user of a pp/mm experiment can perform. + plotting.setChannelVisible('pp', False) + plotting.setChannelVisible('mm', False) + + # mm stays visible: unmeasured pm/mp in the global set must not be + # mistaken for "another visible channel". + assert 'mm' in plotting._visible_channels + assert [row['channel'] for row in plotting.getExperimentChannels(0) if row['visible']] == ['mm'] + + def test_single_channel_experiment_cannot_be_blanked(self, qcore_application): + plotting = Plotting1d( + project_lib=FakeProjectLib(FakePolarizedExperiment({'pp': FakeDataset([0.1], [1e-2])})), parent=None + ) + + plotting.setChannelVisible('pp', False) + + assert 'pp' in plotting._visible_channels + assert [row['channel'] for row in plotting.getExperimentChannels(0) if row['visible']] == ['pp'] + + def test_four_channel_experiment_can_hide_all_but_one(self, qcore_application): + channels = {name: FakeDataset([0.1], [1e-2]) for name in ('pp', 'pm', 'mp', 'mm')} + plotting = Plotting1d(project_lib=FakeProjectLib(FakePolarizedExperiment(channels)), parent=None) + + for name in ('pp', 'pm', 'mp'): + plotting.setChannelVisible(name, False) + plotting.setChannelVisible('mm', False) + + assert plotting._visible_channels == frozenset({'mm'}) + + def test_channel_state_is_notified_when_the_experiment_changes(self, qcore_application): + """`currentExperimentIsPolarized`/`experimentChannelList` must not go stale.""" + project = FakeProjectLib(_polarized_experiment()) + project._experiments[1] = FakeDataset([0.1, 0.2], [1e-2, 1e-3]) # unpolarized + plotting = Plotting1d(project_lib=project, parent=None) + emitted = {'count': 0} + plotting.experimentChannelsChanged.connect(lambda: emitted.__setitem__('count', emitted['count'] + 1)) + + assert plotting.currentExperimentIsPolarized is True + + # Switching to the unpolarized experiment: the backend notifies QML and + # both properties report the new experiment. + project.current_experiment_index = 1 + plotting.notifyExperimentChannelsChanged() + + assert emitted['count'] == 1 + assert plotting.currentExperimentIsPolarized is False + assert plotting.experimentChannelList == [] + + project.current_experiment_index = 0 + plotting.notifyExperimentChannelsChanged() + + assert emitted['count'] == 2 + assert plotting.currentExperimentIsPolarized is True + assert [row['channel'] for row in plotting.experimentChannelList] == ['pp', 'mm'] + + def test_channel_selection_also_notifies_channel_state(self, qcore_application): + plotting = self._plotting(qcore_application) + emitted = {'count': 0} + plotting.experimentChannelsChanged.connect(lambda: emitted.__setitem__('count', emitted['count'] + 1)) + + plotting.setChannelVisible('pp', False) + + # The selector rows carry `visible`, so they must be re-read as well. + assert emitted['count'] == 1 + + def test_library_without_channel_api_is_reported_not_silently_empty(self, qcore_application): + class LegacyProjectLib: + """A library predating the channel API: no channel argument, no predicate.""" + + def __init__(self, experiment): + self._experiments = {0: experiment} + self.current_experiment_index = 0 + self.models = [SimpleNamespace()] + self.q_min = 0.0 + self.q_max = 1.0 + + def experimental_data_for_model_at_index(self, index): + return self._experiments[index] + + plotting = Plotting1d(project_lib=LegacyProjectLib(_polarized_experiment()), parent=None) + + with pytest.raises(RuntimeError, match='experiment_is_polarized_at_index is missing'): + plotting._require_channel_api() + # The slot itself stays safe for QML, but the failure is logged as an error. + assert plotting.getExperimentChannelDataPoints(0, 'pp') == [] + + def test_accessor_without_channel_argument_is_reported(self, qcore_application): + """The predicate alone is not enough: the accessor must take `channel`.""" + + class HalfUpdatedProjectLib(FakeProjectLib): + def experimental_data_for_model_at_index(self, index): + return self._experiments[index] + + plotting = Plotting1d(project_lib=HalfUpdatedProjectLib(_polarized_experiment()), parent=None) + + with pytest.raises(RuntimeError, match='no channel argument'): + plotting._require_channel_api() + + def test_current_library_satisfies_the_channel_api(self, qcore_application): + from easyreflectometry import Project as RealProject + + plotting = Plotting1d(project_lib=RealProject(), parent=None) + + assert plotting._check_channel_api() == '' + + def test_experiment_data_points_use_first_visible_channel(self, qcore_application): + plotting = self._plotting(qcore_application) + pp_points = plotting.getExperimentDataPoints(0) + assert [point['x'] for point in pp_points] == [0.1, 0.2] + + plotting.setChannelVisible('pp', False) + mm_points = plotting.getExperimentDataPoints(0) + assert [point['x'] for point in mm_points] == [0.1, 0.3] + + def test_per_channel_data_points(self, qcore_application): + plotting = self._plotting(qcore_application) + mm_points = plotting.getExperimentChannelDataPoints(0, 'mm') + assert [point['x'] for point in mm_points] == [0.1, 0.3] + assert plotting.getExperimentChannelDataPoints(0, 'pm') == [] # not measured + + +class TestEndToEndPolarizedImport: + def test_import_and_display_through_real_project(self, qcore_application, tmp_path): + """Full chain: Experiment QObject → logic → real Project lib → Plotting1d channels.""" + from easyreflectometry import Project as RealProject + + from EasyReflectometryApp.Backends.Py.experiment import Experiment + + q = np.linspace(0.01, 0.2, 15) + reflectivity = np.exp(-q * 30) + paths = [] + for name in ('run_uu.dat', 'run_dd.dat'): + path = tmp_path / name + np.savetxt(path, np.column_stack([q, reflectivity, 0.01 * reflectivity])) + # The QML FileDialog hands over file:/// URLs; mirror that here. + paths.append(path.as_uri()) + + project = RealProject() + project.calculator = 'refl1d' + project.default_model() + experiment = Experiment(project_lib=project) + + rows = experiment.suggestPolarizedChannels(','.join(paths)) + assert [row['channel'] for row in rows] == ['pp', 'mm'] + + experiment.loadPolarized(rows) + + assert project.experiment_is_polarized_at_index(0) is True + plotting = Plotting1d(project_lib=project, parent=None) + assert plotting.currentExperimentIsPolarized is True + channels = plotting.getExperimentChannels(0) + assert [row['channel'] for row in channels] == ['pp', 'mm'] + pp_points = plotting.getExperimentChannelDataPoints(0, 'pp') + assert len(pp_points) == len(q) + + def test_polarized_experiment_survives_save_and_reload(self, qcore_application, tmp_path): + """A polarized experiment must round-trip through the App's own save/load path. + + Regression test for the data-loss bug flagged in PR #338 review: the + App's save() delegates straight to the library's save_as_json(), so + this only passes because the pinned easyreflectometry commit knows + how to (de)serialize a PolarizedDataSet. If the dependency ever moves + to a commit that regresses this, this test (not just the library's + own) must catch it. + """ + from easyreflectometry import Project as RealProject + from easyreflectometry.data import PolarizedDataSet + from easyscience import global_object + + from EasyReflectometryApp.Backends.Py.experiment import Experiment + + q = np.linspace(0.01, 0.2, 15) + reflectivity = np.exp(-q * 30) + paths = [] + for name in ('run_uu.dat', 'run_dd.dat'): + path = tmp_path / name + np.savetxt(path, np.column_stack([q, reflectivity, 0.01 * reflectivity])) + paths.append(path.as_uri()) + + project = RealProject() + project.set_path_project_parent(tmp_path) + project.calculator = 'refl1d' + project.default_model() + project._info['name'] = 'polarized round trip' + experiment = Experiment(project_lib=project) + rows = experiment.suggestPolarizedChannels(','.join(paths)) + experiment.loadPolarized(rows) + original = project.experiments[0] + + ProjectLogic(project_lib=project).save() + assert project.path_json.exists() + + global_object.map._clear() + reloaded_project = RealProject() + ProjectLogic(project_lib=reloaded_project).load(str(project.path_json)) + reloaded = reloaded_project.experiments[0] + + assert isinstance(reloaded, PolarizedDataSet) + assert reloaded.available_channels == original.available_channels + for channel in original.available_channels: + assert np.allclose(reloaded[channel].x, original[channel].x) + assert np.allclose(reloaded[channel].y, original[channel].y) + + def test_imported_experiment_becomes_the_current_one(self, qcore_application, tmp_path): + """With an experiment already loaded, the import must not stay invisible.""" + from easyreflectometry import Project as RealProject + + from EasyReflectometryApp.Backends.Py.experiment import Experiment + + q = np.linspace(0.01, 0.2, 15) + reflectivity = np.exp(-q * 30) + paths = [] + for name in ('plain.dat', 'run_uu.dat', 'run_dd.dat'): + path = tmp_path / name + np.savetxt(path, np.column_stack([q, reflectivity, 0.01 * reflectivity])) + paths.append(path) + + project = RealProject() + project.calculator = 'refl1d' + project.default_model() + project.load_experiment_for_model_at_index(str(paths[0]), 0) + experiment = Experiment(project_lib=project) + + loaded = [] + experiment.experimentLoaded.connect(loaded.append) + experiment.loadPolarized( + [ + {'path': str(paths[1]), 'channel': 'pp'}, + {'path': str(paths[2]), 'channel': 'mm'}, + ] + ) + + # The polarized group is experiment 1, and the app is told to select it. + assert project.experiment_is_polarized_at_index(1) is True + assert loaded == [1] + + def test_summary_html_compiles_after_a_polarized_import(self, qcore_application, tmp_path): + """Importing emits summaryChanged, and QML reads asHtml straight after. + + The summary used to assume every experiment is a DataSet1D, so reading + it after a polarized import raised inside a QML-read property — which + Qt turns into a process abort with no traceback. + """ + from easyreflectometry import Project as RealProject + + from EasyReflectometryApp.Backends.Py.experiment import Experiment + from EasyReflectometryApp.Backends.Py.summary import Summary + + q = np.linspace(0.01, 0.2, 15) + reflectivity = np.exp(-q * 30) + paths = [] + for name in ('run_uu.dat', 'run_dd.dat'): + path = tmp_path / name + np.savetxt(path, np.column_stack([q, reflectivity, 0.01 * reflectivity])) + paths.append(str(path)) + + project = RealProject() + project.calculator = 'refl1d' + project.default_model() + experiment = Experiment(project_lib=project) + experiment.loadPolarized( + [ + {'path': paths[0], 'channel': 'pp'}, + {'path': paths[1], 'channel': 'mm'}, + ] + ) + + html = Summary(project_lib=project).asHtml + + assert 'could not be generated' not in html + assert '(pp)' in html and '(mm)' in html + + +class TestSummaryFailureIsNotFatal: + def test_asHtml_reports_errors_instead_of_raising(self, qcore_application): + """A raising getter escapes into Qt and aborts the process — never raise.""" + from EasyReflectometryApp.Backends.Py.summary import Summary + + summary = Summary.__new__(Summary) + QObject.__init__(summary) + + class _Boom: + @property + def as_html(self): + raise RuntimeError('summary exploded') + + summary._logic = _Boom() + + html = summary.asHtml + + assert 'could not be generated' in html + assert 'summary exploded' in html + + +class TestSwitchingBetweenExperiments: + """State that must follow the current experiment, not the previous one.""" + + @staticmethod + def _two_polarized_projects(): + """Two polarized experiments with different channels and q grids.""" + first = FakePolarizedExperiment( + { + 'pp': FakeDataset([0.1, 0.2], [1e-2, 1e-3]), + 'pm': FakeDataset([0.1, 0.2], [1e-4, 1e-5]), + 'mp': FakeDataset([0.1, 0.2], [1e-4, 1e-5]), + 'mm': FakeDataset([0.1, 0.2], [2e-2, 2e-3]), + } + ) + second = FakePolarizedExperiment({'pp': FakeDataset([0.5, 0.6], [3e-2, 3e-3])}) + project = FakeProjectLib(first) + project._experiments[1] = second + return project + + def test_channel_points_follow_the_current_experiment(self, qcore_application): + project = self._two_polarized_projects() + plotting = Plotting1d(project_lib=project, parent=None) + + assert [point['x'] for point in plotting.getExperimentChannelDataPoints(0, 'pp')] == [0.1, 0.2] + + project.current_experiment_index = 1 + plotting.notifyExperimentChannelsChanged() + + # Still polarized, so `isPolarizedMode` does not flip — the channel list + # and the plotted points must change all the same. + assert plotting.currentExperimentIsPolarized is True + assert [row['channel'] for row in plotting.experimentChannelList] == ['pp'] + assert [point['x'] for point in plotting.getExperimentChannelDataPoints(1, 'pp')] == [0.5, 0.6] + + def test_hidden_channel_is_restored_when_the_new_experiment_needs_it(self, qcore_application): + project = self._two_polarized_projects() + plotting = Plotting1d(project_lib=project, parent=None) + + # Hide everything except mm on the four-channel experiment. + for channel in ('pp', 'pm', 'mp'): + plotting.setChannelVisible(channel, False) + assert plotting._visible_channels == frozenset({'mm'}) + + # Experiment 1 measures only pp, which is currently hidden: without + # renormalization its chart would be empty and the user could not fix it. + project.current_experiment_index = 1 + plotting.notifyExperimentChannelsChanged() + + assert [row['channel'] for row in plotting.experimentChannelList if row['visible']] == ['pp'] + + def test_selection_is_kept_when_the_new_experiment_still_has_a_visible_channel(self, qcore_application): + project = self._two_polarized_projects() + project._experiments[1] = FakePolarizedExperiment( + {'pp': FakeDataset([0.5], [3e-2]), 'mm': FakeDataset([0.5], [3e-3])} + ) + plotting = Plotting1d(project_lib=project, parent=None) + plotting.setChannelVisible('pp', False) + + project.current_experiment_index = 1 + plotting.notifyExperimentChannelsChanged() + + # mm is still visible on the new experiment, so the user's choice stands. + assert [row['channel'] for row in plotting.experimentChannelList if row['visible']] == ['mm'] + + def test_refused_hide_still_notifies_so_the_checkbox_rebinds(self, qcore_application): + plotting = Plotting1d(project_lib=FakeProjectLib(_polarized_experiment()), parent=None) + plotting.setChannelVisible('pp', False) + emitted = {'count': 0} + plotting.experimentChannelsChanged.connect(lambda: emitted.__setitem__('count', emitted['count'] + 1)) + + plotting.setChannelVisible('mm', False) # last measured channel: refused + + assert plotting._visible_channels == frozenset({'pm', 'mp', 'mm'}) + assert [row['channel'] for row in plotting.experimentChannelList if row['visible']] == ['mm'] + # Without this the checkbox stays unchecked while the channel is plotted. + assert emitted['count'] == 1 + + def test_axes_span_every_visible_channel(self, qcore_application): + """Channel files need not share a q grid; the chart must not clip one.""" + plotting = Plotting1d(project_lib=FakeProjectLib(_polarized_experiment()), parent=None) + + # pp spans 0.1–0.2, mm spans 0.1–0.3. + assert plotting.experimentMaxX == 0.3 + assert plotting.experimentMinX == 0.1 + + plotting.setChannelVisible('mm', False) + assert plotting.experimentMaxX == 0.2 + + +class TestMultiExperimentChannelExpansion: + """Selecting several experiments must not collapse a polarized one to one channel.""" + + def _analysis(self, visible_channels=None): + from EasyReflectometryApp.Backends.Py import analysis as analysis_module + + analysis = analysis_module.Analysis.__new__(analysis_module.Analysis) + experiment = _polarized_experiment() + project = FakeProjectLib(experiment) + project._experiments[1] = FakeDataset([0.1, 0.4], [3e-2, 3e-3]) # unpolarized + analysis._experiments_logic = SimpleNamespace( + _project_lib=project, + available=lambda: ['polarized', 'plain'], + ) + analysis._selected_experiment_indices = [0, 1] + analysis._plotting = SimpleNamespace(_visible_channels=frozenset(visible_channels or {'pp', 'mm'})) + return analysis + + def test_polarized_experiment_expands_to_one_entry_per_visible_channel(self): + rows = self._analysis().get_individual_experiment_data_list(expand_channels=True) + + assert [row['channel'] for row in rows] == ['pp', 'mm', ''] + assert [row['index'] for row in rows] == [0, 0, 1] + # Distinct series identity per channel, and per-experiment hue kept. + assert len({row['color'] for row in rows[:2]}) == 2 + assert '↑↑ pp' in rows[0]['name'] and '↓↓ mm' in rows[1]['name'] + + def test_hidden_channels_are_not_plotted(self): + rows = self._analysis(visible_channels={'mm'}).get_individual_experiment_data_list(expand_channels=True) + + assert [row['channel'] for row in rows] == ['mm', ''] + + def test_flat_list_keeps_one_entry_per_experiment(self): + """Consumers that are not channel aware yet must not get duplicate series.""" + rows = self._analysis().get_individual_experiment_data_list() + + assert [row['channel'] for row in rows] == ['', ''] + assert [row['index'] for row in rows] == [0, 1] + + def test_flat_list_follows_the_visible_channel(self): + rows = self._analysis(visible_channels={'mm'}).get_individual_experiment_data_list() + + # The flattened polarized entry shows the first *visible* channel. + assert list(rows[0]['data'].x) == [0.1, 0.3] + + def test_concatenated_data_follows_the_visible_channel(self): + combined = self._analysis(visible_channels={'mm'}).get_concatenated_experiment_data() + + # mm spans 0.1/0.3, the unpolarized experiment 0.1/0.4; pp (0.1/0.2) is + # hidden and must not be the one that gets concatenated. + assert 0.2 not in list(combined.x) + assert 0.3 in list(combined.x) + + def test_channel_shade_keeps_hue_and_varies_lightness(self): + from EasyReflectometryApp.Backends.Py.logic.experiments import channel_shade + + shades = {channel: channel_shade('#7BA6C4', channel) for channel in ('pp', 'pm', 'mp', 'mm')} + + assert len(set(shades.values())) == 4 + assert channel_shade('not-a-color', 'pp') == 'not-a-color' + + +class TestProjectLogicPolarized: + def test_sync_q_max_walks_polarized_channels(self): + lib = SimpleNamespace( + _experiments={0: _polarized_experiment()}, + q_max=0.05, + ) + logic = ProjectLogic.__new__(ProjectLogic) + logic._project_lib = lib + # 0.3 is the largest q over all channels (mm); q_max should follow it. + changed = logic._sync_q_max_with_loaded_experiments() + assert changed is True + assert lib.q_max >= 0.3 \ No newline at end of file diff --git a/tests/test_py_backend.py b/tests/test_py_backend.py index ffe5fb89..ed4ffdde 100644 --- a/tests/test_py_backend.py +++ b/tests/test_py_backend.py @@ -28,6 +28,7 @@ def __init__(self, _project_lib, parent=None): class StubSample(QObject): externalSampleChanged = Signal() + calculationEngineChanged = Signal() externalRefreshPlot = Signal() modelsTableChanged = Signal() materialsTableChanged = Signal() @@ -48,12 +49,14 @@ class StubExperiment(QObject): externalExperimentChanged = Signal() experimentChanged = Signal() qRangeUpdated = Signal() + experimentLoaded = Signal(int) def __init__(self, _project_lib): super().__init__() class StubAnalysis(QObject): + calculatorChanged = Signal() externalMinimizerChanged = Signal() externalCalculatorChanged = Signal() externalParametersChanged = Signal() @@ -89,6 +92,9 @@ def setSelectedExperimentIndices(self, indices): self.received_indices = indices self._selected = list(indices) + def selectExperimentAtIndex(self, index): + self.setSelectedExperimentIndices([index]) + def _clearCacheAndEmitParametersChanged(self): self.clear_calls += 1 @@ -125,13 +131,31 @@ class StubPlotting(QObject): sldChartRangesChanged = Signal() experimentChartRangesChanged = Signal() samplePageResetAxes = Signal() + experimentChannelsChanged = Signal() + magneticProfileChanged = Signal() + spinAsymmetryChanged = Signal() def __init__(self, _project_lib, parent=None): super().__init__(parent) self.reset_calls = 0 + self.channel_notifications = 0 + self.magnetic_notifications = 0 + self.spin_asymmetry_notifications = 0 self.refresh_calls = {'sample': 0, 'experiment': 0, 'analysis': 0} self._multi = True - self._individual = [{'name': 'E0', 'index': 0, 'color': '#111111', 'hasData': True}] + self._individual = [{'name': 'E0', 'index': 0, 'color': '#111111', 'channel': '', 'hasData': True}] + + def notifyExperimentChannelsChanged(self): + self.channel_notifications += 1 + self.experimentChannelsChanged.emit() + + def notifyMagneticProfileChanged(self): + self.magnetic_notifications += 1 + self.magneticProfileChanged.emit() + + def notifySpinAsymmetryChanged(self): + self.spin_asymmetry_notifications += 1 + self.spinAsymmetryChanged.emit() @property def isMultiExperimentMode(self): @@ -144,8 +168,8 @@ def individualExperimentDataList(self): def getExperimentDataPoints(self, experiment_index): return [{'x': float(experiment_index), 'y': 0.0}] - def getAnalysisDataPoints(self, experiment_index): - return [{'x': float(experiment_index), 'measured': 0.0, 'calculated': 0.0}] + def getAnalysisDataPoints(self, experiment_index, channel=''): + return [{'x': float(experiment_index), 'measured': 0.0, 'calculated': 0.0, 'channel': channel}] def reset_data(self): self.reset_calls += 1 @@ -245,9 +269,37 @@ def test_backend_refresh_plots_emits_ranges_and_multi_signal(monkeypatch, qcore_ assert counts == {'sample': 1, 'sld': 1, 'exp': 1, 'multi': 1} assert backend.plottingIsMultiExperimentMode is True - assert backend.plottingIndividualExperimentDataList == [{'name': 'E0', 'index': 0, 'color': '#111111', 'hasData': True}] + assert backend.plottingIndividualExperimentDataList == [ + {'name': 'E0', 'index': 0, 'color': '#111111', 'channel': '', 'hasData': True} + ] assert backend.plottingGetExperimentDataPoints(3) == [{'x': 3.0, 'y': 0.0}] - assert backend.plottingGetAnalysisDataPoints(5) == [{'x': 5.0, 'measured': 0.0, 'calculated': 0.0}] + assert backend.plottingGetAnalysisDataPoints(5) == [ + {'x': 5.0, 'measured': 0.0, 'calculated': 0.0, 'channel': ''} + ] + + +def test_backend_imported_experiment_is_selected_and_channels_renotified(monkeypatch, qcore_application): + # A freshly imported experiment must become the current selection, and the + # channel state must be re-published so the selector/chart follow it. + backend = _make_backend(monkeypatch) + + backend._experiment.experimentLoaded.emit(2) + + assert backend._analysis.received_indices == [2] + + backend._experiment.externalExperimentChanged.emit() + assert backend._plotting_1d.channel_notifications >= 1 + + +def test_backend_experiment_selection_renotifies_channels(monkeypatch, qcore_application): + # Selecting another experiment goes through analysis.experimentsChanged; + # without this connection a polarized -> polarized switch keeps the old + # channel list and the old chart. + backend = _make_backend(monkeypatch) + + backend._analysis.experimentsChanged.emit() + + assert backend._plotting_1d.channel_notifications == 1 # =========================================================================== @@ -262,11 +314,11 @@ class _DelegationStub: def __init__(self, plotting_1d): self._plotting_1d = plotting_1d - def plottingGetAnalysisDataPoints(self, experiment_index: int) -> list: - return self._plotting_1d.getAnalysisDataPoints(experiment_index) + def plottingGetAnalysisDataPoints(self, experiment_index: int, channel: str = '') -> list: + return self._plotting_1d.getAnalysisDataPoints(experiment_index, channel) - def plottingGetResidualDataPoints(self, experiment_index: int) -> list: - return self._plotting_1d.getResidualDataPoints(experiment_index) + def plottingGetResidualDataPoints(self, experiment_index: int, channel: str = '') -> list: + return self._plotting_1d.getResidualDataPoints(experiment_index, channel) class TestPlottingGetResidualDataPointsDelegation: @@ -281,7 +333,7 @@ def test_delegates_to_plotting_1d(self): result = backend.plottingGetResidualDataPoints(0) - plotting.getResidualDataPoints.assert_called_once_with(0) + plotting.getResidualDataPoints.assert_called_once_with(0, '') assert result == expected def test_passes_experiment_index(self): @@ -290,7 +342,7 @@ def test_passes_experiment_index(self): backend.plottingGetResidualDataPoints(3) - plotting.getResidualDataPoints.assert_called_once_with(3) + plotting.getResidualDataPoints.assert_called_once_with(3, '') def test_returns_empty_list_when_plotting_returns_empty(self): backend, plotting = self._backend() @@ -324,7 +376,7 @@ def test_delegates_to_plotting_1d(self): result = backend.plottingGetAnalysisDataPoints(0) - plotting.getAnalysisDataPoints.assert_called_once_with(0) + plotting.getAnalysisDataPoints.assert_called_once_with(0, '') assert result == expected def test_passes_experiment_index(self): @@ -333,5 +385,5 @@ def test_passes_experiment_index(self): backend.plottingGetAnalysisDataPoints(5) - plotting.getAnalysisDataPoints.assert_called_once_with(5) + plotting.getAnalysisDataPoints.assert_called_once_with(5, '') diff --git a/tests/test_py_experiment.py b/tests/test_py_experiment.py index be36520f..2ee0c9b6 100644 --- a/tests/test_py_experiment.py +++ b/tests/test_py_experiment.py @@ -1,3 +1,5 @@ +import pytest + from EasyReflectometryApp.Backends.Py import experiment as experiment_module @@ -33,6 +35,9 @@ def __init__(self, _project_lib): self.dataset_counts = {} self.loaded_all = [] self.loaded_new = [] + self.loaded_polarized = [] + # (list position of the new experiment, whether q_max changed) + self.load_polarized_result = (0, True) def count_datasets_in_file(self, path): return self.dataset_counts.get(path, 1) @@ -44,6 +49,13 @@ def load_all_experiments_from_file(self, path): def load_new_experiment(self, path): self.loaded_new.append(path) + def suggest_polarized_channel_assignment(self, paths): + return {path: ('pp' if '_uu' in path else 'mm' if '_dd' in path else '') for path in paths} + + def load_polarized_experiment(self, channel_to_path): + self.loaded_polarized.append(dict(channel_to_path)) + return self.load_polarized_result + def _build_experiment(monkeypatch): monkeypatch.setattr(experiment_module, 'ModelsLogic', StubModelsLogic) @@ -102,3 +114,97 @@ def test_load_routes_single_vs_multi_dataset_paths(monkeypatch, qcore_applicatio assert experiment._project_logic.loaded_all == ['A'] assert experiment._project_logic.loaded_new == ['B'] assert changed == {'experiment': 2, 'external': 2} + + +def test_suggest_polarized_channels_returns_editable_rows(monkeypatch, qcore_application): + experiment = _build_experiment(monkeypatch) + monkeypatch.setattr(experiment_module.IO, 'generalizePath', lambda path: path) + + rows = experiment.suggestPolarizedChannels('data/run_uu.dat,data/run_dd.dat,data/run_x.dat') + + assert rows == [ + {'path': 'data/run_uu.dat', 'name': 'run_uu.dat', 'channel': 'pp'}, + {'path': 'data/run_dd.dat', 'name': 'run_dd.dat', 'channel': 'mm'}, + {'path': 'data/run_x.dat', 'name': 'run_x.dat', 'channel': ''}, + ] + + +def _channel_files(tmp_path) -> tuple: + """Two real files: the loader only accepts paths that exist.""" + pp_path = tmp_path / 'run_uu.dat' + mm_path = tmp_path / 'run_dd.dat' + for path in (pp_path, mm_path): + path.write_text('0.1 1.0 0.01\n') + return str(pp_path), str(mm_path) + + +def test_load_polarized_builds_channel_mapping_and_emits(monkeypatch, qcore_application, tmp_path): + experiment = _build_experiment(monkeypatch) + pp_path, mm_path = _channel_files(tmp_path) + changed = {'experiment': 0, 'external': 0, 'q_range': 0} + experiment.experimentChanged.connect(lambda: changed.__setitem__('experiment', changed['experiment'] + 1)) + experiment.externalExperimentChanged.connect(lambda: changed.__setitem__('external', changed['external'] + 1)) + experiment.qRangeUpdated.connect(lambda: changed.__setitem__('q_range', changed['q_range'] + 1)) + + experiment.loadPolarized( + [ + {'path': pp_path, 'channel': 'pp'}, + {'path': mm_path, 'channel': 'mm'}, + {'path': str(tmp_path / 'run_x.dat'), 'channel': ''}, # 'not used' rows are excluded + ] + ) + + assert experiment._project_logic.loaded_polarized == [{'pp': pp_path, 'mm': mm_path}] + assert changed == {'experiment': 1, 'external': 1, 'q_range': 1} + + +def test_polarized_slots_accept_qjsvalues_from_qml(monkeypatch, qcore_application, tmp_path): + # QML hands over JS arrays/objects as QJSValue, not Python lists/dicts. + from PySide6.QtQml import QJSEngine + + experiment = _build_experiment(monkeypatch) + pp_path, mm_path = _channel_files(tmp_path) + monkeypatch.setattr(experiment_module.IO, 'generalizePath', lambda path: path) + engine = QJSEngine() + + paths = engine.evaluate(f"(['{pp_path}', '{mm_path}'])".replace('\\', '/')) + rows = experiment.suggestPolarizedChannels(paths) + assert [row['channel'] for row in rows] == ['pp', 'mm'] + + assignments = engine.evaluate( + f"([{{path: '{pp_path}', channel: 'pp'}}, {{path: '{mm_path}', channel: 'mm'}}])".replace('\\', '/') + ) + experiment.loadPolarized(assignments) + assert experiment._project_logic.loaded_polarized == [{'pp': pp_path.replace('\\', '/'), 'mm': mm_path.replace('\\', '/')}] + + +@pytest.mark.parametrize( + 'rows, message', + [ + ([{'path': 'run_x.dat', 'channel': ''}], 'at least one file'), + ([], 'at least one file'), + ('run_uu.dat', 'Expected a list'), + ([{'channel': 'pp'}], 'Malformed'), + ([{'path': 'PP_FILE', 'channel': 'up'}], 'Unknown spin channel'), + ([{'path': 'PP_FILE', 'channel': 'pp'}, {'path': 'MM_FILE', 'channel': 'pp'}], 'more than one file'), + ([{'path': 'missing.dat', 'channel': 'pp'}], 'No such file'), + ], +) +def test_load_polarized_rejects_invalid_assignments(monkeypatch, qcore_application, tmp_path, rows, message): + """QML is not a trust boundary: the slot validates its own input.""" + experiment = _build_experiment(monkeypatch) + pp_path, mm_path = _channel_files(tmp_path) + substitutes = {'PP_FILE': pp_path, 'MM_FILE': mm_path} + if isinstance(rows, list): + rows = [ + {**row, 'path': substitutes[row['path']]} if row.get('path') in substitutes else row for row in rows + ] + + failures = [] + experiment.loadFailed.connect(failures.append) + + with pytest.raises(ValueError, match=message): + experiment.loadPolarized(rows) + + assert experiment._project_logic.loaded_polarized == [] + assert len(failures) == 1 and message.lower() in failures[0].lower() diff --git a/tests/test_py_plotting_1d.py b/tests/test_py_plotting_1d.py index 6ec6b4d3..9d26b612 100644 --- a/tests/test_py_plotting_1d.py +++ b/tests/test_py_plotting_1d.py @@ -80,7 +80,7 @@ def __init__(self, selected): def get_concatenated_experiment_data(self): return FakeData(x=[0.1, 0.2, 0.3], y=[1e-6, 2e-6, 3e-6], ye=[1e-8, 1e-8, 1e-8]) - def get_individual_experiment_data_list(self): + def get_individual_experiment_data_list(self, expand_channels=False): return [ {'name': 'E0', 'color': '#111111', 'index': 0, 'data': FakeData(x=[0.1], y=[1e-6], ye=[1e-8])}, {'name': 'E1', 'color': '#222222', 'index': 1, 'data': FakeData(x=[0.2], y=[2e-6], ye=[1e-8])}, @@ -224,7 +224,7 @@ def __init__(self, name='', x=None, y=None, ye=None, xe=None): def data_points(self): for i in range(len(self.x)): yield (self.x[i], self.y[i], - self.ye[i] ** 2 if len(self.ye) > i else 0.0) + self.ye[i] if len(self.ye) > i else 0.0) def _make_exp_data_stub(q, r, ye=None): @@ -370,7 +370,7 @@ def test_residual_is_calc_minus_meas(self): q = np.array([0.10]) r_exp = np.array([1e-2]) r_calc = np.array([3e-2]) - ye = np.array([1e-3]) # sigma = 1e-3 + ye = np.array([1e-3 ** 2]) # ye is variance; sigma = sqrt(ye) = 1e-3 proj = _make_project_stub(q, r_exp, r_calc, ye=ye) p = _make_plotting_stub(proj) @@ -386,7 +386,7 @@ def test_residual_rq4_mode(self): q = np.array([0.10]) r_exp = np.array([1e-2]) r_calc = np.array([3e-2]) - ye = np.array([1e-3]) + ye = np.array([1e-3 ** 2]) # ye is variance proj = _make_project_stub(q, r_exp, r_calc, ye=ye) p_linear = _make_plotting_stub(proj, rq4=False) p_rq4 = _make_plotting_stub(proj, rq4=True) @@ -470,7 +470,7 @@ def test_y_range_has_margin(self): q = np.array([0.10]) r_exp = np.array([1e-2]) r_calc = np.array([3e-2]) - ye = np.array([1e-3]) + ye = np.array([1e-3 ** 2]) # ye is variance; sigma = sqrt(ye) = 1e-3 proj = _make_project_stub(q, r_exp, r_calc, ye=ye) p = _make_plotting_stub(proj) @@ -484,7 +484,7 @@ def test_rq4_affects_range(self): q = np.array([0.10]) r_exp = np.array([1e-2]) r_calc = np.array([3e-2]) - ye = np.array([1e-3]) + ye = np.array([1e-3 ** 2]) # ye is variance proj = _make_project_stub(q, r_exp, r_calc, ye=ye) p_linear = _make_plotting_stub(proj, rq4=False)