From f624590e4fae10d08a370baf7a77b8934a282568 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Wed, 15 Oct 2025 22:27:07 +0200 Subject: [PATCH 01/44] initial checkin --- EasyReflectometryApp/Backends/Py/analysis.py | 129 +++++++++ .../Backends/Py/plotting_1d.py | 47 +++- .../Backends/Py/py_backend.py | 57 +++- .../Gui/Globals/BackendWrapper.qml | 13 +- .../Experiment/MainContent/ExperimentView.qml | 18 ++ .../Basic/Groups/ExperimentalDataExplorer.qml | 263 +++++++++++++++++- 6 files changed, 497 insertions(+), 30 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index cbbbce39..b58a5172 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -37,6 +37,16 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._experiments_logic = ExperimentLogic(project_lib) self._minimizers_logic = MinimizersLogic(project_lib) self._chached_parameters = None + # Add support for multiple selected experiments - initialize to empty first to avoid binding loops + self._selected_experiment_indices = [] + + 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 = [] ######################## ## Fitting @@ -187,6 +197,125 @@ def removeExperiment(self, index: int) -> None: 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.""" + print(f"setSelectedExperimentIndices called with: {indices}") + + # Validate indices + available_count = len(self._experiments_logic.available()) + valid_indices = [i for i in indices if 0 <= i < available_count] + + print(f"Available experiments: {available_count}, Valid indices: {valid_indices}") + print(f"Current selection: {self._selected_experiment_indices}") + + if valid_indices != self._selected_experiment_indices: + previous_selection = self._selected_experiment_indices.copy() + self._selected_experiment_indices = valid_indices + print(f"Selection changed from {previous_selection} to {self._selected_experiment_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]) + print(f"Set current experiment index to: {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 + print(f"Auto-selected first experiment, final selection: {self._selected_experiment_indices}") + + # Always trigger plotting refresh when selection changes + print("Triggering plotting system refresh...") + self._refresh_plotting_system() + + self.experimentsChanged.emit() + self.externalExperimentChanged.emit() + print(f"✓ Multi-experiment selection updated and signals emitted") + else: + print("No change in selection - skipping update") + + def get_concatenated_experiment_data(self): + """ + Concatenate data from all selected experiments. + Returns a combined DataSet1D object. + """ + from easyreflectometry.data import DataSet1D + import numpy as np + + 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 _refresh_plotting_system(self) -> None: + """Refresh the plotting system when experiment selection changes.""" + try: + if hasattr(self.parent(), '_plotting_1d'): + plotting = self.parent()._plotting_1d + print("📊 Refreshing plotting system...") + print(f" Current selection: {self._selected_experiment_indices}") + + # Emit signals to refresh experiment data and ranges + print(" Emitting experimentDataChanged signal") + plotting.experimentDataChanged.emit() + print(" Emitting experimentChartRangesChanged signal") + plotting.experimentChartRangesChanged.emit() + print(" Calling refreshExperimentPage()") + plotting.refreshExperimentPage() + print(" Calling refreshExperimentRanges()") + plotting.refreshExperimentRanges() + print("✓ Plotting system refresh completed") + else: + print("⚠️ No plotting system found on parent") + except Exception as e: + print(f"❌ Error refreshing plotting system: {e}") + ######################## ## Minimizers @Property('QVariantList', notify=minimizerChanged) diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 8233c3f8..acf4b209 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -17,6 +17,7 @@ class Plotting1d(QObject): sldChartRangesChanged = Signal() sampleChartRangesChanged = Signal() experimentChartRangesChanged = Signal() + experimentDataChanged = Signal() def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) @@ -83,8 +84,24 @@ def sld_data(self) -> DataSet1D: @property def experiment_data(self) -> DataSet1D: try: - data = self._project_lib.experimental_data_for_model_at_index(self._project_lib.current_experiment_index) - except IndexError: + # 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 + print(f"🔍 experiment_data property accessed, selected_indices: {selected_indices}") + if len(selected_indices) > 1: + # Return concatenated data for multiple experiments + print(" → Returning concatenated data for multiple experiments") + return self._proxy._analysis.get_concatenated_experiment_data() + else: + print(f" → Single experiment mode, using index: {selected_indices[0] if selected_indices else 'current'}") + + # Default single experiment behavior + current_index = self._project_lib.current_experiment_index + print(f" → Loading single experiment data for index: {current_index}") + data = self._project_lib.experimental_data_for_model_at_index(current_index) + print(f" → Single experiment data loaded: {data.name}, {len(data.x)} points") + except IndexError as e: + print(f" → IndexError in experiment_data: {e}") data = DataSet1D( name='Experiment Data empty', x=np.empty(0), @@ -128,6 +145,28 @@ def sldMaxY(self): def sldMinY(self): return self.sld_data.y.min() + # 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 + return np.log10(data.y.max()) if data.y.size > 0 else 1.0 + + @Property(float, notify=experimentChartRangesChanged) + def experimentMinY(self): + data = self.experiment_data + valid_y = data.y[data.y > 0] if data.y.size > 0 else np.array([1e-10]) + return np.log10(valid_y.min()) if valid_y.size > 0 else -10.0 + @Property('QVariant', notify=chartRefsChanged) def chartRefs(self): return self._chartRefs @@ -152,6 +191,10 @@ def refreshExperimentPage(self): def refreshAnalysisPage(self): 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': diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index 602776cd..1ccd424e 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -2,6 +2,8 @@ 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,6 +16,9 @@ class PyBackend(QObject): + # Signal for multi-experiment selection changes + multiExperimentSelectionChanged = Signal() + def __init__(self, parent=None): super().__init__(parent) @@ -25,12 +30,12 @@ def __init__(self, parent=None): 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) @@ -70,12 +75,42 @@ 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.""" + print(f"PyBackend.analysisSetSelectedExperimentIndices called with: {indices}") + print(f"Type of indices: {type(indices)}") + + # Convert QVariantList to Python list if needed + python_indices = list(indices) if hasattr(indices, '__iter__') else [] + print(f"Converted to Python list: {python_indices}") + + if hasattr(self._analysis, 'setSelectedExperimentIndices'): + self._analysis.setSelectedExperimentIndices(python_indices) + print("Successfully called analysis.setSelectedExperimentIndices") + else: + print("ERROR: analysis.setSelectedExperimentIndices method not found") + + # Emit our local signal to notify QML properties + self.multiExperimentSelectionChanged.emit() + ######### Connections to relay info between the backend parts def _connect_backend_parts(self) -> None: self._connect_project_page() @@ -130,7 +165,7 @@ def _relay_project_page_project_changed(self): self._refresh_plots() def _relay_sample_page_sample_changed(self): - self._plotting.reset_data() + self._plotting_1d.reset_data() self._analysis._clearCacheAndEmitParametersChanged() self._status.statusChanged.emit() self._summary.summaryChanged.emit() @@ -142,15 +177,15 @@ def _relay_experiment_page_experiment_changed(self): self._summary.summaryChanged.emit() def _relay_analysis_page(self): - self._plotting.reset_data() + self._plotting_1d.reset_data() self._status.statusChanged.emit() self._experiment.experimentChanged.emit() self._summary.summaryChanged.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() diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 22ea760a..c3fd7f69 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -197,6 +197,11 @@ QtObject { 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: activeBackend.analysis.experimentsSelectedCount + readonly property var analysisSelectedExperimentIndices: activeBackend.analysis.selectedExperimentIndices + function analysisSetSelectedExperimentIndices(value) { activeBackend.analysis.setSelectedExperimentIndices(value) } function analysisSetModelOnExperiment(value) { activeBackend.analysis.setModelOnExperiment(value) } readonly property var analysisModelForExperiment: activeBackend.analysis.modelIndexForExperiment @@ -267,10 +272,10 @@ 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 diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 105cddf3..5aae4af6 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -144,6 +144,24 @@ Rectangle { text: '━ Error' color: chartView.measSerie.color } + + // Show multi-experiment info if applicable + Rectangle { + visible: (Globals.BackendWrapper.analysisExperimentsSelectedCount || 1) > 1 + width: parent.width - 2 * EaStyle.Sizes.fontPixelSize + height: EaStyle.Sizes.fontPixelSize * 3 + color: "transparent" + border.color: EaStyle.Colors.chartGridLine + border.width: 1 + + EaElements.Label { + anchors.centerIn: parent + text: qsTr("Multi-experiment view\n(%1 experiments)").arg(Globals.BackendWrapper.analysisExperimentsSelectedCount || 1) + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.8 + color: EaStyle.Colors.themeForegroundHovered + horizontalAlignment: Text.AlignHCenter + } + } } } // Legend diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml index c7f44dd9..c698b5b8 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml @@ -9,18 +9,100 @@ import easyApp.Gui.Components as EaComponents import Gui.Globals as Globals EaElements.GroupBox { - title: qsTr("Data Explorer") + title: selectedExperimentIndices.length <= 1 ? + qsTr("Data Explorer") : + qsTr("Data Explorer (%1 selected)").arg(selectedExperimentIndices.length) visible: true collapsed: false - Row { - spacing: EaStyle.Sizes.fontPixelSize - - EaComponents.TableView { - id: dataTable - defaultInfoText: qsTr("No Experiments Loaded") - model: Globals.BackendWrapper.analysisExperimentsAvailable.length + + // Property to track selected experiment indices for multi-selection + property var selectedExperimentIndices: [] + + 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 + } + } + + Row { + spacing: EaStyle.Sizes.fontPixelSize - // Headers + 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 { @@ -56,11 +138,39 @@ EaElements.GroupBox { 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.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 { @@ -99,10 +209,14 @@ EaElements.GroupBox { Globals.BackendWrapper.analysisRemoveExperiment(index) } } - mouseArea.onPressed: { - // Set the current experiment index in the backend - if (Globals.BackendWrapper.analysisExperimentsCurrentIndex !== model.index) { - Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(model.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) } } @@ -111,6 +225,129 @@ EaElements.GroupBox { // 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) + } + + 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) { + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(selectedExperimentIndices[0]) + } else { + // 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") + 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) + } + selectedExperimentIndices = allIndices + updateBackendWithSelectedExperiments() + } + + // Initialize with first experiment selected by default + Component.onCompleted: { + if (Globals.BackendWrapper.analysisExperimentsAvailable.length > 0) { + selectSingleExperiment(0) } } } From f1f5947b539e7bad63c82fcac59b9122dcd21b74 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Wed, 15 Oct 2025 22:29:58 +0200 Subject: [PATCH 02/44] ruff --- EasyReflectometryApp/Backends/Py/analysis.py | 7 ++++--- EasyReflectometryApp/Backends/Py/plotting_1d.py | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index b58a5172..6c780906 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -244,7 +244,7 @@ def setSelectedExperimentIndices(self, indices: List[int]) -> None: self.experimentsChanged.emit() self.externalExperimentChanged.emit() - print(f"✓ Multi-experiment selection updated and signals emitted") + print("✓ Multi-experiment selection updated and signals emitted") else: print("No change in selection - skipping update") @@ -253,8 +253,8 @@ def get_concatenated_experiment_data(self): Concatenate data from all selected experiments. Returns a combined DataSet1D object. """ - from easyreflectometry.data import DataSet1D 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)) @@ -282,7 +282,8 @@ def get_concatenated_experiment_data(self): 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())] + 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( diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index acf4b209..e5562f46 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -93,8 +93,9 @@ def experiment_data(self) -> DataSet1D: print(" → Returning concatenated data for multiple experiments") return self._proxy._analysis.get_concatenated_experiment_data() else: - print(f" → Single experiment mode, using index: {selected_indices[0] if selected_indices else 'current'}") - + print(f" → Single experiment. Index: " + f"{selected_indices[0] if selected_indices else 'current'}") + # Default single experiment behavior current_index = self._project_lib.current_experiment_index print(f" → Loading single experiment data for index: {current_index}") From c7f9f76ae77070402d19a0148c16976b276a139a Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 16 Oct 2025 14:06:30 +0200 Subject: [PATCH 03/44] don't loose experiment names on selection --- EasyReflectometryApp/Backends/Py/analysis.py | 6 ++++ .../Backends/Py/logic/experiments.py | 5 +++ .../Gui/Globals/BackendWrapper.qml | 1 + .../Basic/Groups/ExperimentalDataExplorer.qml | 33 +++++++++++++++++-- 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index 6c780906..72b58969 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -155,6 +155,12 @@ def setExperimentName(self, new_name: str) -> None: 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 diff --git a/EasyReflectometryApp/Backends/Py/logic/experiments.py b/EasyReflectometryApp/Backends/Py/logic/experiments.py index 1e910759..f57f18c0 100644 --- a/EasyReflectometryApp/Backends/Py/logic/experiments.py +++ b/EasyReflectometryApp/Backends/Py/logic/experiments.py @@ -30,6 +30,11 @@ def set_experiment_name(self, new_name: str) -> None: if exp: exp.name = new_name + def set_experiment_name_at_index(self, index: int, new_name: str) -> None: + exp = self._project_lib._experiments.get(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 diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index c3fd7f69..cbd7e103 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -220,6 +220,7 @@ QtObject { readonly property int analysisCurrentParameterIndex: activeBackend.analysis.currentParameterIndex 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 diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml index c698b5b8..2ff19b5c 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml @@ -17,6 +17,8 @@ EaElements.GroupBox { // 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 Column { spacing: EaStyle.Sizes.fontPixelSize * 0.5 @@ -178,7 +180,7 @@ EaElements.GroupBox { id: labelLabel width: EaStyle.Sizes.fontPixelSize * 11 text: index > -1 ? Globals.BackendWrapper.analysisExperimentsAvailable[index] : "" - onEditingFinished: Globals.BackendWrapper.analysisSetExperimentName(text) + onEditingFinished: Globals.BackendWrapper.analysisSetExperimentNameAtIndex(index, text) } EaComponents.TableViewComboBox { @@ -269,7 +271,12 @@ EaElements.GroupBox { // Add to selection currentSelection.push(experimentIndex) } - + + // Track if we now have multiple selections + if (currentSelection.length > 1) { + wasMultiSelected = true + } + selectedExperimentIndices = currentSelection updateBackendWithSelectedExperiments() } @@ -288,8 +295,26 @@ EaElements.GroupBox { // If only one experiment is selected, use the existing single-selection logic if (selectedExperimentIndices.length === 1) { - Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(selectedExperimentIndices[0]) + 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) @@ -327,6 +352,7 @@ EaElements.GroupBox { 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') { @@ -340,6 +366,7 @@ EaElements.GroupBox { for (var i = 0; i < Globals.BackendWrapper.analysisExperimentsAvailable.length; i++) { allIndices.push(i) } + wasMultiSelected = true selectedExperimentIndices = allIndices updateBackendWithSelectedExperiments() } From 05f0eb2702c890d93cbbc2f5431c65ea2147cee0 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Fri, 17 Oct 2025 12:02:51 +0200 Subject: [PATCH 04/44] remove debug printout. Add app icon --- EasyReflectometryApp/Backends/Py/analysis.py | 44 +++++-------------- .../Backends/Py/plotting_1d.py | 9 ---- EasyReflectometryApp/main.py | 3 ++ 3 files changed, 15 insertions(+), 41 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index 72b58969..a3a14e3a 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -220,39 +220,26 @@ def selectedExperimentIndices(self) -> List[int]: @Slot('QVariantList') def setSelectedExperimentIndices(self, indices: List[int]) -> None: """Set multiple selected experiment indices.""" - print(f"setSelectedExperimentIndices called with: {indices}") - # Validate indices available_count = len(self._experiments_logic.available()) valid_indices = [i for i in indices if 0 <= i < available_count] - - print(f"Available experiments: {available_count}, Valid indices: {valid_indices}") - print(f"Current selection: {self._selected_experiment_indices}") - + if valid_indices != self._selected_experiment_indices: previous_selection = self._selected_experiment_indices.copy() self._selected_experiment_indices = valid_indices - print(f"Selection changed from {previous_selection} to {self._selected_experiment_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]) - print(f"Set current experiment index to: {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 - print(f"Auto-selected first experiment, final selection: {self._selected_experiment_indices}") - + # Always trigger plotting refresh when selection changes - print("Triggering plotting system refresh...") self._refresh_plotting_system() - + self.experimentsChanged.emit() self.externalExperimentChanged.emit() - print("✓ Multi-experiment selection updated and signals emitted") - else: - print("No change in selection - skipping update") def get_concatenated_experiment_data(self): """ @@ -261,12 +248,12 @@ def get_concatenated_experiment_data(self): """ 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) @@ -278,20 +265,20 @@ def get_concatenated_experiment_data(self): 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), @@ -307,19 +294,12 @@ def _refresh_plotting_system(self) -> None: plotting = self.parent()._plotting_1d print("📊 Refreshing plotting system...") print(f" Current selection: {self._selected_experiment_indices}") - + # Emit signals to refresh experiment data and ranges - print(" Emitting experimentDataChanged signal") plotting.experimentDataChanged.emit() - print(" Emitting experimentChartRangesChanged signal") plotting.experimentChartRangesChanged.emit() - print(" Calling refreshExperimentPage()") plotting.refreshExperimentPage() - print(" Calling refreshExperimentRanges()") plotting.refreshExperimentRanges() - print("✓ Plotting system refresh completed") - else: - print("⚠️ No plotting system found on parent") except Exception as e: print(f"❌ Error refreshing plotting system: {e}") diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index e5562f46..3ab1617a 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -87,22 +87,13 @@ def experiment_data(self) -> DataSet1D: # 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 - print(f"🔍 experiment_data property accessed, selected_indices: {selected_indices}") if len(selected_indices) > 1: # Return concatenated data for multiple experiments - print(" → Returning concatenated data for multiple experiments") return self._proxy._analysis.get_concatenated_experiment_data() - else: - print(f" → Single experiment. Index: " - f"{selected_indices[0] if selected_indices else 'current'}") - # Default single experiment behavior current_index = self._project_lib.current_experiment_index - print(f" → Loading single experiment data for index: {current_index}") data = self._project_lib.experimental_data_for_model_at_index(current_index) - print(f" → Single experiment data loaded: {data.name}, {len(data.x)} points") except IndexError as e: - print(f" → IndexError in experiment_data: {e}") data = DataSet1D( name='Experiment Data empty', x=np.empty(0), diff --git a/EasyReflectometryApp/main.py b/EasyReflectometryApp/main.py index c4af8f72..b0251155 100644 --- a/EasyReflectometryApp/main.py +++ b/EasyReflectometryApp/main.py @@ -10,6 +10,7 @@ from PySide6.QtCore import qInstallMessageHandler from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtQml import qmlRegisterSingletonType +from PySide6.QtGui import QIcon try: # Running locally from Backends.Py import PyBackend @@ -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 From 0f59b90a2becd54502e5bfb25acf3e7629b10d77 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 23 Oct 2025 14:29:18 +0200 Subject: [PATCH 05/44] multiple display for analysis as well --- .../Analysis/MainContent/AnalysisView.qml | 30 ++ .../Sidebar/Basic/Groups/Experiments.qml | 280 ++++++++++++++---- 2 files changed, 256 insertions(+), 54 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index 1a79a3f7..60057fd1 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -139,6 +139,7 @@ Rectangle { rightPadding: EaStyle.Sizes.fontPixelSize topPadding: EaStyle.Sizes.fontPixelSize * 0.5 bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 + spacing: EaStyle.Sizes.fontPixelSize * 0.25 EaElements.Label { text: '━ I (Measured)' @@ -148,6 +149,35 @@ Rectangle { text: '━ (calculated)' color: chartView.calcSerie.color } + + EaElements.Label { + readonly property var selectedIndices: Globals.BackendWrapper.analysisSelectedExperimentIndices || [] + + visible: selectedIndices.length > 1 + text: qsTr('Selected: %1').arg(selectedIndices.map(index => index + 1).join(', ')) + color: EaStyle.Colors.themeAccent + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.85 + wrapMode: Text.NoWrap + onSelectedIndicesChanged: console.debug('AnalysisView legend - selected count:', selectedIndices.length) + } + + Rectangle { + visible: (Globals.BackendWrapper.analysisExperimentsSelectedCount || 1) > 1 + width: parent.width - 2 * EaStyle.Sizes.fontPixelSize + height: EaStyle.Sizes.fontPixelSize * 3 + color: "transparent" + border.color: EaStyle.Colors.chartGridLine + border.width: 1 + + EaElements.Label { + anchors.centerIn: parent + text: qsTr("Multi-experiment view\n(%1 experiments)") + .arg(Globals.BackendWrapper.analysisExperimentsSelectedCount || 1) + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.8 + color: EaStyle.Colors.themeForegroundHovered + horizontalAlignment: Text.AlignHCenter + } + } } } // Legend diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml index 01ddedb9..f72ba56f 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml @@ -1,5 +1,5 @@ -import QtQuick -import QtQuick.Controls +import QtQuick 2.14 +import QtQuick.Controls 2.14 import easyApp.Gui.Style as EaStyle import easyApp.Gui.Elements as EaElements @@ -8,81 +8,253 @@ import easyApp.Gui.Components as EaComponents import Gui.Globals as Globals EaElements.GroupBox { - title: qsTr("Data Explorer") + title: selectedExperimentIndices.length <= 1 ? + qsTr("Data Explorer") : + qsTr("Data Explorer (%1 selected)").arg(selectedExperimentIndices.length) visible: true collapsed: false - Row { - spacing: EaStyle.Sizes.fontPixelSize - EaComponents.TableView { - id: dataTable - defaultInfoText: qsTr("No Experiments Loaded") - model: Globals.BackendWrapper.analysisExperimentsAvailable.length + // Track selection state locally and keep backend in sync + property var selectedExperimentIndices: [] + property bool wasMultiSelected: false - // Headers - header: EaComponents.TableViewHeader { + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 - EaComponents.TableViewLabel { - text: qsTr('No.') - width: EaStyle.Sizes.fontPixelSize * 2.5 - } + // Multi-selection controls (mirrors ExperimentalDataExplorer) + Row { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + visible: Globals.BackendWrapper.analysisExperimentsAvailable.length > 1 - EaComponents.TableViewLabel { - flexibleWidth: true - horizontalAlignment: Text.AlignLeft - text: qsTr('Name') - } + EaElements.Label { + text: qsTr("Ctrl+Click for multi-select") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.75 + color: EaStyle.Colors.themeForegroundMinor + anchors.verticalCenter: parent.verticalCenter + } - EaComponents.TableViewLabel { - width: EaStyle.Sizes.fontPixelSize * 9.5 - horizontalAlignment: Text.AlignHCenter - text: "Model" - } + 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() + } - // Placeholder for row color - EaComponents.TableViewLabel { - id: colorLab - text: "Color" - width: EaStyle.Sizes.fontPixelSize * 2.5 + 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 } - delegate: EaComponents.TableViewDelegate { - EaComponents.TableViewLabel { - id: noLabel - width: EaStyle.Sizes.fontPixelSize * 2.5 - text: index + 1 - } + 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 + } + } - EaComponents.TableViewTextInput { - horizontalAlignment: Text.AlignLeft - id: labelLabel - width: EaStyle.Sizes.fontPixelSize * 11 - text: index > -1 ? Globals.BackendWrapper.analysisExperimentsAvailable[index] : "" - } + Row { + spacing: EaStyle.Sizes.fontPixelSize - EaComponents.TableViewLabel { - id: modelAccess - text: { - return Globals.BackendWrapper.modelNamesForExperiment[model.index] || "" + EaComponents.TableView { + id: dataTable + defaultInfoText: qsTr("No Experiments Loaded") + model: Globals.BackendWrapper.analysisExperimentsAvailable.length + + 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() + } + } } } - EaComponents.TableViewLabel { - id: colorLabel - backgroundColor: { - Globals.BackendWrapper.modelColorsForExperiment[model.index] + 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" + } + + EaComponents.TableViewLabel { + id: colorLab + text: "Color" + width: EaStyle.Sizes.fontPixelSize * 2.5 } } - mouseArea.onPressed: { - Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(model.index) - var modelIndexFromExperiment = Globals.BackendWrapper.analysisModelForExperiment - Globals.BackendWrapper.sampleSetCurrentModelIndex(modelIndexFromExperiment) + delegate: EaComponents.TableViewDelegate { + property bool isSelected: selectedExperimentIndices.indexOf(index) !== -1 + + EaComponents.TableViewLabel { + id: noLabel + width: EaStyle.Sizes.fontPixelSize * 2.5 + text: index + 1 + + 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.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.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) + } + } } + } + } + } + + function toggleExperimentSelection(experimentIndex) { + var currentSelection = selectedExperimentIndices.slice() + var indexPos = currentSelection.indexOf(experimentIndex) + + if (indexPos !== -1) { + currentSelection.splice(indexPos, 1) + } else { + currentSelection.push(experimentIndex) + } + + if (currentSelection.length > 1) { + wasMultiSelected = true + } + + selectedExperimentIndices = currentSelection + updateBackendWithSelectedExperiments() + } + + function selectSingleExperiment(experimentIndex) { + selectedExperimentIndices = [experimentIndex] + updateBackendWithSelectedExperiments() + } + 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 = [] + Globals.BackendWrapper.analysisSetSelectedExperimentIndices([]) + } + + function updateBackendWithSelectedExperiments() { + if (selectedExperimentIndices.length === 0) { + return + } + + 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) } } } From eb94f1acade2fa17ae64f77dfc9f9f43aa6d9dc1 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Fri, 24 Oct 2025 15:21:04 +0200 Subject: [PATCH 06/44] initial version of the complex constraint editor --- EasyReflectometryApp/Backends/Mock/Sample.qml | 84 +++- .../Backends/Py/logic/parameters.py | 80 +++- EasyReflectometryApp/Backends/Py/sample.py | 442 ++++++++++++++++-- .../Gui/Globals/BackendWrapper.qml | 4 +- .../Sidebar/Advanced/Groups/Constraints.qml | 330 ++++++++----- 5 files changed, 751 insertions(+), 189 deletions(-) diff --git a/EasyReflectometryApp/Backends/Mock/Sample.qml b/EasyReflectometryApp/Backends/Mock/Sample.qml index cd63dd2a..3314b0b2 100644 --- a/EasyReflectometryApp/Backends/Mock/Sample.qml +++ b/EasyReflectometryApp/Backends/Mock/Sample.qml @@ -273,48 +273,86 @@ 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 new simplified format + // Mock constraints data - matches the structured format expected by the UI property var constraintsList: [ { - dependentName: "Thickness Layer 1", - expression: "2.0 * parameter 2 + 1.5" + 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" + dependentName: 'Roughness Layer 2', + expression: 'parameter 1 / 3.14', + rawExpression: 'parameter_1 / 3.14', + relation: '=', + type: 'expression' }, { - dependentName: "SLD Layer 3", - expression: "5.0" + dependentName: 'SLD Layer 3', + expression: '5.0', + rawExpression: '5.0', + relation: '=', + type: 'static' } ] - function addConstraint(value1, value2, value3, value4, value5) { - console.debug(`addConstraint ${value1} ${value2} ${value3} ${value4} ${value5}`) - - // Create constraint object in the new simplified format - let expression = "" - if (value5 >= 0 && value5 < parameterNames.length) { - // Parameter-parameter constraint - expression = `${value3} ${value4} ${parameterNames[value5]}` - } else { - // Numeric constraint - expression = `= ${value3}` + 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[value1] || "Unknown", - expression: expression + dependentName: parameterNames[dependentIndex] || 'Unknown parameter', + expression: validation.preview, + rawExpression: expression, + relation: relation, + type: validation.type } - // Add to constraints list - need to reassign the array to trigger property change - var newConstraints = constraintsList.slice() // Create a copy + var newConstraints = constraintsList.slice() newConstraints.push(constraint) constraintsList = newConstraints constraintsChanged() + + return { + success: true, + message: '', + preview: validation.preview, + relation: relation, + type: validation.type + } } function removeConstraintByIndex(index) { diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index a2ed7853..c0eacdd0 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -1,4 +1,7 @@ +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 @@ -6,6 +9,8 @@ from easyscience import global_object from easyscience.variable import Parameter +RESERVED_ALIAS_NAMES = {'np', 'numpy', 'math', 'pi', 'e'} + class Parameters: def __init__(self, project_lib: ProjectLib): @@ -22,6 +27,34 @@ def parameters(self) -> List[str]: self._project_lib.parameters, self._project_lib._models[self._project_lib.current_model_index].unique_name ) + def constraint_context(self) -> list[dict[str, Any]]: + parameter_snapshot = self.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: + 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 @@ -75,8 +108,12 @@ def set_current_parameter_fit(self, new_value: str) -> bool: 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 ['', '*', '/', '+', '-'] @@ -102,17 +139,33 @@ def add_constraint( 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], model_unique_name: str) -> list[dict[str, Any]]: """Convert parameters to list of dictionaries with simplified logic.""" - def _get_parameter_display_name(param: Parameter) -> str: - """Extract display name from parameter path.""" + alias_registry: set[str] = set() + + 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) -> 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}' - return param.name # Fallback to parameter name + return f'{parent_name} {param_name}', parent_name + return param.name, '' # Fallback to parameter name without group def _get_dependency_expression(param: Parameter) -> str: """Get simplified dependency expression.""" @@ -122,7 +175,10 @@ def _get_dependency_expression(param: Parameter) -> str: # 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'] - dep_name = _get_parameter_display_name(dependent_param) + if isinstance(dependent_param, Parameter): + dep_name, _ = _get_parameter_display_data(dependent_param) + else: + dep_name = str(dependent_param) return param.dependency_expression.replace('a', dep_name) # Simple numerical dependency @@ -134,8 +190,14 @@ def _get_dependency_expression(param: Parameter) -> str: if not global_object.map.find_path(model_unique_name, parameter.unique_name): continue + display_name, group_name = _get_parameter_display_data(parameter) + alias = _make_alias(display_name or parameter.name) parameter_list.append({ - 'name': _get_parameter_display_name(parameter), + 'name': display_name, + 'display_name': display_name, + 'group': group_name, + 'alias': alias, + 'unique_name': parameter.unique_name, 'value': float(parameter.value), 'error': float(parameter.variance), 'max': float(parameter.max), diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index ff8eb4c0..408b47d8 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -1,9 +1,20 @@ +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 PySide6.QtCore import Property from PySide6.QtCore import QObject from PySide6.QtCore import Signal from PySide6.QtCore import Slot +from easyscience.variable.descriptor_number import DescriptorNumber + from .logic.assemblies import Assemblies as AssembliesLogic from .logic.layers import Layers as LayersLogic from .logic.material import Material as MaterialLogic @@ -11,6 +22,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() @@ -42,6 +82,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() @@ -414,6 +455,234 @@ 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_lookup[entry['alias']] = entry['object'] + display_lookup[entry['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] + ) -> DescriptorNumber | numbers.Number: + interpreter = Interpreter(config=_ASTEVAL_CONFIG) + for name, value in _GLOBAL_SYMBOLS.items(): + interpreter.symtable[name] = value + if isinstance(value, numbers.Number): + interpreter.readonly_symbols.add(name) + for alias, dependency in dependency_map.items(): + interpreter.symtable[alias] = dependency + interpreter.readonly_symbols.add(alias) + result = interpreter.eval(expression, raise_errors=True) + 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 _prepare_constraint_instruction( + self, + dependent_index: int, + relation_operator: str, + expression: str, + ) -> dict[str, Any]: + if dependent_index < 0 or dependent_index >= len(self._project_lib.parameters): + 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: + evaluation_result = self._evaluate_constraint_expression(expression_text, dependency_map) + 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] @@ -423,7 +692,11 @@ def dependentParameterNames(self) -> list[dict[str, str]]: return [parameter['name'] for parameter in self._parameters_logic.parameters if parameter['independent']] @Property('QVariantList', notify=layersChange) - def relationOperators(self) -> list[str]: + 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) @@ -432,36 +705,66 @@ def arithmicOperators(self) -> list[str]: @Property('QVariantList', notify=constraintsChanged) def constraintsList(self) -> list[dict[str, str]]: - """Get the list of active constraints from dependent parameters.""" - constraints = [] - parameters = self._parameters_logic.parameters - for param in parameters: - if not param['independent']: - constraints.append({param['name']: param['dependency']}) + """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) + + constraints.append({ + 'dependentName': entry['display_name'], + 'expression': expression_display, + 'rawExpression': raw_expression, + 'relation': relation, + 'type': mode, + }) return constraints @Slot(int) - def removeConstraintByIndex(self, index_str: str) -> None: + def removeConstraintByIndex(self, index: int) -> None: """Remove constraint by index by making the parameter independent.""" - try: - index = int(index_str) - except ValueError: - return + 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 = list(constraints_list[index].keys())[0] + param_name = constraints_list[index]['dependentName'] param_obj = self._find_parameter_object_by_name(param_name) - if param_obj is None or param_obj.independent: + if param_obj is None: return - self._make_parameter_independent(param_obj) + 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.""" @@ -478,46 +781,91 @@ def _make_parameter_independent(self, param_obj) -> None: except AttributeError: param_obj._independent = True # Fallback for custom ERL constraints - @Slot(str, str, str, str, str) - def addConstraint(self, value1: str, value2: str, value3: str, value4: str, value5: str) -> None: - dependent = self._project_lib.parameters[int(value1)] - if value5 == '-1': - independent = None - else: - independent = self._project_lib.parameters[int(value5)] - relational_operator = value2 - arithmetic_operator = value4 - value = float(value3) - if arithmetic_operator == '': - if relational_operator == '=': - # simple parameter equality - # assign to the parameter and make it frozen - dependent.value = value + @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._project_lib.parameters[dependent_index] + 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 - # here, we need to make the parameter "dependent" so it can't be changed in GUI dependent._independent = False - - elif relational_operator == '>': - # set the minimum value of the parameter - dependent.min = value + elif mode == 'lower_bound': + dependent.min = instruction['value'] dependent.free = True - - elif relational_operator == '<': - # set the maximum value of the parameter - dependent.max = value + elif mode == 'upper_bound': + dependent.max = instruction['value'] dependent.free = True - else: - # make the parameter dependent on another parameter - # e.g. thickness = 2 * a + 5 - expr = "a " + arithmetic_operator + str(float(value)) - dependency_map = {'a': independent} - dependent.make_dependent_on( - dependency_expression=expr, dependency_map=dependency_map - ) + 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, + } + # # # # Q Range # # # diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index cbd7e103..53909d63 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -164,8 +164,10 @@ QtObject { 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) } // Q range diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml index 0d67f14d..fb8bf7fd 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml @@ -1,110 +1,235 @@ -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.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 { + id: constraintsGroup title: qsTr("Sample constraints") enabled: true last: false - Column { - spacing: EaStyle.Sizes.fontPixelSize * 0.5 - Column { + 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 + } - EaElements.Label { - enabled: true - text: qsTr("Numeric or Parameter-Parameter constraint") + 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.") } + } else { + expressionValid = true + validationMessage = "" + expressionPreview = expr + lastConstraintType = 'expression' + } + } - 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.sampleDepParameterNames - // Removed onCurrentIndexChanged handler to prevent circular updates - } + function insertAlias(aliasText) { + if (!aliasText || aliasText.length === 0) { + return + } - EaElements.ComboBox { - id: relationalOperator - width: 47 - currentIndex: 0 - font.family: EaStyle.Fonts.iconsFamily - model: Globals.BackendWrapper.sampleRelationOperators - } + 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() + } - 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 - // let's avoid circular dependencies by not allowing to select dependent parameter here - // model: Globals.BackendWrapper.sampleParameterNames - model: Globals.BackendWrapper.sampleDepParameterNames - onCurrentIndexChanged: { - if (currentIndex === -1){ - arithmeticOperator.model = Globals.BackendWrapper.sampleArithmicOperators.slice(0,1) // no arithmetic operators - } - else{ - arithmeticOperator.model = Globals.BackendWrapper.sampleArithmicOperators.slice(1) // allow all arithmetic operators - } + 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.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 + } + + 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() + } + + 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.ComboBox { - id: arithmeticOperator - width: relationalOperator.width - currentIndex: 0 - font.family: EaStyle.Fonts.iconsFamily - model: arithmeticOperator.model = Globals.BackendWrapper.sampleArithmicOperators.slice(0,1) - } + 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.TextField { - id: value - width: 65 - horizontalAlignment: Text.AlignRight - text: "1.0000" + 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.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 - ) - - // Reset form - independentPar.currentIndex = -1 - dependentPar.currentIndex = -1 - relationalOperator.currentIndex = 0 - arithmeticOperator.currentIndex = 0 - } + 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() } } } @@ -133,22 +258,7 @@ EaElements.GroupBox { defaultInfoText: qsTr("No Active Constraints") // Table model - use backend data directly like other tables - property int refreshTrigger: 0 - model: { - // Include refreshTrigger to force re-evaluation - refreshTrigger // This creates a dependency - return Globals.BackendWrapper.sampleConstraintsList.length - } - - // No Component.onCompleted needed - table uses backend data directly - - Connections { - target: Globals.BackendWrapper.activeBackend.sample - function onConstraintsChanged() { - // Force table model to refresh by changing the trigger - constraintsTable.refreshTrigger++ - } - } + model: Globals.BackendWrapper.sampleConstraintsList.length // Header row header: EaComponents.TableViewHeader { @@ -193,7 +303,7 @@ EaElements.GroupBox { horizontalAlignment: Text.AlignLeft text: { const constraint = Globals.BackendWrapper.sampleConstraintsList[index] - return constraint ? Object.keys(constraint)[0] : "" + return constraint ? constraint.dependentName : "" } elide: Text.ElideRight } @@ -204,13 +314,15 @@ EaElements.GroupBox { horizontalAlignment: Text.AlignLeft text: { const constraint = Globals.BackendWrapper.sampleConstraintsList[index] - if (constraint) { - const paramName = Object.keys(constraint)[0] - return constraint[paramName] + if (!constraint) { + return "" } - 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 : "" } // Placeholder for delete button space From 284ac906cefdea7113129c0891b6d54458c1d592 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Sat, 25 Oct 2025 13:13:05 +0200 Subject: [PATCH 07/44] initial impl --- .../Sidebar/Basic/Groups/LoadSample.qml | 29 +++++++++++++++++++ .../Pages/Sample/Sidebar/Basic/Groups/qmldir | 1 + .../Gui/Pages/Sample/Sidebar/Basic/Layout.qml | 3 ++ 3 files changed, 33 insertions(+) create mode 100644 EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml 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..261c139a --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml @@ -0,0 +1,29 @@ +import QtQuick 2.14 +import QtQuick.Controls 2.14 +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.SideBarButton { + width: EaStyle.Sizes.sideBarContentWidth + fontIcon: "folder-open" + text: qsTr("Load file") + onClicked: fileDialog.open() + } + + FileDialog { + id: fileDialog + title: qsTr("Select a sample file") + onAccepted: Globals.BackendWrapper.sampleFileLoad(fileDialog.fileUrl) + } + } +} \ No newline at end of file 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..ecd88320 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml @@ -7,6 +7,9 @@ import Gui.Globals as Globals import "./Groups" as Groups EaComponents.SideBarColumn { + Groups.LoadSample{ + enabled: Globals.BackendWrapper.analysisIsFitFinished + } Groups.MaterialEditor{ enabled: Globals.BackendWrapper.analysisIsFitFinished } From eb40191d708ac5e1c097f8cb604280d550c3115f Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Sun, 26 Oct 2025 16:56:24 +0100 Subject: [PATCH 08/44] connect to backend --- EasyReflectometryApp/Backends/Py/project.py | 11 +++++++++++ EasyReflectometryApp/Gui/Globals/BackendWrapper.qml | 1 + .../Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml | 3 ++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/EasyReflectometryApp/Backends/Py/project.py b/EasyReflectometryApp/Backends/Py/project.py index 85dca049..a27d0005 100644 --- a/EasyReflectometryApp/Backends/Py/project.py +++ b/EasyReflectometryApp/Backends/Py/project.py @@ -1,5 +1,7 @@ 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 @@ -101,3 +103,12 @@ def reset(self) -> None: self.externalCreatedChanged.emit() self.externalNameChanged.emit() self.externalProjectReset.emit() + + @Slot(str) + def sampleLoad(self, url: str) -> None: + # Load ORSO file content + orso_data = orso.load_orso(generalizePath(url)) + # Load the sample model + sample = load_orso_model(orso_data) + # Set the sample in the project logic + pass diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index cbd7e103..e73a8912 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -78,6 +78,7 @@ QtObject { function projectReset() { activeBackend.project.reset() } function projectSave() { activeBackend.project.save() } function projectLoad(value) { activeBackend.project.load(value) } + function sampleFileLoad(value) { activeBackend.project.sampleLoad(value) } /////////////// diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml index 261c139a..14973e97 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml @@ -23,7 +23,8 @@ EaElements.GroupBox { FileDialog { id: fileDialog title: qsTr("Select a sample file") - onAccepted: Globals.BackendWrapper.sampleFileLoad(fileDialog.fileUrl) + nameFilters: [ "ORT files (*.ort)", "ORSO files (*.orso)", "All files (*.*)" ] + onAccepted: Globals.BackendWrapper.sampleFileLoad(selectedFiles[0]) } } } \ No newline at end of file From b080b3f144d788d8ee917aadb59c04eadd29c610 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 28 Oct 2025 14:43:30 +0100 Subject: [PATCH 09/44] improved model colour handling --- .../Gui/Pages/Analysis/MainContent/AnalysisView.qml | 10 ++++++++-- .../Gui/Pages/Analysis/MainContent/SldView.qml | 10 ++++++++-- .../Gui/Pages/Sample/MainContent/SldView.qml | 10 ++++++++-- pyproject.toml | 2 +- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index 60057fd1..d46a1f3f 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -48,8 +48,14 @@ Rectangle { calcSerie.onHovered: (point, state) => showMainTooltip(chartView, point, state) calcSerie.color: { - var idx = Globals.BackendWrapper.sampleCurrentModelIndex - Globals.BackendWrapper.sampleModels[idx].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 diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml index 20fbeafe..0a4c721c 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml @@ -42,8 +42,14 @@ Rectangle { calcSerie.onHovered: (point, state) => showMainTooltip(chartView, point, state) calcSerie.color: { - var idx = Globals.BackendWrapper.sampleCurrentModelIndex - Globals.BackendWrapper.sampleModels[idx].color + const models = Globals.BackendWrapper.sampleModels + const idx = Globals.BackendWrapper.sampleCurrentModelIndex + + if (models && idx >= 0 && idx < models.length) { + return models[idx].color + } + + return undefined } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml index 20fbeafe..0a4c721c 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml @@ -42,8 +42,14 @@ Rectangle { calcSerie.onHovered: (point, state) => showMainTooltip(chartView, point, state) calcSerie.color: { - var idx = Globals.BackendWrapper.sampleCurrentModelIndex - Globals.BackendWrapper.sampleModels[idx].color + const models = Globals.BackendWrapper.sampleModels + const idx = Globals.BackendWrapper.sampleCurrentModelIndex + + if (models && idx >= 0 && idx < models.length) { + return models[idx].color + } + + return undefined } diff --git a/pyproject.toml b/pyproject.toml index 903a211d..c24233f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git', - 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', + 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@model_color_update', 'PySide6>=6.8,<6.9', # Issue with TableView formatting in 6.9, 'toml', ] From fc324d2dff7c4a42b5ee369ae700ff37040745a8 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 29 Oct 2025 14:51:07 +0100 Subject: [PATCH 10/44] Added "Select All" checkbox for fit params --- .../Sidebar/Basic/Groups/Fittables.qml | 129 ++++++++++++++++-- 1 file changed, 118 insertions(+), 11 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml index 9dd32688..04199c99 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() } @@ -189,7 +196,7 @@ EaElements.GroupBox { } EaComponents.TableViewLabel { - width: EaStyle.Sizes.fontPixelSize * 3.0 + width: fittables.fitColumnWidth color: EaStyle.Colors.themeForegroundMinor text: qsTr("Fit") } @@ -302,6 +309,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 @@ -357,27 +394,97 @@ 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 + } + fittables.parameterSlider.from = EaLogic.Utils.toDefaultPrecision(from) + fittables.parameterSlider.to = EaLogic.Utils.toDefaultPrecision(to) + } + + 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 } - if (Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].min > from) { - from = Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].min + 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) } } From 971e51e68e7b3e065c6a1aa3174a0ad7c42d0e4a Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 31 Oct 2025 09:05:03 +0100 Subject: [PATCH 11/44] let's try unpinning pyside6 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 903a211d..f0833f14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,9 +31,9 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ - 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git', + 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@unpinned_pyside6', 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', - 'PySide6>=6.8,<6.9', # Issue with TableView formatting in 6.9, + 'PySide6', 'toml', ] From 8482049c52de7dec45ff49cdc9897852c6aaeb49 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 31 Oct 2025 09:53:14 +0100 Subject: [PATCH 12/44] reference correct ERL branch --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f0833f14..7143c71e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@unpinned_pyside6', - 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', + 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@orso_models', 'PySide6', 'toml', ] From 7b93e9aa5faaed98bb9b70b585caec31b9e98692 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 31 Oct 2025 17:21:32 +0100 Subject: [PATCH 13/44] separate experiment plots properly --- EasyReflectometryApp/Backends/Py/analysis.py | 53 +++++ .../Backends/Py/plotting_1d.py | 84 ++++++- .../Backends/Py/py_backend.py | 24 ++ .../Gui/Globals/BackendWrapper.qml | 47 +++- .../Experiment/MainContent/ExperimentView.qml | 220 ++++++++++++++++-- 5 files changed, 408 insertions(+), 20 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index a3a14e3a..40e3d45d 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -39,6 +39,8 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._chached_parameters = None # 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.""" @@ -287,6 +289,57 @@ def get_concatenated_experiment_data(self): 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. + """ + import numpy as np + from easyreflectometry.data import DataSet1D + + 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.""" try: diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 3ab1617a..8dd22ba7 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -88,7 +88,7 @@ def experiment_data(self) -> DataSet1D: 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 + # 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 @@ -103,6 +103,26 @@ 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: + 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): @@ -168,11 +188,50 @@ 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 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(): + if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]: + points.append({ + 'x': float(point[0]), + 'y': float(np.log10(point[1])), + 'errorUpper': float(np.log10(point[1] + np.sqrt(point[2]))), + 'errorLower': float(np.log10(max(point[1] - np.sqrt(point[2]), 1e-10))) # Avoid log(0) + }) + return points + except Exception as e: + console.debug(f"Error getting experiment data points for index {experiment_index}: {e}") + return [] + def refreshSamplePage(self): self.drawCalculatedOnSampleChart() self.drawCalculatedOnSldChart() @@ -217,7 +276,10 @@ def qtchartsReplaceCalculatedOnSldChartAndRedraw(self): 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'] @@ -234,7 +296,23 @@ def qtchartsReplaceMeasuredOnExperimentChartAndRedraw(self): series_error_lower.append(point[0], np.log10(point[1] - np.sqrt(point[2]))) nr_points = nr_points + 1 - console.debug(IO.formatMsg('sub', 'Measurede curve', f'{nr_points} points', 'on experiment page', 'replaced')) + console.debug(IO.formatMsg('sub', 'Measured 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() def drawCalculatedAndMeasuredOnAnalysisChart(self): if PLOT_BACKEND == 'QtCharts': diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index 1ccd424e..ae56b777 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -111,6 +111,24 @@ def analysisSetSelectedExperimentIndices(self, indices) -> None: # 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) + + + ######### Connections to relay info between the backend parts def _connect_backend_parts(self) -> None: self._connect_project_page() @@ -129,6 +147,8 @@ 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) @@ -142,6 +162,8 @@ def _connect_analysis_page(self) -> None: 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() @@ -189,3 +211,5 @@ def _refresh_plots(self): 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/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index cbd7e103..78c9cde0 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -199,9 +199,27 @@ QtObject { function analysisRemoveExperiment(value) { activeBackend.analysis.removeExperiment(value) } // Multi-experiment selection support - readonly property int analysisExperimentsSelectedCount: activeBackend.analysis.experimentsSelectedCount - readonly property var analysisSelectedExperimentIndices: activeBackend.analysis.selectedExperimentIndices - function analysisSetSelectedExperimentIndices(value) { activeBackend.analysis.setSelectedExperimentIndices(value) } + readonly property int analysisExperimentsSelectedCount: { + try { + return activeBackend.analysisExperimentsSelectedCount || 1 + } catch (e) { + return 1 + } + } + readonly property var analysisSelectedExperimentIndices: { + try { + return activeBackend.analysisSelectedExperimentIndices || [] + } catch (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 @@ -287,4 +305,27 @@ QtObject { function plottingSetQtChartsSerieRef(value1, value2, value3) { activeBackend.plotting.setQtChartsSerieRef(value1, value2, value3) } function plottingRefreshSample() { activeBackend.plotting.drawCalculatedOnSampleChart() } function plottingRefreshSLD() { activeBackend.plotting.drawCalculatedOnSldChart() } + + // Multi-experiment plotting support + readonly property bool plottingIsMultiExperimentMode: { + try { + return activeBackend.plottingIsMultiExperimentMode || false + } catch (e) { + return false + } + } + readonly property var plottingIndividualExperimentDataList: { + try { + return activeBackend.plottingIndividualExperimentDataList || [] + } catch (e) { + return [] + } + } + function plottingGetExperimentDataPoints(index) { + try { + return activeBackend.plottingGetExperimentDataPoints(index) + } catch (e) { + return [] + } + } } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 5aae4af6..ff78a58e 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -31,6 +31,37 @@ Rectangle { 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 + onIsMultiExperimentModeChanged: { + updateMultiExperimentSeries() + } + + // Watch for changes in experiment data + Connections { + target: { + try { + return Globals.BackendWrapper.plotting || null + } catch (e) { + return null + } + } + function onExperimentDataChanged() { + if (chartView.isMultiExperimentMode) { + updateMultiExperimentSeries() + } + } + } property double xRange: Globals.BackendWrapper.plottingExperimentMaxX - Globals.BackendWrapper.plottingExperimentMinX axisX.title: "q (Å⁻¹)" @@ -48,6 +79,125 @@ Rectangle { calcSerie.onHovered: (point, state) => showMainTooltip(chartView, point, state) + // Multi-experiment series management + function updateMultiExperimentSeries() { + console.log("🔄 Updating multi-experiment series...") + + // Clear existing multi-experiment series + clearMultiExperimentSeries() + + if (!isMultiExperimentMode) { + console.log(" Single experiment mode - showing default series") + // Show default series for single experiment + measured.visible = true + errorUpper.visible = true + errorLower.visible = true + return + } + + // Hide default series in multi-experiment mode + measured.visible = false + errorUpper.visible = false + errorLower.visible = false + + // Get experiment data list + var experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentDataList + console.log(` Creating series for ${experimentDataList.length} experiments`) + + // 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) { + console.log(` 📈 Populating data for experiment ${seriesSet.expIndex}`) + + // Get data points from backend + var dataPoints = Globals.BackendWrapper.plottingGetExperimentDataPoints(seriesSet.expIndex) + + // Clear existing points + seriesSet.measuredSerie.clear() + seriesSet.errorUpperSerie.clear() + seriesSet.errorLowerSerie.clear() + + // Add data points + for (var i = 0; i < dataPoints.length; i++) { + var point = dataPoints[i] + seriesSet.measuredSerie.append(point.x, point.y) + seriesSet.errorUpperSerie.append(point.x, point.errorUpper) + seriesSet.errorLowerSerie.append(point.x, point.errorLower) + } + + console.log(` ✅ Added ${dataPoints.length} points to ${seriesSet.expName}`) + } + // Tool buttons Row { id: toolButtons @@ -136,30 +286,61 @@ 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 } - // Show multi-experiment info if applicable - Rectangle { - visible: (Globals.BackendWrapper.analysisExperimentsSelectedCount || 1) > 1 - width: parent.width - 2 * EaStyle.Sizes.fontPixelSize - height: EaStyle.Sizes.fontPixelSize * 3 - color: "transparent" - border.color: EaStyle.Colors.chartGridLine - border.width: 1 - + // 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 { - anchors.centerIn: parent - text: qsTr("Multi-experiment view\n(%1 experiments)").arg(Globals.BackendWrapper.analysisExperimentsSelectedCount || 1) - font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.8 - color: EaStyle.Colors.themeForegroundHovered - horizontalAlignment: Text.AlignHCenter + text: qsTr("- - - Error bounds") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeForegroundMinor } } } @@ -185,6 +366,17 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measured) + + // Initialize multi-experiment support + console.log("🚀 ExperimentView initialized - checking multi-experiment mode...") + updateMultiExperimentSeries() + } + + // Update series when chart becomes visible + onVisibleChanged: { + if (visible && isMultiExperimentMode) { + updateMultiExperimentSeries() + } } } From 1f614456c568a8ad034650147e1b2c25d21d9105 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 31 Oct 2025 19:26:16 +0100 Subject: [PATCH 14/44] staggered plots with checkbox control --- .../Gui/Globals/Variables.qml | 1 + .../Experiment/MainContent/ExperimentView.qml | 96 ++++++++++++++++++- .../Basic/Groups/ExperimentalDataExplorer.qml | 37 ++++++- 3 files changed, 127 insertions(+), 7 deletions(-) diff --git a/EasyReflectometryApp/Gui/Globals/Variables.qml b/EasyReflectometryApp/Gui/Globals/Variables.qml index d269b1d4..607caea5 100644 --- a/EasyReflectometryApp/Gui/Globals/Variables.qml +++ b/EasyReflectometryApp/Gui/Globals/Variables.qml @@ -11,4 +11,5 @@ QtObject { property bool showLegendOnSamplePage: false property bool showLegendOnExperimentPage: false property bool showLegendOnAnalysisPage: false + property bool useStaggeredPlotting: false } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index ff78a58e..4cee04a2 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -41,12 +41,62 @@ Rectangle { return false } } + property bool useStaggeredPlotting: { + try { + return Globals.Variables.useStaggeredPlotting || false + } catch (e) { + return false + } + } // Watch for changes in multi-experiment mode onIsMultiExperimentModeChanged: { updateMultiExperimentSeries() } + // 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`) + } + } + + 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 experiment data Connections { target: { @@ -187,15 +237,51 @@ Rectangle { seriesSet.errorUpperSerie.clear() seriesSet.errorLowerSerie.clear() - // Add data points + // 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 a much smaller staggering - just enough to separate the experiments visually + // Each experiment gets offset by 50% of its own data range + var offsetStep = expDataRange * 0.5 + yOffset = experimentIndex * offsetStep + + // Ensure we don't exceed reasonable bounds - limit total staggering to 2x original range + var maxTotalOffset = expDataRange * 2 + var currentTotalOffset = (totalExperiments - 1) * offsetStep + + if (currentTotalOffset > maxTotalOffset) { + // Rescale all offsets proportionally to fit within bounds + var scaleFactor = maxTotalOffset / currentTotalOffset + yOffset = experimentIndex * offsetStep * scaleFactor + console.log(` 📏 Rescaling stagger: factor=${scaleFactor.toFixed(3)}, totalOffset=${(currentTotalOffset * scaleFactor).toFixed(3)}`) + } + + console.log(` 🔢 Experiment ${experimentIndex}/${totalExperiments}: offset=${yOffset.toFixed(6)}, expRange=[${expMinY.toExponential(2)}, ${expMaxY.toExponential(2)}]`) + } + + // 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) - seriesSet.errorUpperSerie.append(point.x, point.errorUpper) - seriesSet.errorLowerSerie.append(point.x, point.errorLower) + seriesSet.measuredSerie.append(point.x, point.y + yOffset) + seriesSet.errorUpperSerie.append(point.x, point.errorUpper + yOffset) + seriesSet.errorLowerSerie.append(point.x, point.errorLower + yOffset) } - console.log(` ✅ Added ${dataPoints.length} points to ${seriesSet.expName}`) + console.log(` ✅ Added ${dataPoints.length} points to ${seriesSet.expName} ${useStaggeredPlotting ? '(staggered)' : '(normal)'}`) } // Tool buttons diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml index 2ff19b5c..bdde356b 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml @@ -20,6 +20,14 @@ EaElements.GroupBox { // 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 @@ -74,7 +82,32 @@ EaElements.GroupBox { 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`) + } + } + } + } + Row { spacing: EaStyle.Sizes.fontPixelSize @@ -159,7 +192,7 @@ EaElements.GroupBox { // Selection background overlay - placed as child to avoid layout interference Rectangle { visible: isSelected - anchors.fill: parent.parent + anchors.fill: parent color: EaStyle.Colors.themeForegroundHovered opacity: 0.2 z: -1 From 594152bb4f15f80ba76490bceb326e8df659a327 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 3 Nov 2025 11:24:14 +0100 Subject: [PATCH 15/44] added staggering distance slider --- .../Gui/Globals/Variables.qml | 1 + .../Experiment/MainContent/ExperimentView.qml | 69 ++++++++++++++----- .../Basic/Groups/ExperimentalDataExplorer.qml | 40 ++++++++--- 3 files changed, 81 insertions(+), 29 deletions(-) diff --git a/EasyReflectometryApp/Gui/Globals/Variables.qml b/EasyReflectometryApp/Gui/Globals/Variables.qml index 607caea5..3da4c21d 100644 --- a/EasyReflectometryApp/Gui/Globals/Variables.qml +++ b/EasyReflectometryApp/Gui/Globals/Variables.qml @@ -12,4 +12,5 @@ QtObject { property bool showLegendOnExperimentPage: false property bool showLegendOnAnalysisPage: false property bool useStaggeredPlotting: false + property double staggeringFactor: 0.5 } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 4cee04a2..35de71d3 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -48,6 +48,13 @@ Rectangle { 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: { @@ -56,10 +63,10 @@ Rectangle { // 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}`) + // 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}`) + // 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]) @@ -71,6 +78,35 @@ Rectangle { } } + // 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 @@ -94,7 +130,7 @@ Rectangle { 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`) + // console.log(`📏 Adjusted Y-axis for staggering: [${allMinY.toExponential(2)}, ${allMaxY.toExponential(2)}] with padding`) } // Watch for changes in experiment data @@ -131,13 +167,13 @@ Rectangle { // Multi-experiment series management function updateMultiExperimentSeries() { - console.log("🔄 Updating multi-experiment series...") + // console.log("🔄 Updating multi-experiment series...") // Clear existing multi-experiment series clearMultiExperimentSeries() if (!isMultiExperimentMode) { - console.log(" Single experiment mode - showing default series") + // console.log(" Single experiment mode - showing default series") // Show default series for single experiment measured.visible = true errorUpper.visible = true @@ -152,7 +188,7 @@ Rectangle { // Get experiment data list var experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentDataList - console.log(` Creating series for ${experimentDataList.length} experiments`) + // console.log(` Creating series for ${experimentDataList.length} experiments`) // Create series for each experiment for (var i = 0; i < experimentDataList.length; i++) { @@ -181,7 +217,7 @@ Rectangle { } function createExperimentSeries(expIndex, expName, color) { - console.log(` 📊 Creating series for experiment ${expIndex}: ${expName} (${color})`) + // console.log(` 📊 Creating series for experiment ${expIndex}: ${expName} (${color})`) // Create measured data series var measuredSerie = chartView.createSeries(ChartView.SeriesTypeLine, @@ -227,8 +263,6 @@ Rectangle { } function populateExperimentSeries(seriesSet) { - console.log(` 📈 Populating data for experiment ${seriesSet.expIndex}`) - // Get data points from backend var dataPoints = Globals.BackendWrapper.plottingGetExperimentDataPoints(seriesSet.expIndex) @@ -254,23 +288,22 @@ Rectangle { var expDataRange = expMaxY - expMinY - // Use a much smaller staggering - just enough to separate the experiments visually - // Each experiment gets offset by 50% of its own data range - var offsetStep = expDataRange * 0.5 + // 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 * 2 + 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 - console.log(` 📏 Rescaling stagger: factor=${scaleFactor.toFixed(3)}, totalOffset=${(currentTotalOffset * scaleFactor).toFixed(3)}`) } - console.log(` 🔢 Experiment ${experimentIndex}/${totalExperiments}: offset=${yOffset.toFixed(6)}, expRange=[${expMinY.toExponential(2)}, ${expMaxY.toExponential(2)}]`) } // Add data points with potential offset @@ -280,8 +313,6 @@ Rectangle { seriesSet.errorUpperSerie.append(point.x, point.errorUpper + yOffset) seriesSet.errorLowerSerie.append(point.x, point.errorLower + yOffset) } - - console.log(` ✅ Added ${dataPoints.length} points to ${seriesSet.expName} ${useStaggeredPlotting ? '(staggered)' : '(normal)'}`) } // Tool buttons @@ -454,7 +485,7 @@ Rectangle { measured) // Initialize multi-experiment support - console.log("🚀 ExperimentView initialized - checking multi-experiment mode...") + // console.log("🚀 ExperimentView initialized - checking multi-experiment mode...") updateMultiExperimentSeries() } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml index bdde356b..587ba10c 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml @@ -106,6 +106,26 @@ EaElements.GroupBox { } } } + + // 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 { @@ -315,9 +335,9 @@ EaElements.GroupBox { } function selectSingleExperiment(experimentIndex) { - console.log("selectSingleExperiment called with index:", experimentIndex) + // console.log("selectSingleExperiment called with index:", experimentIndex) selectedExperimentIndices = [experimentIndex] - console.log("Updated selectedExperimentIndices to:", selectedExperimentIndices) + // console.log("Updated selectedExperimentIndices to:", selectedExperimentIndices) updateBackendWithSelectedExperiments() } @@ -333,7 +353,7 @@ EaElements.GroupBox { // 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") + // 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) { @@ -349,16 +369,16 @@ EaElements.GroupBox { // 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) + // 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) + // console.log("Approach 1: Calling analysisSetSelectedExperimentIndices with:", selectedExperimentIndices) Globals.BackendWrapper.analysisSetSelectedExperimentIndices(selectedExperimentIndices) methodCalled = true } @@ -366,7 +386,7 @@ EaElements.GroupBox { // 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) + // console.log("Approach 2: Calling through analysis object with:", selectedExperimentIndices) Globals.BackendWrapper.analysis.setSelectedExperimentIndices(selectedExperimentIndices) methodCalled = true } @@ -378,7 +398,7 @@ EaElements.GroupBox { 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)) + // console.log("Available backend methods:", Object.keys(Globals.BackendWrapper)) } } } @@ -389,7 +409,7 @@ EaElements.GroupBox { selectedExperimentIndices = [] // Notify backend that selection is cleared if (typeof Globals.BackendWrapper.analysisSetSelectedExperimentIndices === 'function') { - console.log("Calling backend with empty array to clear selection") + // console.log("Calling backend with empty array to clear selection") Globals.BackendWrapper.analysisSetSelectedExperimentIndices([]) } } From 3b5a6557eabcaf63b4f3c88a0ab9353503cfa843 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 3 Nov 2025 15:15:35 +0100 Subject: [PATCH 16/44] fix the validator. Add multi-model constraint UI --- .../Backends/Py/logic/parameters.py | 5 +-- EasyReflectometryApp/Backends/Py/sample.py | 33 +++++++++++++++---- .../Sidebar/Advanced/Groups/Constraints.qml | 10 +++++- .../Pages/Sample/Sidebar/Advanced/Layout.qml | 4 +++ 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index c0eacdd0..d93e8997 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -44,8 +44,9 @@ def constraint_metadata(self) -> list[dict[str, Any]]: context = self.constraint_context() metadata: list[dict[str, Any]] = [] for entry in context: - if not entry['independent']: - continue + # Include ALL parameters (both independent and dependent) for constraint expressions + # if not entry['independent']: + # continue metadata.append({ 'alias': entry['alias'], 'displayName': entry['display_name'], diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 408b47d8..4f58da15 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -460,8 +460,10 @@ def _build_constraint_context(self) -> Tuple[list[dict[str, Any]], Dict[str, Des alias_lookup: Dict[str, DescriptorNumber] = {} display_lookup: Dict[str, str] = {} for entry in context: - alias_lookup[entry['alias']] = entry['object'] - display_lookup[entry['alias']] = entry['display_name'] + 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( @@ -479,17 +481,33 @@ def _extract_dependency_map( return used_aliases def _evaluate_constraint_expression( - self, expression: str, dependency_map: Dict[str, DescriptorNumber] + 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) - for alias, dependency in dependency_map.items(): + + # 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) - result = interpreter.eval(expression, raise_errors=True) + + 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 @@ -548,7 +566,10 @@ def _prepare_constraint_instruction( dependency_map = self._extract_dependency_map(expression_text, alias_lookup) try: - evaluation_result = self._evaluate_constraint_expression(expression_text, dependency_map) + # 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: diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml index fb8bf7fd..2742bfff 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml @@ -8,7 +8,7 @@ import Gui.Globals as Globals EaElements.GroupBox { id: constraintsGroup - title: qsTr("Sample constraints") + title: qsTr("Single constraints") enabled: true last: false @@ -75,6 +75,14 @@ EaElements.GroupBox { 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 diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml index 5824765a..7ce45a88 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml @@ -15,6 +15,10 @@ EaComponents.SideBarColumn { Groups.Constraints{ enabled: Globals.BackendWrapper.analysisIsFitFinished } + Groups.ModelConstraints{ + enabled: Globals.BackendWrapper.analysisIsFitFinished + } + /* property int independentParCurrentIndex: 0 property int dependentParCurrentIndex: 0 From d2daab4f14a5e6e2a9a517e1a1c0ce4bb38835ae Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Wed, 5 Nov 2025 22:03:13 +0100 Subject: [PATCH 17/44] notify the layers of the name change --- EasyReflectometryApp/Backends/Py/sample.py | 1 + 1 file changed, 1 insertion(+) diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index ff8eb4c0..daa22bee 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -155,6 +155,7 @@ 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) From aacf1f525e5c7fc7e953fa7739fc7274e87bf5a3 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 7 Nov 2025 10:53:38 +0100 Subject: [PATCH 18/44] Account for subphase and superphase constraints --- EasyReflectometryApp/Backends/Py/analysis.py | 17 +++++++++++++++ .../Backends/Py/logic/parameters.py | 1 + .../Backends/Py/logic/project.py | 8 +++++++ .../Backends/Py/py_backend.py | 1 - EasyReflectometryApp/Backends/Py/sample.py | 21 +++++++++++++++++-- .../Gui/Globals/BackendWrapper.qml | 5 ++++- 6 files changed, 49 insertions(+), 4 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index 40e3d45d..a0a59bdd 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -37,6 +37,7 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._experiments_logic = ExperimentLogic(project_lib) self._minimizers_logic = MinimizersLogic(project_lib) self._chached_parameters = None + self._chached_enabled_parameters = None # 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 @@ -398,6 +399,22 @@ def fitableParameters(self) -> List[dict[str]]: 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 + enabled_parameters = [] + #import time + #t0 = time.time() + for parameter in self._parameters_logic.parameters: + if parameter['enabled'] == False: + continue + enabled_parameters.append(parameter) + #t1 = time.time() + #print(f"Enabled parameters computation time: {t1 - t0:.4f} seconds") + self._chached_enabled_parameters = enabled_parameters + return enabled_parameters + @Property(int, notify=parametersIndexChanged) def currentParameterIndex(self) -> int: return self._parameters_logic.current_index() diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index a2ed7853..58ade0a2 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -144,6 +144,7 @@ def _get_dependency_expression(param: Parameter) -> str: 'fit': parameter.free, 'independent': parameter.independent, 'dependency': _get_dependency_expression(parameter), + 'enabled': parameter.enabled if hasattr(parameter, 'enabled') else True, 'object': parameter, # Direct reference to the Parameter object }) diff --git a/EasyReflectometryApp/Backends/Py/logic/project.py b/EasyReflectometryApp/Backends/Py/logic/project.py index 9a7f4677..d27d1118 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 diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index ae56b777..92ef2cdf 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -23,7 +23,6 @@ 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() diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index daa22bee..3df6fd7b 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -166,6 +166,7 @@ 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() @@ -420,8 +421,24 @@ def parameterNames(self) -> list[dict[str, str]]: return [parameter['name'] for parameter in self._parameters_logic.parameters] @Property('QVariantList', notify=layersChange) - def dependentParameterNames(self) -> list[dict[str, str]]: - return [parameter['name'] for parameter in self._parameters_logic.parameters if parameter['independent']] + def enabledParameterNames(self) -> list[str]: + enabled_param_names = [] + for parameter in self._parameters_logic.parameters: + if hasattr(parameter['object'], 'enabled') and parameter['object'].enabled == False: + 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 parameter['object'].enabled == False: + continue + dep_param_names.append(parameter['name']) + return dep_param_names @Property('QVariantList', notify=layersChange) def relationOperators(self) -> list[str]: diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 78c9cde0..435a2af8 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -159,6 +159,7 @@ 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 @@ -234,8 +235,10 @@ 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) } From edec400026496953fbe0b463104e29b66d33a032 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 7 Nov 2025 14:27:51 +0100 Subject: [PATCH 19/44] Move SLD underneath the main plot, as requested --- .../Gui/Pages/Analysis/Layout.qml | 9 +- .../Analysis/MainContent/CombinedView.qml | 344 ++++++++++++++++++ .../Gui/Pages/Sample/Layout.qml | 9 +- .../Pages/Sample/MainContent/CombinedView.qml | 297 +++++++++++++++ 4 files changed, 645 insertions(+), 14 deletions(-) create mode 100644 EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml create mode 100644 EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml index d0d3ea12..0620e8ef 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml @@ -15,17 +15,12 @@ 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/AnalysisView.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/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml new file mode 100644 index 00000000..bebdb2c3 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -0,0 +1,344 @@ +// 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 EasyApp.Gui.Charts as EaCharts + +import Gui.Globals as Globals + + +Rectangle { + id: container + + color: EaStyle.Colors.chartBackground + + Column { + anchors.fill: parent + spacing: 0 + + // Analysis Chart (2/3 height) + Rectangle { + id: analysisContainer + width: parent.width + height: parent.height * 0.67 + 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 + + 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 R(q)" + 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 + sldChartView.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 + sldChartView.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() + sldChartView.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 + + EaElements.Label { + text: '━ I (Measured)' + color: analysisChartView.measSerie.color + } + EaElements.Label { + text: '━ (calculated)' + color: analysisChartView.calcSerie.color + } + + EaElements.Label { + readonly property var selectedIndices: Globals.BackendWrapper.analysisSelectedExperimentIndices || [] + + visible: selectedIndices.length > 1 + text: qsTr('Selected: %1').arg(selectedIndices.map(index => index + 1).join(', ')) + color: EaStyle.Colors.themeAccent + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.85 + wrapMode: Text.NoWrap + onSelectedIndicesChanged: console.debug('AnalysisView legend - selected count:', selectedIndices.length) + } + + Rectangle { + visible: (Globals.BackendWrapper.analysisExperimentsSelectedCount || 1) > 1 + width: parent.width - 2 * EaStyle.Sizes.fontPixelSize + height: EaStyle.Sizes.fontPixelSize * 3 + color: "transparent" + border.color: EaStyle.Colors.chartGridLine + border.width: 1 + + EaElements.Label { + anchors.centerIn: parent + text: qsTr("Multi-experiment view\n(%1 experiments)") + .arg(Globals.BackendWrapper.analysisExperimentsSelectedCount || 1) + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.8 + color: EaStyle.Colors.themeForegroundHovered + horizontalAlignment: Text.AlignHCenter + } + } + } + } + + 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) + } + + // Sync X-axis with SLD chart + onAxisXChanged: syncXAxes() + + Connections { + target: analysisChartView.axisX + function onMinChanged() { syncXAxes() } + function onMaxChanged() { syncXAxes() } + } + } + } + + // SLD Chart (1/3 height) + Rectangle { + id: sldContainer + width: parent.width + height: parent.height * 0.33 + color: EaStyle.Colors.chartBackground + + EaCharts.QtCharts1dMeasVsCalc { + id: sldChartView + + anchors.fill: parent + 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(sldChartView, sldDataToolTip, 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 + } + + // Legend + Rectangle { + visible: Globals.Variables.showLegendOnAnalysisPage + + x: sldChartView.plotArea.x + sldChartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize + y: sldChartView.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: sldChartView.calcSerie.color + } + } + } + + EaElements.ToolTip { + id: sldDataToolTip + + arrowLength: 0 + textFormat: Text.RichText + } + + // Data is set in python backend (plotting_1d.py) + Component.onCompleted: { + Globals.References.pages.analysis.mainContent.sldView = sldChartView + Globals.BackendWrapper.plottingSetQtChartsSerieRef('samplePage', + 'sldSerie', + sldChartView.calcSerie) + Globals.BackendWrapper.plottingRefreshSLD() + } + } + } + } + + // 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 + } + + function syncXAxes() { + // Keep both charts' X axes synchronized + if (analysisChartView.axisX.min !== sldChartView.axisX.min || + analysisChartView.axisX.max !== sldChartView.axisX.max) { + sldChartView.axisX.min = analysisChartView.axisX.min + sldChartView.axisX.max = analysisChartView.axisX.max + } + } +} 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..ad2d8c4e --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -0,0 +1,297 @@ +// 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 EasyApp.Gui.Charts as EaCharts + +import Gui.Globals as Globals + + +Rectangle { + id: container + + color: EaStyle.Colors.chartBackground + + Column { + anchors.fill: parent + spacing: 0 + + // Sample Chart (2/3 height) + Rectangle { + id: sampleContainer + width: parent.width + height: parent.height * 0.67 + color: EaStyle.Colors.chartBackground + + EaCharts.QtCharts1dMeasVsCalc { + id: sampleChartView + + anchors.fill: parent + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + + 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 + + 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(sampleChartView, sampleDataToolTip, point, state) + + calcSerie.color: { + var idx = Globals.BackendWrapper.sampleCurrentModelIndex + Globals.BackendWrapper.sampleModels[idx].color + } + + // Tool buttons + Row { + id: sampleToolButtons + + 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 = !sampleChartView.allowHover + } + + 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 = !sampleChartView.allowZoom + sldChartView.allowZoom = sampleChartView.allowZoom + } + } + + 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 = !sampleChartView.allowZoom + sldChartView.allowZoom = sampleChartView.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: { + sampleChartView.resetAxes() + sldChartView.resetAxes() + } + } + } + + // Legend + 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 + + EaElements.Label { + text: '━ I (sample)' + color: sampleChartView.calcSerie.color + } + } + } + + EaElements.ToolTip { + id: sampleDataToolTip + + arrowLength: 0 + textFormat: Text.RichText + } + + // Data is set in python backend (plotting_1d.py) + Component.onCompleted: { + Globals.References.pages.sample.mainContent.sampleView = sampleChartView + Globals.BackendWrapper.plottingSetQtChartsSerieRef('samplePage', + 'sampleSerie', + sampleChartView.calcSerie) + Globals.BackendWrapper.plottingRefreshSample() + } + + // Sync X-axis with SLD chart + onAxisXChanged: syncXAxes() + + Connections { + target: sampleChartView.axisX + function onMinChanged() { syncXAxes() } + function onMaxChanged() { syncXAxes() } + } + } + } + + // SLD Chart (1/3 height) + Rectangle { + id: sldContainer + width: parent.width + height: parent.height * 0.33 + color: EaStyle.Colors.chartBackground + + EaCharts.QtCharts1dMeasVsCalc { + id: sldChartView + + anchors.fill: parent + 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(sldChartView, sldDataToolTip, 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 + } + + // Legend + Rectangle { + visible: Globals.Variables.showLegendOnSamplePage + + x: sldChartView.plotArea.x + sldChartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize + y: sldChartView.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: sldChartView.calcSerie.color + } + } + } + + EaElements.ToolTip { + id: sldDataToolTip + + arrowLength: 0 + textFormat: Text.RichText + } + + // Data is set in python backend (plotting_1d.py) + Component.onCompleted: { + Globals.References.pages.sample.mainContent.sldView = sldChartView + Globals.BackendWrapper.plottingSetQtChartsSerieRef('samplePage', + 'sldSerie', + sldChartView.calcSerie) + Globals.BackendWrapper.plottingRefreshSLD() + } + } + } + } + + // 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 + } + + function syncXAxes() { + // Keep both charts' X axes synchronized + if (sampleChartView.axisX.min !== sldChartView.axisX.min || + sampleChartView.axisX.max !== sldChartView.axisX.max) { + sldChartView.axisX.min = sampleChartView.axisX.min + sldChartView.axisX.max = sampleChartView.axisX.max + } + } +} From 7654ffdd3415c3153b1a9765ccb733f3575cd8e7 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 7 Nov 2025 14:43:01 +0100 Subject: [PATCH 20/44] add splitters in Sample and Analysis --- .../Pages/Analysis/MainContent/CombinedView.qml | 15 +++++++++------ .../Gui/Pages/Sample/MainContent/CombinedView.qml | 15 +++++++++------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index bebdb2c3..b91b52e1 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Controls +import QtQuick.Layouts import QtCharts import EasyApp.Gui.Style as EaStyle @@ -19,15 +20,16 @@ Rectangle { color: EaStyle.Colors.chartBackground - Column { + SplitView { anchors.fill: parent - spacing: 0 + orientation: Qt.Vertical // Analysis Chart (2/3 height) Rectangle { id: analysisContainer - width: parent.width - height: parent.height * 0.67 + SplitView.fillHeight: true + SplitView.preferredHeight: parent.height * 0.67 + SplitView.minimumHeight: 100 color: EaStyle.Colors.chartBackground EaCharts.QtCharts1dMeasVsCalc { @@ -238,8 +240,9 @@ Rectangle { // SLD Chart (1/3 height) Rectangle { id: sldContainer - width: parent.width - height: parent.height * 0.33 + SplitView.fillHeight: true + SplitView.preferredHeight: parent.height * 0.33 + SplitView.minimumHeight: 80 color: EaStyle.Colors.chartBackground EaCharts.QtCharts1dMeasVsCalc { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml index ad2d8c4e..989755ec 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Controls +import QtQuick.Layouts import QtCharts import EasyApp.Gui.Style as EaStyle @@ -19,15 +20,16 @@ Rectangle { color: EaStyle.Colors.chartBackground - Column { + SplitView { anchors.fill: parent - spacing: 0 + orientation: Qt.Vertical // Sample Chart (2/3 height) Rectangle { id: sampleContainer - width: parent.width - height: parent.height * 0.67 + SplitView.fillHeight: true + SplitView.preferredHeight: parent.height * 0.67 + SplitView.minimumHeight: 100 color: EaStyle.Colors.chartBackground EaCharts.QtCharts1dMeasVsCalc { @@ -191,8 +193,9 @@ Rectangle { // SLD Chart (1/3 height) Rectangle { id: sldContainer - width: parent.width - height: parent.height * 0.33 + SplitView.fillHeight: true + SplitView.preferredHeight: parent.height * 0.33 + SplitView.minimumHeight: 80 color: EaStyle.Colors.chartBackground EaCharts.QtCharts1dMeasVsCalc { From 87e9276c0cedd2409640d877ec1f37a0003f2c3a Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 12 Nov 2025 10:05:33 +0100 Subject: [PATCH 21/44] reload the models after orso file load --- EasyReflectometryApp/Backends/Py/logic/project.py | 3 +++ EasyReflectometryApp/Backends/Py/project.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/EasyReflectometryApp/Backends/Py/logic/project.py b/EasyReflectometryApp/Backends/Py/logic/project.py index 9a7f4677..6752d559 100644 --- a/EasyReflectometryApp/Backends/Py/logic/project.py +++ b/EasyReflectometryApp/Backends/Py/logic/project.py @@ -105,6 +105,9 @@ def load_experiment(self, path: str) -> None: def load_new_experiment(self, path: str) -> None: self._project_lib.load_new_experiment(path) + def set_sample_from_orso(self, sample) -> None: + self._project_lib.set_sample_from_orso(sample) + def reset(self) -> None: self._project_lib.reset() self._project_lib.default_model() diff --git a/EasyReflectometryApp/Backends/Py/project.py b/EasyReflectometryApp/Backends/Py/project.py index a27d0005..ac06b46c 100644 --- a/EasyReflectometryApp/Backends/Py/project.py +++ b/EasyReflectometryApp/Backends/Py/project.py @@ -111,4 +111,7 @@ def sampleLoad(self, url: str) -> None: # Load the sample model sample = load_orso_model(orso_data) # Set the sample in the project logic - pass + self._logic.set_sample_from_orso(sample) + # notify listeners + self.externalProjectLoaded.emit() + From f8ca2cbc08e3df0eafefe4398b5b53d584a297c2 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 12 Nov 2025 11:31:13 +0100 Subject: [PATCH 22/44] use correct branch name --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7143c71e..e3969027 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ - 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@unpinned_pyside6', + 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@develop', 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@orso_models', 'PySide6', 'toml', From ed01dd0bf0f876b62ae3c6dec5372829360fb9d7 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Tue, 18 Nov 2025 08:57:18 +0100 Subject: [PATCH 23/44] reparent ERL dependency. Fix multiple experiments display --- EasyReflectometryApp/Backends/Py/analysis.py | 3 +++ pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index a0a59bdd..efefdfe0 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -31,6 +31,7 @@ class Analysis(QObject): def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) + self._project_lib = project_lib self._parameters_logic = ParametersLogic(project_lib) self._fitting_logic = FittingLogic(project_lib) self._calculators_logic = CalculatorsLogic(project_lib) @@ -151,6 +152,7 @@ def setExperimentCurrentIndex(self, new_value: int) -> None: 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: @@ -233,6 +235,7 @@ def setSelectedExperimentIndices(self, indices: List[int]) -> None: # 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) diff --git a/pyproject.toml b/pyproject.toml index c24233f4..903a211d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git', - 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@model_color_update', + 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', 'PySide6>=6.8,<6.9', # Issue with TableView formatting in 6.9, 'toml', ] From 1b9497203a3e70d27c2f6356bf5cae6c2428fcc8 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Mon, 24 Nov 2025 22:07:04 +0100 Subject: [PATCH 24/44] reparent ERL --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e3969027..969d6bc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@develop', - 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@orso_models', + 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', 'PySide6', 'toml', ] From 9bafdcba041eddb25b1505cd6ac30580b70a09b6 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Fri, 28 Nov 2025 10:54:09 +0100 Subject: [PATCH 25/44] minor fixes after ERL update. ruff cleanup --- EasyReflectometryApp/Backends/Py/analysis.py | 9 ++- .../Backends/Py/plotting_1d.py | 5 +- EasyReflectometryApp/Backends/Py/sample.py | 4 +- .../Sidebar/Basic/Groups/Experiments.qml | 3 +- .../Experiment/MainContent/ExperimentView.qml | 66 ++++++++++--------- EasyReflectometryApp/main.py | 2 +- 6 files changed, 47 insertions(+), 42 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index efefdfe0..0055dfdd 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -230,7 +230,7 @@ def setSelectedExperimentIndices(self, indices: List[int]) -> None: 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() + # 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: @@ -298,8 +298,6 @@ 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. """ - import numpy as np - from easyreflectometry.data import DataSet1D if not self._selected_experiment_indices: return [] @@ -324,7 +322,8 @@ def get_individual_experiment_data_list(self): 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}" + 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({ @@ -410,7 +409,7 @@ def enabledParameters(self) -> list[dict[str]]: #import time #t0 = time.time() for parameter in self._parameters_logic.parameters: - if parameter['enabled'] == False: + if not parameter['enabled']: continue enabled_parameters.append(parameter) #t1 = time.time() diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 8dd22ba7..1403f100 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -93,7 +93,7 @@ def experiment_data(self) -> DataSet1D: # 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 as e: + except IndexError: data = DataSet1D( name='Experiment Data empty', x=np.empty(0), @@ -110,7 +110,8 @@ def is_multi_experiment_mode(self) -> bool: if hasattr(self._proxy, '_analysis') and hasattr(self._proxy._analysis, '_selected_experiment_indices'): return len(self._proxy._analysis._selected_experiment_indices) > 1 except Exception: - pass + # log the exception for debugging purposes + console.debug("Exception occurred while checking multi-experiment mode") return False @property diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 3df6fd7b..7f1281a9 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -424,7 +424,7 @@ def parameterNames(self) -> list[dict[str, str]]: def enabledParameterNames(self) -> list[str]: enabled_param_names = [] for parameter in self._parameters_logic.parameters: - if hasattr(parameter['object'], 'enabled') and parameter['object'].enabled == False: + if hasattr(parameter['object'], 'enabled') and not parameter['object'].enabled: continue enabled_param_names.append(parameter['name']) return enabled_param_names @@ -435,7 +435,7 @@ def dependentParameterNames(self) -> list[str]: for parameter in self._parameters_logic.parameters: if not parameter['independent']: continue - if hasattr(parameter['object'], 'enabled') and parameter['object'].enabled == False: + if hasattr(parameter['object'], 'enabled') and not parameter['object'].enabled: continue dep_param_names.append(parameter['name']) return dep_param_names diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml index f72ba56f..4fb9e390 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml @@ -222,7 +222,7 @@ EaElements.GroupBox { function clearAllSelections() { wasMultiSelected = false selectedExperimentIndices = [] - Globals.BackendWrapper.analysisSetSelectedExperimentIndices([]) + // Don't send empty array to backend - let subsequent selection handle it } function updateBackendWithSelectedExperiments() { @@ -230,6 +230,7 @@ EaElements.GroupBox { return } + // console.log(`📊 Updating backend with selection: [${selectedExperimentIndices.join(', ')}]`) Globals.BackendWrapper.analysisSetSelectedExperimentIndices(selectedExperimentIndices) var primaryIndex = selectedExperimentIndices[0] diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 35de71d3..905dc7c9 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -57,16 +57,18 @@ Rectangle { } // Watch for changes in multi-experiment mode - onIsMultiExperimentModeChanged: { - updateMultiExperimentSeries() - } + // 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}`) + // 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}`) + // 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]) @@ -80,9 +82,9 @@ Rectangle { // Watch for changes in staggering factor onStaggeringFactorChanged: { - // console.log(`🔄 ExperimentView detected staggering factor change: ${staggeringFactor.toFixed(2)}`) + // 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`) + // 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]) @@ -96,9 +98,9 @@ Rectangle { Connections { target: Globals.Variables function onStaggeringFactorChanged() { - // console.log(`🔄 Direct watcher: Globals.Variables.staggeringFactor changed to ${Globals.Variables.staggeringFactor}`) + // 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`) + // console.log(`Forcing refresh of ${chartView.multiExperimentSeries.length} series`) for (var i = 0; i < chartView.multiExperimentSeries.length; i++) { chartView.populateExperimentSeries(chartView.multiExperimentSeries[i]) } @@ -133,19 +135,14 @@ Rectangle { // console.log(`📏 Adjusted Y-axis for staggering: [${allMinY.toExponential(2)}, ${allMaxY.toExponential(2)}] with padding`) } - // Watch for changes in experiment data + // Watch for changes in multi-experiment selection Connections { - target: { - try { - return Globals.BackendWrapper.plotting || null - } catch (e) { - return null - } - } - function onExperimentDataChanged() { - if (chartView.isMultiExperimentMode) { - updateMultiExperimentSeries() - } + target: Globals.BackendWrapper.activeBackend + 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() } } @@ -167,13 +164,13 @@ Rectangle { // Multi-experiment series management function updateMultiExperimentSeries() { - // console.log("🔄 Updating multi-experiment series...") + // console.log("Updating multi-experiment series...") + // console.log(` isMultiExperimentMode: ${isMultiExperimentMode}`) // Clear existing multi-experiment series clearMultiExperimentSeries() if (!isMultiExperimentMode) { - // console.log(" Single experiment mode - showing default series") // Show default series for single experiment measured.visible = true errorUpper.visible = true @@ -181,15 +178,22 @@ Rectangle { return } - // Hide default series in multi-experiment mode + // 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 - // Get experiment data list - var experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentDataList - // console.log(` Creating series for ${experimentDataList.length} experiments`) - // Create series for each experiment for (var i = 0; i < experimentDataList.length; i++) { var expData = experimentDataList[i] @@ -217,7 +221,7 @@ Rectangle { } function createExperimentSeries(expIndex, expName, color) { - // console.log(` 📊 Creating series for experiment ${expIndex}: ${expName} (${color})`) + // console.log(` Creating series for experiment ${expIndex}: ${expName} (${color})`) // Create measured data series var measuredSerie = chartView.createSeries(ChartView.SeriesTypeLine, @@ -485,7 +489,7 @@ Rectangle { measured) // Initialize multi-experiment support - // console.log("🚀 ExperimentView initialized - checking multi-experiment mode...") + // console.log("ExperimentView initialized - checking multi-experiment mode...") updateMultiExperimentSeries() } diff --git a/EasyReflectometryApp/main.py b/EasyReflectometryApp/main.py index b0251155..e89a6b59 100644 --- a/EasyReflectometryApp/main.py +++ b/EasyReflectometryApp/main.py @@ -8,9 +8,9 @@ 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 -from PySide6.QtGui import QIcon try: # Running locally from Backends.Py import PyBackend From 74fe3308d0f7c96650591e173d9c8e33c9081371 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 2 Dec 2025 13:58:12 +0100 Subject: [PATCH 26/44] multiplotting in Analysis --- .../Backends/Py/plotting_1d.py | 70 +++++- .../Backends/Py/py_backend.py | 5 + .../Gui/Globals/BackendWrapper.qml | 7 + .../Analysis/MainContent/AnalysisView.qml | 217 ++++++++++++++++-- .../Analysis/MainContent/CombinedView.qml | 205 +++++++++++++++-- 5 files changed, 452 insertions(+), 52 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 1403f100..cd43450e 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -110,8 +110,7 @@ def is_multi_experiment_mode(self) -> bool: if hasattr(self._proxy, '_analysis') and hasattr(self._proxy._analysis, '_selected_experiment_indices'): return len(self._proxy._analysis._selected_experiment_indices) > 1 except Exception: - # log the exception for debugging purposes - console.debug("Exception occurred while checking multi-experiment mode") + pass return False @property @@ -233,6 +232,54 @@ def getExperimentDataPoints(self, experiment_index: int) -> list: 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 + 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 + break + 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.sample_data_for_model_at_index(model_index, q_filtered) + + points = [] + exp_points = list(exp_data.data_points()) + calc_y = calc_data.y + + calc_idx = 0 + for point in exp_points: + if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]: + calc_y_val = calc_y[calc_idx] if calc_idx < len(calc_y) else point[1] + points.append({ + 'x': float(point[0]), + 'measured': float(np.log10(point[1])), + '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() @@ -317,7 +364,24 @@ def qtchartsReplaceMultiExperimentChartAndRedraw(self): 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'] diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index 92ef2cdf..306904a5 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -126,6 +126,11 @@ 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 diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 435a2af8..cb306afb 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -331,4 +331,11 @@ QtObject { return [] } } + function plottingGetAnalysisDataPoints(index) { + try { + return activeBackend.plottingGetAnalysisDataPoints(index) + } catch (e) { + return [] + } + } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index d46a1f3f..0baf81cd 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,31 @@ Rectangle { 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: { + console.log("Analysis: isMultiExperimentMode changed to: " + isMultiExperimentMode) + updateMultiExperimentSeries() + } + + // Watch for changes in multi-experiment selection + Connections { + target: Globals.BackendWrapper.activeBackend + function onMultiExperimentSelectionChanged() { + console.log("Analysis: Multi-experiment selection changed - updating series") + chartView.updateMultiExperimentSeries() + } + } property double xRange: Globals.BackendWrapper.plottingAnalysisMaxX - Globals.BackendWrapper.plottingAnalysisMinX axisX.title: "q (Å⁻¹)" @@ -58,6 +82,114 @@ Rectangle { 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 { id: toolButtons @@ -147,41 +279,66 @@ Rectangle { 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 } - EaElements.Label { - readonly property var selectedIndices: Globals.BackendWrapper.analysisSelectedExperimentIndices || [] - - visible: selectedIndices.length > 1 - text: qsTr('Selected: %1').arg(selectedIndices.map(index => index + 1).join(', ')) - color: EaStyle.Colors.themeAccent - font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.85 - wrapMode: Text.NoWrap - onSelectedIndicesChanged: console.debug('AnalysisView legend - selected count:', selectedIndices.length) - } + // Multi-experiment legend + Column { + visible: chartView.isMultiExperimentMode + spacing: EaStyle.Sizes.fontPixelSize * 0.2 - Rectangle { - visible: (Globals.BackendWrapper.analysisExperimentsSelectedCount || 1) > 1 - width: parent.width - 2 * EaStyle.Sizes.fontPixelSize - height: EaStyle.Sizes.fontPixelSize * 3 - color: "transparent" - border.color: EaStyle.Colors.chartGridLine - border.width: 1 + 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 { - anchors.centerIn: parent - text: qsTr("Multi-experiment view\n(%1 experiments)") - .arg(Globals.BackendWrapper.analysisExperimentsSelectedCount || 1) - font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.8 - color: EaStyle.Colors.themeForegroundHovered - horizontalAlignment: Text.AlignHCenter + 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 } } } @@ -204,6 +361,16 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + + // Initialize multi-experiment support + updateMultiExperimentSeries() + } + + // Update series when chart becomes visible + onVisibleChanged: { + if (visible && isMultiExperimentMode) { + updateMultiExperimentSeries() + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index b91b52e1..06ad54f7 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -45,6 +45,135 @@ Rectangle { 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 + function onMultiExperimentSelectionChanged() { + analysisChartView.updateMultiExperimentSeries() + } + } + + // 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 (Å⁻¹)" @@ -168,41 +297,66 @@ Rectangle { 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 { - text: '━ (calculated)' + visible: !analysisChartView.isMultiExperimentMode + text: '━ (Calculated)' color: analysisChartView.calcSerie.color } - EaElements.Label { - readonly property var selectedIndices: Globals.BackendWrapper.analysisSelectedExperimentIndices || [] - - visible: selectedIndices.length > 1 - text: qsTr('Selected: %1').arg(selectedIndices.map(index => index + 1).join(', ')) - color: EaStyle.Colors.themeAccent - font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.85 - wrapMode: Text.NoWrap - onSelectedIndicesChanged: console.debug('AnalysisView legend - selected count:', selectedIndices.length) - } + // 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 + } - Rectangle { - visible: (Globals.BackendWrapper.analysisExperimentsSelectedCount || 1) > 1 - width: parent.width - 2 * EaStyle.Sizes.fontPixelSize - height: EaStyle.Sizes.fontPixelSize * 3 - color: "transparent" - border.color: EaStyle.Colors.chartGridLine - border.width: 1 + 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 { - anchors.centerIn: parent - text: qsTr("Multi-experiment view\n(%1 experiments)") - .arg(Globals.BackendWrapper.analysisExperimentsSelectedCount || 1) - font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.8 - color: EaStyle.Colors.themeForegroundHovered - horizontalAlignment: Text.AlignHCenter + 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 } } } @@ -224,6 +378,9 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + + // Initialize multi-experiment support + updateMultiExperimentSeries() } // Sync X-axis with SLD chart From 843aa84ea2d5495b032d9cd5a7fdeefec39250d0 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 4 Dec 2025 15:16:59 +0100 Subject: [PATCH 27/44] fixes to multiple sample loads --- .../Backends/Py/logic/minimizers.py | 34 ++++++++++++++----- .../Backends/Py/logic/project.py | 6 ++++ EasyReflectometryApp/Backends/Py/project.py | 4 +-- .../Backends/Py/py_backend.py | 1 + EasyReflectometryApp/Backends/Py/sample.py | 3 +- 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/logic/minimizers.py b/EasyReflectometryApp/Backends/Py/logic/minimizers.py index 9c4756e6..66ba5f26 100644 --- a/EasyReflectometryApp/Backends/Py/logic/minimizers.py +++ b/EasyReflectometryApp/Backends/Py/logic/minimizers.py @@ -26,32 +26,48 @@ 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 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) + if self._project_lib._fitter is not None: + enum_new_minimizer = self._list_available_minimizers[new_value] + self._project_lib._fitter.switch_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 100 # 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 + if self._multi_fitter is None: + return False + if new_value != self._multi_fitter.tolerance: + self._multi_fitter.tolerance = new_value print(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 + if self._multi_fitter is None: + return False + if new_value != self._multi_fitter.max_evaluations: + self._multi_fitter.max_evaluations = new_value print(new_value) return True return False diff --git a/EasyReflectometryApp/Backends/Py/logic/project.py b/EasyReflectometryApp/Backends/Py/logic/project.py index 43635001..adf8976c 100644 --- a/EasyReflectometryApp/Backends/Py/logic/project.py +++ b/EasyReflectometryApp/Backends/Py/logic/project.py @@ -116,6 +116,12 @@ def load_new_experiment(self, path: str) -> None: 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 reset(self) -> None: self._project_lib.reset() self._project_lib.default_model() diff --git a/EasyReflectometryApp/Backends/Py/project.py b/EasyReflectometryApp/Backends/Py/project.py index ac06b46c..6bd48727 100644 --- a/EasyReflectometryApp/Backends/Py/project.py +++ b/EasyReflectometryApp/Backends/Py/project.py @@ -110,8 +110,8 @@ def sampleLoad(self, url: str) -> None: orso_data = orso.load_orso(generalizePath(url)) # Load the sample model sample = load_orso_model(orso_data) - # Set the sample in the project logic - self._logic.set_sample_from_orso(sample) + # Add the sample as a new model in the project + self._logic.add_sample_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 306904a5..bf4286a8 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -188,6 +188,7 @@ def _relay_project_page_project_changed(self): self._analysis._clearCacheAndEmitParametersChanged() self._status.statusChanged.emit() self._summary.summaryChanged.emit() + self._plotting_1d.reset_data() self._refresh_plots() def _relay_sample_page_sample_changed(self): diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 19a3f63a..1047b809 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -8,13 +8,12 @@ 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 from PySide6.QtCore import Slot -from easyscience.variable.descriptor_number import DescriptorNumber - from .logic.assemblies import Assemblies as AssembliesLogic from .logic.layers import Layers as LayersLogic from .logic.material import Material as MaterialLogic From 27ff62c350cb2f73ea0f9c98f6b8203561f89656 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 5 Dec 2025 09:56:17 +0100 Subject: [PATCH 28/44] update ERL dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 969d6bc9..d84cae28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@develop', - 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', + 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@append_sample', 'PySide6', 'toml', ] From 944b89fc2655b7bec9f1bfb6200e7d7d4d8c3349 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 5 Jan 2026 15:29:39 +0100 Subject: [PATCH 29/44] fix the SLD chart on the Analysis page --- .../Backends/Py/plotting_1d.py | 25 ++++++++++++++----- .../Analysis/MainContent/CombinedView.qml | 2 +- .../Pages/Analysis/MainContent/SldView.qml | 4 +-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index cd43450e..1781976a 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -40,6 +40,7 @@ def __init__(self, project_lib: ProjectLib, parent=None): 'analysisPage': { 'calculatedSerie': None, 'measuredSerie': None, + 'sldSerie': None, }, } } @@ -314,13 +315,25 @@ 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')) def drawMeasuredOnExperimentChart(self): if PLOT_BACKEND == 'QtCharts': diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index 06ad54f7..b3ebd0de 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -471,7 +471,7 @@ Rectangle { // Data is set in python backend (plotting_1d.py) Component.onCompleted: { Globals.References.pages.analysis.mainContent.sldView = sldChartView - Globals.BackendWrapper.plottingSetQtChartsSerieRef('samplePage', + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'sldSerie', sldChartView.calcSerie) Globals.BackendWrapper.plottingRefreshSLD() diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml index 0a4c721c..d55ca44a 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml @@ -158,8 +158,8 @@ Rectangle { // Data is set in python backend (plotting_1d.py) Component.onCompleted: { - Globals.References.pages.sample.mainContent.sldView = chartView - Globals.BackendWrapper.plottingSetQtChartsSerieRef('samplePage', + Globals.References.pages.analysis.mainContent.sldView = chartView + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'sldSerie', chartView.calcSerie) Globals.BackendWrapper.plottingRefreshSLD() From 6333926573753d5f500b369c08467e2a9fc01edc Mon Sep 17 00:00:00 2001 From: rozyczko Date: Fri, 9 Jan 2026 08:26:34 +0100 Subject: [PATCH 30/44] initial version of multiple sample plots (profile + sld) --- .../Backends/Mock/Plotting.qml | 19 ++ .../Backends/Py/plotting_1d.py | 131 ++++++++- .../Gui/Globals/BackendWrapper.qml | 38 +++ .../Pages/Sample/MainContent/CombinedView.qml | 262 +++++++++++++----- .../Pages/Sample/MainContent/SampleView.qml | 139 ++++++++-- .../Gui/Pages/Sample/MainContent/SldView.qml | 145 +++++++--- 6 files changed, 596 insertions(+), 138 deletions(-) diff --git a/EasyReflectometryApp/Backends/Mock/Plotting.qml b/EasyReflectometryApp/Backends/Mock/Plotting.qml index 9317594a..537eb221 100644 --- a/EasyReflectometryApp/Backends/Mock/Plotting.qml +++ b/EasyReflectometryApp/Backends/Mock/Plotting.qml @@ -21,6 +21,10 @@ QtObject { property double analysisMinY: -40. property double analysisMaxY: 40. + property int modelCount: 1 + + signal samplePageDataChanged() + function setQtChartsSerieRef(value1, value2, value3) { console.debug(`setQtChartsSerieRef ${value1}, ${value2}, ${value3}`) } @@ -33,4 +37,19 @@ 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' + } + } diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 1781976a..0aedd012 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -18,6 +18,7 @@ class Plotting1d(QObject): sampleChartRangesChanged = Signal() experimentChartRangesChanged = Signal() experimentDataChanged = Signal() + samplePageDataChanged = Signal() # Signal for QML to refresh sample page charts def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) @@ -127,36 +128,96 @@ def individual_experiment_data_list(self) -> list: # 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_y = data.y[data.y > 0] + if valid_y.size > 0: + 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'): + min_y = np.log10(self.sample_data.y.min()) if self.sample_data.y.size > 0 else -10.0 + if max_y == float('-inf'): + max_y = np.log10(self.sample_data.y.max()) if self.sample_data.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) @@ -214,6 +275,51 @@ 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(): + points.append({ + 'x': float(point[0]), + 'y': float(np.log10(point[1])) if point[1] > 0 else -10.0 + }) + 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.""" @@ -282,8 +388,13 @@ def getAnalysisDataPoints(self, experiment_index: int) -> list: return [] def refreshSamplePage(self): - self.drawCalculatedOnSampleChart() - self.drawCalculatedOnSldChart() + # Clear cached data so it gets recalculated + self._sample_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() diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 9a11fd2b..25956d68 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -312,6 +312,44 @@ QtObject { function plottingRefreshSample() { activeBackend.plotting.drawCalculatedOnSampleChart() } function plottingRefreshSLD() { activeBackend.plotting.drawCalculatedOnSldChart() } + // Multi-model sample page plotting support + readonly property int plottingModelCount: activeBackend.plotting.modelCount + function plottingGetSampleDataPointsForModel(index) { + try { + return activeBackend.plotting.getSampleDataPointsForModel(index) + } catch (e) { + return [] + } + } + function plottingGetSldDataPointsForModel(index) { + try { + return activeBackend.plotting.getSldDataPointsForModel(index) + } catch (e) { + return [] + } + } + function plottingGetModelColor(index) { + try { + return activeBackend.plotting.getModelColor(index) + } catch (e) { + return '#000000' + } + } + + // Signal for sample page data changes - forward from backend + signal samplePageDataChanged() + + // 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) + } + } + + Component.onCompleted: { + connectSamplePageDataChanged() + } + // Multi-experiment plotting support readonly property bool plottingIsMultiExperimentMode: { try { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml index 989755ec..2ea2cbe6 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -20,6 +20,13 @@ Rectangle { 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: [] + property var sldSeries: [] + SplitView { anchors.fill: parent orientation: Qt.Vertical @@ -32,33 +39,59 @@ Rectangle { SplitView.minimumHeight: 100 color: EaStyle.Colors.chartBackground - EaCharts.QtCharts1dMeasVsCalc { + 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 - 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 + + ValueAxis { + id: sampleAxisX + titleText: "q (Å⁻¹)" + min: Globals.BackendWrapper.plottingSampleMinX - sampleChartView.xRange * 0.01 + max: Globals.BackendWrapper.plottingSampleMaxX + sampleChartView.xRange * 0.01 + 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 + } 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(sampleChartView, sampleDataToolTip, point, state) + ValueAxis { + id: sampleAxisY + titleText: "Log10 R(q)" + min: Globals.BackendWrapper.plottingSampleMinY - sampleChartView.yRange * 0.01 + max: Globals.BackendWrapper.plottingSampleMaxY + sampleChartView.yRange * 0.01 + 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 + } - calcSerie.color: { - var idx = Globals.BackendWrapper.sampleCurrentModelIndex - Globals.BackendWrapper.sampleModels[idx].color + function resetAxes() { + sampleAxisX.min = sampleAxisX.minAfterReset + sampleAxisX.max = sampleAxisX.maxAfterReset + sampleAxisY.min = sampleAxisY.minAfterReset + sampleAxisY.max = sampleAxisY.maxAfterReset } // Tool buttons @@ -138,7 +171,7 @@ Rectangle { } } - // Legend + // Legend showing all models Rectangle { visible: Globals.Variables.showLegendOnSamplePage @@ -156,9 +189,12 @@ Rectangle { topPadding: EaStyle.Sizes.fontPixelSize * 0.5 bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 - EaElements.Label { - text: '━ I (sample)' - color: sampleChartView.calcSerie.color + Repeater { + model: container.modelCount + EaElements.Label { + text: '━ ' + Globals.BackendWrapper.sampleModels[index].label + color: Globals.BackendWrapper.sampleModels[index].color + } } } } @@ -170,20 +206,13 @@ Rectangle { textFormat: Text.RichText } - // Data is set in python backend (plotting_1d.py) Component.onCompleted: { Globals.References.pages.sample.mainContent.sampleView = sampleChartView - Globals.BackendWrapper.plottingSetQtChartsSerieRef('samplePage', - 'sampleSerie', - sampleChartView.calcSerie) - Globals.BackendWrapper.plottingRefreshSample() } // Sync X-axis with SLD chart - onAxisXChanged: syncXAxes() - Connections { - target: sampleChartView.axisX + target: sampleAxisX function onMinChanged() { syncXAxes() } function onMaxChanged() { syncXAxes() } } @@ -198,41 +227,62 @@ Rectangle { SplitView.minimumHeight: 80 color: EaStyle.Colors.chartBackground - EaCharts.QtCharts1dMeasVsCalc { + ChartView { id: sldChartView anchors.fill: parent anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + anchors.margins: -12 - useOpenGL: EaGlobals.Vars.useOpenGL + 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 - 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 + + ValueAxis { + id: sldAxisX + titleText: "z (Å)" + min: Globals.BackendWrapper.plottingSldMinX - sldChartView.xRange * 0.01 + max: Globals.BackendWrapper.plottingSldMaxX + sldChartView.xRange * 0.01 + property double minAfterReset: Globals.BackendWrapper.plottingSldMinX - sldChartView.xRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + sldChartView.xRange * 0.01 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + } 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(sldChartView, sldDataToolTip, 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 + ValueAxis { + id: sldAxisY + titleText: "SLD (10⁻⁶Å⁻²)" + min: Globals.BackendWrapper.plottingSldMinY - sldChartView.yRange * 0.01 + max: Globals.BackendWrapper.plottingSldMaxY + sldChartView.yRange * 0.01 + property double minAfterReset: Globals.BackendWrapper.plottingSldMinY - sldChartView.yRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxY + sldChartView.yRange * 0.01 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels } - // Legend + function resetAxes() { + sldAxisX.min = sldAxisX.minAfterReset + sldAxisX.max = sldAxisX.maxAfterReset + sldAxisY.min = sldAxisY.minAfterReset + sldAxisY.max = sldAxisY.maxAfterReset + } + + // Legend showing all models Rectangle { visible: Globals.Variables.showLegendOnSamplePage @@ -250,9 +300,12 @@ Rectangle { topPadding: EaStyle.Sizes.fontPixelSize * 0.5 bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 - EaElements.Label { - text: '━ SLD' - color: sldChartView.calcSerie.color + Repeater { + model: container.modelCount + EaElements.Label { + text: '━ SLD ' + Globals.BackendWrapper.sampleModels[index].label + color: Globals.BackendWrapper.sampleModels[index].color + } } } } @@ -264,13 +317,94 @@ Rectangle { textFormat: Text.RichText } - // Data is set in python backend (plotting_1d.py) Component.onCompleted: { Globals.References.pages.sample.mainContent.sldView = sldChartView - Globals.BackendWrapper.plottingSetQtChartsSerieRef('samplePage', - 'sldSerie', - sldChartView.calcSerie) - Globals.BackendWrapper.plottingRefreshSLD() + } + } + } + } + + // 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() + } + } + + 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 = [] + + // Remove old SLD series + for (let j = 0; j < sldSeries.length; j++) { + if (sldSeries[j]) { + sldChartView.removeSeries(sldSeries[j]) + } + } + sldSeries = [] + + // 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, sampleAxisX, sampleAxisY) + sampleLine.color = models[k].color + sampleLine.width = 2 + sampleLine.useOpenGL = EaGlobals.Vars.useOpenGL + sampleSeries.push(sampleLine) + + // Create SLD series + const sldLine = sldChartView.createSeries(ChartView.SeriesTypeLine, "SLD " + models[k].label, sldAxisX, sldAxisY) + sldLine.color = models[k].color + sldLine.width = 2 + sldLine.useOpenGL = EaGlobals.Vars.useOpenGL + sldSeries.push(sldLine) + } + + // 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) + } + } + } + + // Refresh SLD series + for (let j = 0; j < sldSeries.length && j < models.length; j++) { + const series = sldSeries[j] + if (series) { + series.clear() + const points = Globals.BackendWrapper.plottingGetSldDataPointsForModel(j) + for (let p = 0; p < points.length; p++) { + series.append(points[p].x, points[p].y) } } } @@ -291,10 +425,10 @@ Rectangle { function syncXAxes() { // Keep both charts' X axes synchronized - if (sampleChartView.axisX.min !== sldChartView.axisX.min || - sampleChartView.axisX.max !== sldChartView.axisX.max) { - sldChartView.axisX.min = sampleChartView.axisX.min - sldChartView.axisX.max = sampleChartView.axisX.max + if (sampleAxisX.min !== sldAxisX.min || + sampleAxisX.max !== sldAxisX.max) { + sldAxisX.min = sampleAxisX.min + sldAxisX.max = sampleAxisX.max } } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml index 356e7d19..4ca88857 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml @@ -19,32 +19,65 @@ 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 + + ValueAxis { + id: axisX + titleText: "q (Å⁻¹)" + min: Globals.BackendWrapper.plottingSampleMinX - chartView.xRange * 0.01 + max: Globals.BackendWrapper.plottingSampleMaxX + chartView.xRange * 0.01 + 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 + } 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 R(q)" + min: Globals.BackendWrapper.plottingSampleMinY - chartView.yRange * 0.01 + max: Globals.BackendWrapper.plottingSampleMaxY + chartView.yRange * 0.01 + 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 + } - calcSerie.color: { - var idx = Globals.BackendWrapper.sampleCurrentModelIndex - Globals.BackendWrapper.sampleModels[idx].color + function resetAxes() { + axisX.min = axisX.minAfterReset + axisX.max = axisX.maxAfterReset + axisY.min = axisY.minAfterReset + axisY.max = axisY.maxAfterReset } // Tool buttons @@ -115,11 +148,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 @@ -135,13 +167,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 @@ -150,15 +184,62 @@ Rectangle { textFormat: Text.RichText } - // Data is set in python backend (plotting_1d.py) 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() + } + } + + 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 = [] + + // 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 + 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 0a4c721c..61825f7b 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml @@ -19,39 +19,66 @@ 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 sldSeries: [] + + ChartView { id: chartView + anchors.fill: parent anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + anchors.margins: -12 - useOpenGL: EaGlobals.Vars.useOpenGL + 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 - 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 + + ValueAxis { + id: axisX + titleText: "z (Å)" + min: Globals.BackendWrapper.plottingSldMinX - chartView.xRange * 0.01 + max: Globals.BackendWrapper.plottingSldMaxX + chartView.xRange * 0.01 + property double minAfterReset: Globals.BackendWrapper.plottingSldMinX - chartView.xRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + chartView.xRange * 0.01 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + } 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) - 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 + ValueAxis { + id: axisY + titleText: "SLD (10⁻⁶ Å⁻²)" + min: Globals.BackendWrapper.plottingSldMinY - chartView.yRange * 0.01 + max: Globals.BackendWrapper.plottingSldMaxY + chartView.yRange * 0.01 + 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 } + function resetAxes() { + axisX.min = axisX.minAfterReset + axisX.max = axisX.maxAfterReset + axisY.min = axisY.minAfterReset + axisY.max = axisY.maxAfterReset + } // Tool buttons Row { @@ -121,11 +148,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 @@ -141,13 +167,15 @@ Rectangle { topPadding: EaStyle.Sizes.fontPixelSize * 0.5 bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 - EaElements.Label { - text: '━ SLD' - color: chartView.calcSerie.color + Repeater { + model: container.modelCount + EaElements.Label { + text: '━ SLD ' + Globals.BackendWrapper.sampleModels[index].label + color: Globals.BackendWrapper.sampleModels[index].color + } } } } - // Legend EaElements.ToolTip { id: dataToolTip @@ -156,18 +184,65 @@ Rectangle { 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() } } - // Logic + // 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() + } + } + + 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 + 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) + } + } + } + } + + // Logic function showMainTooltip(chart, point, state) { if (!chartView.allowHover) { return From 859d750116043fc2132be7e9fc188ce39b41d393 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 9 Jan 2026 12:00:41 +0100 Subject: [PATCH 31/44] fixed SLD display for added sample. Fixed tooltip display --- EasyReflectometryApp/Backends/Py/logic/models.py | 4 ++-- .../Gui/Pages/Sample/MainContent/CombinedView.qml | 4 ++++ .../Gui/Pages/Sample/MainContent/SampleView.qml | 2 ++ EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml | 2 ++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/logic/models.py b/EasyReflectometryApp/Backends/Py/logic/models.py index 15ae0f47..37dfc5af 100644 --- a/EasyReflectometryApp/Backends/Py/logic/models.py +++ b/EasyReflectometryApp/Backends/Py/logic/models.py @@ -76,7 +76,7 @@ def remove_at_index(self, value: str) -> None: def default_model_content(self, model: Model) -> None: """Set the default content for a model.""" - model.sample.add_assembly() + model.add_assemblies() model.sample.data[0].layers.data[0].material = self._project_lib._materials[ self._project_lib.get_index_air() ] @@ -87,7 +87,7 @@ def default_model_content(self, model: Model) -> None: model.sample.data[1].layers.data[0].material = self._project_lib._materials[ self._project_lib.get_index_sio2() ] - model.sample.data[1].layers.data[0].thickness = 20.0 + model.sample.data[1].layers.data[0].thickness = 100.0 model.sample.data[1].layers.data[0].roughness = 3.0 model.sample.data[1].name = 'SiO2' diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml index 2ea2cbe6..bd9970e9 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -367,6 +367,8 @@ Rectangle { 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) // Create SLD series @@ -374,6 +376,8 @@ Rectangle { sldLine.color = models[k].color sldLine.width = 2 sldLine.useOpenGL = EaGlobals.Vars.useOpenGL + // Connect hovered signal for tooltip + sldLine.hovered.connect((point, state) => showMainTooltip(sldChartView, sldDataToolTip, point, state)) sldSeries.push(sldLine) } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml index 4ca88857..4e051c28 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml @@ -222,6 +222,8 @@ Rectangle { 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) } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml index 61825f7b..a221a529 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml @@ -222,6 +222,8 @@ Rectangle { 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)) sldSeries.push(line) } From 443f68502a5a07490ac6ea1d6b461900f1b7195d Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 9 Jan 2026 15:49:01 +0100 Subject: [PATCH 32/44] implement the chart buttons functionality --- .../Pages/Sample/MainContent/CombinedView.qml | 239 ++++++++++++++++-- .../Pages/Sample/MainContent/SampleView.qml | 113 ++++++++- .../Gui/Pages/Sample/MainContent/SldView.qml | 113 ++++++++- 3 files changed, 438 insertions(+), 27 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml index bd9970e9..471bdb2c 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -97,6 +97,7 @@ Rectangle { // 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 @@ -124,7 +125,7 @@ Rectangle { borderColor: EaStyle.Colors.chartAxis fontIcon: "comment-alt" ToolTip.text: qsTr("Show coordinates tooltip on hover") - onClicked: sampleChartView.allowHover = !sampleChartView.allowHover + onClicked: sampleChartView.allowHover = checked } Item { height: 1; width: 0.5 * EaStyle.Sizes.fontPixelSize } // spacer @@ -138,8 +139,8 @@ Rectangle { fontIcon: "arrows-alt" ToolTip.text: qsTr("Enable pan") onClicked: { - sampleChartView.allowZoom = !sampleChartView.allowZoom - sldChartView.allowZoom = sampleChartView.allowZoom + sampleChartView.allowZoom = !checked + sldChartView.allowZoom = !checked } } @@ -152,8 +153,8 @@ Rectangle { fontIcon: "expand" ToolTip.text: qsTr("Enable box zoom") onClicked: { - sampleChartView.allowZoom = !sampleChartView.allowZoom - sldChartView.allowZoom = sampleChartView.allowZoom + sampleChartView.allowZoom = checked + sldChartView.allowZoom = checked } } @@ -206,15 +207,114 @@ Rectangle { textFormat: Text.RichText } - Component.onCompleted: { - Globals.References.pages.sample.mainContent.sampleView = sampleChartView + // 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() } - // Sync X-axis with SLD chart - Connections { - target: sampleAxisX - function onMinChanged() { syncXAxes() } - function onMaxChanged() { syncXAxes() } + Component.onCompleted: { + Globals.References.pages.sample.mainContent.sampleView = sampleChartView } } } @@ -317,6 +417,112 @@ Rectangle { textFormat: Text.RichText } + // Zoom rectangle + Rectangle { + id: sldRecZoom + + property int xScaleZoom: 0 + property int yScaleZoom: 0 + + visible: false + transform: Scale { + origin.x: 0 + origin.y: 0 + xScale: sldRecZoom.xScaleZoom + yScale: sldRecZoom.yScaleZoom + } + border.color: EaStyle.Colors.appBorder + border.width: 1 + opacity: 0.9 + color: "transparent" + + Rectangle { + anchors.fill: parent + opacity: 0.5 + color: sldRecZoom.border.color + } + } + + // Zoom with left mouse button + MouseArea { + id: sldZoomMouseArea + + enabled: sldChartView.allowZoom + anchors.fill: sldChartView + acceptedButtons: Qt.LeftButton + onPressed: { + sldRecZoom.x = mouseX + sldRecZoom.y = mouseY + sldRecZoom.visible = true + } + onMouseXChanged: { + if (mouseX > sldRecZoom.x) { + sldRecZoom.xScaleZoom = 1 + sldRecZoom.width = Math.min(mouseX, sldChartView.width) - sldRecZoom.x + } else { + sldRecZoom.xScaleZoom = -1 + sldRecZoom.width = sldRecZoom.x - Math.max(mouseX, 0) + } + } + onMouseYChanged: { + if (mouseY > sldRecZoom.y) { + sldRecZoom.yScaleZoom = 1 + sldRecZoom.height = Math.min(mouseY, sldChartView.height) - sldRecZoom.y + } else { + sldRecZoom.yScaleZoom = -1 + sldRecZoom.height = sldRecZoom.y - Math.max(mouseY, 0) + } + } + onReleased: { + const x = Math.min(sldRecZoom.x, mouseX) - sldChartView.anchors.leftMargin + const y = Math.min(sldRecZoom.y, mouseY) - sldChartView.anchors.topMargin + const width = sldRecZoom.width + const height = sldRecZoom.height + sldChartView.zoomIn(Qt.rect(x, y, width, height)) + sldRecZoom.visible = false + } + } + + // Pan with left mouse button + MouseArea { + property real pressedX + property real pressedY + property int threshold: 1 + + enabled: !sldZoomMouseArea.enabled + anchors.fill: sldChartView + 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) + sldChartView.scrollLeft(dx) + else if (dx < -threshold) + sldChartView.scrollRight(-dx) + if (dy > threshold) + sldChartView.scrollUp(dy) + else if (dy < -threshold) + sldChartView.scrollDown(-dy) + } + } + + // Reset axes with right mouse button + MouseArea { + anchors.fill: sldChartView + acceptedButtons: Qt.RightButton + onClicked: sldChartView.resetAxes() + } + Component.onCompleted: { Globals.References.pages.sample.mainContent.sldView = sldChartView } @@ -426,13 +632,4 @@ Rectangle { tooltip.parent = chart tooltip.visible = state } - - function syncXAxes() { - // Keep both charts' X axes synchronized - if (sampleAxisX.min !== sldAxisX.min || - sampleAxisX.max !== sldAxisX.max) { - sldAxisX.min = sampleAxisX.min - sldAxisX.max = sampleAxisX.max - } - } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml index 4e051c28..d7622a36 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml @@ -83,6 +83,7 @@ Rectangle { // 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 @@ -110,7 +111,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 @@ -123,7 +124,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 { @@ -134,7 +135,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 { @@ -184,6 +185,112 @@ Rectangle { 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() + } + Component.onCompleted: { Globals.References.pages.sample.mainContent.sampleView = chartView } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml index a221a529..1ef964fa 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml @@ -83,6 +83,7 @@ Rectangle { // 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 @@ -110,7 +111,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 @@ -123,7 +124,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 { @@ -134,7 +135,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 { @@ -184,6 +185,112 @@ Rectangle { 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() + } + Component.onCompleted: { Globals.References.pages.sample.mainContent.sldView = chartView } From c962ed136f29b5e0be513ccf2b1bb91dd50351e3 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Sun, 11 Jan 2026 22:06:09 +0100 Subject: [PATCH 33/44] allow reset on both sld and profile charts --- .../Pages/Sample/MainContent/CombinedView.qml | 28 +++++++++++++------ .../Pages/Sample/MainContent/SampleView.qml | 14 +++++++--- .../Gui/Pages/Sample/MainContent/SldView.qml | 14 +++++++--- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml index 471bdb2c..25cf5411 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -60,8 +60,7 @@ Rectangle { ValueAxis { id: sampleAxisX titleText: "q (Å⁻¹)" - min: Globals.BackendWrapper.plottingSampleMinX - sampleChartView.xRange * 0.01 - max: Globals.BackendWrapper.plottingSampleMaxX + sampleChartView.xRange * 0.01 + // 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 @@ -69,6 +68,10 @@ Rectangle { 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 @@ -76,8 +79,7 @@ Rectangle { ValueAxis { id: sampleAxisY titleText: "Log10 R(q)" - min: Globals.BackendWrapper.plottingSampleMinY - sampleChartView.yRange * 0.01 - max: Globals.BackendWrapper.plottingSampleMaxY + sampleChartView.yRange * 0.01 + // 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 @@ -85,6 +87,10 @@ Rectangle { minorGridLineColor: EaStyle.Colors.chartMinorGridLine labelsColor: EaStyle.Colors.chartLabels titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } } function resetAxes() { @@ -348,8 +354,7 @@ Rectangle { ValueAxis { id: sldAxisX titleText: "z (Å)" - min: Globals.BackendWrapper.plottingSldMinX - sldChartView.xRange * 0.01 - max: Globals.BackendWrapper.plottingSldMaxX + sldChartView.xRange * 0.01 + // min/max set imperatively to avoid binding reset during zoom property double minAfterReset: Globals.BackendWrapper.plottingSldMinX - sldChartView.xRange * 0.01 property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + sldChartView.xRange * 0.01 color: EaStyle.Colors.chartAxis @@ -357,6 +362,10 @@ Rectangle { 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 @@ -364,8 +373,7 @@ Rectangle { ValueAxis { id: sldAxisY titleText: "SLD (10⁻⁶Å⁻²)" - min: Globals.BackendWrapper.plottingSldMinY - sldChartView.yRange * 0.01 - max: Globals.BackendWrapper.plottingSldMaxY + sldChartView.yRange * 0.01 + // min/max set imperatively to avoid binding reset during zoom property double minAfterReset: Globals.BackendWrapper.plottingSldMinY - sldChartView.yRange * 0.01 property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxY + sldChartView.yRange * 0.01 color: EaStyle.Colors.chartAxis @@ -373,6 +381,10 @@ Rectangle { minorGridLineColor: EaStyle.Colors.chartMinorGridLine labelsColor: EaStyle.Colors.chartLabels titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } } function resetAxes() { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml index d7622a36..87746580 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml @@ -46,8 +46,7 @@ Rectangle { ValueAxis { id: axisX titleText: "q (Å⁻¹)" - min: Globals.BackendWrapper.plottingSampleMinX - chartView.xRange * 0.01 - max: Globals.BackendWrapper.plottingSampleMaxX + chartView.xRange * 0.01 + // 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 @@ -55,6 +54,10 @@ Rectangle { 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 @@ -62,8 +65,7 @@ Rectangle { ValueAxis { id: axisY titleText: "Log10 R(q)" - min: Globals.BackendWrapper.plottingSampleMinY - chartView.yRange * 0.01 - max: Globals.BackendWrapper.plottingSampleMaxY + chartView.yRange * 0.01 + // 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 @@ -71,6 +73,10 @@ Rectangle { minorGridLineColor: EaStyle.Colors.chartMinorGridLine labelsColor: EaStyle.Colors.chartLabels titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } } function resetAxes() { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml index 1ef964fa..1c2ea43c 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml @@ -46,8 +46,7 @@ Rectangle { ValueAxis { id: axisX titleText: "z (Å)" - min: Globals.BackendWrapper.plottingSldMinX - chartView.xRange * 0.01 - max: Globals.BackendWrapper.plottingSldMaxX + chartView.xRange * 0.01 + // min/max set imperatively to avoid binding reset during zoom property double minAfterReset: Globals.BackendWrapper.plottingSldMinX - chartView.xRange * 0.01 property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + chartView.xRange * 0.01 color: EaStyle.Colors.chartAxis @@ -55,6 +54,10 @@ Rectangle { 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 @@ -62,8 +65,7 @@ Rectangle { ValueAxis { id: axisY titleText: "SLD (10⁻⁶ Å⁻²)" - min: Globals.BackendWrapper.plottingSldMinY - chartView.yRange * 0.01 - max: Globals.BackendWrapper.plottingSldMaxY + chartView.yRange * 0.01 + // min/max set imperatively to avoid binding reset during zoom property double minAfterReset: Globals.BackendWrapper.plottingSldMinY - chartView.yRange * 0.01 property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxY + chartView.yRange * 0.01 color: EaStyle.Colors.chartAxis @@ -71,6 +73,10 @@ Rectangle { minorGridLineColor: EaStyle.Colors.chartMinorGridLine labelsColor: EaStyle.Colors.chartLabels titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } } function resetAxes() { From 4e2f5413096aef447790c1ce4ec85951ccbf10bb Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 12 Jan 2026 11:40:55 +0100 Subject: [PATCH 34/44] fixed layers property table enablement --- .../Backends/Py/logic/fitting.py | 2 +- .../Backends/Py/logic/layers.py | 50 ++++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index e5b8df77..c51e7a90 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -33,6 +33,6 @@ def start_stop(self) -> None: 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._result = self._project_lib.fitter.fit_single_data_set_1d(exp_data) self._running = False self._finished = True diff --git a/EasyReflectometryApp/Backends/Py/logic/layers.py b/EasyReflectometryApp/Backends/Py/logic/layers.py index c3f76044..b76a1e1e 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]: @@ -110,7 +123,28 @@ 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( @@ -124,6 +158,8 @@ def _from_layers_collection_to_list_of_dicts(layers_collection: LayerCollection) 'solvent': 'solvent', 'solvation': '0.2', 'apm_enabled': 'True', + 'thickness_enabled': thickness_enabled, + 'roughness_enabled': roughness_enabled, } ) if isinstance(layer, LayerAreaPerMolecule): From e09c79375077b4f94be48d52afa02c46b19acf2a Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 12 Jan 2026 12:14:26 +0100 Subject: [PATCH 35/44] added Fit status dialog --- .../Backends/Mock/Analysis.qml | 8 ++++ EasyReflectometryApp/Backends/Py/analysis.py | 21 ++++++++++ .../Backends/Py/logic/fitting.py | 36 ++++++++++++++++- .../Gui/Globals/BackendWrapper.qml | 5 +++ .../Sidebar/Basic/Popups/FitStatusDialog.qml | 40 ++++++++++++------- 5 files changed, 94 insertions(+), 16 deletions(-) diff --git a/EasyReflectometryApp/Backends/Mock/Analysis.qml b/EasyReflectometryApp/Backends/Mock/Analysis.qml index e368da97..110ee6f0 100644 --- a/EasyReflectometryApp/Backends/Mock/Analysis.qml +++ b/EasyReflectometryApp/Backends/Mock/Analysis.qml @@ -20,6 +20,10 @@ 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 int fitNumRefinedParams: 3 + readonly property real fitChi2: 1.2345 // Parameters property int currentParameterIndex: 0 @@ -100,4 +104,8 @@ QtObject { function fittingStartStop() { console.debug('fittingStartStop') } + function setShowFitResultsDialog(value) { + showFitResultsDialog = value + console.debug(`setShowFitResultsDialog ${value}`) + } } diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index 0055dfdd..ece30ee8 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -66,6 +66,27 @@ 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(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 + @Slot(None) def fittingStartStop(self) -> None: # make sure we can run the fitting diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index c51e7a90..8316c39b 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -1,3 +1,5 @@ +from typing import Optional + from easyreflectometry import Project as ProjectLib from easyscience.fitting import FitResults @@ -7,7 +9,8 @@ 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._show_results_dialog = False @property def status(self) -> str: @@ -24,6 +27,35 @@ 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: + if self._result is None: + return False + return self._result.success + + @property + def fit_n_pars(self) -> int: + if self._result is None: + return 0 + return self._result.n_pars + + @property + def fit_chi2(self) -> float: + 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 +64,9 @@ def start_stop(self) -> None: # Start running the fitting self._running = True self._finished = False + self._show_results_dialog = 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 = True diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 25956d68..38565020 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -256,7 +256,12 @@ 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 int analysisFitNumRefinedParams: activeBackend.analysis.fitNumRefinedParams + readonly property real analysisFitChi2: activeBackend.analysis.fitChi2 function analysisFittingStartStop() { activeBackend.analysis.fittingStartStop() } + function analysisSetShowFitResultsDialog(value) { activeBackend.analysis.setShowFitResultsDialog(value) } // Parameters readonly property int analysisFreeParametersCount: activeBackend.analysis.freeParametersCount diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml index 9ea84974..46299806 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,34 @@ import Gui.Globals as Globals EaElements.Dialog { id: dialog - visible: Globals.BackendWrapper.analysisFittingStatus - title: qsTr("Fit status") + visible: Globals.BackendWrapper.analysisShowFitResultsDialog + title: qsTr("Refinement Results") 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 { + text: "Num. refined parameters: " + Globals.BackendWrapper.analysisFitNumRefinedParams + } + + EaElements.Label { + text: "Chi2: " + Globals.BackendWrapper.analysisFitChi2.toFixed(4) } } From 25678eef0d21844fc4a992a6fb1109418694f083 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 12 Jan 2026 15:09:50 +0100 Subject: [PATCH 36/44] fixes for: showing fit status on error selecting fittables properly --- .../Backends/Mock/Analysis.qml | 1 + EasyReflectometryApp/Backends/Py/analysis.py | 5 ++ .../Backends/Py/logic/fitting.py | 29 ++++++++++-- .../Backends/Py/logic/parameters.py | 46 +++++++++++++------ .../Gui/Globals/BackendWrapper.qml | 1 + .../Sidebar/Basic/Groups/Fittables.qml | 6 ++- .../Sidebar/Basic/Popups/FitStatusDialog.qml | 12 ++++- 7 files changed, 80 insertions(+), 20 deletions(-) diff --git a/EasyReflectometryApp/Backends/Mock/Analysis.qml b/EasyReflectometryApp/Backends/Mock/Analysis.qml index 110ee6f0..0ff93d4e 100644 --- a/EasyReflectometryApp/Backends/Mock/Analysis.qml +++ b/EasyReflectometryApp/Backends/Mock/Analysis.qml @@ -22,6 +22,7 @@ QtObject { 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 diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index ece30ee8..815bd514 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -79,6 +79,10 @@ def setShowFitResultsDialog(self, value: bool) -> None: 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 @@ -488,4 +492,5 @@ def setCurrentParameterFit(self, new_value: bool) -> None: def _clearCacheAndEmitParametersChanged(self): self._chached_parameters = None + self._chached_enabled_parameters = None self.parametersChanged.emit() diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index 8316c39b..38fcaed7 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -2,6 +2,7 @@ from easyreflectometry import Project as ProjectLib from easyscience.fitting import FitResults +from easyscience.fitting.minimizers.utils import FitError class Fitting: @@ -11,6 +12,7 @@ def __init__(self, project_lib: ProjectLib): self._finished = True self._result: Optional[FitResults] = None self._show_results_dialog = False + self._fit_error_message: Optional[str] = None @property def status(self) -> str: @@ -41,6 +43,10 @@ def fit_success(self) -> bool: return False return self._result.success + @property + def fit_error_message(self) -> str: + return self._fit_error_message or '' + @property def fit_n_pars(self) -> int: if self._result is None: @@ -65,8 +71,21 @@ def start_stop(self) -> None: self._running = True self._finished = False self._show_results_dialog = 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 = True + self._fit_error_message = None + try: + 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) + print(f'Fit failed: {e}') + except Exception as e: + # Handle any other unexpected exceptions + self._result = None + self._fit_error_message = str(e) + print(f'Unexpected error during fit: {e}') + finally: + self._running = False + self._finished = True + self._show_results_dialog = True diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index 6556cf40..ff1cb678 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -71,40 +71,60 @@ 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 diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 38565020..706f44bf 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -258,6 +258,7 @@ QtObject { 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 function analysisFittingStartStop() { activeBackend.analysis.fittingStartStop() } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml index 04199c99..405e5291 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml @@ -301,7 +301,11 @@ EaElements.GroupBox { 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) } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml index 46299806..74984f63 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml @@ -17,7 +17,7 @@ EaElements.Dialog { id: dialog visible: Globals.BackendWrapper.analysisShowFitResultsDialog - title: qsTr("Refinement Results") + title: Globals.BackendWrapper.analysisFitSuccess ? qsTr("Refinement Results") : qsTr("Refinement Failed") standardButtons: Dialog.Ok closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside @@ -39,12 +39,22 @@ EaElements.Dialog { } 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 + } } // Logic From 540180e1cc6f4f722a9c091ab420d9ca05d51241 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 13 Jan 2026 14:43:26 +0100 Subject: [PATCH 37/44] show the content of the dependent params combobox --- .../Analysis/MainContent/CombinedView.qml | 18 ------------------ .../Sidebar/Advanced/Groups/Constraints.qml | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index b3ebd0de..305fe01c 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -382,15 +382,6 @@ Rectangle { // Initialize multi-experiment support updateMultiExperimentSeries() } - - // Sync X-axis with SLD chart - onAxisXChanged: syncXAxes() - - Connections { - target: analysisChartView.axisX - function onMinChanged() { syncXAxes() } - function onMaxChanged() { syncXAxes() } - } } } @@ -492,13 +483,4 @@ Rectangle { tooltip.parent = chart tooltip.visible = state } - - function syncXAxes() { - // Keep both charts' X axes synchronized - if (analysisChartView.axisX.min !== sldChartView.axisX.min || - analysisChartView.axisX.max !== sldChartView.axisX.max) { - sldChartView.axisX.min = analysisChartView.axisX.min - sldChartView.axisX.max = analysisChartView.axisX.max - } - } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml index 2742bfff..cb0b65d1 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml @@ -198,6 +198,20 @@ EaElements.GroupBox { constraintsGroup.insertAlias(currentValue) Qt.callLater(() => parameterInsert.currentIndex = -1) } + + 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 + } } EaElements.Label { From 6df6e084f34cf2c375d5f0732795f7b92537752c Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 15 Jan 2026 12:48:56 +0100 Subject: [PATCH 38/44] added plot control subgroup with log q and reversed SLD x axis --- .../Gui/Globals/Variables.qml | 4 ++ .../Pages/Sample/MainContent/CombinedView.qml | 47 +++++++++++++++++-- .../Pages/Sample/MainContent/SampleView.qml | 43 +++++++++++++++-- .../Gui/Pages/Sample/MainContent/SldView.qml | 4 ++ .../Pages/Sample/Sidebar/Advanced/Layout.qml | 2 + 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/EasyReflectometryApp/Gui/Globals/Variables.qml b/EasyReflectometryApp/Gui/Globals/Variables.qml index 3da4c21d..71b34dc1 100644 --- a/EasyReflectometryApp/Gui/Globals/Variables.qml +++ b/EasyReflectometryApp/Gui/Globals/Variables.qml @@ -13,4 +13,8 @@ QtObject { 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/Sample/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml index 25cf5411..fb2f6d4e 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -57,8 +57,12 @@ Rectangle { 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 @@ -74,6 +78,25 @@ Rectangle { } } + 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 { @@ -94,12 +117,23 @@ Rectangle { } function resetAxes() { - sampleAxisX.min = sampleAxisX.minAfterReset - sampleAxisX.max = sampleAxisX.maxAfterReset + 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 @@ -351,12 +385,16 @@ Rectangle { property double xRange: Globals.BackendWrapper.plottingSldMaxX - Globals.BackendWrapper.plottingSldMinX + // Reverse axis logic + property bool reverseZAxis: Globals.Variables.reverseSldZAxis + ValueAxis { id: sldAxisX titleText: "z (Å)" // min/max set imperatively to avoid binding reset during zoom property double minAfterReset: Globals.BackendWrapper.plottingSldMinX - sldChartView.xRange * 0.01 property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + sldChartView.xRange * 0.01 + reverse: sldChartView.reverseZAxis color: EaStyle.Colors.chartAxis gridLineColor: EaStyle.Colors.chartGridLine minorGridLineColor: EaStyle.Colors.chartMinorGridLine @@ -577,11 +615,14 @@ Rectangle { } sldSeries = [] + // 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, sampleAxisX, sampleAxisY) + const sampleLine = sampleChartView.createSeries(ChartView.SeriesTypeLine, models[k].label, sampleXAxisToUse, sampleAxisY) sampleLine.color = models[k].color sampleLine.width = 2 sampleLine.useOpenGL = EaGlobals.Vars.useOpenGL diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml index 87746580..01645bfa 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml @@ -43,8 +43,12 @@ Rectangle { property double xRange: Globals.BackendWrapper.plottingSampleMaxX - Globals.BackendWrapper.plottingSampleMinX + // 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 @@ -60,6 +64,25 @@ Rectangle { } } + 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 ValueAxis { @@ -80,12 +103,23 @@ Rectangle { } function resetAxes() { - axisX.min = axisX.minAfterReset - axisX.max = axisX.maxAfterReset + 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 @@ -328,10 +362,13 @@ Rectangle { } 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, axisX, axisY) + const line = chartView.createSeries(ChartView.SeriesTypeLine, models[k].label, xAxisToUse, axisY) line.color = models[k].color line.width = 2 line.useOpenGL = EaGlobals.Vars.useOpenGL diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml index 1c2ea43c..e27d98f5 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml @@ -43,12 +43,16 @@ Rectangle { property double xRange: Globals.BackendWrapper.plottingSldMaxX - Globals.BackendWrapper.plottingSldMinX + // Reverse axis logic + property bool reverseZAxis: Globals.Variables.reverseSldZAxis + ValueAxis { id: axisX titleText: "z (Å)" // min/max set imperatively to avoid binding reset during zoom property double minAfterReset: Globals.BackendWrapper.plottingSldMinX - chartView.xRange * 0.01 property double maxAfterReset: Globals.BackendWrapper.plottingSldMaxX + chartView.xRange * 0.01 + reverse: chartView.reverseZAxis color: EaStyle.Colors.chartAxis gridLineColor: EaStyle.Colors.chartGridLine minorGridLineColor: EaStyle.Colors.chartMinorGridLine diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml index 7ce45a88..e4a70c27 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml @@ -12,6 +12,8 @@ EaComponents.SideBarColumn { Groups.QRange{ enabled: Globals.BackendWrapper.analysisIsFitFinished } + Groups.PlotControl{ + } Groups.Constraints{ enabled: Globals.BackendWrapper.analysisIsFitFinished } From bedebcd20368b2d1d2cbe73dff6ede85c9b37bbe Mon Sep 17 00:00:00 2001 From: rozyczko Date: Fri, 16 Jan 2026 10:11:25 +0100 Subject: [PATCH 39/44] missed file --- .../Sidebar/Advanced/Groups/PlotControl.qml | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml 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..f7d77177 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml @@ -0,0 +1,40 @@ +// 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.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 + } + } + } +} From bbd50da63f551725f461e0768823b90a05067040 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Fri, 16 Jan 2026 10:16:31 +0100 Subject: [PATCH 40/44] more forgotten files --- .../Advanced/Groups/ModelConstraints.qml | 242 ++++++++++++++++++ .../Sample/Sidebar/Advanced/Groups/qmldir | 6 + 2 files changed, 248 insertions(+) create mode 100644 EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml create mode 100644 EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir 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..a6d2b89a --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml @@ -0,0 +1,242 @@ +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: false + + 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: Math.min(200, Math.max(60, modelConstraintsTable.height)) + + EaComponents.TableView { + id: modelConstraintsTable + width: parent.width + height: Math.min(200, Math.max(60, Math.max(modelConstraintsTable.contentHeight, modelConstraintsTable.implicitHeight))) + + 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 + ToolTip.visible: hovered && Globals.BackendWrapper.sampleConstraintsList[index] && Globals.BackendWrapper.sampleConstraintsList[index].rawExpression + ToolTip.text: Globals.BackendWrapper.sampleConstraintsList[index] ? Globals.BackendWrapper.sampleConstraintsList[index].rawExpression : "" + } + + // 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/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 From f6bc5e77aabfce9383d1dc562d28537c41469184 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Sun, 18 Jan 2026 21:59:10 +0100 Subject: [PATCH 41/44] added logic for single-click multi-model constraints --- EasyReflectometryApp/Backends/Py/sample.py | 122 ++++++++++++++++++ .../Gui/Globals/BackendWrapper.qml | 1 + 2 files changed, 123 insertions(+) diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 1047b809..b62a368f 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -904,6 +904,128 @@ def addConstraint(self, dependent_index: int, relation: str, expression: str): '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 name for the reference parameter + _, _, display_lookup = self._build_constraint_context() + ref_display = self._get_parameter_display_name(reference_param) + + 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}, + } + + 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) -> str: + """Get a display name for a parameter.""" + 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 + return f'{parent_name} {param_name}' + except Exception: # noqa: BLE001 + pass + return param.name + # # # # Q Range # # # diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 706f44bf..ca1ff7cb 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -171,6 +171,7 @@ QtObject { 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 From 21625c59c05c06e5b60b0b431c30ac7f61944c3a Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 22 Jan 2026 18:49:47 +0100 Subject: [PATCH 42/44] attempt at making layer names consistent --- .../Backends/Py/logic/layers.py | 4 + .../Backends/Py/logic/models.py | 25 +++-- .../Backends/Py/logic/parameters.py | 94 +++++++++++++------ EasyReflectometryApp/Backends/Py/sample.py | 57 +++++++++-- .../Advanced/Groups/ModelConstraints.qml | 8 +- 5 files changed, 134 insertions(+), 54 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/logic/layers.py b/EasyReflectometryApp/Backends/Py/logic/layers.py index b76a1e1e..92fa0e5f 100644 --- a/EasyReflectometryApp/Backends/Py/logic/layers.py +++ b/EasyReflectometryApp/Backends/Py/logic/layers.py @@ -60,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) @@ -95,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 diff --git a/EasyReflectometryApp/Backends/Py/logic/models.py b/EasyReflectometryApp/Backends/Py/logic/models.py index 37dfc5af..cf2777d2 100644 --- a/EasyReflectometryApp/Backends/Py/logic/models.py +++ b/EasyReflectometryApp/Backends/Py/logic/models.py @@ -77,33 +77,40 @@ def remove_at_index(self, value: str) -> None: def default_model_content(self, model: Model) -> None: """Set the default content for a model.""" model.add_assemblies() - model.sample.data[0].layers.data[0].material = self._project_lib._materials[ - self._project_lib.get_index_air() - ] + # 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' - model.sample.data[1].layers.data[0].material = self._project_lib._materials[ - self._project_lib.get_index_sio2() - ] + # 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' - model.sample.data[2].layers.data[0].material = self._project_lib._materials[ - self._project_lib.get_index_si() - ] + # 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.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: diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index ff1cb678..28ffa529 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -22,9 +22,9 @@ 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]: + def parameters(self) -> list[dict[str, Any]]: return _from_parameters_to_list_of_dicts( - self._project_lib.parameters, self._project_lib._models[self._project_lib.current_model_index].unique_name + self._project_lib.parameters, self._project_lib._models ) def constraint_context(self) -> list[dict[str, Any]]: @@ -160,10 +160,18 @@ def add_constraint( 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, Any]]: - """Convert parameters to list of dictionaries with simplified logic.""" +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() @@ -179,7 +187,7 @@ def _make_alias(name: str) -> str: alias_registry.add(alias) return alias - def _get_parameter_display_data(param: Parameter) -> Tuple[str, str]: + 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: @@ -188,7 +196,7 @@ def _get_parameter_display_data(param: Parameter) -> Tuple[str, str]: return f'{parent_name} {param_name}', parent_name return param.name, '' # Fallback to parameter name without group - def _get_dependency_expression(param: Parameter) -> str: + def _get_dependency_expression(param: Parameter, model_unique_name: str) -> str: """Get simplified dependency expression.""" if param.independent: return '' @@ -197,7 +205,7 @@ def _get_dependency_expression(param: Parameter) -> str: 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) + 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) @@ -205,30 +213,54 @@ def _get_dependency_expression(param: Parameter) -> str: # 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: - # Skip parameters not in the current model path - if not global_object.map.find_path(model_unique_name, parameter.unique_name): - continue - - display_name, group_name = _get_parameter_display_data(parameter) - alias = _make_alias(display_name or parameter.name) - parameter_list.append({ - 'name': display_name, - 'display_name': display_name, - 'group': group_name, - 'alias': alias, - 'unique_name': parameter.unique_name, - 'value': float(parameter.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), - 'enabled': parameter.enabled if hasattr(parameter, 'enabled') else True, - 'object': parameter, # Direct reference to the Parameter object - }) + + # Process parameters for each model + for model_idx, model in enumerate(models): + model_unique_name = model.unique_name + model_prefix = f'M{model_idx + 1}' + + 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) + parameter_list.append({ + 'name': prefixed_display_name, + 'display_name': prefixed_display_name, + 'group': group_name, + 'alias': alias, + 'unique_name': parameter.unique_name, + 'value': float(parameter.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 diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index b62a368f..ae1e1c90 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -213,6 +213,7 @@ def addNewModel(self) -> None: @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() @@ -764,8 +765,11 @@ def constraintsList(self) -> list[dict[str, str]]: 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': entry['display_name'], + 'dependentName': dependent_display, 'expression': expression_display, 'rawExpression': raw_expression, 'relation': relation, @@ -805,11 +809,34 @@ def removeConstraintByIndex(self, index: int) -> None: self.layersChange.emit() def _find_parameter_object_by_name(self, param_name: str): - """Find parameter object by name.""" + """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: @@ -965,9 +992,10 @@ def constrainModelsParameters(self, model_indices: list) -> None: # Store constraint state for display unique_name = getattr(dependent_param, 'unique_name', None) if unique_name is not None: - # Get display name for the reference parameter - _, _, display_lookup = self._build_constraint_context() - ref_display = self._get_parameter_display_name(reference_param) + # 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', @@ -977,6 +1005,7 @@ def constrainModelsParameters(self, model_indices: list) -> None: 'raw_expression': 'a', 'pretty_expression': ref_display, 'dependency_map': {'a': reference_param}, + 'dependent_display': dep_display, } constraints_added += 1 @@ -1011,8 +1040,14 @@ def _build_model_parameters_map(self, model) -> Dict[str, DescriptorNumber]: return params_map - def _get_parameter_display_name(self, param: DescriptorNumber) -> str: - """Get a display name for a parameter.""" + 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 @@ -1021,10 +1056,14 @@ def _get_parameter_display_name(self, param: DescriptorNumber) -> str: 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 - return f'{parent_name} {param_name}' + display_name = f'{parent_name} {param_name}' + break except Exception: # noqa: BLE001 pass - return param.name + + if model_index is not None: + return f'M{model_index + 1} {display_name}' + return display_name # # # # Q Range diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml index a6d2b89a..aefcd81d 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml @@ -10,7 +10,7 @@ EaElements.GroupBox { id: modelConstraintsGroup title: qsTr("Model constraints") enabled: true - last: false + last: true property var selectedModelIndices: [] property int selectedModelsCount: selectedModelIndices.length @@ -140,12 +140,12 @@ EaElements.GroupBox { Item { id: modelConstraintsTableContainer width: parent.width - height: Math.min(200, Math.max(60, modelConstraintsTable.height)) + height: modelConstraintsTable.height EaComponents.TableView { id: modelConstraintsTable width: parent.width - height: Math.min(200, Math.max(60, Math.max(modelConstraintsTable.contentHeight, modelConstraintsTable.implicitHeight))) + maxRowCountShow: 1000 defaultInfoText: qsTr("No Model Constraints") @@ -196,8 +196,6 @@ EaElements.GroupBox { return constraint.dependentName + ' ' + 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 : "" } // Placeholder for delete button space From aba180e1d6c17a52f911d1017e989a9dca0fbf15 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 22 Jan 2026 19:06:00 +0100 Subject: [PATCH 43/44] ruff check + format. Finally --- EasyReflectometryApp/Backends/Py/__init__.py | 2 +- EasyReflectometryApp/Backends/Py/analysis.py | 60 +++++++-------- .../Backends/Py/experiment.py | 2 +- .../Backends/Py/logic/assemblies.py | 3 +- .../Backends/Py/logic/experiments.py | 13 ++-- .../Backends/Py/logic/helpers.py | 4 +- .../Backends/Py/logic/parameters.py | 74 ++++++++++--------- .../Backends/Py/plotting_1d.py | 60 +++++++-------- EasyReflectometryApp/Backends/Py/project.py | 1 - .../Backends/Py/py_backend.py | 20 +++-- EasyReflectometryApp/Backends/Py/sample.py | 67 +++++++++-------- 11 files changed, 153 insertions(+), 153 deletions(-) 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 815bd514..60745a54 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -113,9 +113,9 @@ def prefitCheck(self) -> bool: if param['min'] >= param['max']: QtWidgets.QMessageBox.warning( None, - "Invalid Parameter Bounds", + 'Invalid Parameter Bounds', f"Parameter '{param['name']}' has invalid bounds: " - f"min ({param['min']}) must be less than max ({param['max']})." + f'min ({param["min"]}) must be less than max ({param["max"]}).', ) return False @@ -132,9 +132,8 @@ def prefitCheck(self) -> bool: # 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." + 'Invalid Parameter Bounds', + f'Parameters {joined} have infinite bounds, which is not allowed for differential evolution minimizer.', ) return False @@ -231,7 +230,7 @@ def removeExperiment(self, index: int) -> None: self.experimentsChanged.emit() self.externalExperimentChanged.emit() else: - print(f"Experiment index {index} is out of range.") + print(f'Experiment index {index} is out of range.') ######################## ## Multi-experiment selection support @@ -294,7 +293,7 @@ def get_concatenated_experiment_data(self): 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}") + print(f'Error accessing experiment {exp_idx}: {e}') continue if not all_x: @@ -306,16 +305,15 @@ def get_concatenated_experiment_data(self): 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)}" + 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) + 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): @@ -340,25 +338,23 @@ def get_individual_experiment_data_list(self): '#e377c2', # Pink '#7f7f7f', # Gray '#bcbd22', # Olive - '#17becf' # Cyan + '#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}" + 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 - }) + 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}") + print(f'Error accessing experiment {exp_idx}: {e}') continue return experiment_data_list @@ -373,8 +369,8 @@ def _refresh_plotting_system(self) -> None: try: if hasattr(self.parent(), '_plotting_1d'): plotting = self.parent()._plotting_1d - print("📊 Refreshing plotting system...") - print(f" Current selection: {self._selected_experiment_indices}") + print('📊 Refreshing plotting system...') + print(f' Current selection: {self._selected_experiment_indices}') # Emit signals to refresh experiment data and ranges plotting.experimentDataChanged.emit() @@ -382,7 +378,7 @@ def _refresh_plotting_system(self) -> None: plotting.refreshExperimentPage() plotting.refreshExperimentRanges() except Exception as e: - print(f"❌ Error refreshing plotting system: {e}") + print(f'❌ Error refreshing plotting system: {e}') ######################## ## Minimizers @@ -431,14 +427,14 @@ def enabledParameters(self) -> list[dict[str]]: if self._chached_enabled_parameters is not None: return self._chached_enabled_parameters enabled_parameters = [] - #import time - #t0 = time.time() + # import time + # t0 = time.time() for parameter in self._parameters_logic.parameters: if not parameter['enabled']: continue enabled_parameters.append(parameter) - #t1 = time.time() - #print(f"Enabled parameters computation time: {t1 - t0:.4f} seconds") + # t1 = time.time() + # print(f"Enabled parameters computation time: {t1 - t0:.4f} seconds") self._chached_enabled_parameters = enabled_parameters return enabled_parameters diff --git a/EasyReflectometryApp/Backends/Py/experiment.py b/EasyReflectometryApp/Backends/Py/experiment.py index 11b26178..6a4a0eb1 100644 --- a/EasyReflectometryApp/Backends/Py/experiment.py +++ b/EasyReflectometryApp/Backends/Py/experiment.py @@ -63,4 +63,4 @@ def load(self, paths: str) -> None: self._project_logic.load_new_experiment(IO.generalizePath(path)) self.experimentChanged.emit() self.externalExperimentChanged.emit() - pass # debug anchor + pass # debug anchor diff --git a/EasyReflectometryApp/Backends/Py/logic/assemblies.py b/EasyReflectometryApp/Backends/Py/logic/assemblies.py index 365189b8..18b3ef4a 100644 --- a/EasyReflectometryApp/Backends/Py/logic/assemblies.py +++ b/EasyReflectometryApp/Backends/Py/logic/assemblies.py @@ -72,8 +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(repetitions=1, - name = new_value) + 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() diff --git a/EasyReflectometryApp/Backends/Py/logic/experiments.py b/EasyReflectometryApp/Backends/Py/logic/experiments.py index f57f18c0..3a2dbff9 100644 --- a/EasyReflectometryApp/Backends/Py/logic/experiments.py +++ b/EasyReflectometryApp/Backends/Py/logic/experiments.py @@ -57,9 +57,9 @@ def set_model_on_experiment(self, new_value: int) -> None: model = models[new_value] exp.model = model except IndexError: - print(f"Model index {new_value} is out of range for the current experiment.") + 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.") + print('No experiment or models available to set on the experiment.') pass def remove_experiment(self, index: int) -> None: @@ -70,11 +70,8 @@ def remove_experiment(self, index: int) -> None: del self._project_lib._experiments[index] # readjust the dictionary keys for continuity temp_experiments = self._project_lib._experiments.copy() - self._project_lib._experiments = { - i: exp for i, exp in enumerate(temp_experiments.values()) - } + self._project_lib._experiments = {i: exp for i, exp in enumerate(temp_experiments.values())} if self._project_lib._current_experiment_index >= index: - self._project_lib._current_experiment_index = \ - max(0, self._project_lib._current_experiment_index - 1) + self._project_lib._current_experiment_index = max(0, self._project_lib._current_experiment_index - 1) else: - print(f"Experiment index {index} is out of range.") + print(f'Experiment index {index} is out of range.') diff --git a/EasyReflectometryApp/Backends/Py/logic/helpers.py b/EasyReflectometryApp/Backends/Py/logic/helpers.py index ab82f0ef..bb1a90d0 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): diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index 28ffa529..7c58131d 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -23,21 +23,21 @@ def as_status_string(self) -> str: @property def parameters(self) -> list[dict[str, Any]]: - return _from_parameters_to_list_of_dicts( - self._project_lib.parameters, self._project_lib._models - ) + return _from_parameters_to_list_of_dicts(self._project_lib.parameters, self._project_lib._models) def constraint_context(self) -> list[dict[str, Any]]: parameter_snapshot = self.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'], - }) + 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]]: @@ -47,12 +47,14 @@ def constraint_metadata(self) -> list[dict[str, Any]]: # 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.append( + { + 'alias': entry['alias'], + 'displayName': entry['display_name'], + 'group': entry.get('group', ''), + 'independent': entry['independent'], + } + ) metadata.sort(key=lambda item: item['displayName']) return metadata @@ -147,7 +149,8 @@ def add_constraint( if arithmetic_operator != '' and independent_idx > -1: dependent.make_dependent_on( - dependency_expression='a' + arithmetic_operator + 'b', dependency_map={'a': independent, 'b': float(value)}) + 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('<', '>') @@ -160,6 +163,7 @@ def add_constraint( print(f'{dependent_idx}, {relational_operator}, {value}, {arithmetic_operator}, {independent_idx}') + def _from_parameters_to_list_of_dicts(parameters: List[Parameter], models) -> list[dict[str, Any]]: """Convert parameters to list of dictionaries with simplified logic. @@ -245,22 +249,24 @@ def _is_layer_parameter(param: Parameter) -> bool: prefixed_display_name = display_name alias = _make_alias(prefixed_display_name or parameter.name) - parameter_list.append({ - 'name': prefixed_display_name, - 'display_name': prefixed_display_name, - 'group': group_name, - 'alias': alias, - 'unique_name': parameter.unique_name, - 'value': float(parameter.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 - }) + parameter_list.append( + { + 'name': prefixed_display_name, + 'display_name': prefixed_display_name, + 'group': group_name, + 'alias': alias, + 'unique_name': parameter.unique_name, + 'value': float(parameter.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 diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 0aedd012..c255c149 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -111,7 +111,7 @@ def is_multi_experiment_mode(self) -> bool: 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: + except Exception: # noqa: S110 pass return False @@ -122,7 +122,7 @@ def individual_experiment_data_list(self) -> list: 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}") + console.debug(f'Error getting individual experiment data: {e}') return [] # Sample @@ -248,7 +248,7 @@ def chartRefs(self): @Property(str) def calcSerieColor(self): return '#00FF00' - #return self._calcSerieColor + # return self._calcSerieColor @Property(bool, notify=experimentDataChanged) def isMultiExperimentMode(self) -> bool: @@ -262,12 +262,14 @@ def individualExperimentDataList(self) -> 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 - }) + 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') @@ -282,10 +284,7 @@ def getSampleDataPointsForModel(self, model_index: int) -> list: data = self._project_lib.sample_data_for_model_at_index(model_index) points = [] for point in data.data_points(): - points.append({ - 'x': float(point[0]), - 'y': float(np.log10(point[1])) if point[1] > 0 else -10.0 - }) + points.append({'x': float(point[0]), 'y': float(np.log10(point[1])) if point[1] > 0 else -10.0}) return points except Exception as e: console.debug(f'Error getting sample data points for model {model_index}: {e}') @@ -298,10 +297,7 @@ def getSldDataPointsForModel(self, model_index: int) -> list: 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]) - }) + 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}') @@ -328,15 +324,17 @@ def getExperimentDataPoints(self, experiment_index: int) -> list: points = [] for point in data.data_points(): if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]: - points.append({ - 'x': float(point[0]), - 'y': float(np.log10(point[1])), - 'errorUpper': float(np.log10(point[1] + np.sqrt(point[2]))), - 'errorLower': float(np.log10(max(point[1] - np.sqrt(point[2]), 1e-10))) # Avoid log(0) - }) + points.append( + { + 'x': float(point[0]), + 'y': float(np.log10(point[1])), + 'errorUpper': float(np.log10(point[1] + np.sqrt(point[2]))), + 'errorLower': float(np.log10(max(point[1] - np.sqrt(point[2]), 1e-10))), # Avoid log(0) + } + ) return points except Exception as e: - console.debug(f"Error getting experiment data points for index {experiment_index}: {e}") + console.debug(f'Error getting experiment data points for index {experiment_index}: {e}') return [] @Slot(int, result='QVariantList') @@ -376,15 +374,17 @@ def getAnalysisDataPoints(self, experiment_index: int) -> list: for point in exp_points: if point[0] < self._project_lib.q_max and self._project_lib.q_min < point[0]: calc_y_val = calc_y[calc_idx] if calc_idx < len(calc_y) else point[1] - points.append({ - 'x': float(point[0]), - 'measured': float(np.log10(point[1])), - 'calculated': float(np.log10(calc_y_val)), - }) + points.append( + { + 'x': float(point[0]), + 'measured': float(np.log10(point[1])), + '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}") + console.debug(f'Error getting analysis data points for index {experiment_index}: {e}') return [] def refreshSamplePage(self): diff --git a/EasyReflectometryApp/Backends/Py/project.py b/EasyReflectometryApp/Backends/Py/project.py index 6bd48727..5217c0df 100644 --- a/EasyReflectometryApp/Backends/Py/project.py +++ b/EasyReflectometryApp/Backends/Py/project.py @@ -114,4 +114,3 @@ def sampleLoad(self, url: str) -> None: self._logic.add_sample_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 bf4286a8..ce924fe8 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -86,7 +86,7 @@ def analysisExperimentsSelectedCount(self) -> int: """Return the count of currently selected experiments.""" return self._analysis.experimentsSelectedCount - @Property('QVariantList', notify=multiExperimentSelectionChanged) + @Property('QVariantList', notify=multiExperimentSelectionChanged) def analysisSelectedExperimentIndices(self) -> list: """Return the list of selected experiment indices.""" return self._analysis.selectedExperimentIndices @@ -94,19 +94,19 @@ def analysisSelectedExperimentIndices(self) -> list: @Slot('QVariantList') def analysisSetSelectedExperimentIndices(self, indices) -> None: """Set multiple selected experiment indices.""" - print(f"PyBackend.analysisSetSelectedExperimentIndices called with: {indices}") - print(f"Type of indices: {type(indices)}") - + print(f'PyBackend.analysisSetSelectedExperimentIndices called with: {indices}') + print(f'Type of indices: {type(indices)}') + # Convert QVariantList to Python list if needed python_indices = list(indices) if hasattr(indices, '__iter__') else [] - print(f"Converted to Python list: {python_indices}") - + print(f'Converted to Python list: {python_indices}') + if hasattr(self._analysis, 'setSelectedExperimentIndices'): self._analysis.setSelectedExperimentIndices(python_indices) - print("Successfully called analysis.setSelectedExperimentIndices") + print('Successfully called analysis.setSelectedExperimentIndices') else: - print("ERROR: analysis.setSelectedExperimentIndices method not found") - + print('ERROR: analysis.setSelectedExperimentIndices method not found') + # Emit our local signal to notify QML properties self.multiExperimentSelectionChanged.emit() @@ -131,8 +131,6 @@ 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() diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index ae1e1c90..73c7647a 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -483,8 +483,10 @@ def _extract_dependency_map( return used_aliases def _evaluate_constraint_expression( - self, expression: str, dependency_map: Dict[str, DescriptorNumber], - all_aliases: Dict[str, DescriptorNumber] | None = None + 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) @@ -508,7 +510,7 @@ def _evaluate_constraint_expression( # 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 NameError(f'{str(e)}\nAvailable aliases: {available}...') from None raise return result @@ -569,9 +571,7 @@ def _prepare_constraint_instruction( 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 - ) + 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: @@ -642,10 +642,7 @@ def _infer_constraint_state( } dependency_map = getattr(parameter_obj, 'dependency_map', {}) or {} - alias_display_subset = { - alias: display_lookup.get(alias, alias) - for alias in dependency_map.keys() - } + 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', @@ -768,13 +765,15 @@ def constraintsList(self) -> list[dict[str, str]]: # 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, - }) + constraints.append( + { + 'dependentName': dependent_display, + 'expression': expression_display, + 'rawExpression': raw_expression, + 'relation': relation, + 'type': mode, + } + ) return constraints @@ -830,6 +829,7 @@ def _find_parameter_object_by_name(self, param_name: str): # 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) @@ -903,20 +903,24 @@ def addConstraint(self, dependent_index: int, relation: str, expression: str): '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', {}), - }) + 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, - }) + state.update( + { + 'value': value, + 'pretty_expression': instruction.get('pretty_expression', numeric), + 'raw_expression': numeric, + } + ) self._constraint_states[unique_name] = state self.constraintsChanged.emit() @@ -1028,9 +1032,9 @@ def _build_model_parameters_map(self, model) -> Dict[str, DescriptorNumber]: # Get parameters from model structure for assembly_idx, assembly in enumerate(model.sample): - assembly_name = assembly.name + # assembly_name = assembly.name for layer_idx, layer in enumerate(assembly.layers): - layer_name = layer.name + # layer_name = layer.name # Get layer parameters for param in layer.get_parameters(): param_name = param.name @@ -1050,6 +1054,7 @@ def _get_parameter_display_name(self, param: DescriptorNumber, model_index: int 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) @@ -1058,7 +1063,7 @@ def _get_parameter_display_name(self, param: DescriptorNumber, model_index: int param_name = global_object.map.get_item_by_key(path[-1]).name display_name = f'{parent_name} {param_name}' break - except Exception: # noqa: BLE001 + except Exception: # noqa: S110 pass if model_index is not None: From 1a765fb0709d2400f6d175423f69469b1c1a2acb Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Thu, 22 Jan 2026 21:18:15 +0100 Subject: [PATCH 44/44] PR fixes. Set #1 --- EasyReflectometryApp/Backends/Py/analysis.py | 16 +++------------- .../Backends/Py/logic/fitting.py | 3 ++- .../Backends/Py/logic/minimizers.py | 2 -- .../Backends/Py/logic/parameters.py | 2 +- EasyReflectometryApp/Backends/Py/plotting_1d.py | 2 +- pyproject.toml | 1 + 6 files changed, 8 insertions(+), 18 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index 60745a54..cf5992fa 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -366,19 +366,9 @@ def selectedExperimentDataList(self) -> List[dict]: def _refresh_plotting_system(self) -> None: """Refresh the plotting system when experiment selection changes.""" - try: - if hasattr(self.parent(), '_plotting_1d'): - plotting = self.parent()._plotting_1d - print('📊 Refreshing plotting system...') - print(f' Current selection: {self._selected_experiment_indices}') - - # Emit signals to refresh experiment data and ranges - plotting.experimentDataChanged.emit() - plotting.experimentChartRangesChanged.emit() - plotting.refreshExperimentPage() - plotting.refreshExperimentRanges() - except Exception as e: - print(f'❌ Error refreshing plotting system: {e}') + # Emit signal to notify parent/listeners that experiment selection changed + # Parent (PyBackend) connects this signal to plotting refresh + self.experimentsChanged.emit() ######################## ## Minimizers diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index 38fcaed7..38493d07 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -17,7 +17,7 @@ def __init__(self, project_lib: ProjectLib): @property def status(self) -> str: if self._result is None: - return False + return '' else: return self._result.success @@ -73,6 +73,7 @@ def start_stop(self) -> None: 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: diff --git a/EasyReflectometryApp/Backends/Py/logic/minimizers.py b/EasyReflectometryApp/Backends/Py/logic/minimizers.py index 66ba5f26..b6352b56 100644 --- a/EasyReflectometryApp/Backends/Py/logic/minimizers.py +++ b/EasyReflectometryApp/Backends/Py/logic/minimizers.py @@ -59,7 +59,6 @@ def set_tolerance(self, new_value: float) -> bool: return False if new_value != self._multi_fitter.tolerance: self._multi_fitter.tolerance = new_value - print(new_value) return True return False @@ -68,6 +67,5 @@ def set_max_iterations(self, new_value: float) -> bool: return False if new_value != self._multi_fitter.max_evaluations: self._multi_fitter.max_evaluations = new_value - print(new_value) return True return False diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index 7c58131d..bc36f539 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -61,7 +61,7 @@ def constraint_metadata(self) -> list[dict[str, Any]]: 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 diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index c255c149..e06fdf6a 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -465,7 +465,7 @@ def qtchartsReplaceMeasuredOnExperimentChartAndRedraw(self): 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]))) + series_error_lower.append(point[0], np.log10(max(point[1] - np.sqrt(point[2]), 1e-10))) nr_points = nr_points + 1 console.debug(IO.formatMsg('sub', 'Measured curve', f'{nr_points} points', 'on experiment page', 'replaced')) diff --git a/pyproject.toml b/pyproject.toml index d84cae28..58c688b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ requires-python = '>=3.11' dependencies = [ 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@develop', 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@append_sample', + 'asteval', 'PySide6', 'toml', ]