diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml index 8012f6b9..5ac1e893 100644 --- a/.github/workflows/installer.yml +++ b/.github/workflows/installer.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-22.04, ubuntu-24.04, windows-2022, macos-13, macos-14] + os: [ubuntu-22.04, ubuntu-24.04, windows-2022, macos-14] steps: - name: Check-out repository @@ -31,6 +31,13 @@ jobs: - name: Upgrade package installer for Python run: python -m pip install --upgrade pip + - name: Install needed libraries (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xfixes0 libxcb-shape0 libxcb-cursor0 + sudo apt-get install libcairo2-dev pkg-config + - name: Install Python dependences run: | python -m pip install flit @@ -74,11 +81,12 @@ jobs: run: | echo "SETUP_EXE_PATH=$(python ${{ env.SCRIPTS_PATH }}/Config.py ${{ env.BRANCH_NAME }} ${{ matrix.os }} setup_exe_path)" >> $GITHUB_ENV - - name: Install needed libraries (Linux) - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xfixes0 libxcb-shape0 libxcb-cursor0 + # - name: Install needed libraries (Linux) + # if: runner.os == 'Linux' + # run: | + # sudo apt-get update + # sudo apt-get install libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xfixes0 libxcb-shape0 libxcb-cursor0 + # sudo apt-get install libcairo2-dev pkg-config # - name: Run app in testmode and quit # shell: bash @@ -106,18 +114,37 @@ jobs: # ${{ secrets.APPLE_CERT_DATA }} ${{ secrets.APPLE_CERT_PASSWORD }} # ${{ secrets.APPLE_NOTARY_USER }} ${{ secrets.APPLE_NOTARY_PASSWORD }} -# - name: Sign offline app installer (Windows) -# if: | -# runner.os == 'Windows' && github.event_name == 'push' && -# (env.BRANCH_NAME == 'master' || env.BRANCH_NAME == 'develop') -# uses: lando/code-sign-action@v2 -# with: -# file: ${{ env.SETUP_EXE_PATH }} -# certificate-data: ${{ secrets.WINDOZE_CERT_DATA }} -# certificate-password: ${{ secrets.WINDOZE_CERT_PASSWORD }} -# keylocker-host: ${{ secrets.KEYLOCKER_HOST }} -# keylocker-api-key: ${{ secrets.KEYLOCKER_API_KEY }} -# keylocker-cert-sha1-hash: ${{ secrets.KEYLOCKER_CERT_SHA1_HASH }} + # - name: Install DigiCert Client tools from Github Custom Actions marketplace + # if: | + # runner.os == 'windows' && github.event_name == 'push' + # uses: digicert/ssm-code-signing@v1.0.1 + + # - name: Set up P12 certificate + # if: | + # runner.os == 'windows' && github.event_name == 'push' + # run: | + # echo "${{ secrets.WINDOWS_CERT_DATA }}" | base64 --decode > /d/Certificate_pkcs12.p12 + # shell: bash + + # - name: Set keylocker variables + # if: | + # runner.os == 'windows' && github.event_name == 'push' + # id: variables + # run: | + # echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + # echo "SM_HOST=${{ secrets.KEYLOCKER_HOST }}" >> "$GITHUB_ENV" + # echo "SM_API_KEY=${{ secrets.KEYLOCKER_API_KEY }}" >> "$GITHUB_ENV" + # echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" + # echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.WINDOWS_CERT_PASSWORD }}" >> "$GITHUB_ENV" + # shell: bash + + # - name: Sign the binary using keypair alias + # if: | + # runner.os == 'windows' && github.event_name == 'push' && env.BRANCH_NAME == 'master' + # run: | + # smctl sign --keypair-alias key_911959544 --input ${{ env.SETUP_EXE_PATH }} + # shell: cmd + - name: Create zip archive of offline app installer for distribution run: > @@ -172,7 +199,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-22.04, ubuntu-24.04, windows-2022, macos-13, macos-14] + os: [ubuntu-22.04, ubuntu-24.04, windows-2022, macos-14] steps: - name: Check-out repository diff --git a/.github/workflows/snapcraft.yml b/.github/workflows/snapcraft.yml index 49e38459..8b02051d 100644 --- a/.github/workflows/snapcraft.yml +++ b/.github/workflows/snapcraft.yml @@ -57,7 +57,7 @@ jobs: - name: Get branch names id: branch-name - uses: tj-actions/branch-names@v6 + uses: tj-actions/branch-names@v8 - name: Get snap filename run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ed9bc3c..0b7fb8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ -# Version 1.1.1 (28 May 2025) +# Version 1.2.0 (1 March 2026) -Fixed Apple Silicon installer. -Fixed experimental data file parser. +Added ORSO file parser +Added simple constraints +Added model-model constraints +Enabled multi-sample display +Enabled multi-experiment display +Improved plotting +Enhanced status bar display +Moved SLD plot to main display diff --git a/EasyReflectometryApp/Backends/Mock/Analysis.qml b/EasyReflectometryApp/Backends/Mock/Analysis.qml index e368da97..8865debb 100644 --- a/EasyReflectometryApp/Backends/Mock/Analysis.qml +++ b/EasyReflectometryApp/Backends/Mock/Analysis.qml @@ -20,6 +20,18 @@ QtObject { readonly property string fittingStatus: ''//undefined //'Success' readonly property bool isFitFinished: true readonly property bool fittingRunning: false + property bool showFitResultsDialog: false + readonly property bool fitSuccess: true + readonly property string fitErrorMessage: '' + readonly property int fitNumRefinedParams: 3 + readonly property real fitChi2: 1.2345 + readonly property var fitResults: ({ success: true, nvarys: 3, chi2: 1.2345 }) + + // Fit failure signal (mirrors Python backend) + signal fitFailed(string message) + + // Stop fit signal (mirrors Python backend) + signal stopFit() // Parameters property int currentParameterIndex: 0 @@ -100,4 +112,8 @@ QtObject { function fittingStartStop() { console.debug('fittingStartStop') } + function setShowFitResultsDialog(value) { + showFitResultsDialog = value + console.debug(`setShowFitResultsDialog ${value}`) + } } diff --git a/EasyReflectometryApp/Backends/Mock/Home.qml b/EasyReflectometryApp/Backends/Mock/Home.qml index 963a0038..bf27eb97 100644 --- a/EasyReflectometryApp/Backends/Mock/Home.qml +++ b/EasyReflectometryApp/Backends/Mock/Home.qml @@ -7,8 +7,8 @@ QtObject { property bool created: false readonly property var version: { - 'number': '1.1.1', - 'date': '28 May 2025', + 'number': '1.2.0', + 'date': '10 March 2026', } readonly property var urls: { diff --git a/EasyReflectometryApp/Backends/Mock/Plotting.qml b/EasyReflectometryApp/Backends/Mock/Plotting.qml index 9317594a..57e49717 100644 --- a/EasyReflectometryApp/Backends/Mock/Plotting.qml +++ b/EasyReflectometryApp/Backends/Mock/Plotting.qml @@ -21,6 +21,25 @@ QtObject { property double analysisMinY: -40. property double analysisMaxY: 40. + property int modelCount: 1 + + // Plot mode properties + property bool plotRQ4: false + property string yMainAxisTitle: 'R(q)' + property bool xAxisLog: false + property string xAxisType: 'linear' + property bool sldXDataReversed: false + property bool scaleShown: false + property bool bkgShown: false + + // Signals for plot mode changes + signal plotModeChanged() + signal axisTypeChanged() + signal sldAxisReversedChanged() + signal referenceLineVisibilityChanged() + signal samplePageDataChanged() + signal samplePageResetAxes() + function setQtChartsSerieRef(value1, value2, value3) { console.debug(`setQtChartsSerieRef ${value1}, ${value2}, ${value3}`) } @@ -33,4 +52,85 @@ QtObject { console.debug(`drawCalculatedOnSldChart`) } + function getSampleDataPointsForModel(index) { + console.debug(`getSampleDataPointsForModel ${index}`) + return [] + } + + function getSldDataPointsForModel(index) { + console.debug(`getSldDataPointsForModel ${index}`) + return [] + } + + function getModelColor(index) { + console.debug(`getModelColor ${index}`) + return '#0000FF' + } + + // Plot mode toggle functions + function togglePlotRQ4() { + plotRQ4 = !plotRQ4 + yMainAxisTitle = plotRQ4 ? 'R(q)×q⁴' : 'R(q)' + plotModeChanged() + } + + function toggleXAxisType() { + xAxisLog = !xAxisLog + xAxisType = xAxisLog ? 'log' : 'linear' + axisTypeChanged() + } + + function reverseSldXData() { + sldXDataReversed = !sldXDataReversed + sldAxisReversedChanged() + } + + function flipScaleShown() { + scaleShown = !scaleShown + referenceLineVisibilityChanged() + } + + function flipBkgShown() { + bkgShown = !bkgShown + referenceLineVisibilityChanged() + } + + // Reference line data accessors (mock implementation) + function getBackgroundData() { + if (!bkgShown) return [] + // Return mock horizontal line at background level + return [ + { 'x': 0.01, 'y': -7.0 }, + { 'x': 0.30, 'y': -7.0 } + ] + } + + function getScaleData() { + if (!scaleShown) return [] + // Return mock horizontal line at scale level (log10(1.0) = 0) + return [ + { 'x': 0.01, 'y': 0.0 }, + { 'x': 0.30, 'y': 0.0 } + ] + } + + // Analysis-specific reference line data accessors (use sample/calculated x-range) + function getBackgroundDataForAnalysis() { + if (!bkgShown) return [] + // Return mock horizontal line at background level using sample x-range + return [ + { 'x': sampleMinX, 'y': -7.0 }, + { 'x': sampleMaxX, 'y': -7.0 } + ] + } + + function getScaleDataForAnalysis() { + if (!scaleShown) return [] + // Return mock horizontal line at scale level using sample x-range + return [ + { 'x': sampleMinX, 'y': 0.0 }, + { 'x': sampleMaxX, 'y': 0.0 } + ] + } + } diff --git a/EasyReflectometryApp/Backends/Mock/Sample.qml b/EasyReflectometryApp/Backends/Mock/Sample.qml index 8f280388..3314b0b2 100644 --- a/EasyReflectometryApp/Backends/Mock/Sample.qml +++ b/EasyReflectometryApp/Backends/Mock/Sample.qml @@ -3,6 +3,8 @@ pragma Singleton import QtQuick QtObject { + // Signals to match the Python backend + signal constraintsChanged // MATERIALS readonly property int currentMaterialIndex: -1 @@ -271,11 +273,96 @@ QtObject { 'parameter 2', 'parameter 3' ] - readonly property var relationOperators: ['=', '<', '>'] + readonly property var relationOperators: [ + { value: '=', text: '=' }, + { value: '>', text: '≥' }, + { value: '<', text: '≤' } + ] readonly property var arithmicOperators: ['', '*', '/', '+', '-'] + readonly property var constraintParametersMetadata: [ + { alias: 'parameter_1', displayName: 'parameter 1', independent: true }, + { alias: 'parameter_2', displayName: 'parameter 2', independent: true }, + { alias: 'parameter_3', displayName: 'parameter 3', independent: true } + ] + + // Mock constraints data - matches the structured format expected by the UI + property var constraintsList: [ + { + dependentName: 'Thickness Layer 1', + expression: 'parameter 2 * 0.5 + 1.5', + rawExpression: 'parameter_2 * 0.5 + 1.5', + relation: '=', + type: 'expression' + }, + { + dependentName: 'Roughness Layer 2', + expression: 'parameter 1 / 3.14', + rawExpression: 'parameter_1 / 3.14', + relation: '=', + type: 'expression' + }, + { + dependentName: 'SLD Layer 3', + expression: '5.0', + rawExpression: '5.0', + relation: '=', + type: 'static' + } + ] + + function validateConstraintExpression(dependentIndex, relation, expression) { + if (dependentIndex < 0 || dependentIndex >= parameterNames.length) { + return { valid: false, message: 'Select a dependent parameter first.' } + } + const expr = expression !== undefined && expression !== null ? String(expression).trim() : '' + if (expr.length === 0) { + return { valid: false, message: 'Expression cannot be empty.' } + } + return { + valid: true, + message: '', + preview: expr, + relation: relation, + type: relation === '=' ? 'expression' : (relation === '>' ? 'lower_bound' : 'upper_bound') + } + } + + function addConstraint(dependentIndex, relation, expression) { + const validation = validateConstraintExpression(dependentIndex, relation, expression) + if (!validation.valid) { + return { success: false, message: validation.message } + } + + const constraint = { + dependentName: parameterNames[dependentIndex] || 'Unknown parameter', + expression: validation.preview, + rawExpression: expression, + relation: relation, + type: validation.type + } + + var newConstraints = constraintsList.slice() + newConstraints.push(constraint) + constraintsList = newConstraints + constraintsChanged() + + return { + success: true, + message: '', + preview: validation.preview, + relation: relation, + type: validation.type + } + } - function addConstraint(value1, value2, value3, value4, value5) { - console.debug(`addConstraint ${value1} ${value2} ${value3} ${value4} ${value5}`) + function removeConstraintByIndex(index) { + console.debug(`removeConstraintByIndex ${index}`) + if (index >= 0 && index < constraintsList.length) { + var newConstraints = constraintsList.slice() // Create a copy + newConstraints.splice(index, 1) + constraintsList = newConstraints + constraintsChanged() + } } // Q Range diff --git a/EasyReflectometryApp/Backends/Mock/Summary.qml b/EasyReflectometryApp/Backends/Mock/Summary.qml index 41e9fc2e..52435b40 100644 --- a/EasyReflectometryApp/Backends/Mock/Summary.qml +++ b/EasyReflectometryApp/Backends/Mock/Summary.qml @@ -3,15 +3,21 @@ pragma Singleton import QtQuick QtObject { + signal htmlExportingFinished(bool success, string filePath) + readonly property bool created: true readonly property var exportFormats: ["HTML", "PDF"] readonly property string fileName: "summary" + readonly property string plotFileName: "plots" readonly property string filePath: '/Users/andpe/ExampleProject/summary' + readonly property string plotFilePath: '/Users/andpe/ExampleProject/plots' readonly property string fileUrl: 'file:///Users/andpe/ExampleProject/summary' + readonly property string plotFileUrl: 'file:///Users/andpe/ExampleProject/plots' + readonly property var plotExportFormats: ["PDF", "PNG", "SVG"] readonly property string asHtml: ' @@ -82,16 +88,31 @@ th, td { padding-right: 18px; } ' - function saveAsHtml() { + function saveAsHtml(path) { console.debug(`Saving HTML summary`) + htmlExportingFinished(true, path) } - function saveAsPdf() { + function saveAsPdf(path) { console.debug(`Saving PDF summary`) + htmlExportingFinished(true, path) } function setFileName(value) { console.debug(`setFileName ${value}`) } + function setPlotFileName(value) { + console.debug(`setPlotFileName ${value}`) + } + + function savePlot(path, widthCm, heightCm) { + console.debug(`savePlot ${path} ${widthCm} ${heightCm}`) + htmlExportingFinished(true, path) + } + + function showPlot(widthCm, heightCm) { + console.debug(`showPlot ${widthCm} ${heightCm}`) + } + } diff --git a/EasyReflectometryApp/Backends/Py/__init__.py b/EasyReflectometryApp/Backends/Py/__init__.py index 135c8694..c855680a 100644 --- a/EasyReflectometryApp/Backends/Py/__init__.py +++ b/EasyReflectometryApp/Backends/Py/__init__.py @@ -1,3 +1,3 @@ from .py_backend import PyBackend -__all__ = [PyBackend] \ No newline at end of file +__all__ = [PyBackend] diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index 0b82111a..b027c958 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -2,6 +2,7 @@ from typing import Optional from easyreflectometry import Project as ProjectLib +from PySide6 import QtWidgets from PySide6.QtCore import Property from PySide6.QtCore import QObject from PySide6.QtCore import Signal @@ -10,8 +11,10 @@ from .logic.calculators import Calculators as CalculatorsLogic from .logic.experiments import Experiments as ExperimentLogic from .logic.fitting import Fitting as FittingLogic +from .logic.helpers import get_original_name from .logic.minimizers import Minimizers as MinimizersLogic from .logic.parameters import Parameters as ParametersLogic +from .workers import FitterWorker class Analysis(QObject): @@ -21,20 +24,60 @@ class Analysis(QObject): parametersChanged = Signal() parametersIndexChanged = Signal() fittingChanged = Signal() + fitFailed = Signal(str) # Emitted with error message when fitting fails + stopFit = Signal() # Signal to request fitting stop externalMinimizerChanged = Signal() externalParametersChanged = Signal() externalCalculatorChanged = Signal() externalFittingChanged = Signal() + externalExperimentChanged = Signal() def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) - self._paramters_logic = ParametersLogic(project_lib) + self._project_lib = project_lib + self._parameters_logic = ParametersLogic(project_lib) self._fitting_logic = FittingLogic(project_lib) self._calculators_logic = CalculatorsLogic(project_lib) self._experiments_logic = ExperimentLogic(project_lib) self._minimizers_logic = MinimizersLogic(project_lib) - self._chached_paramters = None + self._chached_parameters = None + self._chached_enabled_parameters = None + # Thread management for background fitting + self._fitter_thread = None + # Connect stopFit signal to slot + self.stopFit.connect(self._onStopFit) + # Add support for multiple selected experiments - initialize to empty first to avoid binding loops + self._selected_experiment_indices = [] + # Initialize selected experiments after construction to avoid binding loops + self._initialize_selected_experiments() + + def _initialize_selected_experiments(self) -> None: + """Initialize selected experiment indices after object construction to avoid binding loops.""" + available_experiments = self._experiments_logic.available() + if len(available_experiments) > 0: + self._selected_experiment_indices = [0] + else: + self._selected_experiment_indices = [] + + def _ordered_experiments(self) -> list: + """Return experiments as an ordered list of experiment objects. + + Handles mapping-like storage without assuming contiguous integer keys. + """ + experiments = self._experiments_logic._project_lib._experiments + if not experiments: + return [] + + if hasattr(experiments, 'items'): + items = list(experiments.items()) + try: + items.sort(key=lambda item: item[0]) + except TypeError: + pass + return [experiment for _, experiment in items] + + return list(experiments) ######################## ## Fitting @@ -50,13 +93,157 @@ def fittingRunning(self) -> bool: def isFitFinished(self) -> bool: return self._fitting_logic.fit_finished + @Property(bool, notify=fittingChanged) + def showFitResultsDialog(self) -> bool: + return self._fitting_logic.show_results_dialog + + @Slot(bool) + def setShowFitResultsDialog(self, value: bool) -> None: + self._fitting_logic.show_results_dialog = value + self.fittingChanged.emit() + + @Property(bool, notify=fittingChanged) + def fitSuccess(self) -> bool: + return self._fitting_logic.fit_success + + @Property(str, notify=fittingChanged) + def fitErrorMessage(self) -> str: + return self._fitting_logic.fit_error_message + + @Property(int, notify=fittingChanged) + def fitNumRefinedParams(self) -> int: + return self._fitting_logic.fit_n_pars + + @Property(float, notify=fittingChanged) + def fitChi2(self) -> float: + return self._fitting_logic.fit_chi2 + + @Property('QVariant', notify=fittingChanged) + def fitResults(self) -> dict: + """Return fit results as a dict for QML consumption.""" + return { + 'success': self._fitting_logic.fit_success, + 'nvarys': self._fitting_logic.fit_n_pars, + 'chi2': self._fitting_logic.fit_chi2, + } + @Slot(None) def fittingStartStop(self) -> None: - self._fitting_logic.start_stop() + # If already running, stop the fit + if self._fitting_logic.running: + self.stopFit.emit() + return + + # Make sure we can run the fitting + if not self.prefitCheck(): + return + + # Use threaded fitting for non-blocking UI + self._start_threaded_fit() + + def _start_threaded_fit(self) -> None: + """Start fitting in a background thread.""" + # Reset flags and prepare for fit using proper encapsulation + self._fitting_logic.reset_stop_flag() + self._fitting_logic.prepare_for_threaded_fit() + self.fittingChanged.emit() + + # TODO: Thread-safety: prevent model/parameter edits during fitting or snapshot state before starting the worker. + + # Prepare fit data for all experiments + fitter, x_data, y_data, weights, method = self._fitting_logic.prepare_threaded_fit(self._minimizers_logic) + + if fitter is None: + # Error already set in fitting logic + self.fittingChanged.emit() + if self._fitting_logic.fit_error_message: + self.fitFailed.emit(self._fitting_logic.fit_error_message) + return + + # Create and configure worker + self._fitter_thread = FitterWorker( + fitter=fitter, + method_name='fit', + args=(x_data, y_data), + kwargs={'weights': weights, 'method': method}, + parent=self, + ) + self._fitter_thread.setTerminationEnabled(True) + self._fitter_thread.finished.connect(self._on_fit_finished) + self._fitter_thread.failed.connect(self._on_fit_failed) + self._fitter_thread.finished.connect(self._fitter_thread.deleteLater) + self._fitter_thread.failed.connect(self._fitter_thread.deleteLater) + self._fitter_thread.start() + + @Slot(list) + def _on_fit_finished(self, results: list) -> None: + """Handle successful completion of threaded fit.""" + self._fitting_logic.on_fit_finished(results) + self._fitter_thread = None self.fittingChanged.emit() self._clearCacheAndEmitParametersChanged() self.externalFittingChanged.emit() + @Slot(str) + def _on_fit_failed(self, error_message: str) -> None: + """Handle failed threaded fit.""" + self._fitting_logic.on_fit_failed(error_message) + self._fitter_thread = None + self.fittingChanged.emit() + self._clearCacheAndEmitParametersChanged() + self.externalFittingChanged.emit() + self.fitFailed.emit(error_message) + + @Slot() + def _onStopFit(self) -> None: + """Stop fitting and clean up.""" + self._fitting_logic.stop_fit() + if self._fitter_thread is not None: + self._fitter_thread.stop() + self._fitter_thread.deleteLater() + self._fitter_thread = None + self.fittingChanged.emit() + self.externalFittingChanged.emit() + + def prefitCheck(self) -> bool: + """ + Perform a pre-fit check to ensure that all parameters are set correctly. + Returns True if the check passes, False otherwise. + """ + # 1. wrong bounds on parameters + for param in self.fitableParameters: + if not param['fit']: + continue + if param['min'] >= param['max']: + QtWidgets.QMessageBox.warning( + None, + 'Invalid Parameter Bounds', + f"Parameter '{param['name']}' has invalid bounds: " + f'min ({param["min"]}) must be less than max ({param["max"]}).', + ) + return False + + # 2. differential evolution needs finite bounds on all parameters + if 'differential_evolution' in self.minimizersAvailable[self.minimizerCurrentIndex]: + bad_params = [] + for param in self.fitableParameters: + if not param['fit']: + continue + if param['min'] == float('-inf') or param['max'] == float('inf'): + bad_params.append(param['name']) + if bad_params: + joined = '\n' + ',\n'.join(bad_params) + '\n' + # Show a warning in a message box + QtWidgets.QMessageBox.warning( + None, + 'Invalid Parameter Bounds', + f'Parameters {joined} have infinite bounds, which is not allowed for differential evolution minimizer.', + ) + + return False + + return True + ######################## ## Calculators @Property('QVariantList', notify=calculatorChanged) @@ -85,7 +272,208 @@ def experimentCurrentIndex(self) -> int: @Slot(int) def setExperimentCurrentIndex(self, new_value: int) -> None: - self._experiments_logic.set_current_index(new_value) + if self._experiments_logic.set_current_index(new_value): + self.experimentsChanged.emit() + self.externalExperimentChanged.emit() + + @Slot(int) + def setModelOnExperiment(self, new_value: int) -> None: + self._experiments_logic.set_model_on_experiment(new_value) + self.experimentsChanged.emit() + self.externalExperimentChanged.emit() + + @Slot(str) + def setExperimentName(self, new_name: str) -> None: + self._experiments_logic.set_experiment_name(new_name) + self.experimentsChanged.emit() + self.externalExperimentChanged.emit() + + @Slot(int, str) + def setExperimentNameAtIndex(self, index: int, new_name: str) -> None: + self._experiments_logic.set_experiment_name_at_index(index, new_name) + self.experimentsChanged.emit() + self.externalExperimentChanged.emit() + + @Property(int, notify=experimentsChanged) + def modelIndexForExperiment(self) -> int: + # return the model index for the current experiment + models = self._experiments_logic._project_lib._models + experiments = self._ordered_experiments() + index = self.experimentCurrentIndex + current_experiment = experiments[index] if 0 <= index < len(experiments) else None + if current_experiment is not None: + t = models.index(current_experiment.model) + return t + return -1 + + @Property('QVariantList', notify=experimentsChanged) + def modelNamesForExperiment(self) -> list: + # return a list of model names for each experiment + mapped_models = [] + experiments = self._ordered_experiments() + for experiment in experiments: + name = get_original_name(experiment.model) + mapped_models.append(name) + return mapped_models + + @Property('QVariantList', notify=experimentsChanged) + def modelColorsForExperiment(self) -> list: + # return a list of model colors for each experiment + mapped_models = [] + experiments = self._ordered_experiments() + for experiment in experiments: + mapped_models.append(experiment.model.color) + return mapped_models + + @Slot(int) + def removeExperiment(self, index: int) -> None: + """ + Remove the experiment at the given index. + """ + if 0 <= index < len(self._experiments_logic.available()): + self._experiments_logic.remove_experiment(index) + self.experimentsChanged.emit() + self.externalExperimentChanged.emit() + else: + print(f'Experiment index {index} is out of range.') + + ######################## + ## Multi-experiment selection support + # (Initialize selected experiments in the existing __init__ method) + + @Property(int, notify=experimentsChanged) + def experimentsSelectedCount(self) -> int: + """Return the count of currently selected experiments.""" + return len(self._selected_experiment_indices) + + @Property('QVariantList', notify=experimentsChanged) + def selectedExperimentIndices(self) -> List[int]: + """Return the list of selected experiment indices.""" + return self._selected_experiment_indices + + @Slot('QVariantList') + def setSelectedExperimentIndices(self, indices: List[int]) -> None: + """Set multiple selected experiment indices.""" + # Validate indices + available_count = len(self._experiments_logic.available()) + valid_indices = [i for i in indices if 0 <= i < available_count] + + if valid_indices != self._selected_experiment_indices: + # previous_selection = self._selected_experiment_indices.copy() + self._selected_experiment_indices = valid_indices + # Update current experiment index to first selected (or 0 if no selection) + if valid_indices: + self._experiments_logic.set_current_index(valid_indices[0]) + self._project_lib.current_experiment_index = valid_indices[0] + elif len(self._experiments_logic.available()) > 0: + # If no selection but experiments available, default to first experiment + self._experiments_logic.set_current_index(0) + self._selected_experiment_indices = [0] # Auto-select first experiment + + # Always trigger plotting refresh when selection changes + self._refresh_plotting_system() + + self.experimentsChanged.emit() + self.externalExperimentChanged.emit() + + def get_concatenated_experiment_data(self): + """ + Concatenate data from all selected experiments. + Returns a combined DataSet1D object. + """ + import numpy as np + from easyreflectometry.data import DataSet1D + + if not self._selected_experiment_indices: + 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 = [], [], [], [] + + for exp_idx in 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 + all_x.extend(data.x) + all_y.extend(data.y) + all_ye.extend(data.ye if hasattr(data, 'ye') and data.ye.size > 0 else np.zeros_like(data.y)) + all_xe.extend(data.xe if hasattr(data, 'xe') and data.xe.size > 0 else np.zeros_like(data.x)) + except (IndexError, AttributeError) as e: + print(f'Error accessing experiment {exp_idx}: {e}') + continue + + if not all_x: + return DataSet1D(name='No valid experiment data', x=np.empty(0), y=np.empty(0), ye=np.empty(0), xe=np.empty(0)) + + # Sort by x values to maintain proper order + combined_data = list(zip(all_x, all_y, all_ye, all_xe)) + combined_data.sort(key=lambda item: item[0]) + + x_sorted, y_sorted, ye_sorted, xe_sorted = zip(*combined_data) if combined_data else ([], [], [], []) + + exp_names = [ + self._experiments_logic.available()[i] + for i in self._selected_experiment_indices + if i < len(self._experiments_logic.available()) + ] + combined_name = f'Combined: {", ".join(exp_names)}' + + return DataSet1D( + 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): + """ + Get individual experiment data for each selected experiment. + Returns a list of dictionaries with data, name, and color for each experiment. + """ + + if not self._selected_experiment_indices: + return [] + + experiment_data_list = [] + + # Define a color palette for experiments + color_palette = [ + '#1f77b4', # Blue + '#ff7f0e', # Orange + '#2ca02c', # Green + '#d62728', # Red + '#9467bd', # Purple + '#8c564b', # Brown + '#e377c2', # Pink + '#7f7f7f', # Gray + '#bcbd22', # Olive + '#17becf', # Cyan + ] + + 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[idx % len(color_palette)] + + experiment_data_list.append({'data': data, 'name': exp_name, 'color': color, 'index': exp_idx}) + except (IndexError, AttributeError) as e: + print(f'Error accessing experiment {exp_idx}: {e}') + continue + + return experiment_data_list + + @Property('QVariantList', notify=experimentsChanged) + def selectedExperimentDataList(self) -> List[dict]: + """Return individual experiment data for plotting separate lines.""" + return self.get_individual_experiment_data_list() + + def _refresh_plotting_system(self) -> None: + """Refresh the plotting system when experiment selection changes.""" + # Emit signal to notify parent/listeners that experiment selection changed + # Parent (PyBackend) connects this signal to plotting refresh + self.experimentsChanged.emit() ######################## ## Minimizers @@ -125,56 +513,104 @@ def setMinimizerMaxIterations(self, new_value: int) -> None: ## Parameters @Property('QVariantList', notify=parametersChanged) def fitableParameters(self) -> List[dict[str]]: - if self._chached_paramters is None: - self._chached_paramters = self._paramters_logic.parameters - return self._chached_paramters + if self._chached_parameters is None: + self._chached_parameters = self._parameters_logic.parameters + return self._chached_parameters + + @Property('QVariantList', notify=parametersChanged) + def enabledParameters(self) -> list[dict[str]]: + if self._chached_enabled_parameters is not None: + return self._chached_enabled_parameters + self._chached_enabled_parameters = self._parameters_logic.parameters + return self._chached_enabled_parameters + + @Property(str, notify=parametersChanged) + def nameFilterCriteria(self) -> str: + return self._parameters_logic.name_filter_criteria + + @Property(str, notify=parametersChanged) + def variabilityFilterCriteria(self) -> str: + return self._parameters_logic.variability_filter_criteria + + @Slot(str) + def setNameFilterCriteria(self, new_value: str) -> None: + if self._parameters_logic.set_name_filter_criteria(new_value): + self._clearCacheAndEmitParametersChanged() + + @Slot(str) + def setVariabilityFilterCriteria(self, new_value: str) -> None: + if self._parameters_logic.set_variability_filter_criteria(new_value): + self._clearCacheAndEmitParametersChanged() @Property(int, notify=parametersIndexChanged) def currentParameterIndex(self) -> int: - return self._paramters_logic.current_index() + return self._parameters_logic.current_index() @Slot(int) def setCurrentParameterIndex(self, new_value: int) -> None: - if self._paramters_logic.set_current_index(new_value): + if self._parameters_logic.set_current_index(new_value): self.parametersIndexChanged.emit() @Property(int, notify=parametersChanged) def freeParametersCount(self) -> int: - return self._paramters_logic.count_free_parameters() + result = self._parameters_logic.count_free_parameters() + return result @Property(int, notify=parametersChanged) def fixedParametersCount(self) -> int: - return self._paramters_logic.count_fixed_parameters() + result = self._parameters_logic.count_fixed_parameters() + return result @Property(int, notify=parametersChanged) def modelParametersCount(self) -> int: - return 3 + return len( + [ + parameter + for parameter in self._parameters_logic.all_parameters() + if parameter.get('enabled', True) and not self._parameters_logic.is_experiment_parameter(parameter) + ] + ) @Property(int, notify=parametersChanged) def experimentParametersCount(self) -> int: - return 3 + return len( + [ + parameter + for parameter in self._parameters_logic.all_parameters() + if parameter.get('enabled', True) and self._parameters_logic.is_experiment_parameter(parameter) + ] + ) @Slot(float) def setCurrentParameterValue(self, new_value: float) -> None: - if self._paramters_logic.set_current_parameter_value(new_value): + if self._parameters_logic.set_current_parameter_value(new_value): self._clearCacheAndEmitParametersChanged() self.externalParametersChanged.emit() @Slot(float) def setCurrentParameterMin(self, new_value: float) -> None: - if self._paramters_logic.set_current_parameter_min(new_value): + if self._parameters_logic.set_current_parameter_min(new_value): self._clearCacheAndEmitParametersChanged() @Slot(float) def setCurrentParameterMax(self, new_value: float) -> None: - if self._paramters_logic.set_current_parameter_max(new_value): + if self._parameters_logic.set_current_parameter_max(new_value): self._clearCacheAndEmitParametersChanged() @Slot(bool) def setCurrentParameterFit(self, new_value: bool) -> None: - if self._paramters_logic.set_current_parameter_fit(new_value): + if self._parameters_logic.set_current_parameter_fit(new_value): self._clearCacheAndEmitParametersChanged() def _clearCacheAndEmitParametersChanged(self): - self._chached_paramters = None + self._chached_parameters = None + self._chached_enabled_parameters = None + parameters_length = len(self.enabledParameters) + current_index = self._parameters_logic.current_index() + if parameters_length == 0 and current_index != 0: + self._parameters_logic.set_current_index(0) + self.parametersIndexChanged.emit() + elif parameters_length > 0 and current_index >= parameters_length: + self._parameters_logic.set_current_index(parameters_length - 1) + self.parametersIndexChanged.emit() self.parametersChanged.emit() diff --git a/EasyReflectometryApp/Backends/Py/experiment.py b/EasyReflectometryApp/Backends/Py/experiment.py index f01e56c6..4d75d52c 100644 --- a/EasyReflectometryApp/Backends/Py/experiment.py +++ b/EasyReflectometryApp/Backends/Py/experiment.py @@ -53,7 +53,18 @@ def setBackground(self, new_value: float) -> None: # Actions @Slot(str) - def load(self, path: str) -> None: - self._project_logic.load_experiment(IO.generalizePath(path)) - self.experimentChanged.emit() - self.externalExperimentChanged.emit() + def load(self, paths: str) -> None: + # paths is a string containing paths separated by a comma. + # make a list out of it + if isinstance(paths, str): + paths = paths.split(',') + + for path in paths: + generalized = IO.generalizePath(path) + if self._project_logic.count_datasets_in_file(generalized) > 1: + self._project_logic.load_all_experiments_from_file(generalized) + else: + self._project_logic.load_new_experiment(generalized) + self.experimentChanged.emit() + self.externalExperimentChanged.emit() + pass # debug anchor diff --git a/EasyReflectometryApp/Backends/Py/logic/assemblies.py b/EasyReflectometryApp/Backends/Py/logic/assemblies.py index e350d9a4..18b3ef4a 100644 --- a/EasyReflectometryApp/Backends/Py/logic/assemblies.py +++ b/EasyReflectometryApp/Backends/Py/logic/assemblies.py @@ -61,10 +61,8 @@ def move_selected_down(self) -> None: self.index = self.index + 1 def set_name_at_current_index(self, new_value: str) -> None: - if self._assemblies[self.index].name != new_value: - self._assemblies[self.index].name = new_value - return True - return False + self._assemblies[self.index].name = new_value + return True def set_type_at_current_index(self, new_value: str) -> bool: if new_value == self._assemblies[self.index].type: @@ -74,7 +72,7 @@ def set_type_at_current_index(self, new_value: str) -> bool: new_assembly = Multilayer() new_assembly.layers[0].material = self._assemblies[self.index].layers.data[0].material elif new_value == 'Repeating Multi-layer': - new_assembly = RepeatingMultilayer() + new_assembly = RepeatingMultilayer(repetitions=1, name=new_value) new_assembly.layers[0].material = self._assemblies[self.index].layers.data[0].material elif new_value == 'Surfactant Layer': index_air = self._project_lib.get_index_air() @@ -83,10 +81,10 @@ def set_type_at_current_index(self, new_value: str) -> bool: new_assembly.layers[0].solvent = self._project_lib._materials[index_air] new_assembly.layers[1].solvent = self._project_lib._materials[index_d2o] - new_assembly.name = self._assemblies[self.index].name + if new_assembly.name is None: + new_assembly.name = self._assemblies[self.index].name self._assemblies[self.index] = new_assembly - self._project_lib._models[self._project_lib.current_model_index].sample._disable_changes_to_outermost_layers() return True # Only for repeating multilayer @@ -126,13 +124,13 @@ def _from_assemblies_collection_to_list_of_dicts(assemblies_collection: Sample) { 'label': assembly.name, 'type': assembly.type, - 'repetetions': 1, + 'repetitions': 1, 'constrain_apm': 'False', 'conformal_roughness': 'False', } ) if isinstance(assembly, RepeatingMultilayer): - assemblies_list[-1]['repetetions'] = assembly.repetitions + assemblies_list[-1]['repetitions'] = assembly.repetitions if isinstance(assembly, SurfactantLayer): assemblies_list[-1]['constrain_apm'] = assembly.constrain_area_per_molecule diff --git a/EasyReflectometryApp/Backends/Py/logic/experiments.py b/EasyReflectometryApp/Backends/Py/logic/experiments.py index fe94e58f..f292d886 100644 --- a/EasyReflectometryApp/Backends/Py/logic/experiments.py +++ b/EasyReflectometryApp/Backends/Py/logic/experiments.py @@ -4,22 +4,118 @@ class Experiments: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib - self._current_index = 0 + + def _ordered_experiment_items(self) -> list[tuple[object, object]]: + """Return experiments as ordered ``(key, experiment)`` pairs. + + Supports mapping-like storage without assuming contiguous integer keys. + """ + experiments = self._project_lib._experiments + if not experiments: + return [] + + if hasattr(experiments, 'items'): + items = list(experiments.items()) + try: + items.sort(key=lambda item: item[0]) + except TypeError: + pass + return items + + return list(enumerate(experiments)) + + def _experiment_at_index(self, index: int): + items = self._ordered_experiment_items() + if 0 <= index < len(items): + return items[index][1] + return None + + def _experiment_key_at_index(self, index: int): + items = self._ordered_experiment_items() + if 0 <= index < len(items): + return items[index][0] + return None def available(self) -> list[str]: experiments_name = [] try: - experiments_name.append(self._project_lib.experimental_data_for_model_at_index().name) + for _, exp in self._ordered_experiment_items(): + experiments_name.append(exp.name) except IndexError: pass return experiments_name def current_index(self) -> int: - return self._current_index + return self._project_lib._current_experiment_index def set_current_index(self, new_value: int) -> None: - if new_value != self._current_index: - new_value = self._current_index - print(new_value) + if new_value != self._project_lib._current_experiment_index: + self._project_lib._current_experiment_index = new_value return True return False + + def set_experiment_name(self, new_name: str) -> None: + exp = self._experiment_at_index(self._project_lib._current_experiment_index) + if exp: + exp.name = new_name + + def set_experiment_name_at_index(self, index: int, new_name: str) -> None: + exp = self._experiment_at_index(index) + if exp: + exp.name = new_name + + def model_on_experiment(self, experiment_index: int = -1) -> dict: + if experiment_index == -1: + experiment_index = self._project_lib._current_experiment_index + exp = self._experiment_at_index(experiment_index) + if exp: + return exp.model + return {} + + def model_index_on_experiment(self) -> int: + model = self.model_on_experiment() + if model: + return self._project_lib._models.index(model) + return -1 + + def set_model_on_experiment(self, new_value: int) -> None: + exp = self._experiment_at_index(self._project_lib._current_experiment_index) + models = self._project_lib._models + if exp and models: + try: + model = models[new_value] + exp.model = model + except IndexError: + print(f'Model index {new_value} is out of range for the current experiment.') + else: + print('No experiment or models available to set on the experiment.') + pass + + def remove_experiment(self, index: int) -> None: + """ + Remove the experiment at the given index. + """ + total = len(self.available()) + if not (0 <= index < total): + print(f'Experiment index {index} is out of range.') + return + + experiments = self._project_lib._experiments + exp_key = self._experiment_key_at_index(index) + if exp_key is None: + print(f'Experiment index {index} is out of range.') + return + + if hasattr(experiments, 'items'): + del experiments[exp_key] + else: + experiments.pop(index) + + current = self._project_lib._current_experiment_index + new_total = max(0, total - 1) + if new_total == 0: + self._project_lib._current_experiment_index = 0 + elif current > index: + self._project_lib._current_experiment_index = current - 1 + elif current >= new_total: + self._project_lib._current_experiment_index = new_total - 1 diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index e5b8df77..ad18c8aa 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -1,5 +1,17 @@ +import logging +from typing import TYPE_CHECKING +from typing import List +from typing import Optional + from easyreflectometry import Project as ProjectLib from easyscience.fitting import FitResults +from easyscience.fitting.minimizers.utils import FitError + +if TYPE_CHECKING: + from .minimizers import Minimizers + + +logger = logging.getLogger(__name__) class Fitting: @@ -7,12 +19,17 @@ def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib self._running = False self._finished = True - self._result: FitResults = None + self._result: Optional[FitResults] = None + self._results: List[FitResults] = [] # For multi-experiment fits + self._show_results_dialog = False + self._fit_error_message: Optional[str] = None + self._fit_cancelled = False + self._stop_requested = False @property def status(self) -> str: if self._result is None: - return False + return '' else: return self._result.success @@ -24,6 +41,211 @@ def running(self) -> bool: def fit_finished(self) -> bool: return self._finished + @property + def show_results_dialog(self) -> bool: + return self._show_results_dialog + + @show_results_dialog.setter + def show_results_dialog(self, value: bool) -> None: + self._show_results_dialog = value + + @property + def fit_success(self) -> bool: + """Return True if all fits succeeded.""" + if self._results: + return all(r.success for r in self._results) + if self._result is None: + return False + return self._result.success + + @property + def fit_error_message(self) -> str: + return self._fit_error_message or '' + + @property + def fit_cancelled(self) -> bool: + """Return True if fit was cancelled by user.""" + return self._fit_cancelled + + def on_fit_failed(self, error_message: str) -> None: + """Handle fitting failure callback. + + :param error_message: The error message describing the failure. + """ + self._result = None + self._results = [] + self._fit_error_message = error_message + self._running = False + self._finished = True + self._show_results_dialog = True + + def stop_fit(self) -> None: + """Request fitting to stop and clean up state.""" + self._stop_requested = True + self._result = None + self._results = [] + self._running = False + self._finished = True + self._fit_cancelled = True + self._fit_error_message = 'Fitting cancelled by user' + self._show_results_dialog = True + + def reset_stop_flag(self) -> None: + """Reset the stop request flag before starting a new fit.""" + self._stop_requested = False + self._fit_cancelled = False + + def prepare_for_threaded_fit(self) -> None: + """Prepare state for a new threaded fit. + + This method sets the internal state flags to indicate a fit is starting. + Call this before launching the background thread. + """ + self._running = True + self._finished = False + self._show_results_dialog = False + self._fit_error_message = None + + def _ordered_experiments(self) -> list: + """Return experiments as an ordered list of experiment objects. + + Handles mapping-like storage without assuming contiguous integer keys. + """ + experiments = self._project_lib._experiments + if not experiments: + return [] + + if hasattr(experiments, 'items'): + items = list(experiments.items()) + try: + items.sort(key=lambda item: item[0]) + except TypeError: + pass + return [experiment for _, experiment in items] + + return list(experiments) + + def prepare_threaded_fit(self, minimizers_logic: 'Minimizers') -> tuple: + """Prepare data for threaded fitting. + + :param minimizers_logic: The minimizers logic instance to get the current method. + :return: Tuple of (fitter, x_data, y_data, weights, method) or (None, None, None, None, None) on error. + """ + try: + from easyreflectometry.fitting import MultiFitter + + experiments = self._ordered_experiments() + if not experiments: + self._fit_error_message = 'No experiments to fit' + self._running = False + self._finished = True + 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) + + # Apply the user-selected minimizer to the new fitter + selected_minimizer = minimizers_logic.selected_minimizer_enum() + if selected_minimizer is not None: + multi_fitter.easy_science_multi_fitter.switch_minimizer(selected_minimizer) + logger.info( + 'Fitting: applied minimizer %s to MultiFitter (engine: %s, method: %s)', + selected_minimizer.name, + multi_fitter.easy_science_multi_fitter.minimizer.package, + multi_fitter.easy_science_multi_fitter.minimizer._method, + ) + if minimizers_logic.tolerance is not None: + multi_fitter.easy_science_multi_fitter.tolerance = minimizers_logic.tolerance + if minimizers_logic.max_iterations is not None: + multi_fitter.easy_science_multi_fitter.max_evaluations = minimizers_logic.max_iterations + + # Prepare data arrays for all experiments, masking out zero-variance points + import numpy as np + + 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) + + # 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}' + logger.warning( + 'Masked %d data point(s) in experiment %s due to zero variance.', + num_masked, + exp_name, + ) + + x_data.append(x_vals[valid]) + y_data.append(y_vals[valid]) + # ye contains variances (sigma²); weights = 1/sigma = 1/sqrt(variance) + weights.append(1.0 / np.sqrt(ye_vals[valid])) + + # Method is optional in fit() - pass None to use minimizer's default + method = None + + return multi_fitter.easy_science_multi_fitter, x_data, y_data, weights, method + + except Exception as e: + self._fit_error_message = f'Error preparing fit: {e}' + self._running = False + self._finished = True + self._show_results_dialog = True + logger.exception('Error preparing threaded fit') + return None, None, None, None, None + + def on_fit_finished(self, results: List[FitResults]) -> None: + """Handle successful completion of fitting. + + :param results: List of FitResults from the multi-fitter. + """ + self._running = False + self._finished = True + self._show_results_dialog = True + self._fit_error_message = None + + # Store result(s) - handle both single and multiple results + if isinstance(results, list) and len(results) > 0: + # For multi-experiment fits, store the list; use first for single-result properties + self._results = results + self._result = results[0] + engine_name = getattr(results[0], 'minimizer_engine', 'unknown') + logger.info('Fit finished: engine=%s, chi2=%s, success=%s', engine_name, self.fit_chi2, results[0].success) + else: + self._result = results + self._results = [results] if results else [] + + @property + def fit_n_pars(self) -> int: + """Return total number of refined parameters across all fits.""" + if self._results: + return sum(r.n_pars for r in self._results) + if self._result is None: + return 0 + return self._result.n_pars + + @property + def fit_chi2(self) -> float: + """Return total chi-squared across all fits.""" + if self._results: + try: + return float(sum(r.chi2 for r in self._results)) + except (ValueError, TypeError): + return 0.0 + if self._result is None: + return 0.0 + try: + return float(self._result.chi2) + except (ValueError, TypeError): + return 0.0 + def start_stop(self) -> None: if self._running: # Stop running the fitting @@ -32,7 +254,23 @@ def start_stop(self) -> None: # Start running the fitting self._running = True self._finished = False - 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) - self._running = False - self._finished = True + self._show_results_dialog = False + self._fit_error_message = 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) + except FitError as e: + # Handle fit failure - create a failed result + self._result = None + self._fit_error_message = str(e) + logger.warning('Fit failed: %s', e) + except Exception as e: + # Handle any other unexpected exceptions + self._result = None + self._fit_error_message = str(e) + logger.warning('Unexpected error during fit: %s', e) + finally: + self._running = False + self._finished = True + self._show_results_dialog = True diff --git a/EasyReflectometryApp/Backends/Py/logic/helpers.py b/EasyReflectometryApp/Backends/Py/logic/helpers.py index ab82f0ef..70465a5b 100644 --- a/EasyReflectometryApp/Backends/Py/logic/helpers.py +++ b/EasyReflectometryApp/Backends/Py/logic/helpers.py @@ -2,13 +2,13 @@ # SPDX-License-Identifier: BSD-3-Clause # © 2025 Contributors to the EasyApp project -class IO: +class IO: @staticmethod def formatMsg(type, *args): types = {'main': '*', 'sub': ' -'} mark = types[type] - widths = [22,21,20,10] + widths = [22, 21, 20, 10] widths[0] -= len(mark) msgs = [] for idx, arg in enumerate(args): @@ -16,3 +16,14 @@ def formatMsg(type, *args): msg = ' ▌ '.join(msgs) msg = f'{mark} {msg}' return msg + + +def get_original_name(obj) -> str: + """Get original name from user_data, with defensive fallback to obj.name. + + Safely handles cases where user_data is None or not a dict. + """ + user_data = getattr(obj, 'user_data', None) + if isinstance(user_data, dict): + return user_data.get('original_name', obj.name) + return obj.name diff --git a/EasyReflectometryApp/Backends/Py/logic/layers.py b/EasyReflectometryApp/Backends/Py/logic/layers.py index 4b37bfc0..92fa0e5f 100644 --- a/EasyReflectometryApp/Backends/Py/logic/layers.py +++ b/EasyReflectometryApp/Backends/Py/logic/layers.py @@ -4,19 +4,32 @@ from easyreflectometry.sample import LayerAreaPerMolecule from easyreflectometry.sample import LayerCollection from easyreflectometry.sample import Material +from easyreflectometry.sample import Sample class Layers: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib + @property + def _sample(self) -> Sample: + return self._project_lib._models[self._project_lib.current_model_index].sample + @property def _layers(self) -> LayerCollection: - return ( - self._project_lib._models[self._project_lib.current_model_index] - .sample[self._project_lib.current_assembly_index] - .layers - ) + return self._sample[self._project_lib.current_assembly_index].layers + + @property + def _assembly_type(self) -> str: + """Determine if current assembly is superphase, subphase, or regular.""" + current_index = self._project_lib.current_assembly_index + total_assemblies = len(self._sample) + if current_index == 0: + return 'superphase' + elif current_index == total_assemblies - 1: + return 'subphase' + else: + return 'regular' @property def index(self) -> int: @@ -32,7 +45,7 @@ def name_at_current_index(self) -> str: @property def layers(self) -> list[dict[str, str]]: - return _from_layers_collection_to_list_of_dicts(self._layers) + return _from_layers_collection_to_list_of_dicts(self._layers, self._assembly_type) @property def layers_names(self) -> list[str]: @@ -47,6 +60,8 @@ def add_new(self) -> None: index_si = [material.name for material in self._project_lib._materials].index('Si') self._layers.add_layer() self._layers[-1].material = self._project_lib._materials[index_si] + # Set layer name based on material name + self._layers[-1].name = self._project_lib._materials[index_si].name + ' Layer' def duplicate_selected(self) -> None: self._layers.duplicate_layer(self.index) @@ -82,6 +97,8 @@ def set_roughness_at_current_index(self, new_value: float) -> bool: def set_material_at_current_index(self, new_value: int) -> bool: if self._layers[self.index].material != self._project_lib._materials[new_value]: self._layers[self.index].material = self._project_lib._materials[new_value] + # Update layer name based on material name + self._layers[self.index].name = self._project_lib._materials[new_value].name + ' Layer' return True return False @@ -110,22 +127,43 @@ def set_formula(self, new_value: str) -> bool: return False -def _from_layers_collection_to_list_of_dicts(layers_collection: LayerCollection) -> list[dict[str, str]]: +def _from_layers_collection_to_list_of_dicts( + layers_collection: LayerCollection, assembly_type: str = 'regular' +) -> list[dict[str, str]]: + """Convert layers collection to list of dicts. + + :param layers_collection: The collection of layers. + :param assembly_type: Type of assembly - 'superphase', 'subphase', or 'regular'. + - superphase: Neither thickness nor roughness should be editable + - subphase: Only roughness should be editable + - regular: Both thickness and roughness should be editable + """ + # Determine enabled states based on assembly type + if assembly_type == 'superphase': + thickness_enabled = 'False' + roughness_enabled = 'False' + elif assembly_type == 'subphase': + thickness_enabled = 'False' + roughness_enabled = 'True' + else: # regular + thickness_enabled = 'True' + roughness_enabled = 'True' + layers_list = [] for layer in layers_collection: layers_list.append( { 'label': layer.name, 'roughness': str(layer.roughness.value), - 'roughness_enabled': str(layer.roughness.enabled), 'thickness': str(layer.thickness.value), - 'thickness_enabled': str(layer.thickness.enabled), 'material': layer.material.name, 'formula': 'formula', 'apm': '0.1', 'solvent': 'solvent', 'solvation': '0.2', 'apm_enabled': 'True', + 'thickness_enabled': thickness_enabled, + 'roughness_enabled': roughness_enabled, } ) if isinstance(layer, LayerAreaPerMolecule): diff --git a/EasyReflectometryApp/Backends/Py/logic/minimizers.py b/EasyReflectometryApp/Backends/Py/logic/minimizers.py index 9c4756e6..269bd0b5 100644 --- a/EasyReflectometryApp/Backends/Py/logic/minimizers.py +++ b/EasyReflectometryApp/Backends/Py/logic/minimizers.py @@ -26,32 +26,51 @@ def minimizers_available(self) -> list[str]: def minimizer_current_index(self) -> int: return self._minimizer_current_index - def set_minimizer_current_index(self, new_value: int) -> None: + def selected_minimizer_enum(self): + """Return the AvailableMinimizers enum for the currently selected minimizer.""" + if 0 <= self._minimizer_current_index < len(self._list_available_minimizers): + return self._list_available_minimizers[self._minimizer_current_index] + return None + + def set_minimizer_current_index(self, new_value: int) -> bool: if new_value != self._minimizer_current_index: self._minimizer_current_index = new_value enum_new_minimizer = self._list_available_minimizers[new_value] - self._project_lib._fitter.switch_minimizer(enum_new_minimizer) + self._project_lib.minimizer = enum_new_minimizer return True return False + @property + def _multi_fitter(self): + """Get the multi fitter, or None if not available.""" + if self._project_lib._fitter is None: + return None + return self._project_lib._fitter.easy_science_multi_fitter + @property def tolerance(self) -> float: - return self._project_lib._fitter.easy_science_multi_fitter.tolerance + if self._multi_fitter is None: + return 1e-6 # Default tolerance + return self._multi_fitter.tolerance @property def max_iterations(self) -> int: - return self._project_lib._fitter.easy_science_multi_fitter.max_evaluations + if self._multi_fitter is None: + return 5000 # Default max iterations + return self._multi_fitter.max_evaluations def set_tolerance(self, new_value: float) -> bool: - if new_value != self._project_lib._fitter.easy_science_multi_fitter.tolerance: - self._project_lib._fitter.easy_science_multi_fitter.tolerance = new_value - print(new_value) + if self._multi_fitter is None: + return False + if new_value != self._multi_fitter.tolerance: + self._multi_fitter.tolerance = new_value return True return False def set_max_iterations(self, new_value: float) -> bool: - if new_value != self._project_lib._fitter.easy_science_multi_fitter.max_evaluations: - self._project_lib._fitter.easy_science_multi_fitter.max_evaluations = new_value - print(new_value) + if self._multi_fitter is None: + return False + if new_value != self._multi_fitter.max_evaluations: + self._multi_fitter.max_evaluations = new_value return True return False diff --git a/EasyReflectometryApp/Backends/Py/logic/models.py b/EasyReflectometryApp/Backends/Py/logic/models.py index ce7dada7..37bc1e81 100644 --- a/EasyReflectometryApp/Backends/Py/logic/models.py +++ b/EasyReflectometryApp/Backends/Py/logic/models.py @@ -1,9 +1,12 @@ from typing import Union from easyreflectometry import Project as ProjectLib +from easyreflectometry.model import Model from easyreflectometry.model import ModelCollection from easyreflectometry.model.resolution_functions import PercentageFwhm +from .helpers import get_original_name + class Models: def __init__(self, project_lib: ProjectLib): @@ -20,7 +23,7 @@ def index(self, new_value: Union[int, str]) -> None: @property def name_at_current_index(self) -> str: - return self._models[self.index].name + return get_original_name(self._models[self.index]) @property def scaling_at_current_index(self) -> float: @@ -73,36 +76,43 @@ def set_resolution_at_current_index(self, new_value: str) -> bool: def remove_at_index(self, value: str) -> None: self._models.pop(int(value)) + def default_model_content(self, model: Model) -> None: + """Set the default content for a model.""" + model.add_assemblies() + # Superphase (Air layer) + air_material = self._project_lib._materials[self._project_lib.get_index_air()] + model.sample.data[0].layers.data[0].material = air_material + model.sample.data[0].layers.data[0].thickness = 0.0 + model.sample.data[0].layers.data[0].roughness = 0.0 + model.sample.data[0].layers.data[0].name = air_material.name + ' Layer' + model.sample.data[0].name = 'Superphase' + + # Middle layer (SiO2) + sio2_material = self._project_lib._materials[self._project_lib.get_index_sio2()] + model.sample.data[1].layers.data[0].material = sio2_material + model.sample.data[1].layers.data[0].thickness = 100.0 + model.sample.data[1].layers.data[0].roughness = 3.0 + model.sample.data[1].layers.data[0].name = sio2_material.name + ' Layer' + model.sample.data[1].name = 'SiO2' + + # Subphase (Si substrate) + si_material = self._project_lib._materials[self._project_lib.get_index_si()] + model.sample.data[2].layers.data[0].material = si_material + model.sample.data[2].name = 'Substrate' + model.sample.data[2].layers.data[0].name = si_material.name + ' Layer' + model.sample.data[2].layers.data[0].thickness = 0.0 + model.sample.data[2].layers.data[0].roughness = 1.2 + def add_new(self) -> None: self._models.add_model() - self._models[-1].sample.add_assembly() - self._models[-1].sample._enable_changes_to_outermost_layers() - - self._models[-1].sample.data[0].layers.data[0].material = self._project_lib._materials[ - self._project_lib.get_index_air() - ] - self._models[-1].sample.data[0].layers.data[0].thickness = 0.0 - self._models[-1].sample.data[0].layers.data[0].roughness = 0.0 - self._models[-1].sample.data[0].name = 'Superphase' - - self._models[-1].sample.data[1].layers.data[0].material = self._project_lib._materials[ - self._project_lib.get_index_sio2() - ] - self._models[-1].sample.data[1].layers.data[0].thickness = 20.0 - self._models[-1].sample.data[1].layers.data[0].roughness = 3.0 - self._models[-1].sample.data[1].name = 'SiO2' - - self._models[-1].sample.data[2].layers.data[0].material = self._project_lib._materials[ - self._project_lib.get_index_si() - ] - self._models[-1].sample.data[2].name = 'Substrate' - self._models[-1].sample.data[2].layers.data[0].thickness = 0.0 - self._models[-1].sample.data[2].layers.data[0].roughness = 1.2 - - self._models[-1].sample._disable_changes_to_outermost_layers() + self.default_model_content(self._models[-1]) + # Update index to point to the new model + self.index = len(self._models) - 1 def duplicate_selected_model(self) -> None: self._models.duplicate_model(self.index) + # Update index to point to the duplicated model + self.index = len(self._models) - 1 def move_selected_up(self) -> None: if self.index > 0: @@ -120,7 +130,7 @@ def _from_models_collection_to_list_of_dicts(models_collection: ModelCollection) for model in models_collection: models_list.append( { - 'label': model.name, + 'label': get_original_name(model), 'color': str(model.color), } ) diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index 01cbb30b..51386f8b 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -1,33 +1,133 @@ +import re +from typing import Any from typing import List +from typing import Tuple from easyreflectometry import Project as ProjectLib from easyreflectometry.utils import count_fixed_parameters from easyreflectometry.utils import count_free_parameters from easyscience import global_object -from easyscience.Constraints import NumericConstraint -from easyscience.Constraints import ObjConstraint -from easyscience.Objects.variable import Parameter +from easyscience.variable import Parameter + +from .helpers import get_original_name + +RESERVED_ALIAS_NAMES = {'np', 'numpy', 'math', 'pi', 'e'} class Parameters: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib self._current_index = 0 + self._name_filter_criteria = '' + self._variability_filter_criteria = 'all' @property def as_status_string(self) -> str: return f'{self.count_free_parameters() + self.count_fixed_parameters()} ({self.count_free_parameters()} free, {self.count_fixed_parameters()} fixed)' # noqa: E501 @property - def parameters(self) -> List[str]: - return _from_parameters_to_list_of_dicts( - self._project_lib.parameters, self._project_lib._models[self._project_lib.current_model_index].unique_name - ) + def parameters(self) -> list[dict[str, Any]]: + parameters = self.all_parameters() + return [parameter for parameter in parameters if self._parameter_matches_filters(parameter)] + + def all_parameters(self) -> list[dict[str, Any]]: + return _from_parameters_to_list_of_dicts(self._project_lib.parameters, self._project_lib._models) + + @property + def name_filter_criteria(self) -> str: + return self._name_filter_criteria + + @property + def variability_filter_criteria(self) -> str: + return self._variability_filter_criteria + + def set_name_filter_criteria(self, criteria: str) -> bool: + normalized = (criteria or '').strip() + if normalized == self._name_filter_criteria: + return False + self._name_filter_criteria = normalized + self._current_index = 0 + return True + + def set_variability_filter_criteria(self, criteria: str) -> bool: + normalized = (criteria or 'all').strip().lower() + if normalized not in {'all', 'free', 'fixed'}: + normalized = 'all' + if normalized == self._variability_filter_criteria: + return False + self._variability_filter_criteria = normalized + self._current_index = 0 + return True + + def is_experiment_parameter(self, parameter: dict[str, Any]) -> bool: + return _is_experiment_parameter(parameter) + + def _parameter_matches_filters(self, parameter: dict[str, Any]) -> bool: + if not parameter.get('enabled', True): + return False + + if self._variability_filter_criteria == 'free' and not parameter.get('fit', False): + return False + if self._variability_filter_criteria == 'fixed' and parameter.get('fit', False): + return False + + criteria = self._name_filter_criteria + if not criteria: + return True + + normalized = criteria.lower() + searchable_text = ' '.join( + str(parameter.get(key, '')) for key in ('name', 'display_name', 'group', 'unique_name') + ).lower() + + if normalized == 'model': + return not _is_experiment_parameter(parameter) + if normalized == 'experiment': + return _is_experiment_parameter(parameter) + if normalized in {'cell', 'atom_site'}: + return normalized in searchable_text + if normalized == 'b_iso': + return 'b_iso' in searchable_text or 'adp' in searchable_text + + return normalized in searchable_text + + def constraint_context(self) -> list[dict[str, Any]]: + parameter_snapshot = self.all_parameters() + context: list[dict[str, Any]] = [] + for parameter in parameter_snapshot: + context.append( + { + 'alias': parameter['alias'], + 'display_name': parameter['display_name'], + 'group': parameter.get('group', ''), + 'independent': parameter['independent'], + 'object': parameter['object'], + } + ) + return context + + def constraint_metadata(self) -> list[dict[str, Any]]: + context = self.constraint_context() + metadata: list[dict[str, Any]] = [] + for entry in context: + # Include ALL parameters (both independent and dependent) for constraint expressions + # if not entry['independent']: + # continue + metadata.append( + { + 'alias': entry['alias'], + 'displayName': entry['display_name'], + 'group': entry.get('group', ''), + 'independent': entry['independent'], + } + ) + metadata.sort(key=lambda item: item['displayName']) + return metadata def current_index(self) -> int: return self._current_index - def set_current_index(self, new_value: int) -> None: + def set_current_index(self, new_value: int) -> bool: if new_value != self._current_index: self._current_index = new_value return True @@ -39,46 +139,70 @@ def count_free_parameters(self) -> int: def count_fixed_parameters(self) -> int: return count_fixed_parameters(self._project_lib) + def _get_enabled_parameters(self) -> List[Parameter]: + """Return only enabled parameters from the project, filtered the same way as the parameters property.""" + # Use the parameters property which already filters by model path, then filter by enabled + return [p['object'] for p in self.parameters if p.get('enabled', True)] + + def _get_current_parameter(self) -> Parameter: + """Get the current parameter from enabled parameters list.""" + enabled_params = self._get_enabled_parameters() + if 0 <= self._current_index < len(enabled_params): + return enabled_params[self._current_index] + return None + def set_current_parameter_value(self, new_value: str) -> bool: - parameters = self._project_lib.parameters - if float(new_value) != parameters[self._current_index].value: + parameter = self._get_current_parameter() + if parameter is None: + return False + if float(new_value) != parameter.value: try: - parameters[self._current_index].value = float(new_value) + parameter.value = float(new_value) except ValueError: pass return True return False def set_current_parameter_min(self, new_value: str) -> bool: - parameters = self._project_lib.parameters - if float(new_value) != parameters[self._current_index].min: + parameter = self._get_current_parameter() + if parameter is None: + return False + if float(new_value) != parameter.min: try: - parameters[self._current_index].min = float(new_value) + parameter.min = float(new_value) except ValueError: pass return True return False def set_current_parameter_max(self, new_value: str) -> bool: - parameters = self._project_lib.parameters - if float(new_value) != parameters[self._current_index].max: + parameter = self._get_current_parameter() + if parameter is None: + return False + if float(new_value) != parameter.max: try: - parameters[self._current_index].max = float(new_value) + parameter.max = float(new_value) except ValueError: pass return True return False - def set_current_parameter_fit(self, new_value: str) -> bool: - parameters = self._project_lib.parameters - if bool(new_value) != parameters[self._current_index].free: - parameters[self._current_index].free = bool(new_value) + def set_current_parameter_fit(self, new_value: bool) -> bool: + parameter = self._get_current_parameter() + if parameter is None: + return False + if bool(new_value) != parameter.free: + parameter.free = bool(new_value) return True return False ### Constraints - def constraint_relations(self) -> List[str]: - return ['=', '<', '>'] + def constraint_relations(self) -> List[dict[str, str]]: + return [ + {'value': '=', 'text': '='}, + {'value': '>', 'text': '≥'}, + {'value': '<', 'text': '≤'}, + ] def constraint_arithmetic(self) -> List[str]: return ['', '*', '/', '+', '-'] @@ -90,39 +214,141 @@ def add_constraint( dependent = self._project_lib.parameters[dependent_idx] if arithmetic_operator != '' and independent_idx > -1: - constaint = ObjConstraint( - dependent_obj=dependent, operator=str(float(value)) + arithmetic_operator, independent_obj=independent + dependent.make_dependent_on( + dependency_expression='a' + arithmetic_operator + 'b', dependency_map={'a': independent, 'b': float(value)} ) elif arithmetic_operator == '' and independent_idx == -1: relational_operator = relational_operator.replace('=', '==') relational_operator = relational_operator.replace('<', '>') relational_operator = relational_operator.replace('>', '<') - constaint = NumericConstraint(dependent_obj=dependent, operator=relational_operator, value=float(value)) + + dependent.make_dependent_on(dependency_expression='a', dependency_map={'a': float(value)}) else: print('Failed to add constraint: Unsupported type') return - # print(c) - independent.user_constraints[dependent.name] = constaint - constaint() print(f'{dependent_idx}, {relational_operator}, {value}, {arithmetic_operator}, {independent_idx}') -def _from_parameters_to_list_of_dicts(parameters: List[Parameter], model_unique_name: str) -> list[dict[str, str]]: +def _from_parameters_to_list_of_dicts(parameters: List[Parameter], models) -> list[dict[str, Any]]: + """Convert parameters to list of dictionaries with simplified logic. + + Layer parameters (thickness, roughness) are prefixed with model identifier (e.g., M1, M2). + Material parameters and model parameters (scale, background) are not prefixed to avoid duplication. + """ + + alias_registry: set[str] = set() + processed_unique_names: set[str] = set() # Track processed parameters to avoid duplicates + + # Layer parameter names that need model prefix + LAYER_PARAMS = {'thickness', 'roughness'} + + def _make_alias(name: str) -> str: + base = re.sub(r'[^0-9A-Za-z]+', '_', name).strip('_').lower() + if not base: + base = 'param' + if base[0].isdigit(): + base = f'p_{base}' + alias = base + counter = 1 + while alias in alias_registry or alias in RESERVED_ALIAS_NAMES: + alias = f'{base}_{counter}' + counter += 1 + alias_registry.add(alias) + return alias + + def _get_parameter_display_data(param: Parameter, model_unique_name: str) -> Tuple[str, str]: + """Extract display name and group from parameter path.""" + path = global_object.map.find_path(model_unique_name, param.unique_name) + if len(path) >= 2: + parent_name = global_object.map.get_item_by_key(path[-2]).name + param_name = global_object.map.get_item_by_key(path[-1]).name + return f'{parent_name} {param_name}', parent_name + return param.name, '' # Fallback to parameter name without group + + def _get_dependency_expression(param: Parameter, model_unique_name: str) -> str: + """Get simplified dependency expression.""" + if param.independent: + return '' + + # Check if parameter has dependency map with 'a' key (parameter dependency) + if hasattr(param, 'dependency_map') and 'a' in param.dependency_map: + dependent_param = param.dependency_map['a'] + if isinstance(dependent_param, Parameter): + dep_name, _ = _get_parameter_display_data(dependent_param, model_unique_name) + else: + dep_name = str(dependent_param) + return param.dependency_expression.replace('a', dep_name) + + # Simple numerical dependency + return f'= {param.value}' + + def _is_layer_parameter(param: Parameter) -> bool: + """Check if parameter is a layer parameter (thickness or roughness).""" + return param.name.lower() in LAYER_PARAMS + parameter_list = [] - for parameter in parameters: - path = global_object.map.find_path(model_unique_name, parameter.unique_name) - if 0 < len(path): - name = f'{global_object.map.get_item_by_key(path[-2]).name} {global_object.map.get_item_by_key(path[-1]).name}' + + # Process parameters for each model + for model_idx, model in enumerate(models): + model_unique_name = model.unique_name + model_prefix = get_original_name(model) + + for parameter in parameters: + # Skip parameters not in this model's path + if not global_object.map.find_path(model_unique_name, parameter.unique_name): + continue + + # For non-layer parameters, skip if already processed (they're shared across models) + is_layer_param = _is_layer_parameter(parameter) + if not is_layer_param: + if parameter.unique_name in processed_unique_names: + continue + processed_unique_names.add(parameter.unique_name) + + display_name, group_name = _get_parameter_display_data(parameter, model_unique_name) + + # Add model prefix only to layer parameters (thickness, roughness) + if is_layer_param: + prefixed_display_name = f'{model_prefix} {display_name}' + else: + prefixed_display_name = display_name + + alias = _make_alias(prefixed_display_name or parameter.name) + param_value = float(parameter.value) parameter_list.append( { - 'name': name, - 'value': float(parameter.value), + 'name': prefixed_display_name, + 'display_name': prefixed_display_name, + 'group': group_name, + 'alias': alias, + 'unique_name': parameter.unique_name, + 'value': param_value, 'error': float(parameter.variance), 'max': float(parameter.max), 'min': float(parameter.min), 'units': parameter.unit, 'fit': parameter.free, + 'independent': parameter.independent, + 'dependency': _get_dependency_expression(parameter, model_unique_name), + 'enabled': parameter.enabled if hasattr(parameter, 'enabled') else True, + 'object': parameter, # Direct reference to the Parameter object } ) + return parameter_list + + +def _is_experiment_parameter(parameter: dict[str, Any]) -> bool: + searchable_text = ' '.join(str(parameter.get(key, '')) for key in ('name', 'display_name', 'group', 'unique_name')).lower() + experiment_markers = ( + 'experiment', + 'dataset', + 'instrument', + 'resolution', + 'asymmetry', + 'background', + 'scale', + 'probe', + ) + return any(marker in searchable_text for marker in experiment_markers) diff --git a/EasyReflectometryApp/Backends/Py/logic/project.py b/EasyReflectometryApp/Backends/Py/logic/project.py index e47a512a..d13674c2 100644 --- a/EasyReflectometryApp/Backends/Py/logic/project.py +++ b/EasyReflectometryApp/Backends/Py/logic/project.py @@ -7,6 +7,8 @@ class Project: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib + self._project_lib.default_model() + self._update_enablement_of_fixed_layers_for_model(0) @property def created(self) -> bool: @@ -84,6 +86,12 @@ def experimental_data_at_current_index(self) -> bool: pass return experimental_data + def _update_enablement_of_fixed_layers_for_model(self, index: int) -> None: + sample = self._project_lib.models[index].sample + sample[0].layers[0].thickness.enabled = False + sample[0].layers[0].roughness.enabled = False + sample[-1].layers[-1].thickness.enabled = False + def info(self) -> dict: info = copy(self._project_lib._info) info['location'] = self._project_lib.path @@ -102,6 +110,29 @@ def load(self, path: str) -> None: def load_experiment(self, path: str) -> None: self._project_lib.load_experiment_for_model_at_index(path, self._project_lib._current_model_index) + def load_new_experiment(self, path: str) -> None: + self._project_lib.load_new_experiment(path) + + def count_datasets_in_file(self, path: str) -> int: + return self._project_lib.count_datasets_in_file(path) + + def load_all_experiments_from_file(self, path: str) -> int: + return self._project_lib.load_all_experiments_from_file(path) + + def set_sample_from_orso(self, sample) -> None: + self._project_lib.set_sample_from_orso(sample) + + def add_sample_from_orso(self, sample) -> None: + """Add a new model with the given sample to the existing model collection.""" + self._project_lib.add_sample_from_orso(sample) + new_model_index = len(self._project_lib.models) - 1 + self._update_enablement_of_fixed_layers_for_model(new_model_index) + + def replace_models_from_orso(self, sample) -> None: + """Replace all existing models with a single model built from the loaded sample.""" + self._project_lib.replace_models_from_orso(sample) + self._update_enablement_of_fixed_layers_for_model(0) + def reset(self) -> None: self._project_lib.reset() self._project_lib.default_model() diff --git a/EasyReflectometryApp/Backends/Py/logic/status.py b/EasyReflectometryApp/Backends/Py/logic/status.py index 87ac11ce..89fc2fb7 100644 --- a/EasyReflectometryApp/Backends/Py/logic/status.py +++ b/EasyReflectometryApp/Backends/Py/logic/status.py @@ -4,6 +4,10 @@ class Status: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib + self._minimizers_logic = None + + def set_minimizers_logic(self, minimizers_logic): + self._minimizers_logic = minimizers_logic @property def project(self): @@ -11,6 +15,11 @@ def project(self): @property def minimizer(self): + if self._minimizers_logic is not None: + available = self._minimizers_logic.minimizers_available() + idx = self._minimizers_logic.minimizer_current_index() + if 0 <= idx < len(available): + return available[idx] return self._project_lib.minimizer.name @property diff --git a/EasyReflectometryApp/Backends/Py/logic/summary.py b/EasyReflectometryApp/Backends/Py/logic/summary.py index 763e521c..88b4dd90 100644 --- a/EasyReflectometryApp/Backends/Py/logic/summary.py +++ b/EasyReflectometryApp/Backends/Py/logic/summary.py @@ -1,4 +1,8 @@ +import logging from pathlib import Path +from html import escape + +import numpy as np from easyreflectometry import Project as ProjectLib from easyreflectometry.summary import Summary as SummaryLib @@ -11,6 +15,7 @@ def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib self._summary = SummaryLib(project_lib) self._file_name = 'summary' + self._plot_file_name = 'plots' @property def created(self) -> bool: @@ -28,16 +33,209 @@ def file_name(self, value: str) -> None: def file_path(self) -> Path: return self._project_lib.path / self._file_name + @property + def plot_file_name(self) -> str: + return self._plot_file_name + + @plot_file_name.setter + def plot_file_name(self, value: str) -> None: + self._plot_file_name = value + + @property + def plot_file_path(self) -> Path: + return self._project_lib.path / self._plot_file_name + @property def as_html(self) -> str: - return self._summary.compile_html_summary() + base_html = self._summary.compile_html_summary() + return self._inject_multimodel_multiexperiment_sections(base_html) - def save_as_html(self) -> None: + def save_as_html(self, file_path: str | None = None) -> None: if not self._project_lib.path.exists(): self._project_lib.path.mkdir(parents=True, exist_ok=True) - self._summary.save_html_summary(self.file_path.with_suffix('.html')) - def save_as_pdf(self) -> None: + target_path = Path(file_path) if file_path else self.file_path.with_suffix('.html') + target_path.parent.mkdir(parents=True, exist_ok=True) + html_content = self._summary.compile_html_summary(figures=True) + html_content = self._inject_multimodel_multiexperiment_sections(html_content) + + with open(target_path, 'w', encoding='utf-8') as report_file: + report_file.write(html_content) + + def save_as_pdf(self, file_path: str | None = None) -> None: if not self._project_lib.path.exists(): self._project_lib.path.mkdir(parents=True, exist_ok=True) - self._summary.save_pdf_summary(self.file_path.with_suffix('.pdf')) + + target_path = Path(file_path) if file_path else self.file_path.with_suffix('.pdf') + target_path.parent.mkdir(parents=True, exist_ok=True) + self._summary.save_pdf_summary(target_path) + + def save_plot(self, file_path: str, width_cm: float, height_cm: float) -> None: + target_path = Path(file_path) + target_path.parent.mkdir(parents=True, exist_ok=True) + figure = self.make_plot(width_cm, height_cm) + figure.savefig(target_path, dpi=600) + self._plt().close(figure) + + def show_plot(self, width_cm: float, height_cm: float) -> None: + self.make_plot(width_cm, height_cm) + self._plt().show() + + def _plt(self): + # Prevent noisy matplotlib debug logs like "findfont: score(...)" in app console. + logging.getLogger('matplotlib').setLevel(logging.WARNING) + logging.getLogger('matplotlib.font_manager').setLevel(logging.WARNING) + import matplotlib.pyplot as plt + + return plt + + def _gridspec(self): + import matplotlib.gridspec as gridspec + + return gridspec + + def make_plot(self, width_cm: float, height_cm: float): + plt = self._plt() + gridspec = self._gridspec() + + fig = plt.figure(figsize=(width_cm / 2.54, height_cm / 2.54), constrained_layout=True) + gs = gridspec.GridSpec(1, 2, figure=fig) + ax_reflectivity = fig.add_subplot(gs[0, 0]) + ax_sld = fig.add_subplot(gs[0, 1]) + + ax_reflectivity.set_xlabel('$q$/A$^{-1}$') + ax_reflectivity.set_ylabel('$R(q)$') + ax_reflectivity.set_yscale('log') + ax_sld.set_xlabel('$z$/A') + ax_sld.set_ylabel('SLD($z$)/$10^{-6}$A$^{-2}$') + + 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, + ) + 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) + else: + for model_index, model in enumerate(self._project_lib.models): + sample_data = self._project_lib.sample_data_for_model_at_index(model_index) + if sample_data.x.size == 0 or sample_data.y.size == 0: + continue + + color = getattr(model, 'color', None) or '#1f77b4' + ax_reflectivity.plot( + sample_data.x, + sample_data.y * (10**model_index), + ls='-', + color=color, + zorder=10, + label=model.name, + ) + + for model_index, model in enumerate(self._project_lib.models): + sld_data = self._project_lib.sld_data_for_model_at_index(model_index) + if sld_data.x.size == 0 or sld_data.y.size == 0: + continue + + color = getattr(model, 'color', None) or '#1f77b4' + ax_sld.plot(sld_data.x, sld_data.y + (10 * model_index), color=color, ls='-', label=model.name) + + if ax_reflectivity.has_data(): + ax_reflectivity.legend(loc='best') + + return fig + + def _ordered_experiments(self) -> list[tuple[int, object]]: + experiments = self._project_lib.experiments + if hasattr(experiments, 'items'): + return sorted(experiments.items(), key=lambda item: item[0]) + return list(enumerate(experiments)) + + def _inject_multimodel_multiexperiment_sections(self, html: str) -> str: + extra_sections = [ + self._all_models_section_html(), + self._all_experiments_section_html(), + ] + combined_sections = ''.join(section for section in extra_sections if section) + if not combined_sections: + return html + + if '' in html: + return html.replace('', f'{combined_sections}', 1) + return f'{html}\n{combined_sections}' + + def _all_models_section_html(self) -> str: + rows = [] + for model_index, model in enumerate(self._project_lib.models): + assemblies = len(model.sample) + layers = sum(len(assembly.layers) for assembly in model.sample) + rows.append( + ( + f'{model_index}{escape(model.name)}' + f'{assemblies}{layers}' + ) + ) + + if not rows: + return '

All Samples

No samples available.

' + + table_rows = ''.join(rows) + return ( + '

All Samples

' + '' + '' + f'{table_rows}' + '
IndexNameAssembliesLayers
' + ) + + def _all_experiments_section_html(self) -> str: + experiment_rows = [] + for experiment_index, experiment in self._ordered_experiments(): + x = np.asarray(experiment.x) + q_min = float(np.min(x)) if x.size else float('nan') + q_max = float(np.max(x)) if x.size else float('nan') + model_name = getattr(getattr(experiment, 'model', None), 'name', 'N/A') + name = experiment.name or f'Experiment {experiment_index + 1}' + + experiment_rows.append( + ( + f'{experiment_index}{escape(name)}' + f'{escape(model_name)}{len(x)}' + f'{q_min:.6g}{q_max:.6g}' + ) + ) + + if not experiment_rows: + return '

All Experiments

No experiments available.

' + + rows_str = ''.join(experiment_rows) + return ( + '

All Experiments

' + '' + '' + f'{rows_str}' + '
IndexNameModelPointsq minq max
' + ) diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 058a6315..a34744b6 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -17,12 +17,31 @@ class Plotting1d(QObject): sldChartRangesChanged = Signal() sampleChartRangesChanged = Signal() experimentChartRangesChanged = Signal() + experimentDataChanged = Signal() + samplePageDataChanged = Signal() # Signal for QML to refresh sample page charts + samplePageResetAxes = Signal() # Signal for QML to reset chart axes after data load + + # New signals for plot mode properties + plotModeChanged = Signal() + axisTypeChanged = Signal() + sldAxisReversedChanged = Signal() + referenceLineVisibilityChanged = Signal() def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) self._project_lib = project_lib self._proxy = parent self._currentLib1d = 'QtCharts' + self._sample_data = {} + self._model_data = {} + self._sld_data = {} + + # Plot mode state + self._plot_rq4 = False + self._x_axis_log = False + self._sld_x_reversed = False + self._scale_shown = False + self._bkg_shown = False self._chartRefs = { 'QtCharts': { 'samplePage': { @@ -37,38 +56,217 @@ def __init__(self, project_lib: ProjectLib, parent=None): 'analysisPage': { 'calculatedSerie': None, 'measuredSerie': None, + 'sldSerie': None, }, } } + def reset_data(self): + self._sample_data = {} + self._model_data = {} + self._sld_data = {} + console.debug(IO.formatMsg('sub', 'Sample and SLD data cleared')) + + def _apply_rq4(self, x, y): + """Apply R(q)×q⁴ transformation if enabled. + + Works with both numpy arrays and scalar values. + """ + if self._plot_rq4: + return y * (x**4) + return y + + # R(q)×q⁴ mode + @Property(bool, notify=plotModeChanged) + def plotRQ4(self) -> bool: + """Return whether R(q)×q⁴ mode is enabled.""" + return self._plot_rq4 + + @Slot() + def togglePlotRQ4(self) -> None: + """Toggle R(q)×q⁴ plotting mode.""" + self._plot_rq4 = not self._plot_rq4 + self.plotModeChanged.emit() + # Refresh all charts with new mode + self.sampleChartRangesChanged.emit() + self.experimentChartRangesChanged.emit() + self.samplePageDataChanged.emit() + + @Property(str, notify=plotModeChanged) + def yMainAxisTitle(self) -> str: + """Return Y-axis title based on current plot mode.""" + return 'R(q)×q⁴' if self._plot_rq4 else 'R(q)' + + # X-axis type (log/linear) + @Property(bool, notify=axisTypeChanged) + def xAxisLog(self) -> bool: + """Return whether X-axis is logarithmic.""" + return self._x_axis_log + + @Slot() + def toggleXAxisType(self) -> None: + """Toggle between linear and logarithmic X-axis.""" + self._x_axis_log = not self._x_axis_log + self.axisTypeChanged.emit() + + @Property(str, notify=axisTypeChanged) + def xAxisType(self) -> str: + """Return X-axis type as string for QML.""" + return 'log' if self._x_axis_log else 'linear' + + # SLD X-axis reversal + @Property(bool, notify=sldAxisReversedChanged) + def sldXDataReversed(self) -> bool: + """Return whether SLD X-axis is reversed.""" + return self._sld_x_reversed + + @Slot() + def reverseSldXData(self) -> None: + """Toggle SLD X-axis reversal.""" + self._sld_x_reversed = not self._sld_x_reversed + self.sldAxisReversedChanged.emit() + self.sldChartRangesChanged.emit() + + # Reference line visibility + @Property(bool, notify=referenceLineVisibilityChanged) + def scaleShown(self) -> bool: + """Return whether scale reference line is shown.""" + return self._scale_shown + + @Slot() + def flipScaleShown(self) -> None: + """Toggle scale line visibility.""" + self._scale_shown = not self._scale_shown + self.referenceLineVisibilityChanged.emit() + + @Property(bool, notify=referenceLineVisibilityChanged) + def bkgShown(self) -> bool: + """Return whether background reference line is shown.""" + return self._bkg_shown + + @Slot() + def flipBkgShown(self) -> None: + """Toggle background line visibility.""" + self._bkg_shown = not self._bkg_shown + self.referenceLineVisibilityChanged.emit() + + def _get_reference_line_data(self, param_attr: str, default_log: float, use_analysis_range: bool) -> list: + """Build a horizontal reference line for the given model parameter. + + :param param_attr: Model attribute name ('background' or 'scale') + :param default_log: Default log10 value if parameter <= 0 + :param use_analysis_range: If True, use sample/analysis x-range; if False, use experimental data x-range + """ + try: + model_idx = self._project_lib.current_model_index + model = self._project_lib.models[model_idx] + + if use_analysis_range: + x_min, x_max = self._get_all_models_sample_range()[0:2] + if x_min == float('inf') or x_max == float('-inf'): + return [] + else: + exp_idx = self._project_lib.current_experiment_index + exp_data = self._project_lib.experimental_data_for_model_at_index(exp_idx) + 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]) + + param_value = getattr(model, param_attr).value + y_log = float(np.log10(param_value)) if param_value > 0 else default_log + return [{'x': float(x_min), 'y': y_log}, {'x': float(x_max), 'y': y_log}] + except (IndexError, AttributeError, TypeError) as e: + console.debug(f'Error getting {param_attr} reference line data: {e}') + return [] + + @Slot(result='QVariantList') + def getBackgroundData(self) -> list: + """Return background reference line data for the Experiment chart.""" + if not self._bkg_shown: + return [] + return self._get_reference_line_data('background', -10.0, use_analysis_range=False) + + @Slot(result='QVariantList') + def getScaleData(self) -> list: + """Return scale reference line data for the Experiment chart.""" + if not self._scale_shown: + return [] + return self._get_reference_line_data('scale', 0.0, use_analysis_range=False) + + @Slot(result='QVariantList') + def getBackgroundDataForAnalysis(self) -> list: + """Return background reference line data for the Analysis chart (sample x-range).""" + if not self._bkg_shown: + return [] + return self._get_reference_line_data('background', -10.0, use_analysis_range=True) + + @Slot(result='QVariantList') + def getScaleDataForAnalysis(self) -> list: + """Return scale reference line data for the Analysis chart (sample x-range).""" + if not self._scale_shown: + return [] + return self._get_reference_line_data('scale', 0.0, use_analysis_range=True) + @property def sample_data(self) -> DataSet1D: + idx = self._project_lib.current_model_index + if idx in self._sample_data and self._sample_data[idx] is not None: + return self._sample_data[idx] try: - data = self._project_lib.sample_data_for_model_at_index(self._project_lib.current_model_index) + data = self._project_lib.sample_data_for_model_at_index(idx) except IndexError: data = DataSet1D( name='Sample Data empty', x=np.empty(0), y=np.empty(0), ) + self._sample_data[idx] = data + return data + + @property + def model_data(self) -> DataSet1D: + idx = self._project_lib.current_model_index + if idx in self._model_data and self._model_data[idx] is not None: + return self._model_data[idx] + try: + data = self._project_lib.model_data_for_model_at_index(idx) + except IndexError: + data = DataSet1D( + name='Model Data empty', + x=np.empty(0), + y=np.empty(0), + ) + self._model_data[idx] = data return data @property def sld_data(self) -> DataSet1D: + idx = self._project_lib.current_model_index + if idx in self._sld_data and self._sld_data[idx] is not None: + return self._sld_data[idx] try: - data = self._project_lib.sld_data_for_model_at_index(self._project_lib.current_model_index) + data = self._project_lib.sld_data_for_model_at_index(idx) except IndexError: data = DataSet1D( name='SLD Data empty', x=np.empty(0), y=np.empty(0), ) + self._sld_data[idx] = data return data @property def experiment_data(self) -> DataSet1D: try: - data = self._project_lib.experimental_data_for_model_at_index(self._project_lib.current_model_index) + # Check if multi-experiment selection is enabled + if hasattr(self._proxy, '_analysis') and hasattr(self._proxy._analysis, '_selected_experiment_indices'): + selected_indices = self._proxy._analysis._selected_experiment_indices + 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 + current_index = self._project_lib.current_experiment_index + data = self._project_lib.experimental_data_for_model_at_index(current_index) except IndexError: data = DataSet1D( name='Experiment Data empty', @@ -79,59 +277,351 @@ def experiment_data(self) -> DataSet1D: ) return data + @property + def is_multi_experiment_mode(self) -> bool: + """Check if multiple experiments are selected.""" + try: + if hasattr(self._proxy, '_analysis') and hasattr(self._proxy._analysis, '_selected_experiment_indices'): + return len(self._proxy._analysis._selected_experiment_indices) > 1 + except Exception: # noqa: S110 + pass + return False + + @property + def individual_experiment_data_list(self) -> list: + """Get individual experiment data for multi-experiment plotting.""" + try: + if hasattr(self._proxy, '_analysis'): + return self._proxy._analysis.get_individual_experiment_data_list() + except Exception as e: + console.debug(f'Error getting individual experiment data: {e}') + return [] + # Sample @Property(float, notify=sampleChartRangesChanged) def sampleMaxX(self): - return self.sample_data.x.max() + return self._get_all_models_sample_range()[1] @Property(float, notify=sampleChartRangesChanged) def sampleMinX(self): - return self.sample_data.x.min() + return self._get_all_models_sample_range()[0] @Property(float, notify=sampleChartRangesChanged) def sampleMaxY(self): - return np.log10(self.sample_data.y.max()) + return self._get_all_models_sample_range()[3] @Property(float, notify=sampleChartRangesChanged) def sampleMinY(self): - return np.log10(self.sample_data.y.min()) + return self._get_all_models_sample_range()[2] + + def _get_all_models_sample_range(self): + """Get combined X/Y ranges for all models' sample data.""" + min_x, max_x = float('inf'), float('-inf') + min_y, max_y = float('inf'), float('-inf') + + for idx in range(len(self._project_lib.models)): + try: + data = self._project_lib.sample_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: + valid_mask = data.y > 0 + valid_y = data.y[valid_mask] + if valid_y.size > 0: + valid_y = self._apply_rq4(data.x[valid_mask], valid_y) + min_y = min(min_y, np.log10(valid_y.min())) + max_y = max(max_y, np.log10(valid_y.max())) + except (IndexError, ValueError): + continue + + # Fallback to current model if no valid data found + if min_x == float('inf'): + min_x = self.sample_data.x.min() if self.sample_data.x.size > 0 else 0.0 + if max_x == float('-inf'): + max_x = self.sample_data.x.max() if self.sample_data.x.size > 0 else 1.0 + if min_y == float('inf'): + valid_y = self.sample_data.y[self.sample_data.y > 0] if self.sample_data.y.size > 0 else np.array([]) + min_y = np.log10(valid_y.min()) if valid_y.size > 0 else -10.0 + if max_y == float('-inf'): + valid_y = self.sample_data.y[self.sample_data.y > 0] if self.sample_data.y.size > 0 else np.array([]) + max_y = np.log10(valid_y.max()) if valid_y.size > 0 else 0.0 + + return (min_x, max_x, min_y, max_y) # SLD @Property(float, notify=sldChartRangesChanged) def sldMaxX(self): - return self.sld_data.x.max() + return self._get_all_models_sld_range()[1] @Property(float, notify=sldChartRangesChanged) def sldMinX(self): - return self.sld_data.x.min() + return self._get_all_models_sld_range()[0] @Property(float, notify=sldChartRangesChanged) def sldMaxY(self): - return self.sld_data.y.max() + return self._get_all_models_sld_range()[3] @Property(float, notify=sldChartRangesChanged) def sldMinY(self): - return self.sld_data.y.min() + return self._get_all_models_sld_range()[2] + + 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') + min_y, max_y = float('inf'), float('-inf') + + 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()) + except (IndexError, ValueError): + continue + + # Fallback to current model if no valid data found + if min_x == float('inf'): + min_x = self.sld_data.x.min() if self.sld_data.x.size > 0 else 0.0 + if max_x == float('-inf'): + max_x = self.sld_data.x.max() if self.sld_data.x.size > 0 else 1.0 + if min_y == float('inf'): + min_y = self.sld_data.y.min() if self.sld_data.y.size > 0 else -1.0 + if max_y == float('-inf'): + max_y = self.sld_data.y.max() if self.sld_data.y.size > 0 else 1.0 + + return (min_x, max_x, min_y, max_y) + + # Experiment ranges + @Property(float, notify=experimentChartRangesChanged) + def experimentMaxX(self): + data = self.experiment_data + return data.x.max() if data.x.size > 0 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 + + @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) + return np.log10(y_values.max()) + + @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()) @Property('QVariant', notify=chartRefsChanged) def chartRefs(self): return self._chartRefs + @Property(str) + def calcSerieColor(self): + return '#00FF00' + # return self._calcSerieColor + + @Property(bool, notify=experimentDataChanged) + def isMultiExperimentMode(self) -> bool: + """Return whether multiple experiments are selected for plotting.""" + return self.is_multi_experiment_mode + + @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 + # Convert to QML-friendly format + qml_data_list = [] + for exp_data in data_list: + qml_data_list.append( + { + 'name': exp_data['name'], + 'color': exp_data['color'], + 'index': exp_data['index'], + 'hasData': exp_data['data'].x.size > 0, + } + ) + return qml_data_list + @Slot(str, str, 'QVariant') def setQtChartsSerieRef(self, page: str, serie: str, ref: QObject): self._chartRefs['QtCharts'][page][serie] = ref console.debug(IO.formatMsg('sub', f'{serie} on {page}: {ref}')) + @Slot(int, result='QVariantList') + def getSampleDataPointsForModel(self, model_index: int) -> list: + """Get sample data points for a specific model for plotting.""" + try: + data = self._project_lib.sample_data_for_model_at_index(model_index) + points = [] + for point in data.data_points(): + x_val = float(point[0]) + y_val = float(point[1]) + if y_val > 0: + y_val = self._apply_rq4(x_val, y_val) + y_log = float(np.log10(y_val)) if y_val > 0 else -10.0 + points.append({'x': x_val, 'y': y_log}) + return points + except Exception as e: + console.debug(f'Error getting sample data points for model {model_index}: {e}') + return [] + + @Slot(int, result='QVariantList') + def getSldDataPointsForModel(self, model_index: int) -> list: + """Get SLD data points for a specific model for plotting.""" + try: + data = self._project_lib.sld_data_for_model_at_index(model_index) + points = [] + for point in data.data_points(): + points.append({'x': float(point[0]), 'y': float(point[1])}) + return points + except Exception as e: + console.debug(f'Error getting SLD data points for model {model_index}: {e}') + return [] + + @Slot(int, result=str) + def getModelColor(self, model_index: int) -> str: + """Get the color for a specific model.""" + try: + return str(self._project_lib.models[model_index].color) + except (IndexError, AttributeError): + return '#000000' + + @Property(int, notify=sampleChartRangesChanged) + def modelCount(self) -> int: + """Return the number of models.""" + return len(self._project_lib.models) + + @Slot(int, result='QVariantList') + def getExperimentDataPoints(self, experiment_index: int) -> list: + """Get data points for a specific experiment for plotting.""" + 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 + except Exception as e: + console.debug(f'Error getting experiment data points for index {experiment_index}: {e}') + return [] + + @Slot(int, result='QVariantList') + def getAnalysisDataPoints(self, experiment_index: int) -> list: + """Get measured and calculated data points for a specific experiment for analysis plotting.""" + try: + # Get measured experimental data + exp_data = self._project_lib.experimental_data_for_model_at_index(experiment_index) + + # Get the model index for this experiment - it may be different from experiment_index + # When multiple experiments share the same model + model_index = 0 + model_found = False + if hasattr(exp_data, 'model') and exp_data.model is not None: + # Find the model index in the models collection + for idx, model in enumerate(self._project_lib.models): + if model is exp_data.model: + model_index = idx + model_found = True + break + if not model_found: + console.debug(f'Warning: model for experiment {experiment_index} ' + f'not found in models collection, falling back to model 0') + else: + # Fallback: use experiment_index if it's within model range, else 0 + model_index = experiment_index if experiment_index < len(self._project_lib.models) else 0 + + # Get the q values from the experimental data for calculating the model + q_values = exp_data.x + # Filter to q range + mask = (q_values >= self._project_lib.q_min) & (q_values <= self._project_lib.q_max) + q_filtered = q_values[mask] + + # Get calculated model data at the same q points using the correct model index + calc_data = self._project_lib.model_data_for_model_at_index(model_index, q_filtered) + + points = [] + exp_points = list(exp_data.data_points()) + calc_y = calc_data.y + + if len(calc_y) != len(q_filtered): + console.debug(f'Warning: calculated data length ({len(calc_y)}) ' + f'differs from filtered experimental data ({len(q_filtered)}) ' + f'for experiment {experiment_index}') + + calc_idx = 0 + for point in exp_points: + if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]: + q = point[0] + r_meas = point[1] + calc_y_val = calc_y[calc_idx] if calc_idx < len(calc_y) else r_meas + r_meas = self._apply_rq4(q, r_meas) + calc_y_val = self._apply_rq4(q, calc_y_val) + points.append( + { + 'x': float(q), + 'measured': float(np.log10(r_meas)), + 'calculated': float(np.log10(calc_y_val)), + } + ) + calc_idx += 1 + return points + except Exception as e: + console.debug(f'Error getting analysis data points for index {experiment_index}: {e}') + return [] + def refreshSamplePage(self): - self.drawCalculatedOnSampleChart() - self.drawCalculatedOnSldChart() + # Clear cached data so it gets recalculated + self._sample_data = {} + self._model_data = {} + self._sld_data = {} + # Emit signals to update ranges and trigger QML refresh + self.sampleChartRangesChanged.emit() + self.sldChartRangesChanged.emit() + self.samplePageDataChanged.emit() def refreshExperimentPage(self): self.drawMeasuredOnExperimentChart() def refreshAnalysisPage(self): + self._model_data = {} self.drawCalculatedAndMeasuredOnAnalysisChart() + def refreshExperimentRanges(self): + """Emit signal to update experiment chart ranges when selection changes.""" + self.experimentChartRangesChanged.emit() + @Slot() def drawCalculatedOnSampleChart(self): if PLOT_BACKEND == 'QtCharts': @@ -152,17 +642,33 @@ def drawCalculatedOnSldChart(self): self.qtchartsReplaceCalculatedOnSldChartAndRedraw() def qtchartsReplaceCalculatedOnSldChartAndRedraw(self): + # Draw on sample page series = self._chartRefs['QtCharts']['samplePage']['sldSerie'] - series.clear() - nr_points = 0 - for point in self.sld_data.data_points(): - series.append(point[0], point[1]) - nr_points = nr_points + 1 - console.debug(IO.formatMsg('sub', 'Sld curve', f'{nr_points} points', 'on sample page', 'replaced')) + 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 + 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 + console.debug(IO.formatMsg('sub', 'Sld curve', f'{nr_points} points', 'on analysis page', 'replaced')) + + @Slot() def drawMeasuredOnExperimentChart(self): if PLOT_BACKEND == 'QtCharts': - self.qtchartsReplaceMeasuredOnExperimentChartAndRedraw() + if self.is_multi_experiment_mode: + self.qtchartsReplaceMultiExperimentChartAndRedraw() + else: + self.qtchartsReplaceMeasuredOnExperimentChartAndRedraw() def qtchartsReplaceMeasuredOnExperimentChartAndRedraw(self): series_measured = self._chartRefs['QtCharts']['experimentPage']['measuredSerie'] @@ -173,17 +679,59 @@ def qtchartsReplaceMeasuredOnExperimentChartAndRedraw(self): series_error_lower.clear() nr_points = 0 for point in self.experiment_data.data_points(): - if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]: - series_measured.append(point[0], np.log10(point[1])) - series_error_upper.append(point[0], np.log10(point[1] + np.sqrt(point[2]))) - series_error_lower.append(point[0], np.log10(point[1] - np.sqrt(point[2]))) - nr_points = nr_points + 1 + 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) + 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 + + console.debug(IO.formatMsg('sub', 'Measured curve', f'{nr_points} points', 'on experiment page', 'replaced')) - console.debug(IO.formatMsg('sub', 'Measurede curve', f'{nr_points} points', 'on experiment page', 'replaced')) + def qtchartsReplaceMultiExperimentChartAndRedraw(self): + """Draw multiple experiment series with distinct colors.""" + console.debug(IO.formatMsg('sub', 'Multi-experiment mode', 'drawing separate lines')) + # Clear default series but don't use them for multi-experiment mode + if 'measuredSerie' in self._chartRefs['QtCharts']['experimentPage']: + self._chartRefs['QtCharts']['experimentPage']['measuredSerie'].clear() + if 'errorUpperSerie' in self._chartRefs['QtCharts']['experimentPage']: + self._chartRefs['QtCharts']['experimentPage']['errorUpperSerie'].clear() + if 'errorLowerSerie' in self._chartRefs['QtCharts']['experimentPage']: + self._chartRefs['QtCharts']['experimentPage']['errorLowerSerie'].clear() + + # Individual experiment series are managed by QML + # This method is called to trigger the refresh, actual drawing is handled by QML + self.experimentDataChanged.emit() + + @Slot() def drawCalculatedAndMeasuredOnAnalysisChart(self): if PLOT_BACKEND == 'QtCharts': - self.qtchartsReplaceCalculatedAndMeasuredOnAnalysisChartAndRedraw() + if self.is_multi_experiment_mode: + self.qtchartsReplaceMultiExperimentAnalysisChartAndRedraw() + else: + self.qtchartsReplaceCalculatedAndMeasuredOnAnalysisChartAndRedraw() + + def qtchartsReplaceMultiExperimentAnalysisChartAndRedraw(self): + """Clear default series and let QML handle multi-experiment drawing on analysis page.""" + console.debug(IO.formatMsg('sub', 'Multi-experiment mode', 'drawing separate lines on analysis page')) + + # Clear default series but don't use them for multi-experiment mode + if 'measuredSerie' in self._chartRefs['QtCharts']['analysisPage']: + self._chartRefs['QtCharts']['analysisPage']['measuredSerie'].clear() + if 'calculatedSerie' in self._chartRefs['QtCharts']['analysisPage']: + self._chartRefs['QtCharts']['analysisPage']['calculatedSerie'].clear() + + # Individual experiment series are managed by QML + # This method is called to trigger the refresh, actual drawing is handled by QML + self.experimentDataChanged.emit() def qtchartsReplaceCalculatedAndMeasuredOnAnalysisChartAndRedraw(self): series_measured = self._chartRefs['QtCharts']['analysisPage']['measuredSerie'] @@ -192,12 +740,18 @@ def qtchartsReplaceCalculatedAndMeasuredOnAnalysisChartAndRedraw(self): series_calculated.clear() nr_points = 0 for point in self.experiment_data.data_points(): - if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]: - series_measured.append(point[0], np.log10(point[1])) - nr_points = nr_points + 1 - console.debug(IO.formatMsg('sub', 'Measurede curve', f'{nr_points} points', 'on analysis page', 'replaced')) + 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 + console.debug(IO.formatMsg('sub', 'Measured curve', f'{nr_points} points', 'on analysis page', 'replaced')) - for point in self.sample_data.data_points(): - series_calculated.append(point[0], np.log10(point[1])) + 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 console.debug(IO.formatMsg('sub', 'Calculated curve', f'{nr_points} points', 'on analysis page', 'replaced')) diff --git a/EasyReflectometryApp/Backends/Py/project.py b/EasyReflectometryApp/Backends/Py/project.py index 85dca049..4fba7149 100644 --- a/EasyReflectometryApp/Backends/Py/project.py +++ b/EasyReflectometryApp/Backends/Py/project.py @@ -1,5 +1,9 @@ +import warnings + from EasyApp.Logic.Utils.Utils import generalizePath from easyreflectometry import Project as ProjectLib +from easyreflectometry.orso_utils import load_orso_model +from orsopy.fileio import orso from PySide6.QtCore import Property from PySide6.QtCore import QObject from PySide6.QtCore import Signal @@ -18,6 +22,7 @@ class Project(QObject): externalNameChanged = Signal() externalProjectLoaded = Signal() externalProjectReset = Signal() + sampleLoadWarning = Signal(str) def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) @@ -101,3 +106,26 @@ def reset(self) -> None: self.externalCreatedChanged.emit() self.externalNameChanged.emit() self.externalProjectReset.emit() + + @Slot(str, bool) + def sampleLoad(self, url: str, append: bool = True) -> None: + # Load ORSO file content + orso_data = orso.load_orso(generalizePath(url)) + # Load the sample model + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter('always') + sample = load_orso_model(orso_data) + if sample is None: + warning_msg = 'The ORSO file does not contain a valid sample model definition. No sample was loaded.' + for w in caught_warnings: + warning_msg = str(w.message) + self.sampleLoadWarning.emit(warning_msg) + return + if append: + # Add the sample as a new model in the project + self._logic.add_sample_from_orso(sample) + else: + # Replace all existing models with the loaded sample + self._logic.replace_models_from_orso(sample) + # notify listeners + self.externalProjectLoaded.emit() diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index 0b6608d6..57ba5a8b 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -1,7 +1,10 @@ from EasyApp.Logic.Logging import LoggerLevelHandler +from EasyApp.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 .analysis import Analysis from .experiment import Experiment @@ -14,26 +17,31 @@ class PyBackend(QObject): + # Signal for multi-experiment selection changes + multiExperimentSelectionChanged = Signal() + def __init__(self, parent=None): super().__init__(parent) self._project_lib = ProjectLib() - self._project_lib.default_model() # Page and Status bar backend parts self._home = Home() self._project = Project(self._project_lib) self._sample = Sample(self._project_lib) self._experiment = Experiment(self._project_lib) - self._analysis = Analysis(self._project_lib) + self._analysis = Analysis(self._project_lib, parent=self) self._summary = Summary(self._project_lib) self._status = Status(self._project_lib) # Plotting backend part - self._plotting = Plotting1d(self._project_lib) + self._plotting_1d = Plotting1d(self._project_lib, parent=self) self._logger = LoggerLevelHandler(self) + # Wire cross-cutting references before connecting signals + self._status._status_logic.set_minimizers_logic(self._analysis._minimizers_logic) + # Must be last to ensure all backend parts are created self._connect_backend_parts() @@ -70,12 +78,63 @@ def status(self) -> Status: @Property('QVariant', constant=True) def plotting(self) -> Plotting1d: - return self._plotting + return self._plotting_1d @Property('QVariant', constant=True) def logger(self): return self._logger + # Analysis properties and methods for multi-experiment selection + @Property(int, notify=multiExperimentSelectionChanged) + def analysisExperimentsSelectedCount(self) -> int: + """Return the count of currently selected experiments.""" + return self._analysis.experimentsSelectedCount + + @Property('QVariantList', notify=multiExperimentSelectionChanged) + def analysisSelectedExperimentIndices(self) -> list: + """Return the list of selected experiment indices.""" + return self._analysis.selectedExperimentIndices + + @Slot('QVariantList') + def analysisSetSelectedExperimentIndices(self, indices) -> None: + """Set multiple selected experiment indices.""" + console.debug(f'PyBackend.analysisSetSelectedExperimentIndices called with: {indices}') + console.debug(f'Type of indices: {type(indices)}') + + # Convert QVariantList to Python list if needed + python_indices = list(indices) if hasattr(indices, '__iter__') else [] + console.debug(f'Converted to Python list: {python_indices}') + + if hasattr(self._analysis, 'setSelectedExperimentIndices'): + self._analysis.setSelectedExperimentIndices(python_indices) + console.debug('Successfully called analysis.setSelectedExperimentIndices') + else: + console.debug('ERROR: analysis.setSelectedExperimentIndices method not found') + + # Emit our local signal to notify QML properties + self.multiExperimentSelectionChanged.emit() + + # Plotting properties for multi-experiment support + @Property(bool, notify=multiExperimentSelectionChanged) + def plottingIsMultiExperimentMode(self) -> bool: + """Return whether multiple experiments are selected for plotting.""" + return self._plotting_1d.isMultiExperimentMode + + @Property('QVariantList', notify=multiExperimentSelectionChanged) + def plottingIndividualExperimentDataList(self) -> list: + """Return list of individual experiment data for multi-experiment plotting.""" + return self._plotting_1d.individualExperimentDataList + + @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) + ######### Connections to relay info between the backend parts def _connect_backend_parts(self) -> None: self._connect_project_page() @@ -93,6 +152,9 @@ def _connect_project_page(self) -> None: def _connect_sample_page(self) -> None: self._sample.externalSampleChanged.connect(self._relay_sample_page_sample_changed) self._sample.externalRefreshPlot.connect(self._refresh_plots) + self._sample.modelsTableChanged.connect(self._analysis.parametersChanged) + # Connect sample changes to multi-experiment selection signal + self._sample.modelsTableChanged.connect(self.multiExperimentSelectionChanged) def _connect_experiment_page(self) -> None: self._experiment.externalExperimentChanged.connect(self._relay_experiment_page_experiment_changed) @@ -104,6 +166,10 @@ 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.externalExperimentChanged.connect(self._relay_experiment_page_experiment_changed) + self._analysis.externalExperimentChanged.connect(self._refresh_plots) + # Connect multi-experiment selection changes + self._analysis.experimentsChanged.connect(self.multiExperimentSelectionChanged) def _relay_project_page_name(self): self._status.statusChanged.emit() @@ -115,21 +181,30 @@ def _relay_project_page_created(self): self._summary.summaryChanged.emit() def _relay_project_page_project_changed(self): + # Clear layers cache first so that subsequent signal handlers + # (e.g. ComboBox onModelChanged / onCurrentAssemblyNameChanged in + # MultiLayer.qml) read up-to-date layer data. + self._sample._clearCacheAndEmitLayersChanged() self._sample.materialsTableChanged.emit() self._sample.modelsTableChanged.emit() + self._sample.modelsIndexChanged.emit() self._sample.assembliesTableChanged.emit() - self._sample._clearCacheAndEmitLayersChanged() + self._sample.assembliesIndexChanged.emit() self._experiment.experimentChanged.emit() self._analysis.experimentsChanged.emit() self._analysis._clearCacheAndEmitParametersChanged() self._status.statusChanged.emit() self._summary.summaryChanged.emit() + self._plotting_1d.reset_data() self._refresh_plots() + self._plotting_1d.samplePageResetAxes.emit() def _relay_sample_page_sample_changed(self): + self._plotting_1d.reset_data() 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() @@ -138,14 +213,18 @@ def _relay_experiment_page_experiment_changed(self): self._summary.summaryChanged.emit() def _relay_analysis_page(self): + self._plotting_1d.reset_data() self._status.statusChanged.emit() self._experiment.experimentChanged.emit() self._summary.summaryChanged.emit() + self._plotting_1d.samplePageResetAxes.emit() def _refresh_plots(self): - self._plotting.sampleChartRangesChanged.emit() - self._plotting.sldChartRangesChanged.emit() - self._plotting.experimentChartRangesChanged.emit() - self._plotting.refreshSamplePage() - self._plotting.refreshExperimentPage() - self._plotting.refreshAnalysisPage() + self._plotting_1d.sampleChartRangesChanged.emit() + self._plotting_1d.sldChartRangesChanged.emit() + self._plotting_1d.experimentChartRangesChanged.emit() + self._plotting_1d.refreshSamplePage() + self._plotting_1d.refreshExperimentPage() + self._plotting_1d.refreshAnalysisPage() + # Emit signal for multi-experiment changes + self.multiExperimentSelectionChanged.emit() diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 1bb3dd2b..1c73c226 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -1,4 +1,14 @@ +import math +import numbers +import re +from typing import Any +from typing import Dict +from typing import Tuple + +import numpy as np +from asteval import Interpreter from easyreflectometry import Project as ProjectLib +from easyscience.variable.descriptor_number import DescriptorNumber from PySide6.QtCore import Property from PySide6.QtCore import QObject from PySide6.QtCore import Signal @@ -11,6 +21,35 @@ from .logic.parameters import Parameters as ParametersLogic from .logic.project import Project as ProjectLogic +_ASTEVAL_CONFIG = { + 'import': False, + 'importfrom': False, + 'assert': False, + 'augassign': False, + 'delete': False, + 'if': True, + 'ifexp': True, + 'for': False, + 'formattedvalue': False, + 'functiondef': False, + 'print': False, + 'raise': False, + 'listcomp': False, + 'dictcomp': False, + 'setcomp': False, + 'try': False, + 'while': False, + 'with': False, +} + +_GLOBAL_SYMBOLS: Dict[str, Any] = { + 'np': np, + 'numpy': np, + 'math': math, + 'pi': math.pi, + 'e': math.e, +} + class Sample(QObject): materialsTableChanged = Signal() @@ -26,6 +65,7 @@ class Sample(QObject): layersIndexChanged = Signal() qRangeChanged = Signal() + constraintsChanged = Signal() externalRefreshPlot = Signal() externalSampleChanged = Signal() @@ -41,6 +81,7 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._parameters_logic = ParametersLogic(project_lib) self._chached_layers = None + self._constraint_states: Dict[str, dict[str, Any]] = {} self.connect_logic() @@ -132,7 +173,7 @@ def currentModelIndex(self) -> int: return self._models_logic.index @Property('QVariantList', notify=modelsTableChanged) - def modelslNames(self) -> list[str]: + def modelsNames(self) -> list[str]: return self._models_logic.models_names @Property(str, notify=modelsIndexChanged) @@ -142,15 +183,19 @@ def currentModelName(self) -> str: # Setters @Slot(int) def setCurrentModelIndex(self, new_value: int) -> None: - self._project_lib.current_model_index = new_value - self.modelsIndexChanged.emit() - self.assembliesTableChanged.emit() - self.externalRefreshPlot.emit() + if self._project_lib.current_model_index != new_value: + self._project_lib.current_model_index = new_value + self.modelsIndexChanged.emit() + self.assembliesTableChanged.emit() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() @Slot(str) def setCurrentModelName(self, value: str) -> None: if self._models_logic.set_name_at_current_index(value): self.modelsTableChanged.emit() + self.modelsIndexChanged.emit() + self._clearCacheAndEmitLayersChanged() # Actions @Slot(str) @@ -161,12 +206,14 @@ def removeModel(self, value: str) -> None: @Slot() def addNewModel(self) -> None: self._models_logic.add_new() + self._project_logic._update_enablement_of_fixed_layers_for_model(self._models_logic.index) self.modelsTableChanged.emit() self.materialsTableChanged.emit() @Slot() def duplicateSelectedModel(self) -> None: self._models_logic.duplicate_selected_model() + self._project_logic._update_enablement_of_fixed_layers_for_model(self._models_logic.index) self.modelsTableChanged.emit() @Slot() @@ -214,6 +261,8 @@ def setCurrentAssemblyIndex(self, new_value: int) -> None: def setCurrentAssemblyName(self, new_value: str) -> None: if self._assemblies_logic.set_name_at_current_index(new_value): self.assembliesTableChanged.emit() + self.materialsTableChanged.emit() + self.externalSampleChanged.emit() @Slot(str) def setCurrentAssemblyType(self, new_value: str) -> None: @@ -408,28 +457,634 @@ def _clearCacheAndEmitLayersChanged(self): # # # # Constraints # # # + def _build_constraint_context(self) -> Tuple[list[dict[str, Any]], Dict[str, DescriptorNumber], Dict[str, str]]: + context = self._parameters_logic.constraint_context() + alias_lookup: Dict[str, DescriptorNumber] = {} + display_lookup: Dict[str, str] = {} + for entry in context: + alias = entry['alias'] + if alias: # Only add non-empty aliases + alias_lookup[alias] = entry['object'] + display_lookup[alias] = entry['display_name'] + return context, alias_lookup, display_lookup + + def _extract_dependency_map( + self, + expression: str, + alias_lookup: Dict[str, DescriptorNumber], + ) -> Dict[str, DescriptorNumber]: + used_aliases: Dict[str, DescriptorNumber] = {} + for alias, parameter in alias_lookup.items(): + if not alias: + continue + pattern = rf'\b{re.escape(alias)}\b' + if re.search(pattern, expression): + used_aliases[alias] = parameter + return used_aliases + + def _evaluate_constraint_expression( + self, + expression: str, + dependency_map: Dict[str, DescriptorNumber], + all_aliases: Dict[str, DescriptorNumber] | None = None, + ) -> DescriptorNumber | numbers.Number: + """Evaluate constraint expression with all available parameter aliases in scope.""" + interpreter = Interpreter(config=_ASTEVAL_CONFIG) + + # Add global symbols (numpy, etc.) + for name, value in _GLOBAL_SYMBOLS.items(): + interpreter.symtable[name] = value + if isinstance(value, numbers.Number): + interpreter.readonly_symbols.add(name) + + # Add ALL parameter aliases to the symbol table (not just dependencies) + # This allows validation to work even if we haven't detected the parameter yet + aliases_to_add = all_aliases if all_aliases is not None else dependency_map + for alias, dependency in aliases_to_add.items(): + interpreter.symtable[alias] = dependency + interpreter.readonly_symbols.add(alias) + + try: + result = interpreter.eval(expression, raise_errors=True) + except Exception as e: + # Provide helpful error message showing available aliases + if 'not defined' in str(e): + available = ', '.join(sorted(aliases_to_add.keys())[:10]) # Show first 10 + raise NameError(f'{str(e)}\nAvailable aliases: {available}...') from None + raise + return result + + @staticmethod + def _to_float(value: DescriptorNumber | numbers.Number) -> float: + if isinstance(value, DescriptorNumber): + return float(value.value) + if isinstance(value, numbers.Number): + return float(value) + raise TypeError('Expression must evaluate to a numeric value.') + + @staticmethod + def _pretty_expression(expression: str, alias_display: Dict[str, str]) -> str: + pretty_expression = expression + for alias in sorted(alias_display.keys(), key=len, reverse=True): + replacement = alias_display[alias] + if not replacement: + continue + pattern = rf'\b{re.escape(alias)}\b' + pretty_expression = re.sub(pattern, replacement, pretty_expression) + return pretty_expression + + @staticmethod + def _sanitize_relation(operator: str) -> str: + mapping = { + '=': '=', + '==': '=', + '≡': '=', + '>': '>', + '≥': '>', + '>': '>', + '<': '<', + '≤': '<', + '<': '<', + } + return mapping.get(operator, '=') + + @staticmethod + def _format_numeric(value: float) -> str: + return f'{value:.6g}' + + def _get_independent_parameter_entries(self) -> list[dict]: + """Return the filtered list of independent+enabled parameter entries. + + This must match the same filtering as dependentParameterNames so that + the QML dropdown index maps to the correct parameter object. + """ + entries = [] + for parameter in self._parameters_logic.parameters: + if not parameter['independent']: + continue + if hasattr(parameter['object'], 'enabled') and not parameter['object'].enabled: + continue + entries.append(parameter) + return entries + + def _prepare_constraint_instruction( + self, + dependent_index: int, + relation_operator: str, + expression: str, + ) -> dict[str, Any]: + independent_entries = self._get_independent_parameter_entries() + if dependent_index < 0 or dependent_index >= len(independent_entries): + raise ValueError('Select a dependent parameter before defining a constraint.') + + relation = self._sanitize_relation(relation_operator) + expression_text = expression.strip() + if not expression_text: + raise ValueError('Expression cannot be empty.') + + context, alias_lookup, display_lookup = self._build_constraint_context() + dependency_map = self._extract_dependency_map(expression_text, alias_lookup) + + try: + # Pass all available aliases so validation can check any parameter reference + evaluation_result = self._evaluate_constraint_expression(expression_text, dependency_map, all_aliases=alias_lookup) + except NameError as error: + raise NameError(str(error).split('\n')[-1]) from None + except SyntaxError as error: + raise SyntaxError(str(error).split('\n')[-1]) from None + except Exception as error: + raise RuntimeError(str(error)) from None + + pretty_expression = self._pretty_expression(expression_text, display_lookup) + + if relation == '=': + if dependency_map: + if not isinstance(evaluation_result, DescriptorNumber): + raise TypeError('Expressions referencing parameters must evaluate to a parameter quantity.') + return { + 'mode': 'dynamic', + 'expression': expression_text, + 'dependency_map': dependency_map, + 'pretty_expression': pretty_expression, + 'relation': relation, + } + numeric_value = self._to_float(evaluation_result) + return { + 'mode': 'static', + 'value': numeric_value, + 'pretty_expression': self._format_numeric(numeric_value), + 'relation': relation, + } + + if dependency_map: + raise ValueError('Inequality constraints cannot reference other parameters.') + + numeric_value = self._to_float(evaluation_result) + mode = 'lower_bound' if relation == '>' else 'upper_bound' + return { + 'mode': mode, + 'value': numeric_value, + 'pretty_expression': self._format_numeric(numeric_value), + 'relation': relation, + } + + @staticmethod + def _ensure_parameter_independent(parameter: DescriptorNumber) -> None: + try: + parameter.make_independent() + except AttributeError: + parameter._independent = True + + def _infer_constraint_state( + self, + parameter_obj: DescriptorNumber, + display_lookup: Dict[str, str], + ) -> dict[str, Any] | None: + if getattr(parameter_obj, 'independent', True): + return None + + try: + raw_expression = parameter_obj.dependency_expression + except AttributeError: + value = float(parameter_obj.value) + formatted = self._format_numeric(value) + return { + 'mode': 'static', + 'relation': '=', + 'expression': formatted, + 'raw_expression': formatted, + 'pretty_expression': formatted, + 'value': value, + } + + dependency_map = getattr(parameter_obj, 'dependency_map', {}) or {} + alias_display_subset = {alias: display_lookup.get(alias, alias) for alias in dependency_map.keys()} + pretty_expression = self._pretty_expression(raw_expression, alias_display_subset) + return { + 'mode': 'dynamic', + 'relation': '=', + 'expression': raw_expression, + 'raw_expression': raw_expression, + 'pretty_expression': pretty_expression, + 'dependency_map': dependency_map, + } + + def _resolve_constraint_state( + self, + parameter_obj: DescriptorNumber, + display_lookup: Dict[str, str], + ) -> dict[str, Any] | None: + unique_name = getattr(parameter_obj, 'unique_name', None) + if unique_name is not None: + stored = self._constraint_states.get(unique_name) + if stored is not None: + return stored + return self._infer_constraint_state(parameter_obj, display_lookup) + + @staticmethod + def _capture_parameter_state(parameter: DescriptorNumber) -> dict[str, Any]: + state: dict[str, Any] = { + 'value': float(parameter.value), + 'free': bool(parameter.free), + 'independent': getattr(parameter, 'independent', True), + '_independent': getattr(parameter, '_independent', True), + } + if hasattr(parameter, 'min'): + try: + state['min'] = float(parameter.min) + except Exception: # noqa: BLE001 + state['min'] = parameter.min + if hasattr(parameter, 'max'): + try: + state['max'] = float(parameter.max) + except Exception: # noqa: BLE001 + state['max'] = parameter.max + return state + + @staticmethod + def _restore_parameter_state(parameter: DescriptorNumber, state: dict[str, Any]) -> None: + try: + parameter.make_independent() + except AttributeError: + parameter._independent = True + + if 'value' in state and state['value'] is not None: + parameter.value = state['value'] + if 'min' in state and state['min'] is not None: + parameter.min = state['min'] + if 'max' in state and state['max'] is not None: + parameter.max = state['max'] + if 'free' in state and state['free'] is not None: + parameter.free = state['free'] + if '_independent' in state and state['_independent'] is not None: + parameter._independent = state['_independent'] + @Property('QVariantList', notify=layersChange) def parameterNames(self) -> list[dict[str, str]]: return [parameter['name'] for parameter in self._parameters_logic.parameters] @Property('QVariantList', notify=layersChange) - def relationOperators(self) -> list[str]: + def enabledParameterNames(self) -> list[str]: + enabled_param_names = [] + for parameter in self._parameters_logic.parameters: + if hasattr(parameter['object'], 'enabled') and not parameter['object'].enabled: + continue + enabled_param_names.append(parameter['name']) + return enabled_param_names + + @Property('QVariantList', notify=layersChange) + def dependentParameterNames(self) -> list[str]: + dep_param_names = [] + for parameter in self._parameters_logic.parameters: + if not parameter['independent']: + continue + if hasattr(parameter['object'], 'enabled') and not parameter['object'].enabled: + continue + dep_param_names.append(parameter['name']) + return dep_param_names + + @Property('QVariantList', notify=layersChange) + def constraintParametersMetadata(self) -> list[dict[str, Any]]: + return self._parameters_logic.constraint_metadata() + + @Property('QVariantList', notify=layersChange) + def relationOperators(self) -> list[dict[str, str]]: return self._parameters_logic.constraint_relations() @Property('QVariantList', notify=layersChange) def arithmicOperators(self) -> list[str]: return self._parameters_logic.constraint_arithmetic() - @Slot(str, str, str, str, str) - def addConstraint(self, value1: str, value2: str, value3: str, value4: str, value5: str) -> None: - self._parameters_logic.add_constraint( - dependent_idx=int(value1), - relational_operator=value2, - value=float(value3), - arithmetic_operator=value4, - independent_idx=int(value5), - ) + @Property('QVariantList', notify=constraintsChanged) + def constraintsList(self) -> list[dict[str, str]]: + """Get the list of active constraints with display metadata.""" + constraints: list[dict[str, str]] = [] + context, _, display_lookup = self._build_constraint_context() + + for entry in context: + parameter_obj = entry['object'] + state = self._resolve_constraint_state(parameter_obj, display_lookup) + if state is None: + continue + + relation = state.get('relation', '=') + mode = state.get('mode', 'static') + + if mode == 'dynamic': + expression_display = state.get('pretty_expression', state.get('expression', '')) + raw_expression = state.get('expression', expression_display) + else: + value = state.get('value', float(parameter_obj.value)) + expression_display = state.get('pretty_expression', self._format_numeric(float(value))) + raw_expression = state.get('raw_expression', expression_display) + + # Use model-prefixed display name if available (from constrainModelsParameters) + dependent_display = state.get('dependent_display', entry['display_name']) + + constraints.append( + { + 'dependentName': dependent_display, + 'expression': expression_display, + 'rawExpression': raw_expression, + 'relation': relation, + 'type': mode, + } + ) + + return constraints + + @Slot(int) + def removeConstraintByIndex(self, index: int) -> None: + """Remove constraint by index by making the parameter independent.""" + if not isinstance(index, int): + try: + index = int(index) + except (TypeError, ValueError): + return + + constraints_list = self.constraintsList + if index >= len(constraints_list): + return + + param_name = constraints_list[index]['dependentName'] + param_obj = self._find_parameter_object_by_name(param_name) + + if param_obj is None: + return + + unique_name = getattr(param_obj, 'unique_name', None) + state = self._constraint_states.pop(unique_name, None) if unique_name is not None else None + + if state and 'previous' in state: + self._restore_parameter_state(param_obj, state['previous']) + else: + self._make_parameter_independent(param_obj) + self.constraintsChanged.emit() + self.externalSampleChanged.emit() + self.layersChange.emit() + + def _find_parameter_object_by_name(self, param_name: str): + """Find parameter object by name. + + Handles both regular names ('SiO2 sld') and model-prefixed names ('M2 SiO2 sld'). + """ + parameters = self._parameters_logic.parameters + + # Direct match by display name + for param in parameters: + if param['name'] == param_name: + return param['object'] + + # Check constraint states for model-prefixed dependent_display + for unique_name, state in self._constraint_states.items(): + if state.get('dependent_display') == param_name: + # Find the parameter by unique_name + for param in parameters: + if param.get('unique_name') == unique_name: + return param['object'] + + # Try stripping model prefix (e.g., 'M2 SiO2 sld' -> 'SiO2 sld') + import re + + prefix_match = re.match(r'^M\d+\s+(.+)$', param_name) + if prefix_match: + stripped_name = prefix_match.group(1) + for param in parameters: + if param['name'] == stripped_name: + return param['object'] + + return None + + def _make_parameter_independent(self, param_obj) -> None: + """Make a parameter independent, handling different parameter types.""" + try: + param_obj.make_independent() + except AttributeError: + param_obj._independent = True # Fallback for custom ERL constraints + + @Slot(int, str, str, result='QVariant') + def validateConstraintExpression(self, dependent_index: int, relation: str, expression: str): + try: + instruction = self._prepare_constraint_instruction(dependent_index, relation, expression) + except Exception as error: # noqa: BLE001 + return {'valid': False, 'message': str(error)} + + return { + 'valid': True, + 'message': '', + 'preview': instruction.get('pretty_expression', ''), + 'relation': instruction.get('relation', '='), + 'type': instruction.get('mode', ''), + } + + @Slot(int, str, str, result='QVariant') + def addConstraint(self, dependent_index: int, relation: str, expression: str): + try: + instruction = self._prepare_constraint_instruction(dependent_index, relation, expression) + except Exception as error: # noqa: BLE001 + return {'success': False, 'message': str(error)} + + dependent = self._get_independent_parameter_entries()[dependent_index]['object'] + previous_state = self._capture_parameter_state(dependent) + self._ensure_parameter_independent(dependent) + + mode = instruction['mode'] + + try: + if mode == 'dynamic': + dependent.make_dependent_on( + dependency_expression=instruction['expression'], + dependency_map=instruction['dependency_map'], + ) + elif mode == 'static': + dependent.value = instruction['value'] + dependent.free = False + dependent._independent = False + elif mode == 'lower_bound': + dependent.min = instruction['value'] + dependent.free = True + elif mode == 'upper_bound': + dependent.max = instruction['value'] + dependent.free = True + else: + raise ValueError(f'Unsupported constraint mode: {mode}') + except Exception as error: # noqa: BLE001 + return {'success': False, 'message': str(error)} + + unique_name = getattr(dependent, 'unique_name', None) + if unique_name is not None: + state: dict[str, Any] = { + 'mode': mode, + 'relation': instruction.get('relation', '='), + 'previous': previous_state, + } + if mode == 'dynamic': + state.update( + { + 'expression': instruction.get('expression', ''), + 'raw_expression': instruction.get('expression', ''), + 'pretty_expression': instruction.get('pretty_expression', ''), + 'dependency_map': instruction.get('dependency_map', {}), + } + ) + else: + value = instruction.get('value') + numeric = self._format_numeric(float(value)) if value is not None else '' + state.update( + { + 'value': value, + 'pretty_expression': instruction.get('pretty_expression', numeric), + 'raw_expression': numeric, + } + ) + self._constraint_states[unique_name] = state + + self.constraintsChanged.emit() self.externalSampleChanged.emit() + self.layersChange.emit() + + return { + 'success': True, + 'message': '', + 'preview': instruction.get('pretty_expression', ''), + 'relation': instruction.get('relation', '='), + 'type': mode, + } + + @Slot('QVariantList') + def constrainModelsParameters(self, model_indices: list) -> None: + """Constrain matching parameters across selected models. + + For each parameter in the models (except the first one), find the corresponding + parameter in the first model and create a constraint to make them equal. + + :param model_indices: List of model indices to constrain together. + """ + if len(model_indices) < 2: + return + + # Sort indices to ensure consistent ordering - first model becomes the reference + model_indices = sorted([int(idx) for idx in model_indices]) + + # Validate indices + num_models = len(self._project_lib._models) + for idx in model_indices: + if idx < 0 or idx >= num_models: + print(f'Invalid model index: {idx}') + return + + # Get the reference model (first in the sorted list) + reference_model_idx = model_indices[0] + reference_model = self._project_lib._models[reference_model_idx] + + # Build a map of parameter paths to parameters for the reference model + # The path is relative to the model (sample/assembly/layer structure) + reference_params_map = self._build_model_parameters_map(reference_model) + + # For each other model, find matching parameters and constrain them + constraints_added = 0 + for model_idx in model_indices[1:]: + model = self._project_lib._models[model_idx] + model_params_map = self._build_model_parameters_map(model) + + for param_path, dependent_param in model_params_map.items(): + if param_path in reference_params_map: + reference_param = reference_params_map[param_path] + + # Skip if already constrained + if not getattr(dependent_param, 'independent', True): + continue + + # Skip if it's the same parameter object + if dependent_param.unique_name == reference_param.unique_name: + continue + + try: + # Capture previous state for undo capability + previous_state = self._capture_parameter_state(dependent_param) + + # Create a constraint: dependent = reference + dependent_param.make_dependent_on( + dependency_expression='a', + dependency_map={'a': reference_param}, + ) + + # Store constraint state for display + unique_name = getattr(dependent_param, 'unique_name', None) + if unique_name is not None: + # Get display names with model prefix for clarity + # e.g., "M2 SiO2 sld = M1 SiO2 sld" + ref_display = self._get_parameter_display_name(reference_param, reference_model_idx) + dep_display = self._get_parameter_display_name(dependent_param, model_idx) + + self._constraint_states[unique_name] = { + 'mode': 'dynamic', + 'relation': '=', + 'previous': previous_state, + 'expression': 'a', + 'raw_expression': 'a', + 'pretty_expression': ref_display, + 'dependency_map': {'a': reference_param}, + 'dependent_display': dep_display, + } + + constraints_added += 1 + except Exception as e: # noqa: BLE001 + print(f'Failed to constrain parameter {param_path}: {e}') + continue + + if constraints_added > 0: + self.constraintsChanged.emit() + self.externalSampleChanged.emit() + self.layersChange.emit() + + def _build_model_parameters_map(self, model) -> Dict[str, DescriptorNumber]: + """Build a map of relative parameter paths to parameter objects for a model. + + The path structure is: assembly_name/layer_name/param_name + This allows matching parameters across models with the same structure. + """ + params_map: Dict[str, DescriptorNumber] = {} + + # Get parameters from model structure + for assembly_idx, assembly in enumerate(model.sample): + # assembly_name = assembly.name + for layer_idx, layer in enumerate(assembly.layers): + # layer_name = layer.name + # Get layer parameters + for param in layer.get_parameters(): + param_name = param.name + # Create a structural path that's independent of model name + path_key = f'{assembly_idx}/{layer_idx}/{param_name}' + params_map[path_key] = param + + return params_map + + def _get_parameter_display_name(self, param: DescriptorNumber, model_index: int | None = None) -> str: + """Get a display name for a parameter. + + :param param: The parameter to get the display name for. + :param model_index: Optional model index to prefix the display name with (e.g., 'M1'). + :return: Display name, optionally prefixed with model identifier. + """ + display_name = param.name # Fallback + try: + from easyscience import global_object + + # Try to find the parameter's path in the global object map + for model in self._project_lib._models: + path = global_object.map.find_path(model.unique_name, param.unique_name) + if path and len(path) >= 2: + parent_name = global_object.map.get_item_by_key(path[-2]).name + param_name = global_object.map.get_item_by_key(path[-1]).name + display_name = f'{parent_name} {param_name}' + break + except Exception: # noqa: S110 + pass + + if model_index is not None: + return f'M{model_index + 1} {display_name}' + return display_name # # # # Q Range diff --git a/EasyReflectometryApp/Backends/Py/summary.py b/EasyReflectometryApp/Backends/Py/summary.py index cd44af15..ea83b164 100644 --- a/EasyReflectometryApp/Backends/Py/summary.py +++ b/EasyReflectometryApp/Backends/Py/summary.py @@ -16,6 +16,8 @@ class Summary(QObject): createdChanged = Signal() fileNameChanged = Signal() summaryChanged = Signal() + plotFileNameChanged = Signal() + htmlExportingFinished = Signal(bool, str) def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) @@ -42,18 +44,61 @@ def filePath(self) -> str: def fileUrl(self) -> str: return IO.localFileToUrl(str(self._logic.file_path)) + @Property(str, notify=plotFileNameChanged) + def plotFileName(self): + return self._logic.plot_file_name + + @Slot(str) + def setPlotFileName(self, value: str) -> None: + self._logic.plot_file_name = value + self.plotFileNameChanged.emit() + + @Property(str, notify=plotFileNameChanged) + def plotFilePath(self) -> str: + return str(self._logic.plot_file_path) + + @Property(str, notify=plotFileNameChanged) + def plotFileUrl(self) -> str: + return IO.localFileToUrl(str(self._logic.plot_file_path)) + + @Property('QVariant', notify=plotFileNameChanged) + def plotExportFormats(self): + return ['PDF', 'PNG', 'SVG'] + @Property(str, notify=summaryChanged) def asHtml(self): return self._logic.as_html - @Property('QVariant') + @Property('QVariant', notify=summaryChanged) def exportFormats(self): return ['HTML', 'PDF'] - @Slot() - def saveAsHtml(self) -> None: - self._logic.save_as_html() + @Slot(str) + def saveAsHtml(self, path: str = '') -> None: + try: + self._logic.save_as_html(path or None) + target = path or str(self._logic.file_path.with_suffix('.html')) + self.htmlExportingFinished.emit(True, target) + except Exception: # noqa: BLE001 + self.htmlExportingFinished.emit(False, path) + + @Slot(str) + def saveAsPdf(self, path: str = '') -> None: + try: + self._logic.save_as_pdf(path or None) + target = path or str(self._logic.file_path.with_suffix('.pdf')) + self.htmlExportingFinished.emit(True, target) + except Exception: # noqa: BLE001 + self.htmlExportingFinished.emit(False, path) + + @Slot(str, float, float) + def savePlot(self, path: str, width_cm: float, height_cm: float) -> None: + try: + self._logic.save_plot(path, width_cm, height_cm) + self.htmlExportingFinished.emit(True, path) + except Exception: # noqa: BLE001 + self.htmlExportingFinished.emit(False, path) - @Slot() - def saveAsPdf(self) -> None: - self._logic.save_as_pdf() + @Slot(float, float) + def showPlot(self, width_cm: float, height_cm: float) -> None: + self._logic.show_plot(width_cm, height_cm) diff --git a/EasyReflectometryApp/Backends/Py/workers/__init__.py b/EasyReflectometryApp/Backends/Py/workers/__init__.py new file mode 100644 index 00000000..3a920866 --- /dev/null +++ b/EasyReflectometryApp/Backends/Py/workers/__init__.py @@ -0,0 +1,4 @@ +# Workers module for background threading operations +from .fitter_worker import FitterWorker + +__all__ = ['FitterWorker'] diff --git a/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py b/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py new file mode 100644 index 00000000..cb36c5f6 --- /dev/null +++ b/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py @@ -0,0 +1,152 @@ +""" +QThread-based worker for non-blocking fitting operations. + +This module provides a FitterWorker class that runs fitting operations +in a background thread to keep the UI responsive during long-running fits. +""" + +from typing import Any +from typing import Optional + +from PySide6.QtCore import QThread +from PySide6.QtCore import Signal + + +class FitterWorker(QThread): + """ + QThread-based worker for executing fitting operations in the background. + + This worker wraps a fitter object and calls the specified method with + the provided arguments. Results are emitted via signals to avoid + blocking the main UI thread. + + Signals: + finished(list): Emitted with fit results on successful completion. + failed(str): Emitted with error message on failure. + progress(int): Emitted with progress percentage (0-100) during fitting. + + Example: + worker = FitterWorker( + fitter=multi_fitter, + method_name='fit', + args=(x_data, y_data), + kwargs={'weights': weights, 'method': 'leastsq'} + ) + worker.finished.connect(on_fit_complete) + worker.failed.connect(on_fit_failed) + worker.start() + """ + + # Signal emitted when fitting completes successfully + # Carries the list of FitResults objects + finished = Signal(list) + + # Signal emitted when fitting fails + # Carries the error message string + failed = Signal(str) + + # Signal emitted to report fitting progress (0-100) + progress = Signal(int) + + def __init__( + self, + fitter: Any, + method_name: str, + args: tuple = (), + kwargs: Optional[dict] = None, + parent: Optional[Any] = None, + ): + """ + Initialize the fitter worker. + + :param fitter: The fitter object (e.g., MultiFitter or its internal fitter). + :param method_name: Name of the method to call on the fitter. + :param args: Positional arguments to pass to the method. + :param kwargs: Keyword arguments to pass to the method. + :param parent: Optional parent QObject. + """ + super().__init__(parent) + self._fitter = fitter + self._method_name = method_name + self._args = args + self._kwargs = kwargs if kwargs is not None else {} + self._stop_requested = False + + def run(self) -> None: + """ + Execute the fitting operation in the background thread. + + This method is called automatically when start() is invoked. + Results are emitted via the finished or failed signals. + """ + # TODO: Thread-safety: fitting uses shared model state; consider snapshotting or blocking edits during execution. + # Check if stop was requested before starting + if self._stop_requested: + self.failed.emit('Fitting cancelled before start') + return + + # Verify the method exists on the fitter + if not hasattr(self._fitter, self._method_name): + self.failed.emit(f"Fitter has no method '{self._method_name}'") + return + + try: + # Get the method and call it + method = getattr(self._fitter, self._method_name) + result = method(*self._args, **self._kwargs) + + # NOTE: This check only catches stop requests that occurred AFTER the fit + # completed but before we emit the result. It does NOT interrupt the fitting + # algorithm mid-execution since lmfit/scipy don't support cancellation callbacks. + # The effective cancellation window is only before the fit starts (checked above). + if self._stop_requested: + self.failed.emit('Fitting cancelled by user') + return + + # Ensure result is a list for consistent handling + if not isinstance(result, list): + result = [result] + + self.finished.emit(result) + + except Exception as ex: + # Emit failure with error message + error_message = str(ex) + if not error_message: + error_message = f'{type(ex).__name__}: Unknown error during fitting' + self.failed.emit(error_message) + + def stop(self) -> None: + """ + Request the fitting operation to stop. + + This sets a flag that is checked during execution and also + terminates the thread if it's still running. Call wait() after + this to ensure proper thread cleanup. + + .. warning:: + DANGEROUS: This method uses QThread.terminate() which is strongly + discouraged by Qt documentation. It can: + - Leave mutex locks held indefinitely causing deadlocks + - Corrupt data structures mid-operation + - Prevent proper cleanup of resources (especially numpy arrays, scipy internals) + - Cause memory leaks and undefined behavior + + The fitting libraries (lmfit, scipy) do not support graceful cancellation. + The stop flag is only effective BEFORE the fit starts - once the fitting + algorithm is running, it cannot be interrupted cleanly. + + See THREAD_TERMINATION_WARNING.md for details on known issues and + potential future improvements (e.g., using subprocess instead of QThread). + """ + self._stop_requested = True + if self.isRunning(): + # WARNING: terminate() is dangerous but necessary since fitting + # libraries don't support graceful cancellation. See docstring above. + self.terminate() + self.wait() + + @property + def stop_requested(self) -> bool: + """Return True if stop has been requested.""" + return self._stop_requested diff --git a/EasyReflectometryApp/Gui/ApplicationWindow.qml b/EasyReflectometryApp/Gui/ApplicationWindow.qml index 196a6d60..ba90c8d5 100644 --- a/EasyReflectometryApp/Gui/ApplicationWindow.qml +++ b/EasyReflectometryApp/Gui/ApplicationWindow.qml @@ -20,7 +20,7 @@ EaComponents.ApplicationWindow { timer.triggered.connect(cb); timer.start(); } - + id: applicationWindow /////////////////// // APPLICATION BAR /////////////////// diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 0534eec8..1ab96e93 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -78,6 +78,17 @@ QtObject { function projectReset() { activeBackend.project.reset() } function projectSave() { activeBackend.project.save() } function projectLoad(value) { activeBackend.project.load(value) } + function sampleFileLoad(value, append) { activeBackend.project.sampleLoad(value, append) } + + // Sample load warning signal - forwarded from backend + signal sampleLoadWarning(string message) + + property var _sampleLoadWarningConnection: { + if (activeBackend && activeBackend.project && activeBackend.project.sampleLoadWarning) { + activeBackend.project.sampleLoadWarning.connect(sampleLoadWarning) + } + return null + } /////////////// @@ -102,6 +113,7 @@ QtObject { // Model readonly property var sampleModels: activeBackend.sample.models + readonly property var sampleModelNames: activeBackend.sample.modelsNames readonly property string sampleCurrentModelName: activeBackend.sample.currentModelName readonly property int sampleCurrentModelIndex: activeBackend.sample.currentModelIndex @@ -158,11 +170,18 @@ QtObject { function sampleSetCurrentLayerSolvation(value) { activeBackend.sample.setCurrentLayerSolvation(value) } // Constraints + readonly property var sampleEnabledParameterNames: activeBackend.sample.enabledParameterNames readonly property var sampleParameterNames: activeBackend.sample.parameterNames + readonly property var sampleDepParameterNames: activeBackend.sample.dependentParameterNames readonly property var sampleRelationOperators: activeBackend.sample.relationOperators readonly property var sampleArithmicOperators: activeBackend.sample.arithmicOperators + readonly property var sampleConstraintsList: activeBackend.sample.constraintsList + readonly property var sampleConstraintParametersMetadata: activeBackend.sample.constraintParametersMetadata - function sampleAddConstraint(value1, value2, value3, value4, value5) { activeBackend.sample.addConstraint(value1, value2, value3, value4, value5) } + function sampleValidateConstraintExpression(index, relation, expression) { return activeBackend.sample.validateConstraintExpression(index, relation, expression) } + function sampleAddConstraint(index, relation, expression) { return activeBackend.sample.addConstraint(index, relation, expression) } + function sampleRemoveConstraintByIndex(value) { activeBackend.sample.removeConstraintByIndex(value) } + function sampleConstrainModelsParameters(modelIndices) { activeBackend.sample.constrainModelsParameters(modelIndices) } // Q range readonly property var sampleQMin: activeBackend.sample.q_min @@ -183,7 +202,6 @@ QtObject { function experimentSetBackground(value) { activeBackend.experiment.setBackground(value) } readonly property var experimentResolution: activeBackend.experiment.resolution function experimentSetResolution(value) { activeBackend.experiment.setResolution(value) } - function experimentLoad(value) { activeBackend.experiment.load(value) } @@ -193,6 +211,37 @@ QtObject { readonly property var analysisExperimentsAvailable: activeBackend.analysis.experimentsAvailable readonly property int analysisExperimentsCurrentIndex: activeBackend.analysis.experimentCurrentIndex function analysisSetExperimentsCurrentIndex(value) { activeBackend.analysis.setExperimentCurrentIndex(value) } + function analysisRemoveExperiment(value) { activeBackend.analysis.removeExperiment(value) } + + // Multi-experiment selection support + readonly property int analysisExperimentsSelectedCount: { + try { + return activeBackend.analysisExperimentsSelectedCount || 1 + } catch (e) { + console.warn("analysisExperimentsSelectedCount failed:", e) + return 1 + } + } + readonly property var analysisSelectedExperimentIndices: { + try { + return activeBackend.analysisSelectedExperimentIndices || [] + } catch (e) { + console.warn("analysisSelectedExperimentIndices failed:", e) + return [] + } + } + function analysisSetSelectedExperimentIndices(value) { + try { + activeBackend.analysisSetSelectedExperimentIndices(value) + } catch (e) { + console.warn("Failed to set selected experiment indices:", e) + } + } + + function analysisSetModelOnExperiment(value) { activeBackend.analysis.setModelOnExperiment(value) } + readonly property var analysisModelForExperiment: activeBackend.analysis.modelIndexForExperiment + readonly property var modelNamesForExperiment: activeBackend.analysis.modelNamesForExperiment + readonly property var modelColorsForExperiment: activeBackend.analysis.modelColorsForExperiment readonly property var analysisCalculatorsAvailable: activeBackend.analysis.calculatorsAvailable readonly property int analysisCalculatorCurrentIndex: activeBackend.analysis.calculatorCurrentIndex @@ -202,9 +251,13 @@ QtObject { readonly property int analysisMinimizerCurrentIndex: activeBackend.analysis.minimizerCurrentIndex function analysisSetMinimizerCurrentIndex(value) { activeBackend.analysis.setMinimizerCurrentIndex(value) } - readonly property var analysisFitableParameters: activeBackend.analysis.fitableParameters + readonly property var analysisFitableParameters: activeBackend.analysis.enabledParameters readonly property int analysisCurrentParameterIndex: activeBackend.analysis.currentParameterIndex + readonly property var analysisEnabledParameters: activeBackend.analysis.enabledParameters + function analysisSetCurrentParameterIndex(value) { activeBackend.analysis.setCurrentParameterIndex(value) } + function analysisSetExperimentName(value) { activeBackend.analysis.setExperimentName(value) } + function analysisSetExperimentNameAtIndex(index, value) { activeBackend.analysis.setExperimentNameAtIndex(index, value) } // Minimizer readonly property var analysisMinimizerTolerance: activeBackend.analysis.minimizerTolerance @@ -216,18 +269,41 @@ QtObject { readonly property string analysisFittingStatus: activeBackend.analysis.fittingStatus readonly property bool analysisFittingRunning: activeBackend.analysis.fittingRunning readonly property bool analysisIsFitFinished: activeBackend.analysis.isFitFinished + readonly property bool analysisShowFitResultsDialog: activeBackend.analysis.showFitResultsDialog + readonly property bool analysisFitSuccess: activeBackend.analysis.fitSuccess + readonly property string analysisFitErrorMessage: activeBackend.analysis.fitErrorMessage + readonly property int analysisFitNumRefinedParams: activeBackend.analysis.fitNumRefinedParams + readonly property real analysisFitChi2: activeBackend.analysis.fitChi2 + readonly property var analysisFitResults: activeBackend.analysis.fitResults function analysisFittingStartStop() { activeBackend.analysis.fittingStartStop() } + function analysisSetShowFitResultsDialog(value) { activeBackend.analysis.setShowFitResultsDialog(value) } + function analysisStopFit() { activeBackend.analysis.stopFit() } + + // Fit failure signal - forwarded from backend + signal analysisFitFailed(string message) + + // Connect backend fitFailed signal to QML signal + property var _fitFailedConnection: { + if (activeBackend && activeBackend.analysis && activeBackend.analysis.fitFailed) { + activeBackend.analysis.fitFailed.connect(analysisFitFailed) + } + return null + } // Parameters readonly property int analysisFreeParametersCount: activeBackend.analysis.freeParametersCount readonly property int analysisFixedParametersCount: activeBackend.analysis.fixedParametersCount readonly property int analysisModelParametersCount: activeBackend.analysis.modelParametersCount readonly property int analysisExperimentParametersCount: activeBackend.analysis.experimentParametersCount + readonly property string analysisNameFilterCriteria: activeBackend.analysis.nameFilterCriteria + readonly property string analysisVariabilityFilterCriteria: activeBackend.analysis.variabilityFilterCriteria function analysisSetCurrentParameterValue(value) { activeBackend.analysis.setCurrentParameterValue(value) } function analysisSetCurrentParameterMin(value) { activeBackend.analysis.setCurrentParameterMin(value) } function analysisSetCurrentParameterMax(value) { activeBackend.analysis.setCurrentParameterMax(value) } function analysisSetCurrentParameterFit(value) { activeBackend.analysis.setCurrentParameterFit(value) } + function analysisSetNameFilterCriteria(value) { activeBackend.analysis.setNameFilterCriteria(value) } + function analysisSetVariabilityFilterCriteria(value) { activeBackend.analysis.setVariabilityFilterCriteria(value) } /////////////// // Summary page @@ -238,10 +314,26 @@ QtObject { readonly property string summaryFilePath: activeBackend.summary.filePath readonly property string summaryFileUrl: activeBackend.summary.fileUrl readonly property var summaryExportFormats: activeBackend.summary.exportFormats + readonly property string summaryPlotFileName: activeBackend.summary.plotFileName + readonly property string summaryPlotFilePath: activeBackend.summary.plotFilePath + readonly property string summaryPlotFileUrl: activeBackend.summary.plotFileUrl + readonly property var summaryPlotExportFormats: activeBackend.summary.plotExportFormats function summarySetFileName(value) { activeBackend.summary.setFileName(value) } - function summarySaveAsHtml() { activeBackend.summary.saveAsHtml() } - function summarySaveAsPdf() { activeBackend.summary.saveAsPdf() } + function summarySetPlotFileName(value) { activeBackend.summary.setPlotFileName(value) } + function summarySaveAsHtml(path) { activeBackend.summary.saveAsHtml(path) } + function summarySaveAsPdf(path) { activeBackend.summary.saveAsPdf(path) } + function summarySavePlot(path, widthCm, heightCm) { activeBackend.summary.savePlot(path, widthCm, heightCm) } + function summaryShowPlot(widthCm, heightCm) { activeBackend.summary.showPlot(widthCm, heightCm) } + + signal summaryExportingFinished(bool success, string filePath) + + property var _summaryExportConnection: { + if (activeBackend && activeBackend.summary && activeBackend.summary.htmlExportingFinished) { + activeBackend.summary.htmlExportingFinished.connect(summaryExportingFinished) + } + return null + } /////////////// @@ -257,17 +349,183 @@ QtObject { readonly property var plottingSampleMinY: activeBackend.plotting.sampleMinY readonly property var plottingSampleMaxY: activeBackend.plotting.sampleMaxY - readonly property var plottingExperimentMinX: activeBackend.plotting.sampleMinX - readonly property var plottingExperimentMaxX: activeBackend.plotting.sampleMaxX - readonly property var plottingExperimentMinY: activeBackend.plotting.sampleMinY - readonly property var plottingExperimentMaxY: activeBackend.plotting.sampleMaxY + readonly property var plottingExperimentMinX: activeBackend.plotting.experimentMinX + readonly property var plottingExperimentMaxX: activeBackend.plotting.experimentMaxX + readonly property var plottingExperimentMinY: activeBackend.plotting.experimentMinY + readonly property var plottingExperimentMaxY: activeBackend.plotting.experimentMaxY readonly property var plottingAnalysisMinX: activeBackend.plotting.sampleMinX readonly property var plottingAnalysisMaxX: activeBackend.plotting.sampleMaxX readonly property var plottingAnalysisMinY: activeBackend.plotting.sampleMinY readonly property var plottingAnalysisMaxY: activeBackend.plotting.sampleMaxY + readonly property var calcSerieColor: activeBackend.plotting.calcSerieColor + + // Plot mode properties + readonly property bool plottingPlotRQ4: activeBackend.plotting.plotRQ4 + readonly property string plottingYAxisTitle: activeBackend.plotting.yMainAxisTitle + readonly property bool plottingXAxisLog: activeBackend.plotting.xAxisLog + readonly property string plottingXAxisType: activeBackend.plotting.xAxisType + readonly property bool plottingSldXReversed: activeBackend.plotting.sldXDataReversed + + // Reference line visibility + readonly property bool plottingScaleShown: activeBackend.plotting.scaleShown + readonly property bool plottingBkgShown: activeBackend.plotting.bkgShown + + // Plot mode toggle functions + function plottingTogglePlotRQ4() { activeBackend.plotting.togglePlotRQ4() } + function plottingToggleXAxisType() { activeBackend.plotting.toggleXAxisType() } + function plottingReverseSldXData() { activeBackend.plotting.reverseSldXData() } + function plottingFlipScaleShown() { activeBackend.plotting.flipScaleShown() } + function plottingFlipBkgShown() { activeBackend.plotting.flipBkgShown() } + + // Reference line data accessors + function plottingGetBackgroundData() { + try { + return activeBackend.plotting.getBackgroundData() + } catch (e) { + console.warn("plottingGetBackgroundData failed:", e) + return [] + } + } + function plottingGetScaleData() { + try { + return activeBackend.plotting.getScaleData() + } catch (e) { + console.warn("plottingGetScaleData failed:", e) + return [] + } + } + + // Analysis-specific reference line data accessors (use sample/calculated x-range) + function plottingGetBackgroundDataForAnalysis() { + try { + return activeBackend.plotting.getBackgroundDataForAnalysis() + } catch (e) { + console.warn("plottingGetBackgroundDataForAnalysis failed:", e) + return [] + } + } + function plottingGetScaleDataForAnalysis() { + try { + return activeBackend.plotting.getScaleDataForAnalysis() + } catch (e) { + console.warn("plottingGetScaleDataForAnalysis failed:", e) + return [] + } + } + + // Helper to update background/scale reference line series on any chart view. + // useAnalysisRange: true for Analysis charts (sample x-range), false for Experiment charts. + function updateRefLines(bkgSeries, scaleSeries, useAnalysisRange) { + bkgSeries.clear() + if (plottingBkgShown) { + var bkgData = useAnalysisRange ? plottingGetBackgroundDataForAnalysis() : plottingGetBackgroundData() + for (var i = 0; i < bkgData.length; i++) { + bkgSeries.append(bkgData[i].x, bkgData[i].y) + } + } + scaleSeries.clear() + if (plottingScaleShown) { + var scaleData = useAnalysisRange ? plottingGetScaleDataForAnalysis() : plottingGetScaleData() + for (var j = 0; j < scaleData.length; j++) { + scaleSeries.append(scaleData[j].x, scaleData[j].y) + } + } + } function plottingSetQtChartsSerieRef(value1, value2, value3) { activeBackend.plotting.setQtChartsSerieRef(value1, value2, value3) } function plottingRefreshSample() { activeBackend.plotting.drawCalculatedOnSampleChart() } function plottingRefreshSLD() { activeBackend.plotting.drawCalculatedOnSldChart() } + function plottingRefreshExperiment() { activeBackend.plotting.drawMeasuredOnExperimentChart() } + function plottingRefreshAnalysis() { activeBackend.plotting.drawCalculatedAndMeasuredOnAnalysisChart() } + + // Multi-model sample page plotting support + readonly property int plottingModelCount: activeBackend.plotting.modelCount + function plottingGetSampleDataPointsForModel(index) { + try { + return activeBackend.plotting.getSampleDataPointsForModel(index) + } catch (e) { + console.warn("plottingGetSampleDataPointsForModel failed:", e) + return [] + } + } + function plottingGetSldDataPointsForModel(index) { + try { + return activeBackend.plotting.getSldDataPointsForModel(index) + } catch (e) { + console.warn("plottingGetSldDataPointsForModel failed:", e) + return [] + } + } + function plottingGetModelColor(index) { + try { + return activeBackend.plotting.getModelColor(index) + } catch (e) { + console.warn("plottingGetModelColor failed:", e) + return '#000000' + } + } + + // Signal for sample page data changes - forward from backend + signal samplePageDataChanged() + // Signal for resetting chart axes after data load + signal samplePageResetAxes() + // Signal for plot mode changes - forward from backend + signal plotModeChanged() + // Signal to request QML to reset chart axes (e.g., after model load) + signal chartAxesResetRequested() + + // Connect to backend signal (called from Component.onCompleted in QML items) + function connectSamplePageDataChanged() { + if (activeBackend && activeBackend.plotting && activeBackend.plotting.samplePageDataChanged) { + activeBackend.plotting.samplePageDataChanged.connect(samplePageDataChanged) + } + if (activeBackend && activeBackend.plotting && activeBackend.plotting.samplePageResetAxes) { + activeBackend.plotting.samplePageResetAxes.connect(samplePageResetAxes) + } + if (activeBackend && activeBackend.plotting && activeBackend.plotting.plotModeChanged) { + activeBackend.plotting.plotModeChanged.connect(plotModeChanged) + } + if (activeBackend && activeBackend.plotting && activeBackend.plotting.chartAxesResetRequested) { + activeBackend.plotting.chartAxesResetRequested.connect(chartAxesResetRequested) + } + } + + Component.onCompleted: { + connectSamplePageDataChanged() + } + + // Multi-experiment plotting support + readonly property bool plottingIsMultiExperimentMode: { + try { + return activeBackend.plottingIsMultiExperimentMode || false + } catch (e) { + console.warn("plottingIsMultiExperimentMode failed:", e) + return false + } + } + readonly property var plottingIndividualExperimentDataList: { + try { + return activeBackend.plottingIndividualExperimentDataList || [] + } catch (e) { + console.warn("plottingIndividualExperimentDataList failed:", e) + return [] + } + } + function plottingGetExperimentDataPoints(index) { + try { + return activeBackend.plottingGetExperimentDataPoints(index) + } catch (e) { + console.warn("plottingGetExperimentDataPoints failed:", e) + return [] + } + } + function plottingGetAnalysisDataPoints(index) { + try { + return activeBackend.plottingGetAnalysisDataPoints(index) + } catch (e) { + console.warn("plottingGetAnalysisDataPoints failed:", e) + return [] + } + } } diff --git a/EasyReflectometryApp/Gui/Globals/Variables.qml b/EasyReflectometryApp/Gui/Globals/Variables.qml index d269b1d4..71b34dc1 100644 --- a/EasyReflectometryApp/Gui/Globals/Variables.qml +++ b/EasyReflectometryApp/Gui/Globals/Variables.qml @@ -11,4 +11,10 @@ QtObject { property bool showLegendOnSamplePage: false property bool showLegendOnExperimentPage: false property bool showLegendOnAnalysisPage: false + property bool useStaggeredPlotting: false + property double staggeringFactor: 0.5 + + // Sample page plot control settings + property bool reverseSldZAxis: false + property bool logarithmicQAxis: false } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml index e1adec3e..0620e8ef 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml @@ -12,30 +12,17 @@ import Gui.Globals as Globals EaComponents.ContentPage { -// defaultInfo: Globals.Proxies.main.model.defined && -// Globals.Proxies.main.experiment.defined ? -// "" : -// qsTr("No analysis done") mainView: EaComponents.MainContent { -/* tabs: [ - EaElements.TabButton { - text: Globals.Proxies.experimentMainParam('_sample', 'type').value === 'pd' ? - qsTr("Fitting") : - qsTr("I vs. sinθ/λ") - }, - EaElements.TabButton { text: qsTr("Imeas vs. Icalc") } - ] -*/ + tabs: [ + EaElements.TabButton { text: qsTr('Reflectivity') } + ] + items: [ Loader { - source: `MainContent/AnalysisView.qml` + source: `MainContent/CombinedView.qml` onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) } -// Loader { -// source: `MainContent/ScChartTab.qml` -// onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) -// } ] } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index 2caec343..fec58f95 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -23,7 +23,6 @@ Rectangle { property alias calculated: chartView.calcSerie property alias measured: chartView.measSerie -// property alias errorLower: chartView.bkgSerie bkgSerie.color: measSerie.color measSerie.width: 1 bkgSerie.width: 1 @@ -31,6 +30,94 @@ Rectangle { anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 useOpenGL: EaGlobals.Vars.useOpenGL + + // 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) + } + + // 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() + } + } + + Timer { + id: analysisResetAxesTimer + interval: 75 + repeat: false + onTriggered: chartView.resetAxes() + } property double xRange: Globals.BackendWrapper.plottingAnalysisMaxX - Globals.BackendWrapper.plottingAnalysisMinX axisX.title: "q (Å⁻¹)" @@ -40,13 +127,131 @@ Rectangle { axisX.maxAfterReset: Globals.BackendWrapper.plottingAnalysisMaxX + xRange * 0.01 property double yRange: Globals.BackendWrapper.plottingAnalysisMaxY - Globals.BackendWrapper.plottingAnalysisMinY - axisY.title: "Log10 R(q)" + 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 models = Globals.BackendWrapper.sampleModels + const idx = Globals.BackendWrapper.sampleCurrentModelIndex + + if (models && idx >= 0 && idx < models.length) { + return models[idx].color + } + + 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 = true + calculated.visible = true + 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 = true + calculated.visible = true + return + } + + // Hide default series in multi-experiment mode (only after we have data) + measured.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) { + // Create measured data series + var measuredSerie = chartView.createSeries(ChartView.SeriesTypeLine, + `${expName} - Measured`, + chartView.axisX, chartView.axisY) + measuredSerie.color = color + measuredSerie.width = 1 + measuredSerie.capStyle = Qt.RoundCap + measuredSerie.useOpenGL = chartView.useOpenGL + + // Create calculated data series (slightly different style) + var calculatedSerie = chartView.createSeries(ChartView.SeriesTypeLine, + `${expName} - Calculated`, + chartView.axisX, chartView.axisY) + calculatedSerie.color = color + 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 { @@ -135,15 +340,70 @@ Rectangle { 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: '━ I (Measured)' color: chartView.measSerie.color } EaElements.Label { - text: '━ (calculated)' + visible: !chartView.isMultiExperimentMode + text: '━ (Calculated)' color: chartView.calcSerie.color } + + // 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: qsTr("━ Measured (thin)") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeForegroundMinor + } + EaElements.Label { + text: qsTr("━ Calculated (thick)") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeForegroundMinor + } + } } } // Legend @@ -164,6 +424,22 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + + // Initialize multi-experiment support + updateMultiExperimentSeries() + + // Initialize reference lines + updateReferenceLines() + } + + // Update series when chart becomes visible + onVisibleChanged: { + if (visible && isMultiExperimentMode) { + updateMultiExperimentSeries() + } + if (visible) { + updateReferenceLines() + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml new file mode 100644 index 00000000..9eab767e --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -0,0 +1,487 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtCharts + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Globals as EaGlobals +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Charts as EaCharts + +import Gui as Gui +import Gui.Globals as Globals + + +Rectangle { + id: container + + color: EaStyle.Colors.chartBackground + + SplitView { + anchors.fill: parent + orientation: Qt.Vertical + + // Analysis Chart (2/3 height) + Rectangle { + id: analysisContainer + SplitView.fillHeight: true + SplitView.preferredHeight: parent.height * 0.67 + SplitView.minimumHeight: 100 + color: EaStyle.Colors.chartBackground + + EaCharts.QtCharts1dMeasVsCalc { + id: analysisChartView + + property alias calculated: analysisChartView.calcSerie + property alias measured: analysisChartView.measSerie + bkgSerie.color: measSerie.color + measSerie.width: 1 + bkgSerie.width: 1 + + anchors.fill: parent + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + + useOpenGL: EaGlobals.Vars.useOpenGL + + // 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: { + updateMultiExperimentSeries() + } + + // Watch for changes in multi-experiment selection + Connections { + target: Globals.BackendWrapper.activeBackend ?? null + enabled: target !== null + function onMultiExperimentSelectionChanged() { + analysisChartView.updateMultiExperimentSeries() + } + } + + // Watch for plot mode changes (R(q)×q⁴ toggle) + Connections { + target: Globals.BackendWrapper + function onPlotModeChanged() { + console.debug("CombinedView Analysis: Plot mode changed, refreshing chart") + Globals.BackendWrapper.plottingRefreshAnalysis() + // Delay resetAxes to allow axis range properties to update first + combinedAnalysisResetAxesTimer.start() + } + function onChartAxesResetRequested() { + // Reset axes when model is loaded (e.g., from ORSO file) + combinedAnalysisResetAxesTimer.start() + } + function onSamplePageResetAxes() { + combinedAnalysisResetAxesTimer.start() + } + } + + Timer { + id: combinedAnalysisResetAxesTimer + interval: 75 + repeat: false + onTriggered: { + analysisChartView.resetAxes() + sldChart.chartView.resetAxes() + } + } + + // Background reference line series + LineSeries { + id: backgroundRefLine + axisX: analysisChartView.axisX + axisY: analysisChartView.axisY + useOpenGL: analysisChartView.useOpenGL + color: "#888888" + width: 1 + style: Qt.DashLine + visible: Globals.BackendWrapper.plottingBkgShown + } + + // Scale reference line series + LineSeries { + id: scaleRefLine + axisX: analysisChartView.axisX + axisY: analysisChartView.axisY + useOpenGL: analysisChartView.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() { + analysisChartView.updateReferenceLines() + } + } + + function updateReferenceLines() { + Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, true) + } + + // 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) { + // Show default series for single experiment + measured.visible = true + calculated.visible = true + return + } + + // Get experiment data list + var experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentDataList + + // If no data available yet, keep default series visible as fallback + if (experimentDataList.length === 0) { + measured.visible = true + calculated.visible = true + return + } + + // Hide default series in multi-experiment mode (only after we have data) + measured.visible = false + calculated.visible = false + + // Create series for each experiment + for (var i = 0; i < experimentDataList.length; i++) { + var expData = experimentDataList[i] + 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) { + analysisChartView.removeSeries(seriesSet.measuredSerie) + } + if (seriesSet.calculatedSerie) { + analysisChartView.removeSeries(seriesSet.calculatedSerie) + } + } + multiExperimentSeries = [] + } + + function createExperimentSeries(expIndex, expName, color) { + // Create measured data series + var measuredSerie = analysisChartView.createSeries(ChartView.SeriesTypeLine, + `${expName} - Measured`, + analysisChartView.axisX, analysisChartView.axisY) + measuredSerie.color = color + measuredSerie.width = 1 + measuredSerie.capStyle = Qt.RoundCap + measuredSerie.useOpenGL = analysisChartView.useOpenGL + + // Create calculated data series (slightly different style) + var calculatedSerie = analysisChartView.createSeries(ChartView.SeriesTypeLine, + `${expName} - Calculated`, + analysisChartView.axisX, analysisChartView.axisY) + calculatedSerie.color = color + calculatedSerie.width = 2 + calculatedSerie.capStyle = Qt.RoundCap + calculatedSerie.useOpenGL = analysisChartView.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) + + // 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) + } + } + + 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 + + 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(analysisChartView, analysisDataToolTip, point, state) + calcSerie.color: { + const models = Globals.BackendWrapper.sampleModels + const idx = Globals.BackendWrapper.sampleCurrentModelIndex + + if (models && idx >= 0 && idx < models.length) { + return models[idx].color + } + + return undefined + } + + // Tool buttons + Row { + id: analysisToolButtons + + x: analysisChartView.plotArea.x + analysisChartView.plotArea.width - width + y: analysisChartView.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: analysisChartView.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: analysisChartView.allowHover = !analysisChartView.allowHover + } + + Item { height: 1; width: 0.5 * EaStyle.Sizes.fontPixelSize } // spacer + + EaElements.TabButton { + checked: !analysisChartView.allowZoom + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "arrows-alt" + ToolTip.text: qsTr("Enable pan") + onClicked: { + analysisChartView.allowZoom = !analysisChartView.allowZoom + sldChart.chartView.allowZoom = analysisChartView.allowZoom + } + } + + EaElements.TabButton { + checked: analysisChartView.allowZoom + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "expand" + ToolTip.text: qsTr("Enable box zoom") + onClicked: { + analysisChartView.allowZoom = !analysisChartView.allowZoom + sldChart.chartView.allowZoom = analysisChartView.allowZoom + } + } + + EaElements.TabButton { + checkable: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "backspace" + ToolTip.text: qsTr("Reset axes") + onClicked: { + analysisChartView.resetAxes() + sldChart.chartView.resetAxes() + } + } + } + + // Legend + Rectangle { + visible: Globals.Variables.showLegendOnAnalysisPage + + x: analysisChartView.plotArea.x + analysisChartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize + y: analysisChartView.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: !analysisChartView.isMultiExperimentMode + text: '━ I (Measured)' + color: analysisChartView.measSerie.color + } + EaElements.Label { + visible: !analysisChartView.isMultiExperimentMode + text: '━ (Calculated)' + color: analysisChartView.calcSerie.color + } + + // Multi-experiment legend + Column { + visible: analysisChartView.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: analysisChartView.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: qsTr("━ Measured (thin)") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeForegroundMinor + } + EaElements.Label { + text: qsTr("━ Calculated (thick)") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeForegroundMinor + } + } + } + } + + EaElements.ToolTip { + id: analysisDataToolTip + + arrowLength: 0 + textFormat: Text.RichText + } + + // Data is set in python backend (plotting_1d.py) + Component.onCompleted: { + Globals.References.pages.analysis.mainContent.analysisView = analysisChartView + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', + 'measuredSerie', + measured) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', + 'calculatedSerie', + calculated) + + // Initialize multi-experiment support + updateMultiExperimentSeries() + + // Initialize reference lines + updateReferenceLines() + } + } + } + + // SLD Chart (1/3 height) + Gui.SldChart { + id: sldChart + + SplitView.fillHeight: true + SplitView.preferredHeight: parent.height * 0.33 + SplitView.minimumHeight: 80 + + showLegend: Globals.Variables.showLegendOnAnalysisPage + onShowLegendChanged: Globals.Variables.showLegendOnAnalysisPage = showLegend + + Component.onCompleted: { + Globals.References.pages.analysis.mainContent.sldView = sldChart.chartView + } + } + } + + // Logic + function showMainTooltip(chart, tooltip, point, state) { + if (!chart.allowHover) { + return + } + const pos = chart.mapToPosition(Qt.point(point.x, point.y)) + tooltip.x = pos.x + tooltip.y = pos.y + tooltip.text = `

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

` + tooltip.parent = chart + tooltip.visible = state + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml new file mode 100644 index 00000000..31399d53 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick + +import Gui as Gui +import Gui.Globals as Globals + + +Gui.SldChart { + id: sldChart + + showLegend: Globals.Variables.showLegendOnAnalysisPage + + onShowLegendChanged: Globals.Variables.showLegendOnAnalysisPage = showLegend + + Component.onCompleted: { + Globals.References.pages.analysis.mainContent.sldView = sldChart.chartView + } +} + diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml index d5fff228..f568d96b 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml @@ -25,7 +25,20 @@ EaElements.GroupBox { text: qsTr("Minimizer") color: EaStyle.Colors.themeForegroundMinor } - currentIndex: Globals.BackendWrapper.analysisMinimizerCurrentIndex + + // Use a Component.onCompleted handler to set the initial selection to "Bumps_simplex" + Component.onCompleted: { + // Find the index of "Bumps_simplex" in the model + for (let i = 0; i < model.length; i++) { + if (model[i] === "Bumps_simplex") { + currentIndex = i; + Globals.BackendWrapper.analysisSetMinimizerCurrentIndex(i); + break; + } + } + } + + // Keep this binding for subsequent changes onCurrentIndexChanged: Globals.BackendWrapper.analysisSetMinimizerCurrentIndex(currentIndex) } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml new file mode 100644 index 00000000..34de6956 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/PlotControl.qml @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements + +import Gui as Gui + +EaElements.GroupBox { + title: qsTr("Plot control") + collapsed: true + + Gui.PlotControlRefLines {} +} + diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Layout.qml index 2571f4d9..deeee520 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Layout.qml @@ -17,4 +17,6 @@ EaComponents.SideBarColumn { Groups.Calculator {} Groups.Minimizer {} + + Groups.PlotControl {} } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml index 93cf779f..7c7ad714 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml @@ -1,125 +1,261 @@ -// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors -// SPDX-License-Identifier: BSD-3-Clause -// © 2025 Contributors to the EasyReflectometry project - import QtQuick import QtQuick.Controls -import QtQuick.Dialogs -import EasyApp.Gui.Globals as EaGlobals import EasyApp.Gui.Style as EaStyle import EasyApp.Gui.Elements as EaElements import EasyApp.Gui.Components as EaComponents -import EasyApp.Gui.Logic as EaLogic import Gui.Globals as Globals - EaElements.GroupBox { - collapsible: false - last: true + title: selectedExperimentIndices.length <= 1 ? + qsTr("Data Explorer") : + qsTr("Data Explorer (%1 selected)").arg(selectedExperimentIndices.length) + visible: true + collapsed: false + + // Track selection state locally and keep backend in sync + property var selectedExperimentIndices: [] + property bool wasMultiSelected: false + + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + // Multi-selection controls (mirrors ExperimentalDataExplorer) + Row { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + visible: Globals.BackendWrapper.analysisExperimentsAvailable.length > 1 + + EaElements.Label { + text: qsTr("Ctrl+Click for multi-select") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.75 + color: EaStyle.Colors.themeForegroundMinor + anchors.verticalCenter: parent.verticalCenter + } - EaElements.ComboBox { - id: comboBox + EaElements.TabButton { + height: EaStyle.Sizes.fontPixelSize * 1.5 + width: height * 2 + borderColor: EaStyle.Colors.chartAxis + fontIcon: "check-double" + ToolTip.text: qsTr("Select all experiments") + enabled: selectedExperimentIndices.length < Globals.BackendWrapper.analysisExperimentsAvailable.length + onClicked: selectAllExperiments() + } + + EaElements.TabButton { + height: EaStyle.Sizes.fontPixelSize * 1.5 + width: height * 2 + borderColor: EaStyle.Colors.chartAxis + fontIcon: "times" + ToolTip.text: qsTr("Clear selection") + enabled: selectedExperimentIndices.length > 1 + onClicked: { + clearAllSelections() + if (Globals.BackendWrapper.analysisExperimentsAvailable.length > 0) { + selectSingleExperiment(0) + } + } + } + + EaElements.Label { + visible: selectedExperimentIndices.length > 1 && + selectedExperimentIndices.length < Globals.BackendWrapper.analysisExperimentsAvailable.length + text: qsTr("Selected: %1").arg(selectedExperimentIndices.map(i => i + 1).join(", ")) + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeAccent + anchors.verticalCenter: parent.verticalCenter + } - topInset: 0 - bottomInset: 0 + EaElements.Label { + visible: selectedExperimentIndices.length > 1 && + selectedExperimentIndices.length === Globals.BackendWrapper.analysisExperimentsAvailable.length + text: qsTr("All experiments selected") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeForegroundHovered + anchors.verticalCenter: parent.verticalCenter + } + } - width: EaStyle.Sizes.sideBarContentWidth - anchors.bottomMargin: EaStyle.Sizes.fontPixelSize + Row { + spacing: EaStyle.Sizes.fontPixelSize - textRole: "name" + EaComponents.TableView { + id: dataTable + defaultInfoText: qsTr("No Experiments Loaded") + model: Globals.BackendWrapper.analysisExperimentsAvailable.length - model: Globals.BackendWrapper.analysisExperimentsAvailable.length - currentIndex: Globals.BackendWrapper.analysisExperimentsCurrentIndex - onActivated: Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(currentIndex) + onModelChanged: { + if (model === 0) { + clearAllSelections() + } else { + var validSelection = [] + for (var i = 0; i < selectedExperimentIndices.length; i++) { + if (selectedExperimentIndices[i] < model) { + validSelection.push(selectedExperimentIndices[i]) + } + } + if (validSelection.length !== selectedExperimentIndices.length) { + selectedExperimentIndices = validSelection + if (validSelection.length === 0 && model > 0) { + selectSingleExperiment(0) + } else { + updateBackendWithSelectedExperiments() + } + } + } + } - // ComboBox delegate (popup rows) - delegate: ItemDelegate { - id: itemDelegate + header: EaComponents.TableViewHeader { + EaComponents.TableViewLabel { + text: qsTr('No.') + width: EaStyle.Sizes.fontPixelSize * 2.5 + } - width: parent.width - height: EaStyle.Sizes.tableRowHeight + EaComponents.TableViewLabel { + flexibleWidth: true + horizontalAlignment: Text.AlignLeft + text: qsTr('Name') + } - highlighted: comboBox.highlightedIndex === index + EaComponents.TableViewLabel { + width: EaStyle.Sizes.fontPixelSize * 9.5 + horizontalAlignment: Text.AlignHCenter + text: "Model" + } - // ComboBox delegate (popup rows) contentItem - contentItem: Item { - width: parent.width - height: parent.height + EaComponents.TableViewLabel { + id: colorLab + text: "Color" + width: EaStyle.Sizes.fontPixelSize * 2.5 + } + } - Row { - height: parent.height - spacing: EaStyle.Sizes.tableColumnSpacing + delegate: EaComponents.TableViewDelegate { + property bool isSelected: selectedExperimentIndices.indexOf(index) !== -1 EaComponents.TableViewLabel { + id: noLabel + width: EaStyle.Sizes.fontPixelSize * 2.5 text: index + 1 - color: EaStyle.Colors.themeForegroundMinor + + Rectangle { + visible: isSelected + anchors.fill: parent.parent + color: EaStyle.Colors.themeForegroundHovered + opacity: 0.2 + z: -1 + + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 3 + height: parent.height * 0.8 + color: EaStyle.Colors.themeAccent + } + } } - EaComponents.TableViewButton { - anchors.verticalCenter: parent.verticalCenter - fontIcon: "microscope" - ToolTip.text: qsTr("Measured pattern color") - backgroundColor: "transparent" - borderColor: "transparent" - iconColor: EaStyle.Colors.chartForegroundsExtra[2] + EaComponents.TableViewTextInput { + horizontalAlignment: Text.AlignLeft + id: labelLabel + width: EaStyle.Sizes.fontPixelSize * 11 + text: index > -1 ? Globals.BackendWrapper.analysisExperimentsAvailable[index] : "" + onEditingFinished: Globals.BackendWrapper.analysisSetExperimentNameAtIndex(index, text) } - EaComponents.TableViewParameter { - enabled: false - //text: comboBox.model[index]//.name.value - text: Globals.BackendWrapper.analysisExperimentsAvailable[index] + EaComponents.TableViewLabel { + id: modelAccess + text: Globals.BackendWrapper.modelNamesForExperiment[model.index] || "" + } + EaComponents.TableViewLabel { + id: colorLabel + backgroundColor: Globals.BackendWrapper.modelColorsForExperiment[model.index] + } + + mouseArea.onPressed: (mouse) => { + if (mouse.modifiers & Qt.ControlModifier) { + toggleExperimentSelection(index) + } else { + selectSingleExperiment(index) + } } } } - // ComboBox delegate (popup rows) contentItem - - // ComboBox delegate (popup rows) background - background: Rectangle { - color: itemDelegate.highlighted ? - EaStyle.Colors.tableHighlight : - index % 2 ? - EaStyle.Colors.themeBackgroundHovered2 : - EaStyle.Colors.themeBackgroundHovered1 - } - // ComboBox delegate (popup rows) background + } + } + function toggleExperimentSelection(experimentIndex) { + var currentSelection = selectedExperimentIndices.slice() + var indexPos = currentSelection.indexOf(experimentIndex) + + if (indexPos !== -1) { + currentSelection.splice(indexPos, 1) + } else { + currentSelection.push(experimentIndex) } - // ComboBox delegate (popup rows) - // ComboBox (selected item) contentItem - contentItem: Item { - width: parent.width - height: parent.height + if (currentSelection.length > 1) { + wasMultiSelected = true + } - Row { - height: parent.height - spacing: EaStyle.Sizes.tableColumnSpacing + selectedExperimentIndices = currentSelection + updateBackendWithSelectedExperiments() + } - EaComponents.TableViewLabel { - text: comboBox.currentIndex + 1 - color: EaStyle.Colors.themeForegroundMinor - } + function selectSingleExperiment(experimentIndex) { + selectedExperimentIndices = [experimentIndex] + updateBackendWithSelectedExperiments() + } - EaComponents.TableViewButton { - anchors.verticalCenter: parent.verticalCenter - fontIcon: "microscope" - ToolTip.text: qsTr("Measured pattern color") - backgroundColor: "transparent" - borderColor: "transparent" - iconColor: EaStyle.Colors.chartForegroundsExtra[2] - } + function selectAllExperiments() { + var allIndices = [] + for (var i = 0; i < Globals.BackendWrapper.analysisExperimentsAvailable.length; i++) { + allIndices.push(i) + } + wasMultiSelected = true + selectedExperimentIndices = allIndices + updateBackendWithSelectedExperiments() + } + + function clearAllSelections() { + wasMultiSelected = false + selectedExperimentIndices = [] + // Don't send empty array to backend - let subsequent selection handle it + } + + function updateBackendWithSelectedExperiments() { + if (selectedExperimentIndices.length === 0) { + return + } - EaComponents.TableViewParameter { - enabled: false - text: typeof comboBox.model[comboBox.currentIndex] !== 'undefined' ? - comboBox.model[comboBox.currentIndex].name.value : - '' + // console.log(`📊 Updating backend with selection: [${selectedExperimentIndices.join(', ')}]`) + Globals.BackendWrapper.analysisSetSelectedExperimentIndices(selectedExperimentIndices) + + var primaryIndex = selectedExperimentIndices[0] + + if (selectedExperimentIndices.length === 1) { + if (wasMultiSelected) { + var tempIndex = (primaryIndex === 0) ? 1 : 0 + if (tempIndex < Globals.BackendWrapper.analysisExperimentsAvailable.length) { + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(tempIndex) } + wasMultiSelected = false } + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(primaryIndex) + } else { + wasMultiSelected = true + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(primaryIndex) + } + + var modelIndexFromExperiment = Globals.BackendWrapper.analysisModelForExperiment + Globals.BackendWrapper.sampleSetCurrentModelIndex(modelIndexFromExperiment) + } + + Component.onCompleted: { + if (Globals.BackendWrapper.analysisExperimentsAvailable.length > 0) { + selectSingleExperiment(0) } - // ComboBox (selected item) contentItem } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml index 8750dd3d..b66b7a9e 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml @@ -18,11 +18,18 @@ EaElements.GroupBox { //title: qsTr("Parameters") collapsible: false last: true + readonly property var backend: Globals.BackendWrapper Column { id: fittables property int selectedParamIndex: Globals.BackendWrapper.analysisCurrentParameterIndex + property bool bulkUpdatingSelection: false + property real fitColumnWidth: EaStyle.Sizes.fontPixelSize * 3.0 + property alias parameterSlider: slider onSelectedParamIndexChanged: { + if (bulkUpdatingSelection) { + return + } updateSliderLimits() updateSliderValue() } @@ -30,7 +37,7 @@ EaElements.GroupBox { property string selectedColor: EaStyle.Colors.themeForegroundHovered spacing: EaStyle.Sizes.fontPixelSize -/* + // Filter parameters widget Row { spacing: EaStyle.Sizes.fontPixelSize * 0.5 @@ -42,10 +49,14 @@ EaElements.GroupBox { width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 3 placeholderText: qsTr("Filter criteria") + text: Globals.BackendWrapper.analysisNameFilterCriteria onTextChanged: { - nameFilterSelector.currentIndex = nameFilterSelector.indexOfValue(text) - Globals.Proxies.main.fittables.nameFilterCriteria = text + Globals.BackendWrapper.analysisSetNameFilterCriteria(text) + const matchingIndex = nameFilterSelector.indexOfValue(text) + if (matchingIndex !== nameFilterSelector.currentIndex) { + nameFilterSelector.currentIndex = matchingIndex + } } } // Filter criteria @@ -62,25 +73,57 @@ EaElements.GroupBox { valueRole: "value" textRole: "text" - displayText: currentIndex === -1 ? - qsTr("Filter by name") : - currentText.replace(' ◦ ', '') + displayText: { + if (currentIndex === -1) { + return qsTr('Filter by name') + } + const entry = model && model[currentIndex] + if (!entry || !entry.text) { + return qsTr('Filter by name') + } + return entry.text.replace(' ◦ ', '') + } model: [ { value: "", text: `All names (${Globals.BackendWrapper.analysisModelParametersCount + Globals.BackendWrapper.analysisExperimentParametersCount})` }, { value: "model", text: `layer-group Model (${Globals.BackendWrapper.analysisModelParametersCount})` }, - { value: "cell", text: `cube Unit cell` }, - { value: "atom_site", text: `atom Atom sites` }, - { value: "fract", text: `map-marker-alt Atomic coordinates` }, - { value: "occupancy", text: `fill Atomic occupancies` }, - { value: "B_iso", text: `arrows-alt Atomic displacement` }, { value: "experiment", text: `microscope Experiment (${Globals.BackendWrapper.analysisExperimentParametersCount})` }, - { value: "resolution", text: `shapes Peak shape` }, - { value: "asymmetry", text: `balance-scale-left Peak asymmetry` }, { value: "background", text: `wave-square Background` } ] onActivated: filterCriteriaField.text = currentValue + + delegate: EaElements.MenuItem { + width: nameFilterSelector.width + height: EaStyle.Sizes.comboBoxHeight + textFormat: Text.RichText + elide: Text.ElideMiddle + text: { + const entry = nameFilterSelector.model && nameFilterSelector.model[index] + return entry && entry.text ? entry.text : '' + } + highlighted: nameFilterSelector.highlightedIndex === index + hoverEnabled: nameFilterSelector.hoverEnabled + } + + Component.onCompleted: { + const selected = Globals.BackendWrapper.analysisNameFilterCriteria + const index = indexOfValue(selected) + currentIndex = index >= 0 ? index : 0 + } + + Connections { + target: Globals.BackendWrapper + + function onAnalysisNameFilterCriteriaChanged() { + const selected = Globals.BackendWrapper.analysisNameFilterCriteria + const index = nameFilterSelector.indexOfValue(selected) + const targetIndex = index >= 0 ? index : -1 + if (nameFilterSelector.currentIndex !== targetIndex) { + nameFilterSelector.currentIndex = targetIndex + } + } + } } // Filter by name @@ -88,45 +131,82 @@ EaElements.GroupBox { EaElements.ComboBox { id: variabilityFilterSelector - property int lastIndex: -1 - topInset: 0 bottomInset: 0 width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 3 - displayText: currentIndex === -1 ? qsTr("Filter by variability") : currentText + displayText: { + if (currentIndex === -1) { + return qsTr('Filter by variability') + } + const entry = model && model[currentIndex] + if (!entry || !entry.text) { + return qsTr('Filter by variability') + } + return entry.text + } valueRole: "value" textRole: "text" model: [ - { value: 'all', text: `All parameters (${Globals.BackendWrapper.analysisFreeParamsCount + - Globals.BackendWrapper.analysisFixedParamsCount})` }, - { value: 'free', text: `Free parameters (${Globals.BackendWrapper.analysisFreeParamsCount})` }, - { value: 'fixed', text: `Fixed parameters (${Globals.BackendWrapper.analysisFixedParamsCount})` } + { value: 'all', text: `All parameters (${Globals.BackendWrapper.analysisFreeParametersCount + + Globals.BackendWrapper.analysisFixedParametersCount})` }, + { value: 'free', text: `Free parameters (${Globals.BackendWrapper.analysisFreeParametersCount})` }, + { value: 'fixed', text: `Fixed parameters (${Globals.BackendWrapper.analysisFixedParametersCount})` } ] - onModelChanged: currentIndex = lastIndex onActivated: { - lastIndex = currentIndex - Globals.Proxies.main.fittables.variabilityFilterCriteria = currentValue + Globals.BackendWrapper.analysisSetVariabilityFilterCriteria(currentValue) + } + + delegate: EaElements.MenuItem { + width: variabilityFilterSelector.width + height: EaStyle.Sizes.comboBoxHeight + textFormat: Text.RichText + elide: Text.ElideMiddle + text: { + const entry = variabilityFilterSelector.model && variabilityFilterSelector.model[index] + return entry && entry.text ? entry.text : '' + } + highlighted: variabilityFilterSelector.highlightedIndex === index + hoverEnabled: variabilityFilterSelector.hoverEnabled + } + + Component.onCompleted: { + const selected = Globals.BackendWrapper.analysisVariabilityFilterCriteria + const index = indexOfValue(selected) + currentIndex = index >= 0 ? index : 0 + } + + Connections { + target: Globals.BackendWrapper + + function onAnalysisVariabilityFilterCriteriaChanged() { + const selected = Globals.BackendWrapper.analysisVariabilityFilterCriteria + const index = variabilityFilterSelector.indexOfValue(selected) + const targetIndex = index >= 0 ? index : 0 + if (variabilityFilterSelector.currentIndex !== targetIndex) { + variabilityFilterSelector.currentIndex = targetIndex + } + } } } // Filter by variability } // Filter parameters widget -*/ + // Table EaComponents.TableView { id: tableView defaultInfoText: qsTr("No parameters found") - //maxRowCountShow: 7 + - // Math.trunc((applicationWindow.height - EaStyle.Sizes.appWindowMinimumHeight) / - // EaStyle.Sizes.tableRowHeight) - + maxRowCountShow: 8 + + Math.trunc((applicationWindow.height - EaStyle.Sizes.appWindowMinimumHeight) / + EaStyle.Sizes.tableRowHeight) + // Table model // We only use the length of the model object defined in backend logic and // directly access that model in every row using the TableView index property. @@ -143,14 +223,14 @@ EaElements.GroupBox { header: EaComponents.TableViewHeader { EaComponents.TableViewLabel { width: EaStyle.Sizes.fontPixelSize * 2.5 - //text: qsTr("No.") + text: qsTr("No.") } EaComponents.TableViewLabel { flexibleWidth: true horizontalAlignment: Text.AlignLeft color: EaStyle.Colors.themeForegroundMinor - text: qsTr("name") + text: qsTr("Name") } EaComponents.TableViewLabel { @@ -158,11 +238,11 @@ EaElements.GroupBox { width: EaStyle.Sizes.fontPixelSize * 4.5 horizontalAlignment: Text.AlignRight color: EaStyle.Colors.themeForegroundMinor - text: qsTr("value") + text: qsTr("Value") } EaComponents.TableViewLabel { - width: EaStyle.Sizes.fontPixelSize * 3.0 + width: EaStyle.Sizes.fontPixelSize * 4.0 horizontalAlignment: Text.AlignLeft //text: qsTr("units") } @@ -171,27 +251,27 @@ EaElements.GroupBox { width: valueLabel.width horizontalAlignment: Text.AlignRight color: EaStyle.Colors.themeForegroundMinor - text: qsTr("error") + text: qsTr("Error") } EaComponents.TableViewLabel { width: valueLabel.width horizontalAlignment: Text.AlignRight color: EaStyle.Colors.themeForegroundMinor - text: qsTr("min") + text: qsTr("Min") } EaComponents.TableViewLabel { width: valueLabel.width horizontalAlignment: Text.AlignRight color: EaStyle.Colors.themeForegroundMinor - text: qsTr("max") + text: qsTr("Max") } EaComponents.TableViewLabel { - width: EaStyle.Sizes.fontPixelSize * 3.0 + width: fittables.fitColumnWidth color: EaStyle.Colors.themeForegroundMinor - text: qsTr("vary") + text: qsTr("Fit") } } // Header row @@ -216,13 +296,18 @@ EaElements.GroupBox { EaComponents.TableViewLabel { width: EaStyle.Sizes.fontPixelSize * 5 text: Globals.BackendWrapper.analysisFitableParameters[index].name + color: (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? + Globals.BackendWrapper.analysisFitableParameters[index].independent : true) ? + EaStyle.Colors.themeForeground : EaStyle.Colors.themeForegroundDisabled ToolTip.text: textFormat === Text.PlainText ? text : '' } EaComponents.TableViewParameter { id: valueColumn + enabled: Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? + Globals.BackendWrapper.analysisFitableParameters[index].independent : true selected: index === Globals.BackendWrapper.analysisCurrentParameterIndex - text: EaLogic.Utils.toDefaultPrecision(Globals.BackendWrapper.analysisFitableParameters[index].value) + text: EaLogic.Utils.toMaxPrecision(Globals.BackendWrapper.analysisFitableParameters[index].value, 3) onEditingFinished: { focus = false console.debug("*** Editing (manual) 'value' field of fittable on Analysis page ***") @@ -233,15 +318,31 @@ EaElements.GroupBox { } EaComponents.TableViewLabel { - text: Globals.BackendWrapper.analysisFitableParameters[index].units !== 'dimensionless' ? Globals.BackendWrapper.analysisFitableParameters[index].units : "" + text: { + const unit = Globals.BackendWrapper.analysisFitableParameters[index].units + if (unit !== undefined && unit !== 'dimensionless') { + if (unit.endsWith('Å^2')) { + '10⁻⁶Å⁻²' + } else { + unit + } + } else { + "" + } + } color: EaStyle.Colors.themeForegroundMinor } EaComponents.TableViewLabel { - text: EaLogic.Utils.toDefaultPrecision(Globals.BackendWrapper.analysisFitableParameters[index].error) + text: formatError(Globals.BackendWrapper.analysisFitableParameters[index].error) + color: (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? + Globals.BackendWrapper.analysisFitableParameters[index].independent : true) ? + EaStyle.Colors.themeForeground : EaStyle.Colors.themeForegroundDisabled } EaComponents.TableViewParameter { + enabled: Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? + Globals.BackendWrapper.analysisFitableParameters[index].independent : true minored: true text: EaLogic.Utils.toDefaultPrecision(Globals.BackendWrapper.analysisFitableParameters[index].min).replace('Infinity', 'inf') onEditingFinished: { @@ -253,6 +354,8 @@ EaElements.GroupBox { } EaComponents.TableViewParameter { + enabled: Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? + Globals.BackendWrapper.analysisFitableParameters[index].independent : true minored: true text: EaLogic.Utils.toDefaultPrecision(Globals.BackendWrapper.analysisFitableParameters[index].max).replace('Infinity', 'inf') onEditingFinished: { @@ -265,11 +368,17 @@ EaElements.GroupBox { EaComponents.TableViewCheckBox { id: fitColumn - enabled: Globals.BackendWrapper.analysisExperimentsAvailable.length + enabled: Globals.BackendWrapper.analysisExperimentsAvailable.length && + (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? + Globals.BackendWrapper.analysisFitableParameters[index].independent : true) checked: Globals.BackendWrapper.analysisFitableParameters[index].fit onToggled: { console.debug("*** Editing 'fit' field of fittable on Analysis page ***") - Globals.BackendWrapper.analysisSetCurrentParameterFit(checkState) + // Ensure this row is selected before toggling the fit value + if (Globals.BackendWrapper.analysisCurrentParameterIndex !== index) { + Globals.BackendWrapper.analysisSetCurrentParameterIndex(index) + } + Globals.BackendWrapper.analysisSetCurrentParameterFit(checked) } } } @@ -277,6 +386,36 @@ EaElements.GroupBox { } // Table + Item { + id: fitAllContainer + visible: Globals.BackendWrapper.analysisFitableParameters.length + width: tableView.width + height: EaStyle.Sizes.tableRowHeight + + EaComponents.TableViewLabel { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: fitAllCheckBox.left + anchors.rightMargin: EaStyle.Sizes.fontPixelSize * 0.5 + text: qsTr("Select All") + horizontalAlignment: Text.AlignRight + elide: Text.ElideNone + } + + EaComponents.TableViewCheckBox { + id: fitAllCheckBox + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Math.max(tableView.width - fittables.fitColumnWidth, 0) + enabled: Globals.BackendWrapper.analysisExperimentsAvailable.length && + Globals.BackendWrapper.analysisFitableParameters.length + checked: allFittablesSelected() + onToggled: { + setAllFittablesFit(checked) + } + } + } + // Parameter change slider Row { visible: Globals.BackendWrapper.analysisFitableParameters.length @@ -287,8 +426,10 @@ EaElements.GroupBox { readOnly: true width: EaStyle.Sizes.fontPixelSize * 6 text: { - const value = Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].value - const error = Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].error + const par_index = Globals.BackendWrapper.analysisCurrentParameterIndex + const params = Globals.BackendWrapper.analysisFitableParameters + const value = params[par_index] !== undefined ? params[par_index].value : 0 + const error = params[par_index] !== undefined ? params[par_index].error : 0 return EaLogic.Utils.toDefaultPrecision(slider.from) } } @@ -296,7 +437,10 @@ EaElements.GroupBox { EaElements.Slider { id: slider - enabled: !Globals.BackendWrapper.analysisFittingRunning + enabled: !Globals.BackendWrapper.analysisFittingRunning && + Globals.BackendWrapper.analysisFitableParameters.length > 0 && + (Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].independent !== undefined ? + Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].independent : true) width: tableView.width - EaStyle.Sizes.fontPixelSize * 14 stepSize: (to - from) / 20 @@ -327,27 +471,104 @@ EaElements.GroupBox { } } } + // Logic function updateSliderValue() { - const value = Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].value - slider.value = EaLogic.Utils.toDefaultPrecision(value) + if (!backend.analysisFitableParameters.length) { + return + } + const currentIndex = backend.analysisCurrentParameterIndex + if (currentIndex < 0 || currentIndex >= backend.analysisFitableParameters.length) { + return + } + const value = backend.analysisFitableParameters[currentIndex].value + fittables.parameterSlider.value = EaLogic.Utils.toDefaultPrecision(value) } function updateSliderLimits() { - var from = Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].value * 0.9 - var to = Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].value * 1.1 + if (!backend.analysisFitableParameters.length) { + return + } + const currentIndex = backend.analysisCurrentParameterIndex + if (currentIndex < 0 || currentIndex >= backend.analysisFitableParameters.length) { + return + } + var from = backend.analysisFitableParameters[currentIndex].value * 0.9 + var to = backend.analysisFitableParameters[currentIndex].value * 1.1 if (from === 0 && to === 0) { to = 0.1 } - if (Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].max < to) { - to = Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].max + if (backend.analysisFitableParameters[currentIndex].max < to) { + to = backend.analysisFitableParameters[currentIndex].max + } + if (backend.analysisFitableParameters[currentIndex].min > from) { + from = backend.analysisFitableParameters[currentIndex].min } - if (Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].min > from) { - from = Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].min + fittables.parameterSlider.from = EaLogic.Utils.toDefaultPrecision(from) + fittables.parameterSlider.to = EaLogic.Utils.toDefaultPrecision(to) + } + + function formatError(value) { + if (value === undefined || value === 0 || isNaN(value)) return '' + var s = Number(value.toPrecision(2)).toString() + if (s.length <= 6) return s + return value.toExponential(1) + } + + function allFittablesSelected() { + const params = backend.analysisFitableParameters + if (!params || !params.length) { + return false + } + for (let i = 0; i < params.length; i++) { + const parameter = params[i] + const independent = parameter.independent !== undefined ? parameter.independent : true + if (!independent) { + continue + } + if (!parameter.fit) { + return false + } + } + return true + } + + function setAllFittablesFit(enable) { + const params = backend.analysisFitableParameters + if (!params || !params.length) { + return + } + const originalIndex = backend.analysisCurrentParameterIndex + var hasChanges = false + const targetFit = !!enable + fittables.bulkUpdatingSelection = true + try { + for (let i = 0; i < params.length; i++) { + const parameter = params[i] + const independent = parameter.independent !== undefined ? parameter.independent : true + if (!independent) { + continue + } + if (!!parameter.fit === targetFit) { + continue + } + backend.analysisSetCurrentParameterIndex(i) + backend.analysisSetCurrentParameterFit(targetFit) + hasChanges = true + } + } finally { + const paramsLength = params.length + if (paramsLength) { + const targetIndex = originalIndex >= 0 && originalIndex < paramsLength ? originalIndex : Math.min(Math.max(originalIndex, 0), paramsLength - 1) + backend.analysisSetCurrentParameterIndex(targetIndex) + } + fittables.bulkUpdatingSelection = false + } + if (hasChanges) { + updateSliderLimits() + updateSliderValue() } - slider.from = EaLogic.Utils.toDefaultPrecision(from) - slider.to = EaLogic.Utils.toDefaultPrecision(to) } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Layout.qml index befb86a8..4b2fb4ad 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Layout.qml @@ -14,14 +14,16 @@ import Gui.Globals as Globals EaComponents.SideBarColumn { - Groups.Experiments{} + Groups.Experiments{ + enabled: true + } // enabled: Globals.BackendWrapper.analysisIsFitFinished // } // EaElements.GroupBox { // collapsible: false // last: true -// + // Loader { source: 'Experiments.qml' } // } @@ -35,6 +37,7 @@ EaComponents.SideBarColumn { Loader { source: 'Fittables.qml' } } */ + Groups.Fitting{} /* EaElements.GroupBox { diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml index 9ea84974..74984f63 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Controls +import QtQuick.Layouts import EasyApp.Gui.Globals as EaGlobals import EasyApp.Gui.Style as EaStyle @@ -15,25 +16,44 @@ import Gui.Globals as Globals EaElements.Dialog { id: dialog - visible: Globals.BackendWrapper.analysisFittingStatus - title: qsTr("Fit status") + visible: Globals.BackendWrapper.analysisShowFitResultsDialog + title: Globals.BackendWrapper.analysisFitSuccess ? qsTr("Refinement Results") : qsTr("Refinement Failed") standardButtons: Dialog.Ok + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onAccepted: { + Globals.BackendWrapper.analysisSetShowFitResultsDialog(false) + } + + onClosed: { + Globals.BackendWrapper.analysisSetShowFitResultsDialog(false) + } Component.onCompleted: Globals.References.pages.analysis.sidebar.basic.popups.fitStatusDialogOkButton = okButtonRef() - EaElements.Label { - text: { - if ( Globals.BackendWrapper.analysisFittingStatus === 'Success') { - return 'Optimization finished successfully.' - } else if (Globals.BackendWrapper.analysisFittingStatus === 'Failure') { - return 'Optimization failed.' - } else if (Globals.BackendWrapper.analysisFittingStatus === 'Aborted') { - return 'Optimization aborted.' - } else if (Globals.BackendWrapper.analysisFittingStatus === 'No free params') { - return 'Nothing to vary. Allow some parameters to be free.' - } else { - return '' - } + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.Label { + text: "Success: " + Globals.BackendWrapper.analysisFitSuccess + } + + EaElements.Label { + visible: Globals.BackendWrapper.analysisFitSuccess + text: "Num. refined parameters: " + Globals.BackendWrapper.analysisFitNumRefinedParams + } + + EaElements.Label { + visible: Globals.BackendWrapper.analysisFitSuccess + text: "Chi2: " + Globals.BackendWrapper.analysisFitChi2.toFixed(4) + } + + EaElements.Label { + visible: !Globals.BackendWrapper.analysisFitSuccess && Globals.BackendWrapper.analysisFitErrorMessage !== "" + text: "Error: " + Globals.BackendWrapper.analysisFitErrorMessage + wrapMode: Text.WordWrap + width: Math.min(implicitWidth, EaStyle.Sizes.sideBarContentWidth * 1.5) + color: EaStyle.Colors.red } } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Layout.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Layout.qml index f56ede8a..7c6f1c87 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Layout.qml @@ -22,13 +22,13 @@ EaComponents.ContentPage { sideBar: EaComponents.SideBar { tabs: [ - EaElements.TabButton { text: qsTr('Basic controls') } -// EaElements.TabButton { text: qsTr('Advanced controls') } + EaElements.TabButton { text: qsTr('Basic controls') }, + EaElements.TabButton { text: qsTr('Advanced controls') } ] items: [ - Loader { source: 'Sidebar/Basic/Layout.qml' } - // Loader { source: 'Sidebar/Advanced/Layout.qml' } + Loader { source: 'Sidebar/Basic/Layout.qml' }, + Loader { source: 'Sidebar/Advanced/Layout.qml' } ] continueButton.text: qsTr('Continue') diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 105cddf3..070e809d 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -31,6 +31,180 @@ Rectangle { anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 useOpenGL: EaGlobals.Vars.useOpenGL + + // 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, false) + } + + // Multi-experiment support + property var multiExperimentSeries: [] + property bool isMultiExperimentMode: { + try { + return Globals.BackendWrapper.plottingIsMultiExperimentMode || false + } catch (e) { + return false + } + } + property bool useStaggeredPlotting: { + try { + return Globals.Variables.useStaggeredPlotting || false + } catch (e) { + return false + } + } + property double staggeringFactor: { + try { + return Globals.Variables.staggeringFactor !== undefined ? Globals.Variables.staggeringFactor : 0.5 + } catch (e) { + return 0.5 + } + } + + // Watch for changes in multi-experiment mode + // onIsMultiExperimentModeChanged: { + // // Don't update here - wait for experimentDataChanged signal + // // which fires after backend has prepared the data + // console.log(`Multi-experiment mode changed to: ${isMultiExperimentMode}`) + // } + + // Watch for changes in staggered plotting mode + onUseStaggeredPlottingChanged: { + // console.log(`ExperimentView detected staggered mode change: ${useStaggeredPlotting}`) + // console.log(`Multi-experiment mode: ${isMultiExperimentMode}, Series count: ${multiExperimentSeries.length}`) + if (isMultiExperimentMode && multiExperimentSeries.length > 1) { + // console.log(`Refreshing ${multiExperimentSeries.length} series with staggered mode: ${useStaggeredPlotting}`) + // Re-populate all series with new staggering setting + for (var i = 0; i < multiExperimentSeries.length; i++) { + populateExperimentSeries(multiExperimentSeries[i]) + } + // Adjust Y-axis to fit all staggered experiments + adjustAxisForStaggering() + } else { + console.log(` Skipping refresh - not in multi-experiment mode or insufficient series`) + } + } + + // Watch for changes in staggering factor + onStaggeringFactorChanged: { + // console.log(`ExperimentView detected staggering factor change: ${staggeringFactor.toFixed(2)}`) + if (useStaggeredPlotting && isMultiExperimentMode && multiExperimentSeries.length > 1) { + // console.log(`Refreshing ${multiExperimentSeries.length} series with new factor`) + // Re-populate all series with new staggering factor + for (var i = 0; i < multiExperimentSeries.length; i++) { + populateExperimentSeries(multiExperimentSeries[i]) + } + // Adjust Y-axis to fit all staggered experiments + adjustAxisForStaggering() + } + } + + // Additional watcher directly on Globals.Variables.staggeringFactor + Connections { + target: Globals.Variables + function onStaggeringFactorChanged() { + // console.log(`Direct watcher: Globals.Variables.staggeringFactor changed to ${Globals.Variables.staggeringFactor}`) + if (chartView.useStaggeredPlotting && chartView.isMultiExperimentMode && chartView.multiExperimentSeries.length > 1) { + // console.log(`Forcing refresh of ${chartView.multiExperimentSeries.length} series`) + for (var i = 0; i < chartView.multiExperimentSeries.length; i++) { + chartView.populateExperimentSeries(chartView.multiExperimentSeries[i]) + } + chartView.adjustAxisForStaggering() + } + } + } + + function adjustAxisForStaggering() { + if (!useStaggeredPlotting || !isMultiExperimentMode || multiExperimentSeries.length <= 1) { + return + } + + var allMinY = 1e10 + var allMaxY = -1e10 + + // Find the bounds of all staggered series + for (var exp = 0; exp < multiExperimentSeries.length; exp++) { + var series = multiExperimentSeries[exp].measuredSerie + for (var i = 0; i < series.count; i++) { + var point = series.at(i) + allMinY = Math.min(allMinY, point.y) + allMaxY = Math.max(allMaxY, point.y) + } + } + + // Add 10% padding and apply to Y-axis + var padding = (allMaxY - allMinY) * 0.1 + chartView.axisY.min = allMinY - padding + chartView.axisY.max = allMaxY + padding + + // console.log(`📏 Adjusted Y-axis for staggering: [${allMinY.toExponential(2)}, ${allMaxY.toExponential(2)}] with padding`) + } + + // Watch for changes in multi-experiment selection + Connections { + target: Globals.BackendWrapper.activeBackend ?? null + enabled: target !== null + function onMultiExperimentSelectionChanged() { + // Update series when selection changes + // The function will handle showing/hiding appropriate series + console.log("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("ExperimentView: Plot mode changed, refreshing chart") + Globals.BackendWrapper.plottingRefreshExperiment() + // Delay resetAxes to allow axis range properties to update first + experimentResetAxesTimer.start() + } + function onChartAxesResetRequested() { + // Reset axes when model is loaded (e.g., from ORSO file) + experimentResetAxesTimer.start() + } + } + + Timer { + id: experimentResetAxesTimer + interval: 75 + repeat: false + onTriggered: chartView.resetAxes() + } property double xRange: Globals.BackendWrapper.plottingExperimentMaxX - Globals.BackendWrapper.plottingExperimentMinX axisX.title: "q (Å⁻¹)" @@ -40,7 +214,7 @@ Rectangle { axisX.maxAfterReset: Globals.BackendWrapper.plottingExperimentMaxX + xRange * 0.01 property double yRange: Globals.BackendWrapper.plottingExperimentMaxY - Globals.BackendWrapper.plottingExperimentMinY - axisY.title: "Log10 R(q)" + axisY.title: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle axisY.min: Globals.BackendWrapper.plottingExperimentMinY - yRange * 0.01 axisY.max: Globals.BackendWrapper.plottingExperimentMaxY + yRange * 0.01 axisY.minAfterReset: Globals.BackendWrapper.plottingExperimentMinY - yRange * 0.01 @@ -48,6 +222,163 @@ Rectangle { calcSerie.onHovered: (point, state) => showMainTooltip(chartView, point, state) + // Multi-experiment series management + function updateMultiExperimentSeries() { + // console.log("Updating multi-experiment series...") + // console.log(` isMultiExperimentMode: ${isMultiExperimentMode}`) + + // Clear existing multi-experiment series + clearMultiExperimentSeries() + + if (!isMultiExperimentMode) { + // Show default series for single experiment + measured.visible = true + errorUpper.visible = true + errorLower.visible = true + return + } + + // Get experiment data list + var experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentDataList + // 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") + measured.visible = true + errorUpper.visible = true + errorLower.visible = true + return + } + + // Hide default series in multi-experiment mode (only after we have data) + measured.visible = false + errorUpper.visible = false + errorLower.visible = false + + // Create series for each experiment + for (var i = 0; i < experimentDataList.length; i++) { + var expData = experimentDataList[i] + 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.errorUpperSerie) { + chartView.removeSeries(seriesSet.errorUpperSerie) + } + if (seriesSet.errorLowerSerie) { + chartView.removeSeries(seriesSet.errorLowerSerie) + } + } + multiExperimentSeries = [] + } + + function createExperimentSeries(expIndex, expName, color) { + // console.log(` Creating series for experiment ${expIndex}: ${expName} (${color})`) + + // Create measured data series + var measuredSerie = chartView.createSeries(ChartView.SeriesTypeLine, + `${expName} - Data`, + chartView.axisX, chartView.axisY) + measuredSerie.color = color + measuredSerie.width = 2 + measuredSerie.capStyle = Qt.RoundCap + measuredSerie.useOpenGL = chartView.useOpenGL + + // Create error bound series (lighter colors) + var errorColor = Qt.darker(color, 1.3) + + var errorUpperSerie = chartView.createSeries(ChartView.SeriesTypeLine, + `${expName} - Error Upper`, + chartView.axisX, chartView.axisY) + errorUpperSerie.color = errorColor + errorUpperSerie.width = 1 + errorUpperSerie.style = Qt.DashLine + errorUpperSerie.useOpenGL = chartView.useOpenGL + + var errorLowerSerie = chartView.createSeries(ChartView.SeriesTypeLine, + `${expName} - Error Lower`, + chartView.axisX, chartView.axisY) + errorLowerSerie.color = errorColor + errorLowerSerie.width = 1 + errorLowerSerie.style = Qt.DashLine + errorLowerSerie.useOpenGL = chartView.useOpenGL + + // Store references + var seriesSet = { + measuredSerie: measuredSerie, + errorUpperSerie: errorUpperSerie, + errorLowerSerie: errorLowerSerie, + expIndex: expIndex, + expName: expName, + color: color + } + multiExperimentSeries.push(seriesSet) + + // Populate with data + populateExperimentSeries(seriesSet) + } + + function populateExperimentSeries(seriesSet) { + // Get data points from backend + var dataPoints = Globals.BackendWrapper.plottingGetExperimentDataPoints(seriesSet.expIndex) + + // Clear existing points + seriesSet.measuredSerie.clear() + seriesSet.errorUpperSerie.clear() + seriesSet.errorLowerSerie.clear() + + // Calculate staggering offset if enabled + var yOffset = 0 + if (useStaggeredPlotting && isMultiExperimentMode && multiExperimentSeries.length > 1) { + var experimentIndex = seriesSet.expIndex + var totalExperiments = multiExperimentSeries.length + + // Find the individual experiment's data range + var expMinY = 1e10 + var expMaxY = -1e10 + + for (var j = 0; j < dataPoints.length; j++) { + expMinY = Math.min(expMinY, dataPoints[j].y) + expMaxY = Math.max(expMaxY, dataPoints[j].y) + } + + var expDataRange = expMaxY - expMinY + + // Use staggering factor to control offset + // Factor ranges from 0 (no staggering) to 5.0 (maximum staggering) + // Each experiment gets offset proportional to the staggering factor + var offsetStep = expDataRange * 0.5 * staggeringFactor + yOffset = experimentIndex * offsetStep + + // Ensure we don't exceed reasonable bounds - limit total staggering to 2x original range + var maxTotalOffset = expDataRange * 5 + var currentTotalOffset = (totalExperiments - 1) * offsetStep + + if (currentTotalOffset > maxTotalOffset) { + // Rescale all offsets proportionally to fit within bounds + var scaleFactor = maxTotalOffset / currentTotalOffset + yOffset = experimentIndex * offsetStep * scaleFactor + } + + } + + // Add data points with potential offset + for (var i = 0; i < dataPoints.length; i++) { + var point = dataPoints[i] + seriesSet.measuredSerie.append(point.x, point.y + yOffset) + seriesSet.errorUpperSerie.append(point.x, point.errorUpper + yOffset) + seriesSet.errorLowerSerie.append(point.x, point.errorLower + yOffset) + } + } + // Tool buttons Row { id: toolButtons @@ -136,14 +467,63 @@ Rectangle { topPadding: EaStyle.Sizes.fontPixelSize * 0.5 bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 + // Single experiment legend EaElements.Label { + visible: !chartView.isMultiExperimentMode text: '━ I (Measured)' color: chartView.calcSerie.color } EaElements.Label { + visible: !chartView.isMultiExperimentMode text: '━ Error' color: chartView.measSerie.color } + + // 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: qsTr("- - - Error bounds") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeForegroundMinor + } + } } } // Legend @@ -167,6 +547,23 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measured) + + // Initialize multi-experiment support + // console.log("ExperimentView initialized - checking multi-experiment mode...") + updateMultiExperimentSeries() + + // Initialize reference lines + updateReferenceLines() + } + + // Update series when chart becomes visible + onVisibleChanged: { + if (visible && isMultiExperimentMode) { + updateMultiExperimentSeries() + } + if (visible) { + updateReferenceLines() + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml new file mode 100644 index 00000000..ccf27b4d --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements + +import Gui.Globals as Globals + +EaElements.GroupBox { + title: qsTr("Plot control") + collapsed: true + + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingPlotRQ4 + text: qsTr("Plot R(q)×q⁴") + ToolTip.text: qsTr("Checking this box will plot R(q) multiplied by q⁴") + onToggled: { + Globals.BackendWrapper.plottingTogglePlotRQ4() + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.Variables.logarithmicQAxis + text: qsTr("Logarithmic q-axis") + ToolTip.text: qsTr("Checking this box will make the q-axis logarithmic") + onToggled: { + Globals.Variables.logarithmicQAxis = checked + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingScaleShown + text: qsTr("Show scale line") + ToolTip.text: qsTr("Checking this box will show the scale reference line on the plot") + onToggled: { + Globals.BackendWrapper.plottingFlipScaleShown() + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingBkgShown + text: qsTr("Show background line") + ToolTip.text: qsTr("Checking this box will show the background reference line on the plot") + onToggled: { + Globals.BackendWrapper.plottingFlipBkgShown() + } + } + } +} + diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml new file mode 100644 index 00000000..600ca675 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Layout.qml @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick + +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Components as EaComponents + +import "./Groups" as Groups + + +EaComponents.SideBarColumn { + + Groups.PlotControl {} + +} diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalData.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalData.qml index a2df7c2b..fbbe9778 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalData.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalData.qml @@ -2,8 +2,8 @@ import QtQuick 2.14 import QtQuick.Controls 2.14 import QtQuick.Dialogs as Dialogs1 -import easyApp.Gui.Style as EaStyle -import easyApp.Gui.Elements as EaElements +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements import Gui.Globals as Globals @@ -11,6 +11,7 @@ EaElements.GroupBox { title: qsTr("Experimental data") collapsible: false enabled: Globals.Constants.proxy.fitter.isFitFinished + Row { spacing: EaStyle.Sizes.fontPixelSize diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml new file mode 100644 index 00000000..f6b58175 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml @@ -0,0 +1,433 @@ +import QtQuick 2.14 +import QtQuick.Controls 2.14 + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Components as EaComponents + +//import Gui.Globals 1.0 as ExGlobals +import Gui.Globals as Globals + +EaElements.GroupBox { + title: selectedExperimentIndices.length <= 1 ? + qsTr("Data Explorer") : + qsTr("Data Explorer (%1 selected)").arg(selectedExperimentIndices.length) + visible: true + collapsed: false + + // Property to track selected experiment indices for multi-selection + property var selectedExperimentIndices: [] + // Property to track if we were in multi-selection mode + property bool wasMultiSelected: false + + // Watch for changes in selection to automatically disable staggered mode + onSelectedExperimentIndicesChanged: { + if (selectedExperimentIndices.length <= 1 && Globals.Variables.useStaggeredPlotting) { + console.log(`🔄 Selection changed to single experiment - disabling staggered plotting`) + Globals.Variables.useStaggeredPlotting = false + } + } + + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + // Multi-selection controls + Row { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + visible: Globals.BackendWrapper.analysisExperimentsAvailable.length > 1 + + // Helper text for multi-selection + EaElements.Label { + text: qsTr("Ctrl+Click for multi-select") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.75 + color: EaStyle.Colors.themeForegroundMinor + anchors.verticalCenter: parent.verticalCenter + } + + // Select all button + EaElements.TabButton { + height: EaStyle.Sizes.fontPixelSize * 1.5 + width: height * 2 + borderColor: EaStyle.Colors.chartAxis + fontIcon: "check-double" + ToolTip.text: qsTr("Select all experiments") + onClicked: selectAllExperiments() + enabled: selectedExperimentIndices.length < Globals.BackendWrapper.analysisExperimentsAvailable.length + } + + // Clear selection button + EaElements.TabButton { + height: EaStyle.Sizes.fontPixelSize * 1.5 + width: height * 2 + borderColor: EaStyle.Colors.chartAxis + fontIcon: "times" + ToolTip.text: qsTr("Clear selection") + onClicked: { + clearAllSelections() + if (Globals.BackendWrapper.analysisExperimentsAvailable.length > 0) { + selectSingleExperiment(0) + } + } + enabled: selectedExperimentIndices.length > 1 + } + + // Status indicator + EaElements.Label { + text: selectedExperimentIndices.length > 1 ? + qsTr("Selected: %1").arg(selectedExperimentIndices.map(i => i + 1).join(", ")) : + "" + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeAccent + anchors.verticalCenter: parent.verticalCenter + visible: selectedExperimentIndices.length > 1 + } + } + + // Staggered plotting toggle + Row { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + visible: selectedExperimentIndices.length > 1 + + EaElements.CheckBox { + id: staggeredPlottingCheckbox + enabled: selectedExperimentIndices.length > 1 + checked: Globals.Variables.useStaggeredPlotting && selectedExperimentIndices.length > 1 + text: qsTr("Staggered view") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.75 + ToolTip.text: qsTr("Vertically offset experiment lines for easier comparison") + onCheckedChanged: { + if (selectedExperimentIndices.length > 1) { + Globals.Variables.useStaggeredPlotting = checked + console.log(`📊 Staggered plotting mode changed to: ${checked}`) + console.log(`🔄 Updating backend with multi-experiment selection`) + updateBackendWithSelectedExperiments() + } else { + console.log(`⚠️ Single experiment mode - staggering not applicable`) + } + } + } + + // Staggering distance slider + EaElements.Slider { + id: staggeringSlider + width: EaStyle.Sizes.fontPixelSize * 6 + anchors.verticalCenter: parent.verticalCenter + from: 0.0 + to: 5.0 + value: Globals.Variables.staggeringFactor !== undefined ? Globals.Variables.staggeringFactor : 0.5 + stepSize: 0.05 + enabled: staggeredPlottingCheckbox.checked && selectedExperimentIndices.length > 1 + ToolTip.text: "Adjust staggering distance (" + Number(value).toFixed(2) + ")" + ToolTip.visible: hovered + + onValueChanged: { + // Always update the global variable to trigger watchers + Globals.Variables.staggeringFactor = value + // console.log(`📏 Staggering factor changed to: ${value.toFixed(2)}`) + } + } + } + + Row { + spacing: EaStyle.Sizes.fontPixelSize + + EaComponents.TableView { + id: dataTable + defaultInfoText: qsTr("No Experiments Loaded") + model: Globals.BackendWrapper.analysisExperimentsAvailable.length + + // Watch for model changes and update selection accordingly + onModelChanged: { + if (model === 0) { + clearAllSelections() + } else { + // Remove any selected indices that are now out of range + var validSelection = [] + for (var i = 0; i < selectedExperimentIndices.length; i++) { + if (selectedExperimentIndices[i] < model) { + validSelection.push(selectedExperimentIndices[i]) + } + } + if (validSelection.length !== selectedExperimentIndices.length) { + selectedExperimentIndices = validSelection + if (validSelection.length === 0 && model > 0) { + selectSingleExperiment(0) + } else { + updateBackendWithSelectedExperiments() + } + } + } + } // Headers + header: EaComponents.TableViewHeader { + + EaComponents.TableViewLabel { + text: qsTr('No.') + width: EaStyle.Sizes.fontPixelSize * 2.5 + } + + EaComponents.TableViewLabel { + flexibleWidth: true + horizontalAlignment: Text.AlignLeft + text: qsTr('Name') + } + + EaComponents.TableViewLabel { + width: EaStyle.Sizes.fontPixelSize * 9.5 + horizontalAlignment: Text.AlignHCenter + text: "Model" + } + + // Placeholder for row color + EaComponents.TableViewLabel { + id: colorLab + text: "Color" + width: EaStyle.Sizes.fontPixelSize * 2.5 + } + + // Placeholder for row delete button + EaComponents.TableViewLabel { + text: "Del." + width: EaStyle.Sizes.tableRowHeight + } + } + + delegate: EaComponents.TableViewDelegate { + //property var dataModel: model + + // Property to track if this row is selected + property bool isSelected: { + for (var i = 0; i < selectedExperimentIndices.length; i++) { + if (selectedExperimentIndices[i] === index) { + return true + } + } + return false + } + + EaComponents.TableViewLabel { + id: noLabel + width: EaStyle.Sizes.fontPixelSize * 2.5 + text: index + 1 + + // Selection background overlay - placed as child to avoid layout interference + Rectangle { + visible: isSelected + anchors.fill: parent + color: EaStyle.Colors.themeForegroundHovered + opacity: 0.2 + z: -1 + + // Visual selection indicator bar + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 3 + height: parent.height * 0.8 + color: EaStyle.Colors.themeAccent + } + } + } + + EaComponents.TableViewTextInput { + horizontalAlignment: Text.AlignLeft + id: labelLabel + width: EaStyle.Sizes.fontPixelSize * 11 + text: index > -1 ? Globals.BackendWrapper.analysisExperimentsAvailable[index] : "" + onEditingFinished: Globals.BackendWrapper.analysisSetExperimentNameAtIndex(index, text) + } + + EaComponents.TableViewComboBox { + id: modelAccess + horizontalAlignment: Text.AlignLeft + width: EaStyle.Sizes.sideBarContentWidth - (noLabel.width + deleteRowColumn.width + colorLabel.width + labelLabel.width + 5 * EaStyle.Sizes.tableColumnSpacing) + model: Globals.BackendWrapper.sampleModelNames + onActivated: { + Globals.BackendWrapper.analysisSetModelOnExperiment(currentIndex) + } + Component.onCompleted: { + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(model.index) + Globals.BackendWrapper.analysisSetModelOnExperiment(model.index) + currentIndex = 0 + } + } + + EaComponents.TableViewLabel { + id: colorLabel + backgroundColor: Globals.BackendWrapper.sampleModels[modelAccess.currentIndex].color + } + + EaComponents.TableViewButton { + id: deleteRowColumn + fontIcon: "minus-circle" + ToolTip.text: qsTr("Remove this dataset") + onClicked: { + Globals.BackendWrapper.analysisRemoveExperiment(index) + } + } + mouseArea.onPressed: (mouse) => { + // Handle multi-selection with Ctrl key + if (mouse.modifiers & Qt.ControlModifier) { + // Multi-selection mode: toggle selection of this experiment + toggleExperimentSelection(index) + } else { + // Single selection mode: select only this experiment + selectSingleExperiment(index) + } + } + + } + // onCurrentIndexChanged: { + // Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(model.index) + // } + + } + } + } + + /* + * MULTI-EXPERIMENT SELECTION IMPLEMENTATION + * + * This QML implementation provides the UI for selecting multiple experiments + * and concatenating their data for plotting. To complete the functionality, + * the following backend methods need to be implemented: + * + * 1. Globals.BackendWrapper.analysisSetSelectedExperimentIndices(indices: list) + * - Store the list of selected experiment indices + * - Concatenate data from all selected experiments + * - Update plotting data to show combined datasets + * + * 2. Globals.BackendWrapper.analysisExperimentsSelectedCount (property) + * - Return the count of currently selected experiments + * - Used for UI feedback (legend, title, etc.) + * + * 3. Backend plotting concatenation logic: + * - Combine q-values, intensities, and errors from selected experiments + * - Handle potential overlapping q-ranges appropriately + * - Maintain proper error propagation for combined datasets + * - Update plot bounds (min/max X/Y) for concatenated data + * + * Current behavior: + * - Single selection works with existing backend + * - Multi-selection logs selected indices to console + * - Visual feedback works in UI (selection highlighting, counters) + */ + + // Functions to handle multi-selection + function toggleExperimentSelection(experimentIndex) { + var currentSelection = selectedExperimentIndices.slice() // Create a copy + var indexPos = currentSelection.indexOf(experimentIndex) + + if (indexPos !== -1) { + // Remove from selection + currentSelection.splice(indexPos, 1) + } else { + // Add to selection + currentSelection.push(experimentIndex) + } + + // Track if we now have multiple selections + if (currentSelection.length > 1) { + wasMultiSelected = true + } + + selectedExperimentIndices = currentSelection + updateBackendWithSelectedExperiments() + } + + function selectSingleExperiment(experimentIndex) { + // console.log("selectSingleExperiment called with index:", experimentIndex) + selectedExperimentIndices = [experimentIndex] + // console.log("Updated selectedExperimentIndices to:", selectedExperimentIndices) + updateBackendWithSelectedExperiments() + } + + function updateBackendWithSelectedExperiments() { + if (selectedExperimentIndices.length === 0) { + return + } + + // If only one experiment is selected, use the existing single-selection logic + if (selectedExperimentIndices.length === 1) { + var currentIndex = selectedExperimentIndices[0] + + // If we were in multi-selection mode and now switching to single selection, + // force a plot refresh by toggling the current index + if (wasMultiSelected) { + // console.log("Switching from multi-selection to single selection - forcing plot refresh") + // Force refresh by temporarily setting a different index and then back + var tempIndex = (currentIndex === 0) ? 1 : 0 + if (tempIndex < Globals.BackendWrapper.analysisExperimentsAvailable.length) { + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(tempIndex) + } + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(currentIndex) + wasMultiSelected = false + } else { + // Normal single selection + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(currentIndex) + } + } else { + // Mark that we're in multi-selection mode + wasMultiSelected = true + // For multiple experiments, call the new backend method + // console.log("Multi-experiment selection - checking backend method availability") + // console.log("Backend wrapper analysis available:", typeof Globals.BackendWrapper.analysis) + // console.log("analysisSetSelectedExperimentIndices available:", typeof Globals.BackendWrapper.analysisSetSelectedExperimentIndices) + + // Try multiple approaches to call the backend method + var methodCalled = false + + // Approach 1: Direct call to top-level method + if (typeof Globals.BackendWrapper.analysisSetSelectedExperimentIndices === 'function') { + // console.log("Approach 1: Calling analysisSetSelectedExperimentIndices with:", selectedExperimentIndices) + Globals.BackendWrapper.analysisSetSelectedExperimentIndices(selectedExperimentIndices) + methodCalled = true + } + + // Approach 2: Try through analysis object + if (!methodCalled && Globals.BackendWrapper.analysis && + typeof Globals.BackendWrapper.analysis.setSelectedExperimentIndices === 'function') { + // console.log("Approach 2: Calling through analysis object with:", selectedExperimentIndices) + Globals.BackendWrapper.analysis.setSelectedExperimentIndices(selectedExperimentIndices) + methodCalled = true + } + + if (methodCalled) { + console.log("Multi-experiment selection applied:", selectedExperimentIndices) + } else { + // Fallback: set the first selected experiment as current + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(selectedExperimentIndices[0]) + console.log("Multi-experiment selection - fallback to single selection") + console.log("Selected experiments:", selectedExperimentIndices) + // console.log("Available backend methods:", Object.keys(Globals.BackendWrapper)) + } + } + } + + function clearAllSelections() { + console.log("clearAllSelections called - clearing to empty array") + wasMultiSelected = false + selectedExperimentIndices = [] + // Notify backend that selection is cleared + if (typeof Globals.BackendWrapper.analysisSetSelectedExperimentIndices === 'function') { + // console.log("Calling backend with empty array to clear selection") + Globals.BackendWrapper.analysisSetSelectedExperimentIndices([]) + } + } + + function selectAllExperiments() { + var allIndices = [] + for (var i = 0; i < Globals.BackendWrapper.analysisExperimentsAvailable.length; i++) { + allIndices.push(i) + } + wasMultiSelected = true + selectedExperimentIndices = allIndices + updateBackendWithSelectedExperiments() + } + + // Initialize with first experiment selected by default + Component.onCompleted: { + if (Globals.BackendWrapper.analysisExperimentsAvailable.length > 0) { + selectSingleExperiment(0) + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/InstrumentParameters.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/InstrumentParameters.qml index 5b008260..12cc0932 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/InstrumentParameters.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/InstrumentParameters.qml @@ -1,9 +1,9 @@ import QtQuick 2.14 import QtQuick.Controls 2.14 -import easyApp.Gui.Style as EaStyle -import easyApp.Gui.Elements as EaElements -import easyApp.Gui.Components as EaComponents +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Components as EaComponents import Gui.Globals as Globals diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Layout.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Layout.qml index b30d6d99..6a4d92db 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Layout.qml @@ -1,13 +1,17 @@ -import QtQuick 2.14 -import QtQuick.Controls 2.14 +import QtQuick +import QtQuick.Controls -import easyApp.Gui.Components as EaComponents +import EasyApp.Gui.Components as EaComponents import Gui.Globals as Globals import "./Groups" as Groups EaComponents.SideBarColumn { + Groups.ExperimentalDataExplorer{ + enabled: Globals.BackendWrapper.analysisIsFitFinished + //enabled: true + } Groups.ExperimentalData{ enabled: Globals.BackendWrapper.analysisIsFitFinished } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Popups/OpenExperimentFile.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Popups/OpenExperimentFile.qml index 3a8b62b9..23c0b232 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Popups/OpenExperimentFile.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Popups/OpenExperimentFile.qml @@ -12,12 +12,11 @@ FileDialog{ id: openExperimentFileDialog - fileMode: FileDialog.OpenFile + fileMode: FileDialog.OpenFiles nameFilters: [ 'Experiment files (*.dat *.txt *.ort)'] onAccepted: { -// Globals.References.applicationWindow.appBarCentralTabs.analysisButton.enabled = true - Globals.BackendWrapper.experimentLoad(selectedFile) + Globals.BackendWrapper.experimentLoad(selectedFiles) } Component.onCompleted: { diff --git a/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Popups/OpenJsonFile.qml b/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Popups/OpenJsonFile.qml index 2a84b135..9f63d584 100644 --- a/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Popups/OpenJsonFile.qml +++ b/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Popups/OpenJsonFile.qml @@ -17,6 +17,9 @@ FileDialog{ onAccepted: { Globals.References.applicationWindow.appBarCentralTabs.sampleButton.enabled = true + Globals.References.applicationWindow.appBarCentralTabs.experimentButton.enabled = true + Globals.References.applicationWindow.appBarCentralTabs.analysisButton.enabled = true + Globals.References.applicationWindow.appBarCentralTabs.summaryButton.enabled = true Globals.BackendWrapper.projectLoad(selectedFile) } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Layout.qml index b8d7ee1b..9e1ecaef 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Layout.qml @@ -12,17 +12,12 @@ import Gui.Globals as Globals EaComponents.ContentPage { mainView: EaComponents.MainContent { tabs: [ - EaElements.TabButton { text: qsTr('Reflectivity') }, - EaElements.TabButton { text: qsTr('SLD') } + EaElements.TabButton { text: qsTr('Reflectivity') } ] items: [ Loader { - source: `MainContent/SampleView.qml` - onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) - }, - Loader { - source: `MainContent/SldView.qml` + source: `MainContent/CombinedView.qml` onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) } ] diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml new file mode 100644 index 00000000..3801d2e6 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -0,0 +1,486 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtCharts + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Globals as EaGlobals +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Charts as EaCharts + +import Gui as Gui +import Gui.Globals as Globals + + +Rectangle { + id: container + + color: EaStyle.Colors.chartBackground + + // Track model count changes to refresh charts + property int modelCount: Globals.BackendWrapper.sampleModels.length + + // Store dynamically created series + property var sampleSeries: [] + + SplitView { + anchors.fill: parent + orientation: Qt.Vertical + + // Sample Chart (2/3 height) + Rectangle { + id: sampleContainer + SplitView.fillHeight: true + SplitView.preferredHeight: parent.height * 0.67 + SplitView.minimumHeight: 100 + color: EaStyle.Colors.chartBackground + + ChartView { + id: sampleChartView + + 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.plottingSampleMaxX - Globals.BackendWrapper.plottingSampleMinX + + // Logarithmic axis control + property bool useLogQAxis: Globals.Variables.logarithmicQAxis + + ValueAxis { + id: sampleAxisX + visible: !sampleChartView.useLogQAxis + titleText: "q (Å⁻¹)" + // min/max set imperatively to avoid binding reset during zoom + property double minAfterReset: Globals.BackendWrapper.plottingSampleMinX - sampleChartView.xRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxX + sampleChartView.xRange * 0.01 + 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 + } + } + + LogValueAxis { + id: sampleAxisXLog + visible: sampleChartView.useLogQAxis + titleText: "q (Å⁻¹)" + // min/max set for log scale - ensure positive values + property double minAfterReset: Math.max(Globals.BackendWrapper.plottingSampleMinX, 1e-6) + property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxX * 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 + } + } + + property double yRange: Globals.BackendWrapper.plottingSampleMaxY - Globals.BackendWrapper.plottingSampleMinY + + ValueAxis { + id: sampleAxisY + titleText: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle + // min/max set imperatively to avoid binding reset during zoom + property double minAfterReset: Globals.BackendWrapper.plottingSampleMinY - sampleChartView.yRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxY + sampleChartView.yRange * 0.01 + 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() { + if (useLogQAxis) { + sampleAxisXLog.min = sampleAxisXLog.minAfterReset + sampleAxisXLog.max = sampleAxisXLog.maxAfterReset + } else { + sampleAxisX.min = sampleAxisX.minAfterReset + sampleAxisX.max = sampleAxisX.maxAfterReset + } + sampleAxisY.min = sampleAxisY.minAfterReset + sampleAxisY.max = sampleAxisY.maxAfterReset + } + + // Handle logarithmic axis changes + onUseLogQAxisChanged: { + Qt.callLater(container.recreateAllSeries) + Qt.callLater(resetAxes) + } + + // Tool buttons + Row { + id: sampleToolButtons + z: 1 // Keep buttons above MouseAreas + + x: sampleChartView.plotArea.x + sampleChartView.plotArea.width - width + y: sampleChartView.plotArea.y - height - EaStyle.Sizes.fontPixelSize + + spacing: 0.25 * EaStyle.Sizes.fontPixelSize + + EaElements.TabButton { + checked: Globals.Variables.showLegendOnSamplePage + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "align-left" + ToolTip.text: Globals.Variables.showLegendOnSamplePage ? + qsTr("Hide legend") : + qsTr("Show legend") + onClicked: Globals.Variables.showLegendOnSamplePage = checked + } + + EaElements.TabButton { + checked: sampleChartView.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: sampleChartView.allowHover = checked + } + + Item { height: 1; width: 0.5 * EaStyle.Sizes.fontPixelSize } // spacer + + EaElements.TabButton { + checked: !sampleChartView.allowZoom + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "arrows-alt" + ToolTip.text: qsTr("Enable pan") + onClicked: { + sampleChartView.allowZoom = !checked + sldChart.chartView.allowZoom = !checked + } + } + + EaElements.TabButton { + checked: sampleChartView.allowZoom + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "expand" + ToolTip.text: qsTr("Enable box zoom") + onClicked: { + sampleChartView.allowZoom = checked + sldChart.chartView.allowZoom = checked + } + } + + EaElements.TabButton { + checkable: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "backspace" + ToolTip.text: qsTr("Reset axes") + onClicked: { + sampleChartView.resetAxes() + sldChart.chartView.resetAxes() + } + } + } + + // Legend showing all models + Rectangle { + visible: Globals.Variables.showLegendOnSamplePage + + x: sampleChartView.plotArea.x + sampleChartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize + y: sampleChartView.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 + + Repeater { + model: container.modelCount + EaElements.Label { + text: '━ ' + Globals.BackendWrapper.sampleModels[index].label + color: Globals.BackendWrapper.sampleModels[index].color + } + } + } + } + + EaElements.ToolTip { + id: sampleDataToolTip + + arrowLength: 0 + textFormat: Text.RichText + } + + // Zoom rectangle + Rectangle { + id: sampleRecZoom + + property int xScaleZoom: 0 + property int yScaleZoom: 0 + + visible: false + transform: Scale { + origin.x: 0 + origin.y: 0 + xScale: sampleRecZoom.xScaleZoom + yScale: sampleRecZoom.yScaleZoom + } + border.color: EaStyle.Colors.appBorder + border.width: 1 + opacity: 0.9 + color: "transparent" + + Rectangle { + anchors.fill: parent + opacity: 0.5 + color: sampleRecZoom.border.color + } + } + + // Zoom with left mouse button + MouseArea { + id: sampleZoomMouseArea + + enabled: sampleChartView.allowZoom + anchors.fill: sampleChartView + acceptedButtons: Qt.LeftButton + onPressed: { + sampleRecZoom.x = mouseX + sampleRecZoom.y = mouseY + sampleRecZoom.visible = true + } + onMouseXChanged: { + if (mouseX > sampleRecZoom.x) { + sampleRecZoom.xScaleZoom = 1 + sampleRecZoom.width = Math.min(mouseX, sampleChartView.width) - sampleRecZoom.x + } else { + sampleRecZoom.xScaleZoom = -1 + sampleRecZoom.width = sampleRecZoom.x - Math.max(mouseX, 0) + } + } + onMouseYChanged: { + if (mouseY > sampleRecZoom.y) { + sampleRecZoom.yScaleZoom = 1 + sampleRecZoom.height = Math.min(mouseY, sampleChartView.height) - sampleRecZoom.y + } else { + sampleRecZoom.yScaleZoom = -1 + sampleRecZoom.height = sampleRecZoom.y - Math.max(mouseY, 0) + } + } + onReleased: { + const x = Math.min(sampleRecZoom.x, mouseX) - sampleChartView.anchors.leftMargin + const y = Math.min(sampleRecZoom.y, mouseY) - sampleChartView.anchors.topMargin + const width = sampleRecZoom.width + const height = sampleRecZoom.height + sampleChartView.zoomIn(Qt.rect(x, y, width, height)) + sampleRecZoom.visible = false + } + } + + // Pan with left mouse button + MouseArea { + property real pressedX + property real pressedY + property int threshold: 1 + + enabled: !sampleZoomMouseArea.enabled + anchors.fill: sampleChartView + acceptedButtons: Qt.LeftButton + onPressed: { + pressedX = mouseX + pressedY = mouseY + } + onMouseXChanged: Qt.callLater(update) + onMouseYChanged: Qt.callLater(update) + + function update() { + const dx = mouseX - pressedX + const dy = mouseY - pressedY + pressedX = mouseX + pressedY = mouseY + + if (dx > threshold) + sampleChartView.scrollLeft(dx) + else if (dx < -threshold) + sampleChartView.scrollRight(-dx) + if (dy > threshold) + sampleChartView.scrollUp(dy) + else if (dy < -threshold) + sampleChartView.scrollDown(-dy) + } + } + + // Reset axes with right mouse button + MouseArea { + anchors.fill: sampleChartView + acceptedButtons: Qt.RightButton + onClicked: sampleChartView.resetAxes() + } + + Component.onCompleted: { + Globals.References.pages.sample.mainContent.sampleView = sampleChartView + } + } + } + + // SLD Chart (1/3 height) + Gui.SldChart { + id: sldChart + + SplitView.fillHeight: true + SplitView.preferredHeight: parent.height * 0.33 + SplitView.minimumHeight: 80 + + showLegend: Globals.Variables.showLegendOnSamplePage + reverseZAxis: Globals.Variables.reverseSldZAxis + onShowLegendChanged: Globals.Variables.showLegendOnSamplePage = showLegend + + Component.onCompleted: { + Globals.References.pages.sample.mainContent.sldView = sldChart.chartView + } + } + } + + // Create series dynamically when model count changes + onModelCountChanged: { + Qt.callLater(recreateAllSeries) + } + + // Refresh all chart series when model data changes + Connections { + target: Globals.BackendWrapper + function onSamplePageDataChanged() { + refreshAllCharts() + } + function onSamplePageResetAxes() { + sampleCombinedResetAxesTimer.start() + sldCombinedResetAxesTimer.start() + } + function onPlotModeChanged() { + refreshAllCharts() + // Delay resetAxes to allow axis range properties to update first + sampleCombinedResetAxesTimer.start() + } + function onChartAxesResetRequested() { + // Reset axes when model is loaded (e.g., from ORSO file) + sampleCombinedResetAxesTimer.start() + } + } + + Timer { + id: sampleCombinedResetAxesTimer + interval: 75 + repeat: false + onTriggered: { + sampleChartView.resetAxes() + sldChart.chartView.resetAxes() + } + } + + Timer { + id: sldCombinedResetAxesTimer + interval: 75 + repeat: false + onTriggered: sldChart.chartView.resetAxes() + } + + Component.onCompleted: { + Qt.callLater(recreateAllSeries) + } + + // Recreate all series for all models + function recreateAllSeries() { + // Remove old sample series + for (let i = 0; i < sampleSeries.length; i++) { + if (sampleSeries[i]) { + sampleChartView.removeSeries(sampleSeries[i]) + } + } + sampleSeries = [] + + // Determine which x-axis to use for sample chart based on log setting + const sampleXAxisToUse = sampleChartView.useLogQAxis ? sampleAxisXLog : sampleAxisX + + // Create new series for each model + const models = Globals.BackendWrapper.sampleModels + for (let k = 0; k < models.length; k++) { + // Create sample series + const sampleLine = sampleChartView.createSeries(ChartView.SeriesTypeLine, models[k].label, sampleXAxisToUse, sampleAxisY) + sampleLine.color = models[k].color + sampleLine.width = 2 + sampleLine.useOpenGL = EaGlobals.Vars.useOpenGL + // Connect hovered signal for tooltip + sampleLine.hovered.connect((point, state) => showMainTooltip(sampleChartView, sampleDataToolTip, point, state)) + sampleSeries.push(sampleLine) + } + + // Populate data + refreshAllCharts() + } + + // Refresh data in all series + function refreshAllCharts() { + const models = Globals.BackendWrapper.sampleModels + + // 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) + } + } + } + } + + // Logic + function showMainTooltip(chart, tooltip, point, state) { + if (!chart.allowHover) { + return + } + const pos = chart.mapToPosition(Qt.point(point.x, point.y)) + tooltip.x = pos.x + tooltip.y = pos.y + tooltip.text = `

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

` + tooltip.parent = chart + tooltip.visible = state + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml index c8962b02..38c821cd 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml @@ -19,32 +19,111 @@ Rectangle { color: EaStyle.Colors.chartBackground - EaCharts.QtCharts1dMeasVsCalc { + // Track model count changes to refresh charts + property int modelCount: Globals.BackendWrapper.sampleModels.length + + // Store dynamically created series + property var sampleSeries: [] + + 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 - useOpenGL: EaGlobals.Vars.useOpenGL - property double xRange: Globals.BackendWrapper.plottingSampleMaxX - Globals.BackendWrapper.plottingSampleMinX - axisX.title: "q (Å⁻¹)" - axisX.min: Globals.BackendWrapper.plottingSampleMinX - xRange * 0.01 - axisX.max: Globals.BackendWrapper.plottingSampleMaxX + xRange * 0.01 - axisX.minAfterReset: Globals.BackendWrapper.plottingSampleMinX - xRange * 0.01 - axisX.maxAfterReset: Globals.BackendWrapper.plottingSampleMaxX + xRange * 0.01 + + // Logarithmic axis control + property bool useLogQAxis: Globals.Variables.logarithmicQAxis + + ValueAxis { + id: axisX + visible: !chartView.useLogQAxis + titleText: "q (Å⁻¹)" + // min/max set imperatively to avoid binding reset during zoom + property double minAfterReset: Globals.BackendWrapper.plottingSampleMinX - chartView.xRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxX + chartView.xRange * 0.01 + 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 + } + } + + LogValueAxis { + id: axisXLog + visible: chartView.useLogQAxis + titleText: "q (Å⁻¹)" + // min/max set for log scale - ensure positive values + property double minAfterReset: Math.max(Globals.BackendWrapper.plottingSampleMinX, 1e-6) + property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxX * 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 + } + } property double yRange: Globals.BackendWrapper.plottingSampleMaxY - Globals.BackendWrapper.plottingSampleMinY - axisY.title: "Log10 R(q)" - axisY.min: Globals.BackendWrapper.plottingSampleMinY - yRange * 0.01 - axisY.max: Globals.BackendWrapper.plottingSampleMaxY + yRange * 0.01 - axisY.minAfterReset: Globals.BackendWrapper.plottingSampleMinY - yRange * 0.01 - axisY.maxAfterReset: Globals.BackendWrapper.plottingSampleMaxY + yRange * 0.01 - calcSerie.onHovered: (point, state) => showMainTooltip(chartView, point, state) + ValueAxis { + id: axisY + titleText: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle + // min/max set imperatively to avoid binding reset during zoom + property double minAfterReset: Globals.BackendWrapper.plottingSampleMinY - chartView.yRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxY + chartView.yRange * 0.01 + 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() { + if (useLogQAxis) { + axisXLog.min = axisXLog.minAfterReset + axisXLog.max = axisXLog.maxAfterReset + } else { + axisX.min = axisX.minAfterReset + axisX.max = axisX.maxAfterReset + } + axisY.min = axisY.minAfterReset + axisY.max = axisY.maxAfterReset + } + + // Handle logarithmic axis changes + onUseLogQAxisChanged: { + Qt.callLater(recreateAllSeries) + Qt.callLater(resetAxes) + } // Tool buttons Row { id: toolButtons + z: 1 // Keep buttons above MouseAreas x: chartView.plotArea.x + chartView.plotArea.width - width y: chartView.plotArea.y - height - EaStyle.Sizes.fontPixelSize @@ -72,7 +151,7 @@ Rectangle { borderColor: EaStyle.Colors.chartAxis fontIcon: "comment-alt" ToolTip.text: qsTr("Show coordinates tooltip on hover") - onClicked: chartView.allowHover = !chartView.allowHover + onClicked: chartView.allowHover = checked } Item { height: 1; width: 0.5 * EaStyle.Sizes.fontPixelSize } // spacer @@ -85,7 +164,7 @@ Rectangle { borderColor: EaStyle.Colors.chartAxis fontIcon: "arrows-alt" ToolTip.text: qsTr("Enable pan") - onClicked: chartView.allowZoom = !chartView.allowZoom + onClicked: chartView.allowZoom = !checked } EaElements.TabButton { @@ -96,7 +175,7 @@ Rectangle { borderColor: EaStyle.Colors.chartAxis fontIcon: "expand" ToolTip.text: qsTr("Enable box zoom") - onClicked: chartView.allowZoom = !chartView.allowZoom + onClicked: chartView.allowZoom = checked } EaElements.TabButton { @@ -110,11 +189,10 @@ Rectangle { } } - // Tool buttons - // Legend + // Legend showing all models Rectangle { - visible: Globals.Variables.showLegendOnExperimentPage + visible: Globals.Variables.showLegendOnSamplePage x: chartView.plotArea.x + chartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize y: chartView.plotArea.y + EaStyle.Sizes.fontPixelSize @@ -130,13 +208,15 @@ Rectangle { topPadding: EaStyle.Sizes.fontPixelSize * 0.5 bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 - EaElements.Label { - text: '━ I (sample)' - color: chartView.calcSerie.color + Repeater { + model: container.modelCount + EaElements.Label { + text: '━ ' + Globals.BackendWrapper.sampleModels[index].label + color: Globals.BackendWrapper.sampleModels[index].color + } } } } - // Legend EaElements.ToolTip { id: dataToolTip @@ -145,15 +225,192 @@ Rectangle { textFormat: Text.RichText } - // Data is set in python backend (plotting_1d.py) + // Zoom rectangle + Rectangle { + id: recZoom + + property int xScaleZoom: 0 + property int yScaleZoom: 0 + + visible: false + transform: Scale { + origin.x: 0 + origin.y: 0 + xScale: recZoom.xScaleZoom + yScale: recZoom.yScaleZoom + } + border.color: EaStyle.Colors.appBorder + border.width: 1 + opacity: 0.9 + color: "transparent" + + Rectangle { + anchors.fill: parent + opacity: 0.5 + color: recZoom.border.color + } + } + + // Zoom with left mouse button + MouseArea { + id: zoomMouseArea + + enabled: chartView.allowZoom + anchors.fill: chartView + acceptedButtons: Qt.LeftButton + onPressed: { + recZoom.x = mouseX + recZoom.y = mouseY + recZoom.visible = true + } + onMouseXChanged: { + if (mouseX > recZoom.x) { + recZoom.xScaleZoom = 1 + recZoom.width = Math.min(mouseX, chartView.width) - recZoom.x + } else { + recZoom.xScaleZoom = -1 + recZoom.width = recZoom.x - Math.max(mouseX, 0) + } + } + onMouseYChanged: { + if (mouseY > recZoom.y) { + recZoom.yScaleZoom = 1 + recZoom.height = Math.min(mouseY, chartView.height) - recZoom.y + } else { + recZoom.yScaleZoom = -1 + recZoom.height = recZoom.y - Math.max(mouseY, 0) + } + } + onReleased: { + const x = Math.min(recZoom.x, mouseX) - chartView.anchors.leftMargin + const y = Math.min(recZoom.y, mouseY) - chartView.anchors.topMargin + const width = recZoom.width + const height = recZoom.height + chartView.zoomIn(Qt.rect(x, y, width, height)) + recZoom.visible = false + } + } + + // Pan with left mouse button + MouseArea { + property real pressedX + property real pressedY + property int threshold: 1 + + enabled: !zoomMouseArea.enabled + anchors.fill: chartView + acceptedButtons: Qt.LeftButton + onPressed: { + pressedX = mouseX + pressedY = mouseY + } + onMouseXChanged: Qt.callLater(update) + onMouseYChanged: Qt.callLater(update) + + function update() { + const dx = mouseX - pressedX + const dy = mouseY - pressedY + pressedX = mouseX + pressedY = mouseY + + if (dx > threshold) + chartView.scrollLeft(dx) + else if (dx < -threshold) + chartView.scrollRight(-dx) + if (dy > threshold) + chartView.scrollUp(dy) + else if (dy < -threshold) + chartView.scrollDown(-dy) + } + } + + // Reset axes with right mouse button + MouseArea { + anchors.fill: chartView + acceptedButtons: Qt.RightButton + onClicked: chartView.resetAxes() + } + Component.onCompleted: { Globals.References.pages.sample.mainContent.sampleView = chartView - Globals.BackendWrapper.plottingSetQtChartsSerieRef('samplePage', - 'sampleSerie', - chartView.calcSerie) - Globals.BackendWrapper.plottingRefreshSample() } + } + + // Create series dynamically when model count changes + onModelCountChanged: { + Qt.callLater(recreateAllSeries) + } + + // Refresh all chart series when data changes + Connections { + target: Globals.BackendWrapper + function onSamplePageDataChanged() { + refreshAllCharts() + } + function onSamplePageResetAxes() { + sampleResetAxesTimer.start() + } + function onPlotModeChanged() { + refreshAllCharts() + // Delay resetAxes to allow axis range properties to update first + sampleResetAxesTimer.start() + } + function onChartAxesResetRequested() { + // Reset axes when model is loaded (e.g., from ORSO file) + sampleResetAxesTimer.start() + } + } + + Timer { + id: sampleResetAxesTimer + interval: 75 + repeat: false + onTriggered: chartView.resetAxes() + } + Component.onCompleted: { + Qt.callLater(recreateAllSeries) + } + + function recreateAllSeries() { + // Remove old series + for (let i = 0; i < sampleSeries.length; i++) { + if (sampleSeries[i]) { + chartView.removeSeries(sampleSeries[i]) + } + } + sampleSeries = [] + + // Determine which x-axis to use based on log setting + const xAxisToUse = chartView.useLogQAxis ? axisXLog : axisX + + // 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, xAxisToUse, axisY) + line.color = models[k].color + line.width = 2 + line.useOpenGL = EaGlobals.Vars.useOpenGL + // Connect hovered signal for tooltip + line.hovered.connect((point, state) => showMainTooltip(chartView, point, state)) + sampleSeries.push(line) + } + + refreshAllCharts() + } + + function refreshAllCharts() { + const models = Globals.BackendWrapper.sampleModels + 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) + } + } + } } // Logic diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml index 6571850a..d8e0fe08 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml @@ -3,170 +3,21 @@ // © 2025 Contributors to the EasyReflectometry project import QtQuick -import QtQuick.Controls -import QtCharts - -import EasyApp.Gui.Style as EaStyle -import EasyApp.Gui.Globals as EaGlobals -import EasyApp.Gui.Elements as EaElements -import EasyApp.Gui.Charts as EaCharts +import Gui as Gui import Gui.Globals as Globals -Rectangle { - id: container - - color: EaStyle.Colors.chartBackground - - EaCharts.QtCharts1dMeasVsCalc { - id: chartView - - anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 - - useOpenGL: EaGlobals.Vars.useOpenGL - - property double xRange: Globals.BackendWrapper.plottingSldMaxX - Globals.BackendWrapper.plottingSldMinX - axisX.title: "z (Å)" - axisX.min: Globals.BackendWrapper.plottingSldMinX - xRange * 0.01 - axisX.max: Globals.BackendWrapper.plottingSldMaxX + xRange * 0.01 - axisX.minAfterReset: Globals.BackendWrapper.plottingSldMinX - xRange * 0.01 - axisX.maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + xRange * 0.01 - - property double yRange: Globals.BackendWrapper.plottingSldMaxY - Globals.BackendWrapper.plottingSldMinY - axisY.title: "SLD (10⁻⁶Å⁻²)" - axisY.min: Globals.BackendWrapper.plottingSldMinY - yRange * 0.01 - axisY.max: Globals.BackendWrapper.plottingSldMaxY + yRange * 0.01 - axisY.minAfterReset: Globals.BackendWrapper.plottingSldMinY - yRange * 0.01 - axisY.maxAfterReset: Globals.BackendWrapper.plottingSldMaxY + yRange * 0.01 - - calcSerie.onHovered: (point, state) => showMainTooltip(chartView, point, state) - - // 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.showLegendOnSamplePage - autoExclusive: false - height: EaStyle.Sizes.toolButtonHeight - width: EaStyle.Sizes.toolButtonHeight - borderColor: EaStyle.Colors.chartAxis - fontIcon: "align-left" - ToolTip.text: Globals.Variables.showLegendOnSamplePage ? - qsTr("Hide legend") : - qsTr("Show legend") - onClicked: Globals.Variables.showLegendOnSamplePage = 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 - } +Gui.SldChart { + id: sldChart - 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: "backspace" - ToolTip.text: qsTr("Reset axes") - onClicked: chartView.resetAxes() - } - - } - // Tool buttons - - // Legend - Rectangle { - visible: Globals.Variables.showLegendOnExperimentPage - - 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: '━ SLD' - color: chartView.calcSerie.color - } - } - } - // Legend - - EaElements.ToolTip { - id: dataToolTip - - arrowLength: 0 - textFormat: Text.RichText - } - - // Data is set in python backend (plotting_1d.py) - Component.onCompleted: { - Globals.References.pages.sample.mainContent.sldView = chartView - Globals.BackendWrapper.plottingSetQtChartsSerieRef('samplePage', - 'sldSerie', - chartView.calcSerie) - Globals.BackendWrapper.plottingRefreshSLD() - } - } + showLegend: Globals.Variables.showLegendOnSamplePage + reverseZAxis: Globals.Variables.reverseSldZAxis - // Logic + onShowLegendChanged: Globals.Variables.showLegendOnSamplePage = showLegend - 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 + Component.onCompleted: { + Globals.References.pages.sample.mainContent.sldView = sldChart.chartView } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml index 86e4771d..cb0b65d1 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml @@ -1,118 +1,391 @@ -import QtQuick 2.13 -import QtQuick.Controls 2.13 -import QtQml.XmlListModel - -import easyApp.Gui.Style as EaStyle -import easyApp.Gui.Elements as EaElements -import easyApp.Gui.Components as EaComponents -import easyApp.Gui.Logic as EaLogic +import QtQuick +import QtQuick.Controls +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Components as EaComponents import Gui.Globals as Globals EaElements.GroupBox { - title: qsTr("Sample constraints") + id: constraintsGroup + title: qsTr("Single constraints") enabled: true last: false + property bool expressionValid: false + property string validationMessage: "" + property string expressionPreview: "" + property string lastConstraintType: "" + property bool validationDirty: false + + function currentRelationValue() { + if (relationalOperator.currentIndex === -1 || typeof relationalOperator.currentValue === 'undefined') { + return '=' + } + return relationalOperator.currentValue + } + + function resetValidation() { + validationMessage = "" + expressionPreview = "" + lastConstraintType = "" + expressionValid = false + validationDirty = false + } + + function scheduleValidation() { + validationDirty = true + validationTimer.restart() + } + + function runValidation() { + if (!validationDirty) { + return + } + + if (dependentPar.currentIndex === -1) { + expressionValid = false + expressionPreview = "" + lastConstraintType = "" + validationMessage = qsTr("Select a dependent parameter.") + return + } + + const expr = expressionEditor.text ? expressionEditor.text.trim() : "" + if (expr.length === 0) { + expressionValid = false + expressionPreview = "" + lastConstraintType = "" + validationMessage = qsTr("Expression cannot be empty.") + return + } + + if (typeof Globals.BackendWrapper.sampleValidateConstraintExpression === 'function') { + const result = Globals.BackendWrapper.sampleValidateConstraintExpression( + dependentPar.currentIndex, + currentRelationValue(), + expressionEditor.text) + if (result && result.valid) { + expressionValid = true + validationMessage = "" + expressionPreview = result.preview || expr + lastConstraintType = result.type || 'expression' + } else { + expressionValid = false + expressionPreview = "" + lastConstraintType = "" + validationMessage = result && result.message ? result.message : qsTr("Expression is not valid.") + // Debug: show available parameters when validation fails + if (result && result.message && result.message.includes("not defined")) { + console.log("Available constraint parameters:") + const params = Globals.BackendWrapper.sampleConstraintParametersMetadata + for (let i = 0; i < params.length; i++) { + console.log(` ${params[i].alias}: ${params[i].displayName}`) + } + } + } + } else { + expressionValid = true + validationMessage = "" + expressionPreview = expr + lastConstraintType = 'expression' + } + } + + function insertAlias(aliasText) { + if (!aliasText || aliasText.length === 0) { + return + } + + const position = expressionEditor.cursorPosition + const sourceText = expressionEditor.text + const before = sourceText.slice(0, position) + const after = sourceText.slice(position) + const needsLeadingSpace = before.length > 0 && !before.slice(-1).match(/[\s(*/+-]/) + const prefix = needsLeadingSpace ? before + ' ' : before + const needsTrailingSpace = after.length > 0 && !after.slice(0, 1).match(/[\s)*/+-]/) + const suffix = needsTrailingSpace ? ' ' + after : after + expressionEditor.text = prefix + aliasText + suffix + expressionEditor.cursorPosition = prefix.length + aliasText.length + scheduleValidation() + } + + function resetForm() { + validationTimer.stop() + dependentPar.currentIndex = -1 + relationalOperator.currentIndex = relationalOperator.model && relationalOperator.model.length > 0 ? 0 : -1 + expressionEditor.text = "" + parameterInsert.currentIndex = -1 + resetValidation() + } + + Timer { + id: validationTimer + interval: 320 + repeat: false + onTriggered: constraintsGroup.runValidation() + } + Column { - spacing: EaStyle.Sizes.fontPixelSize * 0.5 - Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.75 + width: parent ? parent.width : undefined + + EaElements.Label { + width: parent.width + text: qsTr("Create numeric or symbolic relationships between parameters.") + wrapMode: Text.Wrap + color: EaStyle.Colors.themeForegroundMinor + } - EaElements.Label { - enabled: false - text: qsTr("Numeric or Parameter-Parameter constraint") + Row { + id: parameterRow + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + width: parent.width + + EaElements.ComboBox { + id: dependentPar + width: Math.max(0, parameterRow.width - relationalOperator.width - parameterRow.spacing) + currentIndex: -1 + displayText: currentIndex === -1 ? qsTr("Select dependent parameter") : currentText + model: Globals.BackendWrapper.sampleDepParameterNames + onCurrentIndexChanged: constraintsGroup.scheduleValidation() } - Grid { - columns: 4 - columnSpacing: EaStyle.Sizes.fontPixelSize * 0.5 - rowSpacing: EaStyle.Sizes.fontPixelSize * 0.5 - verticalItemAlignment: Grid.AlignVCenter - - EaElements.ComboBox { - id: dependentPar - width: 359 - currentIndex: -1 - displayText: currentIndex === -1 ? "Select dependent parameter" : currentText - model: Globals.BackendWrapper.sampleParameterNames - onCurrentIndexChanged: { - independentPar.model = Globals.BackendWrapper.sampleParameterNames - independentPar.model[currentIndex] = 'Dependent parameter' - independentPar.currentIndex = -1 + EaElements.ComboBox { + id: relationalOperator + width: EaStyle.Sizes.fontPixelSize * 4 + valueRole: "value" + textRole: "text" + displayText: currentIndex === -1 ? qsTr("=") : currentText + model: Globals.BackendWrapper.sampleRelationOperators + onCurrentIndexChanged: constraintsGroup.scheduleValidation() + Component.onCompleted: { + if (model && model.length > 0) { + currentIndex = 0 } } + } + } + + EaElements.TextArea { + id: expressionEditor + width: parent.width + placeholderText: qsTr("Enter expression, e.g. np.sqrt(1 / sld_ni) + 4") + wrapMode: TextEdit.WrapAnywhere + selectByMouse: true + onTextChanged: constraintsGroup.scheduleValidation() + } + + EaElements.ComboBox { + id: parameterInsert + width: parent.width + valueRole: "alias" + textRole: "displayName" + displayText: { + if (currentIndex === -1) { + return qsTr("Insert parameter alias…") + } + const entry = model && model[currentIndex] + if (!entry) { + return qsTr("Insert parameter alias…") + } + const alias = entry.alias || "" + const name = entry.displayName || alias + return alias ? name + " (" + alias + ")" : name + } + model: Globals.BackendWrapper.sampleConstraintParametersMetadata + onActivated: { + constraintsGroup.insertAlias(currentValue) + Qt.callLater(() => parameterInsert.currentIndex = -1) + } - EaElements.ComboBox { - id: relationalOperator - width: 47 - currentIndex: 0 - font.family: EaStyle.Fonts.iconsFamily - model: Globals.BackendWrapper.sampleRelationOperators + delegate: EaElements.MenuItem { + width: parameterInsert.width + height: EaStyle.Sizes.comboBoxHeight + text: { + const entry = parameterInsert.model[index] + if (!entry) return "" + const alias = entry.alias || "" + const name = entry.displayName || alias + return alias ? name + " (" + alias + ")" : name } + highlighted: parameterInsert.highlightedIndex === index + hoverEnabled: parameterInsert.hoverEnabled + } + } - Item { height: 1; width: 1 } - Item { height: 1; width: 1 } - - EaElements.ComboBox { - id: independentPar - width: dependentPar.width - currentIndex: -1 - displayText: currentIndex === -1 ? "Numeric constrain or select independent parameter" : currentText - model: Globals.BackendWrapper.sampleParameterNames - onCurrentIndexChanged: { - dependentPar.model = Globals.BackendWrapper.sampleParameterNames - if (currentIndex === -1){ - displayText: "Numeric constrain or select independent parameter" - arithmeticOperator.model = Globals.BackendWrapper.sampleArithmicOperators.slice(0,1) // no arithmetic operators + EaElements.Label { + id: previewLabel + width: parent.width + visible: constraintsGroup.expressionValid && constraintsGroup.expressionPreview.length > 0 + text: qsTr("Preview: %1 %2").arg(constraintsGroup.currentRelationValue()).arg(constraintsGroup.expressionPreview) + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.Wrap + } + + EaElements.Label { + id: validationLabel + width: parent.width + visible: !constraintsGroup.expressionValid && constraintsGroup.validationDirty && constraintsGroup.validationMessage.length > 0 + text: constraintsGroup.validationMessage + color: EaStyle.Colors.themeAccent + wrapMode: Text.Wrap + } + + EaElements.SideBarButton { + id: addConstraintButton + wide: true + fontIcon: "plus-circle" + text: qsTr("Add constraint") + enabled: constraintsGroup.expressionValid && dependentPar.currentIndex !== -1 + onClicked: { + if (typeof Globals.BackendWrapper.sampleAddConstraint !== 'function') { + return + } + const result = Globals.BackendWrapper.sampleAddConstraint( + dependentPar.currentIndex, + constraintsGroup.currentRelationValue(), + expressionEditor.text) + if (!result || !result.success) { + constraintsGroup.expressionValid = false + constraintsGroup.validationDirty = true + constraintsGroup.validationMessage = result && result.message ? result.message : qsTr("Constraint could not be created.") + } else { + constraintsGroup.resetForm() + } + } + } + + // Constraints table to display existing constraints + Item { + height: EaStyle.Sizes.fontPixelSize * 0.5 + width: 1 + } + + EaElements.Label { + enabled: true + text: qsTr("Active Constraints") + } + + Item { + id: constraintTableContainer + width: parent.width + height: Math.min(200, Math.max(60, constraintsTable.height)) + + EaComponents.TableView { + id: constraintsTable + width: parent.width + height: Math.min(200, Math.max(60, Math.max(constraintsTable.contentHeight, constraintsTable.implicitHeight))) + + defaultInfoText: qsTr("No Active Constraints") + + // Table model - use backend data directly like other tables + model: Globals.BackendWrapper.sampleConstraintsList.length + + // Header row + header: EaComponents.TableViewHeader { + + EaComponents.TableViewLabel { + width: EaStyle.Sizes.fontPixelSize * 2.5 + text: qsTr("No.") + } + + EaComponents.TableViewLabel { + id: dependentNameHeaderColumn + width: EaStyle.Sizes.fontPixelSize * 12 + horizontalAlignment: Text.AlignHCenter + text: qsTr("Parameter") + } + + EaComponents.TableViewLabel { + width: EaStyle.Sizes.fontPixelSize * 19 + horizontalAlignment: Text.AlignHCenter + text: qsTr("Expression") + } + + // Placeholder for row delete button + EaComponents.TableViewLabel { + width: EaStyle.Sizes.tableRowHeight + } + } + + // Table rows + delegate: EaComponents.TableViewDelegate { + + EaComponents.TableViewLabel { + id: numberColumn + width: EaStyle.Sizes.fontPixelSize * 2.5 + text: index + 1 + color: EaStyle.Colors.themeForegroundMinor + } + + EaComponents.TableViewLabel { + id: dependentNameColumn + width: EaStyle.Sizes.fontPixelSize * 12 + horizontalAlignment: Text.AlignLeft + text: { + const constraint = Globals.BackendWrapper.sampleConstraintsList[index] + return constraint ? constraint.dependentName : "" } - else{ - arithmeticOperator.model = Globals.BackendWrapper.sampleArithmicOperators.slice(1) // allow all arithmetic operators - dependentPar.model[currentIndex] = 'Independent parameter' - //arithmeticOperator.currentIndex = 0 + elide: Text.ElideRight + } + + EaComponents.TableViewLabel { + id: expressionColumn + width: EaStyle.Sizes.fontPixelSize * 15 + horizontalAlignment: Text.AlignLeft + text: { + const constraint = Globals.BackendWrapper.sampleConstraintsList[index] + if (!constraint) { + return "" + } + const prefix = constraint.relation ? constraint.relation + ' ' : '' + return prefix + constraint.expression } + elide: Text.ElideRight + ToolTip.visible: hovered && Globals.BackendWrapper.sampleConstraintsList[index] && Globals.BackendWrapper.sampleConstraintsList[index].rawExpression + ToolTip.text: Globals.BackendWrapper.sampleConstraintsList[index] ? Globals.BackendWrapper.sampleConstraintsList[index].rawExpression : "" } - } - EaElements.ComboBox { - id: arithmeticOperator - width: relationalOperator.width - currentIndex: 0 - font.family: EaStyle.Fonts.iconsFamily - model: arithmeticOperator.model = Globals.BackendWrapper.sampleArithmicOperators.slice(0,1) - } + // Placeholder for delete button space + Item { + width: EaStyle.Sizes.tableRowHeight + } - EaElements.TextField { - id: value - width: 65 - horizontalAlignment: Text.AlignRight - text: "1.0000" + mouseArea.onPressed: { + if (constraintsTable.currentIndex !== index) { + constraintsTable.currentIndex = index + } + } } + } + + // Delete buttons - separate from table content but positioned at row level + Column { + id: deleteButtonsColumn + anchors.right: parent.right + anchors.top: constraintsTable.top + anchors.topMargin: constraintsTable.headerItem ? constraintsTable.headerItem.height : 0 + spacing: 0 - EaElements.SideBarButton { - id: addConstraint - enabled: ( - ( dependentPar.currentIndex !== -1 && independentPar.currentIndex !== -1 && independentPar.currentIndex !== dependentPar.currentIndex ) || - ( dependentPar.currentIndex !== -1 && independentPar.currentIndex === -1 ) - ) - width: 35 - fontIcon: "plus-circle" - ToolTip.text: qsTr("Add Numeric or Parameter-Parameter constraint") - onClicked: { - Globals.BackendWrapper.sampleAddConstraint( - dependentPar.currentIndex, - relationalOperator.currentText.replace("\uf52c", "=").replace("\uf531", ">").replace("\uf536", "<"), - value.text, - arithmeticOperator.currentText.replace("\uf00d", "*").replace("\uf529", "/").replace("\uf067", "+").replace("\uf068", "-"), - independentPar.currentIndex - ) - independentPar.currentIndex = -1 - dependentPar.currentIndex = -1 - relationalOperator.currentIndex = 0 - arithmeticOperator.currentIndex = 0 + Repeater { + model: Globals.BackendWrapper.sampleConstraintsList.length + + EaElements.SideBarButton { + width: 35 + height: EaStyle.Sizes.tableRowHeight + fontIcon: "minus-circle" + ToolTip.text: qsTr("Remove this constraint") + + onClicked: { + Globals.BackendWrapper.sampleRemoveConstraintByIndex(index) + } } } } } + + } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml new file mode 100644 index 00000000..aefcd81d --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml @@ -0,0 +1,240 @@ +import QtQuick +import QtQuick.Controls +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Components as EaComponents + +import Gui.Globals as Globals + +EaElements.GroupBox { + id: modelConstraintsGroup + title: qsTr("Model constraints") + enabled: true + last: true + + property var selectedModelIndices: [] + property int selectedModelsCount: selectedModelIndices.length + + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.75 + width: parent ? parent.width : undefined + + EaElements.Label { + width: parent.width + text: qsTr("Select models to constrain all their matching parameters.") + wrapMode: Text.Wrap + color: EaStyle.Colors.themeForegroundMinor + } + + // Models table + Item { + id: modelTableContainer + width: parent.width + height: Math.min(200, Math.max(60, modelsTable.height)) + + EaComponents.TableView { + id: modelsTable + width: parent.width + height: Math.min(200, Math.max(60, Math.max(modelsTable.contentHeight, modelsTable.implicitHeight))) + enabled: Globals.BackendWrapper.sampleModelNames && Globals.BackendWrapper.sampleModelNames.length > 1 + + defaultInfoText: qsTr("No Models Available") + + // Table model - use backend data directly + model: Globals.BackendWrapper.sampleModelNames ? Globals.BackendWrapper.sampleModelNames.length : 0 + + // Header row + header: EaComponents.TableViewHeader { + + EaComponents.TableViewLabel { + width: EaStyle.Sizes.fontPixelSize * 3 + text: qsTr("Select") + } + + EaComponents.TableViewLabel { + id: modelNameHeaderColumn + width: EaStyle.Sizes.fontPixelSize * 30 + horizontalAlignment: Text.AlignLeft + text: qsTr("") + } + } + + // Table rows + delegate: EaComponents.TableViewDelegate { + + EaElements.CheckBox { + id: modelCheckBox + width: EaStyle.Sizes.fontPixelSize * 3 + checked: false + topPadding: 0 + bottomPadding: 0 + anchors.verticalCenter: parent.verticalCenter + onToggled: { + var newIndices = modelConstraintsGroup.selectedModelIndices.slice() // Copy the array + if (checked) { + newIndices.push(index) + } else { + const idx = newIndices.indexOf(index) + if (idx > -1) { + newIndices.splice(idx, 1) + } + } + modelConstraintsGroup.selectedModelIndices = newIndices // Reassign to trigger update + // console.debug("Model", index, "checked state:", checked, "Selected indices:", modelConstraintsGroup.selectedModelIndices) + } + } + + EaComponents.TableViewLabel { + id: modelNameColumn + width: EaStyle.Sizes.fontPixelSize * 30 + horizontalAlignment: Text.AlignLeft + text: { + const modelName = Globals.BackendWrapper.sampleModelNames[index] + return modelName ? modelName : "" + } + elide: Text.ElideRight + } + + mouseArea.onPressed: { + if (modelsTable.currentIndex !== index) { + modelsTable.currentIndex = index + } + } + } + } + } + + Item { + height: EaStyle.Sizes.fontPixelSize * 0.5 + width: 1 + } + + EaElements.SideBarButton { + id: constrainModelsButton + wide: true + fontIcon: "plus-circle" + text: qsTr("Constrain models parameters") + enabled: modelConstraintsGroup.selectedModelsCount > 1 + onClicked: { + // Call backend to constrain parameters + if (typeof Globals.BackendWrapper.sampleConstrainModelsParameters === 'function') { + Globals.BackendWrapper.sampleConstrainModelsParameters(modelConstraintsGroup.selectedModelIndices) + console.debug("Constrained models parameters for indices:", modelConstraintsGroup.selectedModelIndices) + } else { + console.debug("Backend method not available") + } + } + } + + // Model constraints table + Item { + height: EaStyle.Sizes.fontPixelSize * 0.5 + width: 1 + } + + EaElements.Label { + enabled: true + text: qsTr("Model Constraints") + } + + Item { + id: modelConstraintsTableContainer + width: parent.width + height: modelConstraintsTable.height + + EaComponents.TableView { + id: modelConstraintsTable + width: parent.width + maxRowCountShow: 1000 + + defaultInfoText: qsTr("No Model Constraints") + + // Table model - use backend data directly + model: Globals.BackendWrapper.sampleConstraintsList.length + + // Header row + header: EaComponents.TableViewHeader { + + EaComponents.TableViewLabel { + width: EaStyle.Sizes.fontPixelSize * 2.5 + text: qsTr("No.") + } + + EaComponents.TableViewLabel { + id: modelConstraintNameHeaderColumn + width: EaStyle.Sizes.fontPixelSize * 31 + horizontalAlignment: Text.AlignHCenter + text: qsTr("Constraint") + } + + // Placeholder for row delete button + EaComponents.TableViewLabel { + width: EaStyle.Sizes.tableRowHeight + } + } + + // Table rows + delegate: EaComponents.TableViewDelegate { + + EaComponents.TableViewLabel { + id: modelConstraintNumberColumn + width: EaStyle.Sizes.fontPixelSize * 2.5 + text: index + 1 + color: EaStyle.Colors.themeForegroundMinor + } + + EaComponents.TableViewLabel { + id: modelConstraintColumn + width: EaStyle.Sizes.fontPixelSize * 31 + horizontalAlignment: Text.AlignLeft + text: { + const constraint = Globals.BackendWrapper.sampleConstraintsList[index] + if (!constraint) { + return "" + } + const prefix = constraint.relation ? constraint.relation + ' ' : '' + return constraint.dependentName + ' ' + prefix + constraint.expression + } + elide: Text.ElideRight + } + + // Placeholder for delete button space + Item { + width: EaStyle.Sizes.tableRowHeight + } + + mouseArea.onPressed: { + if (modelConstraintsTable.currentIndex !== index) { + modelConstraintsTable.currentIndex = index + } + } + } + } + + // Delete buttons - separate from table content but positioned at row level + Column { + id: modelDeleteButtonsColumn + anchors.right: parent.right + anchors.top: modelConstraintsTable.top + anchors.topMargin: modelConstraintsTable.headerItem ? modelConstraintsTable.headerItem.height : 0 + spacing: 0 + + Repeater { + model: Globals.BackendWrapper.sampleConstraintsList.length + + EaElements.SideBarButton { + width: 35 + height: EaStyle.Sizes.tableRowHeight + fontIcon: "minus-circle" + ToolTip.text: qsTr("Remove this constraint") + + onClicked: { + Globals.BackendWrapper.sampleRemoveConstraintByIndex(index) + } + } + } + } + } + } +} + \ No newline at end of file diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml new file mode 100644 index 00000000..b448193c --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements + +import Gui.Globals as Globals + +EaElements.GroupBox { + title: qsTr("Plot control") + collapsed: true + + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingPlotRQ4 + text: qsTr("Plot R(q)×q⁴") + ToolTip.text: qsTr("Checking this box will plot R(q) multiplied by q⁴") + onToggled: { + Globals.BackendWrapper.plottingTogglePlotRQ4() + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.Variables.reverseSldZAxis + text: qsTr("Reverse SLD z-axis") + ToolTip.text: qsTr("Checking this box will reverse the z-axis of the SLD plot") + onToggled: { + Globals.Variables.reverseSldZAxis = checked + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.Variables.logarithmicQAxis + text: qsTr("Logarithmic q-axis") + ToolTip.text: qsTr("Checking this box will make the q-axis logarithmic on the sample plot") + onToggled: { + Globals.Variables.logarithmicQAxis = checked + } + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/QRange.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/QRange.qml index 0aede6fe..c42c9bac 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/QRange.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/QRange.qml @@ -1,9 +1,9 @@ import QtQuick 2.13 import QtQuick.Controls 2.13 -import easyApp.Gui.Style as EaStyle -import easyApp.Gui.Elements as EaElements -import easyApp.Gui.Components as EaComponents +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Components as EaComponents import Gui.Globals as Globals diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir new file mode 100644 index 00000000..002327b3 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir @@ -0,0 +1,6 @@ +module Groups + +Constraints 1.0 Constraints.qml +ModelConstraints 1.0 ModelConstraints.qml +PlotControl 1.0 PlotControl.qml +QRange 1.0 QRange.qml \ No newline at end of file diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml index 5824765a..2d32407e 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml @@ -3,7 +3,7 @@ import QtQuick.Controls 2.14 //import QtQml.XmlListModel //import easyApp.Gui.Style 1.0 as EaStyle -import easyApp.Gui.Components 1.0 as EaComponents +import EasyApp.Gui.Components 1.0 as EaComponents import Gui.Globals as Globals import "./Groups" as Groups @@ -12,9 +12,15 @@ EaComponents.SideBarColumn { Groups.QRange{ enabled: Globals.BackendWrapper.analysisIsFitFinished } + Groups.PlotControl{ + } Groups.Constraints{ enabled: Globals.BackendWrapper.analysisIsFitFinished } + Groups.ModelConstraints{ + enabled: Globals.BackendWrapper.analysisIsFitFinished + } + /* property int independentParCurrentIndex: 0 property int dependentParCurrentIndex: 0 diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml index 213ab528..8697204f 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml @@ -78,6 +78,7 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignHCenter enabled: Globals.BackendWrapper.sampleLayers[index].thickness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].thickness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].thickness).toFixed(2) + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerThickness(text) } @@ -85,6 +86,7 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignHCenter enabled: Globals.BackendWrapper.sampleLayers[index].roughness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].roughness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].roughness).toFixed(2) + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerRoughness(text) } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml index dd33ea8d..231e02f1 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml @@ -60,6 +60,7 @@ EaElements.GroupColumn { EaComponents.TableViewTextInput { horizontalAlignment: Text.AlignHCenter text: Globals.BackendWrapper.sampleLayers[index].formula + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerFormula(text) } @@ -67,6 +68,7 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignHCenter enabled: Globals.BackendWrapper.sampleLayers[index].thickness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].thickness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].thickness).toFixed(2) + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerThickness(text) } @@ -74,12 +76,14 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignHCenter enabled: Globals.BackendWrapper.sampleLayers[index].roughness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].roughness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].roughness).toFixed(2) + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerRoughness(text) } EaComponents.TableViewTextInput { horizontalAlignment: Text.AlignHCenter text: (isNaN(Globals.BackendWrapper.sampleLayers[index].solvation)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].solvation).toFixed(2) + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerSolvation(text) } @@ -87,6 +91,7 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignHCenter enabled: Globals.BackendWrapper.sampleLayers[index].apm_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].apm)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].apm).toFixed(2) + onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerAPM(text) } @@ -108,7 +113,7 @@ EaElements.GroupColumn { } } mouseArea.onPressed: { - if (Globals.BackendWrapper.samplCurrentLayerIndex !== index) { + if (Globals.BackendWrapper.sampleCurrentLayerIndex !== index) { Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml new file mode 100644 index 00000000..04235b1f --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml @@ -0,0 +1,61 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements + +import Gui.Globals as Globals + +EaElements.GroupBox { + title: qsTr("Load a sample") + collapsible: true + collapsed: false + + EaElements.GroupColumn { + EaElements.CheckBox { + id: appendCheckBox + text: qsTr("Append to existing models") + checked: true + width: EaStyle.Sizes.sideBarContentWidth + } + + EaElements.SideBarButton { + width: EaStyle.Sizes.sideBarContentWidth + fontIcon: "folder-open" + text: qsTr("Load file") + onClicked: fileDialog.open() + } + + FileDialog { + id: fileDialog + title: qsTr("Select a sample file") + nameFilters: [ "ORT files (*.ort)", "ORSO files (*.orso)", "All files (*.*)" ] + onAccepted: Globals.BackendWrapper.sampleFileLoad(selectedFiles[0], appendCheckBox.checked) + } + } + + // Warning dialog for sample load issues + EaElements.Dialog { + id: sampleLoadWarningDialog + title: qsTr("Sample Load Warning") + standardButtons: Dialog.Ok + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + property string warningMessage: "" + + EaElements.Label { + text: sampleLoadWarningDialog.warningMessage + wrapMode: Text.WordWrap + width: Math.min(implicitWidth, EaStyle.Sizes.sideBarContentWidth * 1.5) + } + } + + Connections { + target: Globals.BackendWrapper + function onSampleLoadWarning(message) { + sampleLoadWarningDialog.warningMessage = message + sampleLoadWarningDialog.open() + } + } +} \ No newline at end of file diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml index b00b3b60..dab516b2 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml @@ -67,12 +67,12 @@ EaElements.GroupBox { } EaComponents.TableViewTextInput { - text: Number(Globals.BackendWrapper.sampleMaterials[index].sld).toFixed(2) + text: Number(Globals.BackendWrapper.sampleMaterials[index].sld).toFixed(3) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentMaterialSld(text) } EaComponents.TableViewTextInput { - text: Number(Globals.BackendWrapper.sampleMaterials[index].isld).toFixed(2) + text: Number(Globals.BackendWrapper.sampleMaterials[index].isld).toFixed(3) onEditingFinished: Globals.BackendWrapper.sampleSetCurrentMaterialISld(text) } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml index 06abb823..4f74fe0f 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml @@ -66,7 +66,9 @@ EaElements.GroupBox { EaComponents.TableViewComboBox{ horizontalAlignment: Text.AlignLeft - model: ["Multi-layer", "Repeating Multi-layer", "Surfactant Layer"] + property var fullModel: ["Multi-layer", "Repeating Multi-layer", "Surfactant Layer"] + property var limitedModel: ["Multi-layer", "Repeating Multi-layer"] + model: index === 0 || index === assembliesView.model - 1 ? limitedModel : fullModel onActivated: { Globals.BackendWrapper.sampleSetCurrentAssemblyType(currentValue) } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelSelector.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelSelector.qml index 490e1774..20a7e792 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelSelector.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelSelector.qml @@ -87,7 +87,7 @@ EaElements.GroupBox { } EaElements.SideBarButton { - enabled: (modelView.currentIndex > 0) ? true : false //When item is selected + enabled: (modelView.currentIndex > -1) ? true : false //When item is selected width: (EaStyle.Sizes.sideBarContentWidth - (2 * (EaStyle.Sizes.tableRowHeight + EaStyle.Sizes.fontPixelSize)) - EaStyle.Sizes.fontPixelSize) / 2 fontIcon: "clone" text: qsTr("Duplicate model") diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/qmldir b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/qmldir index 1917a3db..133dd677 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/qmldir +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/qmldir @@ -2,6 +2,7 @@ module Groups AssemblyEditor AssemblyEditor.qml +LoadSample LoadSample.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 b1ba3525..7edf1a91 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml @@ -1,12 +1,15 @@ import QtQuick 2.14 import QtQuick.Controls 2.14 -import easyApp.Gui.Components 1.0 as EaComponents +import EasyApp.Gui.Components 1.0 as EaComponents import Gui.Globals as Globals import "./Groups" as Groups EaComponents.SideBarColumn { + Groups.LoadSample{ + enabled: Globals.BackendWrapper.analysisIsFitFinished + } Groups.MaterialEditor{ enabled: Globals.BackendWrapper.analysisIsFitFinished } diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/Export.qml b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/Export.qml index 839686e4..5fc4398d 100644 --- a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/Export.qml +++ b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/Export.qml @@ -1,4 +1,4 @@ -// 5SPDX-FileCopyrightText: 2025 EasyApp contributors +// SPDX-FileCopyrightText: 2025 EasyApp contributors // SPDX-License-Identifier: BSD-3-Clause // © 2025 Contributors to the EasyApp project @@ -16,6 +16,7 @@ import Gui.Globals as Globals Column { spacing: EaStyle.Sizes.fontPixelSize + property string _lastRequestedReportPath: '' // Name field + format selector Row { @@ -84,19 +85,40 @@ Column { fontIcon: 'download' text: qsTr('Save') onClicked: { + const outputPath = reportLocationField.text + '.' + formatField.currentValue.toLowerCase() + _lastRequestedReportPath = outputPath if (formatField.currentValue === 'HTML') { - Globals.BackendWrapper.summarySaveAsHtml() + Globals.BackendWrapper.summarySaveAsHtml(outputPath) } else if (formatField.currentValue === 'PDF') { - Globals.BackendWrapper.summarySaveAsPdf() + Globals.BackendWrapper.summarySaveAsPdf(outputPath) } } } + Connections { + target: Globals.BackendWrapper + function onSummaryExportingFinished(success, filePath) { + if (filePath !== _lastRequestedReportPath) { + return + } + reportSavedDialog.success = success + reportSavedDialog.filePath = filePath + reportSavedDialog.open() + } + } + // Save directory dialog FolderDialog { id: summaryParentDirDialog title: qsTr("Choose report parent directory") currentFolder: Globals.BackendWrapper.summaryFileUrl + onAccepted: reportLocationField.text = EaLogic.Utils.urlToLocalFile( + selectedFolder + '/' + nameField.text + ) + } + + SaveConfirmationDialog { + id: reportSavedDialog } } diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/ExportPlots.qml b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/ExportPlots.qml new file mode 100644 index 00000000..1ca5353a --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/ExportPlots.qml @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2025 EasyApp contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyApp project + +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Logic as EaLogic + +import Gui.Globals as Globals + + +Column { + spacing: EaStyle.Sizes.fontPixelSize + property string _lastRequestedPlotPath: '' + + // Open in matplotlib button + EaElements.SideBarButton { + id: showPlotButton + wide: true + fontIcon: 'chart-line' + text: qsTr('Open in matplotlib') + onClicked: { + if (typeof Globals.BackendWrapper.summaryShowPlot === 'function') { + Globals.BackendWrapper.summaryShowPlot( + parseFloat(widthField.text), + parseFloat(heightField.text) + ) + } + } + } + + // Name field + format selector + Row { + spacing: EaStyle.Sizes.fontPixelSize + + // Name field + EaElements.TextField { + id: plotNameField + width: savePlotButton.width - plotFormatField.width - parent.spacing + topInset: plotNameLabel.height + topPadding: topInset + padding + horizontalAlignment: TextInput.AlignLeft + placeholderText: qsTr('Enter figure file name here') + Component.onCompleted: text = Globals.BackendWrapper.summaryPlotFileName + onEditingFinished: Globals.BackendWrapper.summarySetPlotFileName(text) + EaElements.Label { + id: plotNameLabel + text: qsTr('Name') + } + } + + EaElements.ComboBox { + id: plotFormatField + topInset: plotFormatLabel.height + topPadding: topInset + padding + width: EaStyle.Sizes.fontPixelSize * 10 + model: Globals.BackendWrapper.summaryPlotExportFormats + EaElements.Label { + id: plotFormatLabel + text: qsTr('Format') + } + } + } + + // Location field (shows the full output path) + EaElements.TextField { + id: plotLocationField + width: savePlotButton.width + topInset: plotLocationLabel.height + topPadding: topInset + padding + rightPadding: plotChooseButton.width + horizontalAlignment: TextInput.AlignLeft + Component.onCompleted: text = Globals.BackendWrapper.summaryPlotFilePath + + '.' + plotFormatField.currentValue.toLowerCase() + EaElements.Label { + id: plotLocationLabel + text: qsTr('Location') + } + + EaElements.ToolButton { + id: plotChooseButton + anchors.right: parent.right + topPadding: parent.topPadding + showBackground: false + fontIcon: 'folder-open' + ToolTip.text: qsTr('Choose figure parent directory') + onClicked: plotParentDirDialog.open() + } + } + + // Width + Height fields + Row { + spacing: EaStyle.Sizes.fontPixelSize + + EaElements.TextField { + id: widthField + width: (savePlotButton.width - parent.spacing) / 2 + topInset: widthLabel.height + topPadding: topInset + padding + horizontalAlignment: TextInput.AlignLeft + text: '16' + EaElements.Label { + id: widthLabel + text: qsTr('Width (cm)') + } + } + + EaElements.TextField { + id: heightField + width: (savePlotButton.width - parent.spacing) / 2 + topInset: heightLabel.height + topPadding: topInset + padding + horizontalAlignment: TextInput.AlignLeft + text: '12' + EaElements.Label { + id: heightLabel + text: qsTr('Height (cm)') + } + } + } + + // Save plot button + EaElements.SideBarButton { + id: savePlotButton + wide: true + fontIcon: 'download' + text: qsTr('Save plot') + onClicked: { + if (typeof Globals.BackendWrapper.summarySavePlot === 'function') { + _lastRequestedPlotPath = plotLocationField.text + Globals.BackendWrapper.summarySavePlot( + plotLocationField.text, + parseFloat(widthField.text), + parseFloat(heightField.text) + ) + } + } + } + + Connections { + target: Globals.BackendWrapper + function onSummaryExportingFinished(success, filePath) { + if (filePath !== _lastRequestedPlotPath) { + return + } + plotSavedDialog.success = success + plotSavedDialog.filePath = filePath + plotSavedDialog.open() + } + } + + // Directory dialog + FolderDialog { + id: plotParentDirDialog + title: qsTr("Choose figure parent directory") + currentFolder: Globals.BackendWrapper.summaryPlotFileUrl + onAccepted: plotLocationField.text = EaLogic.Utils.urlToLocalFile( + selectedFolder + '/' + + plotNameField.text + '.' + + plotFormatField.currentValue.toLowerCase() + ) + } + + SaveConfirmationDialog { + id: plotSavedDialog + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/SaveConfirmationDialog.qml b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/SaveConfirmationDialog.qml new file mode 100644 index 00000000..60821c00 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/SaveConfirmationDialog.qml @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2025 EasyApp contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyApp project + +import QtQuick +import QtQuick.Controls + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements + + +EaElements.Dialog { + id: saveConfirmationDialog + + property bool success: false + property string filePath: 'undefined' + + visible: false + title: qsTr('Save confirmation') + + standardButtons: Dialog.Ok + + Row { + padding: EaStyle.Sizes.fontPixelSize + spacing: EaStyle.Sizes.fontPixelSize * 0.75 + + EaElements.Label { + anchors.verticalCenter: parent.verticalCenter + font.family: EaStyle.Fonts.iconsFamily + font.pixelSize: EaStyle.Sizes.fontPixelSize * 1.25 + text: saveConfirmationDialog.success ? 'check-circle' : 'minus-circle' + } + + EaElements.Label { + anchors.verticalCenter: parent.verticalCenter + text: saveConfirmationDialog.success + ? qsTr('File "%1" is successfully saved'.arg(saveConfirmationDialog.filePath)) + : qsTr('Failed to save file "%1"'.arg(saveConfirmationDialog.filePath)) + } + } +} \ No newline at end of file diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Layout.qml b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Layout.qml index b74d7bc6..785186ca 100644 --- a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Layout.qml @@ -1,4 +1,4 @@ -// 5SPDX-FileCopyrightText: 2025 EasyApp contributors +// SPDX-FileCopyrightText: 2025 EasyApp contributors // SPDX-License-Identifier: BSD-3-Clause // © 2025 Contributors to the EasyApp project @@ -21,4 +21,13 @@ EaComponents.SideBarColumn { Loader { source: 'Groups/Export.qml' } } + EaElements.GroupBox { + enabled: Globals.BackendWrapper.summaryCreated + title: qsTr('Export plots') + icon: 'chart-line' + collapsed: false + + Loader { source: 'Groups/ExportPlots.qml' } + } + } diff --git a/EasyReflectometryApp/Gui/PlotControlRefLines.qml b/EasyReflectometryApp/Gui/PlotControlRefLines.qml new file mode 100644 index 00000000..e1ee3eeb --- /dev/null +++ b/EasyReflectometryApp/Gui/PlotControlRefLines.qml @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements + +import Gui.Globals as Globals + +Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingPlotRQ4 + text: qsTr("Plot R(q)×q⁴") + ToolTip.text: qsTr("Checking this box will plot R(q) multiplied by q⁴") + onToggled: { + Globals.BackendWrapper.plottingTogglePlotRQ4() + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.Variables.logarithmicQAxis + text: qsTr("Logarithmic q-axis") + ToolTip.text: qsTr("Checking this box will make the q-axis logarithmic") + onToggled: { + Globals.Variables.logarithmicQAxis = checked + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingScaleShown + text: qsTr("Show scale line") + ToolTip.text: qsTr("Checking this box will show the scale reference line on the plot") + onToggled: { + Globals.BackendWrapper.plottingFlipScaleShown() + } + } + + EaElements.CheckBox { + topPadding: 0 + checked: Globals.BackendWrapper.plottingBkgShown + text: qsTr("Show background line") + ToolTip.text: qsTr("Checking this box will show the background reference line on the plot") + onToggled: { + Globals.BackendWrapper.plottingFlipBkgShown() + } + } +} diff --git a/EasyReflectometryApp/Gui/SldChart.qml b/EasyReflectometryApp/Gui/SldChart.qml new file mode 100644 index 00000000..908b28b3 --- /dev/null +++ b/EasyReflectometryApp/Gui/SldChart.qml @@ -0,0 +1,388 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtCharts + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Globals as EaGlobals +import EasyApp.Gui.Elements as EaElements + +import Gui.Globals as Globals + + +Rectangle { + id: root + + color: EaStyle.Colors.chartBackground + + // Whether to show the legend (caller binds to the right page variable) + property bool showLegend: false + + // Whether to show the z-axis reversed + property bool reverseZAxis: false + + // Expose the ChartView so callers can store a reference / call resetAxes + readonly property alias chartView: chartView + + // Track model count changes to refresh charts + property int modelCount: Globals.BackendWrapper.sampleModels.length + + // Store dynamically created series + property var sldSeries: [] + + 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.plottingSldMaxX - Globals.BackendWrapper.plottingSldMinX + + ValueAxis { + id: axisX + titleText: "z (Å)" + property double minAfterReset: Globals.BackendWrapper.plottingSldMinX - chartView.xRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + chartView.xRange * 0.01 + reverse: root.reverseZAxis + 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.plottingSldMaxY - Globals.BackendWrapper.plottingSldMinY + + ValueAxis { + id: axisY + titleText: "SLD (10⁻⁶Å⁻²)" + property double minAfterReset: Globals.BackendWrapper.plottingSldMinY - chartView.yRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxY + chartView.yRange * 0.01 + 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 + } + + // Tool buttons + Row { + id: toolButtons + 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 { + 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 = checked + } + + 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 = !checked + } + + 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 = checked + } + + EaElements.TabButton { + checkable: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "backspace" + ToolTip.text: qsTr("Reset axes") + onClicked: chartView.resetAxes() + } + } + + // Legend showing all models + 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 + + Repeater { + model: root.modelCount + EaElements.Label { + text: '━ SLD ' + Globals.BackendWrapper.sampleModels[index].label + color: Globals.BackendWrapper.sampleModels[index].color + } + } + } + } + + EaElements.ToolTip { + id: dataToolTip + arrowLength: 0 + textFormat: Text.RichText + } + + // Zoom rectangle + Rectangle { + id: recZoom + + property int xScaleZoom: 0 + property int yScaleZoom: 0 + + visible: false + transform: Scale { + origin.x: 0 + origin.y: 0 + xScale: recZoom.xScaleZoom + yScale: recZoom.yScaleZoom + } + border.color: EaStyle.Colors.appBorder + border.width: 1 + opacity: 0.9 + color: "transparent" + + Rectangle { + anchors.fill: parent + opacity: 0.5 + color: recZoom.border.color + } + } + + // Zoom with left mouse button + MouseArea { + id: zoomMouseArea + + enabled: chartView.allowZoom + anchors.fill: chartView + acceptedButtons: Qt.LeftButton + onPressed: { + recZoom.x = mouseX + recZoom.y = mouseY + recZoom.visible = true + } + onMouseXChanged: { + if (mouseX > recZoom.x) { + recZoom.xScaleZoom = 1 + recZoom.width = Math.min(mouseX, chartView.width) - recZoom.x + } else { + recZoom.xScaleZoom = -1 + recZoom.width = recZoom.x - Math.max(mouseX, 0) + } + } + onMouseYChanged: { + if (mouseY > recZoom.y) { + recZoom.yScaleZoom = 1 + recZoom.height = Math.min(mouseY, chartView.height) - recZoom.y + } else { + recZoom.yScaleZoom = -1 + recZoom.height = recZoom.y - Math.max(mouseY, 0) + } + } + onReleased: { + const x = Math.min(recZoom.x, mouseX) - chartView.anchors.leftMargin + const y = Math.min(recZoom.y, mouseY) - chartView.anchors.topMargin + const width = recZoom.width + const height = recZoom.height + chartView.zoomIn(Qt.rect(x, y, width, height)) + recZoom.visible = false + } + } + + // Pan with left mouse button + MouseArea { + property real pressedX + property real pressedY + property int threshold: 1 + + enabled: !zoomMouseArea.enabled + anchors.fill: chartView + acceptedButtons: Qt.LeftButton + onPressed: { + pressedX = mouseX + pressedY = mouseY + } + onMouseXChanged: Qt.callLater(update) + onMouseYChanged: Qt.callLater(update) + + function update() { + const dx = mouseX - pressedX + const dy = mouseY - pressedY + pressedX = mouseX + pressedY = mouseY + + if (dx > threshold) + chartView.scrollLeft(dx) + else if (dx < -threshold) + chartView.scrollRight(-dx) + if (dy > threshold) + chartView.scrollUp(dy) + else if (dy < -threshold) + chartView.scrollDown(-dy) + } + } + + // Reset axes with right mouse button + MouseArea { + anchors.fill: chartView + acceptedButtons: Qt.RightButton + onClicked: chartView.resetAxes() + } + } + + // Create series dynamically when model count changes + onModelCountChanged: { + Qt.callLater(recreateAllSeries) + } + + // Refresh all chart series when data changes + Connections { + target: Globals.BackendWrapper + function onSamplePageDataChanged() { + refreshAllCharts() + } + function onSamplePageResetAxes() { + resetAxesTimer.start() + } + function onPlotModeChanged() { + refreshAllCharts() + resetAxesTimer.start() + } + function onChartAxesResetRequested() { + resetAxesTimer.start() + } + } + + Timer { + id: resetAxesTimer + interval: 75 + repeat: false + onTriggered: chartView.resetAxes() + } + + Component.onCompleted: { + Qt.callLater(recreateAllSeries) + } + + function recreateAllSeries() { + // Remove old series + for (let i = 0; i < sldSeries.length; i++) { + if (sldSeries[i]) { + chartView.removeSeries(sldSeries[i]) + } + } + sldSeries = [] + + // 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) + line.color = models[k].color + line.width = 2 + line.useOpenGL = EaGlobals.Vars.useOpenGL + line.hovered.connect((point, state) => showMainTooltip(point, state)) + sldSeries.push(line) + } + + refreshAllCharts() + } + + 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) + } + } + } + } + + 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 = `

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

` + dataToolTip.parent = chartView + dataToolTip.visible = state + } +} diff --git a/EasyReflectometryApp/Gui/StatusBar.qml b/EasyReflectometryApp/Gui/StatusBar.qml index cf92b436..9afb2c96 100644 --- a/EasyReflectometryApp/Gui/StatusBar.qml +++ b/EasyReflectometryApp/Gui/StatusBar.qml @@ -53,4 +53,11 @@ EaElements.StatusBar { valueText: Globals.BackendWrapper.statusVariables ?? '' ToolTip.text: qsTr('Number of parameters: total, free and fixed') } + EaElements.StatusBarItem { + visible: Globals.BackendWrapper.analysisFitChi2 > 0 + keyIcon: 'chart-line' + keyText: qsTr('Chi²') + valueText: Globals.BackendWrapper.analysisFitChi2.toFixed(2) + ToolTip.text: qsTr('Goodness of fit (chi-squared)') + } } diff --git a/EasyReflectometryApp/Gui/qmldir b/EasyReflectometryApp/Gui/qmldir index d3151d58..fe68abae 100644 --- a/EasyReflectometryApp/Gui/qmldir +++ b/EasyReflectometryApp/Gui/qmldir @@ -1,4 +1,6 @@ module Gui ApplicationWindow ApplicationWindow.qml +PlotControlRefLines PlotControlRefLines.qml +SldChart SldChart.qml StatusBar StatusBar.qml diff --git a/EasyReflectometryApp/__version__.py b/EasyReflectometryApp/__version__.py index b3ddbc41..58d478ab 100644 --- a/EasyReflectometryApp/__version__.py +++ b/EasyReflectometryApp/__version__.py @@ -1 +1 @@ -__version__ = '1.1.1' +__version__ = '1.2.0' diff --git a/EasyReflectometryApp/main.py b/EasyReflectometryApp/main.py index c4af8f72..e89a6b59 100644 --- a/EasyReflectometryApp/main.py +++ b/EasyReflectometryApp/main.py @@ -8,6 +8,7 @@ from EasyApp.Logic.Logging import console from PySide6.QtCore import QUrl from PySide6.QtCore import qInstallMessageHandler +from PySide6.QtGui import QIcon from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtQml import qmlRegisterSingletonType @@ -42,6 +43,8 @@ engine = QQmlApplicationEngine() console.debug(f'QML application engine created {engine}') + app.setWindowIcon(QIcon(str(CURRENT_DIR / 'Gui' / 'Resources' / 'Logo' / 'App.svg'))) + engine.rootContext().setContextProperty('isTestMode', args.testmode) if INSTALLER: # Running from installer diff --git a/INSTALLATION.md b/INSTALLATION.md index b1044273..39b9a173 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -2,11 +2,10 @@ To make the installation of EasyReflectometry as easy as possible, we prepare packaged releases for three major operating systems: -- [Windows](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.0/EasyReflectometryApp_v1.1.0_windows-2022.exe) -- [MacOS](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.0/EasyReflectometryApp_v1.1.0_macos-13-Intel.zip) (Intel) -- [MacOS](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.0/EasyReflectometryApp_v1.1.0_macos-14-AppleSilicon.zip) (ARM) -- [Linux](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.0/EasyReflectometryApp_v1.1.0_ubuntu-22.04) (built on Ubuntu-22.04) -- [Linux](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.0/EasyReflectometryApp_v1.1.0_ubuntu-24.04) (built on Ubuntu-22.04) +- [Windows](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.2.0/EasyReflectometryApp_v1.2.0_windows-2022.exe) +- [MacOS](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.2.0/EasyReflectometryApp_v1.2.0_macos-14-AppleSilicon.zip) (ARM) +- [Linux](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.2.0/EasyReflectometryApp_v1.2.0_ubuntu-22.04) (built on Ubuntu-22.04) +- [Linux](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.2.0/EasyReflectometryApp_v1.2.0_ubuntu-24.04) (built on Ubuntu-22.04) If the relevant EasyReflectometry installation does not work on your system, then please try installation from source. diff --git a/docs/src/conf.py b/docs/src/conf.py index da36a2c7..5d524c6d 100644 --- a/docs/src/conf.py +++ b/docs/src/conf.py @@ -78,9 +78,9 @@ # the built documents. # # The short X.Y version. -version = '1.1.1' +version = '1.2.0' # The full version, including alpha/beta/rc tags. -release = '1.1.1' +release = '1.2.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/src/installation.md b/docs/src/installation.md index d0852aa6..5ceb074d 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -2,7 +2,6 @@ To make the installation of EasyReflectometry as easy as possible, we prepare packaged releases for three major operating systems: - [Windows](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.1/EasyReflectometryApp_v1.1.1_windows-2022.exe) -- [MacOS (Intel)](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.1/EasyReflectometryApp_v1.1.1_macos-13-Intel.zip) - [MacOS (Silicon)](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.1/EasyReflectometryApp_v1.1.1_macos-14-AppleSilicon.zip) - [Linux (built on Ubuntu-24.04)](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.1/EasyReflectometryApp_v1.1.1_ubuntu-22.04) - [Linux (built on Ubuntu-22.04)](https://github.com/EasyScience/EasyReflectometryApp/releases/download/v1.1.1/EasyReflectometryApp_v1.1.1_ubuntu-24.04) diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md index 0f942251..a0c1d75e 100644 --- a/docs/src/tutorial.md +++ b/docs/src/tutorial.md @@ -1,6 +1,6 @@ # Getting started This is the tutorial for EasyReflectometryApp and how to use it. -Version: 1.1.1 +Version: 1.2.0 ## Home page When opening up the EasyRecletometryApp you are presented with the **Home** page. diff --git a/pyproject.toml b/pyproject.toml index 6be66552..99cd71b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = 'hatchling.build' [project] name = 'EasyReflectometryApp' -version = '1.1.1' -release_data = '28 May 2025' +version = '1.2.0' +release_data = '10 March 2026' description = "Making reflectometry data analysis and modelling easy." authors = [ {name = "Andrew R. McCluskey"}, @@ -31,9 +31,10 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ - 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git', - 'easyreflectometry', - 'PySide6>=6.8,<6.9', # Issue with TableView formatting in 6.9, + 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@develop', + 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', + 'asteval', + 'PySide6', 'toml', ] diff --git a/src_qt5/EasyReflectometryApp/Logic/DataStore.py b/src_qt5/EasyReflectometryApp/Logic/DataStore.py index 04b3ef34..ed6cb9af 100644 --- a/src_qt5/EasyReflectometryApp/Logic/DataStore.py +++ b/src_qt5/EasyReflectometryApp/Logic/DataStore.py @@ -7,7 +7,7 @@ from collections.abc import Sequence from easyscience.Objects.core import ComponentSerializer -from easyscience.Utils.io.dict import DictSerializer +from easyscience.utils.io.dict import DictSerializer from easyreflectometry.model import Model T = TypeVar('T') diff --git a/src_qt5/EasyReflectometryApp/Logic/Proxies/Material.py b/src_qt5/EasyReflectometryApp/Logic/Proxies/Material.py index fb75a760..a1e83f94 100644 --- a/src_qt5/EasyReflectometryApp/Logic/Proxies/Material.py +++ b/src_qt5/EasyReflectometryApp/Logic/Proxies/Material.py @@ -9,7 +9,7 @@ from PySide2.QtCore import Slot from easyscience import global_object -from easyscience.Utils.io.xml import XMLSerializer +from easyscience.utils.io.xml import XMLSerializer from easyreflectometry.sample import MaterialCollection diff --git a/src_qt5/EasyReflectometryApp/Logic/Proxies/Model.py b/src_qt5/EasyReflectometryApp/Logic/Proxies/Model.py index 85b3675c..9ee71474 100644 --- a/src_qt5/EasyReflectometryApp/Logic/Proxies/Model.py +++ b/src_qt5/EasyReflectometryApp/Logic/Proxies/Model.py @@ -8,7 +8,7 @@ import numpy as np from easyscience import global_object -from easyscience.Utils.io.xml import XMLSerializer +from easyscience.utils.io.xml import XMLSerializer from easyscience.global_object.undo_redo import property_stack_deco from easyreflectometry.sample import Layer from easyreflectometry.sample import LayerAreaPerMolecule @@ -720,7 +720,7 @@ def setCurrentLayersThickness(self, thickness: float): self.currentLayersIndex].thickness.value == thickness: return self._model[self.currentModelIndex].sample[self.currentItemsIndex].layers[ - self.currentLayersIndex].thickness = thickness + self.currentLayersIndex].thickness.value = thickness self.parent.layersChanged.emit() @Slot(float) @@ -733,7 +733,7 @@ def setCurrentLayersRoughness(self, roughness: float): layer = self._model[self.currentModelIndex].sample[self.currentItemsIndex].layers[self.currentLayersIndex] if layer.roughness.value == roughness: return - layer.roughness = roughness + layer.roughness.value = roughness self.parent.layersChanged.emit() @Slot(float) @@ -795,10 +795,14 @@ def currentSurfactantSolventRoughness(self, x): if item['name'] == x: solvent = self._model[self.currentModelIndex].sample[i].layers[0] if solvent is None: - self._model[self.currentModelIndex].sample[self.currentItemsIndex].layers[0].roughness.user_constraints['solvent_roughness'].enabled == False + # self._model[self.currentModelIndex].sample[self.currentItemsIndex].layers[0].roughness.user_constraints[ + # 'solvent_roughness'].enabled == False + self._model[self.currentModelIndex].sample[self.currentItemsIndex].layers[0].roughness.make_independent() else: solvent.roughness.enabled = True - self._model[self.currentModelIndex].sample[self.currentItemsIndex].constrain_solvent_roughness(solvent.roughness) + self._model[self.currentModelIndex].sample[self.currentItemsIndex].make_dependent_on( + dependency_expression='a', dependency_map={'a': solvent.roughness.value}) + #self._model[self.currentModelIndex].sample[self.currentItemsIndex].constrain_solvent_roughness(solvent.roughness) self.parent.layersChanged.emit() # # # diff --git a/src_qt5/EasyReflectometryApp/Logic/Proxies/Parameter.py b/src_qt5/EasyReflectometryApp/Logic/Proxies/Parameter.py index ad64751b..eb72b32d 100644 --- a/src_qt5/EasyReflectometryApp/Logic/Proxies/Parameter.py +++ b/src_qt5/EasyReflectometryApp/Logic/Proxies/Parameter.py @@ -11,9 +11,9 @@ from easyscience.Constraints import ObjConstraint from easyscience.Constraints import NumericConstraint from easyscience.Constraints import FunctionalConstraint -from easyscience.Utils.io.xml import XMLSerializer +from easyscience.utils.io.xml import XMLSerializer from easyscience import global_object -from easyscience.Utils.classTools import generatePath +from easyscience.utils.classTools import generatePath class ParameterProxy(QObject): diff --git a/src_qt5/EasyReflectometryApp/Logic/Proxies/State.py b/src_qt5/EasyReflectometryApp/Logic/Proxies/State.py index 87335698..4ef0cc54 100644 --- a/src_qt5/EasyReflectometryApp/Logic/Proxies/State.py +++ b/src_qt5/EasyReflectometryApp/Logic/Proxies/State.py @@ -6,7 +6,7 @@ from PySide2.QtCore import Slot import numpy as np -from easyscience.Utils.io.xml import XMLSerializer +from easyscience.utils.io.xml import XMLSerializer class StateProxy(QObject):