From 0de292f803d6cce229bb3c0cf20c5c79292f4f80 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 27 Mar 2026 09:22:20 +0100 Subject: [PATCH 01/18] state reset. changed wording on empty project --- .../Gui/ApplicationWindow.qml | 60 +++++++++++++------ .../Gui/Pages/Project/Layout.qml | 4 +- .../Sidebar/Basic/Popups/OpenJsonFile.qml | 1 + .../Basic/Popups/ProjectDescription.qml | 1 + 4 files changed, 45 insertions(+), 21 deletions(-) diff --git a/EasyReflectometryApp/Gui/ApplicationWindow.qml b/EasyReflectometryApp/Gui/ApplicationWindow.qml index ba90c8d5..76e5c5d8 100644 --- a/EasyReflectometryApp/Gui/ApplicationWindow.qml +++ b/EasyReflectometryApp/Gui/ApplicationWindow.qml @@ -40,24 +40,7 @@ EaComponents.ApplicationWindow { enabled: Globals.References.resetActive fontIcon: "backspace" ToolTip.text: qsTr("Reset to initial state without project, models and data") - onClicked: { - if (Globals.References.applicationWindow.appBarCentralTabs.sampleButton !== null) { - Globals.References.applicationWindow.appBarCentralTabs.sampleButton.enabled = false - } - if (Globals.References.applicationWindow.appBarCentralTabs.experimentButton !== null) { - Globals.References.applicationWindow.appBarCentralTabs.experimentButton.enabled = false - } - if (Globals.References.applicationWindow.appBarCentralTabs.analysisButton !== null) { - Globals.References.applicationWindow.appBarCentralTabs.analysisButton.enabled = false - } - if (Globals.References.applicationWindow.appBarCentralTabs.summaryButton !== null) { - Globals.References.applicationWindow.appBarCentralTabs.summaryButton.enabled = false - } - - Globals.BackendWrapper.projectReset() - Globals.References.applicationWindow.appBarCentralTabs.projectButton.toggle() - Globals.References.resetActive = false - } + onClicked: resetStateDialog.open() } ] @@ -171,6 +154,47 @@ EaComponents.ApplicationWindow { onClosing: Qt.quit() + EaElements.Dialog { + id: resetStateDialog + + title: qsTr("Reset state") + + EaElements.Label { + horizontalAlignment: Text.AlignHCenter + text: qsTr("Are you sure you want to reset the application to its\noriginal state without project, sample and data?\n\nThis operation cannot be undone.") + } + + footer: EaElements.DialogButtonBox { + EaElements.Button { + text: qsTr("Cancel") + onClicked: resetStateDialog.close() + } + + EaElements.Button { + text: qsTr("OK") + onClicked: { + if (Globals.References.applicationWindow.appBarCentralTabs.sampleButton !== null) { + Globals.References.applicationWindow.appBarCentralTabs.sampleButton.enabled = false + } + if (Globals.References.applicationWindow.appBarCentralTabs.experimentButton !== null) { + Globals.References.applicationWindow.appBarCentralTabs.experimentButton.enabled = false + } + if (Globals.References.applicationWindow.appBarCentralTabs.analysisButton !== null) { + Globals.References.applicationWindow.appBarCentralTabs.analysisButton.enabled = false + } + if (Globals.References.applicationWindow.appBarCentralTabs.summaryButton !== null) { + Globals.References.applicationWindow.appBarCentralTabs.summaryButton.enabled = false + } + + Globals.BackendWrapper.projectReset() + Globals.References.applicationWindow.appBarCentralTabs.projectButton.toggle() + Globals.References.resetActive = false + resetStateDialog.close() + } + } + } + } + Component.onCompleted: { console.debug(`Application window loaded ::: ${this}`) if (Globals.BackendWrapper.testMode) { diff --git a/EasyReflectometryApp/Gui/Pages/Project/Layout.qml b/EasyReflectometryApp/Gui/Pages/Project/Layout.qml index 7170e926..723b25c3 100644 --- a/EasyReflectometryApp/Gui/Pages/Project/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Project/Layout.qml @@ -1,8 +1,6 @@ import QtQuick import QtQuick.Controls -import EasyApp.Gui.Style as EaStyle -import EasyApp.Gui.Globals as EaGlobals import EasyApp.Gui.Elements as EaElements import EasyApp.Gui.Components as EaComponents @@ -13,7 +11,7 @@ EaComponents.ContentPage { defaultInfo: Globals.BackendWrapper.projectCreated ? '' : - qsTr('Using default project\n' + Globals.BackendWrapper.projectName) + qsTr('No project defined\n') mainView: EaComponents.MainContent { tabs: [ diff --git a/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Popups/OpenJsonFile.qml b/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Popups/OpenJsonFile.qml index 9f63d584..866e429e 100644 --- a/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Popups/OpenJsonFile.qml +++ b/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Popups/OpenJsonFile.qml @@ -16,6 +16,7 @@ FileDialog{ nameFilters: [ 'JSON files (*.json)'] onAccepted: { + Globals.References.resetActive = true Globals.References.applicationWindow.appBarCentralTabs.sampleButton.enabled = true Globals.References.applicationWindow.appBarCentralTabs.experimentButton.enabled = true Globals.References.applicationWindow.appBarCentralTabs.analysisButton.enabled = true diff --git a/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Popups/ProjectDescription.qml b/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Popups/ProjectDescription.qml index 16e6aa80..092524c0 100644 --- a/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Popups/ProjectDescription.qml +++ b/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Popups/ProjectDescription.qml @@ -24,6 +24,7 @@ EaComponents.ProjectDescriptionDialog { Globals.BackendWrapper.projectSetLocation(projectLocation) Globals.BackendWrapper.projectCreate() + Globals.References.resetActive = true Globals.References.applicationWindow.appBarCentralTabs.sampleButton.enabled = true } From 44eca00d64207ee95ff950fede17c55d8be70e19 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 27 Mar 2026 09:45:52 +0100 Subject: [PATCH 02/18] remove dependency on EasyApp footer. Implement locally --- .../Gui/Pages/Analysis/Layout.qml | 3 +- .../Gui/SideBarWithFooter.qml | 100 ++++++++++++++++++ EasyReflectometryApp/Gui/qmldir | 1 + pyproject.toml | 2 +- 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 EasyReflectometryApp/Gui/SideBarWithFooter.qml diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml index d3ad8769..0cc4951e 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml @@ -7,6 +7,7 @@ import EasyApp.Gui.Globals as EaGlobals import EasyApp.Gui.Elements as EaElements import EasyApp.Gui.Components as EaComponents +import Gui as Gui import Gui.Globals as Globals @@ -25,7 +26,7 @@ EaComponents.ContentPage { ] } - sideBar: EaComponents.SideBar { + sideBar: Gui.SideBarWithFooter { tabs: [ EaElements.TabButton { text: qsTr("Basic controls") }, EaElements.TabButton { text: qsTr("Extra controls") } //; enabled: Globals.Proxies.main.analysis.defined } diff --git a/EasyReflectometryApp/Gui/SideBarWithFooter.qml b/EasyReflectometryApp/Gui/SideBarWithFooter.qml new file mode 100644 index 00000000..b4c855ef --- /dev/null +++ b/EasyReflectometryApp/Gui/SideBarWithFooter.qml @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material + +import EasyApp.Gui.Style as EaStyle +import EasyApp.Gui.Animations as EaAnimations +import EasyApp.Gui.Elements as EaElements + +Item { + id: sideBarContainer + + property alias tabs: tabs.contentData + property alias items: items.contentData + + property alias continueButton: continueButton + property Component footerComponent: null + + anchors.fill: parent + + // Sidebar tabs + EaElements.TabBar { + id: tabs + + anchors.top: sideBarContainer.top + anchors.left: sideBarContainer.left + anchors.right: sideBarContainer.right + } + // Sidebar tabs + + // Sidebar content + SwipeView { + id: items + + anchors.top: tabs.bottom + anchors.bottom: footerLoader.top + anchors.left: sideBarContainer.left + anchors.right: sideBarContainer.right + + anchors.bottomMargin: EaStyle.Sizes.fontPixelSize + + clip: true + interactive: false + + currentIndex: tabs.currentIndex + } + // Sidebar content + + // Footer content (pinned above continue button) + Loader { + id: footerLoader + + active: sideBarContainer.footerComponent !== null + sourceComponent: sideBarContainer.footerComponent + + anchors.bottom: continueButton.visible ? continueButton.top : sideBarContainer.bottom + anchors.horizontalCenter: sideBarContainer.horizontalCenter + + anchors.bottomMargin: footerLoader.item ? 0.25 * EaStyle.Sizes.fontPixelSize : 0 + } + // Footer content + + // Continue button + EaElements.SideBarButton { + id: continueButton + + showBackground: false + + anchors.bottom: sideBarContainer.bottom + anchors.horizontalCenter: sideBarContainer.horizontalCenter + + anchors.bottomMargin: 0.5 * EaStyle.Sizes.fontPixelSize + + fontIcon: "arrow-circle-right" + text: qsTr("Continue") + } + // Continue button + + // Gradient area above button + Rectangle { + height: 1.25 * EaStyle.Sizes.fontPixelSize + anchors.bottom: footerLoader.top + anchors.left: sideBarContainer.left + anchors.right: sideBarContainer.right + + anchors.bottomMargin: 0.85 * EaStyle.Sizes.fontPixelSize + anchors.leftMargin: EaStyle.Sizes.fontPixelSize + anchors.rightMargin: EaStyle.Sizes.fontPixelSize + + gradient: Gradient{ + GradientStop{ position : 0.0; color: `${EaStyle.Colors.contentBackground}`.replace('#', '#00')} + GradientStop{ position : 1.0; color: `${EaStyle.Colors.contentBackground}`.replace('#', '#ff')} + } + } + // Gradient area above button + +} diff --git a/EasyReflectometryApp/Gui/qmldir b/EasyReflectometryApp/Gui/qmldir index fe68abae..3c7bbf56 100644 --- a/EasyReflectometryApp/Gui/qmldir +++ b/EasyReflectometryApp/Gui/qmldir @@ -2,5 +2,6 @@ module Gui ApplicationWindow ApplicationWindow.qml PlotControlRefLines PlotControlRefLines.qml +SideBarWithFooter SideBarWithFooter.qml SldChart SldChart.qml StatusBar StatusBar.qml diff --git a/pyproject.toml b/pyproject.toml index 43813895..e016f5b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ - 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@footer_component', + 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@develop', 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', 'asteval', 'PySide6', From 67b8fc54441f26bd78b4390d22b40dd23a75908a Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Sun, 29 Mar 2026 14:45:33 +0200 Subject: [PATCH 03/18] fixed log display for experiment/analysis --- .../Analysis/MainContent/AnalysisView.qml | 106 ++++++++++++++- .../Analysis/MainContent/CombinedView.qml | 102 ++++++++++++++- .../Analysis/MainContent/ResidualsView.qml | 92 +++++++++++-- .../Experiment/MainContent/ExperimentView.qml | 121 +++++++++++++++++- 4 files changed, 406 insertions(+), 15 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index 9011f5aa..a080232a 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -126,6 +126,106 @@ Rectangle { axisX.minAfterReset: Globals.BackendWrapper.plottingAnalysisMinX - xRange * 0.01 axisX.maxAfterReset: Globals.BackendWrapper.plottingAnalysisMaxX + xRange * 0.01 + // Logarithmic axis control + property bool useLogQAxis: Globals.Variables.logarithmicQAxis + axisX.visible: !useLogQAxis + + LogValueAxis { + id: axisXLog + visible: chartView.useLogQAxis + titleText: "q (Å⁻¹)" + property double minAfterReset: Math.max(Globals.BackendWrapper.plottingAnalysisMinX, 1e-6) + property double maxAfterReset: Globals.BackendWrapper.plottingAnalysisMaxX * 1.1 + base: 10 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + // Dynamic series for log mode (single experiment) + property var logModeSeries: null + + function currentXAxis() { + return useLogQAxis ? axisXLog : chartView.axisX + } + + onUseLogQAxisChanged: { + recreateForLogMode() + } + + function recreateForLogMode() { + // Clean up previous log mode series + if (logModeSeries) { + chartView.removeSeries(logModeSeries.measuredSerie) + chartView.removeSeries(logModeSeries.calculatedSerie) + logModeSeries = null + } + + if (isMultiExperimentMode) { + // Multi-experiment mode: recreate all with the correct axis + updateMultiExperimentSeries() + } else if (useLogQAxis) { + // Single experiment, log mode: create dynamic series on log axis + measured.visible = false + calculated.visible = false + + var newMeasured = chartView.createSeries(ChartView.SeriesTypeLine, "measured_log", axisXLog, chartView.axisY) + newMeasured.color = measured.color + newMeasured.width = measured.width + newMeasured.useOpenGL = chartView.useOpenGL + + var newCalculated = chartView.createSeries(ChartView.SeriesTypeLine, "calculated_log", axisXLog, chartView.axisY) + newCalculated.color = calculated.color + newCalculated.width = calculated.width + newCalculated.useOpenGL = chartView.useOpenGL + + logModeSeries = { + measuredSerie: newMeasured, + calculatedSerie: newCalculated + } + + // Register new series with backend and refresh + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', newMeasured) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', newCalculated) + Globals.BackendWrapper.plottingRefreshAnalysis() + } else { + // Single experiment, linear mode: restore static series + measured.visible = true + calculated.visible = true + + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measured) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() + } + + updateReferenceLines() + Qt.callLater(resetAxes) + } + + function resetAxes() { + if (useLogQAxis) { + if (axisXLog) { + axisXLog.min = axisXLog.minAfterReset + axisXLog.max = axisXLog.maxAfterReset + } + } else { + if (chartView.axisX) { + chartView.axisX.min = chartView.axisX.minAfterReset + chartView.axisX.max = chartView.axisX.maxAfterReset + } + } + if (chartView.axisY) { + chartView.axisY.min = chartView.axisY.minAfterReset + chartView.axisY.max = chartView.axisY.maxAfterReset + } + } + property double yRange: Globals.BackendWrapper.plottingAnalysisMaxY - Globals.BackendWrapper.plottingAnalysisMinY axisY.title: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle axisY.min: Globals.BackendWrapper.plottingAnalysisMinY - yRange * 0.01 @@ -202,10 +302,12 @@ Rectangle { } function createExperimentSeries(expIndex, expName, color) { + var xAxis = currentXAxis() + // Create measured data series var measuredSerie = chartView.createSeries(ChartView.SeriesTypeLine, `${expName} - Measured`, - chartView.axisX, chartView.axisY) + xAxis, chartView.axisY) measuredSerie.color = color measuredSerie.width = 1 measuredSerie.capStyle = Qt.RoundCap @@ -214,7 +316,7 @@ Rectangle { // Create calculated data series (slightly different style) var calculatedSerie = chartView.createSeries(ChartView.SeriesTypeLine, `${expName} - Calculated`, - chartView.axisX, chartView.axisY) + xAxis, chartView.axisY) calculatedSerie.color = color calculatedSerie.width = 2 calculatedSerie.capStyle = Qt.RoundCap diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index a150263c..ec43fb19 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -194,10 +194,12 @@ Rectangle { } function createExperimentSeries(expIndex, expName, color) { + var xAxis = currentXAxis() + // Create measured data series var measuredSerie = analysisChartView.createSeries(ChartView.SeriesTypeLine, `${expName} - Measured`, - analysisChartView.axisX, analysisChartView.axisY) + xAxis, analysisChartView.axisY) measuredSerie.color = color measuredSerie.width = 1 measuredSerie.capStyle = Qt.RoundCap @@ -206,7 +208,7 @@ Rectangle { // Create calculated data series (slightly different style) var calculatedSerie = analysisChartView.createSeries(ChartView.SeriesTypeLine, `${expName} - Calculated`, - analysisChartView.axisX, analysisChartView.axisY) + xAxis, analysisChartView.axisY) calculatedSerie.color = color calculatedSerie.width = 2 calculatedSerie.capStyle = Qt.RoundCap @@ -249,6 +251,102 @@ Rectangle { axisX.minAfterReset: Globals.BackendWrapper.plottingAnalysisMinX - xRange * 0.01 axisX.maxAfterReset: Globals.BackendWrapper.plottingAnalysisMaxX + xRange * 0.01 + // Logarithmic axis control + property bool useLogQAxis: Globals.Variables.logarithmicQAxis + axisX.visible: !useLogQAxis + + LogValueAxis { + id: analysisAxisXLog + visible: analysisChartView.useLogQAxis + titleText: "q (Å⁻¹)" + property double minAfterReset: Math.max(Globals.BackendWrapper.plottingAnalysisMinX, 1e-6) + property double maxAfterReset: Globals.BackendWrapper.plottingAnalysisMaxX * 1.1 + base: 10 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + // Dynamic series for log mode (single experiment) + property var logModeSeries: null + + function currentXAxis() { + return useLogQAxis ? analysisAxisXLog : analysisChartView.axisX + } + + onUseLogQAxisChanged: { + recreateForLogMode() + } + + function recreateForLogMode() { + // Clean up previous log mode series + if (logModeSeries) { + analysisChartView.removeSeries(logModeSeries.measuredSerie) + analysisChartView.removeSeries(logModeSeries.calculatedSerie) + logModeSeries = null + } + + if (isMultiExperimentMode) { + updateMultiExperimentSeries() + } else if (useLogQAxis) { + measured.visible = false + calculated.visible = false + + var newMeasured = analysisChartView.createSeries(ChartView.SeriesTypeLine, "measured_log", analysisAxisXLog, analysisChartView.axisY) + newMeasured.color = measured.color + newMeasured.width = measured.width + newMeasured.useOpenGL = analysisChartView.useOpenGL + + var newCalculated = analysisChartView.createSeries(ChartView.SeriesTypeLine, "calculated_log", analysisAxisXLog, analysisChartView.axisY) + newCalculated.color = calculated.color + newCalculated.width = calculated.width + newCalculated.useOpenGL = analysisChartView.useOpenGL + + logModeSeries = { + measuredSerie: newMeasured, + calculatedSerie: newCalculated + } + + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', newMeasured) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', newCalculated) + Globals.BackendWrapper.plottingRefreshAnalysis() + } else { + measured.visible = true + calculated.visible = true + + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measured) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() + } + + updateReferenceLines() + Qt.callLater(resetAxes) + } + + function resetAxes() { + if (useLogQAxis) { + if (analysisAxisXLog) { + analysisAxisXLog.min = analysisAxisXLog.minAfterReset + analysisAxisXLog.max = analysisAxisXLog.maxAfterReset + } + } else { + if (analysisChartView.axisX) { + analysisChartView.axisX.min = analysisChartView.axisX.minAfterReset + analysisChartView.axisX.max = analysisChartView.axisX.maxAfterReset + } + } + if (analysisChartView.axisY) { + analysisChartView.axisY.min = analysisChartView.axisY.minAfterReset + analysisChartView.axisY.max = analysisChartView.axisY.maxAfterReset + } + } + property double yRange: Globals.BackendWrapper.plottingAnalysisMaxY - Globals.BackendWrapper.plottingAnalysisMinY axisY.title: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle axisY.min: Globals.BackendWrapper.plottingAnalysisMinY - yRange * 0.01 diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml index 4cb5fa8d..36780765 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml @@ -44,8 +44,12 @@ Rectangle { property double xRange: Globals.BackendWrapper.plottingResidualMaxX - Globals.BackendWrapper.plottingResidualMinX + // Logarithmic axis control + property bool useLogQAxis: Globals.Variables.logarithmicQAxis + ValueAxis { id: axisX + visible: !chartView.useLogQAxis titleText: "q (Å⁻¹)" property double minAfterReset: Globals.BackendWrapper.plottingResidualMinX - chartView.xRange * 0.01 property double maxAfterReset: Globals.BackendWrapper.plottingResidualMaxX + chartView.xRange * 0.01 @@ -60,6 +64,33 @@ Rectangle { } } + LogValueAxis { + id: axisXLog + visible: chartView.useLogQAxis + titleText: "q (Å⁻¹)" + property double minAfterReset: Math.max(Globals.BackendWrapper.plottingResidualMinX, 1e-6) + property double maxAfterReset: Globals.BackendWrapper.plottingResidualMaxX * 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 + } + } + + onUseLogQAxisChanged: { + refreshResidualChart() + Qt.callLater(resetAxes) + } + + function currentXAxis() { + return useLogQAxis ? axisXLog : axisX + } + property double yRange: Globals.BackendWrapper.plottingResidualMaxY - Globals.BackendWrapper.plottingResidualMinY ValueAxis { @@ -79,10 +110,21 @@ Rectangle { } function resetAxes() { - axisX.min = axisX.minAfterReset - axisX.max = axisX.maxAfterReset - axisY.min = axisY.minAfterReset - axisY.max = axisY.maxAfterReset + if (useLogQAxis) { + if (axisXLog) { + axisXLog.min = axisXLog.minAfterReset + axisXLog.max = axisXLog.maxAfterReset + } + } else { + if (axisX) { + axisX.min = axisX.minAfterReset + axisX.max = axisX.maxAfterReset + } + } + if (axisY) { + axisY.min = axisY.minAfterReset + axisY.max = axisY.maxAfterReset + } } // Zero reference line — always visible, not affected by scale/bkg toggles @@ -366,6 +408,9 @@ Rectangle { Qt.callLater(refreshResidualChart) } + // Dynamic series for log mode (single experiment) + property var logResidualSerie: null + function refreshResidualChart() { // Update zero-line span to match new range zeroLine.clear() @@ -383,18 +428,49 @@ Rectangle { // Remove any lingering multi-experiment series _clearMultiExperimentSeries() - singleResidualSerie.clear() + // Clean up previous log mode series + if (logResidualSerie) { + chartView.removeSeries(logResidualSerie) + logResidualSerie = null + } + const expIdx = Globals.BackendWrapper.analysisExperimentsCurrentIndex const points = Globals.BackendWrapper.plottingGetResidualDataPoints(expIdx) - for (let i = 0; i < points.length; i++) { - singleResidualSerie.append(points[i].x, points[i].y) + + if (chartView.useLogQAxis) { + // Log mode: use dynamic series on log axis + singleResidualSerie.visible = false + + logResidualSerie = chartView.createSeries(ChartView.SeriesTypeLine, "residual_log", axisXLog, axisY) + logResidualSerie.color = singleResidualSerie.color + logResidualSerie.width = singleResidualSerie.width + logResidualSerie.useOpenGL = EaGlobals.Vars.useOpenGL + + for (let i = 0; i < points.length; i++) { + logResidualSerie.append(points[i].x, points[i].y) + } + } else { + // Linear mode: use static series + singleResidualSerie.visible = !isMultiExperimentMode + singleResidualSerie.clear() + for (let i = 0; i < points.length; i++) { + singleResidualSerie.append(points[i].x, points[i].y) + } } } function _refreshMultiExperiment() { singleResidualSerie.clear() + singleResidualSerie.visible = false _clearMultiExperimentSeries() + // Clean up log mode single series + if (logResidualSerie) { + chartView.removeSeries(logResidualSerie) + logResidualSerie = null + } + + var xAxisToUse = chartView.currentXAxis() const experimentDataList = Globals.BackendWrapper.plottingIndividualExperimentDataList for (let i = 0; i < experimentDataList.length; i++) { const expData = experimentDataList[i] @@ -402,7 +478,7 @@ Rectangle { const serie = chartView.createSeries(ChartView.SeriesTypeLine, expData.name || `Exp ${i + 1}`, - axisX, axisY) + xAxisToUse, axisY) serie.color = expData.color serie.width = 1 serie.useOpenGL = EaGlobals.Vars.useOpenGL diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index f3202d98..5ca3798c 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -213,6 +213,119 @@ Rectangle { axisX.minAfterReset: Globals.BackendWrapper.plottingExperimentMinX - xRange * 0.01 axisX.maxAfterReset: Globals.BackendWrapper.plottingExperimentMaxX + xRange * 0.01 + // Logarithmic axis control + property bool useLogQAxis: Globals.Variables.logarithmicQAxis + axisX.visible: !useLogQAxis + + LogValueAxis { + id: axisXLog + visible: chartView.useLogQAxis + titleText: "q (Å⁻¹)" + property double minAfterReset: Math.max(Globals.BackendWrapper.plottingExperimentMinX, 1e-6) + property double maxAfterReset: Globals.BackendWrapper.plottingExperimentMaxX * 1.1 + base: 10 + color: EaStyle.Colors.chartAxis + gridLineColor: EaStyle.Colors.chartGridLine + minorGridLineColor: EaStyle.Colors.chartMinorGridLine + labelsColor: EaStyle.Colors.chartLabels + titleBrush: EaStyle.Colors.chartLabels + Component.onCompleted: { + min = minAfterReset + max = maxAfterReset + } + } + + // Dynamic series for log mode (single experiment) + property var logModeSeries: null + + function currentXAxis() { + return useLogQAxis ? axisXLog : chartView.axisX + } + + onUseLogQAxisChanged: { + recreateForLogMode() + } + + function recreateForLogMode() { + // Clean up previous log mode series + if (logModeSeries) { + chartView.removeSeries(logModeSeries.measuredSerie) + chartView.removeSeries(logModeSeries.errorUpperSerie) + chartView.removeSeries(logModeSeries.errorLowerSerie) + logModeSeries = null + } + + if (isMultiExperimentMode) { + // Multi-experiment mode: recreate all multi-experiment series with the correct axis + updateMultiExperimentSeries() + } else if (useLogQAxis) { + // Single experiment, log mode: create dynamic series on log axis + measured.visible = false + errorUpper.visible = false + errorLower.visible = false + + var newMeasured = chartView.createSeries(ChartView.SeriesTypeLine, "measured_log", axisXLog, chartView.axisY) + newMeasured.color = measured.color + newMeasured.width = measured.width + newMeasured.useOpenGL = chartView.useOpenGL + + var newErrorUpper = chartView.createSeries(ChartView.SeriesTypeLine, "errorUpper_log", axisXLog, chartView.axisY) + newErrorUpper.color = errorUpper.color + newErrorUpper.width = errorUpper.width + newErrorUpper.useOpenGL = chartView.useOpenGL + + var newErrorLower = chartView.createSeries(ChartView.SeriesTypeLine, "errorLower_log", axisXLog, chartView.axisY) + newErrorLower.color = errorLower.color + newErrorLower.width = errorLower.width + newErrorLower.useOpenGL = chartView.useOpenGL + + logModeSeries = { + measuredSerie: newMeasured, + errorUpperSerie: newErrorUpper, + errorLowerSerie: newErrorLower + } + + // Register new series with backend and refresh + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', newMeasured) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorUpperSerie', newErrorUpper) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorLowerSerie', newErrorLower) + Globals.BackendWrapper.plottingRefreshExperiment() + } else { + // Single experiment, linear mode: restore static series + measured.visible = true + errorUpper.visible = true + errorLower.visible = true + + // Re-register static series + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measured) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorUpperSerie', errorUpper) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorLowerSerie', errorLower) + Globals.BackendWrapper.plottingRefreshExperiment() + } + + // Update reference lines on correct axis + updateReferenceLines() + Qt.callLater(resetAxes) + } + + function resetAxes() { + if (useLogQAxis) { + if (axisXLog) { + axisXLog.min = axisXLog.minAfterReset + axisXLog.max = axisXLog.maxAfterReset + } + } else { + if (chartView.axisX) { + chartView.axisX.min = chartView.axisX.minAfterReset + chartView.axisX.max = chartView.axisX.maxAfterReset + } + } + if (chartView.axisY) { + chartView.axisY.min = chartView.axisY.minAfterReset + chartView.axisY.max = chartView.axisY.maxAfterReset + } + } + property double yRange: Globals.BackendWrapper.plottingExperimentMaxY - Globals.BackendWrapper.plottingExperimentMinY axisY.title: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle axisY.min: Globals.BackendWrapper.plottingExperimentMinY - yRange * 0.01 @@ -283,10 +396,12 @@ Rectangle { function createExperimentSeries(expIndex, expName, color) { // console.log(` Creating series for experiment ${expIndex}: ${expName} (${color})`) + var xAxis = currentXAxis() + // Create measured data series var measuredSerie = chartView.createSeries(ChartView.SeriesTypeLine, `${expName} - Data`, - chartView.axisX, chartView.axisY) + xAxis, chartView.axisY) measuredSerie.color = color measuredSerie.width = 2 measuredSerie.capStyle = Qt.RoundCap @@ -297,7 +412,7 @@ Rectangle { var errorUpperSerie = chartView.createSeries(ChartView.SeriesTypeLine, `${expName} - Error Upper`, - chartView.axisX, chartView.axisY) + xAxis, chartView.axisY) errorUpperSerie.color = errorColor errorUpperSerie.width = 1 errorUpperSerie.style = Qt.DashLine @@ -305,7 +420,7 @@ Rectangle { var errorLowerSerie = chartView.createSeries(ChartView.SeriesTypeLine, `${expName} - Error Lower`, - chartView.axisX, chartView.axisY) + xAxis, chartView.axisY) errorLowerSerie.color = errorColor errorLowerSerie.width = 1 errorLowerSerie.style = Qt.DashLine From 5427db72d5f43585735c0c026354503fcf65d3a8 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 30 Mar 2026 13:39:25 +0200 Subject: [PATCH 04/18] wording on some elements improved experimental plots colors updated --- .../Pages/Experiment/MainContent/ExperimentView.qml | 13 ++++++++++++- .../Sidebar/Basic/Groups/ExperimentalData.qml | 2 +- .../Basic/Groups/ExperimentalDataExplorer.qml | 10 ++++++++++ .../Sample/Sidebar/Basic/Groups/LoadSample.qml | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 5ca3798c..c9aa3217 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -24,6 +24,17 @@ Rectangle { property alias measured: chartView.calcSerie property alias errorUpper: chartView.measSerie property alias errorLower: chartView.bkgSerie + + // Experiment color palette (must match ExperimentalDataExplorer and backend) + property var experimentColorPalette: [ + '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', + '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf' + ] + property color currentExperimentColor: experimentColorPalette[ + Globals.BackendWrapper.analysisExperimentsCurrentIndex % experimentColorPalette.length + ] + + calcSerie.color: currentExperimentColor bkgSerie.color: measSerie.color measSerie.width: 1 bkgSerie.width: 1 @@ -265,7 +276,7 @@ Rectangle { errorLower.visible = false var newMeasured = chartView.createSeries(ChartView.SeriesTypeLine, "measured_log", axisXLog, chartView.axisY) - newMeasured.color = measured.color + newMeasured.color = chartView.currentExperimentColor newMeasured.width = measured.width newMeasured.useOpenGL = chartView.useOpenGL diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalData.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalData.qml index fbbe9778..2b784744 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalData.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalData.qml @@ -19,7 +19,7 @@ EaElements.GroupBox { enabled: true wide: true fontIcon: "upload" - text: qsTr("Import experimental data") + text: qsTr("Load experiment(s) from file(s)") onClicked: { console.debug(`Clicking '${text}' button ::: ${this}`) diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml index f6b58175..70e029fb 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml @@ -234,6 +234,16 @@ EaElements.GroupBox { width: EaStyle.Sizes.fontPixelSize * 11 text: index > -1 ? Globals.BackendWrapper.analysisExperimentsAvailable[index] : "" onEditingFinished: Globals.BackendWrapper.analysisSetExperimentNameAtIndex(index, text) + + // Always show each experiment in its distinct palette color + color: { + var palette = [ + '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', + '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf' + ] + return palette[index % palette.length] + } + font.bold: true } EaComponents.TableViewComboBox { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml index 04235b1f..bed9c8f6 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/LoadSample.qml @@ -23,7 +23,7 @@ EaElements.GroupBox { EaElements.SideBarButton { width: EaStyle.Sizes.sideBarContentWidth fontIcon: "folder-open" - text: qsTr("Load file") + text: qsTr("Load sample from file") onClicked: fileDialog.open() } From 3d85f9da856d1105cb7758c57af4d9b6b5339f44 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 30 Mar 2026 13:59:54 +0200 Subject: [PATCH 05/18] update the plot colors in the Analysis chart as well. --- .../Gui/Globals/Variables.qml | 9 ++++++++ .../Analysis/MainContent/AnalysisView.qml | 21 +++++++++++++------ .../Analysis/MainContent/CombinedView.qml | 21 +++++++++++++------ .../Analysis/MainContent/ResidualsView.qml | 11 +++------- .../Sidebar/Basic/Groups/Experiments.qml | 4 ++++ .../Experiment/MainContent/ExperimentView.qml | 11 +++------- .../Basic/Groups/ExperimentalDataExplorer.qml | 8 +------ 7 files changed, 50 insertions(+), 35 deletions(-) diff --git a/EasyReflectometryApp/Gui/Globals/Variables.qml b/EasyReflectometryApp/Gui/Globals/Variables.qml index 7286bc58..254e4679 100644 --- a/EasyReflectometryApp/Gui/Globals/Variables.qml +++ b/EasyReflectometryApp/Gui/Globals/Variables.qml @@ -18,4 +18,13 @@ QtObject { // Sample page plot control settings property bool reverseSldZAxis: false property bool logarithmicQAxis: false + + // Shared experiment color palette — used by Data Explorer table, Experiment chart, and Analysis charts + readonly property var experimentColorPalette: [ + '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', + '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf' + ] + function experimentColor(index) { + return experimentColorPalette[index % experimentColorPalette.length] + } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index a080232a..deba2b52 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -24,6 +24,9 @@ Rectangle { property alias calculated: chartView.calcSerie property alias measured: chartView.measSerie bkgSerie.color: measSerie.color + measSerie.color: Globals.Variables.experimentColor( + Globals.BackendWrapper.analysisExperimentsCurrentIndex + ) measSerie.width: 1 bkgSerie.width: 1 @@ -235,11 +238,11 @@ Rectangle { calcSerie.onHovered: (point, state) => showMainTooltip(chartView, point, state) calcSerie.color: { - const models = Globals.BackendWrapper.sampleModels - const idx = Globals.BackendWrapper.sampleCurrentModelIndex + const colors = Globals.BackendWrapper.modelColorsForExperiment + const idx = Globals.BackendWrapper.analysisExperimentsCurrentIndex - if (models && idx >= 0 && idx < models.length) { - return models[idx].color + if (colors && idx >= 0 && idx < colors.length) { + return colors[idx] } return undefined @@ -304,6 +307,12 @@ Rectangle { function createExperimentSeries(expIndex, expName, color) { var xAxis = currentXAxis() + // Look up the model color for this experiment + var modelColors = Globals.BackendWrapper.modelColorsForExperiment + var modelColor = (modelColors && expIndex >= 0 && expIndex < modelColors.length) + ? modelColors[expIndex] + : color + // Create measured data series var measuredSerie = chartView.createSeries(ChartView.SeriesTypeLine, `${expName} - Measured`, @@ -313,11 +322,11 @@ Rectangle { measuredSerie.capStyle = Qt.RoundCap measuredSerie.useOpenGL = chartView.useOpenGL - // Create calculated data series (slightly different style) + // Create calculated data series using the model's own color var calculatedSerie = chartView.createSeries(ChartView.SeriesTypeLine, `${expName} - Calculated`, xAxis, chartView.axisY) - calculatedSerie.color = color + calculatedSerie.color = modelColor calculatedSerie.width = 2 calculatedSerie.capStyle = Qt.RoundCap calculatedSerie.useOpenGL = chartView.useOpenGL diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index ec43fb19..a8537607 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -39,6 +39,9 @@ Rectangle { property alias calculated: analysisChartView.calcSerie property alias measured: analysisChartView.measSerie bkgSerie.color: measSerie.color + measSerie.color: Globals.Variables.experimentColor( + Globals.BackendWrapper.analysisExperimentsCurrentIndex + ) measSerie.width: 1 bkgSerie.width: 1 @@ -196,6 +199,12 @@ Rectangle { function createExperimentSeries(expIndex, expName, color) { var xAxis = currentXAxis() + // Look up the model color for this experiment + var modelColors = Globals.BackendWrapper.modelColorsForExperiment + var modelColor = (modelColors && expIndex >= 0 && expIndex < modelColors.length) + ? modelColors[expIndex] + : color + // Create measured data series var measuredSerie = analysisChartView.createSeries(ChartView.SeriesTypeLine, `${expName} - Measured`, @@ -205,11 +214,11 @@ Rectangle { measuredSerie.capStyle = Qt.RoundCap measuredSerie.useOpenGL = analysisChartView.useOpenGL - // Create calculated data series (slightly different style) + // Create calculated data series using the model's own color var calculatedSerie = analysisChartView.createSeries(ChartView.SeriesTypeLine, `${expName} - Calculated`, xAxis, analysisChartView.axisY) - calculatedSerie.color = color + calculatedSerie.color = modelColor calculatedSerie.width = 2 calculatedSerie.capStyle = Qt.RoundCap calculatedSerie.useOpenGL = analysisChartView.useOpenGL @@ -356,11 +365,11 @@ Rectangle { calcSerie.onHovered: (point, state) => showMainTooltip(analysisChartView, analysisDataToolTip, point, state) calcSerie.color: { - const models = Globals.BackendWrapper.sampleModels - const idx = Globals.BackendWrapper.sampleCurrentModelIndex + const colors = Globals.BackendWrapper.modelColorsForExperiment + const idx = Globals.BackendWrapper.analysisExperimentsCurrentIndex - if (models && idx >= 0 && idx < models.length) { - return models[idx].color + if (colors && idx >= 0 && idx < colors.length) { + return colors[idx] } return undefined diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml index 36780765..3aeca36f 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml @@ -151,14 +151,9 @@ Rectangle { axisY: axisY useOpenGL: EaGlobals.Vars.useOpenGL width: 1 - color: { - const models = Globals.BackendWrapper.sampleModels - const idx = Globals.BackendWrapper.sampleCurrentModelIndex - if (models && idx >= 0 && idx < models.length) { - return models[idx].color - } - return EaStyle.Colors.themeForeground - } + color: Globals.Variables.experimentColor( + Globals.BackendWrapper.analysisExperimentsCurrentIndex + ) visible: !isMultiExperimentMode onHovered: (point, state) => showMainTooltip(chartView, dataToolTip, point, state) } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml index 7c7ad714..5eb5cc90 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Experiments.qml @@ -162,6 +162,10 @@ EaElements.GroupBox { width: EaStyle.Sizes.fontPixelSize * 11 text: index > -1 ? Globals.BackendWrapper.analysisExperimentsAvailable[index] : "" onEditingFinished: Globals.BackendWrapper.analysisSetExperimentNameAtIndex(index, text) + + // Match experiment line color on the chart + color: Globals.Variables.experimentColor(index) + font.bold: true } EaComponents.TableViewLabel { diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index c9aa3217..e3dbd056 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -25,14 +25,9 @@ Rectangle { property alias errorUpper: chartView.measSerie property alias errorLower: chartView.bkgSerie - // Experiment color palette (must match ExperimentalDataExplorer and backend) - property var experimentColorPalette: [ - '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', - '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf' - ] - property color currentExperimentColor: experimentColorPalette[ - Globals.BackendWrapper.analysisExperimentsCurrentIndex % experimentColorPalette.length - ] + property color currentExperimentColor: Globals.Variables.experimentColor( + Globals.BackendWrapper.analysisExperimentsCurrentIndex + ) calcSerie.color: currentExperimentColor bkgSerie.color: measSerie.color diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml index 70e029fb..361f088b 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml @@ -236,13 +236,7 @@ EaElements.GroupBox { onEditingFinished: Globals.BackendWrapper.analysisSetExperimentNameAtIndex(index, text) // Always show each experiment in its distinct palette color - color: { - var palette = [ - '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', - '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf' - ] - return palette[index % palette.length] - } + color: Globals.Variables.experimentColor(index) font.bold: true } From 996cd82cce6c9cd57a850fe3c6d06e798dc5f49f Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 30 Mar 2026 14:06:44 +0200 Subject: [PATCH 06/18] experiment lines should be dotted --- .../Gui/Pages/Analysis/MainContent/AnalysisView.qml | 3 +++ .../Gui/Pages/Analysis/MainContent/CombinedView.qml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index deba2b52..13ef3dc3 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -28,7 +28,9 @@ Rectangle { Globals.BackendWrapper.analysisExperimentsCurrentIndex ) measSerie.width: 1 + measSerie.style: Qt.DotLine bkgSerie.width: 1 + bkgSerie.style: Qt.DotLine anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 @@ -319,6 +321,7 @@ Rectangle { xAxis, chartView.axisY) measuredSerie.color = color measuredSerie.width = 1 + measuredSerie.style = Qt.DotLine measuredSerie.capStyle = Qt.RoundCap measuredSerie.useOpenGL = chartView.useOpenGL diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index a8537607..4e63a2a8 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -43,7 +43,9 @@ Rectangle { Globals.BackendWrapper.analysisExperimentsCurrentIndex ) measSerie.width: 1 + measSerie.style: Qt.DotLine bkgSerie.width: 1 + bkgSerie.style: Qt.DotLine anchors.fill: parent anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 @@ -211,6 +213,7 @@ Rectangle { xAxis, analysisChartView.axisY) measuredSerie.color = color measuredSerie.width = 1 + measuredSerie.style = Qt.DotLine measuredSerie.capStyle = Qt.RoundCap measuredSerie.useOpenGL = analysisChartView.useOpenGL From f4098fe946ce44deeeef919e27077628155012ee Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 30 Mar 2026 14:30:43 +0200 Subject: [PATCH 07/18] small fix to display proper experiment colors on non-full selections --- EasyReflectometryApp/Backends/Py/analysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index b027c958..8a5b6335 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -455,7 +455,7 @@ def get_individual_experiment_data_list(self): if exp_idx < len(self._experiments_logic.available()) else f'Experiment {exp_idx + 1}' ) - color = color_palette[idx % len(color_palette)] + color = color_palette[exp_idx % len(color_palette)] experiment_data_list.append({'data': data, 'name': exp_name, 'color': color, 'index': exp_idx}) except (IndexError, AttributeError) as e: From b0e60e56c97f12bbcb17bce278ed848516e9622d Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Mon, 30 Mar 2026 22:06:19 +0200 Subject: [PATCH 08/18] make the dotted charts slightly more visible --- .../Gui/Pages/Analysis/MainContent/AnalysisView.qml | 6 ++++-- .../Gui/Pages/Analysis/MainContent/CombinedView.qml | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index 13ef3dc3..c3761f91 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -27,7 +27,8 @@ Rectangle { measSerie.color: Globals.Variables.experimentColor( Globals.BackendWrapper.analysisExperimentsCurrentIndex ) - measSerie.width: 1 + measSerie.width: 2 + measSerie.opacity: 0.95 measSerie.style: Qt.DotLine bkgSerie.width: 1 bkgSerie.style: Qt.DotLine @@ -320,7 +321,8 @@ Rectangle { `${expName} - Measured`, xAxis, chartView.axisY) measuredSerie.color = color - measuredSerie.width = 1 + measuredSerie.width = 2 + measuredSerie.opacity = 0.95 measuredSerie.style = Qt.DotLine measuredSerie.capStyle = Qt.RoundCap measuredSerie.useOpenGL = chartView.useOpenGL diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index 4e63a2a8..52f52e37 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -42,7 +42,8 @@ Rectangle { measSerie.color: Globals.Variables.experimentColor( Globals.BackendWrapper.analysisExperimentsCurrentIndex ) - measSerie.width: 1 + measSerie.width: 2 + measSerie.opacity: 0.95 measSerie.style: Qt.DotLine bkgSerie.width: 1 bkgSerie.style: Qt.DotLine @@ -212,7 +213,8 @@ Rectangle { `${expName} - Measured`, xAxis, analysisChartView.axisY) measuredSerie.color = color - measuredSerie.width = 1 + measuredSerie.width = 2 + measuredSerie.opacity = 0.95 measuredSerie.style = Qt.DotLine measuredSerie.capStyle = Qt.RoundCap measuredSerie.useOpenGL = analysisChartView.useOpenGL From 1202e21c83d673f837f72ad9d5ccbff97787f915 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 7 Apr 2026 09:28:19 +0200 Subject: [PATCH 09/18] errors should have grey foreground, not being editable --- .../Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml index 32ec1930..89a115ec 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fittables.qml @@ -335,9 +335,7 @@ EaElements.GroupBox { EaComponents.TableViewLabel { text: formatError(Globals.BackendWrapper.analysisFitableParameters[index].error) - color: (Globals.BackendWrapper.analysisFitableParameters[index].independent !== undefined ? - Globals.BackendWrapper.analysisFitableParameters[index].independent : true) ? - EaStyle.Colors.themeForeground : EaStyle.Colors.themeForegroundDisabled + color: EaStyle.Colors.themeForegroundDisabled } EaComponents.TableViewParameter { From 8dc267107277c9fe6df9b25fffbe6e6867f34480 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 8 Apr 2026 13:25:08 +0200 Subject: [PATCH 10/18] use proper symbol for legend markers --- EasyReflectometryApp/Gui/Globals/Variables.qml | 10 ++++++++++ .../Gui/Pages/Analysis/MainContent/AnalysisView.qml | 8 ++++---- .../Gui/Pages/Analysis/MainContent/CombinedView.qml | 8 ++++---- .../Pages/Experiment/MainContent/ExperimentView.qml | 4 ++-- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/EasyReflectometryApp/Gui/Globals/Variables.qml b/EasyReflectometryApp/Gui/Globals/Variables.qml index 254e4679..d84791b1 100644 --- a/EasyReflectometryApp/Gui/Globals/Variables.qml +++ b/EasyReflectometryApp/Gui/Globals/Variables.qml @@ -27,4 +27,14 @@ QtObject { function experimentColor(index) { return experimentColorPalette[index % experimentColorPalette.length] } + + function lineStyleSymbol(style) { + switch (style) { + case Qt.DotLine: return '\u22c5 \u22c5 \u22c5' + case Qt.DashLine: return '\u2504\u2504' + case Qt.DashDotLine: return '\u2504\u22c5\u2504' + case Qt.DashDotDotLine: return '\u2504\u22c5\u22c5\u2504' + default: return '\u2501' + } + } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index c3761f91..490df49d 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -461,12 +461,12 @@ Rectangle { // Single experiment legend EaElements.Label { visible: !chartView.isMultiExperimentMode - text: '━ I (Measured)' + text: Globals.Variables.lineStyleSymbol(chartView.measSerie.style) + ' I (Measured)' color: chartView.measSerie.color } EaElements.Label { visible: !chartView.isMultiExperimentMode - text: '━ (Calculated)' + text: Globals.Variables.lineStyleSymbol(chartView.calcSerie.style) + ' (Calculated)' color: chartView.calcSerie.color } @@ -510,12 +510,12 @@ Rectangle { } EaElements.Label { - text: qsTr("━ Measured (thin)") + text: Globals.Variables.lineStyleSymbol(chartView.measSerie.style) + ' ' + qsTr("Measured") font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 color: EaStyle.Colors.themeForegroundMinor } EaElements.Label { - text: qsTr("━ Calculated (thick)") + text: Globals.Variables.lineStyleSymbol(chartView.calcSerie.style) + ' ' + qsTr("Calculated") font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 color: EaStyle.Colors.themeForegroundMinor } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index 52f52e37..c2bb8f2c 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -479,12 +479,12 @@ Rectangle { // Single experiment legend EaElements.Label { visible: !analysisChartView.isMultiExperimentMode - text: '━ I (Measured)' + text: Globals.Variables.lineStyleSymbol(analysisChartView.measSerie.style) + ' I (Measured)' color: analysisChartView.measSerie.color } EaElements.Label { visible: !analysisChartView.isMultiExperimentMode - text: '━ (Calculated)' + text: Globals.Variables.lineStyleSymbol(analysisChartView.calcSerie.style) + ' (Calculated)' color: analysisChartView.calcSerie.color } @@ -528,12 +528,12 @@ Rectangle { } EaElements.Label { - text: qsTr("━ Measured (thin)") + text: Globals.Variables.lineStyleSymbol(analysisChartView.measSerie.style) + ' ' + qsTr("Measured") font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 color: EaStyle.Colors.themeForegroundMinor } EaElements.Label { - text: qsTr("━ Calculated (thick)") + text: Globals.Variables.lineStyleSymbol(analysisChartView.calcSerie.style) + ' ' + qsTr("Calculated") font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.7 color: EaStyle.Colors.themeForegroundMinor } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index e3dbd056..bf71a2db 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -591,12 +591,12 @@ Rectangle { // Single experiment legend EaElements.Label { visible: !chartView.isMultiExperimentMode - text: '━ I (Measured)' + text: Globals.Variables.lineStyleSymbol(chartView.calcSerie.style) + ' I (Measured)' color: chartView.calcSerie.color } EaElements.Label { visible: !chartView.isMultiExperimentMode - text: '━ Error' + text: Globals.Variables.lineStyleSymbol(chartView.measSerie.style) + ' Error' color: chartView.measSerie.color } From b0880506eeb679a200f92c7adf78d46656f1dcff Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Thu, 9 Apr 2026 11:04:10 +0200 Subject: [PATCH 11/18] Win signing (#301) * try the new action * turn on all platforms --- .github/workflows/installer.yml | 43 ++++++++++++--------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml index 16f6ca33..9bb2f023 100644 --- a/.github/workflows/installer.yml +++ b/.github/workflows/installer.yml @@ -114,37 +114,26 @@ jobs: # ${{ secrets.APPLE_CERT_DATA }} ${{ secrets.APPLE_CERT_PASSWORD }} # ${{ secrets.APPLE_NOTARY_USER }} ${{ secrets.APPLE_NOTARY_PASSWORD }} - - name: Install DigiCert Client tools from Github Custom Actions marketplace - if: | - runner.os == 'windows' && github.event_name == 'push' - uses: digicert/ssm-code-signing@v1.0.1 - - - name: Set up P12 certificate - if: | - runner.os == 'windows' && github.event_name == 'push' + - name: Setup SM_CLIENT_CERT_FILE from base64 secret data + if: runner.os == 'Windows' run: | - echo "${{ secrets.WINDOWS_CERT_DATA }}" | base64 --decode > /d/Certificate_pkcs12.p12 + echo "${{ secrets.KEYLOCKER_CERT_DATA }}" | base64 --decode > /d/Certificate_pkcs12.p12 shell: bash - - name: Set keylocker variables - if: | - runner.os == 'windows' && github.event_name == 'push' - id: variables - run: | - echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - echo "SM_HOST=${{ secrets.KEYLOCKER_HOST }}" >> "$GITHUB_ENV" - echo "SM_API_KEY=${{ secrets.KEYLOCKER_API_KEY }}" >> "$GITHUB_ENV" - echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" - echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.WINDOWS_CERT_PASSWORD }}" >> "$GITHUB_ENV" - shell: bash - - - name: Sign the binary using keypair alias - if: | - runner.os == 'windows' && github.event_name == 'push' && env.BRANCH_NAME == 'master' - run: | - smctl sign --keypair-alias key_911959544 --input ${{ env.SETUP_EXE_PATH }} - shell: cmd + - name: Setup Software Trust Manager + if: runner.os == 'Windows' + uses: digicert/code-signing-software-trust-action@v1 + with: + simple-signing-mode: true + # If the below 2 parameters are supplied, then smctl executable is invoked to attempt the signing. + input: ${{ env.SETUP_EXE_PATH }} + keypair-alias: ${{ secrets.KEYLOCKER_KEYPAIR_ALIAS }} + env: + SM_HOST: ${{ secrets.KEYLOCKER_HOST }} + SM_API_KEY: ${{ secrets.KEYLOCKER_API_KEY }} + SM_CLIENT_CERT_FILE: D:\\Certificate_pkcs12.p12 + SM_CLIENT_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} - name: Create zip archive of offline app installer for distribution run: > From 05b5137626687fa14ffc9b97dae6c084f7ce6d59 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 13 Apr 2026 13:25:37 +0200 Subject: [PATCH 12/18] replace "backspace" icon with "undo" for axes reset --- EasyReflectometryApp/Gui/ApplicationWindow.qml | 2 +- .../Gui/Pages/Analysis/MainContent/AnalysisView.qml | 2 +- .../Gui/Pages/Analysis/MainContent/CombinedView.qml | 2 +- .../Gui/Pages/Analysis/MainContent/ResidualsView.qml | 2 +- .../Gui/Pages/Experiment/MainContent/ExperimentView.qml | 2 +- .../Gui/Pages/Sample/MainContent/CombinedView.qml | 2 +- .../Gui/Pages/Sample/MainContent/SampleView.qml | 2 +- EasyReflectometryApp/Gui/SldChart.qml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/EasyReflectometryApp/Gui/ApplicationWindow.qml b/EasyReflectometryApp/Gui/ApplicationWindow.qml index 76e5c5d8..a190fba9 100644 --- a/EasyReflectometryApp/Gui/ApplicationWindow.qml +++ b/EasyReflectometryApp/Gui/ApplicationWindow.qml @@ -38,7 +38,7 @@ EaComponents.ApplicationWindow { EaElements.ToolButton { enabled: Globals.References.resetActive - fontIcon: "backspace" + fontIcon: "undo" ToolTip.text: qsTr("Reset to initial state without project, models and data") onClicked: resetStateDialog.open() } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index 490df49d..0f54baf5 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -431,7 +431,7 @@ Rectangle { height: EaStyle.Sizes.toolButtonHeight width: EaStyle.Sizes.toolButtonHeight borderColor: EaStyle.Colors.chartAxis - fontIcon: "backspace" + fontIcon: "undo" ToolTip.text: qsTr("Reset axes") onClicked: chartView.resetAxes() } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index c2bb8f2c..b0585d18 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -448,7 +448,7 @@ Rectangle { height: EaStyle.Sizes.toolButtonHeight width: EaStyle.Sizes.toolButtonHeight borderColor: EaStyle.Colors.chartAxis - fontIcon: "backspace" + fontIcon: "undo" ToolTip.text: qsTr("Reset axes") onClicked: { analysisChartView.resetAxes() diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml index 3aeca36f..87781531 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml @@ -219,7 +219,7 @@ Rectangle { height: EaStyle.Sizes.toolButtonHeight width: EaStyle.Sizes.toolButtonHeight borderColor: EaStyle.Colors.chartAxis - fontIcon: "backspace" + fontIcon: "undo" ToolTip.text: qsTr("Reset axes") onClicked: chartView.resetAxes() } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index bf71a2db..04e5f41f 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -562,7 +562,7 @@ Rectangle { height: EaStyle.Sizes.toolButtonHeight width: EaStyle.Sizes.toolButtonHeight borderColor: EaStyle.Colors.chartAxis - fontIcon: "backspace" + fontIcon: "undo" ToolTip.text: qsTr("Reset axes") onClicked: chartView.resetAxes() } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml index 08cb4711..774b1488 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/CombinedView.qml @@ -203,7 +203,7 @@ Rectangle { height: EaStyle.Sizes.toolButtonHeight width: EaStyle.Sizes.toolButtonHeight borderColor: EaStyle.Colors.chartAxis - fontIcon: "backspace" + fontIcon: "undo" ToolTip.text: qsTr("Reset axes") onClicked: { sampleChartView.resetAxes() diff --git a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml index 214e453b..c279bc8f 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/MainContent/SampleView.qml @@ -183,7 +183,7 @@ Rectangle { height: EaStyle.Sizes.toolButtonHeight width: EaStyle.Sizes.toolButtonHeight borderColor: EaStyle.Colors.chartAxis - fontIcon: "backspace" + fontIcon: "undo" ToolTip.text: qsTr("Reset axes") onClicked: chartView.resetAxes() } diff --git a/EasyReflectometryApp/Gui/SldChart.qml b/EasyReflectometryApp/Gui/SldChart.qml index b560b610..a0397ad3 100644 --- a/EasyReflectometryApp/Gui/SldChart.qml +++ b/EasyReflectometryApp/Gui/SldChart.qml @@ -156,7 +156,7 @@ Rectangle { height: EaStyle.Sizes.toolButtonHeight width: EaStyle.Sizes.toolButtonHeight borderColor: EaStyle.Colors.chartAxis - fontIcon: "backspace" + fontIcon: "undo" ToolTip.text: qsTr("Reset axes") onClicked: chartView.resetAxes() } From 10a026ba475da1dce16a23607f285d3309e6fbea Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 14 Apr 2026 10:59:49 +0200 Subject: [PATCH 13/18] initial implementation for lmfit --- .../Backends/Mock/Analysis.qml | 7 + EasyReflectometryApp/Backends/Py/analysis.py | 43 ++++- .../Backends/Py/logic/fitting.py | 72 +++++++- .../Backends/Py/workers/fitter_worker.py | 18 +- .../Gui/Globals/BackendWrapper.qml | 7 + .../Gui/Pages/Analysis/Layout.qml | 5 +- .../Analysis/Sidebar/Basic/Groups/Fitting.qml | 1 - .../Sidebar/Basic/Popups/FitStatusDialog.qml | 4 + EasyReflectometryApp/Gui/StatusBar.qml | 13 +- tests/test_analysis.py | 170 ++++++++++++++++++ tests/test_logic_fitting.py | 73 +++++++- tests/test_workers_fitter_worker.py | 37 +++- 12 files changed, 433 insertions(+), 17 deletions(-) create mode 100644 tests/test_analysis.py diff --git a/EasyReflectometryApp/Backends/Mock/Analysis.qml b/EasyReflectometryApp/Backends/Mock/Analysis.qml index 8865debb..00687c00 100644 --- a/EasyReflectometryApp/Backends/Mock/Analysis.qml +++ b/EasyReflectometryApp/Backends/Mock/Analysis.qml @@ -25,6 +25,13 @@ QtObject { readonly property string fitErrorMessage: '' readonly property int fitNumRefinedParams: 3 readonly property real fitChi2: 1.2345 + readonly property int fitIteration: 0 + readonly property real fitInterimChi2: 0.0 + readonly property real fitInterimReducedChi2: 0.0 + readonly property string fitProgressMessage: '' + readonly property bool fitHasInterimUpdate: false + readonly property bool fitHasPreviewUpdate: false + readonly property var fitPreviewParameterValues: ({}) readonly property var fitResults: ({ success: true, nvarys: 3, chi2: 1.2345 }) // Fit failure signal (mirrors Python backend) diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index 8a5b6335..14217d4d 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -118,6 +118,34 @@ def fitNumRefinedParams(self) -> int: def fitChi2(self) -> float: return self._fitting_logic.fit_chi2 + @Property(int, notify=fittingChanged) + def fitIteration(self) -> int: + return self._fitting_logic.fit_iteration + + @Property(float, notify=fittingChanged) + def fitInterimChi2(self) -> float: + return self._fitting_logic.fit_interim_chi2 + + @Property(float, notify=fittingChanged) + def fitInterimReducedChi2(self) -> float: + return self._fitting_logic.fit_interim_reduced_chi2 + + @Property(str, notify=fittingChanged) + def fitProgressMessage(self) -> str: + return self._fitting_logic.fit_progress_message + + @Property(bool, notify=fittingChanged) + def fitHasInterimUpdate(self) -> bool: + return self._fitting_logic.fit_has_interim_update + + @Property(bool, notify=fittingChanged) + def fitHasPreviewUpdate(self) -> bool: + return self._fitting_logic.fit_has_preview_update + + @Property('QVariant', notify=fittingChanged) + def fitPreviewParameterValues(self) -> dict: + return self._fitting_logic.fit_preview_parameter_values + @Property('QVariant', notify=fittingChanged) def fitResults(self) -> dict: """Return fit results as a dict for QML consumption.""" @@ -171,10 +199,17 @@ def _start_threaded_fit(self) -> None: self._fitter_thread.setTerminationEnabled(True) self._fitter_thread.finished.connect(self._on_fit_finished) self._fitter_thread.failed.connect(self._on_fit_failed) + self._fitter_thread.progressDetail.connect(self._on_fit_progress) self._fitter_thread.finished.connect(self._fitter_thread.deleteLater) self._fitter_thread.failed.connect(self._fitter_thread.deleteLater) self._fitter_thread.start() + @Slot(dict) + def _on_fit_progress(self, payload: dict) -> None: + """Handle in-flight progress payloads emitted from the worker thread.""" + self._fitting_logic.on_fit_progress(payload) + self.fittingChanged.emit() + @Slot(list) def _on_fit_finished(self, results: list) -> None: """Handle successful completion of threaded fit.""" @@ -187,12 +222,16 @@ def _on_fit_finished(self, results: list) -> None: @Slot(str) def _on_fit_failed(self, error_message: str) -> None: """Handle failed threaded fit.""" + is_user_cancel = self._fitting_logic.fit_cancelled and 'cancel' in error_message.lower() + if is_user_cancel: + error_message = 'Fitting cancelled by user' self._fitting_logic.on_fit_failed(error_message) self._fitter_thread = None self.fittingChanged.emit() self._clearCacheAndEmitParametersChanged() self.externalFittingChanged.emit() - self.fitFailed.emit(error_message) + if not is_user_cancel: + self.fitFailed.emit(error_message) @Slot() def _onStopFit(self) -> None: @@ -200,8 +239,6 @@ def _onStopFit(self) -> None: self._fitting_logic.stop_fit() if self._fitter_thread is not None: self._fitter_thread.stop() - self._fitter_thread.deleteLater() - self._fitter_thread = None self.fittingChanged.emit() self.externalFittingChanged.emit() diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index 263da157..919e92ff 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -5,6 +5,7 @@ from typing import cast from easyreflectometry import Project as ProjectLib +from easyreflectometry.utils import count_free_parameters from easyscience.fitting import FitResults from easyscience.fitting.minimizers.utils import FitError @@ -26,6 +27,13 @@ def __init__(self, project_lib: ProjectLib): self._fit_error_message: Optional[str] = None self._fit_cancelled = False self._stop_requested = False + self._fit_iteration = 0 + self._fit_interim_chi2 = 0.0 + self._fit_interim_reduced_chi2 = 0.0 + self._fit_running_message = '' + self._fit_preview_parameter_values: dict = {} + self._fit_has_preview_update = False + self._fit_has_interim_update = False @property def status(self) -> str: @@ -68,6 +76,61 @@ def fit_cancelled(self) -> bool: """Return True if fit was cancelled by user.""" return self._fit_cancelled + @property + def fit_iteration(self) -> int: + return self._fit_iteration + + @property + def fit_interim_chi2(self) -> float: + return self._fit_interim_chi2 + + @property + def fit_interim_reduced_chi2(self) -> float: + return self._fit_interim_reduced_chi2 + + @property + def fit_progress_message(self) -> str: + return self._fit_running_message + + @property + def fit_preview_parameter_values(self) -> dict: + return dict(self._fit_preview_parameter_values) + + @property + def fit_has_preview_update(self) -> bool: + return self._fit_has_preview_update + + @property + def fit_has_interim_update(self) -> bool: + return self._fit_has_interim_update + + def on_fit_progress(self, payload: dict) -> None: + """Update transient state from an in-flight fit progress payload.""" + self._fit_iteration = int(payload.get('iteration', 0) or 0) + self._fit_interim_chi2 = float(payload.get('chi2', 0.0) or 0.0) + self._fit_interim_reduced_chi2 = float( + payload.get('reduced_chi2', self._fit_interim_chi2) or self._fit_interim_chi2 + ) + self._fit_preview_parameter_values = dict(payload.get('parameter_values', {}) or {}) + self._fit_has_preview_update = bool(payload.get('refresh_plots', False)) + self._fit_has_interim_update = True + + if self._fit_iteration > 0: + self._fit_running_message = ( + f'Fitting... iter {self._fit_iteration}, Chi2 = {self._fit_interim_chi2:.6g}' + ) + else: + self._fit_running_message = 'Fitting...' + + def clear_fit_progress(self) -> None: + self._fit_iteration = 0 + self._fit_interim_chi2 = 0.0 + self._fit_interim_reduced_chi2 = 0.0 + self._fit_running_message = '' + self._fit_preview_parameter_values = {} + self._fit_has_preview_update = False + self._fit_has_interim_update = False + def on_fit_failed(self, error_message: str) -> None: """Handle fitting failure callback. @@ -79,6 +142,7 @@ def on_fit_failed(self, error_message: str) -> None: self._running = False self._finished = True self._show_results_dialog = True + self.clear_fit_progress() def stop_fit(self) -> None: """Request fitting to stop and clean up state.""" @@ -90,6 +154,7 @@ def stop_fit(self) -> None: self._fit_cancelled = True self._fit_error_message = 'Fitting cancelled by user' self._show_results_dialog = True + self.clear_fit_progress() def reset_stop_flag(self) -> None: """Reset the stop request flag before starting a new fit.""" @@ -108,6 +173,8 @@ def prepare_for_threaded_fit(self) -> None: self._fit_error_message = None self._result = None self._results = [] + self.clear_fit_progress() + self._fit_running_message = 'Fitting...' def _ordered_experiments(self) -> list: """Return experiments as an ordered list of experiment objects. @@ -213,6 +280,7 @@ def on_fit_finished(self, results: FitResults | List[FitResults]) -> None: self._finished = True self._show_results_dialog = True self._fit_error_message = None + self.clear_fit_progress() # Store result(s) - handle both single and multiple results if isinstance(results, list) and len(results) > 0: @@ -229,8 +297,8 @@ def on_fit_finished(self, results: FitResults | List[FitResults]) -> None: @property def fit_n_pars(self) -> int: """Return the global number of refined parameters for the fit.""" - if self._results: - return sum(result.n_pars for result in self._results) + if len(self._results) > 1: + return count_free_parameters(self._project_lib) if self._result is None: return 0 return self._result.n_pars diff --git a/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py b/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py index cb36c5f6..306ab5ed 100644 --- a/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py +++ b/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py @@ -48,6 +48,9 @@ class FitterWorker(QThread): # Signal emitted to report fitting progress (0-100) progress = Signal(int) + # Detailed fitting progress payload emitted from minimizer callbacks + progressDetail = Signal(dict) + def __init__( self, fitter: Any, @@ -93,7 +96,10 @@ def run(self) -> None: try: # Get the method and call it method = getattr(self._fitter, self._method_name) - result = method(*self._args, **self._kwargs) + kwargs = dict(self._kwargs) + if self._method_name == 'fit' and 'progress_callback' not in kwargs: + kwargs['progress_callback'] = self._progress_callback + result = method(*self._args, **kwargs) # NOTE: This check only catches stop requests that occurred AFTER the fit # completed but before we emit the result. It does NOT interrupt the fitting @@ -116,6 +122,11 @@ def run(self) -> None: error_message = f'{type(ex).__name__}: Unknown error during fitting' self.failed.emit(error_message) + def _progress_callback(self, payload: dict) -> bool: + """Relay plain progress payloads from the minimizer to Qt signals.""" + self.progressDetail.emit(dict(payload)) + return not self._stop_requested + def stop(self) -> None: """ Request the fitting operation to stop. @@ -140,11 +151,6 @@ def stop(self) -> None: potential future improvements (e.g., using subprocess instead of QThread). """ self._stop_requested = True - if self.isRunning(): - # WARNING: terminate() is dangerous but necessary since fitting - # libraries don't support graceful cancellation. See docstring above. - self.terminate() - self.wait() @property def stop_requested(self) -> bool: diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 6c08039e..2a29b05e 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -274,6 +274,13 @@ QtObject { readonly property string analysisFitErrorMessage: activeBackend.analysis.fitErrorMessage readonly property int analysisFitNumRefinedParams: activeBackend.analysis.fitNumRefinedParams readonly property real analysisFitChi2: activeBackend.analysis.fitChi2 + readonly property int analysisFitIteration: activeBackend.analysis.fitIteration + readonly property real analysisFitInterimChi2: activeBackend.analysis.fitInterimChi2 + readonly property real analysisFitInterimReducedChi2: activeBackend.analysis.fitInterimReducedChi2 + readonly property string analysisFitProgressMessage: activeBackend.analysis.fitProgressMessage + readonly property bool analysisFitHasInterimUpdate: activeBackend.analysis.fitHasInterimUpdate + readonly property bool analysisFitHasPreviewUpdate: activeBackend.analysis.fitHasPreviewUpdate + readonly property var analysisFitPreviewParameterValues: activeBackend.analysis.fitPreviewParameterValues readonly property var analysisFitResults: activeBackend.analysis.fitResults function analysisFittingStartStop() { activeBackend.analysis.fittingStartStop() } function analysisSetShowFitResultsDialog(value) { activeBackend.analysis.setShowFitResultsDialog(value) } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml index 0cc4951e..53e62979 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml @@ -13,6 +13,10 @@ import Gui.Globals as Globals EaComponents.ContentPage { + Loader { + source: 'Sidebar/Basic/Popups/FitStatusDialog.qml' + } + mainView: EaComponents.MainContent { tabs: [ EaElements.TabButton { text: qsTr('Reflectivity') } @@ -50,7 +54,6 @@ EaComponents.ContentPage { } Component.onCompleted: Globals.References.pages.analysis.sidebar.basic.popups.startFittingButton = this - Loader { source: 'Sidebar/Basic/Popups/FitStatusDialog.qml' } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml index 6bc6f3a8..fcac53ef 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml @@ -28,7 +28,6 @@ EaElements.GroupBox { } Component.onCompleted: Globals.References.pages.analysis.sidebar.basic.popups.startFittingButton = this - Loader { source: "../Popups/FitStatusDialog.qml" } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml index 45826681..59306346 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml @@ -25,6 +25,10 @@ EaElements.Dialog { Globals.BackendWrapper.analysisSetShowFitResultsDialog(false) } + onRejected: { + Globals.BackendWrapper.analysisSetShowFitResultsDialog(false) + } + onClosed: { Globals.BackendWrapper.analysisSetShowFitResultsDialog(false) } diff --git a/EasyReflectometryApp/Gui/StatusBar.qml b/EasyReflectometryApp/Gui/StatusBar.qml index ff0c676e..249596fa 100644 --- a/EasyReflectometryApp/Gui/StatusBar.qml +++ b/EasyReflectometryApp/Gui/StatusBar.qml @@ -53,8 +53,19 @@ EaElements.StatusBar { valueText: Globals.BackendWrapper.statusVariables ?? '' ToolTip.text: qsTr('Number of parameters: total, free and fixed') } + + EaElements.StatusBarItem { + visible: Globals.BackendWrapper.analysisFittingRunning + keyIcon: 'play-circle' + keyText: qsTr('Fit') + valueText: Globals.BackendWrapper.analysisFitHasInterimUpdate + ? Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4) + : Globals.BackendWrapper.analysisFitProgressMessage + ToolTip.text: qsTr('Current fitting progress') + } + EaElements.StatusBarItem { - visible: Globals.BackendWrapper.analysisFitChi2 > 0 + visible: !Globals.BackendWrapper.analysisFittingRunning && Globals.BackendWrapper.analysisFitChi2 > 0 keyIcon: 'chart-line' keyText: qsTr('Reduced Chi²') valueText: Globals.BackendWrapper.analysisFitChi2.toFixed(2) diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 00000000..e42e5332 --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,170 @@ +from unittest.mock import MagicMock + +from PySide6.QtCore import QObject +from PySide6.QtCore import Signal + +from EasyReflectometryApp.Backends.Py import analysis as analysis_module +from EasyReflectometryApp.Backends.Py.logic.fitting import Fitting +from tests.factories import make_project + + +class StubParametersLogic: + def __init__(self, _project_lib): + pass + + +class StubCalculatorsLogic: + def __init__(self, _project_lib): + pass + + +class StubExperimentLogic: + def __init__(self, project_lib): + self._project_lib = project_lib + + def available(self): + return ['Exp 1'] + + def current_index(self): + return 0 + + +class StubMinimizersLogic: + def __init__(self, _project_lib): + self.tolerance = None + self.max_iterations = None + + def selected_minimizer_enum(self): + return None + + +class StubWorker(QObject): + finished = Signal(list) + failed = Signal(str) + progressDetail = Signal(dict) + + instances = [] + + def __init__(self, fitter, method_name, args=(), kwargs=None, parent=None): + super().__init__(parent) + self.fitter = fitter + self.method_name = method_name + self.args = args + self.kwargs = kwargs or {} + self.parent = parent + self.stop_calls = 0 + self.start_calls = 0 + self.delete_calls = 0 + self.termination_enabled = None + StubWorker.instances.append(self) + + def setTerminationEnabled(self, value): + self.termination_enabled = value + + def start(self): + self.start_calls += 1 + + def stop(self): + self.stop_calls += 1 + + def deleteLater(self): + self.delete_calls += 1 + + +def _make_analysis(monkeypatch): + project = make_project() + monkeypatch.setattr(analysis_module, 'ParametersLogic', StubParametersLogic) + monkeypatch.setattr(analysis_module, 'CalculatorsLogic', StubCalculatorsLogic) + monkeypatch.setattr(analysis_module, 'ExperimentLogic', StubExperimentLogic) + monkeypatch.setattr(analysis_module, 'MinimizersLogic', StubMinimizersLogic) + monkeypatch.setattr(analysis_module, 'FitterWorker', StubWorker) + analysis = analysis_module.Analysis(project) + analysis._clearCacheAndEmitParametersChanged = MagicMock() + return analysis + + +def test_start_threaded_fit_propagates_progress_to_properties(monkeypatch, qcore_application): + StubWorker.instances = [] + analysis = _make_analysis(monkeypatch) + analysis._fitting_logic.prepare_threaded_fit = MagicMock( + return_value=('fake-fitter', ['x'], ['y'], ['w'], None) + ) + fitting_changed = {'count': 0} + analysis.fittingChanged.connect( + lambda: fitting_changed.__setitem__('count', fitting_changed['count'] + 1) + ) + + analysis._start_threaded_fit() + + worker = StubWorker.instances[-1] + worker.progressDetail.emit( + { + 'iteration': 9, + 'chi2': 3.5, + 'reduced_chi2': 1.4, + 'parameter_values': {'thickness': 12.0}, + 'refresh_plots': False, + 'finished': False, + } + ) + + assert worker.method_name == 'fit' + assert worker.kwargs == {'weights': ['w'], 'method': None} + assert worker.start_calls == 1 + assert analysis.fittingRunning is True + assert analysis.fitIteration == 9 + assert analysis.fitInterimChi2 == 3.5 + assert analysis.fitInterimReducedChi2 == 1.4 + assert analysis.fitProgressMessage == 'Fitting... iter 9, Chi2 = 3.5' + assert analysis.fitHasInterimUpdate is True + assert analysis.fitHasPreviewUpdate is False + assert analysis.fitPreviewParameterValues == {'thickness': 12.0} + assert fitting_changed['count'] >= 2 + + +def test_on_stop_fit_requests_worker_stop_without_immediate_cleanup(monkeypatch, qcore_application): + StubWorker.instances = [] + analysis = _make_analysis(monkeypatch) + analysis._fitting_logic.prepare_threaded_fit = MagicMock( + return_value=('fake-fitter', ['x'], ['y'], ['w'], None) + ) + + analysis._start_threaded_fit() + worker = StubWorker.instances[-1] + + analysis._onStopFit() + + assert worker.stop_calls == 1 + assert analysis._fitter_thread is worker + assert analysis.fittingRunning is False + assert analysis.fitErrorMessage == 'Fitting cancelled by user' + + +def test_fitting_start_stop_emits_stop_signal_when_fit_is_running(monkeypatch, qcore_application): + analysis = _make_analysis(monkeypatch) + analysis._fitting_logic.prepare_for_threaded_fit() + received = {'count': 0} + analysis.stopFit.connect(lambda: received.__setitem__('count', received['count'] + 1)) + + analysis.fittingStartStop() + + assert received['count'] == 1 + + +def test_cancelled_worker_failure_does_not_emit_fit_failed(monkeypatch, qcore_application): + StubWorker.instances = [] + analysis = _make_analysis(monkeypatch) + analysis._fitting_logic = Fitting(make_project()) + analysis._clearCacheAndEmitParametersChanged = MagicMock() + analysis._fitting_logic.prepare_for_threaded_fit() + analysis._fitting_logic.stop_fit() + analysis._fitter_thread = StubWorker('fake-fitter', 'fit') + received = [] + analysis.fitFailed.connect(received.append) + + analysis._on_fit_failed('Fit cancelled by progress callback') + + assert analysis._fitter_thread is None + assert analysis.fitErrorMessage == 'Fitting cancelled by user' + assert received == [] + analysis._clearCacheAndEmitParametersChanged.assert_called_once_with() \ No newline at end of file diff --git a/tests/test_logic_fitting.py b/tests/test_logic_fitting.py index fcdc3de0..ec1a137f 100644 --- a/tests/test_logic_fitting.py +++ b/tests/test_logic_fitting.py @@ -82,9 +82,10 @@ def test_prepare_threaded_fit_builds_masked_arrays_and_configures_minimizer(monk assert method is None -def test_on_fit_finished_and_fit_properties_cover_multi_and_single_results(): +def test_on_fit_finished_and_fit_properties_cover_multi_and_single_results(monkeypatch): project = make_project() logic = fitting_module.Fitting(project) + monkeypatch.setattr(fitting_module, 'count_free_parameters', lambda current_project: 2) logic.prepare_for_threaded_fit() logic.on_fit_finished([ @@ -94,7 +95,7 @@ def test_on_fit_finished_and_fit_properties_cover_multi_and_single_results(): assert logic.fit_finished is True assert logic.fit_success is True - assert logic.fit_n_pars == 4 + assert logic.fit_n_pars == 2 assert logic.fit_chi2 == 2.0 logic.on_fit_finished(make_fit_result(success=False, chi2=9.0, n_pars=1, x=[1, 2], reduced_chi=4.5)) @@ -103,6 +104,74 @@ def test_on_fit_finished_and_fit_properties_cover_multi_and_single_results(): assert logic.fit_chi2 == 4.5 +def test_fit_n_pars_uses_global_free_parameter_count_for_multi_experiment_results(monkeypatch): + project = make_project() + logic = fitting_module.Fitting(project) + monkeypatch.setattr(fitting_module, 'count_free_parameters', lambda current_project: 3) + + logic.prepare_for_threaded_fit() + logic.on_fit_finished([ + make_fit_result(success=True, chi2=4.0, n_pars=3, x=[1, 2, 3], reduced_chi=1.1), + make_fit_result(success=True, chi2=6.0, n_pars=3, x=[1, 2, 3, 4], reduced_chi=1.2), + ]) + + assert logic.fit_n_pars == 3 + + +def test_fit_progress_updates_transient_state_and_message(): + project = make_project() + logic = fitting_module.Fitting(project) + + logic.prepare_for_threaded_fit() + logic.on_fit_progress( + { + 'iteration': 12, + 'chi2': 4.25, + 'reduced_chi2': 1.75, + 'parameter_values': {'alpha': 2.0}, + 'refresh_plots': True, + 'finished': False, + } + ) + + assert logic.fit_iteration == 12 + assert logic.fit_interim_chi2 == 4.25 + assert logic.fit_interim_reduced_chi2 == 1.75 + assert logic.fit_preview_parameter_values == {'alpha': 2.0} + assert logic.fit_has_preview_update is True + assert logic.fit_has_interim_update is True + assert logic.fit_progress_message == 'Fitting... iter 12, Chi2 = 4.25' + + +def test_fit_progress_state_resets_on_finish_failure_and_stop(): + project = make_project() + logic = fitting_module.Fitting(project) + + logic.prepare_for_threaded_fit() + logic.on_fit_progress({'iteration': 3, 'chi2': 8.0, 'parameter_values': {'beta': 1.0}}) + logic.on_fit_finished(make_fit_result(success=True, chi2=8.0, n_pars=1, x=[1, 2], reduced_chi=4.0)) + + assert logic.fit_iteration == 0 + assert logic.fit_progress_message == '' + assert logic.fit_has_interim_update is False + + logic.prepare_for_threaded_fit() + logic.on_fit_progress({'iteration': 4, 'chi2': 7.0, 'refresh_plots': True}) + logic.on_fit_failed('boom') + + assert logic.fit_iteration == 0 + assert logic.fit_preview_parameter_values == {} + assert logic.fit_has_preview_update is False + + logic.prepare_for_threaded_fit() + logic.on_fit_progress({'iteration': 5, 'chi2': 6.0}) + logic.stop_fit() + + assert logic.fit_iteration == 0 + assert logic.fit_progress_message == '' + assert logic.fit_has_interim_update is False + + def test_fit_failure_and_cancellation_state_transitions(): project = make_project() logic = fitting_module.Fitting(project) diff --git a/tests/test_workers_fitter_worker.py b/tests/test_workers_fitter_worker.py index e2db5fcc..372d7807 100644 --- a/tests/test_workers_fitter_worker.py +++ b/tests/test_workers_fitter_worker.py @@ -71,6 +71,40 @@ def test_run_uses_fallback_message_for_empty_exception_string(qcore_application) assert received == ['SilentError: Unknown error during fitting'] +def test_run_injects_progress_callback_for_fit_method(qcore_application): + fitter = make_worker_fitter(method_result='ok') + worker = FitterWorker(fitter, 'fit', kwargs={'weights': [1.0]}) + + worker.run() + + _, kwargs = fitter.calls[0] + assert 'progress_callback' in kwargs + assert callable(kwargs['progress_callback']) + assert kwargs['weights'] == [1.0] + + +def test_progress_callback_emits_detail_payload(qcore_application): + worker = FitterWorker(make_worker_fitter(method_result='ok'), 'fit') + received = [] + worker.progressDetail.connect(received.append) + payload = {'iteration': 5, 'chi2': 12.5, 'finished': False} + + should_continue = worker._progress_callback(payload) + + assert should_continue is True + assert received == [payload] + + +def test_progress_callback_requests_stop_when_flagged(qcore_application): + worker = FitterWorker(make_worker_fitter(method_result='ok'), 'fit') + + worker.stop() + + should_continue = worker._progress_callback({'iteration': 1}) + + assert should_continue is False + + def test_stop_sets_flag_without_terminating_idle_thread(qcore_application, monkeypatch): worker = FitterWorker(make_worker_fitter(method_result='ok'), 'fit') terminated = {'terminate': 0, 'wait': 0} @@ -93,4 +127,5 @@ def test_stop_terminates_running_thread(qcore_application, monkeypatch): worker.stop() - assert terminated == {'terminate': 1, 'wait': 1} + assert worker.stop_requested is True + assert terminated == {'terminate': 0, 'wait': 0} From fe4b8a4f164f46fd488efb397c183ad57ccf99b0 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 14 Apr 2026 15:18:49 +0200 Subject: [PATCH 14/18] added counter display in status bar --- EasyReflectometryApp/Gui/StatusBar.qml | 10 ++-- tests/test_qml_fitting_progress_ui.py | 64 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 tests/test_qml_fitting_progress_ui.py diff --git a/EasyReflectometryApp/Gui/StatusBar.qml b/EasyReflectometryApp/Gui/StatusBar.qml index 249596fa..d951d1d1 100644 --- a/EasyReflectometryApp/Gui/StatusBar.qml +++ b/EasyReflectometryApp/Gui/StatusBar.qml @@ -58,9 +58,13 @@ EaElements.StatusBar { visible: Globals.BackendWrapper.analysisFittingRunning keyIcon: 'play-circle' keyText: qsTr('Fit') - valueText: Globals.BackendWrapper.analysisFitHasInterimUpdate - ? Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4) - : Globals.BackendWrapper.analysisFitProgressMessage + valueText: { + if (!Globals.BackendWrapper.analysisFitHasInterimUpdate) + return Globals.BackendWrapper.analysisFitProgressMessage + const iter = Globals.BackendWrapper.analysisFitIteration + const rchi2 = Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4) + return qsTr('iter %1 · χ² %2').arg(iter).arg(rchi2) + } ToolTip.text: qsTr('Current fitting progress') } diff --git a/tests/test_qml_fitting_progress_ui.py b/tests/test_qml_fitting_progress_ui.py new file mode 100644 index 00000000..8c0c45a9 --- /dev/null +++ b/tests/test_qml_fitting_progress_ui.py @@ -0,0 +1,64 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_status_bar_shows_transient_fit_progress_and_hides_final_chi2_while_running(): + status_bar_qml = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'StatusBar.qml' + ).read_text(encoding='utf-8') + + assert "keyText: qsTr('Fit')" in status_bar_qml + assert 'visible: Globals.BackendWrapper.analysisFittingRunning' in status_bar_qml + assert 'Globals.BackendWrapper.analysisFitHasInterimUpdate' in status_bar_qml + assert 'Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4)' in status_bar_qml + assert '!Globals.BackendWrapper.analysisFittingRunning && Globals.BackendWrapper.analysisFitChi2 > 0' in status_bar_qml + + +def test_fit_status_dialog_stays_results_only(): + dialog_qml = ( + ROOT + / 'EasyReflectometryApp' + / 'Gui' + / 'Pages' + / 'Analysis' + / 'Sidebar' + / 'Basic' + / 'Popups' + / 'FitStatusDialog.qml' + ).read_text(encoding='utf-8') + + assert 'visible: Globals.BackendWrapper.analysisShowFitResultsDialog' in dialog_qml + assert 'standardButtons: Dialog.Ok' in dialog_qml + assert 'Refinement Running' not in dialog_qml + assert 'Globals.BackendWrapper.analysisStopFit()' not in dialog_qml + assert 'Globals.BackendWrapper.analysisFitIteration' not in dialog_qml + assert 'Globals.BackendWrapper.analysisFitInterimChi2.toFixed(4)' not in dialog_qml + assert 'Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4)' not in dialog_qml + + +def test_fit_buttons_toggle_between_start_and_cancel_via_start_stop_action(): + layout_qml = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Analysis' / 'Layout.qml' + ).read_text(encoding='utf-8') + fitting_group_qml = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Analysis' / 'Sidebar' / 'Basic' / 'Groups' / 'Fitting.qml' + ).read_text(encoding='utf-8') + + assert "Globals.BackendWrapper.analysisFittingRunning ? qsTr('Cancel fitting') : qsTr('Start fitting')" in layout_qml + assert 'Globals.BackendWrapper.analysisFittingStartStop()' in layout_qml + assert "Globals.BackendWrapper.analysisFittingRunning ? qsTr('Cancel fitting') : qsTr('Start fitting')" in fitting_group_qml + assert 'Globals.BackendWrapper.analysisFittingStartStop()' in fitting_group_qml + + +def test_fit_status_dialog_is_loaded_once_at_stable_page_scope(): + layout_qml = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Analysis' / 'Layout.qml' + ).read_text(encoding='utf-8') + fitting_group_qml = ( + ROOT / 'EasyReflectometryApp' / 'Gui' / 'Pages' / 'Analysis' / 'Sidebar' / 'Basic' / 'Groups' / 'Fitting.qml' + ).read_text(encoding='utf-8') + + assert "source: 'Sidebar/Basic/Popups/FitStatusDialog.qml'" in layout_qml + assert 'FitStatusDialog.qml' not in fitting_group_qml \ No newline at end of file From 413ddd501ebcdefa617d47e799d85b4b7b784bcf Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 15 Apr 2026 11:28:13 +0200 Subject: [PATCH 15/18] added DFO display - "Fitting running ..." with increasing dot number --- EasyReflectometryApp/Gui/StatusBar.qml | 21 ++++++++++++++++----- pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/EasyReflectometryApp/Gui/StatusBar.qml b/EasyReflectometryApp/Gui/StatusBar.qml index d951d1d1..1b9c1ff1 100644 --- a/EasyReflectometryApp/Gui/StatusBar.qml +++ b/EasyReflectometryApp/Gui/StatusBar.qml @@ -59,13 +59,24 @@ EaElements.StatusBar { keyIcon: 'play-circle' keyText: qsTr('Fit') valueText: { - if (!Globals.BackendWrapper.analysisFitHasInterimUpdate) - return Globals.BackendWrapper.analysisFitProgressMessage - const iter = Globals.BackendWrapper.analysisFitIteration - const rchi2 = Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4) - return qsTr('iter %1 · χ² %2').arg(iter).arg(rchi2) + if (Globals.BackendWrapper.analysisFitHasInterimUpdate) { + const iter = Globals.BackendWrapper.analysisFitIteration + const rchi2 = Globals.BackendWrapper.analysisFitInterimReducedChi2.toFixed(4) + return qsTr('iter %1 · χ² %2').arg(iter).arg(rchi2) + } + return qsTr('Fitting running') + '.'.repeat(dotCount % 5) } ToolTip.text: qsTr('Current fitting progress') + + property int dotCount: 0 + Timer { + interval: 600 + repeat: true + running: Globals.BackendWrapper.analysisFittingRunning + && !Globals.BackendWrapper.analysisFitHasInterimUpdate + onTriggered: parent.dotCount++ + } + onVisibleChanged: if (!visible) dotCount = 0 } EaElements.StatusBarItem { diff --git a/pyproject.toml b/pyproject.toml index e016f5b8..f683417a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ requires-python = '>=3.11' dependencies = [ 'EasyApp @ git+https://github.com/EasyScience/EasyApp.git@develop', - 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@develop', + 'easyreflectometry @ git+https://github.com/EasyScience/EasyReflectometryLib.git@interim_updates', 'asteval', 'PySide6', 'toml', From 2b0fd50785f26b5b2053e9622cc4c29fb3a2bf90 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Mon, 20 Apr 2026 13:57:39 +0200 Subject: [PATCH 16/18] Combine layer and model editors; fix issues in lists (#303) * accordion groups. combine layer editor with model editor * added fixes to other lists * PR review fixes * fixed treatment of multilayers * duplicate/add layer should not append after subphase * force collapse explicitly * fix for assembly removal (+test) * more PR related fixes * added two standard icons for docs/issues * can't move subphase and superphase. Can't replace these as well. * remove redundant section in the report * Implement scatter series for measured data and synchronize colors with current experiment * missed file * Fixed issue with Sample tab Added different ways to show experimental data (dots, circles, line) --- .../Backends/Py/logic/assemblies.py | 66 +++++++-- .../Backends/Py/logic/layers.py | 71 +++++++++ .../Backends/Py/logic/material.py | 24 ++++ .../Backends/Py/logic/models.py | 8 ++ .../Backends/Py/logic/summary.py | 5 +- .../Backends/Py/plotting_1d.py | 51 ------- EasyReflectometryApp/Backends/Py/sample.py | 136 ++++++++++++++++-- .../Gui/ApplicationWindow.qml | 11 ++ .../Gui/Globals/ApplicationInfo.qml | 6 +- .../Gui/Globals/BackendWrapper.qml | 15 +- .../Gui/Globals/Variables.qml | 1 + .../Gui/Logic/MeasuredScatter.js | 105 ++++++++++++++ .../Analysis/MainContent/AnalysisView.qml | 135 ++++++++++++++--- .../Analysis/MainContent/CombinedView.qml | 118 ++++++++++++--- .../Experiment/MainContent/ExperimentView.qml | 116 ++++++++++++--- .../Sidebar/Advanced/Groups/PlotControl.qml | 28 ++++ .../Basic/Groups/ExperimentalDataExplorer.qml | 48 +------ .../Basic/Groups/Assemblies/MultiLayer.qml | 9 +- .../Groups/Assemblies/SurfactantLayer.qml | 15 +- .../Sidebar/Basic/Groups/AssemblyEditor.qml | 29 ---- .../Sidebar/Basic/Groups/MaterialEditor.qml | 8 +- .../Sidebar/Basic/Groups/ModelEditor.qml | 34 ++++- .../Sidebar/Basic/Groups/ModelSelector.qml | 2 +- .../Gui/Pages/Sample/Sidebar/Basic/Layout.qml | 8 +- tests/test_logic_assemblies.py | 116 +++++++++++++++ tests/test_logic_layers.py | 88 ++++++++++++ tests/test_logic_summary.py | 10 +- tests/test_py_sample.py | 42 ++++++ 28 files changed, 1066 insertions(+), 239 deletions(-) create mode 100644 EasyReflectometryApp/Gui/Logic/MeasuredScatter.js delete mode 100644 EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/AssemblyEditor.qml create mode 100644 tests/test_py_sample.py diff --git a/EasyReflectometryApp/Backends/Py/logic/assemblies.py b/EasyReflectometryApp/Backends/Py/logic/assemblies.py index 18b3ef4a..27ec3c0b 100644 --- a/EasyReflectometryApp/Backends/Py/logic/assemblies.py +++ b/EasyReflectometryApp/Backends/Py/logic/assemblies.py @@ -11,6 +11,29 @@ class Assemblies: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib + def _has_valid_assembly_index(self, index: int) -> bool: + return 0 <= index < len(self._assemblies) + + def _target_insert_index(self, current_index: int, previous_length: int) -> int: + if previous_length <= 1: + return previous_length + return min(current_index + 1, previous_length - 1) + + def _move_new_assembly_into_position(self, existing_ids: set[int], target_index: int) -> int | None: + new_index = next((idx for idx, assembly in enumerate(self._assemblies) if id(assembly) not in existing_ids), None) + if new_index is None: + return None + + while new_index > target_index: + self._assemblies.move_up(new_index) + new_index -= 1 + + while new_index < target_index: + self._assemblies.move_down(new_index) + new_index += 1 + + return new_index + @property def _assemblies(self) -> Sample: return self._project_lib._models[self._project_lib.current_model_index].sample # Sample is a collection of assemblies @@ -43,12 +66,23 @@ def remove_at_index(self, value: str) -> None: self._assemblies.remove_assembly(int(value)) def add_new(self) -> None: + previous_length = len(self._assemblies) + target_index = self._target_insert_index(self.index, previous_length) + existing_ids = {id(assembly) for assembly in self._assemblies} self._assemblies.add_assembly() + new_index = self._move_new_assembly_into_position(existing_ids, target_index) index_si = self._project_lib.get_index_si() - self._assemblies[-1].layers[0].material = self._project_lib._materials[index_si] + if new_index is not None: + self._assemblies[new_index].layers[0].material = self._project_lib._materials[index_si] def duplicate_selected(self) -> None: + if not self._has_valid_assembly_index(self.index): + return + previous_length = len(self._assemblies) + target_index = self._target_insert_index(self.index, previous_length) + existing_ids = {id(assembly) for assembly in self._assemblies} self._assemblies.duplicate_assembly(self.index) + self._move_new_assembly_into_position(existing_ids, target_index) def move_selected_up(self) -> None: if self.index > 0: @@ -60,31 +94,45 @@ def move_selected_down(self) -> None: self._assemblies.move_down(self.index) self.index = self.index + 1 - def set_name_at_current_index(self, new_value: str) -> None: - self._assemblies[self.index].name = new_value - return True + def set_name_at_current_index(self, new_value: str) -> bool: + return self.set_name_at_index(self.index, new_value) + + def set_name_at_index(self, index: int, new_value: str) -> bool: + if not self._has_valid_assembly_index(index): + return False + if self._assemblies[index].name != new_value: + self._assemblies[index].name = new_value + return True + return False def set_type_at_current_index(self, new_value: str) -> bool: - if new_value == self._assemblies[self.index].type: + return self.set_type_at_index(self.index, new_value) + + def set_type_at_index(self, index: int, new_value: str) -> bool: + if not self._has_valid_assembly_index(index): + return False + if new_value == self._assemblies[index].type: return False if new_value == 'Multi-layer': new_assembly = Multilayer() - new_assembly.layers[0].material = self._assemblies[self.index].layers.data[0].material + new_assembly.layers[0].material = self._assemblies[index].layers.data[0].material elif new_value == 'Repeating Multi-layer': new_assembly = RepeatingMultilayer(repetitions=1, name=new_value) - new_assembly.layers[0].material = self._assemblies[self.index].layers.data[0].material + new_assembly.layers[0].material = self._assemblies[index].layers.data[0].material elif new_value == 'Surfactant Layer': index_air = self._project_lib.get_index_air() index_d2o = self._project_lib.get_index_d2o() new_assembly = SurfactantLayer() new_assembly.layers[0].solvent = self._project_lib._materials[index_air] new_assembly.layers[1].solvent = self._project_lib._materials[index_d2o] + else: + return False if new_assembly.name is None: - new_assembly.name = self._assemblies[self.index].name + new_assembly.name = self._assemblies[index].name - self._assemblies[self.index] = new_assembly + self._assemblies[index] = new_assembly return True # Only for repeating multilayer diff --git a/EasyReflectometryApp/Backends/Py/logic/layers.py b/EasyReflectometryApp/Backends/Py/logic/layers.py index 92fa0e5f..3b0c3220 100644 --- a/EasyReflectometryApp/Backends/Py/logic/layers.py +++ b/EasyReflectometryApp/Backends/Py/logic/layers.py @@ -11,6 +11,12 @@ class Layers: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib + def _has_valid_layer_index(self, index: int) -> bool: + return 0 <= index < len(self._layers) + + def _has_valid_material_index(self, index: int) -> bool: + return 0 <= index < len(self._project_lib._materials) + @property def _sample(self) -> Sample: return self._project_lib._models[self._project_lib.current_model_index].sample @@ -82,18 +88,42 @@ def set_name_at_current_index(self, new_value: str) -> bool: return True return False + def set_name_at_index(self, index: int, new_value: str) -> bool: + if not self._has_valid_layer_index(index): + return False + if self._layers[index].name != new_value: + self._layers[index].name = new_value + return True + return False + def set_thickness_at_current_index(self, new_value: float) -> bool: if self._layers[self.index].thickness.value != new_value: self._layers[self.index].thickness.value = new_value return True return False + def set_thickness_at_index(self, index: int, new_value: float) -> bool: + if not self._has_valid_layer_index(index): + return False + if self._layers[index].thickness.value != new_value: + self._layers[index].thickness.value = new_value + return True + return False + def set_roughness_at_current_index(self, new_value: float) -> bool: if self._layers[self.index].roughness.value != new_value: self._layers[self.index].roughness.value = new_value return True return False + def set_roughness_at_index(self, index: int, new_value: float) -> bool: + if not self._has_valid_layer_index(index): + return False + if self._layers[index].roughness.value != new_value: + self._layers[index].roughness.value = new_value + return True + return False + 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] @@ -102,30 +132,71 @@ def set_material_at_current_index(self, new_value: int) -> bool: return True return False + def set_material_at_index(self, index: int, new_value: int) -> bool: + if not self._has_valid_layer_index(index) or not self._has_valid_material_index(new_value): + return False + if self._layers[index].material != self._project_lib._materials[new_value]: + self._layers[index].material = self._project_lib._materials[new_value] + self._layers[index].name = self._project_lib._materials[new_value].name + ' Layer' + return True + return False + def set_solvent_at_current_index(self, new_value: int) -> bool: if self._layers[self.index].solvent != self._project_lib._materials[new_value]: self._layers[self.index].solvent = self._project_lib._materials[new_value] return True return False + def set_solvent_at_index(self, index: int, new_value: int) -> bool: + if not self._has_valid_layer_index(index) or not self._has_valid_material_index(new_value): + return False + if self._layers[index].solvent != self._project_lib._materials[new_value]: + self._layers[index].solvent = self._project_lib._materials[new_value] + return True + return False + def set_apm_at_current_index(self, new_value: float) -> bool: if self._layers[self.index].area_per_molecule != new_value: self._layers[self.index].area_per_molecule = new_value return True return False + def set_apm_at_index(self, index: int, new_value: float) -> bool: + if not self._has_valid_layer_index(index): + return False + if self._layers[index].area_per_molecule != new_value: + self._layers[index].area_per_molecule = new_value + return True + return False + def set_solvation_at_current_index(self, new_value: float) -> bool: if self._layers[self.index].solvent_fraction != new_value: self._layers[self.index].solvent_fraction = new_value return True return False + def set_solvation_at_index(self, index: int, new_value: float) -> bool: + if not self._has_valid_layer_index(index): + return False + if self._layers[index].solvent_fraction != new_value: + self._layers[index].solvent_fraction = new_value + return True + return False + def set_formula(self, new_value: str) -> bool: if self._layers[self.index].molecular_formula != new_value: self._layers[self.index].molecular_formula = new_value return True return False + def set_formula_at_index(self, index: int, new_value: str) -> bool: + if not self._has_valid_layer_index(index): + return False + if self._layers[index].molecular_formula != new_value: + self._layers[index].molecular_formula = new_value + return True + return False + def _from_layers_collection_to_list_of_dicts( layers_collection: LayerCollection, assembly_type: str = 'regular' diff --git a/EasyReflectometryApp/Backends/Py/logic/material.py b/EasyReflectometryApp/Backends/Py/logic/material.py index 919b2977..d5ba815c 100644 --- a/EasyReflectometryApp/Backends/Py/logic/material.py +++ b/EasyReflectometryApp/Backends/Py/logic/material.py @@ -57,18 +57,42 @@ def set_name_at_current_index(self, new_value: str) -> bool: return True return False + def set_name_at_index(self, index: int, new_value: str) -> bool: + if not (0 <= index < len(self._materials)): + return False + if self._materials[index].name != new_value: + self._materials[index].name = new_value + return True + return False + def set_sld_at_current_index(self, new_value: float) -> bool: if self._materials[self.index].sld.value != new_value: self._materials[self.index].sld.value = new_value return True return False + def set_sld_at_index(self, index: int, new_value: float) -> bool: + if not (0 <= index < len(self._materials)): + return False + if self._materials[index].sld.value != new_value: + self._materials[index].sld.value = new_value + return True + return False + def set_isld_at_current_index(self, new_value: float) -> bool: if self._materials[self.index].isld.value != new_value: self._materials[self.index].isld.value = new_value return True return False + def set_isld_at_index(self, index: int, new_value: float) -> bool: + if not (0 <= index < len(self._materials)): + return False + if self._materials[index].isld.value != new_value: + self._materials[index].isld.value = new_value + return True + return False + def _from_materials_collection_to_list_of_dicts(materials_collection: MaterialCollection) -> list[dict[str, str]]: materials_list = [] diff --git a/EasyReflectometryApp/Backends/Py/logic/models.py b/EasyReflectometryApp/Backends/Py/logic/models.py index 44349cf7..b5e22e2d 100644 --- a/EasyReflectometryApp/Backends/Py/logic/models.py +++ b/EasyReflectometryApp/Backends/Py/logic/models.py @@ -57,6 +57,14 @@ def set_name_at_current_index(self, new_value: str) -> bool: return True return False + def set_name_at_index(self, index: int, new_value: str) -> bool: + if not (0 <= index < len(self._models)): + return False + if self._models[index].name != new_value: + self._models[index].name = new_value + return True + return False + def set_scaling_at_current_index(self, new_value: str) -> bool: if self._models[self.index].scale.value != float(new_value): self._models[self.index].scale.value = float(new_value) diff --git a/EasyReflectometryApp/Backends/Py/logic/summary.py b/EasyReflectometryApp/Backends/Py/logic/summary.py index 88b4dd90..024402ab 100644 --- a/EasyReflectometryApp/Backends/Py/logic/summary.py +++ b/EasyReflectometryApp/Backends/Py/logic/summary.py @@ -48,7 +48,8 @@ def plot_file_path(self) -> Path: @property def as_html(self) -> str: base_html = self._summary.compile_html_summary() - return self._inject_multimodel_multiexperiment_sections(base_html) + return base_html + # return self._inject_multimodel_multiexperiment_sections(base_html) def save_as_html(self, file_path: str | None = None) -> None: if not self._project_lib.path.exists(): @@ -57,7 +58,7 @@ def save_as_html(self, file_path: str | None = None) -> None: target_path = Path(file_path) if file_path else self.file_path.with_suffix('.html') target_path.parent.mkdir(parents=True, exist_ok=True) html_content = self._summary.compile_html_summary(figures=True) - html_content = self._inject_multimodel_multiexperiment_sections(html_content) + # html_content = self._inject_multimodel_multiexperiment_sections(html_content) with open(target_path, 'w', encoding='utf-8') as report_file: report_file.write(html_content) diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index b3f5fddd..84a5039e 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -562,22 +562,6 @@ def individualExperimentDataList(self) -> list: ) return qml_data_list - @Property(float, notify=sampleChartRangesChanged) - def residualMinX(self): - return self._get_residual_range()[0] - - @Property(float, notify=sampleChartRangesChanged) - def residualMaxX(self): - return self._get_residual_range()[1] - - @Property(float, notify=sampleChartRangesChanged) - def residualMinY(self): - return self._get_residual_range()[2] - - @Property(float, notify=sampleChartRangesChanged) - def residualMaxY(self): - return self._get_residual_range()[3] - @Slot(str, str, 'QVariant') def setQtChartsSerieRef(self, page: str, serie: str, ref: QObject): self._chartRefs['QtCharts'][page][serie] = ref @@ -758,41 +742,6 @@ def getResidualDataPoints(self, experiment_index: int) -> list: console.debug(f'Error getting residual data points for index {experiment_index}: {e}') return [] - def _get_residual_range(self) -> tuple[float, float, float, float]: - """Return residual plot ranges for the current selection.""" - try: - if self.is_multi_experiment_mode: - selected_indices = getattr(self._proxy._analysis, '_selected_experiment_indices', []) - else: - selected_indices = [self._project_lib.current_experiment_index] - - all_points = [] - for experiment_index in selected_indices: - all_points.extend(self.getResidualDataPoints(experiment_index)) - - if not all_points: - return 0.0, 1.0, -1.0, 1.0 - - x_values = np.asarray([point['x'] for point in all_points], dtype=float) - y_values = np.asarray([point['y'] for point in all_points], dtype=float) - if x_values.size == 0 or y_values.size == 0: - return 0.0, 1.0, -1.0, 1.0 - - min_x = float(np.min(x_values)) - max_x = float(np.max(x_values)) - min_y = float(np.min(y_values)) - max_y = float(np.max(y_values)) - - if min_y == max_y: - margin = max(abs(min_y) * 0.05, 1.0) - else: - margin = (max_y - min_y) * 0.05 - - return min_x, max_x, min_y - margin, max_y + margin - except Exception as e: - console.debug(f'Error getting residual range: {e}') - return 0.0, 1.0, -1.0, 1.0 - def refreshSamplePage(self): # Clear cached data so it gets recalculated self._sample_data = {} diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index 1c73c226..d3da25c7 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -118,6 +118,11 @@ def setCurrentMaterialName(self, new_value: str) -> None: if self._material_logic.set_name_at_current_index(new_value): self.materialsTableChanged.emit() + @Slot(int, str) + def setMaterialNameAtIndex(self, index: int, new_value: str) -> None: + if self._material_logic.set_name_at_index(index, new_value): + self.materialsTableChanged.emit() + @Slot(float) def setCurrentMaterialSld(self, new_value: float) -> None: if self._material_logic.set_sld_at_current_index(new_value): @@ -125,6 +130,13 @@ def setCurrentMaterialSld(self, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, float) + def setMaterialSldAtIndex(self, index: int, new_value: float) -> None: + if self._material_logic.set_sld_at_index(index, new_value): + self.materialsTableChanged.emit() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(float) def setCurrentMaterialISld(self, new_value: float) -> None: if self._material_logic.set_isld_at_current_index(new_value): @@ -132,6 +144,13 @@ def setCurrentMaterialISld(self, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, float) + def setMaterialISldAtIndex(self, index: int, new_value: float) -> None: + if self._material_logic.set_isld_at_index(index, new_value): + self.materialsTableChanged.emit() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + # Actions @Slot(str) def removeMaterial(self, value: str) -> None: @@ -197,6 +216,13 @@ def setCurrentModelName(self, value: str) -> None: self.modelsIndexChanged.emit() self._clearCacheAndEmitLayersChanged() + @Slot(int, str) + def setModelNameAtIndex(self, index: int, value: str) -> None: + if self._models_logic.set_name_at_index(index, value): + self.modelsTableChanged.emit() + self.modelsIndexChanged.emit() + self._clearCacheAndEmitLayersChanged() + # Actions @Slot(str) def removeModel(self, value: str) -> None: @@ -249,13 +275,34 @@ def currentAssemblyName(self) -> str: def currentAssemblyType(self) -> str: return self._assemblies_logic.type_at_current_index + def _refreshCurrentAssemblySelectionState(self) -> None: + sample = self._project_lib._models[self._project_lib.current_model_index].sample + assembly_count = len(sample) + + if assembly_count == 0: + self._project_lib.current_assembly_index = 0 + self._project_lib.current_layer_index = 0 + else: + self._project_lib.current_assembly_index = max( + 0, + min(self._project_lib.current_assembly_index, assembly_count - 1), + ) + layer_count = len(sample[self._project_lib.current_assembly_index].layers) + self._project_lib.current_layer_index = max( + 0, + min(self._project_lib.current_layer_index, max(layer_count - 1, 0)), + ) + + self._clearCacheAndEmitLayersChanged() + self.assembliesIndexChanged.emit() + self.layersIndexChanged.emit() + # Setters @Slot(int) def setCurrentAssemblyIndex(self, new_value: int) -> None: self._project_lib.current_assembly_index = new_value - self._clearCacheAndEmitLayersChanged() + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() - self.assembliesIndexChanged.emit() @Slot(str) def setCurrentAssemblyName(self, new_value: str) -> None: @@ -264,14 +311,28 @@ def setCurrentAssemblyName(self, new_value: str) -> None: self.materialsTableChanged.emit() self.externalSampleChanged.emit() + @Slot(int, str) + def setAssemblyNameAtIndex(self, index: int, new_value: str) -> None: + if self._assemblies_logic.set_name_at_index(index, new_value): + self.assembliesTableChanged.emit() + self.materialsTableChanged.emit() + self.externalSampleChanged.emit() + @Slot(str) def setCurrentAssemblyType(self, new_value: str) -> None: - self._assemblies_logic.set_type_at_current_index(new_value) - self._clearCacheAndEmitLayersChanged() - self.assembliesTableChanged.emit() - self.assembliesIndexChanged.emit() - self.externalRefreshPlot.emit() - self.externalSampleChanged.emit() + if self._assemblies_logic.set_type_at_current_index(new_value): + self._refreshCurrentAssemblySelectionState() + self.assembliesTableChanged.emit() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + + @Slot(int, str) + def setAssemblyTypeAtIndex(self, index: int, new_value: str) -> None: + if self._assemblies_logic.set_type_at_index(index, new_value): + self._refreshCurrentAssemblySelectionState() + self.assembliesTableChanged.emit() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() # Assembly specific @Property(str, notify=assembliesTableChanged) @@ -302,6 +363,7 @@ def setCurrentAssemblyConformalRoughness(self, new_value: bool) -> None: @Slot(str) def removeAssembly(self, value: str) -> None: self._assemblies_logic.remove_at_index(value) + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @@ -309,6 +371,7 @@ def removeAssembly(self, value: str) -> None: @Slot() def addNewAssembly(self) -> None: self._assemblies_logic.add_new() + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @@ -316,6 +379,7 @@ def addNewAssembly(self) -> None: @Slot() def duplicateSelectedAssembly(self) -> None: self._assemblies_logic.duplicate_selected() + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @@ -323,12 +387,14 @@ def duplicateSelectedAssembly(self) -> None: @Slot() def moveSelectedAssemblyUp(self) -> None: self._assemblies_logic.move_selected_up() + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() @Slot() def moveSelectedAssemblyDown(self) -> None: self._assemblies_logic.move_selected_down() + self._refreshCurrentAssemblySelectionState() self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() @@ -367,6 +433,11 @@ def setCurrentLayerName(self, new_value: str) -> None: if self._layers_logic.set_name_at_current_index(new_value): self._clearCacheAndEmitLayersChanged() + @Slot(int, str) + def setLayerNameAtIndex(self, index: int, new_value: str) -> None: + if self._layers_logic.set_name_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + @Slot(int) def setCurrentLayerMaterial(self, new_value: int) -> None: if self._layers_logic.set_material_at_current_index(new_value): @@ -374,6 +445,13 @@ def setCurrentLayerMaterial(self, new_value: int) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, int) + def setLayerMaterialAtIndex(self, index: int, new_value: int) -> None: + if self._layers_logic.set_material_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(int) def setCurrentLayerSolvent(self, new_value: int) -> None: if self._layers_logic.set_solvent_at_current_index(new_value): @@ -381,6 +459,13 @@ def setCurrentLayerSolvent(self, new_value: int) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, int) + def setLayerSolventAtIndex(self, index: int, new_value: int) -> None: + if self._layers_logic.set_solvent_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(float) def setCurrentLayerThickness(self, new_value: float) -> None: if self._layers_logic.set_thickness_at_current_index(new_value): @@ -388,6 +473,13 @@ def setCurrentLayerThickness(self, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, float) + def setLayerThicknessAtIndex(self, index: int, new_value: float) -> None: + if self._layers_logic.set_thickness_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(float) def setCurrentLayerRoughness(self, new_value: float) -> None: if self._layers_logic.set_roughness_at_current_index(new_value): @@ -395,6 +487,13 @@ def setCurrentLayerRoughness(self, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, float) + def setLayerRoughnessAtIndex(self, index: int, new_value: float) -> None: + if self._layers_logic.set_roughness_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(str) def setCurrentLayerFormula(self, new_value: str) -> None: if self._layers_logic.set_formula(new_value): @@ -402,6 +501,13 @@ def setCurrentLayerFormula(self, new_value: str) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, str) + def setLayerFormulaAtIndex(self, index: int, new_value: str) -> None: + if self._layers_logic.set_formula_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(float) def setCurrentLayerAPM(self, new_value: float) -> None: if self._layers_logic.set_apm_at_current_index(new_value): @@ -409,6 +515,13 @@ def setCurrentLayerAPM(self, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, float) + def setLayerAPMAtIndex(self, index: int, new_value: float) -> None: + if self._layers_logic.set_apm_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + @Slot(float) def setCurrentLayerSolvation(self, new_value: float) -> None: if self._layers_logic.set_solvation_at_current_index(new_value): @@ -416,6 +529,13 @@ def setCurrentLayerSolvation(self, new_value: float) -> None: self.externalRefreshPlot.emit() self.externalSampleChanged.emit() + @Slot(int, float) + def setLayerSolvationAtIndex(self, index: int, new_value: float) -> None: + if self._layers_logic.set_solvation_at_index(index, new_value): + self._clearCacheAndEmitLayersChanged() + self.externalRefreshPlot.emit() + self.externalSampleChanged.emit() + # Actions @Slot(str) def removeLayer(self, value: str) -> None: diff --git a/EasyReflectometryApp/Gui/ApplicationWindow.qml b/EasyReflectometryApp/Gui/ApplicationWindow.qml index a190fba9..cc3f89b4 100644 --- a/EasyReflectometryApp/Gui/ApplicationWindow.qml +++ b/EasyReflectometryApp/Gui/ApplicationWindow.qml @@ -51,6 +51,17 @@ EaComponents.ApplicationWindow { fontIcon: "cog" ToolTip.text: qsTr("Application preferences") onClicked: EaGlobals.Vars.showAppPreferencesDialog = true + }, + EaElements.ToolButton { + fontIcon: 'question-circle' + ToolTip.text: qsTr('Get online help') + onClicked: Qt.openUrlExternally(Globals.ApplicationInfo.about.docsUrl) + }, + + EaElements.ToolButton { + fontIcon: 'bug' + ToolTip.text: qsTr('Report a bug or issue') + onClicked: Qt.openUrlExternally(Globals.ApplicationInfo.about.issuesUrl) } ] diff --git a/EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml b/EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml index 38e3644e..73efb6eb 100644 --- a/EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml +++ b/EasyReflectometryApp/Gui/Globals/ApplicationInfo.qml @@ -17,8 +17,10 @@ QtObject { 'url': 'https://ess.eu', 'icon': Qt.resolvedUrl('../Resources/Logo/ESS.png'), 'heightScale': 3.0 - } - ] + }, + ], + 'docsUrl': 'https://easyscience.github.io/EasyReflectometryApp/', + 'issuesUrl': 'https://github.com/easyscience/EasyReflectometryApp/issues' } } diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 6c08039e..e77da1f1 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -103,8 +103,11 @@ QtObject { function sampleSetCurrentMaterialIndex(value) { activeBackend.sample.setCurrentMaterialIndex(value) } function sampleSetCurrentMaterialName(value) { activeBackend.sample.setCurrentMaterialName(value) } - function sampleSetCurrentMaterialSld(value) { activeBackend.sample.setCurrentMaterialSld(value) } + function sampleSetMaterialNameAtIndex(index, value) { activeBackend.sample.setMaterialNameAtIndex(index, value) } + function sampleSetCurrentMaterialSld(value) { activeBackend.sample.setCurrentMaterialSld(value) } + function sampleSetMaterialSldAtIndex(index, value) { activeBackend.sample.setMaterialSldAtIndex(index, value) } function sampleSetCurrentMaterialISld(value) { activeBackend.sample.setCurrentMaterialISld(value) } + function sampleSetMaterialISldAtIndex(index, value) { activeBackend.sample.setMaterialISldAtIndex(index, value) } function sampleRemoveMaterial(value) { activeBackend.sample.removeMaterial(value) } function sampleAddNewMaterial() { activeBackend.sample.addNewMaterial() } function sampleDuplicateSelectedMaterial() { activeBackend.sample.duplicateSelectedMaterial() } @@ -120,6 +123,7 @@ QtObject { function sampleSetCurrentModelIndex(value) { activeBackend.sample.setCurrentModelIndex(value) } function sampleSetCurrentModelName(value) { activeBackend.sample.setCurrentModelName(value) } + function sampleSetModelNameAtIndex(index, value) { activeBackend.sample.setModelNameAtIndex(index, value) } function sampleRemoveModel(value) { activeBackend.sample.removeModel(value) } function sampleAddNewModel() { activeBackend.sample.addNewModel() } function sampleDuplicateSelectedModel() { activeBackend.sample.duplicateSelectedModel() } @@ -135,7 +139,9 @@ QtObject { function sampleSetCurrentAssemblyIndex(value) { activeBackend.sample.setCurrentAssemblyIndex(value) } function sampleSetCurrentAssemblyName(value) { activeBackend.sample.setCurrentAssemblyName(value) } + function sampleSetAssemblyNameAtIndex(index, value) { activeBackend.sample.setAssemblyNameAtIndex(index, value) } function sampleSetCurrentAssemblyType(value) { activeBackend.sample.setCurrentAssemblyType(value) } + function sampleSetAssemblyTypeAtIndex(index, value) { activeBackend.sample.setAssemblyTypeAtIndex(index, value) } function sampleRemoveAssembly(value) { activeBackend.sample.removeAssembly(value) } function sampleAddNewAssembly() { activeBackend.sample.addNewAssembly() } function sampleDuplicateSelectedAssembly() { activeBackend.sample.duplicateSelectedAssembly() } @@ -162,12 +168,19 @@ QtObject { function sampleMoveSelectedLayerDown() { activeBackend.sample.moveSelectedLayerDown() } function sampleSetCurrentLayerFormula(value) { activeBackend.sample.setCurrentLayerFormula(value) } + function sampleSetLayerFormulaAtIndex(index, value) { activeBackend.sample.setLayerFormulaAtIndex(index, value) } function sampleSetCurrentLayerMaterial(value) { activeBackend.sample.setCurrentLayerMaterial(value) } + function sampleSetLayerMaterialAtIndex(index, value) { activeBackend.sample.setLayerMaterialAtIndex(index, value) } function sampleSetCurrentLayerSolvent(value) { activeBackend.sample.setCurrentLayerSolvent(value) } + function sampleSetLayerSolventAtIndex(index, value) { activeBackend.sample.setLayerSolventAtIndex(index, value) } function sampleSetCurrentLayerThickness(value) { activeBackend.sample.setCurrentLayerThickness(value) } + function sampleSetLayerThicknessAtIndex(index, value) { activeBackend.sample.setLayerThicknessAtIndex(index, value) } function sampleSetCurrentLayerRoughness(value) { activeBackend.sample.setCurrentLayerRoughness(value) } + function sampleSetLayerRoughnessAtIndex(index, value) { activeBackend.sample.setLayerRoughnessAtIndex(index, value) } function sampleSetCurrentLayerAPM(value) { activeBackend.sample.setCurrentLayerAPM(value) } + function sampleSetLayerAPMAtIndex(index, value) { activeBackend.sample.setLayerAPMAtIndex(index, value) } function sampleSetCurrentLayerSolvation(value) { activeBackend.sample.setCurrentLayerSolvation(value) } + function sampleSetLayerSolvationAtIndex(index, value) { activeBackend.sample.setLayerSolvationAtIndex(index, value) } // Constraints readonly property var sampleEnabledParameterNames: activeBackend.sample.enabledParameterNames diff --git a/EasyReflectometryApp/Gui/Globals/Variables.qml b/EasyReflectometryApp/Gui/Globals/Variables.qml index d84791b1..f0ae2cd1 100644 --- a/EasyReflectometryApp/Gui/Globals/Variables.qml +++ b/EasyReflectometryApp/Gui/Globals/Variables.qml @@ -18,6 +18,7 @@ QtObject { // Sample page plot control settings property bool reverseSldZAxis: false property bool logarithmicQAxis: false + property int experimentMarkerStyle: 0 // 0: dots, 1: circles, 2: line // Shared experiment color palette — used by Data Explorer table, Experiment chart, and Analysis charts readonly property var experimentColorPalette: [ diff --git a/EasyReflectometryApp/Gui/Logic/MeasuredScatter.js b/EasyReflectometryApp/Gui/Logic/MeasuredScatter.js new file mode 100644 index 00000000..27a29db5 --- /dev/null +++ b/EasyReflectometryApp/Gui/Logic/MeasuredScatter.js @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project +// +// Shared helpers for the "measured data" scatter series rendered on the +// Analysis, Combined and Experiment chart views. +// +// Centralising the series style here keeps the three views in sync and +// gives us a single place to tweak marker shape / size / opacity on demand. + +// Style constants. These are the only place where these magic numbers live. +// MARKER_SHAPE expects one of `ScatterSeries.MarkerShape*` enum values; it is +// resolved lazily inside `applyStyle` so this file does not depend on QtCharts +// being importable at load time. +var MARKER_SIZE_DOTS = 2 +var MARKER_SIZE_CIRCLES = 5 +var BORDER_WIDTH = 0 +var OPACITY = 0.95 +var MARKER_SHAPE = "Circle" // "Circle" or "Rectangle" (ScatterSeries.MarkerShape*) + +// Resolve a marker-shape name to the matching ScatterSeries enum value. +// `scatterSeriesType` is the QML type object (e.g. ScatterSeries) from which +// to read the enum. Falls back to 0 (Circle) if anything is missing. +// `shape` is the shape name, defaults to MARKER_SHAPE +function _resolveMarkerShape(scatterSeriesType, shape) { + if (!shape) { + shape = MARKER_SHAPE + } + if (!scatterSeriesType) { + return 0 + } + var key = "MarkerShape" + shape + var value = scatterSeriesType[key] + return (value !== undefined) ? value : 0 +} + +// Apply the canonical measured-scatter style to an existing ScatterSeries. +// serie -- the ScatterSeries to style (must not be null) +// color -- foreground/border color +// markerStyle -- 0: dots, 1: circles, 2: line +// scatterSeriesType -- the ScatterSeries QML type, used to resolve the +// marker-shape enum (pass `ScatterSeries` from QML) +function applyStyle(serie, color, markerStyle, scatterSeriesType) { + if (!serie) { + console.warn("MeasuredScatter.applyStyle: serie is null - style not applied") + return + } + var markerSize = (markerStyle === 0) ? MARKER_SIZE_DOTS : MARKER_SIZE_CIRCLES + serie.color = color + serie.borderColor = color + serie.markerSize = markerSize + serie.borderWidth = BORDER_WIDTH + serie.opacity = OPACITY + serie.markerShape = _resolveMarkerShape(scatterSeriesType) +} + +// Create a styled measured series on the given chart. +// chartView -- ChartView instance +// chartViewType -- the ChartView QML type (for SeriesType enum) +// scatterSeriesType -- the ScatterSeries QML type (for MarkerShape enum, if scatter) +// name, axisX, axisY -- forwarded to createSeries +// color -- color applied via applyStyle +// markerStyle -- 0: dots, 1: circles, 2: line +// Returns the new series, or null if creation failed. +function create(chartView, chartViewType, scatterSeriesType, name, axisX, axisY, color, markerStyle) { + if (!chartView) { + console.warn("MeasuredScatter.create: chartView is null") + return null + } + var seriesType + if (markerStyle === 2) { // line + seriesType = (chartViewType && chartViewType.SeriesTypeLine !== undefined) + ? chartViewType.SeriesTypeLine + : 1 // fallback: ChartView.SeriesTypeLine == 1 + } else { // scatter + seriesType = (chartViewType && chartViewType.SeriesTypeScatter !== undefined) + ? chartViewType.SeriesTypeScatter + : 2 // fallback: ChartView.SeriesTypeScatter == 2 + } + var serie = chartView.createSeries(seriesType, name, axisX, axisY) + if (!serie) { + console.warn("MeasuredScatter.create: createSeries returned null for '" + name + "'") + return null + } + if (markerStyle !== 2) { // apply scatter style only if not line + applyStyle(serie, color, markerStyle, scatterSeriesType) + } else { // line style + serie.color = color + serie.width = 2 // line width + } + serie.useOpenGL = chartView.useOpenGL + return serie +} + +// Update color + borderColor in lockstep. Useful when the selected experiment +// changes and the series should track the new color. +function setColor(serie, color) { + if (!serie) { + return + } + serie.color = color + if (serie.borderColor !== undefined) { + serie.borderColor = color + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index 0f54baf5..d59d5458 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -12,6 +12,7 @@ import EasyApp.Gui.Elements as EaElements import EasyApp.Gui.Charts as EaCharts import Gui.Globals as Globals +import "../../../Logic/MeasuredScatter.js" as MeasuredScatter Rectangle { @@ -33,6 +34,12 @@ Rectangle { bkgSerie.width: 1 bkgSerie.style: Qt.DotLine + // Track current experiment color for scatter series + property color currentExperimentColor: Globals.Variables.experimentColor( + Globals.BackendWrapper.analysisExperimentsCurrentIndex + ) + onCurrentExperimentColorChanged: MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 useOpenGL: EaGlobals.Vars.useOpenGL @@ -74,6 +81,9 @@ Rectangle { Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, true) } + // Scatter series for measured data (single experiment, linear mode) + property var measuredScatterSerie: null + // Multi-experiment support property var multiExperimentSeries: [] property bool isMultiExperimentMode: { @@ -118,6 +128,14 @@ Rectangle { } } + // Recreate series when marker style changes + Connections { + target: Globals.Variables + function onExperimentMarkerStyleChanged() { + chartView.recreateSeriesForCurrentMode() + } + } + Timer { id: analysisResetAxesTimer interval: 75 @@ -179,12 +197,12 @@ Rectangle { } else if (useLogQAxis) { // Single experiment, log mode: create dynamic series on log axis measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false calculated.visible = false - var newMeasured = chartView.createSeries(ChartView.SeriesTypeLine, "measured_log", axisXLog, chartView.axisY) - newMeasured.color = measured.color - newMeasured.width = measured.width - newMeasured.useOpenGL = chartView.useOpenGL + var newMeasured = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + "measured_log", axisXLog, chartView.axisY, + measured.color, Globals.Variables.experimentMarkerStyle) var newCalculated = chartView.createSeries(ChartView.SeriesTypeLine, "calculated_log", axisXLog, chartView.axisY) newCalculated.color = calculated.color @@ -201,11 +219,16 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', newCalculated) Globals.BackendWrapper.plottingRefreshAnalysis() } else { - // Single experiment, linear mode: restore static series - measured.visible = true + // Single experiment, linear mode: restore scatter series + measured.visible = false + if (!measuredScatterSerie) { + console.warn("AnalysisView.recreateForLogMode: measuredScatterSerie is null - linear mode will render no measured points") + } else { + measuredScatterSerie.visible = true + } calculated.visible = true - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measured) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) Globals.BackendWrapper.plottingRefreshAnalysis() } @@ -261,8 +284,19 @@ Rectangle { if (!isMultiExperimentMode) { // Show default series for single experiment console.log("Analysis: Single experiment mode - showing default series") - measured.visible = true + measured.visible = false + if (!measuredScatterSerie) { + console.warn("AnalysisView.updateMultiExperimentSeries: measuredScatterSerie is null - single mode will render no measured points") + } else { + measuredScatterSerie.visible = true + MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + } calculated.visible = true + + // Re-register scatter series and refresh data + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() return } @@ -273,13 +307,25 @@ Rectangle { // 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 + measured.visible = false + if (!measuredScatterSerie) { + console.warn("AnalysisView.updateMultiExperimentSeries: measuredScatterSerie is null - no-data fallback will render no measured points") + } else { + measuredScatterSerie.visible = true + } calculated.visible = true + + // Re-register the scatter series and refresh so the chart + // matches what the single-experiment branch above does. + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() return } // Hide default series in multi-experiment mode (only after we have data) measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false calculated.visible = false console.log("Analysis: Hidden default series, creating " + experimentDataList.length + " experiment series") @@ -316,16 +362,11 @@ Rectangle { ? modelColors[expIndex] : color - // Create measured data series - var measuredSerie = chartView.createSeries(ChartView.SeriesTypeLine, - `${expName} - Measured`, - xAxis, chartView.axisY) - measuredSerie.color = color - measuredSerie.width = 2 - measuredSerie.opacity = 0.95 - measuredSerie.style = Qt.DotLine - measuredSerie.capStyle = Qt.RoundCap - measuredSerie.useOpenGL = chartView.useOpenGL + // Create measured data series (scatter points) + var measuredSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + `${expName} - Measured`, + xAxis, chartView.axisY, + color, Globals.Variables.experimentMarkerStyle) // Create calculated data series using the model's own color var calculatedSerie = chartView.createSeries(ChartView.SeriesTypeLine, @@ -531,12 +572,48 @@ Rectangle { textFormat: Text.RichText } + function recreateSeriesForCurrentMode() { + if (isMultiExperimentMode) { + // Multi-experiment mode: recreate all multi-experiment series + updateMultiExperimentSeries() + } else if (useLogQAxis) { + // Single experiment, log mode: recreate log mode series + recreateForLogMode() + } else { + // Single experiment, linear mode: recreate scatter series + if (measuredScatterSerie) { + chartView.removeSeries(measuredScatterSerie) + measuredScatterSerie = null + } + measuredScatterSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + "measured_scatter", + chartView.axisX, chartView.axisY, + measured.color, Globals.Variables.experimentMarkerStyle) + if (measuredScatterSerie) { + measuredScatterSerie.visible = true + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingRefreshAnalysis() + } + } + } + // Data is set in python backend (plotting_1d.py) Component.onCompleted: { + // Create scatter series for measured data (single experiment, linear mode) + measuredScatterSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + "measured_scatter", + chartView.axisX, chartView.axisY, + measured.color, Globals.Variables.experimentMarkerStyle) + if (!measuredScatterSerie) { + console.warn("AnalysisView: failed to create measuredScatterSerie - measured data will not render") + } + measured.visible = false + Globals.References.pages.analysis.mainContent.analysisView = chartView + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', - measured) + measuredScatterSerie) Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) @@ -551,10 +628,22 @@ Rectangle { // Update series when chart becomes visible onVisibleChanged: { - if (visible && isMultiExperimentMode) { - updateMultiExperimentSeries() - } if (visible) { + if (isMultiExperimentMode) { + updateMultiExperimentSeries() + } else { + // Ensure scatter series has correct color and data after tab switch + if (!measuredScatterSerie) { + console.warn("AnalysisView.onVisibleChanged: measuredScatterSerie is null - tab switch will render no measured points") + } else { + MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + measuredScatterSerie.visible = true + } + measured.visible = false + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() + } updateReferenceLines() } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index b0585d18..9df0710e 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -14,6 +14,7 @@ import EasyApp.Gui.Charts as EaCharts import Gui as Gui import Gui.Globals as Globals +import "../../../Logic/MeasuredScatter.js" as MeasuredScatter Rectangle { @@ -48,6 +49,15 @@ Rectangle { bkgSerie.width: 1 bkgSerie.style: Qt.DotLine + // Scatter series for measured data (single experiment, linear mode) + property var measuredScatterSerie: null + + // Track current experiment color for scatter series + property color currentExperimentColor: Globals.Variables.experimentColor( + Globals.BackendWrapper.analysisExperimentsCurrentIndex + ) + onCurrentExperimentColorChanged: MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + anchors.fill: parent anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 @@ -105,6 +115,14 @@ Rectangle { } } + // Recreate series when marker style changes + Connections { + target: Globals.Variables + function onExperimentMarkerStyleChanged() { + analysisChartView.recreateSeriesForCurrentMode() + } + } + // Background reference line series LineSeries { id: backgroundRefLine @@ -156,9 +174,20 @@ Rectangle { clearMultiExperimentSeries() if (!isMultiExp) { - // Show default series for single experiment - measured.visible = true + // Show default scatter series for single experiment + measured.visible = false + if (!measuredScatterSerie) { + console.warn("CombinedView.updateMultiExperimentSeries: measuredScatterSerie is null - single mode will render no measured points") + } else { + measuredScatterSerie.visible = true + MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + } calculated.visible = true + + // Re-register scatter series and refresh data + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() return } @@ -167,13 +196,25 @@ Rectangle { // If no data available yet, keep default series visible as fallback if (experimentDataList.length === 0) { - measured.visible = true + measured.visible = false + if (!measuredScatterSerie) { + console.warn("CombinedView.updateMultiExperimentSeries: measuredScatterSerie is null - no-data fallback will render no measured points") + } else { + measuredScatterSerie.visible = true + } calculated.visible = true + + // Re-register and refresh so this branch matches the + // single-experiment branch above. + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) + Globals.BackendWrapper.plottingRefreshAnalysis() return } // Hide default series in multi-experiment mode (only after we have data) measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false calculated.visible = false // Create series for each experiment @@ -208,16 +249,11 @@ Rectangle { ? modelColors[expIndex] : color - // Create measured data series - var measuredSerie = analysisChartView.createSeries(ChartView.SeriesTypeLine, - `${expName} - Measured`, - xAxis, analysisChartView.axisY) - measuredSerie.color = color - measuredSerie.width = 2 - measuredSerie.opacity = 0.95 - measuredSerie.style = Qt.DotLine - measuredSerie.capStyle = Qt.RoundCap - measuredSerie.useOpenGL = analysisChartView.useOpenGL + // Create measured data series (scatter points) + var measuredSerie = MeasuredScatter.create(analysisChartView, ChartView, ScatterSeries, + `${expName} - Measured`, + xAxis, analysisChartView.axisY, + color, Globals.Variables.experimentMarkerStyle) // Create calculated data series using the model's own color var calculatedSerie = analysisChartView.createSeries(ChartView.SeriesTypeLine, @@ -310,12 +346,12 @@ Rectangle { updateMultiExperimentSeries() } else if (useLogQAxis) { measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false calculated.visible = false - var newMeasured = analysisChartView.createSeries(ChartView.SeriesTypeLine, "measured_log", analysisAxisXLog, analysisChartView.axisY) - newMeasured.color = measured.color - newMeasured.width = measured.width - newMeasured.useOpenGL = analysisChartView.useOpenGL + var newMeasured = MeasuredScatter.create(analysisChartView, ChartView, ScatterSeries, + "measured_log", analysisAxisXLog, analysisChartView.axisY, + measured.color, Globals.Variables.experimentMarkerStyle) var newCalculated = analysisChartView.createSeries(ChartView.SeriesTypeLine, "calculated_log", analysisAxisXLog, analysisChartView.axisY) newCalculated.color = calculated.color @@ -331,10 +367,16 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', newCalculated) Globals.BackendWrapper.plottingRefreshAnalysis() } else { - measured.visible = true + // Single experiment, linear mode: restore scatter series + measured.visible = false + if (!measuredScatterSerie) { + console.warn("CombinedView.recreateForLogMode: measuredScatterSerie is null - linear mode will render no measured points") + } else { + measuredScatterSerie.visible = true + } calculated.visible = true - Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measured) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) Globals.BackendWrapper.plottingRefreshAnalysis() } @@ -550,10 +592,21 @@ Rectangle { // Data is set in python backend (plotting_1d.py) Component.onCompleted: { + // Create scatter series for measured data (single experiment, linear mode) + measuredScatterSerie = MeasuredScatter.create(analysisChartView, ChartView, ScatterSeries, + "measured_scatter", + analysisChartView.axisX, analysisChartView.axisY, + measured.color, Globals.Variables.experimentMarkerStyle) + if (!measuredScatterSerie) { + console.warn("CombinedView: failed to create measuredScatterSerie - measured data will not render") + } + measured.visible = false + Globals.References.pages.analysis.mainContent.analysisView = analysisChartView + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', - measured) + measuredScatterSerie) Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'calculatedSerie', calculated) @@ -565,6 +618,31 @@ Rectangle { // Initialize reference lines updateReferenceLines() } + + function recreateSeriesForCurrentMode() { + if (isMultiExperimentMode) { + // Multi-experiment mode: recreate all multi-experiment series + updateMultiExperimentSeries() + } else if (useLogQAxis) { + // Single experiment, log mode: recreate log mode series + recreateForLogMode() + } else { + // Single experiment, linear mode: recreate scatter series + if (measuredScatterSerie) { + analysisChartView.removeSeries(measuredScatterSerie) + measuredScatterSerie = null + } + measuredScatterSerie = MeasuredScatter.create(analysisChartView, ChartView, ScatterSeries, + "measured_scatter", + analysisChartView.axisX, analysisChartView.axisY, + measured.color, Globals.Variables.experimentMarkerStyle) + if (measuredScatterSerie) { + measuredScatterSerie.visible = true + Globals.BackendWrapper.plottingSetQtChartsSerieRef('analysisPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingRefreshAnalysis() + } + } + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 04e5f41f..8d30a1bf 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -12,6 +12,7 @@ import EasyApp.Gui.Elements as EaElements import EasyApp.Gui.Charts as EaCharts import Gui.Globals as Globals +import "../../../Logic/MeasuredScatter.js" as MeasuredScatter Rectangle { @@ -34,6 +35,9 @@ Rectangle { measSerie.width: 1 bkgSerie.width: 1 + // Keep scatter series color in sync with selected experiment + onCurrentExperimentColorChanged: MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 useOpenGL: EaGlobals.Vars.useOpenGL @@ -71,10 +75,42 @@ Rectangle { } } - function updateReferenceLines() { - Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, false) + // Recreate series when marker style changes + Connections { + target: Globals.Variables + function onExperimentMarkerStyleChanged() { + chartView.recreateSeriesForCurrentMode() + } + } + + function recreateSeriesForCurrentMode() { + if (isMultiExperimentMode) { + // Multi-experiment mode: recreate all multi-experiment series + updateMultiExperimentSeries() + } else if (useLogQAxis) { + // Single experiment, log mode: recreate log mode series + recreateForLogMode() + } else { + // Single experiment, linear mode: recreate scatter series + if (measuredScatterSerie) { + chartView.removeSeries(measuredScatterSerie) + measuredScatterSerie = null + } + measuredScatterSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + "measured_scatter", + chartView.axisX, chartView.axisY, + currentExperimentColor, Globals.Variables.experimentMarkerStyle) + if (measuredScatterSerie) { + measuredScatterSerie.visible = true + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingRefreshExperiment() + } + } } + // Scatter series for measured data (single experiment, linear mode) + property var measuredScatterSerie: null + // Multi-experiment support property var multiExperimentSeries: [] property bool isMultiExperimentMode: { @@ -267,13 +303,13 @@ Rectangle { } else if (useLogQAxis) { // Single experiment, log mode: create dynamic series on log axis measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false errorUpper.visible = false errorLower.visible = false - var newMeasured = chartView.createSeries(ChartView.SeriesTypeLine, "measured_log", axisXLog, chartView.axisY) - newMeasured.color = chartView.currentExperimentColor - newMeasured.width = measured.width - newMeasured.useOpenGL = chartView.useOpenGL + var newMeasured = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + "measured_log", axisXLog, chartView.axisY, + chartView.currentExperimentColor, Globals.Variables.experimentMarkerStyle) var newErrorUpper = chartView.createSeries(ChartView.SeriesTypeLine, "errorUpper_log", axisXLog, chartView.axisY) newErrorUpper.color = errorUpper.color @@ -297,13 +333,18 @@ Rectangle { Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorLowerSerie', newErrorLower) Globals.BackendWrapper.plottingRefreshExperiment() } else { - // Single experiment, linear mode: restore static series - measured.visible = true + // Single experiment, linear mode: restore scatter series + measured.visible = false + if (!measuredScatterSerie) { + console.warn("ExperimentView.recreateForLogMode: measuredScatterSerie is null - linear mode will render no measured points") + } else { + measuredScatterSerie.visible = true + } errorUpper.visible = true errorLower.visible = true - // Re-register static series - Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measured) + // Re-register scatter series for measured, static series for errors + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measuredScatterSerie) Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorUpperSerie', errorUpper) Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorLowerSerie', errorLower) Globals.BackendWrapper.plottingRefreshExperiment() @@ -351,9 +392,21 @@ Rectangle { if (!isMultiExperimentMode) { // Show default series for single experiment - measured.visible = true + measured.visible = false + if (!measuredScatterSerie) { + console.warn("ExperimentView.updateMultiExperimentSeries: measuredScatterSerie is null - single mode will render no measured points") + } else { + measuredScatterSerie.visible = true + MeasuredScatter.setColor(measuredScatterSerie, currentExperimentColor) + } errorUpper.visible = true errorLower.visible = true + + // Re-register scatter series and refresh data + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorUpperSerie', errorUpper) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorLowerSerie', errorLower) + Globals.BackendWrapper.plottingRefreshExperiment() return } @@ -362,14 +415,27 @@ Rectangle { // 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 + measured.visible = false + if (!measuredScatterSerie) { + console.warn("ExperimentView.updateMultiExperimentSeries: measuredScatterSerie is null - no-data fallback will render no measured points") + } else { + measuredScatterSerie.visible = true + } errorUpper.visible = true errorLower.visible = true + + // Re-register and refresh so this branch matches the + // single-experiment branch above. + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', measuredScatterSerie) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorUpperSerie', errorUpper) + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorLowerSerie', errorLower) + Globals.BackendWrapper.plottingRefreshExperiment() return } // Hide default series in multi-experiment mode (only after we have data) measured.visible = false + if (measuredScatterSerie) measuredScatterSerie.visible = false errorUpper.visible = false errorLower.visible = false @@ -404,14 +470,11 @@ Rectangle { var xAxis = currentXAxis() - // Create measured data series - var measuredSerie = chartView.createSeries(ChartView.SeriesTypeLine, - `${expName} - Data`, - xAxis, chartView.axisY) - measuredSerie.color = color - measuredSerie.width = 2 - measuredSerie.capStyle = Qt.RoundCap - measuredSerie.useOpenGL = chartView.useOpenGL + // Create measured data series (scatter points) + var measuredSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + `${expName} - Data`, + xAxis, chartView.axisY, + color, Globals.Variables.experimentMarkerStyle) // Create error bound series (lighter colors) var errorColor = Qt.darker(color, 1.3) @@ -658,7 +721,18 @@ Rectangle { // Data is set in python backend (plotting_1d.py) Component.onCompleted: { + // Create scatter series for measured data (single experiment, linear mode) + measuredScatterSerie = MeasuredScatter.create(chartView, ChartView, ScatterSeries, + "measured_scatter", + chartView.axisX, chartView.axisY, + currentExperimentColor, Globals.Variables.experimentMarkerStyle) + if (!measuredScatterSerie) { + console.warn("ExperimentView: failed to create measuredScatterSerie - measured data will not render") + } + measured.visible = false + Globals.References.pages.experiment.mainContent.experimentView = chartView + Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'errorUpperSerie', errorUpper) @@ -667,7 +741,7 @@ Rectangle { errorLower) Globals.BackendWrapper.plottingSetQtChartsSerieRef('experimentPage', 'measuredSerie', - measured) + measuredScatterSerie) // Initialize multi-experiment support // console.log("ExperimentView initialized - checking multi-experiment mode...") diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml index eec8e7fc..1a3fd816 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Advanced/Groups/PlotControl.qml @@ -56,6 +56,34 @@ EaElements.GroupBox { Globals.BackendWrapper.plottingFlipBkgShown() } } + + EaElements.Label { + text: qsTr("Marker style") + } + + EaElements.ComboBox { + model: ListModel { + ListElement { text: qsTr("Dots"); value: 0 } + ListElement { text: qsTr("Circles"); value: 1 } + ListElement { text: qsTr("Line"); value: 2 } + } + currentIndex: { + var val = Globals.Variables.experimentMarkerStyle + for (var i = 0; i < model.count; ++i) { + if (model.get(i).value === val) { + return i + } + } + return 0 // default to dots + } + onCurrentIndexChanged: { + if (currentIndex >= 0 && currentIndex < model.count) { + Globals.Variables.experimentMarkerStyle = model.get(currentIndex).value + } + } + textRole: "text" + valueRole: "value" + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml index 361f088b..dcb1228a 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/Sidebar/Basic/Groups/ExperimentalDataExplorer.qml @@ -349,16 +349,16 @@ EaElements.GroupBox { if (selectedExperimentIndices.length === 0) { return } - - // If only one experiment is selected, use the existing single-selection logic + + // Always notify backend of the current selection (single or multi) + Globals.BackendWrapper.analysisSetSelectedExperimentIndices(selectedExperimentIndices) + 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) @@ -372,50 +372,14 @@ EaElements.GroupBox { } 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)) - } + Globals.BackendWrapper.analysisSetExperimentsCurrentIndex(selectedExperimentIndices[0]) } } 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([]) - } + Globals.BackendWrapper.analysisSetSelectedExperimentIndices([]) } function selectAllExperiments() { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml index 8697204f..4edb0679 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/MultiLayer.qml @@ -57,11 +57,12 @@ EaElements.GroupColumn { } EaComponents.TableViewComboBox{ + readonly property int rowIndex: index property string currentAssemblyName: Globals.BackendWrapper.sampleCurrentAssemblyName horizontalAlignment: Text.AlignLeft model: Globals.BackendWrapper.sampleMaterialNames - onActivated: { - Globals.BackendWrapper.sampleSetCurrentLayerMaterial(currentIndex) + onActivated: function(comboIndex) { + Globals.BackendWrapper.sampleSetLayerMaterialAtIndex(rowIndex, comboIndex) } onModelChanged: { currentIndex = indexOfValue(Globals.BackendWrapper.sampleLayers[index].material) @@ -79,7 +80,7 @@ EaElements.GroupColumn { enabled: Globals.BackendWrapper.sampleLayers[index].thickness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].thickness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].thickness).toFixed(2) onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerThickness(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerThicknessAtIndex(index, text) } EaComponents.TableViewTextInput { @@ -87,7 +88,7 @@ EaElements.GroupColumn { enabled: Globals.BackendWrapper.sampleLayers[index].roughness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].roughness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].roughness).toFixed(2) onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerRoughness(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerRoughnessAtIndex(index, text) } EaComponents.TableViewButton { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml index 231e02f1..50b50790 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/Assemblies/SurfactantLayer.qml @@ -61,7 +61,7 @@ EaElements.GroupColumn { horizontalAlignment: Text.AlignHCenter text: Globals.BackendWrapper.sampleLayers[index].formula onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerFormula(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerFormulaAtIndex(index, text) } EaComponents.TableViewTextInput { @@ -69,7 +69,7 @@ EaElements.GroupColumn { enabled: Globals.BackendWrapper.sampleLayers[index].thickness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].thickness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].thickness).toFixed(2) onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerThickness(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerThicknessAtIndex(index, text) } EaComponents.TableViewTextInput { @@ -77,14 +77,14 @@ EaElements.GroupColumn { enabled: Globals.BackendWrapper.sampleLayers[index].roughness_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].roughness)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].roughness).toFixed(2) onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerRoughness(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerRoughnessAtIndex(index, text) } EaComponents.TableViewTextInput { horizontalAlignment: Text.AlignHCenter text: (isNaN(Globals.BackendWrapper.sampleLayers[index].solvation)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].solvation).toFixed(2) onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerSolvation(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerSolvationAtIndex(index, text) } EaComponents.TableViewTextInput { @@ -92,15 +92,16 @@ EaElements.GroupColumn { enabled: Globals.BackendWrapper.sampleLayers[index].apm_enabled === "True" text: (isNaN(Globals.BackendWrapper.sampleLayers[index].apm)) ? '--' : Number(Globals.BackendWrapper.sampleLayers[index].apm).toFixed(2) onActiveFocusChanged: if (activeFocus && Globals.BackendWrapper.sampleCurrentLayerIndex !== index) Globals.BackendWrapper.sampleSetCurrentLayerIndex(index) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentLayerAPM(text) + onEditingFinished: Globals.BackendWrapper.sampleSetLayerAPMAtIndex(index, text) } EaComponents.TableViewComboBox{ + readonly property int rowIndex: index property string currentAssemblyName: Globals.BackendWrapper.sampleCurrentAssemblyName horizontalAlignment: Text.AlignLeft model: Globals.BackendWrapper.sampleMaterialNames - onActivated: { - Globals.BackendWrapper.sampleSetCurrentLayerSolvent(currentIndex) + onActivated: function(comboIndex) { + Globals.BackendWrapper.sampleSetLayerSolventAtIndex(rowIndex, comboIndex) } onModelChanged: { currentIndex = indexOfValue(Globals.BackendWrapper.sampleLayers[index].solvent) diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/AssemblyEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/AssemblyEditor.qml deleted file mode 100644 index 99a033b3..00000000 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/AssemblyEditor.qml +++ /dev/null @@ -1,29 +0,0 @@ -import QtQuick -import QtQuick.Controls - -import EasyApp.Gui.Elements as EaElements - -import Gui.Globals as Globals -import "./Assemblies" as Assemblies - -EaElements.GroupBox { - title: qsTr("Layer editor: " + Globals.BackendWrapper.sampleCurrentAssemblyName) - collapsible: true - collapsed: false - property string currentAssemblyType: Globals.BackendWrapper.sampleCurrentAssemblyType - - EaElements.GroupColumn { - - Assemblies.MultiLayer{ - visible: (currentAssemblyType == 'Multi-layer') ? true : false - } - - Assemblies.RepeatingMultiLayer{ - visible: (currentAssemblyType == 'Repeating Multi-layer') ? true : false - } - - Assemblies.SurfactantLayer { - visible: (currentAssemblyType == 'Surfactant Layer') ? true : false - } - } -} diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml index dab516b2..fa9af094 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/MaterialEditor.qml @@ -10,7 +10,7 @@ import Gui.Globals as Globals EaElements.GroupBox { title: qsTr("Material editor") collapsible: true - collapsed: false + collapsed: true EaElements.GroupColumn { @@ -63,17 +63,17 @@ EaElements.GroupBox { EaComponents.TableViewTextInput { text: Globals.BackendWrapper.sampleMaterials[index].label - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentMaterialName(text) + onEditingFinished: Globals.BackendWrapper.sampleSetMaterialNameAtIndex(index, text) } EaComponents.TableViewTextInput { text: Number(Globals.BackendWrapper.sampleMaterials[index].sld).toFixed(3) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentMaterialSld(text) + onEditingFinished: Globals.BackendWrapper.sampleSetMaterialSldAtIndex(index, text) } EaComponents.TableViewTextInput { text: Number(Globals.BackendWrapper.sampleMaterials[index].isld).toFixed(3) - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentMaterialISld(text) + onEditingFinished: Globals.BackendWrapper.sampleSetMaterialISldAtIndex(index, text) } EaComponents.TableViewButton { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml index 4f74fe0f..a10b6314 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelEditor.qml @@ -6,11 +6,14 @@ import EasyApp.Gui.Elements as EaElements import EasyApp.Gui.Components as EaComponents import Gui.Globals as Globals +import "./Assemblies" as Assemblies EaElements.GroupBox { title: qsTr("Model editor: " + Globals.BackendWrapper.sampleCurrentModelName) collapsible: true - collapsed: false + collapsed: true + + property string currentAssemblyType: Globals.BackendWrapper.sampleCurrentAssemblyType EaElements.GroupColumn { @@ -61,16 +64,17 @@ EaElements.GroupBox { EaComponents.TableViewTextInput { horizontalAlignment: Text.AlignLeft text: Globals.BackendWrapper.sampleAssemblies[index].label - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentAssemblyName(text) + onEditingFinished: Globals.BackendWrapper.sampleSetAssemblyNameAtIndex(index, text) } EaComponents.TableViewComboBox{ + readonly property int rowIndex: index horizontalAlignment: Text.AlignLeft property var fullModel: ["Multi-layer", "Repeating Multi-layer", "Surfactant Layer"] property var limitedModel: ["Multi-layer", "Repeating Multi-layer"] model: index === 0 || index === assembliesView.model - 1 ? limitedModel : fullModel - onActivated: { - Globals.BackendWrapper.sampleSetCurrentAssemblyType(currentValue) + onActivated: function(comboIndex) { + Globals.BackendWrapper.sampleSetAssemblyTypeAtIndex(rowIndex, model[comboIndex]) } Component.onCompleted: { currentIndex = indexOfValue(Globals.BackendWrapper.sampleAssemblies[index].type) @@ -115,7 +119,7 @@ EaElements.GroupBox { } EaElements.SideBarButton { - enabled: (Globals.BackendWrapper.sampleCurrentAssemblyIndex !== 0 && Globals.BackendWrapper.sampleAssemblies.length > 0 ) ? true : false//When item is selected + enabled: (Globals.BackendWrapper.sampleCurrentAssemblyIndex > 1 && Globals.BackendWrapper.sampleCurrentAssemblyIndex < Globals.BackendWrapper.sampleAssemblies.length - 1 && Globals.BackendWrapper.sampleAssemblies.length > 0) ? true : false width: EaStyle.Sizes.tableRowHeight fontIcon: "arrow-up" ToolTip.text: qsTr("Move assembly up") @@ -123,12 +127,30 @@ EaElements.GroupBox { } EaElements.SideBarButton { - enabled: (Globals.BackendWrapper.sampleCurrentAssemblyIndex + 1 !== Globals.BackendWrapper.sampleAssemblies.length && Globals.BackendWrapper.sampleAssemblies.length > 0 ) ? true : false//When item is selected + enabled: (Globals.BackendWrapper.sampleCurrentAssemblyIndex > 0 && Globals.BackendWrapper.sampleCurrentAssemblyIndex < Globals.BackendWrapper.sampleAssemblies.length - 2 && Globals.BackendWrapper.sampleAssemblies.length > 0) ? true : false width: EaStyle.Sizes.tableRowHeight fontIcon: "arrow-down" ToolTip.text: qsTr("Move assembly down") onClicked: Globals.BackendWrapper.sampleMoveSelectedAssemblyDown() } } + + // Layer editor + EaElements.Label { + text: qsTr("Layer editor: " + Globals.BackendWrapper.sampleCurrentAssemblyName) + font.bold: true + } + + Assemblies.MultiLayer { + visible: currentAssemblyType === 'Multi-layer' + } + + Assemblies.RepeatingMultiLayer { + visible: currentAssemblyType === 'Repeating Multi-layer' + } + + Assemblies.SurfactantLayer { + visible: currentAssemblyType === 'Surfactant Layer' + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelSelector.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelSelector.qml index 20a7e792..45b630b5 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelSelector.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Groups/ModelSelector.qml @@ -54,7 +54,7 @@ EaElements.GroupBox { EaComponents.TableViewTextInput { horizontalAlignment: Text.AlignLeft text: Globals.BackendWrapper.sampleModels[index].label - onEditingFinished: Globals.BackendWrapper.sampleSetCurrentModelName(text) + onEditingFinished: Globals.BackendWrapper.sampleSetModelNameAtIndex(index, text) } EaComponents.TableViewButton { diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml index 7edf1a91..33320963 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Basic/Layout.qml @@ -8,20 +8,20 @@ import "./Groups" as Groups EaComponents.SideBarColumn { Groups.LoadSample{ + collapsed: false enabled: Globals.BackendWrapper.analysisIsFitFinished } Groups.MaterialEditor{ + collapsed: true enabled: Globals.BackendWrapper.analysisIsFitFinished } Groups.ModelSelector{ + collapsed: true enabled: Globals.BackendWrapper.analysisIsFitFinished } Groups.ModelEditor { id: modelEditor - enabled: Globals.BackendWrapper.analysisIsFitFinished - } - Groups.AssemblyEditor{ - id: assemblyEditor + collapsed: true enabled: Globals.BackendWrapper.analysisIsFitFinished } } diff --git a/tests/test_logic_assemblies.py b/tests/test_logic_assemblies.py index 2926bc25..09b77105 100644 --- a/tests/test_logic_assemblies.py +++ b/tests/test_logic_assemblies.py @@ -59,6 +59,73 @@ def test_assemblies_add_new_and_type_transitions(monkeypatch): assert logic.set_conformal_roughness('True') is True +def test_assemblies_add_new_inserts_after_current_row_and_keeps_subphase_last(monkeypatch): + monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + + materials = make_material_collection(make_material('Air'), make_material('Si'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Superphase', layers=[make_layer(name='Top Layer', material=materials[0])]), + make_assembly(name='Middle', layers=[make_layer(name='Middle Layer', material=materials[0])]), + make_assembly(name='Subphase', layers=[make_layer(name='Bottom Layer', material=materials[1])]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + project.current_assembly_index = 1 + logic = assemblies_module.Assemblies(project) + + logic.add_new() + + assert [assembly.name for assembly in logic._assemblies] == ['Superphase', 'Middle', 'Assembly 4', 'Subphase'] + assert logic._assemblies[2].layers[0].material.name == 'Si' + + +def test_assemblies_duplicate_selected_inserts_after_current_row_and_keeps_subphase_last(monkeypatch): + monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + + sample = make_sample( + make_assembly(name='Superphase', layers=[make_layer(name='Top Layer')]), + make_assembly(name='Middle', layers=[make_layer(name='Middle Layer')]), + make_assembly(name='Subphase', layers=[make_layer(name='Bottom Layer')]), + ) + model = make_model(sample=sample) + project = make_project(models=make_model_collection(model)) + project.current_assembly_index = 1 + logic = assemblies_module.Assemblies(project) + + logic.duplicate_selected() + + assert [assembly.name for assembly in logic._assemblies] == ['Superphase', 'Middle', 'Middle', 'Subphase'] + + +def test_assemblies_add_and_duplicate_selected_subphase_insert_before_last(monkeypatch): + monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + + materials = make_material_collection(make_material('Air'), make_material('Si'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Superphase', layers=[make_layer(name='Top Layer', material=materials[0])]), + make_assembly(name='Middle', layers=[make_layer(name='Middle Layer', material=materials[0])]), + make_assembly(name='Subphase', layers=[make_layer(name='Bottom Layer', material=materials[1])]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + project.current_assembly_index = 2 + logic = assemblies_module.Assemblies(project) + + logic.add_new() + assert [assembly.name for assembly in logic._assemblies] == ['Superphase', 'Middle', 'Assembly 4', 'Subphase'] + assert logic._assemblies[-1].name == 'Subphase' + + logic.duplicate_selected() + assert [assembly.name for assembly in logic._assemblies] == ['Superphase', 'Middle', 'Assembly 4', 'Assembly 4', 'Subphase'] + assert logic._assemblies[-1].name == 'Subphase' + + def test_assemblies_duplicate_move_and_remove(monkeypatch): monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) @@ -79,3 +146,52 @@ def test_assemblies_duplicate_move_and_remove(monkeypatch): logic.remove_at_index('2') assert len(logic._assemblies) == 2 + + +def test_assemblies_set_type_at_index_updates_target_row_when_current_index_differs(monkeypatch): + monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + + materials = make_material_collection(make_material('Air'), make_material('Si'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top Layer', material=materials[0])]), + make_assembly(name='Middle', layers=[make_layer(name='Middle Layer', material=materials[1])]), + make_assembly(name='Bottom', layers=[make_layer(name='Bottom Layer', material=materials[1])]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + project.current_assembly_index = 2 + logic = assemblies_module.Assemblies(project) + + assert logic.set_type_at_index(1, 'Surfactant Layer') is True + + assert isinstance(logic._assemblies[1], FakeSurfactantLayer) + assert logic._assemblies[1].layers[0].solvent.name == 'Air' + assert logic._assemblies[1].layers[1].solvent.name == 'D2O' + + assert isinstance(logic._assemblies[2], FakeMultilayer) + assert logic._assemblies[2].name == 'Bottom' + assert project.current_assembly_index == 2 + + +def test_assemblies_index_based_setters_ignore_invalid_indices(monkeypatch): + monkeypatch.setattr(assemblies_module, 'Multilayer', FakeMultilayer) + monkeypatch.setattr(assemblies_module, 'RepeatingMultilayer', FakeRepeatingMultilayer) + monkeypatch.setattr(assemblies_module, 'SurfactantLayer', FakeSurfactantLayer) + + materials = make_material_collection(make_material('Air'), make_material('Si'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top Layer', material=materials[0])]), + make_assembly(name='Middle', layers=[make_layer(name='Middle Layer', material=materials[1])]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + logic = assemblies_module.Assemblies(project) + + assert logic.set_name_at_index(5, 'Ignored') is False + assert logic.set_type_at_index(4, 'Surfactant Layer') is False + assert logic.set_type_at_index(0, 'Unsupported') is False + + assert logic._assemblies[0].name == 'Top' + assert isinstance(logic._assemblies[0], FakeMultilayer) diff --git a/tests/test_logic_layers.py b/tests/test_logic_layers.py index ff69bcb7..aa2c8617 100644 --- a/tests/test_logic_layers.py +++ b/tests/test_logic_layers.py @@ -99,3 +99,91 @@ def test_layers_move_duplicate_and_setters_update_current_layer(monkeypatch): logic.remove_at_index('2') assert len(logic._layers) == 2 + + +def test_layers_index_based_setters_update_target_row_even_when_current_index_differs(monkeypatch): + monkeypatch.setattr(layers_module, 'Material', make_material) + monkeypatch.setattr(layers_module, 'LayerAreaPerMolecule', FakeLayerAreaPerMolecule) + + air = make_material('Air') + si = make_material('Si') + d2o = make_material('D2O') + materials = make_material_collection(air, si, d2o) + + surfactant_layer = FakeLayerAreaPerMolecule( + name='Headgroup', + material=air, + thickness=11.0, + roughness=2.0, + solvent=d2o, + area_per_molecule=44.0, + solvent_fraction=0.35, + molecular_formula='C12H25', + ) + + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top Layer', material=air)]), + make_assembly( + name='Middle', + layers=[ + make_layer(name='Layer A', material=air, thickness=10.0, roughness=1.0), + surfactant_layer, + ], + ), + make_assembly(name='Bottom', layers=[make_layer(name='Bottom Layer', material=si)]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + project.current_assembly_index = 1 + project.current_layer_index = 1 + logic = layers_module.Layers(project) + + assert logic.set_thickness_at_index(0, 15.0) is True + assert logic._layers[0].thickness.value == 15.0 + assert logic._layers[1].thickness.value == 11.0 + + assert logic.set_roughness_at_index(0, 3.5) is True + assert logic._layers[0].roughness.value == 3.5 + assert logic._layers[1].roughness.value == 2.0 + + assert logic.set_material_at_index(0, 1) is True + assert logic._layers[0].material.name == 'Si' + assert logic._layers[0].name == 'Si Layer' + assert logic._layers[1].material.name == 'Air' + + assert logic.set_formula_at_index(1, 'C10H21') is True + assert logic._layers[1].molecular_formula == 'C10H21' + + assert logic.set_solvation_at_index(1, 0.5) is True + assert logic._layers[1].solvent_fraction == 0.5 + + assert logic.set_apm_at_index(1, 55.0) is True + assert logic._layers[1].area_per_molecule == 55.0 + + assert logic.set_solvent_at_index(1, 0) is True + assert logic._layers[1].solvent.name == 'Air' + + +def test_layers_index_based_setters_ignore_invalid_indices(monkeypatch): + monkeypatch.setattr(layers_module, 'Material', make_material) + + air = make_material('Air') + si = make_material('Si') + materials = make_material_collection(air, si) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top Layer', material=air)]), + make_assembly(name='Middle', layers=[make_layer(name='Layer A', material=air, thickness=10.0, roughness=1.0)]), + make_assembly(name='Bottom', layers=[make_layer(name='Bottom Layer', material=si)]), + ) + model = make_model(sample=sample) + project = make_project(materials=materials, models=make_model_collection(model)) + project.current_assembly_index = 1 + logic = layers_module.Layers(project) + + assert logic.set_material_at_index(3, 1) is False + assert logic.set_material_at_index(0, 5) is False + assert logic.set_solvent_at_index(2, 0) is False + assert logic.set_thickness_at_index(4, 12.0) is False + + assert logic._layers[0].material.name == 'Air' + assert logic._layers[0].thickness.value == 10.0 diff --git a/tests/test_logic_summary.py b/tests/test_logic_summary.py index 8cc40e13..b721baee 100644 --- a/tests/test_logic_summary.py +++ b/tests/test_logic_summary.py @@ -129,10 +129,10 @@ def test_summary_html_and_save_operations(tmp_path, monkeypatch): logic = summary_module.Summary(project) html = logic.as_html - assert 'All Samples' in html - assert 'All Experiments' in html - assert 'Model <1>' in html - assert 'Exp <1>' in html + # assert 'All Samples' in html + # assert 'All Experiments' in html + # assert 'Model <1>' in html + # assert 'Exp <1>' in html logic.save_as_html() html_path = project.path / 'summary.html' @@ -190,7 +190,7 @@ def test_summary_injection_and_explicit_paths(tmp_path, monkeypatch): injected = logic._inject_multimodel_multiexperiment_sections('
base
') - assert 'All Samples' in injected + # assert 'All Samples' in injected assert 'All Experiments' in injected assert logic.file_path == project.path / 'custom-summary' assert logic.plot_file_path == project.path / 'custom-plots' diff --git a/tests/test_py_sample.py b/tests/test_py_sample.py new file mode 100644 index 00000000..3570c366 --- /dev/null +++ b/tests/test_py_sample.py @@ -0,0 +1,42 @@ +from EasyReflectometryApp.Backends.Py.sample import Sample +from tests.factories import make_assembly +from tests.factories import make_layer +from tests.factories import make_material +from tests.factories import make_material_collection +from tests.factories import make_model +from tests.factories import make_model_collection +from tests.factories import make_project +from tests.factories import make_sample + + +def test_remove_selected_assembly_refreshes_cached_layers_and_clamps_layer_index(qcore_application): + materials = make_material_collection(make_material('Air'), make_material('Si'), make_material('D2O')) + sample = make_sample( + make_assembly(name='Top', layers=[make_layer(name='Top Layer', material=materials[0])]), + make_assembly( + name='Middle', + assembly_type='Surfactant Layer', + layers=[ + make_layer(name='Head Layer', material=materials[0]), + make_layer(name='Tail Layer', material=materials[2]), + ], + ), + make_assembly(name='Bottom', layers=[make_layer(name='Bottom Layer', material=materials[1])]), + ) + project = make_project(materials=materials, models=make_model_collection(make_model(sample=sample))) + project.current_model_index = 0 + project.current_assembly_index = 1 + project.current_layer_index = 1 + + backend = Sample(project) + + assert [layer['label'] for layer in backend.layers] == ['Head Layer', 'Tail Layer'] + assert backend.currentAssemblyType == 'Surfactant Layer' + assert backend.currentLayerIndex == 1 + + backend.removeAssembly('1') + + assert backend.currentAssemblyIndex == 1 + assert backend.currentAssemblyType == 'Multi-layer' + assert backend.currentLayerIndex == 0 + assert [layer['label'] for layer in backend.layers] == ['Bottom Layer'] \ No newline at end of file From 35435042b64c54d235c7c794672820f43bb7a013 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 22 Apr 2026 15:23:25 +0200 Subject: [PATCH 17/18] properly scaled residuals display --- .../Backends/Py/plotting_1d.py | 26 +++++++++++++++---- tests/test_py_plotting_1d.py | 19 ++++++++++++-- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 84a5039e..564ae31b 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -463,9 +463,11 @@ def _invalidate_residual_range_cache(self): def _get_residual_range(self) -> tuple: """Return (min_x, max_x, min_y, max_y) for the residual chart. - X range matches the filtered analysis domain. Y range is computed - from residual values across all currently selected experiments, with - a 10 % margin. Safe fallback values are returned when data is empty. + X range matches the full analysis chart domain so residuals line up + vertically with the reflectivity chart above, even when an experiment + covers only part of the model q-range. Y range is computed from + residual values across all currently selected experiments, with a + 10 % margin. Safe fallback values are returned when data is empty. The result is cached until invalidated by ``_invalidate_residual_range_cache``. """ @@ -475,6 +477,14 @@ def _get_residual_range(self) -> tuple: min_x, max_x = float('inf'), float('-inf') min_y, max_y = float('inf'), float('-inf') + try: + analysis_min_x, analysis_max_x = self._get_all_models_sample_range()[0:2] + if analysis_min_x != float('inf') and analysis_max_x != float('-inf'): + min_x = analysis_min_x + max_x = analysis_max_x + except Exception as e: + console.debug(f'Error getting analysis x range for residuals: {e}') + try: indices = [] if self.is_multi_experiment_mode: @@ -496,8 +506,14 @@ def _get_residual_range(self) -> tuple: residual = (calc - meas) / meas else: residual = calc - meas - min_x = min(min_x, q) - max_x = max(max_x, q) + if min_x == float('inf'): + min_x = q + else: + min_x = min(min_x, q) + if max_x == float('-inf'): + max_x = q + else: + max_x = max(max_x, q) min_y = min(min_y, residual) max_y = max(max_y, residual) except Exception as e: diff --git a/tests/test_py_plotting_1d.py b/tests/test_py_plotting_1d.py index 6d5bb1dc..6ec6b4d3 100644 --- a/tests/test_py_plotting_1d.py +++ b/tests/test_py_plotting_1d.py @@ -241,6 +241,7 @@ def _make_project_stub(q, r_exp, r_calc, q_min=0.0, q_max=1.0, ye=None): proj.current_model_index = 0 proj.experimental_data_for_model_at_index.return_value = _make_exp_data_stub(q, r_exp, ye) proj.model_data_for_model_at_index.return_value = _DataSet1DStub(name='calc', x=q, y=r_calc) + proj.sample_data_for_model_at_index.return_value = _DataSet1DStub(name='sample', x=np.array([q_min, q_max]), y=np.array([1.0, 1.0])) proj.models = [MagicMock()] return proj @@ -448,8 +449,22 @@ def test_x_range_matches_q_domain(self): p = _make_plotting_stub(proj) rng = p._get_residual_range() - assert pytest.approx(rng[0], rel=1e-6) == q.min() - assert pytest.approx(rng[1], rel=1e-6) == q.max() + assert pytest.approx(rng[0], rel=1e-6) == 0.0 + assert pytest.approx(rng[1], rel=1e-6) == 1.0 + + def test_x_range_uses_full_analysis_domain_when_experiment_is_subset(self): + q = np.array([0.10, 0.20, 0.30]) + r_exp = np.array([1e-1, 1e-2, 1e-3]) + r_calc = np.array([1.1e-1, 1.1e-2, 1.1e-3]) + proj = _make_project_stub(q, r_exp, r_calc, q_min=0.0, q_max=1.0) + p = _make_plotting_stub(proj) + + rng = p._get_residual_range() + residual_points = p.getResidualDataPoints(0) + + assert pytest.approx(rng[0], rel=1e-6) == 0.0 + assert pytest.approx(rng[1], rel=1e-6) == 1.0 + assert [point['x'] for point in residual_points] == pytest.approx([0.10, 0.20, 0.30], rel=1e-6) def test_y_range_has_margin(self): q = np.array([0.10]) From c58319e20cf7fcb00221b32e9057aaa77be7d072 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 23 Apr 2026 09:42:07 +0200 Subject: [PATCH 18/18] zero display for residuals, initialization errors fixed. --- .../Analysis/MainContent/CombinedView.qml | 22 +++++++++---------- .../Analysis/MainContent/ResidualsView.qml | 15 +++++++++---- .../Experiment/MainContent/ExperimentView.qml | 10 ++++++--- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index 9df0710e..a9d429b2 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -294,12 +294,12 @@ Rectangle { } } - property double xRange: Globals.BackendWrapper.plottingAnalysisMaxX - Globals.BackendWrapper.plottingAnalysisMinX + property double xRange: isNaN(Globals.BackendWrapper.plottingAnalysisMaxX) || isNaN(Globals.BackendWrapper.plottingAnalysisMinX) ? 1.0 : 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 + axisX.min: isNaN(Globals.BackendWrapper.plottingAnalysisMinX) ? 0.0 : Globals.BackendWrapper.plottingAnalysisMinX - xRange * 0.01 + axisX.max: isNaN(Globals.BackendWrapper.plottingAnalysisMaxX) ? 1.0 : Globals.BackendWrapper.plottingAnalysisMaxX + xRange * 0.01 + axisX.minAfterReset: isNaN(Globals.BackendWrapper.plottingAnalysisMinX) ? 0.0 : Globals.BackendWrapper.plottingAnalysisMinX - xRange * 0.01 + axisX.maxAfterReset: isNaN(Globals.BackendWrapper.plottingAnalysisMaxX) ? 1.0 : Globals.BackendWrapper.plottingAnalysisMaxX + xRange * 0.01 // Logarithmic axis control property bool useLogQAxis: Globals.Variables.logarithmicQAxis @@ -403,12 +403,12 @@ Rectangle { } } - property double yRange: Globals.BackendWrapper.plottingAnalysisMaxY - Globals.BackendWrapper.plottingAnalysisMinY + property double yRange: isNaN(Globals.BackendWrapper.plottingAnalysisMaxY) || isNaN(Globals.BackendWrapper.plottingAnalysisMinY) ? 10.0 : Globals.BackendWrapper.plottingAnalysisMaxY - Globals.BackendWrapper.plottingAnalysisMinY axisY.title: "Log10 " + Globals.BackendWrapper.plottingYAxisTitle - axisY.min: Globals.BackendWrapper.plottingAnalysisMinY - yRange * 0.01 - axisY.max: Globals.BackendWrapper.plottingAnalysisMaxY + yRange * 0.01 - axisY.minAfterReset: Globals.BackendWrapper.plottingAnalysisMinY - yRange * 0.01 - axisY.maxAfterReset: Globals.BackendWrapper.plottingAnalysisMaxY + yRange * 0.01 + axisY.min: isNaN(Globals.BackendWrapper.plottingAnalysisMinY) ? -10.0 : Globals.BackendWrapper.plottingAnalysisMinY - yRange * 0.01 + axisY.max: isNaN(Globals.BackendWrapper.plottingAnalysisMaxY) ? 0.0 : Globals.BackendWrapper.plottingAnalysisMaxY + yRange * 0.01 + axisY.minAfterReset: isNaN(Globals.BackendWrapper.plottingAnalysisMinY) ? -10.0 : Globals.BackendWrapper.plottingAnalysisMinY - yRange * 0.01 + axisY.maxAfterReset: isNaN(Globals.BackendWrapper.plottingAnalysisMaxY) ? 0.0 : Globals.BackendWrapper.plottingAnalysisMaxY + yRange * 0.01 calcSerie.onHovered: (point, state) => showMainTooltip(analysisChartView, analysisDataToolTip, point, state) calcSerie.color: { @@ -419,7 +419,7 @@ Rectangle { return colors[idx] } - return undefined + return "#ff0000" } // Tool buttons diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml index 87781531..c849db47 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/ResidualsView.qml @@ -95,9 +95,16 @@ Rectangle { ValueAxis { id: axisY - titleText: "(Model − Experiment) / σ" - property double minAfterReset: Globals.BackendWrapper.plottingResidualMinY - chartView.yRange * 0.05 - property double maxAfterReset: Globals.BackendWrapper.plottingResidualMaxY + chartView.yRange * 0.05 + // titleText: "(Model − Experiment) / σ" + titleText: "(M-E)/σ" + property double minAfterReset: { + const maxAbs = Math.max(Math.abs(Globals.BackendWrapper.plottingResidualMinY), Math.abs(Globals.BackendWrapper.plottingResidualMaxY)) + return -maxAbs - maxAbs * 0.05 + } + property double maxAfterReset: { + const maxAbs = Math.max(Math.abs(Globals.BackendWrapper.plottingResidualMinY), Math.abs(Globals.BackendWrapper.plottingResidualMaxY)) + return maxAbs + maxAbs * 0.05 + } color: EaStyle.Colors.chartAxis gridLineColor: EaStyle.Colors.chartGridLine minorGridLineColor: EaStyle.Colors.chartMinorGridLine @@ -135,7 +142,7 @@ Rectangle { useOpenGL: EaGlobals.Vars.useOpenGL color: EaStyle.Colors.chartGridLine width: 1 - style: Qt.DashLine + style: Qt.DotLine Component.onCompleted: { // Span the full residual X domain diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 8d30a1bf..4964d8da 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -66,6 +66,10 @@ Rectangle { visible: Globals.BackendWrapper.plottingScaleShown } + function updateReferenceLines() { + Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, false) + } + // Update reference lines when visibility changes Connections { target: Globals.BackendWrapper.activeBackend?.plotting ?? null @@ -351,7 +355,7 @@ Rectangle { } // Update reference lines on correct axis - updateReferenceLines() + chartView.updateReferenceLines() Qt.callLater(resetAxes) } @@ -748,7 +752,7 @@ Rectangle { updateMultiExperimentSeries() // Initialize reference lines - updateReferenceLines() + chartView.updateReferenceLines() } // Update series when chart becomes visible @@ -757,7 +761,7 @@ Rectangle { updateMultiExperimentSeries() } if (visible) { - updateReferenceLines() + chartView.updateReferenceLines() } } }