diff --git a/EasyReflectometryApp/Backends/Mock/Analysis.qml b/EasyReflectometryApp/Backends/Mock/Analysis.qml index e368da97..0ff93d4e 100644 --- a/EasyReflectometryApp/Backends/Mock/Analysis.qml +++ b/EasyReflectometryApp/Backends/Mock/Analysis.qml @@ -20,6 +20,11 @@ QtObject { readonly property string fittingStatus: ''//undefined //'Success' readonly property bool isFitFinished: true readonly property bool fittingRunning: false + property bool showFitResultsDialog: false + readonly property bool fitSuccess: true + readonly property string fitErrorMessage: '' + readonly property int fitNumRefinedParams: 3 + readonly property real fitChi2: 1.2345 // Parameters property int currentParameterIndex: 0 @@ -100,4 +105,8 @@ QtObject { function fittingStartStop() { console.debug('fittingStartStop') } + function setShowFitResultsDialog(value) { + showFitResultsDialog = value + console.debug(`setShowFitResultsDialog ${value}`) + } } 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/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/__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 cbbbce39..cf5992fa 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -31,12 +31,26 @@ 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) 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 + self._initialize_selected_experiments() + + def _initialize_selected_experiments(self) -> None: + """Initialize selected experiment indices after object construction to avoid binding loops.""" + available_experiments = self._experiments_logic.available() + if len(available_experiments) > 0: + self._selected_experiment_indices = [0] + else: + self._selected_experiment_indices = [] ######################## ## Fitting @@ -52,6 +66,31 @@ def fittingRunning(self) -> bool: def isFitFinished(self) -> bool: return self._fitting_logic.fit_finished + @Property(bool, notify=fittingChanged) + def showFitResultsDialog(self) -> bool: + return self._fitting_logic.show_results_dialog + + @Slot(bool) + def setShowFitResultsDialog(self, value: bool) -> None: + self._fitting_logic.show_results_dialog = value + self.fittingChanged.emit() + + @Property(bool, notify=fittingChanged) + def fitSuccess(self) -> bool: + return self._fitting_logic.fit_success + + @Property(str, notify=fittingChanged) + def fitErrorMessage(self) -> str: + return self._fitting_logic.fit_error_message + + @Property(int, notify=fittingChanged) + def fitNumRefinedParams(self) -> int: + return self._fitting_logic.fit_n_pars + + @Property(float, notify=fittingChanged) + def fitChi2(self) -> float: + return self._fitting_logic.fit_chi2 + @Slot(None) def fittingStartStop(self) -> None: # make sure we can run the fitting @@ -74,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 @@ -93,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 @@ -138,6 +176,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: @@ -145,6 +184,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 @@ -185,7 +230,145 @@ 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 + # (Initialize selected experiments in the existing __init__ method) + + @Property(int, notify=experimentsChanged) + def experimentsSelectedCount(self) -> int: + """Return the count of currently selected experiments.""" + return len(self._selected_experiment_indices) + + @Property('QVariantList', notify=experimentsChanged) + def selectedExperimentIndices(self) -> List[int]: + """Return the list of selected experiment indices.""" + return self._selected_experiment_indices + + @Slot('QVariantList') + def setSelectedExperimentIndices(self, indices: List[int]) -> None: + """Set multiple selected experiment indices.""" + # Validate indices + available_count = len(self._experiments_logic.available()) + valid_indices = [i for i in indices if 0 <= i < available_count] + + if valid_indices != self._selected_experiment_indices: + # previous_selection = self._selected_experiment_indices.copy() + self._selected_experiment_indices = valid_indices + # Update current experiment index to first selected (or 0 if no selection) + if valid_indices: + self._experiments_logic.set_current_index(valid_indices[0]) + self._project_lib.current_experiment_index = valid_indices[0] + elif len(self._experiments_logic.available()) > 0: + # If no selection but experiments available, default to first experiment + self._experiments_logic.set_current_index(0) + self._selected_experiment_indices = [0] # Auto-select first experiment + + # Always trigger plotting refresh when selection changes + self._refresh_plotting_system() + + self.experimentsChanged.emit() + self.externalExperimentChanged.emit() + + def get_concatenated_experiment_data(self): + """ + Concatenate data from all selected experiments. + Returns a combined DataSet1D object. + """ + import numpy as np + from easyreflectometry.data import DataSet1D + + if not self._selected_experiment_indices: + return DataSet1D(name='No experiments selected', x=np.empty(0), y=np.empty(0), ye=np.empty(0), xe=np.empty(0)) + + all_x, all_y, all_ye, all_xe = [], [], [], [] + + for exp_idx in self._selected_experiment_indices: + try: + data = self._experiments_logic._project_lib.experimental_data_for_model_at_index(exp_idx) + if data.x.size > 0: # Only include non-empty datasets + all_x.extend(data.x) + all_y.extend(data.y) + all_ye.extend(data.ye if hasattr(data, 'ye') and data.ye.size > 0 else np.zeros_like(data.y)) + all_xe.extend(data.xe if hasattr(data, 'xe') and data.xe.size > 0 else np.zeros_like(data.x)) + except (IndexError, AttributeError) as e: + print(f'Error accessing experiment {exp_idx}: {e}') + continue + + if not all_x: + return DataSet1D(name='No valid experiment data', x=np.empty(0), y=np.empty(0), ye=np.empty(0), xe=np.empty(0)) + + # Sort by x values to maintain proper order + combined_data = list(zip(all_x, all_y, all_ye, all_xe)) + combined_data.sort(key=lambda item: item[0]) + + x_sorted, y_sorted, ye_sorted, xe_sorted = zip(*combined_data) if combined_data else ([], [], [], []) + + exp_names = [ + self._experiments_logic.available()[i] + for i in self._selected_experiment_indices + if i < len(self._experiments_logic.available()) + ] + combined_name = f'Combined: {", ".join(exp_names)}' + + return DataSet1D( + name=combined_name, x=np.array(x_sorted), y=np.array(y_sorted), ye=np.array(ye_sorted), xe=np.array(xe_sorted) + ) + + def get_individual_experiment_data_list(self): + """ + Get individual experiment data for each selected experiment. + Returns a list of dictionaries with data, name, and color for each experiment. + """ + + if not self._selected_experiment_indices: + return [] + + experiment_data_list = [] + + # Define a color palette for experiments + color_palette = [ + '#1f77b4', # Blue + '#ff7f0e', # Orange + '#2ca02c', # Green + '#d62728', # Red + '#9467bd', # Purple + '#8c564b', # Brown + '#e377c2', # Pink + '#7f7f7f', # Gray + '#bcbd22', # Olive + '#17becf', # Cyan + ] + + for idx, exp_idx in enumerate(self._selected_experiment_indices): + try: + data = self._experiments_logic._project_lib.experimental_data_for_model_at_index(exp_idx) + if data.x.size > 0: # Only include non-empty datasets + exp_name = ( + self._experiments_logic.available()[exp_idx] + if exp_idx < len(self._experiments_logic.available()) + else f'Experiment {exp_idx + 1}' + ) + color = color_palette[idx % len(color_palette)] + + experiment_data_list.append({'data': data, 'name': exp_name, 'color': color, 'index': exp_idx}) + except (IndexError, AttributeError) as e: + print(f'Error accessing experiment {exp_idx}: {e}') + continue + + return experiment_data_list + + @Property('QVariantList', notify=experimentsChanged) + def selectedExperimentDataList(self) -> List[dict]: + """Return individual experiment data for plotting separate lines.""" + return self.get_individual_experiment_data_list() + + def _refresh_plotting_system(self) -> None: + """Refresh the plotting system when experiment selection changes.""" + # Emit signal to notify parent/listeners that experiment selection changed + # Parent (PyBackend) connects this signal to plotting refresh + self.experimentsChanged.emit() ######################## ## Minimizers @@ -229,6 +412,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 not parameter['enabled']: + 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() @@ -279,4 +478,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/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 1e910759..3a2dbff9 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 @@ -52,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: @@ -65,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/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index e5b8df77..38493d07 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -1,5 +1,8 @@ +from typing import Optional + from easyreflectometry import Project as ProjectLib from easyscience.fitting import FitResults +from easyscience.fitting.minimizers.utils import FitError class Fitting: @@ -7,12 +10,14 @@ 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 + self._fit_error_message: Optional[str] = None @property def status(self) -> str: if self._result is None: - return False + return '' else: return self._result.success @@ -24,6 +29,39 @@ 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_error_message(self) -> str: + return self._fit_error_message or '' + + @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 +70,23 @@ def start_stop(self) -> None: # Start running the fitting self._running = True self._finished = False - exp_data = self._project_lib.experimental_data_for_model_at_index(0) - self._result = self._project_lib._fitter.fit_single_data_set_1d(exp_data) - self._running = False - self._finished = True + self._show_results_dialog = False + self._fit_error_message = None + try: + # This needs extension to support multiple data sets + exp_data = self._project_lib.experimental_data_for_model_at_index(0) + self._result = self._project_lib.fitter.fit_single_data_set_1d(exp_data) + except FitError as e: + # Handle fit failure - create a failed result + self._result = None + self._fit_error_message = str(e) + 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/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/layers.py b/EasyReflectometryApp/Backends/Py/logic/layers.py index c3f76044..92fa0e5f 100644 --- a/EasyReflectometryApp/Backends/Py/logic/layers.py +++ b/EasyReflectometryApp/Backends/Py/logic/layers.py @@ -4,19 +4,32 @@ from easyreflectometry.sample import LayerAreaPerMolecule from easyreflectometry.sample import LayerCollection from easyreflectometry.sample import Material +from easyreflectometry.sample import Sample class Layers: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib + @property + def _sample(self) -> Sample: + return self._project_lib._models[self._project_lib.current_model_index].sample + @property def _layers(self) -> LayerCollection: - return ( - self._project_lib._models[self._project_lib.current_model_index] - .sample[self._project_lib.current_assembly_index] - .layers - ) + return self._sample[self._project_lib.current_assembly_index].layers + + @property + def _assembly_type(self) -> str: + """Determine if current assembly is superphase, subphase, or regular.""" + current_index = self._project_lib.current_assembly_index + total_assemblies = len(self._sample) + if current_index == 0: + return 'superphase' + elif current_index == total_assemblies - 1: + return 'subphase' + else: + return 'regular' @property def index(self) -> int: @@ -32,7 +45,7 @@ def name_at_current_index(self) -> str: @property def layers(self) -> list[dict[str, str]]: - return _from_layers_collection_to_list_of_dicts(self._layers) + return _from_layers_collection_to_list_of_dicts(self._layers, self._assembly_type) @property def layers_names(self) -> list[str]: @@ -47,6 +60,8 @@ def add_new(self) -> None: index_si = [material.name for material in self._project_lib._materials].index('Si') self._layers.add_layer() self._layers[-1].material = self._project_lib._materials[index_si] + # Set layer name based on material name + self._layers[-1].name = self._project_lib._materials[index_si].name + ' Layer' def duplicate_selected(self) -> None: self._layers.duplicate_layer(self.index) @@ -82,6 +97,8 @@ def set_roughness_at_current_index(self, new_value: float) -> bool: def set_material_at_current_index(self, new_value: int) -> bool: if self._layers[self.index].material != self._project_lib._materials[new_value]: self._layers[self.index].material = self._project_lib._materials[new_value] + # Update layer name based on material name + self._layers[self.index].name = self._project_lib._materials[new_value].name + ' Layer' return True return False @@ -110,7 +127,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 +162,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): diff --git a/EasyReflectometryApp/Backends/Py/logic/minimizers.py b/EasyReflectometryApp/Backends/Py/logic/minimizers.py index 9c4756e6..b6352b56 100644 --- a/EasyReflectometryApp/Backends/Py/logic/minimizers.py +++ b/EasyReflectometryApp/Backends/Py/logic/minimizers.py @@ -26,32 +26,46 @@ 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 - print(new_value) + if self._multi_fitter is None: + return False + if new_value != self._multi_fitter.tolerance: + self._multi_fitter.tolerance = new_value return True return False def set_max_iterations(self, new_value: float) -> bool: - if new_value != self._project_lib._fitter.easy_science_multi_fitter.max_evaluations: - self._project_lib._fitter.easy_science_multi_fitter.max_evaluations = new_value - print(new_value) + if self._multi_fitter is None: + return False + if new_value != self._multi_fitter.max_evaluations: + self._multi_fitter.max_evaluations = new_value return True return False diff --git a/EasyReflectometryApp/Backends/Py/logic/models.py b/EasyReflectometryApp/Backends/Py/logic/models.py index 15ae0f47..cf2777d2 100644 --- a/EasyReflectometryApp/Backends/Py/logic/models.py +++ b/EasyReflectometryApp/Backends/Py/logic/models.py @@ -76,34 +76,41 @@ 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.sample.data[0].layers.data[0].material = self._project_lib._materials[ - self._project_lib.get_index_air() - ] + model.add_assemblies() + # Superphase (Air layer) + air_material = self._project_lib._materials[self._project_lib.get_index_air()] + model.sample.data[0].layers.data[0].material = air_material model.sample.data[0].layers.data[0].thickness = 0.0 model.sample.data[0].layers.data[0].roughness = 0.0 + model.sample.data[0].layers.data[0].name = air_material.name + ' Layer' model.sample.data[0].name = 'Superphase' - 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 + # 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 a2ed7853..bc36f539 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): @@ -17,15 +22,46 @@ def as_status_string(self) -> str: return f'{self.count_free_parameters() + self.count_fixed_parameters()} ({self.count_free_parameters()} free, {self.count_fixed_parameters()} fixed)' # noqa: E501 @property - def parameters(self) -> List[str]: - return _from_parameters_to_list_of_dicts( - self._project_lib.parameters, self._project_lib._models[self._project_lib.current_model_index].unique_name - ) + def parameters(self) -> list[dict[str, Any]]: + 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'], + } + ) + return context + + def constraint_metadata(self) -> list[dict[str, Any]]: + context = self.constraint_context() + metadata: list[dict[str, Any]] = [] + for entry in context: + # Include ALL parameters (both independent and dependent) for constraint expressions + # if not entry['independent']: + # continue + metadata.append( + { + 'alias': entry['alias'], + 'displayName': entry['display_name'], + 'group': entry.get('group', ''), + 'independent': entry['independent'], + } + ) + metadata.sort(key=lambda item: item['displayName']) + return metadata def current_index(self) -> int: return self._current_index - def set_current_index(self, new_value: int) -> None: + def set_current_index(self, new_value: int) -> bool: if new_value != self._current_index: self._current_index = new_value return True @@ -37,46 +73,70 @@ def count_free_parameters(self) -> int: def count_fixed_parameters(self) -> int: return count_fixed_parameters(self._project_lib) + def _get_enabled_parameters(self) -> List[Parameter]: + """Return only enabled parameters from the project, filtered the same way as the parameters property.""" + # Use the parameters property which already filters by model path, then filter by enabled + return [p['object'] for p in self.parameters if p.get('enabled', True)] + + def _get_current_parameter(self) -> Parameter: + """Get the current parameter from enabled parameters list.""" + enabled_params = self._get_enabled_parameters() + if 0 <= self._current_index < len(enabled_params): + return enabled_params[self._current_index] + return None + def set_current_parameter_value(self, new_value: str) -> bool: - parameters = self._project_lib.parameters - if float(new_value) != parameters[self._current_index].value: + parameter = self._get_current_parameter() + if parameter is None: + return False + if float(new_value) != parameter.value: try: - parameters[self._current_index].value = float(new_value) + parameter.value = float(new_value) except ValueError: pass return True return False def set_current_parameter_min(self, new_value: str) -> bool: - parameters = self._project_lib.parameters - if float(new_value) != parameters[self._current_index].min: + parameter = self._get_current_parameter() + if parameter is None: + return False + if float(new_value) != parameter.min: try: - parameters[self._current_index].min = float(new_value) + parameter.min = float(new_value) except ValueError: pass return True return False def set_current_parameter_max(self, new_value: str) -> bool: - parameters = self._project_lib.parameters - if float(new_value) != parameters[self._current_index].max: + parameter = self._get_current_parameter() + if parameter is None: + return False + if float(new_value) != parameter.max: try: - parameters[self._current_index].max = float(new_value) + parameter.max = float(new_value) except ValueError: pass return True return False - def set_current_parameter_fit(self, new_value: str) -> bool: - parameters = self._project_lib.parameters - if bool(new_value) != parameters[self._current_index].free: - parameters[self._current_index].free = bool(new_value) + def set_current_parameter_fit(self, new_value: bool) -> bool: + parameter = self._get_current_parameter() + if parameter is None: + return False + if bool(new_value) != parameter.free: + parameter.free = bool(new_value) return True return False ### Constraints - def constraint_relations(self) -> List[str]: - return ['=', '<', '>'] + def constraint_relations(self) -> List[dict[str, str]]: + return [ + {'value': '=', 'text': '='}, + {'value': '>', 'text': '≥'}, + {'value': '<', 'text': '≤'}, + ] def constraint_arithmetic(self) -> List[str]: return ['', '*', '/', '+', '-'] @@ -89,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('<', '>') @@ -102,19 +163,44 @@ 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]]: - """Convert parameters to list of dictionaries with simplified logic.""" - def _get_parameter_display_name(param: Parameter) -> str: - """Extract display name from parameter path.""" +def _from_parameters_to_list_of_dicts(parameters: List[Parameter], models) -> list[dict[str, Any]]: + """Convert parameters to list of dictionaries with simplified logic. + + Layer parameters (thickness, roughness) are prefixed with model identifier (e.g., M1, M2). + Material parameters and model parameters (scale, background) are not prefixed to avoid duplication. + """ + + alias_registry: set[str] = set() + processed_unique_names: set[str] = set() # Track processed parameters to avoid duplicates + + # Layer parameter names that need model prefix + LAYER_PARAMS = {'thickness', 'roughness'} + + def _make_alias(name: str) -> str: + base = re.sub(r'[^0-9A-Za-z]+', '_', name).strip('_').lower() + if not base: + base = 'param' + if base[0].isdigit(): + base = f'p_{base}' + alias = base + counter = 1 + while alias in alias_registry or alias in RESERVED_ALIAS_NAMES: + alias = f'{base}_{counter}' + counter += 1 + alias_registry.add(alias) + return alias + + def _get_parameter_display_data(param: Parameter, model_unique_name: str) -> Tuple[str, str]: + """Extract display name and group from parameter path.""" path = global_object.map.find_path(model_unique_name, param.unique_name) if len(path) >= 2: parent_name = global_object.map.get_item_by_key(path[-2]).name param_name = global_object.map.get_item_by_key(path[-1]).name - return f'{parent_name} {param_name}' - 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: + def _get_dependency_expression(param: Parameter, model_unique_name: str) -> str: """Get simplified dependency expression.""" if param.independent: return '' @@ -122,29 +208,65 @@ 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, model_unique_name) + else: + dep_name = str(dependent_param) return param.dependency_expression.replace('a', dep_name) # Simple numerical dependency return f'= {param.value}' + def _is_layer_parameter(param: Parameter) -> bool: + """Check if parameter is a layer parameter (thickness or roughness).""" + return param.name.lower() in LAYER_PARAMS + parameter_list = [] - for parameter in parameters: - # Skip parameters not in the current model path - if not global_object.map.find_path(model_unique_name, parameter.unique_name): - continue - - parameter_list.append({ - 'name': _get_parameter_display_name(parameter), - '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), - '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/logic/project.py b/EasyReflectometryApp/Backends/Py/logic/project.py index 9a7f4677..adf8976c 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 @@ -105,6 +113,15 @@ 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 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/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 8233c3f8..e06fdf6a 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -17,6 +17,8 @@ class Plotting1d(QObject): sldChartRangesChanged = Signal() 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) @@ -39,6 +41,7 @@ def __init__(self, project_lib: ProjectLib, parent=None): 'analysisPage': { 'calculatedSerie': None, 'measuredSerie': None, + 'sldSerie': None, }, } } @@ -83,7 +86,15 @@ 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) + # Check if multi-experiment selection is enabled + if hasattr(self._proxy, '_analysis') and hasattr(self._proxy._analysis, '_selected_experiment_indices'): + selected_indices = self._proxy._analysis._selected_experiment_indices + if len(selected_indices) > 1: + # Return concatenated data for multiple experiments (legacy support) + return self._proxy._analysis.get_concatenated_experiment_data() + # Default single experiment behavior + current_index = self._project_lib.current_experiment_index + data = self._project_lib.experimental_data_for_model_at_index(current_index) except IndexError: data = DataSet1D( name='Experiment Data empty', @@ -94,39 +105,141 @@ def experiment_data(self) -> DataSet1D: ) return data + @property + def is_multi_experiment_mode(self) -> bool: + """Check if multiple experiments are selected.""" + try: + if hasattr(self._proxy, '_analysis') and hasattr(self._proxy._analysis, '_selected_experiment_indices'): + return len(self._proxy._analysis._selected_experiment_indices) > 1 + except Exception: # noqa: S110 + pass + return False + + @property + def individual_experiment_data_list(self) -> list: + """Get individual experiment data for multi-experiment plotting.""" + try: + if hasattr(self._proxy, '_analysis'): + return self._proxy._analysis.get_individual_experiment_data_list() + except Exception as e: + console.debug(f'Error getting individual experiment data: {e}') + return [] + # Sample @Property(float, notify=sampleChartRangesChanged) def sampleMaxX(self): - return self.sample_data.x.max() + return self._get_all_models_sample_range()[1] @Property(float, notify=sampleChartRangesChanged) def sampleMinX(self): - return self.sample_data.x.min() + return self._get_all_models_sample_range()[0] @Property(float, notify=sampleChartRangesChanged) def sampleMaxY(self): - return np.log10(self.sample_data.y.max()) + return self._get_all_models_sample_range()[3] @Property(float, notify=sampleChartRangesChanged) def sampleMinY(self): - return np.log10(self.sample_data.y.min()) + return self._get_all_models_sample_range()[2] + + def _get_all_models_sample_range(self): + """Get combined X/Y ranges for all models' sample data.""" + min_x, max_x = float('inf'), float('-inf') + min_y, max_y = float('inf'), float('-inf') + + for idx in range(len(self._project_lib.models)): + try: + data = self._project_lib.sample_data_for_model_at_index(idx) + if data.x.size > 0: + min_x = min(min_x, data.x.min()) + max_x = max(max_x, data.x.max()) + if data.y.size > 0: + valid_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) + 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): @@ -135,16 +248,153 @@ def chartRefs(self): @Property(str) def calcSerieColor(self): return '#00FF00' - #return self._calcSerieColor + # return self._calcSerieColor + + @Property(bool, notify=experimentDataChanged) + def isMultiExperimentMode(self) -> bool: + """Return whether multiple experiments are selected for plotting.""" + return self.is_multi_experiment_mode + + @Property('QVariantList', notify=experimentDataChanged) + def individualExperimentDataList(self) -> list: + """Return list of individual experiment data for multi-experiment plotting.""" + data_list = self.individual_experiment_data_list + # Convert to QML-friendly format + qml_data_list = [] + for exp_data in data_list: + qml_data_list.append( + { + 'name': exp_data['name'], + 'color': exp_data['color'], + 'index': exp_data['index'], + 'hasData': exp_data['data'].x.size > 0, + } + ) + return qml_data_list @Slot(str, str, 'QVariant') def setQtChartsSerieRef(self, page: str, serie: str, ref: QObject): self._chartRefs['QtCharts'][page][serie] = ref console.debug(IO.formatMsg('sub', f'{serie} on {page}: {ref}')) + @Slot(int, result='QVariantList') + def getSampleDataPointsForModel(self, model_index: int) -> list: + """Get sample data points for a specific model for plotting.""" + try: + data = self._project_lib.sample_data_for_model_at_index(model_index) + points = [] + for point in data.data_points(): + 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.""" + 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 [] + + @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() + # 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() @@ -152,6 +402,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': @@ -172,17 +426,32 @@ 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': - self.qtchartsReplaceMeasuredOnExperimentChartAndRedraw() + if self.is_multi_experiment_mode: + self.qtchartsReplaceMultiExperimentChartAndRedraw() + else: + self.qtchartsReplaceMeasuredOnExperimentChartAndRedraw() def qtchartsReplaceMeasuredOnExperimentChartAndRedraw(self): series_measured = self._chartRefs['QtCharts']['experimentPage']['measuredSerie'] @@ -196,14 +465,47 @@ 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', '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': - 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/project.py b/EasyReflectometryApp/Backends/Py/project.py index 85dca049..5217c0df 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,14 @@ 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) + # 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 602776cd..ce924fe8 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,23 +16,25 @@ class PyBackend(QObject): + # Signal for multi-experiment selection changes + multiExperimentSelectionChanged = Signal() + def __init__(self, parent=None): super().__init__(parent) self._project_lib = ProjectLib() - self._project_lib.default_model() # Page and Status bar backend parts self._home = Home() self._project = Project(self._project_lib) self._sample = Sample(self._project_lib) self._experiment = Experiment(self._project_lib) - self._analysis = Analysis(self._project_lib) + self._analysis = Analysis(self._project_lib, parent=self) self._summary = Summary(self._project_lib) self._status = Status(self._project_lib) # Plotting backend part - self._plotting = Plotting1d(self._project_lib) + self._plotting_1d = Plotting1d(self._project_lib, parent=self) self._logger = LoggerLevelHandler(self) @@ -70,12 +74,63 @@ def status(self) -> Status: @Property('QVariant', constant=True) def plotting(self) -> Plotting1d: - return self._plotting + return self._plotting_1d @Property('QVariant', constant=True) def logger(self): return self._logger + # Analysis properties and methods for multi-experiment selection + @Property(int, notify=multiExperimentSelectionChanged) + def analysisExperimentsSelectedCount(self) -> int: + """Return the count of currently selected experiments.""" + return self._analysis.experimentsSelectedCount + + @Property('QVariantList', notify=multiExperimentSelectionChanged) + def analysisSelectedExperimentIndices(self) -> list: + """Return the list of selected experiment indices.""" + return self._analysis.selectedExperimentIndices + + @Slot('QVariantList') + def analysisSetSelectedExperimentIndices(self, indices) -> None: + """Set multiple selected experiment indices.""" + 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() + + # Plotting properties for multi-experiment support + @Property(bool, notify=multiExperimentSelectionChanged) + def plottingIsMultiExperimentMode(self) -> bool: + """Return whether multiple experiments are selected for plotting.""" + return self._plotting_1d.isMultiExperimentMode + + @Property('QVariantList', notify=multiExperimentSelectionChanged) + def plottingIndividualExperimentDataList(self) -> list: + """Return list of individual experiment data for multi-experiment plotting.""" + return self._plotting_1d.individualExperimentDataList + + @Slot(int, result='QVariantList') + def plottingGetExperimentDataPoints(self, experiment_index: int) -> list: + """Get data points for a specific experiment for plotting.""" + return self._plotting_1d.getExperimentDataPoints(experiment_index) + + @Slot(int, result='QVariantList') + def plottingGetAnalysisDataPoints(self, experiment_index: int) -> list: + """Get measured and calculated data points for a specific experiment for analysis plotting.""" + return self._plotting_1d.getAnalysisDataPoints(experiment_index) + ######### Connections to relay info between the backend parts def _connect_backend_parts(self) -> None: self._connect_project_page() @@ -94,6 +149,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) @@ -107,6 +164,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() @@ -127,10 +186,11 @@ 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): - self._plotting.reset_data() + self._plotting_1d.reset_data() self._analysis._clearCacheAndEmitParametersChanged() self._status.statusChanged.emit() self._summary.summaryChanged.emit() @@ -142,15 +202,17 @@ 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() + # Emit signal for multi-experiment changes + self.multiExperimentSelectionChanged.emit() diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index ff8eb4c0..73c7647a 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -1,4 +1,14 @@ +import math +import numbers +import re +from typing import Any +from typing import Dict +from typing import Tuple + +import numpy as np +from asteval import Interpreter from easyreflectometry import Project as ProjectLib +from easyscience.variable.descriptor_number import DescriptorNumber from PySide6.QtCore import Property from PySide6.QtCore import QObject from PySide6.QtCore import Signal @@ -11,6 +21,35 @@ from .logic.parameters import Parameters as ParametersLogic from .logic.project import Project as ProjectLogic +_ASTEVAL_CONFIG = { + 'import': False, + 'importfrom': False, + 'assert': False, + 'augassign': False, + 'delete': False, + 'if': True, + 'ifexp': True, + 'for': False, + 'formattedvalue': False, + 'functiondef': False, + 'print': False, + 'raise': False, + 'listcomp': False, + 'dictcomp': False, + 'setcomp': False, + 'try': False, + 'while': False, + 'with': False, +} + +_GLOBAL_SYMBOLS: Dict[str, Any] = { + 'np': np, + 'numpy': np, + 'math': math, + 'pi': math.pi, + 'e': math.e, +} + class Sample(QObject): materialsTableChanged = Signal() @@ -42,6 +81,7 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._parameters_logic = ParametersLogic(project_lib) self._chached_layers = None + self._constraint_states: Dict[str, dict[str, Any]] = {} self.connect_logic() @@ -155,6 +195,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) @@ -165,12 +206,14 @@ def removeModel(self, value: str) -> None: @Slot() def addNewModel(self) -> None: self._models_logic.add_new() + self._project_logic._update_enablement_of_fixed_layers_for_model(self._models_logic.index) self.modelsTableChanged.emit() self.materialsTableChanged.emit() @Slot() def duplicateSelectedModel(self) -> None: self._models_logic.duplicate_selected_model() + self._project_logic._update_enablement_of_fixed_layers_for_model(self._models_logic.index) self.modelsTableChanged.emit() @Slot() @@ -414,16 +457,282 @@ def _clearCacheAndEmitLayersChanged(self): # # # # Constraints # # # + def _build_constraint_context(self) -> Tuple[list[dict[str, Any]], Dict[str, DescriptorNumber], Dict[str, str]]: + context = self._parameters_logic.constraint_context() + alias_lookup: Dict[str, DescriptorNumber] = {} + display_lookup: Dict[str, str] = {} + for entry in context: + alias = entry['alias'] + if alias: # Only add non-empty aliases + alias_lookup[alias] = entry['object'] + display_lookup[alias] = entry['display_name'] + return context, alias_lookup, display_lookup + + def _extract_dependency_map( + self, + expression: str, + alias_lookup: Dict[str, DescriptorNumber], + ) -> Dict[str, DescriptorNumber]: + used_aliases: Dict[str, DescriptorNumber] = {} + for alias, parameter in alias_lookup.items(): + if not alias: + continue + pattern = rf'\b{re.escape(alias)}\b' + if re.search(pattern, expression): + used_aliases[alias] = parameter + return used_aliases + + def _evaluate_constraint_expression( + self, + expression: str, + dependency_map: Dict[str, DescriptorNumber], + all_aliases: Dict[str, DescriptorNumber] | None = None, + ) -> DescriptorNumber | numbers.Number: + """Evaluate constraint expression with all available parameter aliases in scope.""" + interpreter = Interpreter(config=_ASTEVAL_CONFIG) + + # Add global symbols (numpy, etc.) + for name, value in _GLOBAL_SYMBOLS.items(): + interpreter.symtable[name] = value + if isinstance(value, numbers.Number): + interpreter.readonly_symbols.add(name) + + # Add ALL parameter aliases to the symbol table (not just dependencies) + # This allows validation to work even if we haven't detected the parameter yet + aliases_to_add = all_aliases if all_aliases is not None else dependency_map + for alias, dependency in aliases_to_add.items(): + interpreter.symtable[alias] = dependency + interpreter.readonly_symbols.add(alias) + + try: + result = interpreter.eval(expression, raise_errors=True) + except Exception as e: + # Provide helpful error message showing available aliases + if 'not defined' in str(e): + available = ', '.join(sorted(aliases_to_add.keys())[:10]) # Show first 10 + raise NameError(f'{str(e)}\nAvailable aliases: {available}...') from None + raise + return result + + @staticmethod + def _to_float(value: DescriptorNumber | numbers.Number) -> float: + if isinstance(value, DescriptorNumber): + return float(value.value) + if isinstance(value, numbers.Number): + return float(value) + raise TypeError('Expression must evaluate to a numeric value.') + + @staticmethod + def _pretty_expression(expression: str, alias_display: Dict[str, str]) -> str: + pretty_expression = expression + for alias in sorted(alias_display.keys(), key=len, reverse=True): + replacement = alias_display[alias] + if not replacement: + continue + pattern = rf'\b{re.escape(alias)}\b' + pretty_expression = re.sub(pattern, replacement, pretty_expression) + return pretty_expression + + @staticmethod + def _sanitize_relation(operator: str) -> str: + mapping = { + '=': '=', + '==': '=', + '≡': '=', + '>': '>', + '≥': '>', + '>': '>', + '<': '<', + '≤': '<', + '<': '<', + } + return mapping.get(operator, '=') + + @staticmethod + def _format_numeric(value: float) -> str: + return f'{value:.6g}' + + def _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: + # Pass all available aliases so validation can check any parameter reference + evaluation_result = self._evaluate_constraint_expression(expression_text, dependency_map, all_aliases=alias_lookup) + except NameError as error: + raise NameError(str(error).split('\n')[-1]) from None + except SyntaxError as error: + raise SyntaxError(str(error).split('\n')[-1]) from None + except Exception as error: + raise RuntimeError(str(error)) from None + + pretty_expression = self._pretty_expression(expression_text, display_lookup) + + if relation == '=': + if dependency_map: + if not isinstance(evaluation_result, DescriptorNumber): + raise TypeError('Expressions referencing parameters must evaluate to a parameter quantity.') + return { + 'mode': 'dynamic', + 'expression': expression_text, + 'dependency_map': dependency_map, + 'pretty_expression': pretty_expression, + 'relation': relation, + } + numeric_value = self._to_float(evaluation_result) + return { + 'mode': 'static', + 'value': numeric_value, + 'pretty_expression': self._format_numeric(numeric_value), + 'relation': relation, + } + + if dependency_map: + raise ValueError('Inequality constraints cannot reference other parameters.') + + numeric_value = self._to_float(evaluation_result) + mode = 'lower_bound' if relation == '>' else 'upper_bound' + return { + 'mode': mode, + 'value': numeric_value, + 'pretty_expression': self._format_numeric(numeric_value), + 'relation': relation, + } + + @staticmethod + def _ensure_parameter_independent(parameter: DescriptorNumber) -> None: + try: + parameter.make_independent() + except AttributeError: + parameter._independent = True + + def _infer_constraint_state( + self, + parameter_obj: DescriptorNumber, + display_lookup: Dict[str, str], + ) -> dict[str, Any] | None: + if getattr(parameter_obj, 'independent', True): + return None + + try: + raw_expression = parameter_obj.dependency_expression + except AttributeError: + value = float(parameter_obj.value) + formatted = self._format_numeric(value) + return { + 'mode': 'static', + 'relation': '=', + 'expression': formatted, + 'raw_expression': formatted, + 'pretty_expression': formatted, + 'value': value, + } + + dependency_map = getattr(parameter_obj, 'dependency_map', {}) or {} + alias_display_subset = {alias: display_lookup.get(alias, alias) for alias in dependency_map.keys()} + pretty_expression = self._pretty_expression(raw_expression, alias_display_subset) + return { + 'mode': 'dynamic', + 'relation': '=', + 'expression': raw_expression, + 'raw_expression': raw_expression, + 'pretty_expression': pretty_expression, + 'dependency_map': dependency_map, + } + + def _resolve_constraint_state( + self, + parameter_obj: DescriptorNumber, + display_lookup: Dict[str, str], + ) -> dict[str, Any] | None: + unique_name = getattr(parameter_obj, 'unique_name', None) + if unique_name is not None: + stored = self._constraint_states.get(unique_name) + if stored is not None: + return stored + return self._infer_constraint_state(parameter_obj, display_lookup) + + @staticmethod + def _capture_parameter_state(parameter: DescriptorNumber) -> dict[str, Any]: + state: dict[str, Any] = { + 'value': float(parameter.value), + 'free': bool(parameter.free), + 'independent': getattr(parameter, 'independent', True), + '_independent': getattr(parameter, '_independent', True), + } + if hasattr(parameter, 'min'): + try: + state['min'] = float(parameter.min) + except Exception: # noqa: BLE001 + state['min'] = parameter.min + if hasattr(parameter, 'max'): + try: + state['max'] = float(parameter.max) + except Exception: # noqa: BLE001 + state['max'] = parameter.max + return state + + @staticmethod + def _restore_parameter_state(parameter: DescriptorNumber, state: dict[str, Any]) -> None: + try: + parameter.make_independent() + except AttributeError: + parameter._independent = True + + if 'value' in state and state['value'] is not None: + parameter.value = state['value'] + if 'min' in state and state['min'] is not None: + parameter.min = state['min'] + if 'max' in state and state['max'] is not None: + parameter.max = state['max'] + if 'free' in state and state['free'] is not None: + parameter.free = state['free'] + if '_independent' in state and state['_independent'] is not None: + parameter._independent = state['_independent'] + @Property('QVariantList', notify=layersChange) def parameterNames(self) -> list[dict[str, str]]: return [parameter['name'] for parameter in self._parameters_logic.parameters] @Property('QVariantList', notify=layersChange) - def 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 not parameter['object'].enabled: + continue + enabled_param_names.append(parameter['name']) + return enabled_param_names + + @Property('QVariantList', notify=layersChange) + def dependentParameterNames(self) -> list[str]: + dep_param_names = [] + for parameter in self._parameters_logic.parameters: + if not parameter['independent']: + continue + if hasattr(parameter['object'], 'enabled') and not parameter['object'].enabled: + continue + dep_param_names.append(parameter['name']) + return dep_param_names @Property('QVariantList', notify=layersChange) - def 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,43 +741,102 @@ 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) + + # Use model-prefixed display name if available (from constrainModelsParameters) + dependent_display = state.get('dependent_display', entry['display_name']) + + constraints.append( + { + 'dependentName': dependent_display, + 'expression': expression_display, + 'rawExpression': raw_expression, + 'relation': relation, + 'type': mode, + } + ) return constraints @Slot(int) - def removeConstraintByIndex(self, index_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.""" + """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: @@ -478,46 +846,230 @@ 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, + } + + @Slot('QVariantList') + def constrainModelsParameters(self, model_indices: list) -> None: + """Constrain matching parameters across selected models. + + For each parameter in the models (except the first one), find the corresponding + parameter in the first model and create a constraint to make them equal. + + :param model_indices: List of model indices to constrain together. + """ + if len(model_indices) < 2: + return + + # Sort indices to ensure consistent ordering - first model becomes the reference + model_indices = sorted([int(idx) for idx in model_indices]) + + # Validate indices + num_models = len(self._project_lib._models) + for idx in model_indices: + if idx < 0 or idx >= num_models: + print(f'Invalid model index: {idx}') + return + + # Get the reference model (first in the sorted list) + reference_model_idx = model_indices[0] + reference_model = self._project_lib._models[reference_model_idx] + + # Build a map of parameter paths to parameters for the reference model + # The path is relative to the model (sample/assembly/layer structure) + reference_params_map = self._build_model_parameters_map(reference_model) + + # For each other model, find matching parameters and constrain them + constraints_added = 0 + for model_idx in model_indices[1:]: + model = self._project_lib._models[model_idx] + model_params_map = self._build_model_parameters_map(model) + + for param_path, dependent_param in model_params_map.items(): + if param_path in reference_params_map: + reference_param = reference_params_map[param_path] + + # Skip if already constrained + if not getattr(dependent_param, 'independent', True): + continue + + # Skip if it's the same parameter object + if dependent_param.unique_name == reference_param.unique_name: + continue + + try: + # Capture previous state for undo capability + previous_state = self._capture_parameter_state(dependent_param) + + # Create a constraint: dependent = reference + dependent_param.make_dependent_on( + dependency_expression='a', + dependency_map={'a': reference_param}, + ) + + # Store constraint state for display + unique_name = getattr(dependent_param, 'unique_name', None) + if unique_name is not None: + # Get display names with model prefix for clarity + # e.g., "M2 SiO2 sld = M1 SiO2 sld" + ref_display = self._get_parameter_display_name(reference_param, reference_model_idx) + dep_display = self._get_parameter_display_name(dependent_param, model_idx) + + self._constraint_states[unique_name] = { + 'mode': 'dynamic', + 'relation': '=', + 'previous': previous_state, + 'expression': 'a', + 'raw_expression': 'a', + 'pretty_expression': ref_display, + 'dependency_map': {'a': reference_param}, + 'dependent_display': dep_display, + } + + constraints_added += 1 + except Exception as e: # noqa: BLE001 + print(f'Failed to constrain parameter {param_path}: {e}') + continue + + if constraints_added > 0: + self.constraintsChanged.emit() + self.externalSampleChanged.emit() + self.layersChange.emit() + + def _build_model_parameters_map(self, model) -> Dict[str, DescriptorNumber]: + """Build a map of relative parameter paths to parameter objects for a model. + + The path structure is: assembly_name/layer_name/param_name + This allows matching parameters across models with the same structure. + """ + params_map: Dict[str, DescriptorNumber] = {} + + # Get parameters from model structure + for assembly_idx, assembly in enumerate(model.sample): + # assembly_name = assembly.name + for layer_idx, layer in enumerate(assembly.layers): + # layer_name = layer.name + # Get layer parameters + for param in layer.get_parameters(): + param_name = param.name + # Create a structural path that's independent of model name + path_key = f'{assembly_idx}/{layer_idx}/{param_name}' + params_map[path_key] = param + + return params_map + + def _get_parameter_display_name(self, param: DescriptorNumber, model_index: int | None = None) -> str: + """Get a display name for a parameter. + + :param param: The parameter to get the display name for. + :param model_index: Optional model index to prefix the display name with (e.g., 'M1'). + :return: Display name, optionally prefixed with model identifier. + """ + display_name = param.name # Fallback + try: + from easyscience import global_object + + # Try to find the parameter's path in the global object map + for model in self._project_lib._models: + path = global_object.map.find_path(model.unique_name, param.unique_name) + if path and len(path) >= 2: + parent_name = global_object.map.get_item_by_key(path[-2]).name + param_name = global_object.map.get_item_by_key(path[-1]).name + display_name = f'{parent_name} {param_name}' + break + except Exception: # noqa: S110 + pass + + if model_index is not None: + return f'M{model_index + 1} {display_name}' + return display_name + # # # # Q Range # # # diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 22ea760a..ca1ff7cb 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) } /////////////// @@ -159,14 +160,18 @@ QtObject { function sampleSetCurrentLayerSolvation(value) { activeBackend.sample.setCurrentLayerSolvation(value) } // Constraints + readonly property var sampleEnabledParameterNames: activeBackend.sample.enabledParameterNames readonly property var sampleParameterNames: activeBackend.sample.parameterNames readonly property var sampleDepParameterNames: activeBackend.sample.dependentParameterNames readonly property var sampleRelationOperators: activeBackend.sample.relationOperators readonly property var sampleArithmicOperators: activeBackend.sample.arithmicOperators readonly property var sampleConstraintsList: activeBackend.sample.constraintsList + readonly property var sampleConstraintParametersMetadata: activeBackend.sample.constraintParametersMetadata - function sampleAddConstraint(value1, value2, value3, value4, value5) { activeBackend.sample.addConstraint(value1, value2, value3, value4, value5) } + function sampleValidateConstraintExpression(index, relation, expression) { return activeBackend.sample.validateConstraintExpression(index, relation, expression) } + function sampleAddConstraint(index, relation, expression) { return activeBackend.sample.addConstraint(index, relation, expression) } function sampleRemoveConstraintByIndex(value) { activeBackend.sample.removeConstraintByIndex(value) } + function sampleConstrainModelsParameters(modelIndices) { activeBackend.sample.constrainModelsParameters(modelIndices) } // Q range readonly property var sampleQMin: activeBackend.sample.q_min @@ -197,6 +202,29 @@ 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: { + 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 @@ -211,10 +239,13 @@ QtObject { readonly property int analysisMinimizerCurrentIndex: activeBackend.analysis.minimizerCurrentIndex function analysisSetMinimizerCurrentIndex(value) { activeBackend.analysis.setMinimizerCurrentIndex(value) } - readonly property var analysisFitableParameters: activeBackend.analysis.fitableParameters + readonly property var analysisFitableParameters: activeBackend.analysis.enabledParameters readonly property int analysisCurrentParameterIndex: activeBackend.analysis.currentParameterIndex + readonly property var analysisEnabledParameters: activeBackend.analysis.enabledParameters + function analysisSetCurrentParameterIndex(value) { activeBackend.analysis.setCurrentParameterIndex(value) } function analysisSetExperimentName(value) { activeBackend.analysis.setExperimentName(value) } + function analysisSetExperimentNameAtIndex(index, value) { activeBackend.analysis.setExperimentNameAtIndex(index, value) } // Minimizer readonly property var analysisMinimizerTolerance: activeBackend.analysis.minimizerTolerance @@ -226,7 +257,13 @@ QtObject { readonly property string analysisFittingStatus: activeBackend.analysis.fittingStatus readonly property bool analysisFittingRunning: activeBackend.analysis.fittingRunning readonly property bool analysisIsFitFinished: activeBackend.analysis.isFitFinished + readonly property bool analysisShowFitResultsDialog: activeBackend.analysis.showFitResultsDialog + readonly property bool analysisFitSuccess: activeBackend.analysis.fitSuccess + readonly property string analysisFitErrorMessage: activeBackend.analysis.fitErrorMessage + readonly property int analysisFitNumRefinedParams: activeBackend.analysis.fitNumRefinedParams + readonly property real analysisFitChi2: activeBackend.analysis.fitChi2 function analysisFittingStartStop() { activeBackend.analysis.fittingStartStop() } + function analysisSetShowFitResultsDialog(value) { activeBackend.analysis.setShowFitResultsDialog(value) } // Parameters readonly property int analysisFreeParametersCount: activeBackend.analysis.freeParametersCount @@ -267,10 +304,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 @@ -281,4 +318,72 @@ QtObject { function plottingSetQtChartsSerieRef(value1, value2, value3) { activeBackend.plotting.setQtChartsSerieRef(value1, value2, value3) } 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 { + 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 [] + } + } + function plottingGetAnalysisDataPoints(index) { + try { + return activeBackend.plottingGetAnalysisDataPoints(index) + } catch (e) { + return [] + } + } } diff --git a/EasyReflectometryApp/Gui/Globals/Variables.qml b/EasyReflectometryApp/Gui/Globals/Variables.qml index d269b1d4..71b34dc1 100644 --- a/EasyReflectometryApp/Gui/Globals/Variables.qml +++ b/EasyReflectometryApp/Gui/Globals/Variables.qml @@ -11,4 +11,10 @@ QtObject { property bool showLegendOnSamplePage: false property bool showLegendOnExperimentPage: false property bool showLegendOnAnalysisPage: false + property bool useStaggeredPlotting: false + property double staggeringFactor: 0.5 + + // Sample page plot control settings + property bool reverseSldZAxis: false + property bool logarithmicQAxis: false } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml index 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/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index 1a79a3f7..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 (Å⁻¹)" @@ -48,8 +72,122 @@ 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 + } + + // 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 @@ -139,15 +277,70 @@ Rectangle { rightPadding: EaStyle.Sizes.fontPixelSize topPadding: EaStyle.Sizes.fontPixelSize * 0.5 bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 + spacing: EaStyle.Sizes.fontPixelSize * 0.25 + // Single experiment legend EaElements.Label { + visible: !chartView.isMultiExperimentMode text: '━ I (Measured)' color: chartView.measSerie.color } EaElements.Label { - text: '━ (calculated)' + visible: !chartView.isMultiExperimentMode + text: '━ (Calculated)' color: chartView.calcSerie.color } + + // Multi-experiment legend + Column { + visible: chartView.isMultiExperimentMode + spacing: EaStyle.Sizes.fontPixelSize * 0.2 + + EaElements.Label { + text: qsTr("Multi-experiment view:") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + font.bold: true + color: EaStyle.Colors.themeForeground + } + + Repeater { + model: chartView.isMultiExperimentMode ? Globals.BackendWrapper.plottingIndividualExperimentDataList : [] + delegate: Row { + spacing: EaStyle.Sizes.fontPixelSize * 0.3 + + Rectangle { + width: EaStyle.Sizes.fontPixelSize * 0.8 + height: 3 + color: modelData.color || "#1f77b4" + anchors.verticalCenter: parent.verticalCenter + } + + EaElements.Label { + text: modelData.name || `Exp ${index + 1}` + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.8 + color: EaStyle.Colors.themeForeground + anchors.verticalCenter: parent.verticalCenter + } + } + } + + Rectangle { + width: parent.width - 2 * EaStyle.Sizes.fontPixelSize + height: 1 + color: EaStyle.Colors.chartGridLine + } + + EaElements.Label { + text: qsTr("━ Measured (thin)") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeForegroundMinor + } + EaElements.Label { + text: qsTr("━ Calculated (thick)") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeForegroundMinor + } + } } } // Legend @@ -168,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 new file mode 100644 index 00000000..305fe01c --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -0,0 +1,486 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtCharts + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Globals as EaGlobals +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Charts as EaCharts + +import Gui.Globals as Globals + + +Rectangle { + id: container + + color: EaStyle.Colors.chartBackground + + SplitView { + anchors.fill: parent + orientation: Qt.Vertical + + // Analysis Chart (2/3 height) + Rectangle { + id: analysisContainer + SplitView.fillHeight: true + SplitView.preferredHeight: parent.height * 0.67 + SplitView.minimumHeight: 100 + color: EaStyle.Colors.chartBackground + + EaCharts.QtCharts1dMeasVsCalc { + id: analysisChartView + + property alias calculated: analysisChartView.calcSerie + property alias measured: analysisChartView.measSerie + bkgSerie.color: measSerie.color + measSerie.width: 1 + bkgSerie.width: 1 + + anchors.fill: parent + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + + useOpenGL: EaGlobals.Vars.useOpenGL + + // Multi-experiment support + property var multiExperimentSeries: [] + property bool isMultiExperimentMode: { + try { + return Globals.BackendWrapper.plottingIsMultiExperimentMode || false + } catch (e) { + return false + } + } + + // Watch for changes in multi-experiment mode property + onIsMultiExperimentModeChanged: { + updateMultiExperimentSeries() + } + + // Watch for changes in multi-experiment selection + Connections { + target: Globals.BackendWrapper.activeBackend + 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 (Å⁻¹)" + 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 + + // Single experiment legend + EaElements.Label { + visible: !analysisChartView.isMultiExperimentMode + text: '━ I (Measured)' + color: analysisChartView.measSerie.color + } + EaElements.Label { + visible: !analysisChartView.isMultiExperimentMode + text: '━ (Calculated)' + color: analysisChartView.calcSerie.color + } + + // Multi-experiment legend + Column { + visible: analysisChartView.isMultiExperimentMode + spacing: EaStyle.Sizes.fontPixelSize * 0.2 + + EaElements.Label { + text: qsTr("Multi-experiment view:") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + font.bold: true + color: EaStyle.Colors.themeForeground + } + + Repeater { + model: analysisChartView.isMultiExperimentMode ? Globals.BackendWrapper.plottingIndividualExperimentDataList : [] + delegate: Row { + spacing: EaStyle.Sizes.fontPixelSize * 0.3 + + Rectangle { + width: EaStyle.Sizes.fontPixelSize * 0.8 + height: 3 + color: modelData.color || "#1f77b4" + anchors.verticalCenter: parent.verticalCenter + } + + EaElements.Label { + text: modelData.name || `Exp ${index + 1}` + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.8 + color: EaStyle.Colors.themeForeground + anchors.verticalCenter: parent.verticalCenter + } + } + } + + Rectangle { + width: parent.width - 2 * EaStyle.Sizes.fontPixelSize + height: 1 + color: EaStyle.Colors.chartGridLine + } + + EaElements.Label { + text: qsTr("━ Measured (thin)") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeForegroundMinor + } + EaElements.Label { + text: qsTr("━ Calculated (thick)") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeForegroundMinor + } + } + } + } + + EaElements.ToolTip { + id: analysisDataToolTip + + arrowLength: 0 + textFormat: Text.RichText + } + + // Data is set in python backend (plotting_1d.py) + Component.onCompleted: { + Globals.References.pages.analysis.mainContent.analysisView = analysisChartView + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', + 'measuredSerie', + measured) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', + 'calculatedSerie', + calculated) + + // Initialize multi-experiment support + updateMultiExperimentSeries() + } + } + } + + // SLD Chart (1/3 height) + Rectangle { + id: sldContainer + SplitView.fillHeight: true + SplitView.preferredHeight: parent.height * 0.33 + SplitView.minimumHeight: 80 + 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('analysisPage', + '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 + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/SldView.qml index 20fbeafe..d55ca44a 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 } @@ -152,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() diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml index 01ddedb9..4fb9e390 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,254 @@ 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 = [] + // Don't send empty array to backend - let subsequent selection handle it + } + + function updateBackendWithSelectedExperiments() { + if (selectedExperimentIndices.length === 0) { + return + } + + // console.log(`📊 Updating backend with selection: [${selectedExperimentIndices.join(', ')}]`) + Globals.BackendWrapper.analysisSetSelectedExperimentIndices(selectedExperimentIndices) + + var primaryIndex = selectedExperimentIndices[0] + + if (selectedExperimentIndices.length === 1) { + if (wasMultiSelected) { + var tempIndex = (primaryIndex === 0) ? 1 : 0 + if (tempIndex < Globals.BackendWrapper.analysisExperimentsAvailable.length) { + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(tempIndex) + } + wasMultiSelected = false } + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(primaryIndex) + } else { + wasMultiSelected = true + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(primaryIndex) + } + + var modelIndexFromExperiment = Globals.BackendWrapper.analysisModelForExperiment + Globals.BackendWrapper.sampleSetCurrentModelIndex(modelIndexFromExperiment) + } + + Component.onCompleted: { + if (Globals.BackendWrapper.analysisExperimentsAvailable.length > 0) { + selectSingleExperiment(0) } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml index 9dd32688..405e5291 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") } @@ -294,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) } } } @@ -302,6 +313,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 +398,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 (Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].min > from) { - from = Globals.BackendWrapper.analysisFitableParameters[Globals.BackendWrapper.analysisCurrentParameterIndex].min + 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 + } + const originalIndex = backend.analysisCurrentParameterIndex + var hasChanges = false + const targetFit = !!enable + fittables.bulkUpdatingSelection = true + try { + for (let i = 0; i < params.length; i++) { + const parameter = params[i] + const independent = parameter.independent !== undefined ? parameter.independent : true + if (!independent) { + continue + } + if (!!parameter.fit === targetFit) { + continue + } + backend.analysisSetCurrentParameterIndex(i) + backend.analysisSetCurrentParameterFit(targetFit) + hasChanges = true + } + } finally { + const paramsLength = params.length + if (paramsLength) { + const targetIndex = originalIndex >= 0 && originalIndex < paramsLength ? originalIndex : Math.min(Math.max(originalIndex, 0), paramsLength - 1) + backend.analysisSetCurrentParameterIndex(targetIndex) + } + fittables.bulkUpdatingSelection = false + } + if (hasChanges) { + updateSliderLimits() + updateSliderValue() } - slider.from = EaLogic.Utils.toDefaultPrecision(from) - slider.to = EaLogic.Utils.toDefaultPrecision(to) } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml index 9ea84974..74984f63 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Controls +import QtQuick.Layouts import EasyApp.Gui.Globals as EaGlobals import EasyApp.Gui.Style as EaStyle @@ -15,25 +16,44 @@ import Gui.Globals as Globals EaElements.Dialog { id: dialog - visible: Globals.BackendWrapper.analysisFittingStatus - title: qsTr("Fit status") + visible: Globals.BackendWrapper.analysisShowFitResultsDialog + title: Globals.BackendWrapper.analysisFitSuccess ? qsTr("Refinement Results") : qsTr("Refinement Failed") standardButtons: Dialog.Ok + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onAccepted: { + Globals.BackendWrapper.analysisSetShowFitResultsDialog(false) + } + + onClosed: { + Globals.BackendWrapper.analysisSetShowFitResultsDialog(false) + } Component.onCompleted: Globals.References.pages.analysis.sidebar.basic.popups.fitStatusDialogOkButton = okButtonRef() - EaElements.Label { - text: { - if ( Globals.BackendWrapper.analysisFittingStatus === 'Success') { - return 'Optimization finished successfully.' - } else if (Globals.BackendWrapper.analysisFittingStatus === 'Failure') { - return 'Optimization failed.' - } else if (Globals.BackendWrapper.analysisFittingStatus === 'Aborted') { - return 'Optimization aborted.' - } else if (Globals.BackendWrapper.analysisFittingStatus === 'No free params') { - return 'Nothing to vary. Allow some parameters to be free.' - } else { - return '' - } + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.Label { + text: "Success: " + Globals.BackendWrapper.analysisFitSuccess + } + + EaElements.Label { + visible: Globals.BackendWrapper.analysisFitSuccess + text: "Num. refined parameters: " + Globals.BackendWrapper.analysisFitNumRefinedParams + } + + EaElements.Label { + visible: Globals.BackendWrapper.analysisFitSuccess + text: "Chi2: " + Globals.BackendWrapper.analysisFitChi2.toFixed(4) + } + + EaElements.Label { + visible: !Globals.BackendWrapper.analysisFitSuccess && Globals.BackendWrapper.analysisFitErrorMessage !== "" + text: "Error: " + Globals.BackendWrapper.analysisFitErrorMessage + wrapMode: Text.WordWrap + width: Math.min(implicitWidth, EaStyle.Sizes.sideBarContentWidth * 1.5) + color: EaStyle.Colors.red } } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 105cddf3..905dc7c9 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -31,6 +31,120 @@ 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 + } + } + property bool useStaggeredPlotting: { + try { + return Globals.Variables.useStaggeredPlotting || false + } catch (e) { + return false + } + } + property double staggeringFactor: { + try { + return Globals.Variables.staggeringFactor !== undefined ? Globals.Variables.staggeringFactor : 0.5 + } catch (e) { + return 0.5 + } + } + + // Watch for changes in multi-experiment mode + // onIsMultiExperimentModeChanged: { + // // Don't update here - wait for experimentDataChanged signal + // // which fires after backend has prepared the data + // console.log(`Multi-experiment mode changed to: ${isMultiExperimentMode}`) + // } + + // Watch for changes in staggered plotting mode + onUseStaggeredPlottingChanged: { + // console.log(`ExperimentView detected staggered mode change: ${useStaggeredPlotting}`) + // console.log(`Multi-experiment mode: ${isMultiExperimentMode}, Series count: ${multiExperimentSeries.length}`) + if (isMultiExperimentMode && multiExperimentSeries.length > 1) { + // console.log(`Refreshing ${multiExperimentSeries.length} series with staggered mode: ${useStaggeredPlotting}`) + // Re-populate all series with new staggering setting + for (var i = 0; i < multiExperimentSeries.length; i++) { + populateExperimentSeries(multiExperimentSeries[i]) + } + // Adjust Y-axis to fit all staggered experiments + adjustAxisForStaggering() + } else { + console.log(` Skipping refresh - not in multi-experiment mode or insufficient series`) + } + } + + // Watch for changes in staggering factor + onStaggeringFactorChanged: { + // console.log(`ExperimentView detected staggering factor change: ${staggeringFactor.toFixed(2)}`) + if (useStaggeredPlotting && isMultiExperimentMode && multiExperimentSeries.length > 1) { + // console.log(`Refreshing ${multiExperimentSeries.length} series with new factor`) + // Re-populate all series with new staggering factor + for (var i = 0; i < multiExperimentSeries.length; i++) { + populateExperimentSeries(multiExperimentSeries[i]) + } + // Adjust Y-axis to fit all staggered experiments + adjustAxisForStaggering() + } + } + + // Additional watcher directly on Globals.Variables.staggeringFactor + Connections { + target: Globals.Variables + function onStaggeringFactorChanged() { + // console.log(`Direct watcher: Globals.Variables.staggeringFactor changed to ${Globals.Variables.staggeringFactor}`) + if (chartView.useStaggeredPlotting && chartView.isMultiExperimentMode && chartView.multiExperimentSeries.length > 1) { + // console.log(`Forcing refresh of ${chartView.multiExperimentSeries.length} series`) + for (var i = 0; i < chartView.multiExperimentSeries.length; i++) { + chartView.populateExperimentSeries(chartView.multiExperimentSeries[i]) + } + chartView.adjustAxisForStaggering() + } + } + } + + function adjustAxisForStaggering() { + if (!useStaggeredPlotting || !isMultiExperimentMode || multiExperimentSeries.length <= 1) { + return + } + + var allMinY = 1e10 + var allMaxY = -1e10 + + // Find the bounds of all staggered series + for (var exp = 0; exp < multiExperimentSeries.length; exp++) { + var series = multiExperimentSeries[exp].measuredSerie + for (var i = 0; i < series.count; i++) { + var point = series.at(i) + allMinY = Math.min(allMinY, point.y) + allMaxY = Math.max(allMaxY, point.y) + } + } + + // Add 10% padding and apply to Y-axis + var padding = (allMaxY - allMinY) * 0.1 + chartView.axisY.min = allMinY - padding + chartView.axisY.max = allMaxY + padding + + // console.log(`📏 Adjusted Y-axis for staggering: [${allMinY.toExponential(2)}, ${allMaxY.toExponential(2)}] with padding`) + } + + // Watch for changes in multi-experiment selection + Connections { + target: Globals.BackendWrapper.activeBackend + 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() + } + } property double xRange: Globals.BackendWrapper.plottingExperimentMaxX - Globals.BackendWrapper.plottingExperimentMinX axisX.title: "q (Å⁻¹)" @@ -48,6 +162,163 @@ Rectangle { calcSerie.onHovered: (point, state) => showMainTooltip(chartView, point, state) + // Multi-experiment series management + function updateMultiExperimentSeries() { + // console.log("Updating multi-experiment series...") + // console.log(` isMultiExperimentMode: ${isMultiExperimentMode}`) + + // Clear existing multi-experiment series + clearMultiExperimentSeries() + + if (!isMultiExperimentMode) { + // Show default series for single experiment + measured.visible = true + errorUpper.visible = true + errorLower.visible = true + return + } + + // Get experiment data list + var experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentDataList + // If no data available yet, keep default series visible as fallback + if (experimentDataList.length === 0) { + console.log("No experiment data available - keeping default series visible") + measured.visible = true + errorUpper.visible = true + errorLower.visible = true + return + } + + // Hide default series in multi-experiment mode (only after we have data) + measured.visible = false + errorUpper.visible = false + errorLower.visible = false + + // Create series for each experiment + for (var i = 0; i < experimentDataList.length; i++) { + var expData = experimentDataList[i] + if (expData.hasData) { + createExperimentSeries(expData.index, expData.name, expData.color) + } + } + } + + function clearMultiExperimentSeries() { + // Remove all dynamically created series + for (var i = 0; i < multiExperimentSeries.length; i++) { + var seriesSet = multiExperimentSeries[i] + if (seriesSet.measuredSerie) { + chartView.removeSeries(seriesSet.measuredSerie) + } + if (seriesSet.errorUpperSerie) { + chartView.removeSeries(seriesSet.errorUpperSerie) + } + if (seriesSet.errorLowerSerie) { + chartView.removeSeries(seriesSet.errorLowerSerie) + } + } + multiExperimentSeries = [] + } + + function createExperimentSeries(expIndex, expName, color) { + // console.log(` Creating series for experiment ${expIndex}: ${expName} (${color})`) + + // Create measured data series + var measuredSerie = chartView.createSeries(ChartView.SeriesTypeLine, + `${expName} - Data`, + chartView.axisX, chartView.axisY) + measuredSerie.color = color + measuredSerie.width = 2 + measuredSerie.capStyle = Qt.RoundCap + measuredSerie.useOpenGL = chartView.useOpenGL + + // Create error bound series (lighter colors) + var errorColor = Qt.darker(color, 1.3) + + var errorUpperSerie = chartView.createSeries(ChartView.SeriesTypeLine, + `${expName} - Error Upper`, + chartView.axisX, chartView.axisY) + errorUpperSerie.color = errorColor + errorUpperSerie.width = 1 + errorUpperSerie.style = Qt.DashLine + errorUpperSerie.useOpenGL = chartView.useOpenGL + + var errorLowerSerie = chartView.createSeries(ChartView.SeriesTypeLine, + `${expName} - Error Lower`, + chartView.axisX, chartView.axisY) + errorLowerSerie.color = errorColor + errorLowerSerie.width = 1 + errorLowerSerie.style = Qt.DashLine + errorLowerSerie.useOpenGL = chartView.useOpenGL + + // Store references + var seriesSet = { + measuredSerie: measuredSerie, + errorUpperSerie: errorUpperSerie, + errorLowerSerie: errorLowerSerie, + expIndex: expIndex, + expName: expName, + color: color + } + multiExperimentSeries.push(seriesSet) + + // Populate with data + populateExperimentSeries(seriesSet) + } + + function populateExperimentSeries(seriesSet) { + // Get data points from backend + var dataPoints = Globals.BackendWrapper.plottingGetExperimentDataPoints(seriesSet.expIndex) + + // Clear existing points + seriesSet.measuredSerie.clear() + seriesSet.errorUpperSerie.clear() + seriesSet.errorLowerSerie.clear() + + // Calculate staggering offset if enabled + var yOffset = 0 + if (useStaggeredPlotting && isMultiExperimentMode && multiExperimentSeries.length > 1) { + var experimentIndex = seriesSet.expIndex + var totalExperiments = multiExperimentSeries.length + + // Find the individual experiment's data range + var expMinY = 1e10 + var expMaxY = -1e10 + + for (var j = 0; j < dataPoints.length; j++) { + expMinY = Math.min(expMinY, dataPoints[j].y) + expMaxY = Math.max(expMaxY, dataPoints[j].y) + } + + var expDataRange = expMaxY - expMinY + + // Use staggering factor to control offset + // Factor ranges from 0 (no staggering) to 5.0 (maximum staggering) + // Each experiment gets offset proportional to the staggering factor + var offsetStep = expDataRange * 0.5 * staggeringFactor + yOffset = experimentIndex * offsetStep + + // Ensure we don't exceed reasonable bounds - limit total staggering to 2x original range + var maxTotalOffset = expDataRange * 5 + var currentTotalOffset = (totalExperiments - 1) * offsetStep + + if (currentTotalOffset > maxTotalOffset) { + // Rescale all offsets proportionally to fit within bounds + var scaleFactor = maxTotalOffset / currentTotalOffset + yOffset = experimentIndex * offsetStep * scaleFactor + } + + } + + // Add data points with potential offset + for (var i = 0; i < dataPoints.length; i++) { + var point = dataPoints[i] + seriesSet.measuredSerie.append(point.x, point.y + yOffset) + seriesSet.errorUpperSerie.append(point.x, point.errorUpper + yOffset) + seriesSet.errorLowerSerie.append(point.x, point.errorLower + yOffset) + } + } + // Tool buttons Row { id: toolButtons @@ -136,14 +407,63 @@ Rectangle { topPadding: EaStyle.Sizes.fontPixelSize * 0.5 bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 + // Single experiment legend EaElements.Label { + visible: !chartView.isMultiExperimentMode text: '━ I (Measured)' color: chartView.calcSerie.color } EaElements.Label { + visible: !chartView.isMultiExperimentMode text: '━ Error' color: chartView.measSerie.color } + + // Multi-experiment legend + Column { + visible: chartView.isMultiExperimentMode + spacing: EaStyle.Sizes.fontPixelSize * 0.2 + + EaElements.Label { + text: qsTr("Multi-experiment view:") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + font.bold: true + color: EaStyle.Colors.themeForeground + } + + Repeater { + model: chartView.isMultiExperimentMode ? Globals.BackendWrapper.plottingIndividualExperimentDataList : [] + delegate: Row { + spacing: EaStyle.Sizes.fontPixelSize * 0.3 + + Rectangle { + width: EaStyle.Sizes.fontPixelSize * 0.8 + height: 3 + color: modelData.color || "#1f77b4" + anchors.verticalCenter: parent.verticalCenter + } + + EaElements.Label { + text: modelData.name || `Exp ${index + 1}` + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.8 + color: EaStyle.Colors.themeForeground + anchors.verticalCenter: parent.verticalCenter + } + } + } + + Rectangle { + width: parent.width - 2 * EaStyle.Sizes.fontPixelSize + height: 1 + color: EaStyle.Colors.chartGridLine + } + + EaElements.Label { + text: qsTr("- - - Error bounds") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeForegroundMinor + } + } } } // Legend @@ -167,6 +487,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() + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml index c7f44dd9..587ba10c 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml @@ -9,18 +9,155 @@ 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 + + // Property to track selected experiment indices for multi-selection + property var selectedExperimentIndices: [] + // Property to track if we were in multi-selection mode + property bool wasMultiSelected: false + + // Watch for changes in selection to automatically disable staggered mode + onSelectedExperimentIndicesChanged: { + if (selectedExperimentIndices.length <= 1 && Globals.Variables.useStaggeredPlotting) { + console.log(`🔄 Selection changed to single experiment - disabling staggered plotting`) + Globals.Variables.useStaggeredPlotting = false + } + } + + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + // Multi-selection controls + Row { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + visible: Globals.BackendWrapper.analysisExperimentsAvailable.length > 1 + + // Helper text for multi-selection + EaElements.Label { + text: qsTr("Ctrl+Click for multi-select") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.75 + color: EaStyle.Colors.themeForegroundMinor + anchors.verticalCenter: parent.verticalCenter + } + + // Select all button + EaElements.TabButton { + height: EaStyle.Sizes.fontPixelSize * 1.5 + width: height * 2 + borderColor: EaStyle.Colors.chartAxis + fontIcon: "check-double" + ToolTip.text: qsTr("Select all experiments") + onClicked: selectAllExperiments() + enabled: selectedExperimentIndices.length < Globals.BackendWrapper.analysisExperimentsAvailable.length + } + + // Clear selection button + EaElements.TabButton { + height: EaStyle.Sizes.fontPixelSize * 1.5 + width: height * 2 + borderColor: EaStyle.Colors.chartAxis + fontIcon: "times" + ToolTip.text: qsTr("Clear selection") + onClicked: { + clearAllSelections() + if (Globals.BackendWrapper.analysisExperimentsAvailable.length > 0) { + selectSingleExperiment(0) + } + } + enabled: selectedExperimentIndices.length > 1 + } + + // Status indicator + EaElements.Label { + text: selectedExperimentIndices.length > 1 ? + qsTr("Selected: %1").arg(selectedExperimentIndices.map(i => i + 1).join(", ")) : + "" + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 + color: EaStyle.Colors.themeAccent + anchors.verticalCenter: parent.verticalCenter + visible: selectedExperimentIndices.length > 1 + } + } - EaComponents.TableView { - id: dataTable - defaultInfoText: qsTr("No Experiments Loaded") - model: Globals.BackendWrapper.analysisExperimentsAvailable.length + // Staggered plotting toggle + Row { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + visible: selectedExperimentIndices.length > 1 - // Headers + EaElements.CheckBox { + id: staggeredPlottingCheckbox + enabled: selectedExperimentIndices.length > 1 + checked: Globals.Variables.useStaggeredPlotting && selectedExperimentIndices.length > 1 + text: qsTr("Staggered view") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.75 + ToolTip.text: qsTr("Vertically offset experiment lines for easier comparison") + onCheckedChanged: { + if (selectedExperimentIndices.length > 1) { + Globals.Variables.useStaggeredPlotting = checked + console.log(`📊 Staggered plotting mode changed to: ${checked}`) + console.log(`🔄 Updating backend with multi-experiment selection`) + updateBackendWithSelectedExperiments() + } else { + console.log(`⚠️ Single experiment mode - staggering not applicable`) + } + } + } + + // Staggering distance slider + EaElements.Slider { + id: staggeringSlider + width: EaStyle.Sizes.fontPixelSize * 6 + anchors.verticalCenter: parent.verticalCenter + from: 0.0 + to: 5.0 + value: Globals.Variables.staggeringFactor !== undefined ? Globals.Variables.staggeringFactor : 0.5 + stepSize: 0.05 + enabled: staggeredPlottingCheckbox.checked && selectedExperimentIndices.length > 1 + ToolTip.text: "Adjust staggering distance (" + Number(value).toFixed(2) + ")" + ToolTip.visible: hovered + + onValueChanged: { + // Always update the global variable to trigger watchers + Globals.Variables.staggeringFactor = value + // console.log(`📏 Staggering factor changed to: ${value.toFixed(2)}`) + } + } + } + + Row { + spacing: EaStyle.Sizes.fontPixelSize + + EaComponents.TableView { + id: dataTable + defaultInfoText: qsTr("No Experiments Loaded") + model: Globals.BackendWrapper.analysisExperimentsAvailable.length + + // Watch for model changes and update selection accordingly + onModelChanged: { + if (model === 0) { + clearAllSelections() + } else { + // Remove any selected indices that are now out of range + var validSelection = [] + for (var i = 0; i < selectedExperimentIndices.length; i++) { + if (selectedExperimentIndices[i] < model) { + validSelection.push(selectedExperimentIndices[i]) + } + } + if (validSelection.length !== selectedExperimentIndices.length) { + selectedExperimentIndices = validSelection + if (validSelection.length === 0 && model > 0) { + selectSingleExperiment(0) + } else { + updateBackendWithSelectedExperiments() + } + } + } + } // Headers header: EaComponents.TableViewHeader { EaComponents.TableViewLabel { @@ -56,11 +193,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 + 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 { @@ -68,7 +233,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 { @@ -99,10 +264,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 +280,154 @@ 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) + } + + // Track if we now have multiple selections + if (currentSelection.length > 1) { + wasMultiSelected = true + } + + selectedExperimentIndices = currentSelection + updateBackendWithSelectedExperiments() + } + + function selectSingleExperiment(experimentIndex) { + // console.log("selectSingleExperiment called with index:", experimentIndex) + selectedExperimentIndices = [experimentIndex] + // console.log("Updated selectedExperimentIndices to:", selectedExperimentIndices) + updateBackendWithSelectedExperiments() + } + + function updateBackendWithSelectedExperiments() { + if (selectedExperimentIndices.length === 0) { + return + } + + // If only one experiment is selected, use the existing single-selection logic + if (selectedExperimentIndices.length === 1) { + var currentIndex = selectedExperimentIndices[0] + + // If we were in multi-selection mode and now switching to single selection, + // force a plot refresh by toggling the current index + if (wasMultiSelected) { + // console.log("Switching from multi-selection to single selection - forcing plot refresh") + // Force refresh by temporarily setting a different index and then back + var tempIndex = (currentIndex === 0) ? 1 : 0 + if (tempIndex < Globals.BackendWrapper.analysisExperimentsAvailable.length) { + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(tempIndex) + } + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(currentIndex) + wasMultiSelected = false + } else { + // Normal single selection + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(currentIndex) + } + } else { + // Mark that we're in multi-selection mode + wasMultiSelected = true + // For multiple experiments, call the new backend method + // console.log("Multi-experiment selection - checking backend method availability") + // console.log("Backend wrapper analysis available:", typeof Globals.BackendWrapper.analysis) + // console.log("analysisSetSelectedExperimentIndices available:", typeof Globals.BackendWrapper.analysisSetSelectedExperimentIndices) + + // Try multiple approaches to call the backend method + var methodCalled = false + + // Approach 1: Direct call to top-level method + if (typeof Globals.BackendWrapper.analysisSetSelectedExperimentIndices === 'function') { + // console.log("Approach 1: Calling analysisSetSelectedExperimentIndices with:", selectedExperimentIndices) + Globals.BackendWrapper.analysisSetSelectedExperimentIndices(selectedExperimentIndices) + methodCalled = true + } + + // Approach 2: Try through analysis object + if (!methodCalled && Globals.BackendWrapper.analysis && + typeof Globals.BackendWrapper.analysis.setSelectedExperimentIndices === 'function') { + // console.log("Approach 2: Calling through analysis object with:", selectedExperimentIndices) + Globals.BackendWrapper.analysis.setSelectedExperimentIndices(selectedExperimentIndices) + methodCalled = true + } + + if (methodCalled) { + console.log("Multi-experiment selection applied:", selectedExperimentIndices) + } else { + // Fallback: set the first selected experiment as current + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(selectedExperimentIndices[0]) + console.log("Multi-experiment selection - fallback to single selection") + console.log("Selected experiments:", selectedExperimentIndices) + // console.log("Available backend methods:", Object.keys(Globals.BackendWrapper)) + } + } + } + + function clearAllSelections() { + console.log("clearAllSelections called - clearing to empty array") + wasMultiSelected = false + selectedExperimentIndices = [] + // Notify backend that selection is cleared + if (typeof Globals.BackendWrapper.analysisSetSelectedExperimentIndices === 'function') { + // console.log("Calling backend with empty array to clear selection") + Globals.BackendWrapper.analysisSetSelectedExperimentIndices([]) + } + } + + function selectAllExperiments() { + var allIndices = [] + for (var i = 0; i < Globals.BackendWrapper.analysisExperimentsAvailable.length; i++) { + allIndices.push(i) + } + wasMultiSelected = true + selectedExperimentIndices = allIndices + updateBackendWithSelectedExperiments() + } + + // Initialize with first experiment selected by default + Component.onCompleted: { + if (Globals.BackendWrapper.analysisExperimentsAvailable.length > 0) { + selectSingleExperiment(0) } } } diff --git a/EasyReflectometryApp/Gui/Pages/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..fb2f6d4e --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -0,0 +1,688 @@ +// SPDX-FileCopyrightText: 2025 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2025 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtCharts + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Globals as EaGlobals +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Charts as EaCharts + +import Gui.Globals as Globals + + +Rectangle { + id: container + + color: EaStyle.Colors.chartBackground + + // Track model count changes to refresh charts + property int modelCount: Globals.BackendWrapper.sampleModels.length + + // Store dynamically created series + property var sampleSeries: [] + property var sldSeries: [] + + SplitView { + anchors.fill: parent + orientation: Qt.Vertical + + // Sample Chart (2/3 height) + Rectangle { + id: sampleContainer + SplitView.fillHeight: true + SplitView.preferredHeight: parent.height * 0.67 + SplitView.minimumHeight: 100 + color: EaStyle.Colors.chartBackground + + ChartView { + id: sampleChartView + + anchors.fill: parent + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + anchors.margins: -12 + + antialiasing: true + legend.visible: false + backgroundRoundness: 0 + backgroundColor: EaStyle.Colors.chartBackground + plotAreaColor: EaStyle.Colors.chartPlotAreaBackground + + property bool allowZoom: true + property bool allowHover: true + + property double xRange: Globals.BackendWrapper.plottingSampleMaxX - Globals.BackendWrapper.plottingSampleMinX + + // Logarithmic axis control + property bool useLogQAxis: Globals.Variables.logarithmicQAxis + + ValueAxis { + id: sampleAxisX + visible: !sampleChartView.useLogQAxis + titleText: "q (Å⁻¹)" + // min/max set imperatively to avoid binding reset during zoom + property double minAfterReset: Globals.BackendWrapper.plottingSampleMinX - sampleChartView.xRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxX + sampleChartView.xRange * 0.01 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + LogValueAxis { + id: sampleAxisXLog + visible: sampleChartView.useLogQAxis + titleText: "q (Å⁻¹)" + // min/max set for log scale - ensure positive values + property double minAfterReset: Math.max(Globals.BackendWrapper.plottingSampleMinX, 1e-6) + property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxX * 1.1 + base: 10 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + property double yRange: Globals.BackendWrapper.plottingSampleMaxY - Globals.BackendWrapper.plottingSampleMinY + + ValueAxis { + id: sampleAxisY + titleText: "Log10 R(q)" + // min/max set imperatively to avoid binding reset during zoom + property double minAfterReset: Globals.BackendWrapper.plottingSampleMinY - sampleChartView.yRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxY + sampleChartView.yRange * 0.01 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + function resetAxes() { + if (useLogQAxis) { + sampleAxisXLog.min = sampleAxisXLog.minAfterReset + sampleAxisXLog.max = sampleAxisXLog.maxAfterReset + } else { + sampleAxisX.min = sampleAxisX.minAfterReset + sampleAxisX.max = sampleAxisX.maxAfterReset + } + sampleAxisY.min = sampleAxisY.minAfterReset + sampleAxisY.max = sampleAxisY.maxAfterReset + } + + // Handle logarithmic axis changes + onUseLogQAxisChanged: { + Qt.callLater(container.recreateAllSeries) + Qt.callLater(resetAxes) + } + + // Tool buttons + Row { + id: sampleToolButtons + z: 1 // Keep buttons above MouseAreas + + x: sampleChartView.plotArea.x + sampleChartView.plotArea.width - width + y: sampleChartView.plotArea.y - height - EaStyle.Sizes.fontPixelSize + + spacing: 0.25 * EaStyle.Sizes.fontPixelSize + + EaElements.TabButton { + checked: Globals.Variables.showLegendOnSamplePage + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "align-left" + ToolTip.text: Globals.Variables.showLegendOnSamplePage ? + qsTr("Hide legend") : + qsTr("Show legend") + onClicked: Globals.Variables.showLegendOnSamplePage = checked + } + + EaElements.TabButton { + checked: sampleChartView.allowHover + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "comment-alt" + ToolTip.text: qsTr("Show coordinates tooltip on hover") + onClicked: sampleChartView.allowHover = checked + } + + Item { height: 1; width: 0.5 * EaStyle.Sizes.fontPixelSize } // spacer + + EaElements.TabButton { + checked: !sampleChartView.allowZoom + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "arrows-alt" + ToolTip.text: qsTr("Enable pan") + onClicked: { + sampleChartView.allowZoom = !checked + sldChartView.allowZoom = !checked + } + } + + EaElements.TabButton { + checked: sampleChartView.allowZoom + autoExclusive: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "expand" + ToolTip.text: qsTr("Enable box zoom") + onClicked: { + sampleChartView.allowZoom = checked + sldChartView.allowZoom = checked + } + } + + EaElements.TabButton { + checkable: false + height: EaStyle.Sizes.toolButtonHeight + width: EaStyle.Sizes.toolButtonHeight + borderColor: EaStyle.Colors.chartAxis + fontIcon: "backspace" + ToolTip.text: qsTr("Reset axes") + onClicked: { + sampleChartView.resetAxes() + sldChartView.resetAxes() + } + } + } + + // Legend showing all models + Rectangle { + visible: Globals.Variables.showLegendOnSamplePage + + x: sampleChartView.plotArea.x + sampleChartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize + y: sampleChartView.plotArea.y + EaStyle.Sizes.fontPixelSize + width: childrenRect.width + height: childrenRect.height + + color: EaStyle.Colors.mainContentBackgroundHalfTransparent + border.color: EaStyle.Colors.chartGridLine + + Column { + leftPadding: EaStyle.Sizes.fontPixelSize + rightPadding: EaStyle.Sizes.fontPixelSize + topPadding: EaStyle.Sizes.fontPixelSize * 0.5 + bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 + + Repeater { + model: container.modelCount + EaElements.Label { + text: '━ ' + Globals.BackendWrapper.sampleModels[index].label + color: Globals.BackendWrapper.sampleModels[index].color + } + } + } + } + + EaElements.ToolTip { + id: sampleDataToolTip + + arrowLength: 0 + textFormat: Text.RichText + } + + // Zoom rectangle + Rectangle { + id: sampleRecZoom + + property int xScaleZoom: 0 + property int yScaleZoom: 0 + + visible: false + transform: Scale { + origin.x: 0 + origin.y: 0 + xScale: sampleRecZoom.xScaleZoom + yScale: sampleRecZoom.yScaleZoom + } + border.color: EaStyle.Colors.appBorder + border.width: 1 + opacity: 0.9 + color: "transparent" + + Rectangle { + anchors.fill: parent + opacity: 0.5 + color: sampleRecZoom.border.color + } + } + + // Zoom with left mouse button + MouseArea { + id: sampleZoomMouseArea + + enabled: sampleChartView.allowZoom + anchors.fill: sampleChartView + acceptedButtons: Qt.LeftButton + onPressed: { + sampleRecZoom.x = mouseX + sampleRecZoom.y = mouseY + sampleRecZoom.visible = true + } + onMouseXChanged: { + if (mouseX > sampleRecZoom.x) { + sampleRecZoom.xScaleZoom = 1 + sampleRecZoom.width = Math.min(mouseX, sampleChartView.width) - sampleRecZoom.x + } else { + sampleRecZoom.xScaleZoom = -1 + sampleRecZoom.width = sampleRecZoom.x - Math.max(mouseX, 0) + } + } + onMouseYChanged: { + if (mouseY > sampleRecZoom.y) { + sampleRecZoom.yScaleZoom = 1 + sampleRecZoom.height = Math.min(mouseY, sampleChartView.height) - sampleRecZoom.y + } else { + sampleRecZoom.yScaleZoom = -1 + sampleRecZoom.height = sampleRecZoom.y - Math.max(mouseY, 0) + } + } + onReleased: { + const x = Math.min(sampleRecZoom.x, mouseX) - sampleChartView.anchors.leftMargin + const y = Math.min(sampleRecZoom.y, mouseY) - sampleChartView.anchors.topMargin + const width = sampleRecZoom.width + const height = sampleRecZoom.height + sampleChartView.zoomIn(Qt.rect(x, y, width, height)) + sampleRecZoom.visible = false + } + } + + // Pan with left mouse button + MouseArea { + property real pressedX + property real pressedY + property int threshold: 1 + + enabled: !sampleZoomMouseArea.enabled + anchors.fill: sampleChartView + acceptedButtons: Qt.LeftButton + onPressed: { + pressedX = mouseX + pressedY = mouseY + } + onMouseXChanged: Qt.callLater(update) + onMouseYChanged: Qt.callLater(update) + + function update() { + const dx = mouseX - pressedX + const dy = mouseY - pressedY + pressedX = mouseX + pressedY = mouseY + + if (dx > threshold) + sampleChartView.scrollLeft(dx) + else if (dx < -threshold) + sampleChartView.scrollRight(-dx) + if (dy > threshold) + sampleChartView.scrollUp(dy) + else if (dy < -threshold) + sampleChartView.scrollDown(-dy) + } + } + + // Reset axes with right mouse button + MouseArea { + anchors.fill: sampleChartView + acceptedButtons: Qt.RightButton + onClicked: sampleChartView.resetAxes() + } + + Component.onCompleted: { + Globals.References.pages.sample.mainContent.sampleView = sampleChartView + } + } + } + + // SLD Chart (1/3 height) + Rectangle { + id: sldContainer + SplitView.fillHeight: true + SplitView.preferredHeight: parent.height * 0.33 + SplitView.minimumHeight: 80 + color: EaStyle.Colors.chartBackground + + ChartView { + id: sldChartView + + anchors.fill: parent + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + anchors.margins: -12 + + antialiasing: true + legend.visible: false + backgroundRoundness: 0 + backgroundColor: EaStyle.Colors.chartBackground + plotAreaColor: EaStyle.Colors.chartPlotAreaBackground + + property bool allowZoom: true + property bool allowHover: true + + property double xRange: Globals.BackendWrapper.plottingSldMaxX - Globals.BackendWrapper.plottingSldMinX + + // 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 + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + property double yRange: Globals.BackendWrapper.plottingSldMaxY - Globals.BackendWrapper.plottingSldMinY + + ValueAxis { + id: sldAxisY + titleText: "SLD (10⁻⁶Å⁻²)" + // 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 + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + 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 + + 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 + + Repeater { + model: container.modelCount + EaElements.Label { + text: '━ SLD ' + Globals.BackendWrapper.sampleModels[index].label + color: Globals.BackendWrapper.sampleModels[index].color + } + } + } + } + + EaElements.ToolTip { + id: sldDataToolTip + + arrowLength: 0 + 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 + } + } + } + } + + // 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 = [] + + // Determine which x-axis to use for sample chart based on log setting + const sampleXAxisToUse = sampleChartView.useLogQAxis ? sampleAxisXLog : sampleAxisX + + // Create new series for each model + const models = Globals.BackendWrapper.sampleModels + for (let k = 0; k < models.length; k++) { + // Create sample series + const sampleLine = sampleChartView.createSeries(ChartView.SeriesTypeLine, models[k].label, sampleXAxisToUse, sampleAxisY) + sampleLine.color = models[k].color + sampleLine.width = 2 + sampleLine.useOpenGL = EaGlobals.Vars.useOpenGL + // Connect hovered signal for tooltip + sampleLine.hovered.connect((point, state) => showMainTooltip(sampleChartView, sampleDataToolTip, point, state)) + sampleSeries.push(sampleLine) + + // 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 + // Connect hovered signal for tooltip + sldLine.hovered.connect((point, state) => showMainTooltip(sldChartView, sldDataToolTip, point, state)) + 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) + } + } + } + } + + // Logic + function showMainTooltip(chart, tooltip, point, state) { + if (!chart.allowHover) { + return + } + const pos = chart.mapToPosition(Qt.point(point.x, point.y)) + tooltip.x = pos.x + tooltip.y = pos.y + tooltip.text = `

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

` + tooltip.parent = chart + tooltip.visible = state + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml index 356e7d19..01645bfa 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml @@ -19,37 +19,111 @@ Rectangle { color: EaStyle.Colors.chartBackground - EaCharts.QtCharts1dMeasVsCalc { + // Track model count changes to refresh charts + property int modelCount: Globals.BackendWrapper.sampleModels.length + + // Store dynamically created series + property var sampleSeries: [] + + ChartView { id: chartView + anchors.fill: parent anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 + anchors.margins: -12 + + antialiasing: true + legend.visible: false + backgroundRoundness: 0 + backgroundColor: EaStyle.Colors.chartBackground + plotAreaColor: EaStyle.Colors.chartPlotAreaBackground + + property bool allowZoom: true + property bool allowHover: true - useOpenGL: EaGlobals.Vars.useOpenGL - property double xRange: Globals.BackendWrapper.plottingSampleMaxX - Globals.BackendWrapper.plottingSampleMinX - axisX.title: "q (Å⁻¹)" - axisX.min: Globals.BackendWrapper.plottingSampleMinX - xRange * 0.01 - axisX.max: Globals.BackendWrapper.plottingSampleMaxX + xRange * 0.01 - axisX.minAfterReset: Globals.BackendWrapper.plottingSampleMinX - xRange * 0.01 - axisX.maxAfterReset: Globals.BackendWrapper.plottingSampleMaxX + xRange * 0.01 + + // Logarithmic axis control + property bool useLogQAxis: Globals.Variables.logarithmicQAxis + + ValueAxis { + id: axisX + visible: !chartView.useLogQAxis + titleText: "q (Å⁻¹)" + // min/max set imperatively to avoid binding reset during zoom + property double minAfterReset: Globals.BackendWrapper.plottingSampleMinX - chartView.xRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxX + chartView.xRange * 0.01 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + LogValueAxis { + id: axisXLog + visible: chartView.useLogQAxis + titleText: "q (Å⁻¹)" + // min/max set for log scale - ensure positive values + property double minAfterReset: Math.max(Globals.BackendWrapper.plottingSampleMinX, 1e-6) + property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxX * 1.1 + base: 10 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } property double yRange: Globals.BackendWrapper.plottingSampleMaxY - Globals.BackendWrapper.plottingSampleMinY - axisY.title: "Log10 R(q)" - axisY.min: Globals.BackendWrapper.plottingSampleMinY - yRange * 0.01 - axisY.max: Globals.BackendWrapper.plottingSampleMaxY + yRange * 0.01 - axisY.minAfterReset: Globals.BackendWrapper.plottingSampleMinY - yRange * 0.01 - axisY.maxAfterReset: Globals.BackendWrapper.plottingSampleMaxY + yRange * 0.01 - calcSerie.onHovered: (point, state) => showMainTooltip(chartView, point, state) + ValueAxis { + id: axisY + titleText: "Log10 R(q)" + // min/max set imperatively to avoid binding reset during zoom + property double minAfterReset: Globals.BackendWrapper.plottingSampleMinY - chartView.yRange * 0.01 + property double maxAfterReset: Globals.BackendWrapper.plottingSampleMaxY + chartView.yRange * 0.01 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } - calcSerie.color: { - var idx = Globals.BackendWrapper.sampleCurrentModelIndex - Globals.BackendWrapper.sampleModels[idx].color + function resetAxes() { + if (useLogQAxis) { + axisXLog.min = axisXLog.minAfterReset + axisXLog.max = axisXLog.maxAfterReset + } else { + axisX.min = axisX.minAfterReset + axisX.max = axisX.maxAfterReset + } + axisY.min = axisY.minAfterReset + axisY.max = axisY.maxAfterReset + } + + // Handle logarithmic axis changes + onUseLogQAxisChanged: { + Qt.callLater(recreateAllSeries) + Qt.callLater(resetAxes) } // Tool buttons Row { id: toolButtons + z: 1 // Keep buttons above MouseAreas x: chartView.plotArea.x + chartView.plotArea.width - width y: chartView.plotArea.y - height - EaStyle.Sizes.fontPixelSize @@ -77,7 +151,7 @@ Rectangle { borderColor: EaStyle.Colors.chartAxis fontIcon: "comment-alt" ToolTip.text: qsTr("Show coordinates tooltip on hover") - onClicked: chartView.allowHover = !chartView.allowHover + onClicked: chartView.allowHover = checked } Item { height: 1; width: 0.5 * EaStyle.Sizes.fontPixelSize } // spacer @@ -90,7 +164,7 @@ Rectangle { borderColor: EaStyle.Colors.chartAxis fontIcon: "arrows-alt" ToolTip.text: qsTr("Enable pan") - onClicked: chartView.allowZoom = !chartView.allowZoom + onClicked: chartView.allowZoom = !checked } EaElements.TabButton { @@ -101,7 +175,7 @@ Rectangle { borderColor: EaStyle.Colors.chartAxis fontIcon: "expand" ToolTip.text: qsTr("Enable box zoom") - onClicked: chartView.allowZoom = !chartView.allowZoom + onClicked: chartView.allowZoom = checked } EaElements.TabButton { @@ -115,11 +189,10 @@ Rectangle { } } - // Tool buttons - // Legend + // Legend showing all models Rectangle { - visible: Globals.Variables.showLegendOnExperimentPage + visible: Globals.Variables.showLegendOnSamplePage x: chartView.plotArea.x + chartView.plotArea.width - width - EaStyle.Sizes.fontPixelSize y: chartView.plotArea.y + EaStyle.Sizes.fontPixelSize @@ -135,13 +208,15 @@ Rectangle { topPadding: EaStyle.Sizes.fontPixelSize * 0.5 bottomPadding: EaStyle.Sizes.fontPixelSize * 0.5 - EaElements.Label { - text: '━ I (sample)' - color: chartView.calcSerie.color + Repeater { + model: container.modelCount + EaElements.Label { + text: '━ ' + Globals.BackendWrapper.sampleModels[index].label + color: Globals.BackendWrapper.sampleModels[index].color + } } } } - // Legend EaElements.ToolTip { id: dataToolTip @@ -150,15 +225,173 @@ Rectangle { textFormat: Text.RichText } - // Data is set in python backend (plotting_1d.py) + // Zoom rectangle + Rectangle { + id: recZoom + + property int xScaleZoom: 0 + property int yScaleZoom: 0 + + visible: false + transform: Scale { + origin.x: 0 + origin.y: 0 + xScale: recZoom.xScaleZoom + yScale: recZoom.yScaleZoom + } + border.color: EaStyle.Colors.appBorder + border.width: 1 + opacity: 0.9 + color: "transparent" + + Rectangle { + anchors.fill: parent + opacity: 0.5 + color: recZoom.border.color + } + } + + // Zoom with left mouse button + MouseArea { + id: zoomMouseArea + + enabled: chartView.allowZoom + anchors.fill: chartView + acceptedButtons: Qt.LeftButton + onPressed: { + recZoom.x = mouseX + recZoom.y = mouseY + recZoom.visible = true + } + onMouseXChanged: { + if (mouseX > recZoom.x) { + recZoom.xScaleZoom = 1 + recZoom.width = Math.min(mouseX, chartView.width) - recZoom.x + } else { + recZoom.xScaleZoom = -1 + recZoom.width = recZoom.x - Math.max(mouseX, 0) + } + } + onMouseYChanged: { + if (mouseY > recZoom.y) { + recZoom.yScaleZoom = 1 + recZoom.height = Math.min(mouseY, chartView.height) - recZoom.y + } else { + recZoom.yScaleZoom = -1 + recZoom.height = recZoom.y - Math.max(mouseY, 0) + } + } + onReleased: { + const x = Math.min(recZoom.x, mouseX) - chartView.anchors.leftMargin + const y = Math.min(recZoom.y, mouseY) - chartView.anchors.topMargin + const width = recZoom.width + const height = recZoom.height + chartView.zoomIn(Qt.rect(x, y, width, height)) + recZoom.visible = false + } + } + + // Pan with left mouse button + MouseArea { + property real pressedX + property real pressedY + property int threshold: 1 + + enabled: !zoomMouseArea.enabled + anchors.fill: chartView + acceptedButtons: Qt.LeftButton + onPressed: { + pressedX = mouseX + pressedY = mouseY + } + onMouseXChanged: Qt.callLater(update) + onMouseYChanged: Qt.callLater(update) + + function update() { + const dx = mouseX - pressedX + const dy = mouseY - pressedY + pressedX = mouseX + pressedY = mouseY + + if (dx > threshold) + chartView.scrollLeft(dx) + else if (dx < -threshold) + chartView.scrollRight(-dx) + if (dy > threshold) + chartView.scrollUp(dy) + else if (dy < -threshold) + chartView.scrollDown(-dy) + } + } + + // Reset axes with right mouse button + MouseArea { + anchors.fill: chartView + acceptedButtons: Qt.RightButton + onClicked: chartView.resetAxes() + } + Component.onCompleted: { Globals.References.pages.sample.mainContent.sampleView = chartView - Globals.BackendWrapper.plottingSetQtChartsSerieRef('samplePage', - 'sampleSerie', - chartView.calcSerie) - Globals.BackendWrapper.plottingRefreshSample() } + } + // Create series dynamically when model count changes + onModelCountChanged: { + Qt.callLater(recreateAllSeries) + } + + // Refresh all chart series when data changes + Connections { + target: Globals.BackendWrapper + function onSamplePageDataChanged() { + refreshAllCharts() + } + } + + Component.onCompleted: { + Qt.callLater(recreateAllSeries) + } + + function recreateAllSeries() { + // Remove old series + for (let i = 0; i < sampleSeries.length; i++) { + if (sampleSeries[i]) { + chartView.removeSeries(sampleSeries[i]) + } + } + sampleSeries = [] + + // Determine which x-axis to use based on log setting + const xAxisToUse = chartView.useLogQAxis ? axisXLog : axisX + + // Create new series for each model + const models = Globals.BackendWrapper.sampleModels + for (let k = 0; k < models.length; k++) { + const line = chartView.createSeries(ChartView.SeriesTypeLine, models[k].label, xAxisToUse, axisY) + line.color = models[k].color + line.width = 2 + line.useOpenGL = EaGlobals.Vars.useOpenGL + // Connect hovered signal for tooltip + line.hovered.connect((point, state) => showMainTooltip(chartView, point, state)) + sampleSeries.push(line) + } + + refreshAllCharts() + } + + function refreshAllCharts() { + const models = Globals.BackendWrapper.sampleModels + for (let i = 0; i < sampleSeries.length && i < models.length; i++) { + const series = sampleSeries[i] + if (series) { + series.clear() + const points = Globals.BackendWrapper.plottingGetSampleDataPointsForModel(i) + for (let p = 0; p < points.length; p++) { + series.append(points[p].x, points[p].y) + } + } + } } // Logic diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml index 20fbeafe..e27d98f5 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SldView.qml @@ -19,37 +19,81 @@ 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 + + // 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 + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } 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: { - var idx = Globals.BackendWrapper.sampleCurrentModelIndex - Globals.BackendWrapper.sampleModels[idx].color + ValueAxis { + id: axisY + titleText: "SLD (10⁻⁶ Å⁻²)" + // 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 + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } } + function resetAxes() { + axisX.min = axisX.minAfterReset + axisX.max = axisX.maxAfterReset + axisY.min = axisY.minAfterReset + axisY.max = axisY.maxAfterReset + } // Tool buttons Row { id: toolButtons + z: 1 // Keep buttons above MouseAreas x: chartView.plotArea.x + chartView.plotArea.width - width y: chartView.plotArea.y - height - EaStyle.Sizes.fontPixelSize @@ -77,7 +121,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 @@ -90,7 +134,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 { @@ -101,7 +145,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 { @@ -115,11 +159,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 +178,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 @@ -150,18 +195,173 @@ Rectangle { textFormat: Text.RichText } - // Data is set in python backend (plotting_1d.py) + // Zoom rectangle + Rectangle { + id: recZoom + + property int xScaleZoom: 0 + property int yScaleZoom: 0 + + visible: false + transform: Scale { + origin.x: 0 + origin.y: 0 + xScale: recZoom.xScaleZoom + yScale: recZoom.yScaleZoom + } + border.color: EaStyle.Colors.appBorder + border.width: 1 + opacity: 0.9 + color: "transparent" + + Rectangle { + anchors.fill: parent + opacity: 0.5 + color: recZoom.border.color + } + } + + // Zoom with left mouse button + MouseArea { + id: zoomMouseArea + + enabled: chartView.allowZoom + anchors.fill: chartView + acceptedButtons: Qt.LeftButton + onPressed: { + recZoom.x = mouseX + recZoom.y = mouseY + recZoom.visible = true + } + onMouseXChanged: { + if (mouseX > recZoom.x) { + recZoom.xScaleZoom = 1 + recZoom.width = Math.min(mouseX, chartView.width) - recZoom.x + } else { + recZoom.xScaleZoom = -1 + recZoom.width = recZoom.x - Math.max(mouseX, 0) + } + } + onMouseYChanged: { + if (mouseY > recZoom.y) { + recZoom.yScaleZoom = 1 + recZoom.height = Math.min(mouseY, chartView.height) - recZoom.y + } else { + recZoom.yScaleZoom = -1 + recZoom.height = recZoom.y - Math.max(mouseY, 0) + } + } + onReleased: { + const x = Math.min(recZoom.x, mouseX) - chartView.anchors.leftMargin + const y = Math.min(recZoom.y, mouseY) - chartView.anchors.topMargin + const width = recZoom.width + const height = recZoom.height + chartView.zoomIn(Qt.rect(x, y, width, height)) + recZoom.visible = false + } + } + + // Pan with left mouse button + MouseArea { + property real pressedX + property real pressedY + property int threshold: 1 + + enabled: !zoomMouseArea.enabled + anchors.fill: chartView + acceptedButtons: Qt.LeftButton + onPressed: { + pressedX = mouseX + pressedY = mouseY + } + onMouseXChanged: Qt.callLater(update) + onMouseYChanged: Qt.callLater(update) + + function update() { + const dx = mouseX - pressedX + const dy = mouseY - pressedY + pressedX = mouseX + pressedY = mouseY + + if (dx > threshold) + chartView.scrollLeft(dx) + else if (dx < -threshold) + chartView.scrollRight(-dx) + if (dy > threshold) + chartView.scrollUp(dy) + else if (dy < -threshold) + chartView.scrollDown(-dy) + } + } + + // Reset axes with right mouse button + MouseArea { + anchors.fill: chartView + acceptedButtons: Qt.RightButton + onClicked: chartView.resetAxes() + } + Component.onCompleted: { Globals.References.pages.sample.mainContent.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 + // Connect hovered signal for tooltip + line.hovered.connect((point, state) => showMainTooltip(chartView, point, state)) + sldSeries.push(line) + } + + refreshAllCharts() + } + + function refreshAllCharts() { + const models = Globals.BackendWrapper.sampleModels + for (let i = 0; i < sldSeries.length && i < models.length; i++) { + const series = sldSeries[i] + if (series) { + series.clear() + const points = Globals.BackendWrapper.plottingGetSldDataPointsForModel(i) + for (let p = 0; p < points.length; p++) { + series.append(points[p].x, points[p].y) + } + } + } + } + + // Logic function showMainTooltip(chart, point, state) { if (!chartView.allowHover) { return diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml index 0d67f14d..cb0b65d1 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml @@ -1,110 +1,257 @@ -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 { - title: qsTr("Sample constraints") + id: constraintsGroup + title: qsTr("Single 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 - EaElements.Label { - enabled: true - text: qsTr("Numeric or Parameter-Parameter constraint") - } + function currentRelationValue() { + if (relationalOperator.currentIndex === -1 || typeof relationalOperator.currentValue === 'undefined') { + return '=' + } + return relationalOperator.currentValue + } - 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 resetValidation() { + validationMessage = "" + expressionPreview = "" + lastConstraintType = "" + expressionValid = false + validationDirty = false + } + + function scheduleValidation() { + validationDirty = true + validationTimer.restart() + } - EaElements.ComboBox { - id: relationalOperator - width: 47 - currentIndex: 0 - font.family: EaStyle.Fonts.iconsFamily - model: Globals.BackendWrapper.sampleRelationOperators + function runValidation() { + if (!validationDirty) { + return + } + + if (dependentPar.currentIndex === -1) { + expressionValid = false + expressionPreview = "" + lastConstraintType = "" + validationMessage = qsTr("Select a dependent parameter.") + return + } + + const expr = expressionEditor.text ? expressionEditor.text.trim() : "" + if (expr.length === 0) { + expressionValid = false + expressionPreview = "" + lastConstraintType = "" + validationMessage = qsTr("Expression cannot be empty.") + return + } + + if (typeof Globals.BackendWrapper.sampleValidateConstraintExpression === 'function') { + const result = Globals.BackendWrapper.sampleValidateConstraintExpression( + dependentPar.currentIndex, + currentRelationValue(), + expressionEditor.text) + if (result && result.valid) { + expressionValid = true + validationMessage = "" + expressionPreview = result.preview || expr + lastConstraintType = result.type || 'expression' + } else { + expressionValid = false + expressionPreview = "" + lastConstraintType = "" + validationMessage = result && result.message ? result.message : qsTr("Expression is not valid.") + // Debug: show available parameters when validation fails + if (result && result.message && result.message.includes("not defined")) { + console.log("Available constraint parameters:") + const params = Globals.BackendWrapper.sampleConstraintParametersMetadata + for (let i = 0; i < params.length; i++) { + console.log(` ${params[i].alias}: ${params[i].displayName}`) + } } + } + } else { + expressionValid = true + validationMessage = "" + expressionPreview = expr + lastConstraintType = 'expression' + } + } - 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 insertAlias(aliasText) { + if (!aliasText || aliasText.length === 0) { + return + } + + const position = expressionEditor.cursorPosition + const sourceText = expressionEditor.text + const before = sourceText.slice(0, position) + const after = sourceText.slice(position) + const needsLeadingSpace = before.length > 0 && !before.slice(-1).match(/[\s(*/+-]/) + const prefix = needsLeadingSpace ? before + ' ' : before + const needsTrailingSpace = after.length > 0 && !after.slice(0, 1).match(/[\s)*/+-]/) + const suffix = needsTrailingSpace ? ' ' + after : after + expressionEditor.text = prefix + aliasText + suffix + expressionEditor.cursorPosition = prefix.length + aliasText.length + scheduleValidation() + } + + function resetForm() { + validationTimer.stop() + dependentPar.currentIndex = -1 + relationalOperator.currentIndex = relationalOperator.model && relationalOperator.model.length > 0 ? 0 : -1 + expressionEditor.text = "" + parameterInsert.currentIndex = -1 + resetValidation() + } + + Timer { + id: validationTimer + interval: 320 + repeat: false + onTriggered: constraintsGroup.runValidation() + } + + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.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.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.TextField { - id: value - width: 65 - horizontalAlignment: Text.AlignRight - text: "1.0000" + 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.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 +280,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 +325,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 +336,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 diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml new file mode 100644 index 00000000..aefcd81d --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml @@ -0,0 +1,240 @@ +import QtQuick +import QtQuick.Controls +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Elements as EaElements +import EasyApp.Gui.Components as EaComponents + +import Gui.Globals as Globals + +EaElements.GroupBox { + id: modelConstraintsGroup + title: qsTr("Model constraints") + enabled: true + last: true + + property var selectedModelIndices: [] + property int selectedModelsCount: selectedModelIndices.length + + Column { + spacing: EaStyle.Sizes.fontPixelSize * 0.75 + width: parent ? parent.width : undefined + + EaElements.Label { + width: parent.width + text: qsTr("Select models to constrain all their matching parameters.") + wrapMode: Text.Wrap + color: EaStyle.Colors.themeForegroundMinor + } + + // Models table + Item { + id: modelTableContainer + width: parent.width + height: Math.min(200, Math.max(60, modelsTable.height)) + + EaComponents.TableView { + id: modelsTable + width: parent.width + height: Math.min(200, Math.max(60, Math.max(modelsTable.contentHeight, modelsTable.implicitHeight))) + enabled: Globals.BackendWrapper.sampleModelNames && Globals.BackendWrapper.sampleModelNames.length > 1 + + defaultInfoText: qsTr("No Models Available") + + // Table model - use backend data directly + model: Globals.BackendWrapper.sampleModelNames ? Globals.BackendWrapper.sampleModelNames.length : 0 + + // Header row + header: EaComponents.TableViewHeader { + + EaComponents.TableViewLabel { + width: EaStyle.Sizes.fontPixelSize * 3 + text: qsTr("Select") + } + + EaComponents.TableViewLabel { + id: modelNameHeaderColumn + width: EaStyle.Sizes.fontPixelSize * 30 + horizontalAlignment: Text.AlignLeft + text: qsTr("") + } + } + + // Table rows + delegate: EaComponents.TableViewDelegate { + + EaElements.CheckBox { + id: modelCheckBox + width: EaStyle.Sizes.fontPixelSize * 3 + checked: false + topPadding: 0 + bottomPadding: 0 + anchors.verticalCenter: parent.verticalCenter + onToggled: { + var newIndices = modelConstraintsGroup.selectedModelIndices.slice() // Copy the array + if (checked) { + newIndices.push(index) + } else { + const idx = newIndices.indexOf(index) + if (idx > -1) { + newIndices.splice(idx, 1) + } + } + modelConstraintsGroup.selectedModelIndices = newIndices // Reassign to trigger update + // console.debug("Model", index, "checked state:", checked, "Selected indices:", modelConstraintsGroup.selectedModelIndices) + } + } + + EaComponents.TableViewLabel { + id: modelNameColumn + width: EaStyle.Sizes.fontPixelSize * 30 + horizontalAlignment: Text.AlignLeft + text: { + const modelName = Globals.BackendWrapper.sampleModelNames[index] + return modelName ? modelName : "" + } + elide: Text.ElideRight + } + + mouseArea.onPressed: { + if (modelsTable.currentIndex !== index) { + modelsTable.currentIndex = index + } + } + } + } + } + + Item { + height: EaStyle.Sizes.fontPixelSize * 0.5 + width: 1 + } + + EaElements.SideBarButton { + id: constrainModelsButton + wide: true + fontIcon: "plus-circle" + text: qsTr("Constrain models parameters") + enabled: modelConstraintsGroup.selectedModelsCount > 1 + onClicked: { + // Call backend to constrain parameters + if (typeof Globals.BackendWrapper.sampleConstrainModelsParameters === 'function') { + Globals.BackendWrapper.sampleConstrainModelsParameters(modelConstraintsGroup.selectedModelIndices) + console.debug("Constrained models parameters for indices:", modelConstraintsGroup.selectedModelIndices) + } else { + console.debug("Backend method not available") + } + } + } + + // Model constraints table + Item { + height: EaStyle.Sizes.fontPixelSize * 0.5 + width: 1 + } + + EaElements.Label { + enabled: true + text: qsTr("Model Constraints") + } + + Item { + id: modelConstraintsTableContainer + width: parent.width + height: modelConstraintsTable.height + + EaComponents.TableView { + id: modelConstraintsTable + width: parent.width + maxRowCountShow: 1000 + + defaultInfoText: qsTr("No Model Constraints") + + // Table model - use backend data directly + model: Globals.BackendWrapper.sampleConstraintsList.length + + // Header row + header: EaComponents.TableViewHeader { + + EaComponents.TableViewLabel { + width: EaStyle.Sizes.fontPixelSize * 2.5 + text: qsTr("No.") + } + + EaComponents.TableViewLabel { + id: modelConstraintNameHeaderColumn + width: EaStyle.Sizes.fontPixelSize * 31 + horizontalAlignment: Text.AlignHCenter + text: qsTr("Constraint") + } + + // Placeholder for row delete button + EaComponents.TableViewLabel { + width: EaStyle.Sizes.tableRowHeight + } + } + + // Table rows + delegate: EaComponents.TableViewDelegate { + + EaComponents.TableViewLabel { + id: modelConstraintNumberColumn + width: EaStyle.Sizes.fontPixelSize * 2.5 + text: index + 1 + color: EaStyle.Colors.themeForegroundMinor + } + + EaComponents.TableViewLabel { + id: modelConstraintColumn + width: EaStyle.Sizes.fontPixelSize * 31 + horizontalAlignment: Text.AlignLeft + text: { + const constraint = Globals.BackendWrapper.sampleConstraintsList[index] + if (!constraint) { + return "" + } + const prefix = constraint.relation ? constraint.relation + ' ' : '' + return constraint.dependentName + ' ' + prefix + constraint.expression + } + elide: Text.ElideRight + } + + // Placeholder for delete button space + Item { + width: EaStyle.Sizes.tableRowHeight + } + + mouseArea.onPressed: { + if (modelConstraintsTable.currentIndex !== index) { + modelConstraintsTable.currentIndex = index + } + } + } + } + + // Delete buttons - separate from table content but positioned at row level + Column { + id: modelDeleteButtonsColumn + anchors.right: parent.right + anchors.top: modelConstraintsTable.top + anchors.topMargin: modelConstraintsTable.headerItem ? modelConstraintsTable.headerItem.height : 0 + spacing: 0 + + Repeater { + model: Globals.BackendWrapper.sampleConstraintsList.length + + EaElements.SideBarButton { + width: 35 + height: EaStyle.Sizes.tableRowHeight + fontIcon: "minus-circle" + ToolTip.text: qsTr("Remove this constraint") + + onClicked: { + Globals.BackendWrapper.sampleRemoveConstraintByIndex(index) + } + } + } + } + } + } +} + \ No newline at end of file diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/PlotControl.qml new file mode 100644 index 00000000..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 + } + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir new file mode 100644 index 00000000..002327b3 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/qmldir @@ -0,0 +1,6 @@ +module Groups + +Constraints 1.0 Constraints.qml +ModelConstraints 1.0 ModelConstraints.qml +PlotControl 1.0 PlotControl.qml +QRange 1.0 QRange.qml \ No newline at end of file diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml index 5824765a..e4a70c27 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Layout.qml @@ -12,9 +12,15 @@ EaComponents.SideBarColumn { Groups.QRange{ enabled: Globals.BackendWrapper.analysisIsFitFinished } + Groups.PlotControl{ + } Groups.Constraints{ enabled: Globals.BackendWrapper.analysisIsFitFinished } + Groups.ModelConstraints{ + enabled: Globals.BackendWrapper.analysisIsFitFinished + } + /* property int independentParCurrentIndex: 0 property int dependentParCurrentIndex: 0 diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml new file mode 100644 index 00000000..14973e97 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml @@ -0,0 +1,30 @@ +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") + nameFilters: [ "ORT files (*.ort)", "ORSO files (*.orso)", "All files (*.*)" ] + onAccepted: Globals.BackendWrapper.sampleFileLoad(selectedFiles[0]) + } + } +} \ 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 } diff --git a/EasyReflectometryApp/main.py b/EasyReflectometryApp/main.py index c4af8f72..e89a6b59 100644 --- a/EasyReflectometryApp/main.py +++ b/EasyReflectometryApp/main.py @@ -8,6 +8,7 @@ from EasyApp.Logic.Logging import console from PySide6.QtCore import QUrl from PySide6.QtCore import qInstallMessageHandler +from PySide6.QtGui import QIcon from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtQml import qmlRegisterSingletonType @@ -42,6 +43,8 @@ engine = QQmlApplicationEngine() console.debug(f'QML application engine created {engine}') + app.setWindowIcon(QIcon(str(CURRENT_DIR / 'Gui' / 'Resources' / 'Logo' / 'App.svg'))) + engine.rootContext().setContextProperty('isTestMode', args.testmode) if INSTALLER: # Running from installer diff --git a/pyproject.toml b/pyproject.toml index 903a211d..58c688b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,9 +31,10 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ - 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git', - 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', - 'PySide6>=6.8,<6.9', # Issue with TableView formatting in 6.9, + 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@develop', + 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@append_sample', + 'asteval', + 'PySide6', 'toml', ]