diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml index c507480f..c398225b 100644 --- a/.github/workflows/installer.yml +++ b/.github/workflows/installer.yml @@ -111,26 +111,26 @@ jobs: # ${{ secrets.APPLE_CERT_DATA }} ${{ secrets.APPLE_CERT_PASSWORD }} # ${{ secrets.APPLE_NOTARY_USER }} ${{ secrets.APPLE_NOTARY_PASSWORD }} - - name: Setup SM_CLIENT_CERT_FILE from base64 secret data - if: runner.os == 'Windows' - run: | - echo "${{ secrets.KEYLOCKER_CERT_DATA }}" | base64 --decode > /d/Certificate_pkcs12.p12 - shell: bash + # - name: Setup SM_CLIENT_CERT_FILE from base64 secret data + # if: runner.os == 'Windows' + # run: | + # echo "${{ secrets.KEYLOCKER_CERT_DATA }}" | base64 --decode > /d/Certificate_pkcs12.p12 + # shell: bash - - 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: 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: > diff --git a/.gitignore b/.gitignore index 60af99fa..2e84e5b1 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ settings.ini* #Snap *.snap + +# Claude Code personal settings +.claude/settings.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4905b981..7284d3a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# Unreleased + +- Migrated to the new `easyscience` core API surface exposed by `reflectometry-lib`: + - Layer removal now calls `remove_at(index)`; the lib's index-based `remove` was replaced by standard `MutableSequence.remove(value)` semantics. + - Parameter discovery uses `get_all_parameters()`. +- **Breaking:** project files saved by earlier versions (predating `file_format=2`) can no longer be opened. Opening one now shows a clear error dialog instead of failing uncaught; affected projects must be recreated. + # Version 1.3.0 (1 May 2026) - Migrated the application to the new `EasyApplication` module and removed the old `EasyApp` footer dependency. diff --git a/EasyReflectometryApp/Backends/Mock/Analysis.qml b/EasyReflectometryApp/Backends/Mock/Analysis.qml index 00687c00..fbf8ace1 100644 --- a/EasyReflectometryApp/Backends/Mock/Analysis.qml +++ b/EasyReflectometryApp/Backends/Mock/Analysis.qml @@ -34,6 +34,28 @@ QtObject { readonly property var fitPreviewParameterValues: ({}) readonly property var fitResults: ({ success: true, nvarys: 3, chi2: 1.2345 }) + // Bayesian sampling + readonly property bool isBayesianSelected: false + readonly property int bayesianSamples: 10000 + readonly property int bayesianBurnIn: 2000 + readonly property int bayesianPopulation: 10 + readonly property int bayesianThinning: 1 + readonly property var bayesianPosterior: null + readonly property bool bayesianResultAvailable: false + readonly property var bayesianMarginals: [] + + // Phase 2: corner/trace/distribution plots (HTML), diagnostics, heatmap + readonly property string bayesianCornerPlotUrl: '' + readonly property string bayesianTracePlotUrl: '' + readonly property string bayesianDistributionPlotUrl: '' + readonly property var bayesianDiagnostics: ({}) + readonly property var bayesianParamNames: [] + readonly property var bayesianHeatmapData: null + readonly property string bayesianHeatmapPlotUrl: '' + function bayesianComputeHeatmap(x, y) { + console.debug(`bayesianComputeHeatmap ${x}, ${y}`) + } + // Fit failure signal (mirrors Python backend) signal fitFailed(string message) @@ -123,4 +145,16 @@ QtObject { showFitResultsDialog = value console.debug(`setShowFitResultsDialog ${value}`) } + function setBayesianSamples(value) { + console.debug(`setBayesianSamples ${value}`) + } + function setBayesianBurnIn(value) { + console.debug(`setBayesianBurnIn ${value}`) + } + function setBayesianPopulation(value) { + console.debug(`setBayesianPopulation ${value}`) + } + function setBayesianThinning(value) { + console.debug(`setBayesianThinning ${value}`) + } } diff --git a/EasyReflectometryApp/Backends/Mock/Plotting.qml b/EasyReflectometryApp/Backends/Mock/Plotting.qml index 9b55c586..b50bc353 100644 --- a/EasyReflectometryApp/Backends/Mock/Plotting.qml +++ b/EasyReflectometryApp/Backends/Mock/Plotting.qml @@ -37,6 +37,20 @@ QtObject { property bool scaleShown: false property bool bkgShown: false + // Posterior predictive (Bayesian) overlay data + readonly property var posteriorPredictiveQ: [] + readonly property var posteriorPredictiveMedian: [] + readonly property var posteriorPredictiveLower: [] + readonly property var posteriorPredictiveUpper: [] + signal posteriorPredictiveDataChanged() + + // Phase 2: SLD posterior predictive (Bayesian) overlay + readonly property var posteriorPredictiveSldZ: [] + readonly property var posteriorPredictiveSldMedian: [] + readonly property var posteriorPredictiveSldLower: [] + readonly property var posteriorPredictiveSldUpper: [] + signal posteriorPredictiveSldDataChanged() + // Signals for plot mode changes signal plotModeChanged() signal axisTypeChanged() diff --git a/EasyReflectometryApp/Backends/Mock/Status.qml b/EasyReflectometryApp/Backends/Mock/Status.qml index 30b1bb7d..191ef012 100644 --- a/EasyReflectometryApp/Backends/Mock/Status.qml +++ b/EasyReflectometryApp/Backends/Mock/Status.qml @@ -5,7 +5,7 @@ import QtQuick QtObject { readonly property string project: 'Undefined' - readonly property string phaseCount: '1' + readonly property string modelsCount: '1' readonly property string experimentsCount: '1' readonly property string calculator: 'CrysPy' readonly property string minimizer: 'Lmfit (leastsq)' diff --git a/EasyReflectometryApp/Backends/Py/analysis.py b/EasyReflectometryApp/Backends/Py/analysis.py index 7a738433..be21653d 100644 --- a/EasyReflectometryApp/Backends/Py/analysis.py +++ b/EasyReflectometryApp/Backends/Py/analysis.py @@ -1,3 +1,7 @@ +import logging +import os +import time + from typing import List from typing import Optional @@ -8,6 +12,7 @@ from PySide6.QtCore import Signal from PySide6.QtCore import Slot +from .logic.bayesian import Bayesian as BayesianLogic from .logic.calculators import Calculators as CalculatorsLogic from .logic.experiments import Experiments as ExperimentLogic from .logic.fitting import Fitting as FittingLogic @@ -16,6 +21,8 @@ from .logic.parameters import Parameters as ParametersLogic from .workers import FitterWorker +logger = logging.getLogger(__name__) + class Analysis(QObject): minimizerChanged = Signal() @@ -41,6 +48,8 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._calculators_logic = CalculatorsLogic(project_lib) self._experiments_logic = ExperimentLogic(project_lib) self._minimizers_logic = MinimizersLogic(project_lib) + self._bayesian_logic = BayesianLogic() + self._plotting = None # Set by PyBackend after construction self._chached_parameters = None self._chached_enabled_parameters = None # Thread management for background fitting @@ -60,6 +69,13 @@ def _initialize_selected_experiments(self) -> None: else: self._selected_experiment_indices = [] + def set_plotting(self, plotting) -> None: + """Store a reference to the Plotting1d instance for posterior predictive publishing. + + Called by PyBackend after construction. + """ + self._plotting = plotting + def _ordered_experiments(self) -> list: """Return experiments as an ordered list of experiment objects. @@ -155,6 +171,244 @@ def fitResults(self) -> dict: 'chi2': self._fitting_logic.fit_chi2, } + # ------------------------------------------------------------------ + # Bayesian sampling properties + # ------------------------------------------------------------------ + + @Property(bool, notify=minimizerChanged) + def isBayesianSelected(self) -> bool: + return self._minimizers_logic.is_bayesian_selected() + + # ------------------------------------------------------------------ + # Bayesian sampling progress properties + # ------------------------------------------------------------------ + + @Property(int, notify=fittingChanged) + def sampleProgressStep(self) -> int: + return self._fitting_logic.sample_step + + @Property(str, notify=fittingChanged) + def sampleProgressMessage(self) -> str: + return self._fitting_logic.sample_progress_message + + @Property(bool, notify=fittingChanged) + def sampleProgressHasUpdate(self) -> bool: + return self._fitting_logic.sample_has_update + + # ------------------------------------------------------------------ + # Bayesian sampling parameter properties + # ------------------------------------------------------------------ + + @Property(int, notify=minimizerChanged) + def bayesianSamples(self) -> int: + return self._bayesian_logic.samples + + def _set_bayesian_attr(self, attr: str, value) -> None: + """Assign a Bayesian hyper-parameter, tolerating invalid input. + + The ``Bayesian`` setters raise ``ValueError`` on invalid values. Letting + that propagate out of a Qt slot prints a stderr traceback and leaves the + UI without feedback. Instead we log the rejection and always re-emit + ``minimizerChanged`` so QML re-reads the property and the input reverts + to the last valid value. + """ + try: + setattr(self._bayesian_logic, attr, value) + except ValueError as exc: + logger.warning('Rejected invalid Bayesian %s value %r: %s', attr, value, exc) + self.minimizerChanged.emit() + + @Slot(int) + def setBayesianSamples(self, value: int) -> None: + self._set_bayesian_attr('samples', value) + + @Property(int, notify=minimizerChanged) + def bayesianBurnIn(self) -> int: + return self._bayesian_logic.burn + + @Slot(int) + def setBayesianBurnIn(self, value: int) -> None: + self._set_bayesian_attr('burn', value) + + @Property(int, notify=minimizerChanged) + def bayesianPopulation(self) -> int: + return self._bayesian_logic.population + + @Slot(int) + def setBayesianPopulation(self, value: int) -> None: + self._set_bayesian_attr('population', value) + + @Property(int, notify=minimizerChanged) + def bayesianThinning(self) -> int: + return self._bayesian_logic.thin + + @Slot(int) + def setBayesianThinning(self, value: int) -> None: + self._set_bayesian_attr('thin', value) + + @Property(str, notify=minimizerChanged) + def bayesianInitializer(self) -> str: + return self._bayesian_logic.initializer + + @Slot(str) + def setBayesianInitializer(self, value: str) -> None: + self._set_bayesian_attr('initializer', value) + + @Property('QVariantList', notify=minimizerChanged) + def bayesianInitializerOptions(self) -> list: + return ['eps', 'cov', 'lhs', 'random'] + + @Property(bool, notify=fittingChanged) + def bayesianResultAvailable(self) -> bool: + return self._bayesian_logic.has_result + + @Property('QVariant', notify=fittingChanged) + def bayesianPosterior(self) -> dict | None: + p = self._bayesian_logic.posterior + if p is None: + return None + return { + 'paramNames': self._bayesian_display_name_list(), + 'nDraws': int(p['draws'].shape[0]), + } + + @Property('QVariant', notify=fittingChanged) + def bayesianMarginals(self) -> list: + if not self._bayesian_logic.has_result: + return [] + import numpy as np + + p = self._bayesian_logic.posterior + display_names = self._bayesian_display_name_list() + out = [] + for k, name in enumerate(display_names): + col = p['draws'][:, k] + counts, edges = np.histogram(col, bins=40, density=True) + centers = 0.5 * (edges[:-1] + edges[1:]) + out.append( + dict( + name=name, + mean=float(col.mean()), + std=float(col.std()), + ci_low=float(np.quantile(col, 0.025)), + ci_high=float(np.quantile(col, 0.975)), + binCenters=centers.tolist(), + counts=counts.tolist(), + ) + ) + return out + + # Phase 2: corner/trace plot PNGs, diagnostics, heatmap + + def _bayesian_display_names(self) -> dict[str, str]: + """Build mapping from unique_name to human-readable display name. + + Uses the unfiltered parameter list (``all_parameters()``) so that every + sampled parameter can be resolved, independent of the current table + filter state. + """ + mapping: dict[str, str] = {} + try: + for p in self._parameters_logic.all_parameters(): + unique = p.get('unique_name', '') + display = p.get('name', unique) + if unique: + mapping[unique] = display + except Exception: + logger.exception('Failed to build Bayesian parameter display-name mapping') + return mapping + + def _bayesian_display_name_list(self) -> list[str]: + """Return the posterior parameter names translated to display names.""" + posterior = self._bayesian_logic.posterior + if posterior is None: + return [] + mapping = self._bayesian_display_names() + result: list[str] = [] + for name in posterior['param_names']: + result.append(mapping.get(name) or name) + return result + + @Property(str, notify=fittingChanged) + def bayesianCornerPlotUrl(self) -> str: + """Return a file URL for the corner plot PNG, or empty string.""" + if not self._bayesian_logic.has_result: + return '' + if not self._bayesian_logic.corner_plot_url: + self._render_corner_plot() + return self._bayesian_logic.corner_plot_url + + @Property(str, notify=fittingChanged) + def bayesianTracePlotUrl(self) -> str: + """Return a file URL for the trace plot PNG, or empty string.""" + if not self._bayesian_logic.has_result: + return '' + if not self._bayesian_logic.trace_plot_url: + self._render_trace_plot() + return self._bayesian_logic.trace_plot_url + + @Property(str, notify=fittingChanged) + def bayesianDistributionPlotUrl(self) -> str: + """Return a file URL for the interactive distribution HTML, or empty string.""" + if not self._bayesian_logic.has_result: + return '' + if not self._bayesian_logic.distribution_plot_url: + self._render_distribution_plot() + return self._bayesian_logic.distribution_plot_url + + @Property('QVariant', notify=fittingChanged) + def bayesianDiagnostics(self) -> dict: + """Return convergence diagnostics dict.""" + if self._bayesian_logic.has_result and not self._bayesian_logic.diagnostics: + self._compute_diagnostics() + return self._bayesian_logic.diagnostics + + @Property('QVariantList', notify=fittingChanged) + def bayesianParamNames(self) -> list: + """Return parameter names for heatmap axis dropdowns (display names).""" + return self._bayesian_display_name_list() + + heatmapChanged = Signal() + + @Property('QVariant', notify=heatmapChanged) + def bayesianHeatmapData(self) -> dict | None: + """Return 2D histogram data for the heatmap view.""" + return self._bayesian_logic.heatmap_data + + @Property(str, notify=heatmapChanged) + def bayesianHeatmapPlotUrl(self) -> str: + """Return a file URL for the rendered 2D heatmap PNG, or empty string.""" + return self._bayesian_logic.heatmap_plot_url + + @Slot(int, int) + def computeBayesianHeatmap(self, paramX: int, paramY: int) -> None: + """Compute 2D histogram for the selected parameter pair.""" + import numpy as np + + posterior = self._bayesian_logic.posterior + if posterior is None: + return + draws = np.asarray(posterior['draws']) + if draws.ndim == 3: + draws = draws.reshape(-1, draws.shape[-1]) + x = draws[:, paramX] + y = draws[:, paramY] + display_names = self._bayesian_display_name_list() + H, xedges, yedges = np.histogram2d(x, y, bins=50, density=True) + self._bayesian_logic.heatmap_data = { + 'xLabel': display_names[paramX] if paramX < len(display_names) else posterior['param_names'][paramX], + 'yLabel': display_names[paramY] if paramY < len(display_names) else posterior['param_names'][paramY], + 'xCenters': (0.5 * (xedges[:-1] + xedges[1:])).tolist(), + 'yCenters': (0.5 * (yedges[:-1] + yedges[1:])).tolist(), + 'zValues': H.T.tolist(), + } + self._render_heatmap_plot(paramX, paramY) + self.heatmapChanged.emit() + + # ------------------------------------------------------------------ + # Fitting start / stop (classical + Bayesian dispatch) + # ------------------------------------------------------------------ + @Slot(None) def fittingStartStop(self) -> None: # If already running, stop the fit @@ -170,7 +424,12 @@ def fittingStartStop(self) -> None: self._start_threaded_fit() def _start_threaded_fit(self) -> None: - """Start fitting in a background thread.""" + """Start fitting in a background thread, dispatching to sampling when Bayesian is selected.""" + if self._minimizers_logic.is_bayesian_selected(): + self._start_threaded_sample() + return + + # Classical fitting path # Reset flags and prepare for fit using proper encapsulation self._fitting_logic.reset_stop_flag() self._fitting_logic.prepare_for_threaded_fit() @@ -196,7 +455,6 @@ def _start_threaded_fit(self) -> None: kwargs={'weights': weights, 'method': method}, parent=self, ) - self._fitter_thread.setTerminationEnabled(True) self._fitter_thread.finished.connect(self._on_fit_finished) self._fitter_thread.failed.connect(self._on_fit_failed) self._fitter_thread.progressDetail.connect(self._on_fit_progress) @@ -207,7 +465,10 @@ def _start_threaded_fit(self) -> None: @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) + if payload.get('sampling'): + self._fitting_logic.on_sample_progress(payload) + else: + self._fitting_logic.on_fit_progress(payload) self.fittingChanged.emit() @Slot(list) @@ -243,6 +504,413 @@ def _onStopFit(self) -> None: self.fittingChanged.emit() self.externalFittingChanged.emit() + # ------------------------------------------------------------------ + # Bayesian sampling dispatch and result handling + # ------------------------------------------------------------------ + + def _start_threaded_sample(self) -> None: + """Start Bayesian MCMC sampling in a background thread.""" + self._fitting_logic.prepare_for_threaded_sample() + self.fittingChanged.emit() + + multi_fitter, data_group = self._fitting_logic.prepare_threaded_sample(self._minimizers_logic) + + if multi_fitter is None: + self.fittingChanged.emit() + if self._fitting_logic.fit_error_message: + self.fitFailed.emit(self._fitting_logic.fit_error_message) + return + + logger.info( + 'Bayesian DREAM: samples=%d burn=%d thin=%d population=%d initializer=%s', + self._bayesian_logic.samples, + self._bayesian_logic.burn, + self._bayesian_logic.thin, + self._bayesian_logic.population, + self._bayesian_logic.initializer, + ) + + self._fitter_thread = FitterWorker( + fitter=multi_fitter, # the high-level reflectometry MultiFitter + method_name='mcmc_sample', + args=(data_group,), # sc.DataGroup + kwargs={ + 'samples': self._bayesian_logic.samples, + 'burn': self._bayesian_logic.burn, + 'thin': self._bayesian_logic.thin, + 'population': self._bayesian_logic.population, + 'initializer': self._bayesian_logic.initializer, + }, + parent=self, + ) + self._fitter_thread.finished.connect(self._on_sample_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(list) + def _on_sample_finished(self, results: list) -> None: + """Handle successful completion of Bayesian sampling.""" + if not results: + logger.error('Bayesian sampling finished with empty results list') + self._fitting_logic.on_sample_finished() + self._fitter_thread = None + self.fittingChanged.emit() + self.externalFittingChanged.emit() + return + try: + posterior = results[0] # {'draws', 'param_names', 'state', 'logp'} + self._bayesian_logic.posterior = posterior + self._fitting_logic.on_sample_finished() + self._fitter_thread = None + except Exception: + logger.exception('Error storing Bayesian posterior result') + self._fitter_thread = None + self.fittingChanged.emit() + self.externalFittingChanged.emit() + return + # Phase 2: compute posterior predictive, diagnostics, and rendered plots + try: + self._compute_and_publish_posterior_predictive() + self._compute_diagnostics() + self._render_corner_plot() + self._render_trace_plot() + except Exception: + logger.exception('Error during posterior computation / rendering') + finally: + self.fittingChanged.emit() + self.externalFittingChanged.emit() + + def _compute_and_publish_posterior_predictive(self) -> None: + """Compute posterior predictive reflectivity and SLD, publish to plotting.""" + if self._plotting is None: + return + from easyreflectometry.analysis.bayesian import ( + posterior_predictive_reflectivity, + posterior_predictive_sld_profile, + ) + import numpy as np + + posterior = self._bayesian_logic.posterior + if posterior is None: + logger.warning('_compute_and_publish_posterior_predictive: no posterior available') + return + + experiments = self._ordered_experiments() + if not experiments: + logger.warning('_compute_and_publish_posterior_predictive: no experiments available') + return + + # Compute posterior predictive reflectivity for all experiments + q_all = [] + median_all = [] + lo_all = [] + hi_all = [] + + total_q_points = sum(len(np.asarray(experiment.x)) for experiment in experiments) + logger.info( + '_compute_and_publish_posterior_predictive: draws shape=%s, n_params=%d, ' + 'n_experiments=%d, total_q_points=%d', + posterior['draws'].shape, + len(posterior['param_names']), + len(experiments), + total_q_points, + ) + + try: + + for i, experiment in enumerate(experiments): + q_i = np.asarray(experiment.x) + model_i = experiment.model + + median_i, lo_i, hi_i = posterior_predictive_reflectivity( + posterior['draws'], + posterior['param_names'], + model=model_i, + q_values=q_i, + n_samples=200, + ) + logger.info( + 'Posterior predictive for experiment %d: q=%d, median shape=%s', + i, len(q_i), median_i.shape, + ) + + q_all.append(q_i) + median_all.append(median_i) + lo_all.append(lo_i) + hi_all.append(hi_i) + + q_concat = np.concatenate(q_all) + median_concat = np.concatenate(median_all) + lo_concat = np.concatenate(lo_all) + hi_concat = np.concatenate(hi_all) + self._plotting.set_posterior_predictive(q_concat, median_concat, lo_concat, hi_concat) + except Exception: + logger.exception('Failed to compute or publish posterior predictive reflectivity') + return + + # SLD profile: use the first experiment's model (SLD is shared across experiments) + try: + z, sld_median, sld_lo, sld_hi = posterior_predictive_sld_profile( + posterior['draws'], + posterior['param_names'], + model=experiments[0].model, + n_samples=200, + ) + self._plotting.set_posterior_predictive_sld(z, sld_median, sld_lo, sld_hi) + except Exception: + logger.exception('Failed to compute or publish posterior predictive SLD profile') + + def _compute_diagnostics(self) -> None: + """Compute convergence diagnostics from the posterior and state.""" + posterior = self._bayesian_logic.posterior + if posterior is None: + self._bayesian_logic.diagnostics = {} + return + + diagnostics = { + 'nDraws': int(posterior['draws'].shape[0]), + 'nParams': int(posterior['draws'].shape[1]), + 'burnIn': self._bayesian_logic.burn, + 'thin': self._bayesian_logic.thin, + 'population': self._bayesian_logic.population, + 'samples': self._bayesian_logic.samples, + } + + # Extract acceptance rate from BUMPS state if available + state = posterior.get('state') + if state is not None: + try: + diagnostics['acceptanceRate'] = float(getattr(state, 'acceptance_rate', None) or 0.0) + except (AttributeError, TypeError, ValueError): + pass + + draws = posterior['draws'] + state = posterior.get('state') + + # Try to obtain 3D draws (chains × draws × params) needed by arviz R-hat. + # The BUMPS MCMCDraw.chains() returns (n_generations, n_chains, n_params). + draws_3d = None + if getattr(draws, 'ndim', 0) == 3 and draws.shape[0] >= 2: + draws_3d = draws + elif state is not None and hasattr(state, 'chains'): + try: + import numpy as np + + _draw_counts, chains_3d, _logp_3d = state.chains() + # chains_3d: (n_generations, n_chains, n_params) + if chains_3d.shape[1] >= 2: # at least 2 chains + # Transpose to arviz convention: (n_chains, n_draws, n_params) + draws_3d = np.moveaxis(chains_3d, 0, 1) + else: + diagnostics['rhatStatus'] = 'Unavailable: only one chain was sampled (increase Population).' + except Exception: + draws_3d = None + + if draws_3d is not None: + try: + import arviz as _arviz + + # Build arviz InferenceData from 3D draws (n_chains, n_draws, n_params) + import numpy as np + + posterior_dict = {} + for i, name in enumerate(posterior['param_names']): + posterior_dict[name] = draws_3d[:, :, i] + idata = _arviz.from_dict({'posterior': posterior_dict}) + rhat = _arviz.rhat(idata) + if rhat is not None: + # arviz.rhat returns an xarray Dataset; extract scalar values + mapping = self._bayesian_display_names() + mapped_rhat = {} + for name in posterior['param_names']: + val = float(rhat[name].values) + display = mapping.get(name, name) + mapped_rhat[display] = val + finite_rhat = {name: value for name, value in mapped_rhat.items() if np.isfinite(value)} + if finite_rhat: + diagnostics['rhat'] = finite_rhat + else: + diagnostics['rhatStatus'] = 'Unavailable: all R-hat values are NaN/Inf.' + else: + diagnostics['rhatStatus'] = 'Unavailable: arviz.rhat returned None.' + except ImportError: + diagnostics['rhatStatus'] = 'Unavailable: arviz is not installed.' + except Exception as exc: + diagnostics['rhatStatus'] = f'Unavailable: R-hat computation failed ({exc}).' + else: + if 'rhatStatus' not in diagnostics: + diagnostics['rhatStatus'] = 'Unavailable: the sampler returned flattened draws without chain identities.' + + self._bayesian_logic.diagnostics = diagnostics + + @Property(int, notify=fittingChanged) + def sampleProgressTotalSteps(self) -> int: + return self._fitting_logic.sample_total_steps + + def _plot_file_path(self, stem: str, ext: str = 'png'): + """Return a stable temporary file path for a rendered Bayesian plot.""" + from pathlib import Path + import tempfile + + out_dir = Path(tempfile.gettempdir()) / 'EasyReflectometryApp' / 'bayesian' + out_dir.mkdir(parents=True, exist_ok=True) + return out_dir / f'{stem}.{ext}' + + def _plot_file_url(self, stem: str) -> str: + """Return a stable temporary file URL for a rendered Bayesian plot.""" + return self._plot_file_path(stem).as_uri() + + def _render_corner_plot(self) -> None: + """Render corner plot to interactive HTML and expose it as a file URL.""" + posterior = self._bayesian_logic.posterior + if posterior is None: + self._bayesian_logic.corner_plot_url = '' + return + try: + from easyreflectometry.analysis.bayesian import plot_corner + + display_names = self._bayesian_display_name_list() + import numpy as np + draws = np.asarray(posterior['draws']) + if draws.ndim == 3: + draws = draws.reshape(-1, draws.shape[-1]) + + fig = plot_corner(draws, display_names) + if fig is None: + self._bayesian_logic.corner_plot_url = '' + logger.info('Plotly unavailable — corner plot not rendered') + return + + html = fig.to_html( + include_plotlyjs=True, + full_html=True, + config={'responsive': True}, + ) + path = self._plot_file_path('corner', 'html') + path.write_text(html, encoding='utf-8') + self._bayesian_logic.corner_plot_url = path.as_uri() + f'?t={time.time_ns()}' + except ImportError: + self._bayesian_logic.corner_plot_url = '' + logger.info('Plotly not installed — corner plot unavailable') + except Exception: + self._bayesian_logic.corner_plot_url = '' + logger.exception('Failed to render corner plot') + + def _render_distribution_plot(self) -> None: + """Render marginal distribution plot to interactive HTML and expose it as a file URL.""" + posterior = self._bayesian_logic.posterior + if posterior is None: + self._bayesian_logic.distribution_plot_url = '' + return + try: + from easyreflectometry.analysis.bayesian import plot_distribution + + display_names = self._bayesian_display_name_list() + import numpy as np + draws = np.asarray(posterior['draws']) + if draws.ndim == 3: + draws = draws.reshape(-1, draws.shape[-1]) + + fig = plot_distribution(draws, display_names, return_figure=True) + if fig is None: + self._bayesian_logic.distribution_plot_url = '' + logger.info('Plotly unavailable — distribution plot not rendered') + return + + html = fig.to_html( + include_plotlyjs=True, + full_html=True, + config={'responsive': True}, + ) + path = self._plot_file_path('distribution', 'html') + path.write_text(html, encoding='utf-8') + self._bayesian_logic.distribution_plot_url = path.as_uri() + f'?t={time.time_ns()}' + except ImportError: + self._bayesian_logic.distribution_plot_url = '' + logger.info('Plotly not installed — distribution plot unavailable') + except Exception: + self._bayesian_logic.distribution_plot_url = '' + logger.exception('Failed to render distribution plot') + + def _render_trace_plot(self) -> None: + """Render MCMC trace plot to interactive HTML and expose it as a file URL.""" + posterior = self._bayesian_logic.posterior + if posterior is None: + self._bayesian_logic.trace_plot_url = '' + return + try: + from easyreflectometry.analysis.bayesian import plot_trace + + import numpy as np + draws = np.asarray(posterior['draws']) + if draws.ndim == 2: + draws = draws[np.newaxis, ...] # (1, n_draws, n_params) + + display_names = self._bayesian_display_name_list() + + fig = plot_trace(draws, display_names, return_figure=True) + if fig is None or not hasattr(fig, 'to_html'): + self._bayesian_logic.trace_plot_url = '' + logger.info('Plotly unavailable — trace plot not rendered') + return + + html = fig.to_html( # type: ignore[union-attr] + include_plotlyjs=True, + full_html=True, + config={'responsive': True}, + ) + path = self._plot_file_path('trace', 'html') + path.write_text(html, encoding='utf-8') + self._bayesian_logic.trace_plot_url = path.as_uri() + f'?t={time.time_ns()}' + except ImportError: + self._bayesian_logic.trace_plot_url = '' + logger.info('Plotly not installed — trace plot unavailable') + except Exception: + self._bayesian_logic.trace_plot_url = '' + logger.exception('Failed to render trace plot') + + def _render_heatmap_plot(self, paramX: int, paramY: int) -> None: + """Render selected 2D posterior density heatmap to PNG and expose it as a file URL.""" + posterior = self._bayesian_logic.posterior + if posterior is None: + self._bayesian_logic.heatmap_plot_url = '' + return + try: + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + import numpy as np + + draws = np.asarray(posterior['draws']) + if draws.ndim == 3: + draws = draws.reshape(-1, draws.shape[-1]) + + x = draws[:, paramX] + y = draws[:, paramY] + display_names = self._bayesian_display_name_list() + x_label = display_names[paramX] if paramX < len(display_names) else posterior['param_names'][paramX] + y_label = display_names[paramY] if paramY < len(display_names) else posterior['param_names'][paramY] + + fig, axis = plt.subplots(figsize=(8, 6)) + heatmap = axis.hist2d(x, y, bins=60, density=True, cmap='viridis') + fig.colorbar(heatmap[3], ax=axis, label='Posterior density') + axis.set_xlabel(x_label) + axis.set_ylabel(y_label) + axis.set_title(f'Joint posterior: {x_label} vs {y_label}') + axis.grid(False) + fig.tight_layout() + + path = self._plot_file_path(f'heatmap_{paramX}_{paramY}') + fig.savefig(path, format='png', dpi=100, bbox_inches='tight') + plt.close(fig) + self._bayesian_logic.heatmap_plot_url = path.as_uri() + f'?t={time.time_ns()}' + except Exception: + self._bayesian_logic.heatmap_plot_url = '' + logger.exception('Failed to render Bayesian heatmap') + def prefitCheck(self) -> bool: """ Perform a pre-fit check to ensure that all parameters are set correctly. @@ -373,7 +1041,7 @@ def removeExperiment(self, index: int) -> None: self.experimentsChanged.emit() self.externalExperimentChanged.emit() else: - print(f'Experiment index {index} is out of range.') + logger.warning('Experiment index %s is out of range.', index) ######################## ## Multi-experiment selection support @@ -436,7 +1104,7 @@ def get_concatenated_experiment_data(self): all_ye.extend(data.ye if hasattr(data, 'ye') and data.ye.size > 0 else np.zeros_like(data.y)) all_xe.extend(data.xe if hasattr(data, 'xe') and data.xe.size > 0 else np.zeros_like(data.x)) except (IndexError, AttributeError) as e: - print(f'Error accessing experiment {exp_idx}: {e}') + logger.warning('Error accessing experiment %s: %s', exp_idx, e) continue if not all_x: @@ -470,18 +1138,18 @@ def get_individual_experiment_data_list(self): experiment_data_list = [] - # Define a color palette for experiments + # Define a muted/pastel color palette for experiments color_palette = [ - '#1f77b4', # Blue - '#ff7f0e', # Orange - '#2ca02c', # Green - '#d62728', # Red - '#9467bd', # Purple - '#8c564b', # Brown - '#e377c2', # Pink - '#7f7f7f', # Gray - '#bcbd22', # Olive - '#17becf', # Cyan + '#7BA6C4', # Soft Blue + '#E8B889', # Soft Orange + '#8DBF8D', # Soft Green + '#D48787', # Soft Red + '#B296B8', # Soft Purple + '#A68F7F', # Soft Brown + '#D4A8BC', # Soft Pink + '#A5A5A5', # Soft Gray + '#B8B87D', # Soft Olive + '#7BB8B8', # Soft Cyan ] for idx, exp_idx in enumerate(self._selected_experiment_indices): @@ -497,7 +1165,7 @@ def get_individual_experiment_data_list(self): experiment_data_list.append({'data': data, 'name': exp_name, 'color': color, 'index': exp_idx}) except (IndexError, AttributeError) as e: - print(f'Error accessing experiment {exp_idx}: {e}') + logger.warning('Error accessing experiment %s: %s', exp_idx, e) continue return experiment_data_list @@ -652,3 +1320,58 @@ def _clearCacheAndEmitParametersChanged(self): self._parameters_logic.set_current_index(parameters_length - 1) self.parametersIndexChanged.emit() self.parametersChanged.emit() + + # ------------------------------------------------------------------ + # Bayesian plot saving + # ------------------------------------------------------------------ + + @Slot(str, result=bool) + def saveBayesianPlot(self, source_url: str) -> bool: + """Open a native save dialog to save a rendered Bayesian plot PNG. + + :param source_url: ``file://`` URL of the rendered plot (e.g. from + ``bayesianCornerPlotUrl``, ``bayesianTracePlotUrl``, + ``bayesianHeatmapPlotUrl``). + :returns: ``True`` if the file was saved successfully. + """ + import shutil + from pathlib import Path + + if not source_url or not source_url.startswith('file://'): + logger.warning('Invalid Bayesian plot URL for saving: %s', source_url) + return False + + # Strip query string (e.g. ?t= used for cache-busting) + clean_url = source_url.split('?')[0] + source_path = Path(clean_url.replace('file:///', '', 1) if clean_url.startswith('file:///') + else clean_url.replace('file://', '', 1)) + # Handle Windows paths: file:///C:/... → C:/... + if os.name == 'nt' and str(source_path).startswith('/'): + source_path = Path(str(source_path)[1:]) + + if not source_path.exists(): + logger.warning('Bayesian plot file not found: %s', source_path) + return False + + suggested = source_path.name or 'bayesian_plot.png' + if source_path.suffix.lower() == '.html': + file_filter = 'HTML Files (*.html)' + else: + file_filter = 'PNG Images (*.png)' + dialog = QtWidgets.QFileDialog() + save_path, _ = dialog.getSaveFileName( + None, + 'Save Bayesian plot', + str(Path.home() / suggested), + file_filter, + ) + if not save_path: + return False + + try: + shutil.copy2(str(source_path), save_path) + logger.info('Bayesian plot saved to %s', save_path) + return True + except OSError as exc: + logger.exception('Failed to save Bayesian plot to %s', save_path) + return False \ No newline at end of file diff --git a/EasyReflectometryApp/Backends/Py/helpers.py b/EasyReflectometryApp/Backends/Py/helpers.py index 23ed7e91..b9d3686f 100644 --- a/EasyReflectometryApp/Backends/Py/helpers.py +++ b/EasyReflectometryApp/Backends/Py/helpers.py @@ -26,10 +26,7 @@ def generalizePath(fpath: str) -> str: @staticmethod def localFileToUrl(fpath: str) -> str: - if not sys.platform.startswith('win'): - return QUrl.fromLocalFile(fpath).toString() - url = QUrl.fromLocalFile(fpath.split(':')[-1]).toString() - return url + return QUrl.fromLocalFile(fpath).toString() @staticmethod def formatMsg(type, *args): diff --git a/EasyReflectometryApp/Backends/Py/home.py b/EasyReflectometryApp/Backends/Py/home.py index 606902b8..7c01a8a5 100644 --- a/EasyReflectometryApp/Backends/Py/home.py +++ b/EasyReflectometryApp/Backends/Py/home.py @@ -13,14 +13,14 @@ def __init__(self, parent=None): super().__init__(parent) @Property('QVariant', constant=True) - def version(self) -> dict[str:str]: + def version(self) -> dict[str, str]: return { 'number': PYPROJECT['project']['version'], - 'date': PYPROJECT['project']['release_data'], + 'date': PYPROJECT['release']['release_date'], } @Property('QVariant', constant=True) - def urls(self) -> dict[str:str]: + def urls(self) -> dict[str, str]: return { 'homepage': PYPROJECT['project']['urls']['homepage'], 'issues': PYPROJECT['project']['urls']['issues'], diff --git a/EasyReflectometryApp/Backends/Py/logic/bayesian.py b/EasyReflectometryApp/Backends/Py/logic/bayesian.py new file mode 100644 index 00000000..6dc3aad2 --- /dev/null +++ b/EasyReflectometryApp/Backends/Py/logic/bayesian.py @@ -0,0 +1,114 @@ +"""State container for Bayesian DREAM hyper-parameters and posterior results.""" + +_VALID_INITIALIZERS = ('eps', 'cov', 'lhs', 'random') + + +class Bayesian: + """Holds DREAM hyper-parameters and the last posterior result. + + This is purely a state container — execution is delegated to the worker. + """ + + DEFAULTS = dict(samples=10000, burn=2000, population=10, thin=1, initializer='eps') + + def __init__(self): + self._samples: int = self.DEFAULTS['samples'] + self._burn: int = self.DEFAULTS['burn'] + self._population: int = self.DEFAULTS['population'] + self._thin: int = self.DEFAULTS['thin'] + self._initializer: str = self.DEFAULTS['initializer'] + self._posterior: dict | None = None + # Phase 2 — cached rendered assets and diagnostics + self.corner_plot_url: str = '' + self.distribution_plot_url: str = '' + self.trace_plot_url: str = '' + self.heatmap_plot_url: str = '' + self.heatmap_data: dict | None = None + self.diagnostics: dict = {} + + # ------------------------------------------------------------------ + # Hyper-parameters + # ------------------------------------------------------------------ + + @property + def samples(self) -> int: + return self._samples + + @samples.setter + def samples(self, value: int) -> None: + if value > 0: + self._samples = value + else: + raise ValueError('samples must be a positive integer') + + @property + def burn(self) -> int: + return self._burn + + @burn.setter + def burn(self, value: int) -> None: + if value >= 0: + self._burn = value + else: + raise ValueError('burn must be a non-negative integer') + + @property + def population(self) -> int: + return self._population + + @population.setter + def population(self, value: int) -> None: + if value > 0: + self._population = value + else: + raise ValueError('population must be a positive integer') + + @property + def thin(self) -> int: + return self._thin + + @thin.setter + def thin(self, value: int) -> None: + if value > 0: + self._thin = value + else: + raise ValueError('thin must be a positive integer') + + @property + def initializer(self) -> str: + return self._initializer + + @initializer.setter + def initializer(self, value: str) -> None: + if value in _VALID_INITIALIZERS: + self._initializer = value + else: + raise ValueError( + f'Unknown initializer {value!r}. Valid options: {_VALID_INITIALIZERS}' + ) + + # ------------------------------------------------------------------ + # Posterior result + # ------------------------------------------------------------------ + + @property + def posterior(self) -> dict | None: + return self._posterior + + @posterior.setter + def posterior(self, value: dict | None) -> None: + self._posterior = value + + @property + def has_result(self) -> bool: + return self._posterior is not None + + def clear(self) -> None: + """Clear the stored posterior result and all rendered / computed assets.""" + self._posterior = None + self.corner_plot_url = '' + self.distribution_plot_url = '' + self.trace_plot_url = '' + self.heatmap_plot_url = '' + self.heatmap_data = None + self.diagnostics = {} diff --git a/EasyReflectometryApp/Backends/Py/logic/experiments.py b/EasyReflectometryApp/Backends/Py/logic/experiments.py index f292d886..6d7e304f 100644 --- a/EasyReflectometryApp/Backends/Py/logic/experiments.py +++ b/EasyReflectometryApp/Backends/Py/logic/experiments.py @@ -1,5 +1,9 @@ +import logging + from easyreflectometry import Project as ProjectLib +logger = logging.getLogger(__name__) + class Experiments: def __init__(self, project_lib: ProjectLib): @@ -86,9 +90,9 @@ def set_model_on_experiment(self, new_value: int) -> None: model = models[new_value] exp.model = model except IndexError: - print(f'Model index {new_value} is out of range for the current experiment.') + logger.warning('Model index %s is out of range for the current experiment.', new_value) else: - print('No experiment or models available to set on the experiment.') + logger.warning('No experiment or models available to set on the experiment.') pass def remove_experiment(self, index: int) -> None: @@ -97,13 +101,13 @@ def remove_experiment(self, index: int) -> None: """ total = len(self.available()) if not (0 <= index < total): - print(f'Experiment index {index} is out of range.') + logger.warning('Experiment index %s is out of range.', index) return experiments = self._project_lib._experiments exp_key = self._experiment_key_at_index(index) if exp_key is None: - print(f'Experiment index {index} is out of range.') + logger.warning('Experiment index %s is out of range.', index) return if hasattr(experiments, 'items'): diff --git a/EasyReflectometryApp/Backends/Py/logic/fitting.py b/EasyReflectometryApp/Backends/Py/logic/fitting.py index 7ff09b79..01c9684d 100644 --- a/EasyReflectometryApp/Backends/Py/logic/fitting.py +++ b/EasyReflectometryApp/Backends/Py/logic/fitting.py @@ -10,6 +10,8 @@ from easyscience.fitting.minimizers.utils import FitError if TYPE_CHECKING: + import scipp as sc + from .minimizers import Minimizers @@ -34,6 +36,10 @@ def __init__(self, project_lib: ProjectLib): self._fit_preview_parameter_values: dict = {} self._fit_has_preview_update = False self._fit_has_interim_update = False + self._sample_step = 0 + self._sample_total_steps = 0 + self._sample_running_message = '' + self._sample_has_update = False @property def status(self) -> str: @@ -104,6 +110,30 @@ def fit_has_preview_update(self) -> bool: def fit_has_interim_update(self) -> bool: return self._fit_has_interim_update + # ------------------------------------------------------------------ + # Bayesian sampling progress + # ------------------------------------------------------------------ + + @property + def sample_step(self) -> int: + return self._sample_step + + @property + def sample_progress_message(self) -> str: + return self._sample_running_message + + @property + def sample_has_update(self) -> bool: + return self._sample_has_update + + @property + def sample_total_steps(self) -> int: + return self._sample_total_steps + + # ------------------------------------------------------------------ + # Progress handling + # ------------------------------------------------------------------ + 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) @@ -130,6 +160,7 @@ def clear_fit_progress(self) -> None: self._fit_preview_parameter_values = {} self._fit_has_preview_update = False self._fit_has_interim_update = False + self.clear_sample_progress() def on_fit_failed(self, error_message: str) -> None: """Handle fitting failure callback. @@ -271,6 +302,125 @@ def prepare_threaded_fit(self, minimizers_logic: 'Minimizers') -> tuple: logger.exception('Error preparing threaded fit') return None, None, None, None, None + # ------------------------------------------------------------------ + # Bayesian sampling helpers + # ------------------------------------------------------------------ + + def collect_all_experiments_datagroup(self) -> 'sc.DataGroup': + """Build the scipp DataGroup required by reflectometry-lib ``MultiFitter.mcmc_sample()``. + + Scope decision (see issue #319): Bayesian sampling deliberately runs over + **all** experiments, mirroring the classical fit path (``prepare_threaded_fit``) + which also fits every experiment. The experiment selection currently affects + only plotting, not the fit/sampling scope. If per-selection sampling is ever + wanted, filter ``self._ordered_experiments()`` here and rename accordingly. + + :return: DataGroup with reflectivity coords and data. + :rtype: sc.DataGroup + """ + import scipp as sc + + experiments = self._ordered_experiments() + coords = {} + data = {} + for i, experiment in enumerate(experiments): + import numpy as np + + x_vals = np.asarray(experiment.x, dtype=float) + xe_vals = np.asarray(experiment.xe, dtype=float) + y_vals = np.asarray(experiment.y, dtype=float) + ye_vals = np.asarray(experiment.ye, dtype=float) # variances (σ²) + + coords[f'Qz_{i}'] = sc.array( + dims=[f'Qz_{i}'], values=x_vals, variances=xe_vals, unit=sc.Unit('1/angstrom'), + ) + data[f'R_{i}'] = sc.array( + dims=[f'Qz_{i}'], values=y_vals, variances=ye_vals, + ) + return sc.DataGroup(data=data, coords=coords, attrs={}) + + def prepare_threaded_sample(self, minimizers_logic: 'Minimizers') -> tuple: + """Prepare high-level MultiFitter + DataGroup for Bayesian sampling. + + :param minimizers_logic: The minimizers logic instance. + :return: Tuple of (multi_fitter, data_group) or (None, None) on error. + """ + try: + from easyreflectometry.fitting import MultiFitter + + experiments = self._ordered_experiments() + if not experiments: + self._fit_error_message = 'No experiments to sample' + self._running = False + self._finished = True + self._show_results_dialog = True + return None, None + + models = [experiment.model for experiment in experiments] + multi_fitter = MultiFitter(*models) + + # Ensure underlying engine is BUMPS for the sample() call + selected = minimizers_logic.selected_minimizer_enum() + if selected is not None: + multi_fitter.easy_science_multi_fitter.switch_minimizer(selected) + + data_group = self.collect_all_experiments_datagroup() + return multi_fitter, data_group + except Exception as e: + self._fit_error_message = f'Error preparing sampling: {e}' + self._running = False + self._finished = True + self._show_results_dialog = True + logger.exception('Error preparing threaded sample') + return None, None + + def prepare_for_threaded_sample(self) -> None: + """Set running flags and sampling progress message before launching the worker.""" + self._running = True + self._finished = False + self._show_results_dialog = False + self._fit_error_message = None + self._result = None + self._results = [] + self.clear_fit_progress() + self.clear_sample_progress() + self._sample_running_message = 'Sampling… (this may take several minutes)' + + def on_sample_finished(self) -> None: + """Handle successful Bayesian sampling completion without FitResults. + + Clears classical fit result state (which is not applicable to sampling) + while preserving the shared running / dialog lifecycle. + """ + self._running = False + self._finished = True + self._show_results_dialog = True + self._fit_error_message = None + self._result = None + self._results = [] + self.clear_fit_progress() + + def on_sample_progress(self, payload: dict) -> None: + """Update transient state from an in-flight DREAM sampling progress payload.""" + self._sample_step = int(payload.get('iteration', 0) or 0) + self._sample_total_steps = int(payload.get('total_steps', 0) or 0) + self._sample_has_update = True + self._fit_has_interim_update = True + total = self._sample_total_steps + if self._sample_step > 0: + if total > 0: + self._sample_running_message = f'DREAM step {self._sample_step} of {total}' + else: + self._sample_running_message = f'DREAM step {self._sample_step}' + else: + self._sample_running_message = 'Sampling…' + + def clear_sample_progress(self) -> None: + self._sample_step = 0 + self._sample_total_steps = 0 + self._sample_running_message = '' + self._sample_has_update = False + def on_fit_finished(self, results: FitResults | List[FitResults]) -> None: """Handle successful completion of fitting. diff --git a/EasyReflectometryApp/Backends/Py/logic/layers.py b/EasyReflectometryApp/Backends/Py/logic/layers.py index 3b0c3220..215b023c 100644 --- a/EasyReflectometryApp/Backends/Py/logic/layers.py +++ b/EasyReflectometryApp/Backends/Py/logic/layers.py @@ -58,7 +58,7 @@ def layers_names(self) -> list[str]: return [element['label'] for element in self.layers] def remove_at_index(self, value: str) -> None: - self._layers.remove(int(value)) + self._layers.remove_at(int(value)) def add_new(self) -> None: if 'Si' not in [material.name for material in self._project_lib._materials]: diff --git a/EasyReflectometryApp/Backends/Py/logic/minimizers.py b/EasyReflectometryApp/Backends/Py/logic/minimizers.py index 269bd0b5..fea2b05c 100644 --- a/EasyReflectometryApp/Backends/Py/logic/minimizers.py +++ b/EasyReflectometryApp/Backends/Py/logic/minimizers.py @@ -1,11 +1,15 @@ from easyreflectometry import Project as ProjectLib from easyscience import AvailableMinimizers +BAYESIAN_LABEL = 'BUMPS-DREAM (Bayesian)' + class Minimizers: def __init__(self, project_lib: ProjectLib): self._project_lib = project_lib - self._minimizer_current_index = 0 + # Default to the first classical minimizer (index 1); index 0 is the + # Bayesian sentinel (None) which requires an explicit user choice. + self._minimizer_current_index = 1 self._list_available_minimizers = list(AvailableMinimizers) try: self._list_available_minimizers.remove(AvailableMinimizers.LMFit) @@ -19,24 +23,39 @@ def __init__(self, project_lib: ProjectLib): self._list_available_minimizers.remove(AvailableMinimizers.DFO) except ValueError: pass + # Prepend Bayesian sentinel (None) as first entry + self._list_available_minimizers = [None] + self._list_available_minimizers def minimizers_available(self) -> list[str]: - return [minimizer.name for minimizer in self._list_available_minimizers] + return [BAYESIAN_LABEL if m is None else m.name for m in self._list_available_minimizers] def minimizer_current_index(self) -> int: return self._minimizer_current_index + def is_bayesian_selected(self) -> bool: + return self._list_available_minimizers[self._minimizer_current_index] is None + def selected_minimizer_enum(self): - """Return the AvailableMinimizers enum for the currently selected minimizer.""" - if 0 <= self._minimizer_current_index < len(self._list_available_minimizers): - return self._list_available_minimizers[self._minimizer_current_index] - return None + """Return the AvailableMinimizers enum for the currently selected minimizer. + + Falls back to ``Bumps_simplex`` when the Bayesian sentinel (``None``) + is selected, so callers that do not check ``is_bayesian_selected()`` + still receive a valid engine. + """ + entry = self._list_available_minimizers[self._minimizer_current_index] + return entry if entry is not None else AvailableMinimizers.Bumps_simplex def set_minimizer_current_index(self, new_value: int) -> bool: + if not 0 <= new_value < len(self._list_available_minimizers): + return False if new_value != self._minimizer_current_index: self._minimizer_current_index = new_value - enum_new_minimizer = self._list_available_minimizers[new_value] - self._project_lib.minimizer = enum_new_minimizer + entry = self._list_available_minimizers[new_value] + if entry is None: + # Bayesian mode: ensure underlying engine is Bumps for sample() + self._project_lib.minimizer = AvailableMinimizers.Bumps_simplex + else: + self._project_lib.minimizer = entry return True return False diff --git a/EasyReflectometryApp/Backends/Py/logic/parameters.py b/EasyReflectometryApp/Backends/Py/logic/parameters.py index 37ca5428..a22c8664 100644 --- a/EasyReflectometryApp/Backends/Py/logic/parameters.py +++ b/EasyReflectometryApp/Backends/Py/logic/parameters.py @@ -1,4 +1,6 @@ +import logging import re +from collections.abc import MutableSequence from typing import Any from typing import List from typing import Tuple @@ -6,11 +8,13 @@ from easyreflectometry import Project as ProjectLib from easyreflectometry.utils import count_fixed_parameters from easyreflectometry.utils import count_free_parameters -from easyscience import global_object +from easyscience.base_classes import ModelBase from easyscience.variable import Parameter from .helpers import get_original_name +logger = logging.getLogger(__name__) + RESERVED_ALIAS_NAMES = {'np', 'numpy', 'math', 'pi', 'e'} @@ -155,11 +159,16 @@ def set_current_parameter_value(self, new_value: str) -> bool: parameter = self._get_current_parameter() if parameter is None: return False - if float(new_value) != parameter.value: + try: + float_value = float(new_value) + except ValueError: + return False + if float_value != parameter.value: try: - parameter.value = float(new_value) - except ValueError: - pass + parameter.value = float_value + except (ValueError, TypeError): + logger.exception('Failed to set parameter value to %s', float_value) + return False return True return False @@ -167,11 +176,16 @@ def set_current_parameter_min(self, new_value: str) -> bool: parameter = self._get_current_parameter() if parameter is None: return False - if float(new_value) != parameter.min: + try: + float_value = float(new_value) + except ValueError: + return False + if float_value != parameter.min: try: - parameter.min = float(new_value) - except ValueError: - pass + parameter.min = float_value + except (ValueError, TypeError): + logger.exception('Failed to set parameter min to %s', float_value) + return False return True return False @@ -179,11 +193,16 @@ def set_current_parameter_max(self, new_value: str) -> bool: parameter = self._get_current_parameter() if parameter is None: return False - if float(new_value) != parameter.max: + try: + float_value = float(new_value) + except ValueError: + return False + if float_value != parameter.max: try: - parameter.max = float(new_value) - except ValueError: - pass + parameter.max = float_value + except (ValueError, TypeError): + logger.exception('Failed to set parameter max to %s', float_value) + return False return True return False @@ -224,10 +243,13 @@ def add_constraint( dependent.make_dependent_on(dependency_expression='a', dependency_map={'a': float(value)}) else: - print('Failed to add constraint: Unsupported type') + logger.warning('Failed to add constraint: Unsupported type') return - print(f'{dependent_idx}, {relational_operator}, {value}, {arithmetic_operator}, {independent_idx}') + logger.debug( + 'Added constraint: %s, %s, %s, %s, %s', + dependent_idx, relational_operator, value, arithmetic_operator, independent_idx, + ) def _from_parameters_to_list_of_dicts(parameters: List[Parameter], models) -> list[dict[str, Any]]: @@ -257,27 +279,26 @@ def _make_alias(name: str) -> str: alias_registry.add(alias) return alias - def _get_parameter_display_data(param: Parameter, model_unique_name: str) -> Tuple[str, str]: - """Extract display name and group from parameter path. + def _get_parameter_display_data(param: Parameter, path: list) -> Tuple[str, str]: + """Extract display name and group from the object path. For layer parameters (thickness, roughness), uses the assembly name from the path instead of the layer name, so that renaming an assembly in the Model Editor is reflected in the Analysis parameters table. """ - path = global_object.map.find_path(model_unique_name, param.unique_name) - if len(path) >= 2: - param_name = global_object.map.get_item_by_key(path[-1]).name + if path is not None and len(path) >= 2: + param_name = path[-1].name # For layer parameters the path is: # Model -> Sample -> Assembly -> LayerCollection -> Layer -> param # Use the assembly name (path[-4]) instead of the layer name (path[-2]) if _is_layer_parameter(param) and len(path) >= 4: - parent_name = global_object.map.get_item_by_key(path[-4]).name + parent_name = path[-4].name else: - parent_name = global_object.map.get_item_by_key(path[-2]).name + parent_name = path[-2].name return f'{parent_name} {param_name}', parent_name return param.name, '' # Fallback to parameter name without group - def _get_dependency_expression(param: Parameter, model_unique_name: str) -> str: + def _get_dependency_expression(param: Parameter, paths: dict) -> str: """Get simplified dependency expression.""" if param.independent: return '' @@ -286,7 +307,7 @@ def _get_dependency_expression(param: Parameter, model_unique_name: str) -> str: if hasattr(param, 'dependency_map') and 'a' in param.dependency_map: dependent_param = param.dependency_map['a'] if isinstance(dependent_param, Parameter): - dep_name, _ = _get_parameter_display_data(dependent_param, model_unique_name) + dep_name, _ = _get_parameter_display_data(dependent_param, paths.get(dependent_param.unique_name)) else: dep_name = str(dependent_param) return param.dependency_expression.replace('a', dep_name) @@ -302,12 +323,15 @@ def _is_layer_parameter(param: Parameter) -> bool: # Process parameters for each model for model_idx, model in enumerate(models): - model_unique_name = model.unique_name model_prefix = get_original_name(model) + # Object paths replace the global_object.map graph, which the new + # easyscience core no longer populates with parent->child edges. + paths = _build_param_object_paths(model) for parameter in parameters: - # Skip parameters not in this model's path - if not global_object.map.find_path(model_unique_name, parameter.unique_name): + # Skip parameters not in this model's tree + path = paths.get(parameter.unique_name) + if path is None: continue # For non-layer parameters, skip if already processed (they're shared across models) @@ -317,7 +341,7 @@ def _is_layer_parameter(param: Parameter) -> bool: continue processed_unique_names.add(parameter.unique_name) - display_name, group_name = _get_parameter_display_data(parameter, model_unique_name) + display_name, group_name = _get_parameter_display_data(parameter, path) # Add model prefix only to layer parameters (thickness, roughness) if is_layer_param: @@ -341,7 +365,7 @@ def _is_layer_parameter(param: Parameter) -> bool: 'units': parameter.unit, 'fit': parameter.free, 'independent': parameter.independent, - 'dependency': _get_dependency_expression(parameter, model_unique_name), + 'dependency': _get_dependency_expression(parameter, paths), 'enabled': parameter.enabled if hasattr(parameter, 'enabled') else True, 'object': parameter, # Direct reference to the Parameter object } @@ -350,6 +374,48 @@ def _is_layer_parameter(param: Parameter) -> bool: return parameter_list +def _build_param_object_paths(model) -> dict: + """Map each parameter's ``unique_name`` to its object chain ``[model, ..., parameter]``. + + Replaces ``global_object.map.find_path``: the new easyscience core + (``NewBase`` / ``ModelBase`` / ``EasyList``) registers vertices but no + parent->child edges, so the map graph can no longer be walked. The chain + mirrors the old map path, e.g. + ``Model -> Sample -> Assembly -> LayerCollection -> Layer -> [Material ->] Parameter``, + so the existing index logic (``path[-4]`` for the assembly, ``path[-2]`` + for the immediate parent) keeps working. + """ + paths: dict[str, list] = {} + visited: set[int] = {id(model)} + + def _children(obj) -> list: + # Collections expose their members by iteration, not as attributes. + if isinstance(obj, MutableSequence): + return list(obj) + children = [] + for attr_name in dir(obj): + if attr_name.startswith('_'): + continue + try: + value = getattr(obj, attr_name) + except Exception: + continue + if isinstance(value, (Parameter, ModelBase, MutableSequence)): + children.append(value) + return children + + def _visit(obj, chain: list) -> None: + for child in _children(obj): + if isinstance(child, Parameter): + paths.setdefault(child.unique_name, chain + [child]) + elif isinstance(child, (ModelBase, MutableSequence)) and id(child) not in visited: + visited.add(id(child)) + _visit(child, chain + [child]) + + _visit(model, [model]) + return paths + + def _is_experiment_parameter(parameter: dict[str, Any]) -> bool: searchable_text = ' '.join(str(parameter.get(key, '')) for key in ('name', 'display_name', 'group', 'unique_name')).lower() experiment_markers = ( diff --git a/EasyReflectometryApp/Backends/Py/logic/project.py b/EasyReflectometryApp/Backends/Py/logic/project.py index 05b71d4b..329a03a3 100644 --- a/EasyReflectometryApp/Backends/Py/logic/project.py +++ b/EasyReflectometryApp/Backends/Py/logic/project.py @@ -89,10 +89,29 @@ def experimental_data_at_current_index(self) -> bool: return experimental_data def _update_enablement_of_fixed_layers_for_model(self, index: int) -> None: + """Re-derive superphase/subphase layer enablement from assembly position. + + Enablement is position-based (superphase = first assembly, subphase = + last), so it must be recomputed whenever assemblies are added, removed, + or reordered. This method is idempotent: it first re-enables every layer, + then disables the fixed superphase/subphase parameters. A full reset is + safe because this is the only code path that disables layer enablement + (issue #329). + """ sample = self._project_lib.models[index].sample - sample[0].layers[0].thickness.enabled = False - sample[0].layers[0].roughness.enabled = False - sample[-1].layers[-1].thickness.enabled = False + for assembly in sample: + for layer in assembly.layers: + layer.thickness.enabled = True + layer.roughness.enabled = True + if len(sample) == 0: + return + # Superphase (first assembly, first layer): thickness and roughness fixed. + if len(sample[0].layers) > 0: + sample[0].layers[0].thickness.enabled = False + sample[0].layers[0].roughness.enabled = False + # Subphase (last assembly, last layer): thickness fixed. + if len(sample[-1].layers) > 0: + sample[-1].layers[-1].thickness.enabled = False def info(self) -> dict: info = copy(self._project_lib._info) diff --git a/EasyReflectometryApp/Backends/Py/logic/status.py b/EasyReflectometryApp/Backends/Py/logic/status.py index 89fc2fb7..19c9571b 100644 --- a/EasyReflectometryApp/Backends/Py/logic/status.py +++ b/EasyReflectometryApp/Backends/Py/logic/status.py @@ -29,3 +29,7 @@ def calculator(self): @property def experiments_count(self): return str(len(self._project_lib._experiments.keys())) + + @property + def models_count(self): + return str(len(self._project_lib._models)) diff --git a/EasyReflectometryApp/Backends/Py/plotting_1d.py b/EasyReflectometryApp/Backends/Py/plotting_1d.py index 86e11ca5..738accc4 100644 --- a/EasyReflectometryApp/Backends/Py/plotting_1d.py +++ b/EasyReflectometryApp/Backends/Py/plotting_1d.py @@ -27,6 +27,10 @@ class Plotting1d(QObject): sldAxisReversedChanged = Signal() referenceLineVisibilityChanged = Signal() + # Posterior predictive signal + posteriorPredictiveDataChanged = Signal() + posteriorPredictiveSldDataChanged = Signal() + def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) self._project_lib = project_lib @@ -43,6 +47,18 @@ def __init__(self, project_lib: ProjectLib, parent=None): self._scale_shown = False self._bkg_shown = False self._residual_range_cache = None + + # Posterior predictive state + self._posterior_q: list = [] + self._posterior_median: list = [] + self._posterior_lower: list = [] + self._posterior_upper: list = [] + + # Posterior predictive SLD state + self._posterior_sld_z: list = [] + self._posterior_sld_median: list = [] + self._posterior_sld_lower: list = [] + self._posterior_sld_upper: list = [] self._chartRefs = { 'QtCharts': { 'samplePage': { @@ -118,6 +134,9 @@ def togglePlotRQ4(self) -> None: self.sampleChartRangesChanged.emit() self.experimentChartRangesChanged.emit() self.samplePageDataChanged.emit() + # Notify QML to re-read posterior predictive properties + # so transforms are re-applied with the new RQ4 setting. + self.posteriorPredictiveDataChanged.emit() @Property(str, notify=plotModeChanged) def yMainAxisTitle(self) -> str: @@ -439,6 +458,9 @@ def experimentMaxY(self): if data.y.size == 0: return 1.0 y_values = self._apply_rq4(data.x, data.y) + y_values = y_values[y_values > 0] + if y_values.size == 0: + return 1.0 return np.log10(y_values.max()) @Property(float, notify=experimentChartRangesChanged) @@ -460,6 +482,19 @@ def _invalidate_residual_range_cache(self): """Clear the cached residual range so it is recomputed on next access.""" self._residual_range_cache = None + @staticmethod + def _compute_residual(calculated: float, measured: float, sigma: float) -> float: + """Compute residual value for a single data point. + + Uses a three-tier fallback: (calc−meas)/σ, then /meas, + then plain difference when both are unavailable. + """ + if sigma > 0.0: + return (calculated - measured) / sigma + if measured > 0.0: + return (calculated - measured) / measured + return calculated - measured + def _get_residual_range(self) -> tuple: """Return (min_x, max_x, min_y, max_y) for the residual chart. @@ -497,15 +532,8 @@ def _get_residual_range(self) -> tuple: aligned = self._get_aligned_analysis_values(exp_idx) for item in aligned: q = item['q'] - calc = item['calculated'] - meas = item['measured'] - sigma = item['sigma'] - if sigma > 0.0: - residual = (calc - meas) / sigma - elif meas > 0.0: - residual = (calc - meas) / meas - else: - residual = calc - meas + residual = self._compute_residual( + item['calculated'], item['measured'], item['sigma']) if min_x == float('inf'): min_x = q else: @@ -748,10 +776,8 @@ def getResidualDataPoints(self, experiment_index: int) -> list: try: points = [] for point in self._get_aligned_analysis_values(experiment_index): - sigma = point['sigma'] - residual = point['calculated'] - point['measured'] - if sigma > 0: - residual = residual / sigma + residual = self._compute_residual( + point['calculated'], point['measured'], point['sigma']) points.append({'x': point['q'], 'y': float(residual)}) return points except Exception as e: @@ -787,10 +813,13 @@ def drawCalculatedOnSampleChart(self): self.qtchartsReplaceCalculatedOnSampleChartAndRedraw() def qtchartsReplaceCalculatedOnSampleChartAndRedraw(self): - series = self._chartRefs['QtCharts']['samplePage']['sampleSerie'] - series.clear() + if not self._clear_qtcharts_series('samplePage', 'sampleSerie'): + return + series = self._qtcharts_series_ref('samplePage', 'sampleSerie') nr_points = 0 for point in self.sample_data.data_points(): + if point[1] <= 0: + continue series.append(point[0], np.log10(point[1])) nr_points = nr_points + 1 console.debug(IO.formatMsg('sub', 'Calc curve', f'{nr_points} points', 'on sample page', 'replaced')) @@ -907,3 +936,117 @@ def qtchartsReplaceCalculatedAndMeasuredOnAnalysisChartAndRedraw(self): series_calculated.append(q, np.log10(r_calc)) nr_points = nr_points + 1 console.debug(IO.formatMsg('sub', 'Calculated curve', f'{nr_points} points', 'on analysis page', 'replaced')) + + # ------------------------------------------------------------------ + # Posterior predictive (Bayesian) + # ------------------------------------------------------------------ + + @Property('QVariantList', notify=posteriorPredictiveDataChanged) + def posteriorPredictiveQ(self) -> list: + return self._posterior_q + + @Property('QVariantList', notify=posteriorPredictiveDataChanged) + def posteriorPredictiveMedian(self) -> list: + return self._transform_posterior_series( + self._posterior_q, self._posterior_median) + + @Property('QVariantList', notify=posteriorPredictiveDataChanged) + def posteriorPredictiveLower(self) -> list: + return self._transform_posterior_series( + self._posterior_q, self._posterior_lower) + + @Property('QVariantList', notify=posteriorPredictiveDataChanged) + def posteriorPredictiveUpper(self) -> list: + return self._transform_posterior_series( + self._posterior_q, self._posterior_upper) + + def _transform_posterior_series(self, q_list: list, y_list: list) -> list: + """Apply RQ4 and log10 transforms to a posterior predictive series. + + Transforms are applied at read time so that toggling plot mode + (RQ4 on/off) is reflected without re-publishing the data. + """ + if not y_list: + return [] + q = np.asarray(q_list, dtype=float) + y = np.asarray(y_list, dtype=float) + y = self._apply_rq4(q, y) + eps = 1e-30 + return np.where(y > 0, np.log10(y), np.log10(eps)).tolist() + + def set_posterior_predictive(self, q, median, lower, upper) -> None: + """Publish posterior predictive reflectivity curves to QML. + + Applies the same chart-space transforms (R(q)×q⁴, log10) used by + the existing analysis series, so the posterior overlay stays in sync + with plot-mode toggles. + """ + import numpy as np + + q = np.asarray(q, dtype=float) + median = np.asarray(median, dtype=float) + lower = np.asarray(lower, dtype=float) + upper = np.asarray(upper, dtype=float) + + # Store linear data — transforms applied at read time in property getters + # so that toggling RQ4 mode is reflected without re-publishing. + self._posterior_q = q.tolist() + self._posterior_median = median.tolist() + self._posterior_lower = lower.tolist() + self._posterior_upper = upper.tolist() + self.posteriorPredictiveDataChanged.emit() + + def clear_posterior_predictive(self) -> None: + """Clear the posterior predictive reflectivity data.""" + self._posterior_q = [] + self._posterior_median = [] + self._posterior_lower = [] + self._posterior_upper = [] + self.posteriorPredictiveDataChanged.emit() + + # ------------------------------------------------------------------ + # Posterior predictive SLD profile (Phase 2) + # ------------------------------------------------------------------ + + @Property('QVariantList', notify=posteriorPredictiveSldDataChanged) + def posteriorPredictiveSldZ(self) -> list: + return self._posterior_sld_z + + @Property('QVariantList', notify=posteriorPredictiveSldDataChanged) + def posteriorPredictiveSldMedian(self) -> list: + return self._posterior_sld_median + + @Property('QVariantList', notify=posteriorPredictiveSldDataChanged) + def posteriorPredictiveSldLower(self) -> list: + return self._posterior_sld_lower + + @Property('QVariantList', notify=posteriorPredictiveSldDataChanged) + def posteriorPredictiveSldUpper(self) -> list: + return self._posterior_sld_upper + + def set_posterior_predictive_sld(self, z, median, lower, upper) -> None: + """Publish posterior predictive SLD profile curves to QML. + + Unlike reflectivity, SLD data is published without any chart-space + transform, matching the existing SLD chart series convention. + """ + import numpy as np + + z = np.asarray(z, dtype=float) + median = np.asarray(median, dtype=float) + lower = np.asarray(lower, dtype=float) + upper = np.asarray(upper, dtype=float) + + self._posterior_sld_z = z.tolist() + self._posterior_sld_median = median.tolist() + self._posterior_sld_lower = lower.tolist() + self._posterior_sld_upper = upper.tolist() + self.posteriorPredictiveSldDataChanged.emit() + + def clear_posterior_predictive_sld(self) -> None: + """Clear the posterior predictive SLD data.""" + self._posterior_sld_z = [] + self._posterior_sld_median = [] + self._posterior_sld_lower = [] + self._posterior_sld_upper = [] + self.posteriorPredictiveSldDataChanged.emit() diff --git a/EasyReflectometryApp/Backends/Py/project.py b/EasyReflectometryApp/Backends/Py/project.py index 5ebf592a..50337442 100644 --- a/EasyReflectometryApp/Backends/Py/project.py +++ b/EasyReflectometryApp/Backends/Py/project.py @@ -23,6 +23,7 @@ class Project(QObject): externalProjectLoaded = Signal() externalProjectReset = Signal() sampleLoadWarning = Signal(str) + projectLoadError = Signal(str) def __init__(self, project_lib: ProjectLib, parent=None): super().__init__(parent) @@ -85,7 +86,22 @@ def create(self) -> None: @Slot(str) def load(self, path: str) -> None: - self._logic.load(IO.generalizePath(path)) + try: + self._logic.load(IO.generalizePath(path)) + except ValueError as ex: + # easyreflectometry rejects project files whose file_format predates + # the current schema. Show a user-facing message for that case and + # surface any other unreadable-JSON error verbatim, rather than + # letting it propagate uncaught. + if 'file_format' in str(ex): + message = ( + 'This project file uses obsolete and unsupported format.\n' + 'Please re-create the project from its underlying data and save it again.' + ) + else: + message = str(ex) + self.projectLoadError.emit(message) + return self.createdChanged.emit() self.nameChanged.emit() self.descriptionChanged.emit() @@ -109,8 +125,11 @@ def reset(self) -> None: @Slot(str, bool) def sampleLoad(self, url: str, append: bool = True) -> None: - # Load ORSO file content - orso_data = orso.load_orso(IO.generalizePath(url)) + try: + orso_data = orso.load_orso(IO.generalizePath(url)) + except Exception as ex: + self.projectLoadError.emit(f'Failed to load ORSO file: {ex}') + return # Load the sample model with warnings.catch_warnings(record=True) as caught_warnings: warnings.simplefilter('always') diff --git a/EasyReflectometryApp/Backends/Py/py_backend.py b/EasyReflectometryApp/Backends/Py/py_backend.py index f7ea0706..36aee0e8 100644 --- a/EasyReflectometryApp/Backends/Py/py_backend.py +++ b/EasyReflectometryApp/Backends/Py/py_backend.py @@ -41,6 +41,7 @@ def __init__(self, parent=None): # Wire cross-cutting references before connecting signals self._status._status_logic.set_minimizers_logic(self._analysis._minimizers_logic) + self._analysis.set_plotting(self._plotting_1d) # Must be last to ensure all backend parts are created self._connect_backend_parts() @@ -197,6 +198,8 @@ def _relay_project_page_project_changed(self): self._sample._clearCacheAndEmitLayersChanged() self._sample.materialsTableChanged.emit() self._sample.modelsTableChanged.emit() + # Notify summary that paths have changed (project path changed) + self._summary.refreshPaths() self._sample.modelsIndexChanged.emit() self._sample.assembliesTableChanged.emit() self._sample.assembliesIndexChanged.emit() diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index d3da25c7..7c4dcd93 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -1,3 +1,4 @@ +import logging import math import numbers import re @@ -21,6 +22,8 @@ from .logic.parameters import Parameters as ParametersLogic from .logic.project import Project as ProjectLogic +logger = logging.getLogger(__name__) + _ASTEVAL_CONFIG = { 'import': False, 'importfrom': False, @@ -364,6 +367,7 @@ def setCurrentAssemblyConformalRoughness(self, new_value: bool) -> None: def removeAssembly(self, value: str) -> None: self._assemblies_logic.remove_at_index(value) self._refreshCurrentAssemblySelectionState() + self._project_logic._update_enablement_of_fixed_layers_for_model(self._models_logic.index) self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @@ -372,6 +376,7 @@ def removeAssembly(self, value: str) -> None: def addNewAssembly(self) -> None: self._assemblies_logic.add_new() self._refreshCurrentAssemblySelectionState() + self._project_logic._update_enablement_of_fixed_layers_for_model(self._models_logic.index) self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @@ -380,6 +385,7 @@ def addNewAssembly(self) -> None: def duplicateSelectedAssembly(self) -> None: self._assemblies_logic.duplicate_selected() self._refreshCurrentAssemblySelectionState() + self._project_logic._update_enablement_of_fixed_layers_for_model(self._models_logic.index) self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() self.externalSampleChanged.emit() @@ -388,6 +394,7 @@ def duplicateSelectedAssembly(self) -> None: def moveSelectedAssemblyUp(self) -> None: self._assemblies_logic.move_selected_up() self._refreshCurrentAssemblySelectionState() + self._project_logic._update_enablement_of_fixed_layers_for_model(self._models_logic.index) self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() @@ -395,6 +402,7 @@ def moveSelectedAssemblyUp(self) -> None: def moveSelectedAssemblyDown(self) -> None: self._assemblies_logic.move_selected_down() self._refreshCurrentAssemblySelectionState() + self._project_logic._update_enablement_of_fixed_layers_for_model(self._models_logic.index) self.assembliesTableChanged.emit() self.externalRefreshPlot.emit() @@ -904,6 +912,9 @@ def constraintsList(self) -> list[dict[str, str]]: constraints.append( { 'dependentName': dependent_display, + # Carry the unique_name so removal keys off identity, not the + # (possibly duplicated) display name (issue #328). + 'uniqueName': getattr(parameter_obj, 'unique_name', '') or '', 'expression': expression_display, 'rawExpression': raw_expression, 'relation': relation, @@ -926,14 +937,17 @@ def removeConstraintByIndex(self, index: int) -> None: if index >= len(constraints_list): return - param_name = constraints_list[index]['dependentName'] - param_obj = self._find_parameter_object_by_name(param_name) + # Resolve by unique_name (parameter identity), not display name, so two + # parameters sharing a display name don't collide (issue #328). + unique_name = constraints_list[index].get('uniqueName') + if not unique_name: + return + param_obj = self._find_parameter_object_by_unique_name(unique_name) if param_obj is None: return - unique_name = getattr(param_obj, 'unique_name', None) - state = self._constraint_states.pop(unique_name, None) if unique_name is not None else None + state = self._constraint_states.pop(unique_name, None) if state and 'previous' in state: self._restore_parameter_state(param_obj, state['previous']) @@ -943,36 +957,11 @@ def removeConstraintByIndex(self, index: int) -> None: self.externalSampleChanged.emit() self.layersChange.emit() - def _find_parameter_object_by_name(self, param_name: str): - """Find parameter object by name. - - Handles both regular names ('SiO2 sld') and model-prefixed names ('M2 SiO2 sld'). - """ - parameters = self._parameters_logic.parameters - - # Direct match by display name - for param in parameters: - if param['name'] == param_name: + def _find_parameter_object_by_unique_name(self, unique_name: str): + """Find a parameter object by its unique_name (stable identity).""" + for param in self._parameters_logic.parameters: + if param.get('unique_name') == unique_name: return param['object'] - - # Check constraint states for model-prefixed dependent_display - for unique_name, state in self._constraint_states.items(): - if state.get('dependent_display') == param_name: - # Find the parameter by unique_name - for param in parameters: - if param.get('unique_name') == unique_name: - return param['object'] - - # Try stripping model prefix (e.g., 'M2 SiO2 sld' -> 'SiO2 sld') - import re - - prefix_match = re.match(r'^M\d+\s+(.+)$', param_name) - if prefix_match: - stripped_name = prefix_match.group(1) - for param in parameters: - if param['name'] == stripped_name: - return param['object'] - return None def _make_parameter_independent(self, param_obj) -> None: @@ -1090,7 +1079,7 @@ def constrainModelsParameters(self, model_indices: list) -> None: num_models = len(self._project_lib._models) for idx in model_indices: if idx < 0 or idx >= num_models: - print(f'Invalid model index: {idx}') + logger.warning('Invalid model index: %s', idx) return # Get the reference model (first in the sorted list) @@ -1150,7 +1139,7 @@ def constrainModelsParameters(self, model_indices: list) -> None: constraints_added += 1 except Exception as e: # noqa: BLE001 - print(f'Failed to constrain parameter {param_path}: {e}') + logger.warning('Failed to constrain parameter %s: %s', param_path, e) continue if constraints_added > 0: @@ -1172,7 +1161,7 @@ def _build_model_parameters_map(self, model) -> Dict[str, DescriptorNumber]: for layer_idx, layer in enumerate(assembly.layers): # layer_name = layer.name # Get layer parameters - for param in layer.get_parameters(): + for param in layer.get_all_parameters(): param_name = param.name # Create a structural path that's independent of model name path_key = f'{assembly_idx}/{layer_idx}/{param_name}' diff --git a/EasyReflectometryApp/Backends/Py/status.py b/EasyReflectometryApp/Backends/Py/status.py index 0809920a..87cddfab 100644 --- a/EasyReflectometryApp/Backends/Py/status.py +++ b/EasyReflectometryApp/Backends/Py/status.py @@ -40,5 +40,5 @@ def variables(self): return self._parameters_logic.as_status_string @Property(str, notify=statusChanged) - def phaseCount(self): - return None + def modelsCount(self): + return self._status_logic.models_count diff --git a/EasyReflectometryApp/Backends/Py/summary.py b/EasyReflectometryApp/Backends/Py/summary.py index c3c43049..567743c8 100644 --- a/EasyReflectometryApp/Backends/Py/summary.py +++ b/EasyReflectometryApp/Backends/Py/summary.py @@ -73,6 +73,16 @@ def asHtml(self): def exportFormats(self): return ['HTML', 'PDF'] + @Slot() + def refreshPaths(self) -> None: + """Re-emit path-related signals so QML bindings re-evaluate. + + Call this whenever the project path changes so that filePath, + fileUrl, plotFilePath and plotFileUrl stay in sync. + """ + self.fileNameChanged.emit() + self.plotFileNameChanged.emit() + @Slot(str) def saveAsHtml(self, path: str = '') -> None: try: diff --git a/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py b/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py index 306ab5ed..47cd2240 100644 --- a/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py +++ b/EasyReflectometryApp/Backends/Py/workers/fitter_worker.py @@ -97,14 +97,16 @@ def run(self) -> None: # Get the method and call it method = getattr(self._fitter, self._method_name) kwargs = dict(self._kwargs) - if self._method_name == 'fit' and 'progress_callback' not in kwargs: + if self._method_name in ('fit', 'mcmc_sample') and 'progress_callback' not in kwargs: kwargs['progress_callback'] = self._progress_callback + if self._method_name == 'mcmc_sample' and 'abort_test' not in kwargs: + kwargs['abort_test'] = lambda: self._stop_requested + elif self._method_name == 'fit' and 'abort_test' not in kwargs and self._uses_bumps_minimizer(): + kwargs['abort_test'] = lambda: self._stop_requested 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 - # algorithm mid-execution since lmfit/scipy don't support cancellation callbacks. - # The effective cancellation window is only before the fit starts (checked above). + # For BUMPS fit/mcmc_sample, abort_test interrupts mid-run and method() returns early; + # for lmfit/dfo this still only catches stops requested after the fit completed. if self._stop_requested: self.failed.emit('Fitting cancelled by user') return @@ -127,28 +129,24 @@ def _progress_callback(self, payload: dict) -> bool: self.progressDetail.emit(dict(payload)) return not self._stop_requested + def _uses_bumps_minimizer(self) -> bool: + """Return True if the underlying minimizer is BUMPS (and therefore accepts abort_test).""" + minimizer = getattr(self._fitter, 'minimizer', None) + return getattr(minimizer, 'package', None) == 'bumps' + def stop(self) -> None: """ Request the fitting operation to stop. - This sets a flag that is checked during execution and also - terminates the thread if it's still running. Call wait() after - this to ensure proper thread cleanup. - - .. warning:: - DANGEROUS: This method uses QThread.terminate() which is strongly - discouraged by Qt documentation. It can: - - Leave mutex locks held indefinitely causing deadlocks - - Corrupt data structures mid-operation - - Prevent proper cleanup of resources (especially numpy arrays, scipy internals) - - Cause memory leaks and undefined behavior - - The fitting libraries (lmfit, scipy) do not support graceful cancellation. - The stop flag is only effective BEFORE the fit starts - once the fitting - algorithm is running, it cannot be interrupted cleanly. + This only sets a flag; the thread is **not** terminated. The flag is + checked between iterations via the BUMPS ``abort_test`` / progress + callback, so cancellation is cooperative. - See THREAD_TERMINATION_WARNING.md for details on known issues and - potential future improvements (e.g., using subprocess instead of QThread). + .. note:: + The stop flag is effective only for minimizers that poll it (BUMPS). + Other fitting backends (lmfit, scipy) do not support graceful + cancellation, so once such a fit is running it cannot be interrupted + and will finish before the stop takes effect. """ self._stop_requested = True diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 4154ba9a..d8fc7fba 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -42,7 +42,7 @@ QtObject { // Status bar ///////////// readonly property string statusProject: activeBackend.status.project - readonly property string statusPhaseCount: activeBackend.status.phaseCount + readonly property string statusModelsCount: activeBackend.status.modelsCount readonly property string statusExperimentsCount: activeBackend.status.experimentsCount readonly property string statusCalculator: activeBackend.status.calculator readonly property string statusMinimizer: activeBackend.status.minimizer @@ -90,6 +90,16 @@ QtObject { return null } + // Project load error signal - forwarded from backend + signal projectLoadError(string message) + + property var _projectLoadErrorConnection: { + if (activeBackend && activeBackend.project && activeBackend.project.projectLoadError) { + activeBackend.project.projectLoadError.connect(projectLoadError) + } + return null + } + /////////////// // Sample page @@ -299,6 +309,44 @@ QtObject { function analysisSetShowFitResultsDialog(value) { activeBackend.analysis.setShowFitResultsDialog(value) } function analysisStopFit() { activeBackend.analysis.stopFit() } + // Bayesian sampling + readonly property bool analysisIsBayesianSelected: activeBackend.analysis.isBayesianSelected + + readonly property int bayesianSamples: activeBackend.analysis.bayesianSamples + readonly property int bayesianBurnIn: activeBackend.analysis.bayesianBurnIn + readonly property int bayesianPopulation: activeBackend.analysis.bayesianPopulation + readonly property int bayesianThinning: activeBackend.analysis.bayesianThinning + + function bayesianSetSamples(v) { activeBackend.analysis.setBayesianSamples(v) } + function bayesianSetBurnIn(v) { activeBackend.analysis.setBayesianBurnIn(v) } + function bayesianSetPopulation(v) { activeBackend.analysis.setBayesianPopulation(v) } + function bayesianSetThinning(v) { activeBackend.analysis.setBayesianThinning(v) } + + readonly property string bayesianInitializer: activeBackend.analysis.bayesianInitializer + readonly property var bayesianInitializerOptions: activeBackend.analysis.bayesianInitializerOptions + function setBayesianInitializer(v) { activeBackend.analysis.setBayesianInitializer(v) } + + readonly property var bayesianPosterior: activeBackend.analysis.bayesianPosterior + readonly property bool bayesianResultAvailable: activeBackend.analysis.bayesianResultAvailable + readonly property var bayesianMarginals: activeBackend.analysis.bayesianMarginals + + // Phase 2: corner/trace/distribution plots (HTML), diagnostics, heatmap + readonly property string bayesianCornerPlotUrl: activeBackend.analysis.bayesianCornerPlotUrl + readonly property string bayesianTracePlotUrl: activeBackend.analysis.bayesianTracePlotUrl + readonly property string bayesianDistributionPlotUrl: activeBackend.analysis.bayesianDistributionPlotUrl + readonly property var bayesianDiagnostics: activeBackend.analysis.bayesianDiagnostics + readonly property var bayesianParamNames: activeBackend.analysis.bayesianParamNames + readonly property var bayesianHeatmapData: activeBackend.analysis.bayesianHeatmapData + readonly property string bayesianHeatmapPlotUrl: activeBackend.analysis.bayesianHeatmapPlotUrl + function bayesianComputeHeatmap(x, y) { activeBackend.analysis.computeBayesianHeatmap(x, y) } + function bayesianSavePlot(sourceUrl) { activeBackend.analysis.saveBayesianPlot(sourceUrl) } + + // Phase 2: SLD posterior predictive + readonly property var posteriorPredictiveSldZ: activeBackend.plotting.posteriorPredictiveSldZ + readonly property var posteriorPredictiveSldMedian: activeBackend.plotting.posteriorPredictiveSldMedian + readonly property var posteriorPredictiveSldLower: activeBackend.plotting.posteriorPredictiveSldLower + readonly property var posteriorPredictiveSldUpper: activeBackend.plotting.posteriorPredictiveSldUpper + // Fit failure signal - forwarded from backend signal analysisFitFailed(string message) @@ -397,6 +445,12 @@ QtObject { readonly property bool plottingScaleShown: activeBackend.plotting.scaleShown readonly property bool plottingBkgShown: activeBackend.plotting.bkgShown + // Posterior predictive (Bayesian) overlay data + readonly property var posteriorPredictiveQ: activeBackend.plotting.posteriorPredictiveQ + readonly property var posteriorPredictiveMedian: activeBackend.plotting.posteriorPredictiveMedian + readonly property var posteriorPredictiveLower: activeBackend.plotting.posteriorPredictiveLower + readonly property var posteriorPredictiveUpper: activeBackend.plotting.posteriorPredictiveUpper + // Plot mode toggle functions function plottingTogglePlotRQ4() { activeBackend.plotting.togglePlotRQ4() } function plottingToggleXAxisType() { activeBackend.plotting.toggleXAxisType() } @@ -500,6 +554,10 @@ QtObject { signal plotModeChanged() // Signal to request QML to reset chart axes (e.g., after model load) signal chartAxesResetRequested() + // Signal for posterior predictive (Bayesian) overlay data updates + signal posteriorPredictiveDataChanged() + // Signal for posterior predictive SLD (Bayesian) overlay data updates + signal posteriorPredictiveSldDataChanged() // Connect to backend signal (called from Component.onCompleted in QML items) function connectSamplePageDataChanged() { @@ -515,6 +573,12 @@ QtObject { if (activeBackend && activeBackend.plotting && activeBackend.plotting.chartAxesResetRequested) { activeBackend.plotting.chartAxesResetRequested.connect(chartAxesResetRequested) } + if (activeBackend && activeBackend.plotting && activeBackend.plotting.posteriorPredictiveDataChanged) { + activeBackend.plotting.posteriorPredictiveDataChanged.connect(posteriorPredictiveDataChanged) + } + if (activeBackend && activeBackend.plotting && activeBackend.plotting.posteriorPredictiveSldDataChanged) { + activeBackend.plotting.posteriorPredictiveSldDataChanged.connect(posteriorPredictiveSldDataChanged) + } } Component.onCompleted: { @@ -562,4 +626,10 @@ QtObject { return [] } } + + // Bayesian sampling progress + readonly property int analysisSampleProgressStep: activeBackend.analysis.sampleProgressStep + readonly property string analysisSampleProgressMessage: activeBackend.analysis.sampleProgressMessage + readonly property bool analysisSampleProgressHasUpdate: activeBackend.analysis.sampleProgressHasUpdate + readonly property int analysisSampleProgressTotalSteps: activeBackend.analysis.sampleProgressTotalSteps } diff --git a/EasyReflectometryApp/Gui/Globals/Variables.qml b/EasyReflectometryApp/Gui/Globals/Variables.qml index f0ae2cd1..7ef26008 100644 --- a/EasyReflectometryApp/Gui/Globals/Variables.qml +++ b/EasyReflectometryApp/Gui/Globals/Variables.qml @@ -21,9 +21,10 @@ QtObject { property int experimentMarkerStyle: 0 // 0: dots, 1: circles, 2: line // Shared experiment color palette — used by Data Explorer table, Experiment chart, and Analysis charts + // Muted/pastel palette to match the application's professional aesthetic readonly property var experimentColorPalette: [ - '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', - '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf' + '#7BA6C4', '#E8B889', '#8DBF8D', '#D48787', '#B296B8', + '#A68F7F', '#D4A8BC', '#A5A5A5', '#B8B87D', '#7BB8B8' ] function experimentColor(index) { return experimentColorPalette[index % experimentColorPalette.length] diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml index 98b23300..3ebb4ff6 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml @@ -19,13 +19,21 @@ EaComponents.ContentPage { mainView: EaComponents.MainContent { tabs: [ - EaElements.TabButton { text: qsTr('Reflectivity') } + EaElements.TabButton { text: qsTr('Reflectivity') }, + EaElements.TabButton { + text: qsTr('Bayesian Posterior') + enabled: Globals.BackendWrapper.bayesianResultAvailable + } ] items: [ Loader { source: `MainContent/CombinedView.qml` onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) + }, + Loader { + source: `MainContent/BayesianPosteriorView.qml` + onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) } ] } @@ -46,7 +54,7 @@ EaComponents.ContentPage { enabled: Globals.BackendWrapper.analysisExperimentsAvailable.length wide: true fontIcon: Globals.BackendWrapper.analysisFittingRunning ? 'stop-circle' : 'play-circle' - text: Globals.BackendWrapper.analysisFittingRunning ? qsTr('Cancel fitting') : qsTr('Start fitting') + text: Globals.BackendWrapper.analysisFittingRunning ? qsTr('Cancel fitting') : (Globals.BackendWrapper.analysisIsBayesianSelected ? qsTr('Start sampling') : qsTr('Start fitting')) onClicked: { console.debug(`Clicking '${text}' button: ${this}`) diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml index 943bec9a..74baab0f 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/AnalysisView.qml @@ -43,6 +43,9 @@ Rectangle { anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 useOpenGL: EaGlobals.Vars.useOpenGL + + // Disable built-in Qt Charts legend - we use our custom legend instead + legend.visible: false // Background reference line series LineSeries { @@ -81,6 +84,77 @@ Rectangle { Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, true) } + // Posterior predictive overlay (Bayesian) + LineSeries { + id: ppUpperSerie + axisX: chartView.currentXAxis() + axisY: chartView.axisY + visible: Globals.BackendWrapper.bayesianResultAvailable + } + + LineSeries { + id: ppLowerSerie + axisX: chartView.currentXAxis() + axisY: chartView.axisY + visible: Globals.BackendWrapper.bayesianResultAvailable + } + + LineSeries { + id: ppMedianSerie + name: qsTr("Posterior median") + axisX: chartView.currentXAxis() + axisY: chartView.axisY + color: "#E67E22" + width: 5 + visible: Globals.BackendWrapper.bayesianResultAvailable + } + + AreaSeries { + id: ppBandSerie + name: qsTr("95% credible interval") + axisX: chartView.currentXAxis() + axisY: chartView.axisY + color: Qt.rgba(0.902, 0.494, 0.133, 0.25) // orange with alpha + borderWidth: 0 + upperSeries: ppUpperSerie + lowerSeries: ppLowerSerie + visible: Globals.BackendWrapper.bayesianResultAvailable + } + + Connections { + target: Globals.BackendWrapper + function onPosteriorPredictiveDataChanged() { + chartView.refreshPosteriorPredictiveOverlay() + } + } + + function refreshPosteriorPredictiveOverlay() { + ppMedianSerie.clear() + ppUpperSerie.clear() + ppLowerSerie.clear() + const q = Globals.BackendWrapper.posteriorPredictiveQ + const m = Globals.BackendWrapper.posteriorPredictiveMedian + const lo = Globals.BackendWrapper.posteriorPredictiveLower + const hi = Globals.BackendWrapper.posteriorPredictiveUpper + console.log("AnalysisView.refreshPosteriorPredictiveOverlay: q.length=" + (q ? q.length : "null") + + " m.length=" + (m ? m.length : "null") + + " bayesianResultAvailable=" + Globals.BackendWrapper.bayesianResultAvailable) + if (!q || !m || !lo || !hi) { + console.warn("AnalysisView.refreshPosteriorPredictiveOverlay: posterior data is null/empty") + return + } + if (q.length === 0) { + console.warn("AnalysisView.refreshPosteriorPredictiveOverlay: posterior data arrays are empty") + return + } + for (let i = 0; i < q.length; ++i) { + ppMedianSerie.append(q[i], m[i]) + ppLowerSerie.append(q[i], lo[i]) + ppUpperSerie.append(q[i], hi[i]) + } + console.log("AnalysisView.refreshPosteriorPredictiveOverlay: appended " + q.length + " points") + } + // Scatter series for measured data (single experiment, linear mode) property var measuredScatterSerie: null @@ -235,6 +309,7 @@ Rectangle { updateReferenceLines() Qt.callLater(resetAxes) + Qt.callLater(refreshPosteriorPredictiveOverlay) } function resetAxes() { @@ -511,6 +586,36 @@ Rectangle { color: chartView.calcSerie.color } + // Bayesian posterior predictive legend + Row { + visible: !chartView.isMultiExperimentMode && Globals.BackendWrapper.bayesianResultAvailable + spacing: EaStyle.Sizes.fontPixelSize * 0.3 + Rectangle { + width: EaStyle.Sizes.fontPixelSize * 1.2 + height: 2 + color: "#E67E22" + anchors.verticalCenter: parent.verticalCenter + } + EaElements.Label { + text: qsTr("Posterior median") + color: EaStyle.Colors.themeForegroundMinor + } + } + Row { + visible: !chartView.isMultiExperimentMode && Globals.BackendWrapper.bayesianResultAvailable + spacing: EaStyle.Sizes.fontPixelSize * 0.3 + Rectangle { + width: EaStyle.Sizes.fontPixelSize * 1.2 + height: EaStyle.Sizes.fontPixelSize * 0.6 + color: Qt.rgba(0.902, 0.494, 0.133, 0.25) + anchors.verticalCenter: parent.verticalCenter + } + EaElements.Label { + text: qsTr("95% credible interval") + color: EaStyle.Colors.themeForegroundMinor + } + } + // Multi-experiment legend Column { visible: chartView.isMultiExperimentMode @@ -595,6 +700,7 @@ Rectangle { Globals.BackendWrapper.plottingRefreshAnalysis() } } + Qt.callLater(refreshPosteriorPredictiveOverlay) } // Data is set in python backend (plotting_1d.py) @@ -624,6 +730,9 @@ Rectangle { // Initialize reference lines updateReferenceLines() + + // Initialize posterior predictive overlay + refreshPosteriorPredictiveOverlay() } // Update series when chart becomes visible @@ -645,6 +754,7 @@ Rectangle { Globals.BackendWrapper.plottingRefreshAnalysis() } updateReferenceLines() + refreshPosteriorPredictiveOverlay() } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianCornerView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianCornerView.qml new file mode 100644 index 00000000..e8c00333 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianCornerView.qml @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtWebEngine + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +import Gui.Globals as Globals + + +Rectangle { + id: container + + color: EaStyle.Colors.chartBackground + + ColumnLayout { + anchors.fill: parent + anchors.margins: EaStyle.Sizes.fontPixelSize + spacing: EaStyle.Sizes.fontPixelSize + + RowLayout { + Layout.fillWidth: true + visible: Globals.BackendWrapper.bayesianCornerPlotUrl !== '' + + EaElements.Label { + Layout.fillWidth: true + text: qsTr("Pairwise Parameter Correlations & Marginal Distributions") + font: EaStyle.Fonts.headingFont + } + + EaElements.Button { + text: qsTr("Save") + onClicked: Globals.BackendWrapper.bayesianSavePlot( + Globals.BackendWrapper.bayesianCornerPlotUrl + ) + } + } + + WebEngineView { + id: cornerWebView + Layout.fillWidth: true + Layout.fillHeight: true + visible: Globals.BackendWrapper.bayesianCornerPlotUrl !== '' + url: Globals.BackendWrapper.bayesianCornerPlotUrl || 'about:blank' + settings.showScrollBars: true + + BusyIndicator { + anchors.centerIn: parent + running: Globals.BackendWrapper.bayesianCornerPlotUrl === '' + && Globals.BackendWrapper.bayesianResultAvailable + } + } + + // Placeholder: no results available + Item { + Layout.fillWidth: true + Layout.fillHeight: true + visible: !Globals.BackendWrapper.bayesianResultAvailable + + EaElements.Label { + anchors.centerIn: parent + text: qsTr("No Bayesian results available.") + color: EaStyle.Colors.themeForegroundMinor + } + } + + // Placeholder: results available but no plot rendered yet + Item { + Layout.fillWidth: true + Layout.fillHeight: true + visible: Globals.BackendWrapper.bayesianCornerPlotUrl === '' + && Globals.BackendWrapper.bayesianResultAvailable + + ColumnLayout { + anchors.centerIn: parent + spacing: EaStyle.Sizes.fontPixelSize + + EaElements.Label { + Layout.fillWidth: true + text: qsTr("Corner plot is being rendered…") + color: EaStyle.Colors.themeForegroundMinor + horizontalAlignment: Text.AlignHCenter + } + + EaElements.Label { + Layout.fillWidth: true + text: qsTr("Interactive corner plots require plotly. Install it with:
" + + "pip install plotly") + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + } + } + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianDiagnosticsView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianDiagnosticsView.qml new file mode 100644 index 00000000..eb2b9ed0 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianDiagnosticsView.qml @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +import Gui.Globals as Globals + + +Rectangle { + id: container + + color: EaStyle.Colors.chartBackground + + Flickable { + anchors.fill: parent + anchors.margins: EaStyle.Sizes.fontPixelSize + contentHeight: diagColumn.implicitHeight + EaStyle.Sizes.fontPixelSize * 2 + clip: true + + ColumnLayout { + id: diagColumn + width: parent.width + spacing: EaStyle.Sizes.fontPixelSize + + EaElements.Label { + Layout.fillWidth: true + text: qsTr("MCMC Convergence Diagnostics") + font: EaStyle.Fonts.headingFont + visible: Globals.BackendWrapper.bayesianResultAvailable + } + + // Placeholder: no results available + Item { + Layout.fillWidth: true + Layout.fillHeight: true + visible: !Globals.BackendWrapper.bayesianResultAvailable + + EaElements.Label { + anchors.centerIn: parent + text: qsTr("No Bayesian results available.") + color: EaStyle.Colors.themeForegroundMinor + } + } + + // Sampling configuration + EaElements.GroupBox { + title: qsTr("Sampling Configuration") + visible: Globals.BackendWrapper.bayesianResultAvailable + Layout.fillWidth: true + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Repeater { + model: [ + { label: qsTr("Requested samples"), key: "samples" }, + { label: qsTr("Burn-in steps"), key: "burnIn" }, + { label: qsTr("Thinning"), key: "thin" }, + { label: qsTr("Population (chains)"), key: "population" }, + { label: qsTr("Retained draws"), key: "nDraws" }, + { label: qsTr("Parameters"), key: "nParams" }, + ] + + delegate: RowLayout { + Layout.fillWidth: true + EaElements.Label { + text: modelData.label + Layout.fillWidth: true + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + } + EaElements.Label { + text: { + var diag = Globals.BackendWrapper.bayesianDiagnostics + return diag && diag[modelData.key] !== undefined + ? diag[modelData.key] : '—' + } + font.bold: true + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + } + } + } + } + } + + // Acceptance rate + EaElements.GroupBox { + title: qsTr("Acceptance Rate") + visible: Globals.BackendWrapper.bayesianResultAvailable + && Globals.BackendWrapper.bayesianDiagnostics.acceptanceRate !== undefined + Layout.fillWidth: true + + EaElements.Label { + text: { + var rate = Globals.BackendWrapper.bayesianDiagnostics.acceptanceRate + return rate !== undefined ? (rate * 100).toFixed(1) + '%' : '—' + } + font.bold: true + } + } + + // Gelman-Rubin R-hat + EaElements.GroupBox { + title: qsTr("Gelman-Rubin R̂ (Convergence)") + visible: Globals.BackendWrapper.bayesianResultAvailable + && Globals.BackendWrapper.bayesianDiagnostics.rhat !== undefined + Layout.fillWidth: true + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Repeater { + model: { + var diag = Globals.BackendWrapper.bayesianDiagnostics + return diag && diag.rhat ? Object.keys(diag.rhat) : [] + } + + delegate: RowLayout { + Layout.fillWidth: true + EaElements.Label { + text: modelData + Layout.fillWidth: true + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + } + EaElements.Label { + text: { + var diag = Globals.BackendWrapper.bayesianDiagnostics + return diag && diag.rhat && diag.rhat[modelData] !== undefined + ? diag.rhat[modelData].toFixed(4) : '—' + } + font.bold: true + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + color: { + var diag = Globals.BackendWrapper.bayesianDiagnostics + if (diag && diag.rhat && diag.rhat[modelData] !== undefined) { + return diag.rhat[modelData] < 1.1 + ? (EaStyle.Colors.themeAccent || "#27ae60") + : (EaStyle.Colors.warning || "#e67e22") + } + return EaStyle.Colors.themeForegroundMinor + } + } + } + } + } + } + + EaElements.GroupBox { + title: qsTr("Gelman-Rubin R̂ (Convergence)") + visible: Globals.BackendWrapper.bayesianResultAvailable + && Globals.BackendWrapper.bayesianDiagnostics.rhat === undefined + && Globals.BackendWrapper.bayesianDiagnostics.rhatStatus !== undefined + Layout.fillWidth: true + + EaElements.Label { + text: { + var diag = Globals.BackendWrapper.bayesianDiagnostics + return diag && diag.rhatStatus !== undefined ? String(diag.rhatStatus) : '' + } + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.WordWrap + } + } + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianHeatmapView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianHeatmapView.qml new file mode 100644 index 00000000..2c5fe8f5 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianHeatmapView.qml @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +import Gui.Globals as Globals + + +Rectangle { + id: container + + color: EaStyle.Colors.chartBackground + + onVisibleChanged: if (visible) updateHeatmap() + + ColumnLayout { + anchors.fill: parent + anchors.margins: EaStyle.Sizes.fontPixelSize + spacing: EaStyle.Sizes.fontPixelSize + + // Parameter selection row + save button + RowLayout { + Layout.fillWidth: true + visible: Globals.BackendWrapper.bayesianResultAvailable + + EaElements.Label { + text: qsTr("X-axis:") + } + EaElements.ComboBox { + id: paramXCombo + Layout.fillWidth: true + model: Globals.BackendWrapper.bayesianParamNames + onCurrentIndexChanged: updateHeatmap() + } + EaElements.Label { + text: qsTr("Y-axis:") + } + EaElements.ComboBox { + id: paramYCombo + Layout.fillWidth: true + model: Globals.BackendWrapper.bayesianParamNames + currentIndex: Math.min(1, model ? model.length - 1 : 0) + onCurrentIndexChanged: updateHeatmap() + } + + EaElements.Button { + text: qsTr("Save") + enabled: Globals.BackendWrapper.bayesianHeatmapPlotUrl !== '' + onClicked: Globals.BackendWrapper.bayesianSavePlot( + Globals.BackendWrapper.bayesianHeatmapPlotUrl + ) + } + } + + // Heatmap image (rendered as PNG via matplotlib) + Image { + id: heatmapImage + Layout.fillWidth: true + Layout.fillHeight: true + fillMode: Image.PreserveAspectFit + source: Globals.BackendWrapper.bayesianHeatmapPlotUrl || '' + cache: false + visible: source !== '' + } + + // Placeholder: no results available + Item { + Layout.fillWidth: true + Layout.fillHeight: true + visible: !Globals.BackendWrapper.bayesianResultAvailable + + EaElements.Label { + anchors.centerIn: parent + text: qsTr("No Bayesian results available.") + color: EaStyle.Colors.themeForegroundMinor + } + } + + // Placeholder: results available but no parameters loaded + Item { + Layout.fillWidth: true + Layout.fillHeight: true + visible: Globals.BackendWrapper.bayesianResultAvailable + && heatmapImage.source === '' + + EaElements.Label { + anchors.centerIn: parent + text: qsTr("Select two parameters above to display the joint posterior density.") + color: EaStyle.Colors.themeForegroundMinor + } + } + } + + function updateHeatmap() { + if (paramXCombo.currentIndex >= 0 && paramYCombo.currentIndex >= 0) { + Globals.BackendWrapper.bayesianComputeHeatmap( + paramXCombo.currentIndex, paramYCombo.currentIndex + ) + } + } + + Component.onCompleted: updateHeatmap() +} diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianMarginalsView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianMarginalsView.qml new file mode 100644 index 00000000..86b6a1f4 --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianMarginalsView.qml @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtWebEngine + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +import Gui.Globals as Globals + + +Rectangle { + id: container + + color: EaStyle.Colors.chartBackground + + ColumnLayout { + anchors.fill: parent + anchors.margins: EaStyle.Sizes.fontPixelSize + spacing: EaStyle.Sizes.fontPixelSize + + RowLayout { + Layout.fillWidth: true + visible: Globals.BackendWrapper.bayesianDistributionPlotUrl !== '' + + EaElements.Label { + Layout.fillWidth: true + text: qsTr("Marginal Posterior Distributions") + font: EaStyle.Fonts.headingFont + } + + EaElements.Button { + text: qsTr("Save") + onClicked: Globals.BackendWrapper.bayesianSavePlot( + Globals.BackendWrapper.bayesianDistributionPlotUrl + ) + } + } + + WebEngineView { + id: distributionWebView + Layout.fillWidth: true + Layout.fillHeight: true + visible: Globals.BackendWrapper.bayesianDistributionPlotUrl !== '' + url: Globals.BackendWrapper.bayesianDistributionPlotUrl || 'about:blank' + settings.showScrollBars: true + + BusyIndicator { + anchors.centerIn: parent + running: Globals.BackendWrapper.bayesianDistributionPlotUrl === '' + && Globals.BackendWrapper.bayesianResultAvailable + } + } + + // Placeholder: no results available + Item { + Layout.fillWidth: true + Layout.fillHeight: true + visible: !Globals.BackendWrapper.bayesianResultAvailable + + EaElements.Label { + anchors.centerIn: parent + text: qsTr("No Bayesian results available.") + color: EaStyle.Colors.themeForegroundMinor + } + } + + // Placeholder: results available but plot not yet rendered + Item { + Layout.fillWidth: true + Layout.fillHeight: true + visible: Globals.BackendWrapper.bayesianDistributionPlotUrl === '' + && Globals.BackendWrapper.bayesianResultAvailable + + ColumnLayout { + anchors.centerIn: parent + spacing: EaStyle.Sizes.fontPixelSize + + EaElements.Label { + Layout.fillWidth: true + text: qsTr("Distribution plot is being rendered…") + color: EaStyle.Colors.themeForegroundMinor + horizontalAlignment: Text.AlignHCenter + } + + EaElements.Label { + Layout.fillWidth: true + text: qsTr("Interactive distribution plots require plotly. Install it with:
" + + "pip install plotly") + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + } + } + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianPosteriorView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianPosteriorView.qml new file mode 100644 index 00000000..db8d43dd --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianPosteriorView.qml @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +import Gui.Globals as Globals + + +Rectangle { + id: container + + color: EaStyle.Colors.chartBackground + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Convergence summary header + EaElements.Label { + Layout.fillWidth: true + Layout.margins: EaStyle.Sizes.fontPixelSize + visible: Globals.BackendWrapper.bayesianResultAvailable + && Globals.BackendWrapper.bayesianPosterior !== null + text: { + if (!Globals.BackendWrapper.bayesianPosterior) return '' + const pp = Globals.BackendWrapper.bayesianPosterior + return 'Bayesian MCMC Sampling Results\n' + + 'Posterior draws: ' + pp.nDraws + } + wrapMode: Text.WordWrap + } + + EaElements.Label { + Layout.fillWidth: true + Layout.margins: EaStyle.Sizes.fontPixelSize + visible: !Globals.BackendWrapper.bayesianResultAvailable + text: qsTr("No Bayesian results available. Run a BUMPS-DREAM sampling to see posterior distributions.") + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.WordWrap + } + + // Subtab bar + TabBar { + id: subtabBar + Layout.fillWidth: true + Layout.preferredHeight: EaStyle.Sizes.toolButtonHeight + background: Rectangle { color: EaStyle.Colors.chartBackground } + + TabButton { + text: qsTr("Marginals") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + implicitHeight: EaStyle.Sizes.toolButtonHeight + } + TabButton { + text: qsTr("Corner Plot") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + implicitHeight: EaStyle.Sizes.toolButtonHeight + } + TabButton { + text: qsTr("Traces") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + implicitHeight: EaStyle.Sizes.toolButtonHeight + } + TabButton { + text: qsTr("2D Heatmap") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + implicitHeight: EaStyle.Sizes.toolButtonHeight + } + TabButton { + text: qsTr("Diagnostics") + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + implicitHeight: EaStyle.Sizes.toolButtonHeight + } + } + + // Sub-view stack + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: subtabBar.currentIndex + + BayesianMarginalsView { } + BayesianCornerView { } + BayesianTraceView { } + BayesianHeatmapView { } + BayesianDiagnosticsView { } + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianTraceView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianTraceView.qml new file mode 100644 index 00000000..cd30a15b --- /dev/null +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/BayesianTraceView.qml @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +// SPDX-License-Identifier: BSD-3-Clause +// © 2026 Contributors to the EasyReflectometry project + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtWebEngine + +import EasyApplication.Gui.Style as EaStyle +import EasyApplication.Gui.Elements as EaElements + +import Gui.Globals as Globals + + +Rectangle { + id: container + + color: EaStyle.Colors.chartBackground + + ColumnLayout { + anchors.fill: parent + anchors.margins: EaStyle.Sizes.fontPixelSize + spacing: EaStyle.Sizes.fontPixelSize + + RowLayout { + Layout.fillWidth: true + visible: Globals.BackendWrapper.bayesianTracePlotUrl !== '' + + EaElements.Label { + Layout.fillWidth: true + text: qsTr("MCMC Chain Traces") + font: EaStyle.Fonts.headingFont + } + + EaElements.Button { + text: qsTr("Save") + onClicked: Globals.BackendWrapper.bayesianSavePlot( + Globals.BackendWrapper.bayesianTracePlotUrl + ) + } + } + + WebEngineView { + id: traceWebView + Layout.fillWidth: true + Layout.fillHeight: true + visible: Globals.BackendWrapper.bayesianTracePlotUrl !== '' + url: Globals.BackendWrapper.bayesianTracePlotUrl || 'about:blank' + settings.showScrollBars: true + + BusyIndicator { + anchors.centerIn: parent + running: Globals.BackendWrapper.bayesianTracePlotUrl === '' + && Globals.BackendWrapper.bayesianResultAvailable + } + } + + // Placeholder: no results available + Item { + Layout.fillWidth: true + Layout.fillHeight: true + visible: !Globals.BackendWrapper.bayesianResultAvailable + + EaElements.Label { + anchors.centerIn: parent + text: qsTr("No Bayesian results available.") + color: EaStyle.Colors.themeForegroundMinor + } + } + + // Placeholder: results available but plot not yet rendered + Item { + Layout.fillWidth: true + Layout.fillHeight: true + visible: Globals.BackendWrapper.bayesianTracePlotUrl === '' + && Globals.BackendWrapper.bayesianResultAvailable + + ColumnLayout { + anchors.centerIn: parent + spacing: EaStyle.Sizes.fontPixelSize + + EaElements.Label { + Layout.fillWidth: true + text: qsTr("Trace plot is being rendered…") + color: EaStyle.Colors.themeForegroundMinor + horizontalAlignment: Text.AlignHCenter + } + + EaElements.Label { + Layout.fillWidth: true + text: qsTr("Interactive trace plots require plotly. Install it with:
" + + "pip install plotly") + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + font.pixelSize: EaStyle.Sizes.fontPixelSize * 0.9 + } + } + } + } +} diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml index 83341103..ae8fa414 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/MainContent/CombinedView.qml @@ -62,6 +62,9 @@ Rectangle { anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 useOpenGL: EaGlobals.Vars.useOpenGL + + // Disable built-in Qt Charts legend - we use our custom legend instead + legend.visible: false // Multi-experiment support property var multiExperimentSeries: [] @@ -156,6 +159,62 @@ Rectangle { } } + // Posterior predictive overlay (Bayesian) + LineSeries { + id: ppMedianSerie + name: qsTr("Posterior median") + axisX: analysisChartView.currentXAxis() + axisY: analysisChartView.axisY + color: "#E67E22" + width: 5 + visible: Globals.BackendWrapper.bayesianResultAvailable + } + + AreaSeries { + id: ppBandSerie + name: qsTr("95% credible interval") + axisX: analysisChartView.currentXAxis() + axisY: analysisChartView.axisY + color: Qt.rgba(0.902, 0.494, 0.133, 0.25) // orange with alpha + borderWidth: 0 + upperSeries: LineSeries { + id: ppUpperSerie + axisX: analysisChartView.currentXAxis() + axisY: analysisChartView.axisY + visible: Globals.BackendWrapper.bayesianResultAvailable + } + lowerSeries: LineSeries { + id: ppLowerSerie + axisX: analysisChartView.currentXAxis() + axisY: analysisChartView.axisY + visible: Globals.BackendWrapper.bayesianResultAvailable + } + visible: Globals.BackendWrapper.bayesianResultAvailable + } + + Connections { + target: Globals.BackendWrapper + function onPosteriorPredictiveDataChanged() { + analysisChartView.refreshPosteriorPredictiveOverlay() + } + } + + function refreshPosteriorPredictiveOverlay() { + ppMedianSerie.clear() + ppUpperSerie.clear() + ppLowerSerie.clear() + const q = Globals.BackendWrapper.posteriorPredictiveQ + const m = Globals.BackendWrapper.posteriorPredictiveMedian + const lo = Globals.BackendWrapper.posteriorPredictiveLower + const hi = Globals.BackendWrapper.posteriorPredictiveUpper + if (!q || !m || !lo || !hi) return + for (let i = 0; i < q.length; ++i) { + ppMedianSerie.append(q[i], m[i]) + ppLowerSerie.append(q[i], lo[i]) + ppUpperSerie.append(q[i], hi[i]) + } + } + function updateReferenceLines() { Globals.BackendWrapper.updateRefLines(backgroundRefLine, scaleRefLine, true) } @@ -530,6 +589,36 @@ Rectangle { color: analysisChartView.calcSerie.color } + // Bayesian posterior predictive legend + Row { + visible: !analysisChartView.isMultiExperimentMode && Globals.BackendWrapper.bayesianResultAvailable + spacing: EaStyle.Sizes.fontPixelSize * 0.3 + Rectangle { + width: EaStyle.Sizes.fontPixelSize * 1.2 + height: 2 + color: "#E67E22" + anchors.verticalCenter: parent.verticalCenter + } + EaElements.Label { + text: qsTr("Posterior median") + color: EaStyle.Colors.themeForegroundMinor + } + } + Row { + visible: !analysisChartView.isMultiExperimentMode && Globals.BackendWrapper.bayesianResultAvailable + spacing: EaStyle.Sizes.fontPixelSize * 0.3 + Rectangle { + width: EaStyle.Sizes.fontPixelSize * 1.2 + height: EaStyle.Sizes.fontPixelSize * 0.6 + color: Qt.rgba(0.902, 0.494, 0.133, 0.25) + anchors.verticalCenter: parent.verticalCenter + } + EaElements.Label { + text: qsTr("95% credible interval") + color: EaStyle.Colors.themeForegroundMinor + } + } + // Multi-experiment legend Column { visible: analysisChartView.isMultiExperimentMode @@ -617,6 +706,9 @@ Rectangle { // Initialize reference lines updateReferenceLines() + + // Initialize posterior predictive overlay if results already exist + Qt.callLater(refreshPosteriorPredictiveOverlay) } function recreateSeriesForCurrentMode() { diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml index 2e9f427e..613e2086 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Advanced/Groups/Minimizer.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Controls +import QtQuick.Layouts import EasyApplication.Gui.Style as EaStyle import EasyApplication.Gui.Elements as EaElements @@ -14,7 +15,11 @@ EaElements.GroupBox { title: qsTr("Minimization method") icon: 'level-down-alt' - EaElements.GroupRow{ + Column { + width: parent.width + spacing: 0 + + EaElements.GroupRow{ EaElements.ComboBox { width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 2 topInset: minimizerLabel.height @@ -42,13 +47,15 @@ EaElements.GroupBox { onCurrentIndexChanged: Globals.BackendWrapper.analysisSetMinimizerCurrentIndex(currentIndex) } + // Classical tolerance / max evaluations fields — hidden in Bayesian mode EaElements.TextField { + visible: !Globals.BackendWrapper.analysisIsBayesianSelected width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 4 topInset: toleranceLabel.height topPadding: topInset + padding horizontalAlignment: TextInput.AlignLeft onAccepted: { - onAccepted: Globals.BackendWrapper.analysisSetMinimizerTolerance(text) + Globals.BackendWrapper.analysisSetMinimizerTolerance(text) focus = false } text: Globals.BackendWrapper.analysisMinimizerTolerance === undefined ? 'Defaults' : Number(Globals.BackendWrapper.analysisMinimizerTolerance).toFixed(3) @@ -60,6 +67,7 @@ EaElements.GroupBox { } EaElements.TextField { + visible: !Globals.BackendWrapper.analysisIsBayesianSelected width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 4 topInset: maxIterLabel.height topPadding: topInset + padding @@ -76,4 +84,93 @@ EaElements.GroupBox { } } } + + // Bayesian DREAM controls — only visible when Bayesian is selected + EaElements.GroupRow { + visible: Globals.BackendWrapper.analysisIsBayesianSelected + height: visible ? implicitHeight : 0 + + EaElements.TextField { + width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 5.5 + topInset: samplesLabel.height + topPadding: topInset + padding + horizontalAlignment: TextInput.AlignLeft + onEditingFinished: Globals.BackendWrapper.bayesianSetSamples(Number(text)) + text: Globals.BackendWrapper.bayesianSamples + EaElements.Label { + id: samplesLabel + text: qsTr("Samples") + color: EaStyle.Colors.themeForegroundMinor + } + } + + EaElements.TextField { + width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 5.5 + topInset: burninLabel.height + topPadding: topInset + padding + horizontalAlignment: TextInput.AlignLeft + onEditingFinished: Globals.BackendWrapper.bayesianSetBurnIn(Number(text)) + text: Globals.BackendWrapper.bayesianBurnIn + EaElements.Label { + id: burninLabel + text: qsTr("Burn-in steps") + color: EaStyle.Colors.themeForegroundMinor + } + } + + EaElements.TextField { + width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 5.5 + topInset: populationLabel.height + topPadding: topInset + padding + horizontalAlignment: TextInput.AlignLeft + onEditingFinished: Globals.BackendWrapper.bayesianSetPopulation(Number(text)) + text: Globals.BackendWrapper.bayesianPopulation + EaElements.Label { + id: populationLabel + text: qsTr("Population") + color: EaStyle.Colors.themeForegroundMinor + } + } + + EaElements.TextField { + width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 5.5 + topInset: thinningLabel.height + topPadding: topInset + padding + horizontalAlignment: TextInput.AlignLeft + onEditingFinished: Globals.BackendWrapper.bayesianSetThinning(Number(text)) + text: Globals.BackendWrapper.bayesianThinning + EaElements.Label { + id: thinningLabel + text: qsTr("Thinning") + color: EaStyle.Colors.themeForegroundMinor + } + } + + EaElements.ComboBox { + width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 5 + topInset: initLabel.height + topPadding: topInset + padding + model: Globals.BackendWrapper.bayesianInitializerOptions + currentIndex: { + const options = Globals.BackendWrapper.bayesianInitializerOptions + const current = Globals.BackendWrapper.bayesianInitializer + for (let i = 0; i < options.length; i++) { + if (options[i] === current) return i + } + return 0 + } + onCurrentIndexChanged: { + const options = Globals.BackendWrapper.bayesianInitializerOptions + if (currentIndex >= 0 && currentIndex < options.length) { + Globals.BackendWrapper.setBayesianInitializer(options[currentIndex]) + } + } + EaElements.Label { + id: initLabel + text: qsTr("Initializer") + color: EaStyle.Colors.themeForegroundMinor + } + } + } + } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml index e773ba80..0bbd5f51 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Groups/Fitting.qml @@ -20,7 +20,7 @@ EaElements.GroupBox { enabled: Globals.BackendWrapper.analysisExperimentsAvailable.length wide: true fontIcon: Globals.BackendWrapper.analysisFittingRunning ? 'stop-circle' : 'play-circle' - text: Globals.BackendWrapper.analysisFittingRunning ? qsTr('Cancel fitting') : qsTr('Start fitting') + text: Globals.BackendWrapper.analysisFittingRunning ? qsTr('Cancel fitting') : (Globals.BackendWrapper.analysisIsBayesianSelected ? qsTr('Start sampling') : qsTr('Start fitting')) onClicked: { console.debug(`Clicking '${text}' button: ${this}`) @@ -30,5 +30,22 @@ EaElements.GroupBox { Component.onCompleted: Globals.References.pages.analysis.sidebar.basic.popups.startFittingButton = this } + // Progress message shown during fitting or sampling + EaElements.Label { + visible: Globals.BackendWrapper.analysisFitProgressMessage !== '' + text: Globals.BackendWrapper.analysisFitProgressMessage + color: EaStyle.Colors.themeForegroundMinor + wrapMode: Text.WordWrap + } + + // Indeterminate progress bar shown during fitting/sampling + ProgressBar { + visible: Globals.BackendWrapper.analysisFittingRunning + indeterminate: Globals.BackendWrapper.analysisIsBayesianSelected + from: 0 + to: 100 + value: Globals.BackendWrapper.analysisFitIteration > 0 ? 50 : 0 + width: parent.width + } } } diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml index b53eda6e..67955746 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Sidebar/Basic/Popups/FitStatusDialog.qml @@ -17,7 +17,9 @@ EaElements.Dialog { id: dialog visible: Globals.BackendWrapper.analysisShowFitResultsDialog - title: Globals.BackendWrapper.analysisFitSuccess ? qsTr("Refinement Results") : qsTr("Refinement Failed") + title: Globals.BackendWrapper.bayesianResultAvailable + ? qsTr("Bayesian Sampling Results") + : (Globals.BackendWrapper.analysisFitSuccess ? qsTr("Refinement Results") : qsTr("Refinement Failed")) standardButtons: Dialog.Ok closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside @@ -38,22 +40,41 @@ EaElements.Dialog { Column { spacing: EaStyle.Sizes.fontPixelSize * 0.5 + // Bayesian-specific content EaElements.Label { + visible: Globals.BackendWrapper.bayesianResultAvailable + text: "Bayesian MCMC sampling completed successfully." + } + + EaElements.Label { + visible: Globals.BackendWrapper.bayesianResultAvailable + && Globals.BackendWrapper.bayesianPosterior !== null + text: { + if (!Globals.BackendWrapper.bayesianPosterior) return '' + const pp = Globals.BackendWrapper.bayesianPosterior + const params = pp.paramNames.map(function(n) { return ' • ' + n }).join('\n') + return 'Posterior draws: ' + pp.nDraws + '\nParameters:\n' + params + } + } + + // Classical fit content (hidden when Bayesian result is shown) + EaElements.Label { + visible: !Globals.BackendWrapper.bayesianResultAvailable text: "Success: " + Globals.BackendWrapper.analysisFitSuccess } EaElements.Label { - visible: Globals.BackendWrapper.analysisFitSuccess + visible: !Globals.BackendWrapper.bayesianResultAvailable && Globals.BackendWrapper.analysisFitSuccess text: "Num. refined parameters: " + Globals.BackendWrapper.analysisFitNumRefinedParams } EaElements.Label { - visible: Globals.BackendWrapper.analysisFitSuccess + visible: !Globals.BackendWrapper.bayesianResultAvailable && Globals.BackendWrapper.analysisFitSuccess text: "Reduced Chi2: " + Globals.BackendWrapper.analysisFitChi2.toFixed(4) } EaElements.Label { - visible: !Globals.BackendWrapper.analysisFitSuccess && Globals.BackendWrapper.analysisFitErrorMessage !== "" + visible: !Globals.BackendWrapper.bayesianResultAvailable && !Globals.BackendWrapper.analysisFitSuccess && Globals.BackendWrapper.analysisFitErrorMessage !== "" text: "Error: " + Globals.BackendWrapper.analysisFitErrorMessage wrapMode: Text.WordWrap width: Math.min(implicitWidth, EaStyle.Sizes.sideBarContentWidth * 1.5) diff --git a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml index 1c1ac872..04225ae6 100644 --- a/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml +++ b/EasyReflectometryApp/Gui/Pages/Experiment/MainContent/ExperimentView.qml @@ -41,6 +41,9 @@ Rectangle { anchors.topMargin: EaStyle.Sizes.toolButtonHeight - EaStyle.Sizes.fontPixelSize - 1 useOpenGL: EaGlobals.Vars.useOpenGL + + // Disable built-in Qt Charts legend - we use our custom legend instead + legend.visible: false // Background reference line series LineSeries { diff --git a/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Groups/GetStarted.qml b/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Groups/GetStarted.qml index 649b351f..674c5ba0 100644 --- a/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Groups/GetStarted.qml +++ b/EasyReflectometryApp/Gui/Pages/Project/Sidebar/Basic/Groups/GetStarted.qml @@ -63,4 +63,28 @@ Grid { } // button 3 + // Error dialog for project load issues (e.g. outdated project file_format) + EaElements.Dialog { + id: projectLoadErrorDialog + title: qsTr('Project Load Error') + standardButtons: Dialog.Ok + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + property string errorMessage: '' + + EaElements.Label { + text: projectLoadErrorDialog.errorMessage + wrapMode: Text.WordWrap + width: EaStyle.Sizes.sideBarContentWidth + } + } + + Connections { + target: Globals.BackendWrapper + function onProjectLoadError(message) { + projectLoadErrorDialog.errorMessage = message + projectLoadErrorDialog.open() + } + } + } diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/Export.qml b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/Export.qml index ab04d1f3..5905d172 100644 --- a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/Export.qml +++ b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/Export.qml @@ -61,7 +61,7 @@ Column { topPadding: topInset + padding rightPadding: chooseButton.width horizontalAlignment: TextInput.AlignLeft - Component.onCompleted: text = Globals.BackendWrapper.summaryFilePath + text: Globals.BackendWrapper.summaryFilePath EaElements.Label { id: locationLabel text: qsTr('Location') diff --git a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/ExportPlots.qml b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/ExportPlots.qml index 86739497..557caff9 100644 --- a/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/ExportPlots.qml +++ b/EasyReflectometryApp/Gui/Pages/Summary/Sidebar/Basic/Groups/ExportPlots.qml @@ -74,7 +74,7 @@ Column { topPadding: topInset + padding rightPadding: plotChooseButton.width horizontalAlignment: TextInput.AlignLeft - Component.onCompleted: text = Globals.BackendWrapper.summaryPlotFilePath + + text: Globals.BackendWrapper.summaryPlotFilePath + '.' + plotFormatField.currentValue.toLowerCase() EaElements.Label { id: plotLocationLabel diff --git a/EasyReflectometryApp/Gui/SldChart.qml b/EasyReflectometryApp/Gui/SldChart.qml index 053c8980..c39fdcfe 100644 --- a/EasyReflectometryApp/Gui/SldChart.qml +++ b/EasyReflectometryApp/Gui/SldChart.qml @@ -26,6 +26,8 @@ Rectangle { // Expose the ChartView so callers can store a reference / call resetAxes readonly property alias chartView: chartView + readonly property alias chartAxisX: axisX + readonly property alias chartAxisY: axisY // Track model count changes to refresh charts property int modelCount: Globals.BackendWrapper.sampleModels.length @@ -301,6 +303,39 @@ Rectangle { acceptedButtons: Qt.RightButton onClicked: chartView.resetAxes() } + + // Phase 2: Posterior predictive SLD overlay (Bayesian) + LineSeries { + id: ppSldMedianSerie + name: qsTr("Posterior median SLD") + axisX: root.chartAxisX + axisY: root.chartAxisY + color: "#E67E22" + width: 2 + visible: Globals.BackendWrapper.bayesianResultAvailable + } + + AreaSeries { + id: ppSldBandSerie + name: qsTr("95% credible interval") + axisX: root.chartAxisX + axisY: root.chartAxisY + color: Qt.rgba(0.902, 0.494, 0.133, 0.25) + borderWidth: 0 + upperSeries: LineSeries { + id: ppSldUpperSerie + axisX: root.chartAxisX + axisY: root.chartAxisY + visible: Globals.BackendWrapper.bayesianResultAvailable + } + lowerSeries: LineSeries { + id: ppSldLowerSerie + axisX: root.chartAxisX + axisY: root.chartAxisY + visible: Globals.BackendWrapper.bayesianResultAvailable + } + visible: Globals.BackendWrapper.bayesianResultAvailable + } } // Create series dynamically when model count changes @@ -335,6 +370,7 @@ Rectangle { Component.onCompleted: { Qt.callLater(recreateAllSeries) + Qt.callLater(refreshPosteriorPredictiveSldOverlay) } function recreateAllSeries() { @@ -385,4 +421,27 @@ Rectangle { dataToolTip.parent = chartView dataToolTip.visible = state } + + function refreshPosteriorPredictiveSldOverlay() { + ppSldMedianSerie.clear() + ppSldUpperSerie.clear() + ppSldLowerSerie.clear() + const z = Globals.BackendWrapper.posteriorPredictiveSldZ + const m = Globals.BackendWrapper.posteriorPredictiveSldMedian + const lo = Globals.BackendWrapper.posteriorPredictiveSldLower + const hi = Globals.BackendWrapper.posteriorPredictiveSldUpper + if (!z || !m || !lo || !hi) return + for (let i = 0; i < z.length; ++i) { + ppSldMedianSerie.append(z[i], m[i]) + ppSldLowerSerie.append(z[i], lo[i]) + ppSldUpperSerie.append(z[i], hi[i]) + } + } + + Connections { + target: Globals.BackendWrapper + function onPosteriorPredictiveSldDataChanged() { + refreshPosteriorPredictiveSldOverlay() + } + } } diff --git a/EasyReflectometryApp/Gui/StatusBar.qml b/EasyReflectometryApp/Gui/StatusBar.qml index 696404f3..6b14a296 100644 --- a/EasyReflectometryApp/Gui/StatusBar.qml +++ b/EasyReflectometryApp/Gui/StatusBar.qml @@ -22,7 +22,7 @@ EaElements.StatusBar { EaElements.StatusBarItem { keyIcon: 'layer-group' keyText: qsTr('Models') - valueText: Globals.BackendWrapper.statusPhaseCount ?? '' + valueText: Globals.BackendWrapper.statusModelsCount ?? '' ToolTip.text: qsTr('Number of models added') } @@ -60,11 +60,23 @@ EaElements.StatusBar { keyText: qsTr('Fit') valueText: { if (Globals.BackendWrapper.analysisFitHasInterimUpdate) { + if (Globals.BackendWrapper.analysisIsBayesianSelected + && Globals.BackendWrapper.analysisSampleProgressHasUpdate) { + const step = Globals.BackendWrapper.analysisSampleProgressStep + const total = Globals.BackendWrapper.analysisSampleProgressTotalSteps + if (total > 0) { + return qsTr('DREAM step %1 of %2').arg(step).arg(total) + } + return qsTr('DREAM step %1').arg(step) + } 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) + if (Globals.BackendWrapper.analysisIsBayesianSelected) { + return qsTr('Sampling') + '.'.repeat(dotCount % 5) + } + return qsTr('Fitting') + '.'.repeat(dotCount % 5) } ToolTip.text: qsTr('Current fitting progress') diff --git a/EasyReflectometryApp/main.py b/EasyReflectometryApp/main.py index df6b8a46..0854aaf5 100644 --- a/EasyReflectometryApp/main.py +++ b/EasyReflectometryApp/main.py @@ -13,6 +13,18 @@ from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtQml import qmlRegisterSingletonType +try: + from PySide6.QtWebEngineQuick import QtWebEngineQuick as _QtWebEngineQuick + + _QtWebEngineQuick.initialize() +except ImportError: + _QtWebEngineQuick = None + +# Suppress matplotlib debug/verbose logging (especially font lookup spam) +import logging as _logging +_logging.getLogger('matplotlib').setLevel(_logging.WARNING) +_logging.getLogger('matplotlib.font_manager').setLevel(_logging.WARNING) + try: # Running locally from Backends.Py import PyBackend from Backends.Py.helpers import Application diff --git a/pyproject.toml b/pyproject.toml index c3749c68..275f46e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,6 @@ build-backend = 'hatchling.build' [project] name = 'EasyReflectometryApp' version = '1.3.1' -release_data = '1 May 2026' description = "Making reflectometry data analysis and modelling easy." authors = [ {name = "Andrew R. McCluskey"}, @@ -36,6 +35,11 @@ dependencies = [ 'asteval', 'PySide6', 'toml', + 'numpy', + 'matplotlib', + 'corner>=2.2', + 'arviz>=0.18', + 'plotly>=5.0', ] [project.optional-dependencies] @@ -64,6 +68,7 @@ docs = [ [release] app_name = 'EasyReflectometryApp' family_name = 'EasyReflectometryApp' +release_date = '1 May 2026' tag_template = 'v{VERSION}' title_template = 'Version {VERSION} ({DATE})' description_file = 'RELEASE.md' diff --git a/tests/factories.py b/tests/factories.py index 38d5fe3d..d13be5e4 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -91,7 +91,7 @@ def duplicate_layer(self, index): ) self.insert(index + 1, duplicate) - def remove(self, index): + def remove_at(self, index): self.pop(index) def move_up(self, index): @@ -243,12 +243,13 @@ def switch(self, value): class FakeExperiment: - def __init__(self, name, model=None, x=None, y=None, ye=None): + def __init__(self, name, model=None, x=None, y=None, ye=None, xe=None): self.name = name self.model = model self.x = [] if x is None else x self.y = [] if y is None else y self.ye = [] if ye is None else ye + self.xe = [] if xe is None else xe class FakeMinimizerValue: diff --git a/tests/test_analysis.py b/tests/test_analysis.py index e42e5332..34c78be7 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -12,6 +12,10 @@ class StubParametersLogic: def __init__(self, _project_lib): pass + @property + def parameters(self): + return [] + class StubCalculatorsLogic: def __init__(self, _project_lib): @@ -37,6 +41,9 @@ def __init__(self, _project_lib): def selected_minimizer_enum(self): return None + def is_bayesian_selected(self): + return False + class StubWorker(QObject): finished = Signal(list) @@ -167,4 +174,159 @@ def test_cancelled_worker_failure_does_not_emit_fit_failed(monkeypatch, qcore_ap 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 + analysis._clearCacheAndEmitParametersChanged.assert_called_once_with() + + +# --------------------------------------------------------------------------- +# Bayesian sampling dispatch tests +# --------------------------------------------------------------------------- + + +class StubBayesianMinimizersLogic(StubMinimizersLogic): + """Minimizers stub that reports Bayesian mode is active.""" + + def is_bayesian_selected(self): + return True + + +def _make_analysis_bayesian(monkeypatch): + """Create an Analysis instance configured for Bayesian sampling.""" + 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', StubBayesianMinimizersLogic) + monkeypatch.setattr(analysis_module, 'FitterWorker', StubWorker) + analysis = analysis_module.Analysis(project) + analysis._clearCacheAndEmitParametersChanged = MagicMock() + return analysis + + +def test_start_threaded_sample_forwards_bayesian_kwargs(monkeypatch, qcore_application): + """_start_threaded_sample passes samples, burn, thin, population, initializer to worker.""" + StubWorker.instances = [] + analysis = _make_analysis_bayesian(monkeypatch) + analysis._fitting_logic.prepare_threaded_sample = MagicMock( + return_value=('multi-fitter', 'data-group') + ) + # Set non-default Bayesian hyper-params + analysis._bayesian_logic.samples = 5000 + analysis._bayesian_logic.burn = 1000 + analysis._bayesian_logic.thin = 5 + analysis._bayesian_logic.population = 8 + analysis._bayesian_logic.initializer = 'lhs' + + analysis._start_threaded_sample() + + worker = StubWorker.instances[-1] + assert worker.method_name == 'mcmc_sample' + assert worker.args == ('data-group',) + assert worker.kwargs == { + 'samples': 5000, + 'burn': 1000, + 'thin': 5, + 'population': 8, + 'initializer': 'lhs', + } + assert worker.start_calls == 1 + assert analysis.fittingRunning is True + + +def test_start_threaded_sample_uses_defaults_when_not_set(monkeypatch, qcore_application): + """_start_threaded_sample uses Bayesian DEFAULTS when no custom values are set.""" + StubWorker.instances = [] + analysis = _make_analysis_bayesian(monkeypatch) + analysis._fitting_logic.prepare_threaded_sample = MagicMock( + return_value=('multi-fitter', 'data-group') + ) + + analysis._start_threaded_sample() + + worker = StubWorker.instances[-1] + assert worker.kwargs == { + 'samples': 10000, + 'burn': 2000, + 'thin': 1, + 'population': 10, + 'initializer': 'eps', + } + + +def test_start_threaded_sample_propagates_sampling_progress(monkeypatch, qcore_application): + """Progress payloads with sampling=True update Bayesian-specific progress properties.""" + StubWorker.instances = [] + analysis = _make_analysis_bayesian(monkeypatch) + analysis._fitting_logic.prepare_threaded_sample = MagicMock( + return_value=('multi-fitter', 'data-group') + ) + + analysis._start_threaded_sample() + worker = StubWorker.instances[-1] + worker.progressDetail.emit({ + 'iteration': 25, + 'total_steps': 100, + 'chi2': 4.2, + 'reduced_chi2': 1.8, + 'sampling': True, + }) + + assert analysis.sampleProgressStep == 25 + assert analysis.sampleProgressMessage != '' + assert analysis.sampleProgressHasUpdate is True + + +def test_fitting_start_stop_dispatches_to_sample_when_bayesian(monkeypatch, qcore_application): + """fittingStartStop calls _start_threaded_sample when Bayesian minimizer is selected.""" + StubWorker.instances = [] + analysis = _make_analysis_bayesian(monkeypatch) + analysis._fitting_logic.prepare_threaded_sample = MagicMock( + return_value=('multi-fitter', 'data-group') + ) + + # Mock prefitCheck to avoid complex real checks + analysis.prefitCheck = MagicMock(return_value=True) + + # fittingStartStop should detect Bayesian mode and dispatch to sample + analysis.fittingStartStop() + + worker = StubWorker.instances[-1] + assert worker.method_name == 'mcmc_sample' + assert 'samples' in worker.kwargs + assert 'burn' in worker.kwargs + assert 'thin' in worker.kwargs + assert 'population' in worker.kwargs + assert 'initializer' in worker.kwargs + + +def test_start_threaded_sample_error_emits_fit_failed(monkeypatch, qcore_application): + """When prepare_threaded_sample returns None, fitFailed signal is emitted.""" + StubWorker.instances = [] + analysis = _make_analysis_bayesian(monkeypatch) + analysis._fitting_logic.prepare_threaded_sample = MagicMock(return_value=(None, None)) + # Make prepare_threaded_sample return None and set error message (as real code does) + def _prepare_and_fail(*args, **kwargs): + analysis._fitting_logic._fit_error_message = 'No experiments to sample' + return None, None + + analysis._fitting_logic.prepare_threaded_sample = MagicMock(side_effect=_prepare_and_fail) + + received = [] + analysis.fitFailed.connect(received.append) + + analysis._start_threaded_sample() + + assert len(received) == 1 + assert 'No experiments to sample' in received[0] + + +def test_bayesian_initializer_property_round_trip(monkeypatch, qcore_application): + """bayesianInitializer property and setter work through the QML-facing layer.""" + analysis = _make_analysis_bayesian(monkeypatch) + assert analysis.bayesianInitializer == 'eps' + assert analysis.bayesianInitializerOptions == ['eps', 'cov', 'lhs', 'random'] + + analysis.setBayesianInitializer('lhs') + assert analysis.bayesianInitializer == 'lhs' + + analysis.setBayesianInitializer('cov') + assert analysis.bayesianInitializer == 'cov' \ No newline at end of file diff --git a/tests/test_analysis_bayesian.py b/tests/test_analysis_bayesian.py new file mode 100644 index 00000000..4b39e8e1 --- /dev/null +++ b/tests/test_analysis_bayesian.py @@ -0,0 +1,989 @@ +# SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Exhaustive tests for Bayesian functionality in the analysis backend layer.""" + +import logging +import os +import tempfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock +from unittest.mock import patch + +import numpy as np +import pytest +from PySide6.QtCore import QObject +from PySide6.QtCore import Signal + +from EasyReflectometryApp.Backends.Py import analysis as analysis_module +from tests.factories import make_project + + +# --------------------------------------------------------------------------- +# Stub helpers (reuse + extend the stubs from test_analysis.py) +# --------------------------------------------------------------------------- + +class StubParametersLogic: + def __init__(self, _project_lib): + self._all_params = [] + + def all_parameters(self): + return self._all_params + + +class StubCalculatorsLogic: + def __init__(self, _project_lib): + pass + + +class StubExperimentLogic: + def __init__(self, project_lib): + self._project_lib = project_lib + self._current_index = 0 + + def available(self): + return ['Exp 1'] + + def current_index(self): + return self._current_index + + +class StubMinimizersLogic: + def __init__(self, _project_lib): + self._bayesian = False + self.tolerance = None + self.max_iterations = None + + def selected_minimizer_enum(self): + return None + + def is_bayesian_selected(self): + return self._bayesian + + def set_bayesian(self, value): + self._bayesian = value + + +class StubFittingLogic: + def __init__(self): + self.sample_step = 0 + self.sample_total_steps = 0 + self.sample_progress_message = '' + self.sample_has_update = False + self.running = False + self.fit_error_message = '' + self.fit_cancelled = False + self.fit_success = False + + def prepare_for_threaded_sample(self): + pass + + def prepare_threaded_sample(self, minimizers_logic): + return ('fake-multi-fitter', 'fake-data-group') + + def on_sample_finished(self): + pass + + def on_sample_progress(self, payload): + self.sample_step = payload.get('iteration', 0) + self.sample_total_steps = payload.get('total_steps', 0) + self.sample_has_update = True + + def reset_stop_flag(self): + pass + + def prepare_for_threaded_fit(self): + pass + + def stop_fit(self): + self.running = False + self.fit_cancelled = True + + def on_fit_failed(self, msg): + self.fit_error_message = msg + + +class StubPlotting: + def __init__(self): + self.posterior_q = None + self.posterior_median = None + self.posterior_lo = None + self.posterior_hi = None + self.sld_z = None + self.sld_median = None + self.sld_lo = None + self.sld_hi = None + + def set_posterior_predictive(self, q, median, lo, hi): + self.posterior_q = q + self.posterior_median = median + self.posterior_lo = lo + self.posterior_hi = hi + + def set_posterior_predictive_sld(self, z, median, lo, hi): + self.sld_z = z + self.sld_median = median + self.sld_lo = lo + self.sld_hi = hi + + +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 + + +# --------------------------------------------------------------------------- +# Sample posterior data +# --------------------------------------------------------------------------- + +SAMPLE_POSTERIOR_2D = { + 'draws': np.array([ + [1.0, 2.0], + [1.1, 2.1], + [0.9, 1.9], + [1.05, 2.05], + ]), + 'param_names': ['thickness', 'roughness'], +} + +SAMPLE_POSTERIOR_3D = { + 'draws': np.array([ + [[1.0, 2.0], [1.1, 2.1], [0.95, 1.95], [1.05, 2.05], + [1.02, 2.02], [0.98, 1.98], [1.07, 2.07], [0.93, 1.93]], + [[0.9, 1.9], [1.0, 2.0], [1.1, 2.1], [0.85, 1.85], + [1.05, 2.05], [0.95, 1.95], [1.08, 2.08], [0.92, 1.92]], + ]), + 'param_names': ['thickness', 'roughness'], +} + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def analysis(monkeypatch): + """Build an Analysis instance with all stubs wired in.""" + 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) + # Replace the fitting logic with our stub + from EasyReflectometryApp.Backends.Py.logic.fitting import Fitting + monkeypatch.setattr(analysis_module, 'FittingLogic', lambda _project_lib: StubFittingLogic()) + + project = make_project() + analysis_inst = analysis_module.Analysis(project) + analysis_inst._clearCacheAndEmitParametersChanged = MagicMock() + analysis_inst._plotting = StubPlotting() + return analysis_inst + + +@pytest.fixture +def analysis_with_posterior(analysis): + """Analysis with a posterior result set on the Bayesian logic.""" + analysis._bayesian_logic._posterior = SAMPLE_POSTERIOR_2D + return analysis + + +# =================================================================== +# Bayesian property wiring +# =================================================================== + +class TestBayesianSelection: + def test_is_bayesian_selected_false_by_default(self, analysis): + assert analysis.isBayesianSelected is False + + def test_is_bayesian_selected_true(self, analysis): + analysis._minimizers_logic.set_bayesian(True) + assert analysis.isBayesianSelected is True + + def test_is_bayesian_reflects_minimizer_change(self, analysis): + analysis._minimizers_logic.set_bayesian(True) + assert analysis.isBayesianSelected is True + analysis._minimizers_logic.set_bayesian(False) + assert analysis.isBayesianSelected is False + + +class TestBayesianHyperParameterProperties: + """Property wiring for samples, burn, population, thin.""" + + def test_bayesian_samples_default(self, analysis): + assert analysis.bayesianSamples == 10000 + + def test_bayesian_samples_setter_updates_logic(self, analysis): + analysis.setBayesianSamples(5000) + assert analysis._bayesian_logic.samples == 5000 + + def test_bayesian_burn_default(self, analysis): + assert analysis.bayesianBurnIn == 2000 + + def test_bayesian_burn_setter_updates_logic(self, analysis): + analysis.setBayesianBurnIn(1000) + assert analysis._bayesian_logic.burn == 1000 + + def test_bayesian_population_default(self, analysis): + assert analysis.bayesianPopulation == 10 + + def test_bayesian_population_setter_updates_logic(self, analysis): + analysis.setBayesianPopulation(5) + assert analysis._bayesian_logic.population == 5 + + def test_bayesian_thinning_default(self, analysis): + assert analysis.bayesianThinning == 1 + + def test_bayesian_thinning_setter_updates_logic(self, analysis): + analysis.setBayesianThinning(3) + assert analysis._bayesian_logic.thin == 3 + + +class TestBayesianHyperParameterSignals: + """Setter slots emit minimizerChanged.""" + + def test_set_bayesian_samples_emits(self, analysis): + emissions = [] + analysis.minimizerChanged.connect(lambda: emissions.append('minimizerChanged')) + analysis.setBayesianSamples(5000) + assert 'minimizerChanged' in emissions + + def test_set_bayesian_burn_emits(self, analysis): + emissions = [] + analysis.minimizerChanged.connect(lambda: emissions.append('minimizerChanged')) + analysis.setBayesianBurnIn(1000) + assert 'minimizerChanged' in emissions + + def test_set_bayesian_population_emits(self, analysis): + emissions = [] + analysis.minimizerChanged.connect(lambda: emissions.append('minimizerChanged')) + analysis.setBayesianPopulation(5) + assert 'minimizerChanged' in emissions + + def test_set_bayesian_thinning_emits(self, analysis): + emissions = [] + analysis.minimizerChanged.connect(lambda: emissions.append('minimizerChanged')) + analysis.setBayesianThinning(3) + assert 'minimizerChanged' in emissions + + +class TestBayesianProgressProperties: + """Sampling progress properties from fitting stub.""" + + def test_sample_progress_step(self, analysis): + assert analysis.sampleProgressStep == 0 + + def test_sample_progress_message(self, analysis): + assert analysis.sampleProgressMessage == '' + + def test_sample_progress_has_update(self, analysis): + assert analysis.sampleProgressHasUpdate is False + + def test_sample_total_steps(self, analysis): + assert analysis.sampleProgressTotalSteps == 0 + + +# =================================================================== +# Bayesian result availability and posterior +# =================================================================== + +class TestBayesianResultAvailable: + def test_no_result_by_default(self, analysis): + assert analysis.bayesianResultAvailable is False + + def test_result_available_after_posterior(self, analysis_with_posterior): + assert analysis_with_posterior.bayesianResultAvailable is True + + def test_result_becomes_unavailable_after_clear(self, analysis_with_posterior): + analysis_with_posterior._bayesian_logic.clear() + assert analysis_with_posterior.bayesianResultAvailable is False + + +class TestBayesianPosterior: + def test_none_by_default(self, analysis): + assert analysis.bayesianPosterior is None + + def test_returns_formatted_dict(self, analysis_with_posterior): + result = analysis_with_posterior.bayesianPosterior + assert result is not None + assert 'paramNames' in result + assert 'nDraws' in result + assert result['nDraws'] == 4 + + def test_none_after_clear(self, analysis_with_posterior): + analysis_with_posterior._bayesian_logic.clear() + assert analysis_with_posterior.bayesianPosterior is None + + +# =================================================================== +# Display name mapping +# =================================================================== + +class TestBayesianDisplayNames: + def test_empty_when_no_posterior(self, analysis): + assert analysis._bayesian_display_name_list() == [] + + def test_empty_when_no_parameters(self, analysis_with_posterior): + names = analysis_with_posterior._bayesian_display_name_list() + # No parameter mapping → returns original param_names + assert names == ['thickness', 'roughness'] + + def test_maps_through_parameters_logic(self, analysis_with_posterior): + analysis_with_posterior._parameters_logic._all_params = [ + {'unique_name': 'thickness', 'name': 'Thickness'}, + {'unique_name': 'roughness', 'name': 'Roughness'}, + ] + names = analysis_with_posterior._bayesian_display_name_list() + assert names == ['Thickness', 'Roughness'] + + def test_fallback_to_unique_name_when_display_missing(self, analysis_with_posterior): + analysis_with_posterior._parameters_logic._all_params = [ + {'unique_name': 'thickness', 'name': ''}, + {'unique_name': 'roughness'}, + ] + names = analysis_with_posterior._bayesian_display_name_list() + assert names == ['thickness', 'roughness'] + + def test_builds_mapping_dict(self, analysis_with_posterior): + analysis_with_posterior._parameters_logic._all_params = [ + {'unique_name': 'thickness', 'name': 'Thickness'}, + {'unique_name': 'roughness', 'name': 'Roughness'}, + ] + mapping = analysis_with_posterior._bayesian_display_names() + assert mapping == {'thickness': 'Thickness', 'roughness': 'Roughness'} + + def test_mapping_handles_exception_gracefully(self, analysis_with_posterior): + def broken_all_params(): + raise RuntimeError('boom') + + analysis_with_posterior._parameters_logic.all_parameters = broken_all_params + # Should not raise + mapping = analysis_with_posterior._bayesian_display_names() + assert mapping == {} + + +# =================================================================== +# Marginal distributions +# =================================================================== + +class TestBayesianMarginals: + def test_empty_when_no_posterior(self, analysis): + assert analysis.bayesianMarginals == [] + + def test_returns_one_entry_per_param(self, analysis_with_posterior): + marginals = analysis_with_posterior.bayesianMarginals + assert len(marginals) == 2 + + def test_marginal_keys(self, analysis_with_posterior): + marginals = analysis_with_posterior.bayesianMarginals + for m in marginals: + assert 'name' in m + assert 'mean' in m + assert 'std' in m + assert 'ci_low' in m + assert 'ci_high' in m + assert 'binCenters' in m + assert 'counts' in m + + def test_marginal_stats_approximate(self, analysis_with_posterior): + marginals = analysis_with_posterior.bayesianMarginals + # thickness ~ 1.0 ± small, roughness ~ 2.0 ± small + assert abs(marginals[0]['mean'] - 1.0) < 0.1 + assert abs(marginals[1]['mean'] - 2.0) < 0.1 + + def test_marginal_names_fallback(self, analysis_with_posterior): + marginals = analysis_with_posterior.bayesianMarginals + assert marginals[0]['name'] == 'thickness' + assert marginals[1]['name'] == 'roughness' + + def test_marginal_names_use_display_names(self, analysis_with_posterior): + analysis_with_posterior._parameters_logic._all_params = [ + {'unique_name': 'thickness', 'name': 'Thickness'}, + {'unique_name': 'roughness', 'name': 'Roughness'}, + ] + marginals = analysis_with_posterior.bayesianMarginals + assert marginals[0]['name'] == 'Thickness' + assert marginals[1]['name'] == 'Roughness' + + def test_marginal_bin_arrays_have_correct_length(self, analysis_with_posterior): + marginals = analysis_with_posterior.bayesianMarginals + for m in marginals: + assert isinstance(m['binCenters'], list) + assert isinstance(m['counts'], list) + assert len(m['binCenters']) == len(m['counts']) == 40 # bins=40 + + +# =================================================================== +# Corner plot URL +# =================================================================== + +class TestBayesianCornerPlotUrl: + def test_empty_when_no_posterior(self, analysis): + assert analysis.bayesianCornerPlotUrl == '' + + def test_lazy_renders_once(self, analysis_with_posterior): + # Pre-set a cached URL so the second access doesn't re-trigger rendering + analysis_with_posterior._bayesian_logic.corner_plot_url = 'file:///cached.png' + with patch.object(analysis_with_posterior, '_render_corner_plot') as mock: + url1 = analysis_with_posterior.bayesianCornerPlotUrl + url2 = analysis_with_posterior.bayesianCornerPlotUrl + assert mock.call_count == 0 # already cached, no call needed + + def test_returns_cached_url(self, analysis_with_posterior): + analysis_with_posterior._bayesian_logic.corner_plot_url = 'file:///cached.png' + url = analysis_with_posterior.bayesianCornerPlotUrl + assert url == 'file:///cached.png' + + +# =================================================================== +# Trace plot URL +# =================================================================== + +class TestBayesianTracePlotUrl: + def test_empty_when_no_posterior(self, analysis): + assert analysis.bayesianTracePlotUrl == '' + + def test_lazy_renders_once(self, analysis_with_posterior): + # Pre-set a cached URL so the second access doesn't re-trigger rendering + analysis_with_posterior._bayesian_logic.trace_plot_url = 'file:///cached_trace.png' + with patch.object(analysis_with_posterior, '_render_trace_plot') as mock: + url1 = analysis_with_posterior.bayesianTracePlotUrl + url2 = analysis_with_posterior.bayesianTracePlotUrl + assert mock.call_count == 0 # already cached, no call needed + + def test_returns_cached_url(self, analysis_with_posterior): + analysis_with_posterior._bayesian_logic.trace_plot_url = 'file:///cached_trace.png' + url = analysis_with_posterior.bayesianTracePlotUrl + assert url == 'file:///cached_trace.png' + + +# =================================================================== +# Distribution plot URL +# =================================================================== + +class TestBayesianDistributionPlotUrl: + def test_empty_when_no_posterior(self, analysis): + assert analysis.bayesianDistributionPlotUrl == '' + + def test_lazy_renders_once(self, analysis_with_posterior): + analysis_with_posterior._bayesian_logic.distribution_plot_url = 'file:///cached_dist.html' + with patch.object(analysis_with_posterior, '_render_distribution_plot') as mock: + url1 = analysis_with_posterior.bayesianDistributionPlotUrl + url2 = analysis_with_posterior.bayesianDistributionPlotUrl + assert mock.call_count == 0 # already cached, no call needed + + def test_returns_cached_url(self, analysis_with_posterior): + analysis_with_posterior._bayesian_logic.distribution_plot_url = 'file:///cached_dist.html' + url = analysis_with_posterior.bayesianDistributionPlotUrl + assert url == 'file:///cached_dist.html' + + +class TestRenderDistributionPlot: + def test_sets_empty_url_when_no_posterior(self, analysis): + analysis._render_distribution_plot() + assert analysis._bayesian_logic.distribution_plot_url == '' + + def test_handles_import_error_gracefully(self, analysis_with_posterior, caplog): + """Distribution plot rendering catches ImportError when plotly is missing.""" + caplog.set_level(logging.INFO) + import builtins + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == 'easyreflectometry.analysis.bayesian': + raise ImportError('No module named plotly') + return original_import(name, *args, **kwargs) + + with patch.object(builtins, '__import__', mock_import): + analysis_with_posterior._render_distribution_plot() + + assert analysis_with_posterior._bayesian_logic.distribution_plot_url == '' + assert 'not installed' in caplog.text + + +# =================================================================== +# Heatmap +# =================================================================== + +class TestBayesianHeatmap: + def test_data_none_when_no_posterior(self, analysis): + assert analysis.bayesianHeatmapData is None + + def test_plot_url_empty_when_no_posterior(self, analysis): + assert analysis.bayesianHeatmapPlotUrl == '' + + def test_compute_populates_data(self, analysis_with_posterior): + with patch.object(analysis_with_posterior, '_render_heatmap_plot') as mock_render: + emissions = [] + analysis_with_posterior.heatmapChanged.connect(lambda: emissions.append('emitted')) + analysis_with_posterior.computeBayesianHeatmap(0, 1) + + assert analysis_with_posterior.bayesianHeatmapData is not None + data = analysis_with_posterior.bayesianHeatmapData + assert 'xLabel' in data + assert 'yLabel' in data + assert 'xCenters' in data + assert 'yCenters' in data + assert 'zValues' in data + mock_render.assert_called_once_with(0, 1) + assert 'emitted' in emissions + + def test_compute_does_nothing_without_posterior(self, analysis): + emissions = [] + analysis.heatmapChanged.connect(lambda: emissions.append('emitted')) + analysis.computeBayesianHeatmap(0, 1) + assert analysis.bayesianHeatmapData is None + assert emissions == [] + + def test_compute_with_3d_draws(self, analysis): + analysis._bayesian_logic._posterior = SAMPLE_POSTERIOR_3D + with patch.object(analysis, '_render_heatmap_plot'): + analysis.computeBayesianHeatmap(0, 1) + data = analysis.bayesianHeatmapData + assert data is not None + assert data['xLabel'] == 'thickness' + assert data['yLabel'] == 'roughness' + + def test_param_names_for_display(self, analysis_with_posterior): + analysis_with_posterior._parameters_logic._all_params = [ + {'unique_name': 'thickness', 'name': 'Thickness'}, + {'unique_name': 'roughness', 'name': 'Roughness'}, + ] + with patch.object(analysis_with_posterior, '_render_heatmap_plot'): + analysis_with_posterior.computeBayesianHeatmap(0, 1) + data = analysis_with_posterior.bayesianHeatmapData + assert data['xLabel'] == 'Thickness' + assert data['yLabel'] == 'Roughness' + + +# =================================================================== +# Plot file paths +# =================================================================== + +class TestPlotFilePath: + def test_returns_path_in_tempdir(self, analysis): + path = analysis._plot_file_path('corner') + assert isinstance(path, Path) + assert 'EasyReflectometryApp' in str(path) + assert 'bayesian' in str(path) + assert path.name == 'corner.png' + + def test_html_ext(self, analysis): + path = analysis._plot_file_path('corner', 'html') + assert path.name == 'corner.html' + + def test_url_from_stem(self, analysis): + url = analysis._plot_file_url('corner') + assert url.startswith('file:///') + assert 'corner.png' in url + + def test_different_stems(self, analysis): + assert analysis._plot_file_path('trace').name == 'trace.png' + assert analysis._plot_file_path('heatmap_0_1').name == 'heatmap_0_1.png' + + +# =================================================================== +# Render methods (basic smoke tests — MPL rendering is tested separately) +# =================================================================== + +class TestRenderCornerPlot: + def test_sets_empty_url_when_no_posterior(self, analysis): + analysis._render_corner_plot() + assert analysis._bayesian_logic.corner_plot_url == '' + + def test_handles_import_error_gracefully(self, analysis_with_posterior, caplog): + """Corner plot rendering catches ImportError when plotly is missing.""" + caplog.set_level(logging.INFO) + import builtins + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == 'easyreflectometry.analysis.bayesian': + raise ImportError('No module named plotly') + return original_import(name, *args, **kwargs) + + with patch.object(builtins, '__import__', mock_import): + analysis_with_posterior._render_corner_plot() + + assert analysis_with_posterior._bayesian_logic.corner_plot_url == '' + assert 'not installed' in caplog.text + + +class TestRenderTracePlot: + def test_sets_empty_url_when_no_posterior(self, analysis): + analysis._render_trace_plot() + assert analysis._bayesian_logic.trace_plot_url == '' + + def test_handles_import_error_gracefully(self, analysis_with_posterior, caplog): + """Trace plot rendering catches ImportError when plotly is missing.""" + caplog.set_level(logging.INFO) + import builtins + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == 'easyreflectometry.analysis.bayesian': + raise ImportError('No module named plotly') + return original_import(name, *args, **kwargs) + + with patch.object(builtins, '__import__', mock_import): + analysis_with_posterior._render_trace_plot() + + assert analysis_with_posterior._bayesian_logic.trace_plot_url == '' + assert 'not installed' in caplog.text + + +class TestRenderHeatmapPlot: + def test_sets_empty_url_when_no_posterior(self, analysis): + analysis._render_heatmap_plot(0, 1) + assert analysis._bayesian_logic.heatmap_plot_url == '' + + def test_renders_with_posterior(self, analysis_with_posterior): + with patch('matplotlib.pyplot') as mock_plt: + mock_fig = MagicMock() + mock_plt.subplots.return_value = (mock_fig, MagicMock()) + analysis_with_posterior._render_heatmap_plot(0, 1) + assert mock_plt.subplots.called + + +# =================================================================== +# Diagnostics +# =================================================================== + +class TestBayesianDiagnostics: + def test_empty_when_no_posterior(self, analysis): + assert analysis.bayesianDiagnostics == {} + + def test_computes_lazily(self, analysis_with_posterior): + with patch.object(analysis_with_posterior, '_compute_diagnostics') as mock: + analysis_with_posterior.bayesianDiagnostics + mock.assert_called_once() + + def test_returns_cached(self, analysis_with_posterior): + analysis_with_posterior._bayesian_logic.diagnostics = {'already': 'computed'} + with patch.object(analysis_with_posterior, '_compute_diagnostics') as mock: + result = analysis_with_posterior.bayesianDiagnostics + assert result == {'already': 'computed'} + mock.assert_not_called() + + def test_basic_diagnostics_shape(self, analysis_with_posterior): + analysis_with_posterior._compute_diagnostics() + diag = analysis_with_posterior._bayesian_logic.diagnostics + assert 'nDraws' in diag + assert 'nParams' in diag + assert 'burnIn' in diag + assert 'thin' in diag + assert 'population' in diag + assert 'samples' in diag + assert diag['nDraws'] == 4 + assert diag['nParams'] == 2 + + def test_acceptance_rate_from_state(self, analysis_with_posterior): + analysis_with_posterior._bayesian_logic._posterior = { + 'draws': SAMPLE_POSTERIOR_2D['draws'], + 'param_names': ['thickness', 'roughness'], + 'state': SimpleNamespace(acceptance_rate=0.35), + } + analysis_with_posterior._compute_diagnostics() + assert analysis_with_posterior._bayesian_logic.diagnostics.get('acceptanceRate') == 0.35 + + def test_acceptance_rate_handles_missing_state(self, analysis_with_posterior): + # Default SAMPLE_POSTERIOR_2D has no 'state' key + analysis_with_posterior._compute_diagnostics() + assert 'acceptanceRate' not in analysis_with_posterior._bayesian_logic.diagnostics + + def test_rhat_status_with_2d_draws(self, analysis_with_posterior): + analysis_with_posterior._compute_diagnostics() + assert analysis_with_posterior._bayesian_logic.diagnostics.get('rhatStatus', '').startswith( + 'Unavailable: the sampler returned flattened draws' + ) + + def test_rhat_attempted_with_3d_draws(self, analysis_with_posterior): + analysis_with_posterior._bayesian_logic._posterior = SAMPLE_POSTERIOR_3D + analysis_with_posterior._compute_diagnostics() + diag = analysis_with_posterior._bayesian_logic.diagnostics + # 3D draws with >=2 chains trigger the arviz path + # If arviz is installed and R-hat succeeds → 'rhat' key present + # If arviz is installed but R-hat fails → 'rhatStatus' with fallback message + # If arviz is not installed → 'rhatStatus' with arviz message + assert 'rhat' in diag or 'rhatStatus' in diag + + def test_clear_posterior_resets_diagnostics(self, analysis_with_posterior): + analysis_with_posterior._compute_diagnostics() + assert analysis_with_posterior._bayesian_logic.diagnostics != {} + analysis_with_posterior._bayesian_logic.clear() + assert analysis_with_posterior._bayesian_logic.diagnostics == {} + + def test_rhat_from_state_chains_3d(self, analysis_with_posterior): + """When draws are 2D but state.chains() returns 3D, R-hat should be computed.""" + chains_3d = np.array([ + [[1.0, 2.0], [1.1, 2.1], [0.9, 1.9], [1.05, 2.05]], + [[0.9, 1.9], [1.0, 2.0], [1.1, 2.1], [0.95, 1.95]], + ]) # (n_generations=4, n_chains=2, n_params=2) + mock_state = SimpleNamespace() + mock_state.chains = lambda: (None, chains_3d, None) + # Use 2D posterior but attach a state with .chains() + posterior_2d_with_state = dict(SAMPLE_POSTERIOR_2D) + posterior_2d_with_state['state'] = mock_state + analysis_with_posterior._bayesian_logic._posterior = posterior_2d_with_state + analysis_with_posterior._compute_diagnostics() + diag = analysis_with_posterior._bayesian_logic.diagnostics + # Should attempt R-hat; either succeed (if arviz installed) or report arviz missing + if 'rhat' in diag: + assert isinstance(diag['rhat'], dict) + assert len(diag['rhat']) > 0 + else: + assert 'rhatStatus' in diag + + def test_rhat_state_chains_single_chain(self, analysis_with_posterior): + """Single chain from state.chains() should report 'increase Population'.""" + chains_3d = np.array([ + [[1.0, 2.0]], + [[1.1, 2.1]], + [[0.9, 1.9]], + [[1.05, 2.05]], + ]) # (n_generations=4, n_chains=1, n_params=2) + mock_state = SimpleNamespace() + mock_state.chains = lambda: (None, chains_3d, None) + posterior_2d_with_state = dict(SAMPLE_POSTERIOR_2D) + posterior_2d_with_state['state'] = mock_state + analysis_with_posterior._bayesian_logic._posterior = posterior_2d_with_state + analysis_with_posterior._compute_diagnostics() + diag = analysis_with_posterior._bayesian_logic.diagnostics + assert 'increase Population' in diag.get('rhatStatus', '') + + +# =================================================================== +# Posterior predictive +# =================================================================== + +class TestPosteriorPredictive: + def test_noop_when_plotting_none(self, analysis_with_posterior): + analysis_with_posterior._plotting = None + # Should not raise + analysis_with_posterior._compute_and_publish_posterior_predictive() + + def test_noop_when_no_experiments(self, analysis_with_posterior): + # Make _ordered_experiments return empty via analysis-level method + original = analysis_with_posterior._ordered_experiments + analysis_with_posterior._ordered_experiments = lambda: [] + # Should not raise + analysis_with_posterior._compute_and_publish_posterior_predictive() + analysis_with_posterior._ordered_experiments = original + + def test_noop_when_posterior_cleared(self, analysis_with_posterior): + analysis_with_posterior._bayesian_logic.clear() + analysis_with_posterior._compute_and_publish_posterior_predictive() + assert analysis_with_posterior._plotting.posterior_q is None + + +# =================================================================== +# Bayesian sampling dispatch +# =================================================================== + +class TestStartThreadedSample: + def test_dispatches_when_bayesian_selected(self, analysis, monkeypatch): + StubWorker.instances = [] + analysis._minimizers_logic.set_bayesian(True) + + analysis._start_threaded_fit() + + worker = StubWorker.instances[-1] + assert worker.method_name == 'mcmc_sample' + assert worker.start_calls == 1 + + def test_worker_kwargs_contain_hyper_params(self, analysis): + StubWorker.instances = [] + analysis._minimizers_logic.set_bayesian(True) + analysis._bayesian_logic.samples = 5000 + analysis._bayesian_logic.burn = 1000 + analysis._bayesian_logic.thin = 2 + analysis._bayesian_logic.population = 5 + + analysis._start_threaded_fit() + + worker = StubWorker.instances[-1] + assert worker.kwargs['samples'] == 5000 + assert worker.kwargs['burn'] == 1000 + assert worker.kwargs['thin'] == 2 + assert worker.kwargs['population'] == 5 + + def test_worker_has_progress_and_delete_connections(self, analysis): + StubWorker.instances = [] + analysis._minimizers_logic.set_bayesian(True) + + analysis._start_threaded_fit() + + worker = StubWorker.instances[-1] + # Verify signal connections exist (they are connected before start) + assert worker.start_calls == 1 + + def test_emits_fit_failed_on_preparation_error(self, analysis): + analysis._fitting_logic.prepare_threaded_sample = lambda ml: (None, None) + analysis._fitting_logic.fit_error_message = 'No experiments to sample' + received = [] + analysis.fitFailed.connect(received.append) + + analysis._minimizers_logic.set_bayesian(True) + analysis._start_threaded_fit() + + assert len(received) >= 1 + + +class TestOnSampleFinished: + def test_stores_posterior(self, analysis): + results = [SAMPLE_POSTERIOR_2D] + analysis._on_sample_finished(results) + assert analysis._bayesian_logic.posterior is SAMPLE_POSTERIOR_2D + + def test_clears_fitter_thread(self, analysis): + analysis._fitter_thread = 'some-worker' + analysis._on_sample_finished([SAMPLE_POSTERIOR_2D]) + assert analysis._fitter_thread is None + + def test_calls_posterior_predictive_and_diagnostics(self, analysis): + with patch.object(analysis, '_compute_and_publish_posterior_predictive') as mock_pp: + with patch.object(analysis, '_compute_diagnostics') as mock_diag: + with patch.object(analysis, '_render_corner_plot') as mock_corner: + with patch.object(analysis, '_render_trace_plot') as mock_trace: + analysis._on_sample_finished([SAMPLE_POSTERIOR_2D]) + mock_pp.assert_called_once() + mock_diag.assert_called_once() + mock_corner.assert_called_once() + mock_trace.assert_called_once() + + def test_emits_fitting_signals(self, analysis): + emissions = {'fitting': 0, 'external': 0} + analysis.fittingChanged.connect(lambda: emissions.__setitem__('fitting', emissions['fitting'] + 1)) + analysis.externalFittingChanged.connect(lambda: emissions.__setitem__('external', emissions['external'] + 1)) + + analysis._on_sample_finished([SAMPLE_POSTERIOR_2D]) + + assert emissions['fitting'] >= 1 + assert emissions['external'] >= 1 + + +# =================================================================== +# Bayesian param names +# =================================================================== + +class TestBayesianParamNames: + def test_empty_when_no_posterior(self, analysis): + assert analysis.bayesianParamNames == [] + + def test_returns_list_of_names(self, analysis_with_posterior): + names = analysis_with_posterior.bayesianParamNames + assert names == ['thickness', 'roughness'] + + def test_uses_display_names(self, analysis_with_posterior): + analysis_with_posterior._parameters_logic._all_params = [ + {'unique_name': 'thickness', 'name': 'Thickness'}, + {'unique_name': 'roughness', 'name': 'Roughness'}, + ] + names = analysis_with_posterior.bayesianParamNames + assert names == ['Thickness', 'Roughness'] + + +# =================================================================== +# Save Bayesian plot +# =================================================================== + +class MockQFileDialog: + """Stand-in for QtWidgets.QFileDialog that doesn't need a QApplication.""" + def __init__(self, *args, **kwargs): + pass + + @staticmethod + def getSaveFileName(*args, **kwargs): + return '', '' + + +class TestSaveBayesianPlot: + @pytest.fixture(autouse=True) + def _patch_qfiledialog(self, monkeypatch): + monkeypatch.setattr(analysis_module.QtWidgets, 'QFileDialog', MockQFileDialog) + + def test_returns_false_for_invalid_url(self, analysis): + assert analysis.saveBayesianPlot('') is False + assert analysis.saveBayesianPlot('not-a-url') is False + + def test_returns_false_for_nonexistent_file(self, analysis): + url = 'file:///nonexistent/path/plot.png' + assert analysis.saveBayesianPlot(url) is False + + def test_saves_file_successfully(self, analysis, tmp_path): + # Create a source PNG inside tmp_path (avoids polluting system temp dir) + src_dir = tmp_path / 'bayesian' + src_dir.mkdir(parents=True, exist_ok=True) + src_file = src_dir / 'test_save.png' + src_file.write_text('fake-png-content') + + url = src_file.resolve().as_uri() + save_dest = str(tmp_path / 'saved_plot.png') + # Patch getSaveFileName to return a real path + original_get = MockQFileDialog.getSaveFileName + MockQFileDialog.getSaveFileName = lambda *a, **kw: (save_dest, 'PNG (*.png)') + try: + result = analysis.saveBayesianPlot(url) + assert result is True + saved = Path(save_dest) + assert saved.exists() + assert saved.read_text() == 'fake-png-content' + saved.unlink() + finally: + MockQFileDialog.getSaveFileName = original_get + + def test_returns_false_when_dialog_cancelled(self, analysis, tmp_path): + src_dir = tmp_path / 'bayesian' + src_dir.mkdir(parents=True, exist_ok=True) + src_file = src_dir / 'cancelled_test.png' + src_file.write_text('content') + + url = src_file.resolve().as_uri() + # MockQFileDialog.getSaveFileName returns ('', '') by default → cancelled + result = analysis.saveBayesianPlot(url) + assert result is False + + +# =================================================================== +# Sampling progress forwarding +# =================================================================== + +class TestSamplingProgress: + def test_on_fit_progress_dispatches_to_sample(self, analysis): + payload = {'sampling': True, 'iteration': 42, 'total_steps': 1000} + analysis._on_fit_progress(payload) + assert analysis._fitting_logic.sample_step == 42 + assert analysis._fitting_logic.sample_total_steps == 1000 + + def test_fitting_changed_emitted_on_progress(self, analysis): + emissions = [] + analysis.fittingChanged.connect(lambda: emissions.append('emitted')) + analysis._on_fit_progress({'sampling': True, 'iteration': 1, 'total_steps': 100}) + assert 'emitted' in emissions diff --git a/tests/test_logic_bayesian.py b/tests/test_logic_bayesian.py new file mode 100644 index 00000000..41a71bc9 --- /dev/null +++ b/tests/test_logic_bayesian.py @@ -0,0 +1,219 @@ +# SPDX-FileCopyrightText: 2026 EasyReflectometry contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Exhaustive unit tests for the Bayesian state container (logic/bayesian.py).""" + +import pytest + +from EasyReflectometryApp.Backends.Py.logic.bayesian import Bayesian + + +class TestDefaults: + """Default values from the DEFAULTS dict and constructor.""" + + def test_default_samples(self): + assert Bayesian().samples == 10000 + + def test_default_burn(self): + assert Bayesian().burn == 2000 + + def test_default_population(self): + assert Bayesian().population == 10 + + def test_default_thin(self): + assert Bayesian().thin == 1 + + def test_default_posterior_none(self): + assert Bayesian().posterior is None + + def test_default_has_result_false(self): + assert Bayesian().has_result is False + + def test_default_rendered_assets_empty(self): + b = Bayesian() + assert b.corner_plot_url == '' + assert b.distribution_plot_url == '' + assert b.trace_plot_url == '' + assert b.heatmap_plot_url == '' + assert b.heatmap_data is None + assert b.diagnostics == {} + + +class TestSamples: + """samples property — positive integer.""" + + def test_get_set(self): + b = Bayesian() + b.samples = 5000 + assert b.samples == 5000 + + def test_accepts_large_value(self): + b = Bayesian() + b.samples = 1_000_000 + assert b.samples == 1_000_000 + + def test_accepts_minimum(self): + b = Bayesian() + b.samples = 1 + assert b.samples == 1 + + @pytest.mark.parametrize('bad', [0, -1, -100]) + def test_rejects_non_positive(self, bad): + b = Bayesian() + with pytest.raises(ValueError, match='samples must be a positive integer'): + b.samples = bad + + +class TestBurn: + """burn property — non-negative integer.""" + + def test_get_set(self): + b = Bayesian() + b.burn = 1000 + assert b.burn == 1000 + + def test_accepts_zero(self): + b = Bayesian() + b.burn = 0 + assert b.burn == 0 + + @pytest.mark.parametrize('bad', [-1, -100]) + def test_rejects_negative(self, bad): + b = Bayesian() + with pytest.raises(ValueError, match='burn must be a non-negative integer'): + b.burn = bad + + +class TestPopulation: + """population property — positive integer.""" + + def test_get_set(self): + b = Bayesian() + b.population = 5 + assert b.population == 5 + + def test_accepts_large(self): + b = Bayesian() + b.population = 50 + assert b.population == 50 + + def test_accepts_minimum(self): + b = Bayesian() + b.population = 1 + assert b.population == 1 + + @pytest.mark.parametrize('bad', [0, -1, -10]) + def test_rejects_non_positive(self, bad): + b = Bayesian() + with pytest.raises(ValueError, match='population must be a positive integer'): + b.population = bad + + +class TestThin: + """thin property — positive integer.""" + + def test_get_set(self): + b = Bayesian() + b.thin = 5 + assert b.thin == 5 + + def test_accepts_minimum(self): + b = Bayesian() + b.thin = 1 + assert b.thin == 1 + + @pytest.mark.parametrize('bad', [0, -1, -5]) + def test_rejects_non_positive(self, bad): + b = Bayesian() + with pytest.raises(ValueError, match='thin must be a positive integer'): + b.thin = bad + + +class TestPosterior: + """posterior dict and has_result.""" + + def test_set_posterior(self): + b = Bayesian() + posterior = {'draws': 'fake', 'param_names': ['a', 'b']} + b._posterior = posterior + assert b.posterior is posterior + assert b.has_result is True + + def test_set_none(self): + b = Bayesian() + b._posterior = None + assert b.posterior is None + assert b.has_result is False + + def test_posterior_immutable_via_underscore(self): + """The posterior property returns the internal reference.""" + b = Bayesian() + data = {'draws': 'x'} + b._posterior = data + assert b.posterior is data + + +class TestClear: + """clear() resets everything to post-construction state.""" + + def test_clears_posterior(self): + b = Bayesian() + b._posterior = {'draws': 'x', 'param_names': ['a']} + b.clear() + assert b.posterior is None + assert b.has_result is False + + def test_clears_rendered_assets(self): + b = Bayesian() + b.corner_plot_url = 'file:///corner.html' + b.distribution_plot_url = 'file:///distribution.html' + b.trace_plot_url = 'file:///trace.png' + b.heatmap_plot_url = 'file:///heatmap.png' + b.heatmap_data = {'x': [1, 2]} + b.diagnostics = {'rhat': {'a': 1.0}} + b.clear() + assert b.corner_plot_url == '' + assert b.distribution_plot_url == '' + assert b.trace_plot_url == '' + assert b.heatmap_plot_url == '' + assert b.heatmap_data is None + assert b.diagnostics == {} + + def test_does_not_affect_hyperparams(self): + b = Bayesian() + b.samples = 5000 + b.burn = 1000 + b.population = 5 + b.thin = 2 + b.clear() + assert b.samples == 5000 + assert b.burn == 1000 + assert b.population == 5 + assert b.thin == 2 + + +class TestIdempotentSetters: + """Setting the same value multiple times works.""" + + def test_samples_idempotent(self): + b = Bayesian() + b.samples = 5000 + b.samples = 5000 + assert b.samples == 5000 + + def test_burn_idempotent(self): + b = Bayesian() + b.burn = 500 + b.burn = 500 + assert b.burn == 500 + + def test_population_idempotent(self): + b = Bayesian() + b.population = 7 + b.population = 7 + assert b.population == 7 + + def test_thin_idempotent(self): + b = Bayesian() + b.thin = 3 + b.thin = 3 + assert b.thin == 3 diff --git a/tests/test_logic_fitting.py b/tests/test_logic_fitting.py index a1153ab3..7911e7fa 100644 --- a/tests/test_logic_fitting.py +++ b/tests/test_logic_fitting.py @@ -222,3 +222,186 @@ def _raise_fit_error(exp_data): project.fitter = SimpleNamespace(fit_single_data_set_1d=_raise_fit_error) logic.start_stop() assert 'fit failed' in logic.fit_error_message + + +# =================================================================== +# Bayesian sampling preparation +# =================================================================== + +def test_prepare_threaded_sample_handles_empty_experiments(): + project = make_project(experiments={}) + logic = fitting_module.Fitting(project) + + result = logic.prepare_threaded_sample(StubMinimizersLogic()) + + assert result == (None, None) + assert logic.fit_error_message == 'No experiments to sample' + assert logic.fit_finished is True + assert logic.show_results_dialog is True + + +def test_prepare_threaded_sample_builds_multifitter_and_datagroup(monkeypatch): + install_fake_multifitter(monkeypatch) + model_a = make_model(name='A') + experiments = { + 0: make_experiment('Exp A', model=model_a, + x=np.array([1.0, 2.0]), y=np.array([4.0, 5.0]), ye=np.array([0.1, 0.2])), + } + project = make_project(experiments=experiments) + logic = fitting_module.Fitting(project) + + # Mock the datagroup collection to avoid scipp dependency + monkeypatch.setattr(logic, 'collect_all_experiments_datagroup', lambda: 'fake-data-group') + + multi_fitter, data_group = logic.prepare_threaded_sample(StubMinimizersLogic()) + + assert multi_fitter is not None + assert multi_fitter.models == (model_a,) + assert data_group == 'fake-data-group' + + +def test_collect_all_experiments_datagroup_builds_sc_structs(monkeypatch): + # Fake scipp to avoid import issues + import numpy as np + + # We need to make scipp available; we'll mock the import within the method + model = make_model(name='M1') + experiments = { + 0: make_experiment('Exp 1', model=model, + x=np.array([1.0, 2.0]), y=np.array([0.1, 0.2]), ye=np.array([0.01, 0.04])), + } + project = make_project(experiments=experiments) + logic = fitting_module.Fitting(project) + + # Create a minimal sc.DataGroup stand-in + class FakeSCUnit: + def __init__(self, unit_str): + self._str = unit_str + def __eq__(self, other): + return isinstance(other, FakeSCUnit) and other._str == self._str + + class FakeSCArray: + _registry = [] + def __init__(self, *, dims, values, variances=None, unit=None): + self.dims = dims + self.values = values + self.variances = variances + self.unit = unit + FakeSCArray._registry.append(self) + + class FakeSCDataGroup(dict): + def __init__(self, data=None, coords=None, attrs=None): + super().__init__() + self.data = data or {} + self.coords = coords or {} + self.attrs = attrs or {} + def __repr__(self): + return f'DataGroup({dict(self.data)})' + + FakeSCArray._registry = [] + + monkeypatch.setattr('scipp.Unit', FakeSCUnit) + monkeypatch.setattr('scipp.array', FakeSCArray) + monkeypatch.setattr('scipp.DataGroup', FakeSCDataGroup) + + dg = logic.collect_all_experiments_datagroup() + + # Verify FakeSCArray was called to create coords and data + assert len(FakeSCArray._registry) >= 2 # coords + data entries + # Coords entries have 'Qz' in their dims + coord_calls = [a for a in FakeSCArray._registry if 'Qz' in str(a.dims)] + assert len(coord_calls) >= 1 + # Data entries have variances (ye_vals) + data_calls = [a for a in FakeSCArray._registry if a.variances is not None] + project = make_project() + logic = fitting_module.Fitting(project) + + logic.prepare_for_threaded_sample() + + assert logic.running is True + assert logic.fit_finished is False + assert logic.show_results_dialog is False + assert logic.sample_progress_message == 'Sampling… (this may take several minutes)' + + +# =================================================================== +# Bayesian sampling progress +# =================================================================== + +def test_on_sample_progress_updates_step_and_total(): + project = make_project() + logic = fitting_module.Fitting(project) + + logic.on_sample_progress({'iteration': 42, 'total_steps': 1000}) + + assert logic.sample_step == 42 + assert logic.sample_total_steps == 1000 + assert logic.sample_has_update is True + + +def test_on_sample_progress_handles_zero_defaults(): + project = make_project() + logic = fitting_module.Fitting(project) + + logic.on_sample_progress({}) + + assert logic.sample_step == 0 + assert logic.sample_total_steps == 0 + + +def test_on_sample_progress_handles_none_values(): + project = make_project() + logic = fitting_module.Fitting(project) + + logic.on_sample_progress({'iteration': None, 'total_steps': None}) + + assert logic.sample_step == 0 + assert logic.sample_total_steps == 0 + + +# =================================================================== +# Bayesian sampling completion lifecycle +# =================================================================== + +def test_on_sample_finished_clears_fit_state_and_shows_dialog(): + project = make_project() + logic = fitting_module.Fitting(project) + + # Simulate that a fit was running + logic.prepare_for_threaded_sample() + assert logic.running is True + + logic.on_sample_finished() + + assert logic.running is False + assert logic.fit_finished is True + assert logic.show_results_dialog is True + assert logic.fit_error_message == '' + assert logic.fit_success is False # default + assert logic.sample_step == 0 # cleared via clear_fit_progress + assert logic.sample_has_update is False + + +def test_on_sample_finished_preserves_none_result(): + """on_sample_finished does not set a FitResult — Bayesian results are stored elsewhere.""" + project = make_project() + logic = fitting_module.Fitting(project) + + logic.on_sample_finished() + + assert logic._result is None + assert logic._results == [] + + +def test_clear_sample_progress_works(): + project = make_project() + logic = fitting_module.Fitting(project) + + logic.on_sample_progress({'iteration': 50, 'total_steps': 500}) + assert logic.sample_step == 50 + + logic.clear_sample_progress() + + assert logic.sample_step == 0 + assert logic.sample_total_steps == 0 + assert logic.sample_progress_message == '' diff --git a/tests/test_logic_minimizers.py b/tests/test_logic_minimizers.py index 9da9bffa..3981ab0e 100644 --- a/tests/test_logic_minimizers.py +++ b/tests/test_logic_minimizers.py @@ -17,6 +17,7 @@ class FakeAvailableMinimizers: LMFit = FakeEnumValue('LMFit') Bumps = FakeEnumValue('Bumps') DFO = FakeEnumValue('DFO') + Bumps_simplex = FakeEnumValue('Bumps_simplex') DREAM = FakeEnumValue('DREAM') SciPy = FakeEnumValue('SciPy') @@ -30,8 +31,13 @@ def test_minimizers_filters_out_blocked_entries(monkeypatch): logic = minimizers_module.Minimizers(project) - assert logic.minimizers_available() == ['DREAM', 'SciPy'] + # Bayesian sentinel is prepended at index 0 + assert logic.minimizers_available() == ['BUMPS-DREAM (Bayesian)', 'DREAM', 'SciPy'] + # Default is index 1 (first classical minimizer); index 0 is the Bayesian + # sentinel, which requires an explicit user choice (issue #320). + assert logic.minimizer_current_index() == 1 assert logic.selected_minimizer_enum().name == 'DREAM' + assert logic.is_bayesian_selected() is False def test_minimizers_set_index_updates_project_and_runtime_properties(monkeypatch): @@ -41,9 +47,10 @@ def test_minimizers_set_index_updates_project_and_runtime_properties(monkeypatch logic = minimizers_module.Minimizers(project) - assert logic.set_minimizer_current_index(1) is True + # Index 0 = Bayesian sentinel, index 1 = DREAM, index 2 = SciPy + assert logic.set_minimizer_current_index(2) is True assert project.minimizer.name == 'SciPy' - assert logic.set_minimizer_current_index(1) is False + assert logic.set_minimizer_current_index(2) is False assert logic.tolerance == 1e-6 assert logic.max_iterations == 5000 diff --git a/tests/test_logic_parameters.py b/tests/test_logic_parameters.py index 7c8fbf5b..41a25ddf 100644 --- a/tests/test_logic_parameters.py +++ b/tests/test_logic_parameters.py @@ -1,4 +1,4 @@ -from types import SimpleNamespace +import logging from EasyReflectometryApp.Backends.Py.logic import parameters as parameters_module from tests.factories import FakeParameter @@ -8,45 +8,33 @@ from tests.factories import make_project -class FakeMap: - def __init__(self, paths, names): - self._paths = paths - self._names = names +class FakeNode: + """Minimal stand-in for an easyscience ModelBase container in the object tree. - def find_path(self, model_unique_name, parameter_unique_name): - return self._paths.get((model_unique_name, parameter_unique_name), []) + The new core no longer populates the global_object.map graph, so + ``_from_parameters_to_list_of_dicts`` walks the model object tree + instead. These nodes let the tests build that tree from named + containers (assemblies, materials, ...) holding Parameters and + sub-collections. + """ - def get_item_by_key(self, key): - return SimpleNamespace(name=self._names[key]) + def __init__(self, name, unique_name, **children): + self.name = name + self.unique_name = unique_name + for key, value in children.items(): + setattr(self, key, value) -def test_from_parameters_to_list_of_dicts_prefixes_layers_and_deduplicates_shared_params(monkeypatch): +def _patch_tree_types(monkeypatch): + # The traversal uses module-level `Parameter`/`ModelBase` for isinstance + # checks; point them at the test fakes so the fake tree is walkable. monkeypatch.setattr(parameters_module, 'Parameter', FakeParameter) - fake_map = FakeMap( - { - ('m1', 'thickness'): ['group_t1', 'param_t1'], - ('m2', 'thickness'): ['group_t2', 'param_t2'], - ('m1', 'scale'): ['group_scale', 'param_scale'], - ('m2', 'scale'): ['group_scale', 'param_scale'], - ('m1', 'dep'): ['group_dep', 'param_dep'], - }, - { - 'group_t1': 'LayerA', - 'param_t1': 'thickness', - 'group_t2': 'LayerA', - 'param_t2': 'thickness', - 'group_scale': 'Instrument', - 'param_scale': 'scale', - 'group_dep': 'Instrument', - 'param_dep': 'background', - }, - ) - monkeypatch.setattr(parameters_module.global_object, 'map', fake_map) + monkeypatch.setattr(parameters_module, 'ModelBase', FakeNode) + + +def test_from_parameters_to_list_of_dicts_prefixes_layers_and_deduplicates_shared_params(monkeypatch): + _patch_tree_types(monkeypatch) - models = make_model_collection( - make_model(name='M1 internal', unique_name='m1', user_data={'original_name': 'M1'}), - make_model(name='M2 internal', unique_name='m2', user_data={'original_name': 'M2'}), - ) scale = make_parameter(name='scale', unique_name='scale', value=1.5, free=False, enabled=True) thickness = make_parameter(name='thickness', unique_name='thickness', value=20.0, free=True, enabled=True) dependent = make_parameter( @@ -60,6 +48,26 @@ def test_from_parameters_to_list_of_dicts_prefixes_layers_and_deduplicates_share enabled=False, ) + def build_model(unique_name, original_name, with_background): + model = make_model( + name=f'{original_name} internal', unique_name=unique_name, user_data={'original_name': original_name} + ) + # Model -> sample -> assembly -> layers -> layer -> thickness (a layer parameter) + layer = FakeNode('Layer', f'{unique_name}_layer', thickness=thickness) + assembly = FakeNode('LayerA', f'{unique_name}_asm', layers=[layer]) + model.sample = [assembly] + # Non-layer params (scale, background) live under a shared 'Instrument' parent + instrument = FakeNode('Instrument', f'{unique_name}_instr', scale=scale) + if with_background: + instrument.background = dependent + model.instrument = instrument + return model + + models = make_model_collection( + build_model('m1', 'M1', with_background=True), + build_model('m2', 'M2', with_background=False), + ) + result = parameters_module._from_parameters_to_list_of_dicts([thickness, scale, dependent], models) assert [entry['display_name'] for entry in result] == [ @@ -162,50 +170,10 @@ def test_add_constraint_supports_arithmetic_and_constant_dependencies(): def test_from_parameters_to_list_of_dicts_handles_alias_edge_cases_and_fallbacks(monkeypatch): - monkeypatch.setattr(parameters_module, 'Parameter', FakeParameter) + _patch_tree_types(monkeypatch) - class ParameterWithoutEnabled: - def __init__(self, name, unique_name, value=0.0, independent=True, dependency_expression='', dependency_map=None): - self.name = name - self.unique_name = unique_name - self.value = value - self.variance = 0.0 - self.min = 0.0 - self.max = 10.0 - self.unit = '' - self.free = False - self.independent = independent - self.dependency_expression = dependency_expression - self.dependency_map = dependency_map or {} - - fake_map = FakeMap( - { - ('m1', 'p1'): ['group_same', 'param_same'], - ('m1', 'p2'): ['group_same', 'param_same'], - ('m1', 'reserved'): ['group_np', 'param_np'], - ('m1', 'numeric'): ['group_num', 'param_num'], - ('m1', 'empty'): ['only_key'], - ('m1', 'dep_other'): ['group_dep', 'param_dep'], - ('m1', 'no_enabled'): ['group_ne', 'param_ne'], - }, - { - 'group_same': 'Same Group', - 'param_same': 'Same Name', - 'group_np': 'Reserved', - 'param_np': 'np', - 'group_num': '123', - 'param_num': '456', - 'group_dep': 'DepGroup', - 'param_dep': 'DepName', - 'group_ne': 'Visible', - 'param_ne': 'Parameter', - }, - ) - monkeypatch.setattr(parameters_module.global_object, 'map', fake_map) - - models = make_model_collection(make_model(name='M1', unique_name='m1', user_data={'original_name': 'M1'})) - param1 = make_parameter(name='same', unique_name='p1') - param2 = make_parameter(name='same', unique_name='p2') + param1 = make_parameter(name='Same Name', unique_name='p1') + param2 = make_parameter(name='Same Name', unique_name='p2') reserved = make_parameter(name='np', unique_name='reserved') numeric = make_parameter(name='456', unique_name='numeric') empty = make_parameter(name='!!!', unique_name='empty') @@ -216,7 +184,21 @@ def __init__(self, name, unique_name, value=0.0, independent=True, dependency_ex dependency_expression='a+1', dependency_map={'a': 'external'}, ) - no_enabled = ParameterWithoutEnabled(name='visible', unique_name='no_enabled', value=2.0) + no_enabled = make_parameter(name='visible', unique_name='no_enabled', value=2.0) + del no_enabled.enabled # exercise the missing-`enabled` fallback (-> True) + + model = make_model(name='M1', unique_name='m1', user_data={'original_name': 'M1'}) + # Each parameter sits under a named container so its display name and + # alias derive from ` `. + model.groups = [ + FakeNode('Same Group', 'sg', p1=param1, p2=param2), + FakeNode('Reserved', 'res', r=reserved), + FakeNode('123', 'num', n=numeric), + FakeNode('', 'empty_parent', e=empty), + FakeNode('DepGroup', 'depg', d=dep_other), + FakeNode('Visible', 'vis', v=no_enabled), + ] + models = make_model_collection(model) result = parameters_module._from_parameters_to_list_of_dicts( [param1, param2, reserved, numeric, empty, dep_other, no_enabled], @@ -228,7 +210,7 @@ def __init__(self, name, unique_name, value=0.0, independent=True, dependency_ex assert aliases[1] == 'same_group_same_name_1' assert aliases[2] == 'reserved_np' assert aliases[3] == 'p_123_456' - assert result[4]['display_name'] == '!!!' + assert result[4]['display_name'].endswith('!!!') assert aliases[4] == 'param' assert result[5]['dependency'] == 'external+1' assert result[6]['enabled'] is True @@ -265,7 +247,7 @@ def test_parameters_special_filters_and_invalid_variability_normalization(monkey assert [entry['display_name'] for entry in logic.parameters] == ['adp b_iso'] -def test_parameter_current_selection_edge_cases_and_unsupported_constraint(monkeypatch, capsys): +def test_parameter_current_selection_edge_cases_and_unsupported_constraint(monkeypatch, caplog): project = make_project() logic = parameters_module.Parameters(project) parameter = make_parameter(name='Scale', unique_name='scale', value=1.0, free=True) @@ -298,10 +280,8 @@ def test_parameter_current_selection_edge_cases_and_unsupported_constraint(monke dependent = make_parameter(name='Background', unique_name='background', value=0.5) project.parameters = [parameter, dependent] - logic.add_constraint(1, '=', 2.0, '', 0) + with caplog.at_level(logging.WARNING, logger='EasyReflectometryApp.Backends.Py.logic.parameters'): + logic.add_constraint(1, '=', 2.0, '', 0) - # NOTE: The production code reports this error via print(). If the error reporting - # mechanism changes to logging, this assertion must be updated to use caplog instead. - captured = capsys.readouterr() - assert 'Unsupported type' in captured.out + assert 'Unsupported type' in caplog.text assert dependent.independent is True diff --git a/tests/test_py_backend.py b/tests/test_py_backend.py index 6abbe094..517b093d 100644 --- a/tests/test_py_backend.py +++ b/tests/test_py_backend.py @@ -68,6 +68,10 @@ def __init__(self, _project_lib, parent=None): self._selected = [0] self.received_indices = None self.clear_calls = 0 + self._plotting_accepted = None + + def set_plotting(self, plotting): + self._plotting_accepted = plotting @property def experimentsSelectedCount(self): diff --git a/tests/test_py_project.py b/tests/test_py_project.py index 64b9e4d5..99201344 100644 --- a/tests/test_py_project.py +++ b/tests/test_py_project.py @@ -120,3 +120,26 @@ def _warn_and_return_none(_orso_data): assert project._logic.added_samples == [] assert project._logic.replaced_samples == [] assert received == ['Missing model in ORSO'] + + +def test_load_emits_error_on_outdated_file_format(monkeypatch, qcore_application): + project = _build_project(monkeypatch) + monkeypatch.setattr(project_module.IO, 'generalizePath', lambda path: path) + + def _raise_format_error(_path): + raise ValueError('This project file predates file_format=2 and cannot be loaded.') + + monkeypatch.setattr(project._logic, 'load', _raise_format_error) + + errors = [] + project.projectLoadError.connect(lambda msg: errors.append(msg)) + loaded = {'count': 0} + project.externalProjectLoaded.connect(lambda: loaded.__setitem__('count', loaded['count'] + 1)) + + project.load('old_project.json') + + assert errors == [ + 'This project file uses obsolete and unsupported format.\n' + 'Please re-create the project from its underlying data and save it again.' + ] + assert loaded['count'] == 0 diff --git a/tests/test_py_status.py b/tests/test_py_status.py index 10d11a55..e37708a3 100644 --- a/tests/test_py_status.py +++ b/tests/test_py_status.py @@ -5,6 +5,7 @@ class StubStatusLogic: def __init__(self, _project_lib): self.project = 'Demo Project' self.experiments_count = '4' + self.models_count = '2' self.calculator = 'refnx' self.minimizer = 'LeastSquares' @@ -27,11 +28,11 @@ def test_status_wrapper_delegates_to_logic(monkeypatch, qcore_application): assert status.variables == '2 free / 10 total' -def test_status_wrapper_phase_count_is_none(monkeypatch, qcore_application): +def test_status_wrapper_models_count_delegates_to_logic(monkeypatch, qcore_application): monkeypatch.setattr(status_module, 'StatusLogic', StubStatusLogic) monkeypatch.setattr(status_module, 'ParametersLogic', StubParametersLogic) status = status_module.Status(project_lib=object()) - assert status.phaseCount is None + assert status.modelsCount == '2' diff --git a/tests/test_qml_fitting_progress_ui.py b/tests/test_qml_fitting_progress_ui.py index a33581f5..a1b6d1b1 100644 --- a/tests/test_qml_fitting_progress_ui.py +++ b/tests/test_qml_fitting_progress_ui.py @@ -45,9 +45,9 @@ def test_fit_buttons_toggle_between_start_and_cancel_via_start_stop_action(): 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.analysisFittingRunning ? qsTr('Cancel fitting') : (Globals.BackendWrapper.analysisIsBayesianSelected ? qsTr('Start sampling') : 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.analysisFittingRunning ? qsTr('Cancel fitting') : (Globals.BackendWrapper.analysisIsBayesianSelected ? qsTr('Start sampling') : qsTr('Start fitting'))" in fitting_group_qml assert 'Globals.BackendWrapper.analysisFittingStartStop()' in fitting_group_qml