From 4094e666a5797247501f53da152846da302cbc05 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 12:03:52 +0200 Subject: [PATCH 01/17] Use full samples for pair-plot marginal KDEs --- src/easydiffraction/display/plotting.py | 10 +-- .../easydiffraction/display/test_plotting.py | 69 +++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index 2ca6dff0..b7b7543c 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -214,6 +214,7 @@ class _PosteriorPairsContext: labels: list[str] annotation_labels: list[str] title: str + marginal_density_samples: np.ndarray density_samples: np.ndarray scatter_samples: np.ndarray show_contours: bool @@ -1632,6 +1633,7 @@ def _posterior_pairs_context( self._posterior_pair_title(uncertainty_multiplier), resolved_threshold, ), + marginal_density_samples=selected_samples, density_samples=density_samples, scatter_samples=scatter_samples, show_contours=show_contours, @@ -1640,7 +1642,7 @@ def _posterior_pairs_context( axis_ranges=self._posterior_pair_axis_ranges( fit_results=fit_results, parameter_names=parameter_names, - density_samples=selected_samples, + samples=selected_samples, ), ) @@ -1681,7 +1683,7 @@ def _posterior_pair_axis_ranges( *, fit_results: object, parameter_names: list[str], - density_samples: np.ndarray, + samples: np.ndarray, ) -> list[tuple[float, float]]: """Return per-parameter axis ranges for a pair plot.""" axis_ranges: list[tuple[float, float]] = [] @@ -1692,7 +1694,7 @@ def _posterior_pair_axis_ranges( ) axis_ranges.append( self._posterior_axis_bounds( - density_samples[:, index], + samples[:, index], lower_bound=lower_bound, upper_bound=upper_bound, ) @@ -1777,7 +1779,7 @@ def _add_posterior_pair_diagonal( ) -> None: """Add the diagonal marginal-density panel.""" go = __import__('plotly.graph_objects', fromlist=['Histogram']) - density_values = context.density_samples[:, parameter_index] + density_values = context.marginal_density_samples[:, parameter_index] density_trace = self._posterior_density_trace( fit_results=context.fit_results, parameter_name=context.parameter_names[parameter_index], diff --git a/tests/unit/easydiffraction/display/test_plotting.py b/tests/unit/easydiffraction/display/test_plotting.py index 18f0afe8..0429227e 100644 --- a/tests/unit/easydiffraction/display/test_plotting.py +++ b/tests/unit/easydiffraction/display/test_plotting.py @@ -585,6 +585,7 @@ def test_posterior_pairs_context_thins_kde_samples_and_preserves_axis_ranges(): context = plotter._posterior_pairs_context(parameters=None) assert context is not None + assert context.marginal_density_samples.shape == (sample_count, parameter_count) assert context.density_samples.shape == ( Plotter._posterior_pair_density_max_points(parameter_count), parameter_count, @@ -596,6 +597,74 @@ def test_posterior_pairs_context_thins_kde_samples_and_preserves_axis_ranges(): assert context.axis_ranges[1][0] < -50.0 +def test_posterior_pair_diagonal_matches_standalone_distribution_when_thinned(): + from easydiffraction.analysis.fit_helpers.bayesian import PosteriorParameterSummary + from easydiffraction.analysis.fit_helpers.bayesian import PosteriorSamples + from easydiffraction.display.plotting import Plotter + + sample_count = 5001 + angle = np.linspace(0.0, 12.0 * np.pi, sample_count, dtype=float) + sample_axis = np.linspace(-1.0, 1.0, sample_count, dtype=float) + samples = np.empty((1, sample_count, 2), dtype=float) + samples[0, :, 0] = 3.8913 + 0.00016 * np.sin(angle) + 0.00003 * np.cos(2.0 * angle) + samples[0, :, 1] = 0.0780 + 0.0024 * np.cos(0.5 * angle) + 0.0005 * sample_axis**2 + parameter_names = ['length_a', 'broad_gauss_u'] + posterior_samples = PosteriorSamples( + parameter_names=parameter_names, + parameter_samples=samples, + log_posterior=np.zeros((1, sample_count), dtype=float), + ) + parameters = [ + SimpleNamespace(unique_name='length_a', name='length_a', fit_min=3.8909, fit_max=3.8917), + SimpleNamespace( + unique_name='broad_gauss_u', + name='broad_gauss_u', + fit_min=0.074, + fit_max=0.082, + ), + ] + summaries = [ + PosteriorParameterSummary( + unique_name=name, + display_name=name, + map_value=float(samples[0, -1, index]), + median=float(np.median(samples[:, :, index])), + standard_deviation=float(np.std(samples[:, :, index], ddof=1)), + interval_68=tuple(np.quantile(samples[:, :, index], [0.16, 0.84]).tolist()), + interval_95=tuple(np.quantile(samples[:, :, index], [0.025, 0.975]).tolist()), + ) + for index, name in enumerate(parameter_names) + ] + fit_results = SimpleNamespace( + posterior_samples=posterior_samples, + posterior_parameter_summaries=summaries, + posterior_predictive={}, + parameters=parameters, + ) + plotter = Plotter() + plotter._get_posterior_samples_and_fit_results = MethodType( + lambda self: (posterior_samples, fit_results), + plotter, + ) + plotter._get_fit_result_for_correlation = MethodType(lambda self: fit_results, plotter) + + pair_figure = plotter._build_posterior_pairs_plot(parameters=parameters) + distribution_figure = plotter._build_param_distribution_plot(parameters[0]) + + pair_trace = next( + trace + for trace in pair_figure.data + if trace.name == 'Marginal density' + and trace.hovertemplate == 'length_a: %{x:.4f}
density: %{y:.4f}' + ) + distribution_trace = next( + trace for trace in distribution_figure.data if trace.name == 'Marginal density' + ) + + np.testing.assert_allclose(pair_trace.x, distribution_trace.x) + np.testing.assert_allclose(pair_trace.y, distribution_trace.y) + + def test_build_posterior_pairs_plot_rejects_unknown_style(): plotter, _, _ = _make_bayesian_plotter_fixture() From 03f0d1282495459a42a4e832a729276e3df2cceb Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 16:15:49 +0200 Subject: [PATCH 02/17] Support posterior distribution defaults --- src/easydiffraction/project/display.py | 26 ++++++++-- .../easydiffraction/project/test_display.py | 51 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py index 6bab7df3..f236717b 100644 --- a/src/easydiffraction/project/display.py +++ b/src/easydiffraction/project/display.py @@ -16,6 +16,7 @@ from easydiffraction.display.progress import ACTIVITY_LABEL_PROCESSING from easydiffraction.display.progress import activity_indicator from easydiffraction.utils.enums import VerbosityEnum +from easydiffraction.utils.logging import log from easydiffraction.utils.utils import render_object_help from easydiffraction.utils.utils import render_table @@ -142,9 +143,28 @@ def pairs( max_parameters=max_parameters, ) - def distribution(self, param: object) -> None: - """Plot one sampled parameter's posterior distribution.""" - self._project.rendering.plotter.plot_param_distribution(param) + def distribution(self, param: object | None = None) -> None: + """Plot posterior distributions for one or all free parameters.""" + plotter = self._project.rendering.plotter + if param is not None: + plotter.plot_param_distribution(param) + return + + free_parameters = getattr(self._project, 'free_parameters', None) + if not free_parameters: + log.warning('No free parameters found.') + return + + if plotter.engine == PlotterEngineEnum.ASCII.value: + log.warning( + 'Posterior distribution plots require an explicit parameter ' + 'with the ASCII backend. Iterate over project.free_parameters ' + 'to render them one by one.' + ) + return + + for free_parameter in free_parameters: + plotter.plot_param_distribution(free_parameter) def predictive( self, diff --git a/tests/unit/easydiffraction/project/test_display.py b/tests/unit/easydiffraction/project/test_display.py index b67d7c02..c0787691 100644 --- a/tests/unit/easydiffraction/project/test_display.py +++ b/tests/unit/easydiffraction/project/test_display.py @@ -50,6 +50,7 @@ def _recorder(*args, **kwargs): project = SimpleNamespace( analysis=SimpleNamespace(display=analysis_display), rendering=SimpleNamespace(plotter=plotter), + free_parameters=[], verbosity='full', ) return project, calls @@ -259,6 +260,56 @@ def fake_activity_indicator(label, *, verbosity): ] +def test_posterior_distribution_without_param_plots_all_free_parameters(): + project, calls = _make_project_stub() + project.free_parameters = ['a', 'b'] + project.rendering.plotter.engine = 'plotly' + display = ProjectDisplay(project) + + display.posterior.distribution() + + assert calls == [ + ('plot_param_distribution', ('a',), {}), + ('plot_param_distribution', ('b',), {}), + ] + + +def test_posterior_distribution_without_param_warns_once_for_ascii(monkeypatch): + import easydiffraction.project.display as display_mod + + project, calls = _make_project_stub() + project.free_parameters = ['a', 'b'] + project.rendering.plotter.engine = 'asciichartpy' + display = ProjectDisplay(project) + warnings: list[str] = [] + + monkeypatch.setattr(display_mod.log, 'warning', warnings.append) + + display.posterior.distribution() + + assert calls == [] + assert warnings == [ + 'Posterior distribution plots require an explicit parameter ' + 'with the ASCII backend. Iterate over project.free_parameters ' + 'to render them one by one.' + ] + + +def test_posterior_distribution_without_param_warns_when_no_free_parameters(monkeypatch): + import easydiffraction.project.display as display_mod + + project, calls = _make_project_stub() + display = ProjectDisplay(project) + warnings: list[str] = [] + + monkeypatch.setattr(display_mod.log, 'warning', warnings.append) + + display.posterior.distribution() + + assert calls == [] + assert warnings == ['No free parameters found.'] + + def test_pattern_auto_routes_measured_and_excluded_to_plot_meas(): project, calls = _make_project_stub() display = ProjectDisplay(project) From 42bc99a2826e397c5b3244f6bbd23896c52d9480 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 16:22:21 +0200 Subject: [PATCH 03/17] Support ASCII posterior predictive plots --- src/easydiffraction/display/plotters/ascii.py | 1 + src/easydiffraction/display/plotters/base.py | 4 + .../display/plotters/plotly.py | 1 + src/easydiffraction/display/plotting.py | 101 ++++++++--- .../display/plotters/test_ascii.py | 22 +++ .../easydiffraction/display/test_plotting.py | 167 ++++++++++++++++++ 6 files changed, 275 insertions(+), 21 deletions(-) diff --git a/src/easydiffraction/display/plotters/ascii.py b/src/easydiffraction/display/plotters/ascii.py index c81af0b0..534a4f1b 100644 --- a/src/easydiffraction/display/plotters/ascii.py +++ b/src/easydiffraction/display/plotters/ascii.py @@ -22,6 +22,7 @@ DEFAULT_COLORS = { 'meas': asciichartpy.blue, 'calc': asciichartpy.red, + 'posterior': asciichartpy.red, 'resid': asciichartpy.green, } diff --git a/src/easydiffraction/display/plotters/base.py b/src/easydiffraction/display/plotters/base.py index 4cf43d66..ed6df748 100644 --- a/src/easydiffraction/display/plotters/base.py +++ b/src/easydiffraction/display/plotters/base.py @@ -191,6 +191,10 @@ class XAxisType(StrEnum): 'mode': 'lines', 'name': 'Total calculated (Icalc)', }, + 'posterior': { + 'mode': 'lines', + 'name': 'Max posterior', + }, 'bkg': { 'mode': 'lines', 'name': 'Background (Ibkg)', diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index fbbda288..afa99da9 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -38,6 +38,7 @@ 'meas': 'rgb(31, 119, 180)', 'bkg': 'rgb(140, 140, 140)', 'calc': 'rgb(214, 39, 40)', + 'posterior': 'rgb(214, 39, 40)', 'resid': 'rgb(44, 160, 44)', } diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index b7b7543c..82883c41 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -1049,8 +1049,10 @@ def plot_posterior_predictive( style : str, default='band' ``'band'`` shows the 95% credible interval, ``'draws'`` shows sampled predictive curves, and ``'band+draws'`` shows - both together. Single-crystal plots currently render only - the interval-based reflection check. + both together. ASCII powder plots fall back to measured and + max-posterior lines without uncertainty bands or draws. + Single-crystal plots currently render only the interval- + based reflection check. x_min : float | None, default=None Lower bound for the x-axis range. x_max : float | None, default=None @@ -1099,10 +1101,6 @@ def _plot_posterior_predictive_request( log.warning('Plotter is not attached to a project.') return - if self.engine != PlotterEngineEnum.PLOTLY.value: - log.warning('Posterior predictive plots currently require the Plotly backend.') - return - self._update_project_categories(expt_name) experiment = self._project.experiments[expt_name] x_axis, _, sample_form, scattering_type, _ = self._resolve_x_axis( @@ -1111,6 +1109,12 @@ def _plot_posterior_predictive_request( ) if sample_form == SampleFormEnum.SINGLE_CRYSTAL: + if self.engine != PlotterEngineEnum.PLOTLY.value: + log.warning( + 'Single-crystal posterior predictive plots currently ' + 'require the Plotly backend.' + ) + return self._plot_single_crystal_posterior_predictive( experiment=experiment, expt_name=expt_name, @@ -1237,6 +1241,10 @@ def _plot_non_bragg_posterior_predictive( style: str, ) -> None: """Render non-Bragg posterior predictive summaries.""" + show_draws = self.engine == PlotterEngineEnum.PLOTLY.value and style in { + 'draws', + 'band+draws', + } pattern = intensity_category_for(experiment) y_meas = getattr(pattern, 'intensity_meas', None) if y_meas is None: @@ -1264,7 +1272,7 @@ def _plot_non_bragg_posterior_predictive( experiment=experiment, expt_name=expt_name, x_axis=x_axis, - include_draws=style in {'draws', 'band+draws'}, + include_draws=show_draws, ) if summary is None: return @@ -1273,7 +1281,7 @@ def _plot_non_bragg_posterior_predictive( summary=summary, x_min=ctx['x_min'], x_max=ctx['x_max'], - include_draws=style in {'draws', 'band+draws'}, + include_draws=show_draws, ) if filtered_summary is None: log.warning( @@ -3448,7 +3456,18 @@ def _plot_posterior_predictive_summary( show_draws: bool, excluded_ranges: tuple[tuple[float, float], ...] = (), ) -> None: - """Render posterior predictive summaries using Plotly.""" + """Render posterior predictive summaries using the active backend.""" + if self.engine == PlotterEngineEnum.ASCII.value: + self._plot_ascii_posterior_predictive_lines( + expt_name=expt_name, + x=np.asarray(summary.x, dtype=float), + y_meas=np.asarray(y_meas, dtype=float), + y_calc=np.asarray(summary.map_prediction, dtype=float), + axes_labels=axes_labels, + excluded_ranges=excluded_ranges, + ) + return + go = __import__('plotly.graph_objects', fromlist=['Figure', 'Scatter']) axis_frame_color = self._plot_axis_frame_color() @@ -3568,6 +3587,27 @@ def _plot_posterior_predictive_summary( ) self._show_plot_figure(fig) + def _plot_ascii_posterior_predictive_lines( + self, + *, + expt_name: str, + x: np.ndarray, + y_meas: np.ndarray, + y_calc: np.ndarray, + axes_labels: list[str], + excluded_ranges: tuple[tuple[float, float], ...] = (), + ) -> None: + """Render posterior predictive lines on the ASCII backend.""" + self._backend.plot_powder( + x=x, + y_series=[y_meas, y_calc], + labels=['meas', 'posterior'], + axes_labels=axes_labels, + title=f"Posterior predictive for experiment 🔬 '{expt_name}'", + height=self.height, + excluded_ranges=excluded_ranges, + ) + def _plot_single_crystal_posterior_predictive_summary( self, *, @@ -3679,6 +3719,10 @@ def _plot_posterior_predictive_data( style: str, ) -> None: """Render posterior predictive curves on the powder layout.""" + show_draws = self.engine == PlotterEngineEnum.PLOTLY.value and style in { + 'draws', + 'band+draws', + } pattern = intensity_category_for(experiment) ctx = self._prepare_powder_context( pattern, @@ -3695,7 +3739,7 @@ def _plot_posterior_predictive_data( experiment=experiment, expt_name=expt_name, x_axis=x_axis, - include_draws=style in {'draws', 'band+draws'}, + include_draws=show_draws, ) if summary is None: return @@ -3717,6 +3761,31 @@ def _plot_posterior_predictive_data( y_calc = self._filtered_y_array( summary.map_prediction, summary.x, ctx['x_min'], ctx['x_max'] ) + excluded_ranges = ( + self._excluded_ranges( + experiment=experiment, + x_min=ctx['x_min'], + x_max=ctx['x_max'], + ) + if plot_options.show_excluded + else () + ) + if self.engine == PlotterEngineEnum.ASCII.value: + if plot_options.show_residual: + log.warning( + 'Posterior predictive residuals are unavailable for ' + 'ASCII summary plots; ignoring show_residual=True.' + ) + self._plot_ascii_posterior_predictive_lines( + expt_name=expt_name, + x=np.asarray(ctx['x_filtered'], dtype=float), + y_meas=np.asarray(y_meas, dtype=float), + y_calc=np.asarray(y_calc, dtype=float), + axes_labels=list(ctx['axes_labels']), + excluded_ranges=excluded_ranges, + ) + return + show_residual = True if plot_options.show_residual is None else plot_options.show_residual y_resid = y_meas - y_calc if show_residual else None @@ -3737,7 +3806,7 @@ def _plot_posterior_predictive_data( ) predictive_draws = None - if style in {'draws', 'band+draws'}: + if show_draws: draws = getattr(summary, 'draws', None) if draws is None: log.warning('Posterior predictive draws are unavailable for plotting.') @@ -3760,16 +3829,6 @@ def _plot_posterior_predictive_data( x_min=ctx['x_min'], x_max=ctx['x_max'], ) - excluded_ranges = ( - self._excluded_ranges( - experiment=experiment, - x_min=ctx['x_min'], - x_max=ctx['x_max'], - ) - if plot_options.show_excluded - else () - ) - plot_spec = PowderMeasVsCalcSpec( x=ctx['x_filtered'], y_meas=y_meas, diff --git a/tests/unit/easydiffraction/display/plotters/test_ascii.py b/tests/unit/easydiffraction/display/plotters/test_ascii.py index 49dd7653..84f90bac 100644 --- a/tests/unit/easydiffraction/display/plotters/test_ascii.py +++ b/tests/unit/easydiffraction/display/plotters/test_ascii.py @@ -23,6 +23,28 @@ def test_ascii_plotter_plot_minimal(capsys): assert 'Displaying data for selected x-range' in out +def test_ascii_plotter_plot_supports_max_posterior_legend(capsys): + from easydiffraction.display.plotters.ascii import AsciiPlotter + + x = np.array([0.0, 1.0, 2.0]) + y_meas = np.array([1.0, 2.0, 3.0]) + y_map = np.array([0.5, 1.5, 2.5]) + plotter = AsciiPlotter() + + plotter.plot_powder( + x=x, + y_series=[y_meas, y_map], + labels=['meas', 'posterior'], + axes_labels=['x', 'y'], + title='Posterior predictive', + height=5, + ) + + out = capsys.readouterr().out + assert 'Measured (Imeas)' in out + assert 'Max posterior' in out + + def test_ascii_plotter_plot_single_crystal(capsys): from easydiffraction.display.plotters.ascii import AsciiPlotter diff --git a/tests/unit/easydiffraction/display/test_plotting.py b/tests/unit/easydiffraction/display/test_plotting.py index 0429227e..9793f97b 100644 --- a/tests/unit/easydiffraction/display/test_plotting.py +++ b/tests/unit/easydiffraction/display/test_plotting.py @@ -726,6 +726,7 @@ def test_plot_posterior_predictive_summary_uses_consistent_labels_and_styles(mon captured: dict[str, object] = {} plotter = Plotter() + plotter.engine = 'plotly' plotter._backend = SimpleNamespace( _show_figure=lambda figure: captured.setdefault('fig', figure) ) @@ -866,6 +867,172 @@ class Experiment: assert plot_spec.y_calc_line_dash == 'dot' +def test_plot_posterior_predictive_request_allows_ascii_for_powder_bragg(monkeypatch): + from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum + from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum + from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum + from easydiffraction.display.plotting import Plotter + + captured: dict[str, object] = {} + + class ExptType: + sample_form = type('SF', (), {'value': SampleFormEnum.POWDER})() + scattering_type = type('S', (), {'value': ScatteringTypeEnum.BRAGG})() + beam_mode = type('B', (), {'value': BeamModeEnum.CONSTANT_WAVELENGTH})() + + class Experiment: + type = ExptType() + + class Project: + experiments = {'hrpt': Experiment()} + + plotter = Plotter() + plotter.engine = 'asciichartpy' + plotter._set_project(Project()) + + monkeypatch.setattr(Plotter, '_update_project_categories', lambda self, expt_name: None) + + def fake_plot_posterior_predictive_data( + self, + *, + experiment, + expt_name, + plot_options, + x_axis, + style, + ): + captured['experiment'] = experiment + captured['expt_name'] = expt_name + captured['style'] = style + captured['x_axis'] = x_axis + captured['show_residual'] = plot_options.show_residual + + monkeypatch.setattr( + Plotter, '_plot_posterior_predictive_data', fake_plot_posterior_predictive_data + ) + + plotter.plot_posterior_predictive('hrpt') + + assert captured['experiment'] is Project.experiments['hrpt'] + assert captured['expt_name'] == 'hrpt' + assert captured['style'] == 'band' + assert captured['show_residual'] is None + + +def test_plot_posterior_predictive_summary_routes_ascii_to_measured_and_map(monkeypatch): + from types import SimpleNamespace + + from easydiffraction.display.plotting import Plotter + + captured: dict[str, object] = {} + plotter = Plotter() + plotter.engine = 'asciichartpy' + plotter._backend = SimpleNamespace( + plot_powder=lambda **kwargs: captured.setdefault('powder', kwargs) + ) + + plotter._plot_posterior_predictive_summary( + expt_name='pdf', + summary=SimpleNamespace( + x=np.array([1.0, 2.0, 3.0]), + map_prediction=np.array([9.0, 10.0, 11.0]), + lower_95=np.array([8.0, 9.0, 10.0]), + upper_95=np.array([10.0, 11.0, 12.0]), + draws=np.array([[8.5, 9.5, 10.5]]), + ), + y_meas=np.array([9.5, 10.5, 11.5]), + axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + show_band=True, + show_draws=True, + excluded_ranges=((1.2, 1.4),), + ) + + assert captured['powder']['labels'] == ['meas', 'posterior'] + np.testing.assert_allclose(captured['powder']['x'], np.array([1.0, 2.0, 3.0])) + np.testing.assert_allclose( + captured['powder']['y_series'][0], + np.array([9.5, 10.5, 11.5]), + ) + np.testing.assert_allclose( + captured['powder']['y_series'][1], + np.array([9.0, 10.0, 11.0]), + ) + assert captured['powder']['excluded_ranges'] == ((1.2, 1.4),) + + +def test_plot_posterior_predictive_data_routes_ascii_to_line_plot_without_intervals(monkeypatch): + from types import SimpleNamespace + + from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum + from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum + from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum + from easydiffraction.display.plotting import Plotter + from easydiffraction.display.plotting import XAxisType + + captured: dict[str, object] = {} + + class ExptType: + sample_form = type('SF', (), {'value': SampleFormEnum.POWDER})() + scattering_type = type('S', (), {'value': ScatteringTypeEnum.BRAGG})() + beam_mode = type('B', (), {'value': BeamModeEnum.CONSTANT_WAVELENGTH})() + + class Pattern: + two_theta = np.array([1.0, 2.0, 3.0]) + intensity_meas = np.array([10.0, 12.0, 11.0]) + intensity_bkg = np.array([1.0, 1.0, 1.0]) + + class Experiment: + type = ExptType() + data = Pattern() + + plotter = Plotter() + plotter.engine = 'asciichartpy' + plotter._backend = SimpleNamespace( + plot_powder=lambda **kwargs: captured.setdefault('powder', kwargs), + plot_powder_meas_vs_calc=lambda **kwargs: captured.setdefault('composite', kwargs), + ) + + monkeypatch.setattr( + Plotter, + '_get_or_build_posterior_predictive_summary', + lambda self, **kwargs: SimpleNamespace( + x=np.array([1.0, 2.0, 3.0]), + lower_95=np.array([8.0, 9.0, 10.0]), + upper_95=np.array([10.0, 11.0, 12.0]), + map_prediction=np.array([9.0, 11.0, 10.5]), + draws=None, + ), + ) + + plotter._plot_posterior_predictive_data( + experiment=Experiment(), + expt_name='hrpt', + plot_options=SimpleNamespace( + x_min=None, + x_max=None, + show_residual=None, + show_background=None, + show_bragg=None, + show_excluded=False, + x=None, + ), + x_axis=XAxisType.TWO_THETA, + style='band+draws', + ) + + assert 'composite' not in captured + assert captured['powder']['labels'] == ['meas', 'posterior'] + np.testing.assert_allclose(captured['powder']['x'], np.array([1.0, 2.0, 3.0])) + np.testing.assert_allclose( + captured['powder']['y_series'][0], + np.array([10.0, 12.0, 11.0]), + ) + np.testing.assert_allclose( + captured['powder']['y_series'][1], + np.array([9.0, 11.0, 10.5]), + ) + + def test_plot_meas_vs_calc_request_respects_background_and_bragg_flags(): from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum From 3463bdbf47903792ebe5efe45963f3c5ef7668ce Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 17:26:30 +0200 Subject: [PATCH 04/17] Add ASCII posterior density plots --- src/easydiffraction/display/plotters/ascii.py | 1 + src/easydiffraction/display/plotters/base.py | 4 ++ src/easydiffraction/display/plotting.py | 50 ++++++++++++++++++- src/easydiffraction/project/display.py | 8 --- .../easydiffraction/display/test_plotting.py | 50 +++++++++++++++++++ .../easydiffraction/project/test_display.py | 15 ++---- 6 files changed, 107 insertions(+), 21 deletions(-) diff --git a/src/easydiffraction/display/plotters/ascii.py b/src/easydiffraction/display/plotters/ascii.py index 534a4f1b..a8d000d1 100644 --- a/src/easydiffraction/display/plotters/ascii.py +++ b/src/easydiffraction/display/plotters/ascii.py @@ -23,6 +23,7 @@ 'meas': asciichartpy.blue, 'calc': asciichartpy.red, 'posterior': asciichartpy.red, + 'density': asciichartpy.green, 'resid': asciichartpy.green, } diff --git a/src/easydiffraction/display/plotters/base.py b/src/easydiffraction/display/plotters/base.py index ed6df748..6910321b 100644 --- a/src/easydiffraction/display/plotters/base.py +++ b/src/easydiffraction/display/plotters/base.py @@ -195,6 +195,10 @@ class XAxisType(StrEnum): 'mode': 'lines', 'name': 'Max posterior', }, + 'density': { + 'mode': 'lines', + 'name': 'Marginal density', + }, 'bkg': { 'mode': 'lines', 'name': 'Background (Ibkg)', diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index 82883c41..f050199e 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -999,6 +999,9 @@ def plot_posterior_pairs( ``parameters`` is omitted and ``threshold`` is ``None``. Must be at least ``2``. """ + if self.engine != PlotterEngineEnum.PLOTLY.value: + console.paragraph(self._posterior_pair_title(None)) + plot = self._build_posterior_pairs_plot( parameters=parameters, style=style, @@ -1023,6 +1026,10 @@ def plot_param_distribution( posterior to plot. Strings may be unique names or user-facing labels. """ + if self.engine == PlotterEngineEnum.ASCII.value: + self._plot_ascii_param_distribution(param) + return + plot = self._build_param_distribution_plot(param) if plot is None: return @@ -2467,13 +2474,52 @@ def _build_param_distribution_plot( ) return fig + def _plot_ascii_param_distribution( + self, + param: object, + ) -> None: + """Render one posterior marginal on the ASCII backend.""" + context = self._posterior_distribution_context(param) + if context is None: + return + + lower_bound, upper_bound = self._posterior_parameter_bounds( + fit_results=context.fit_results, + parameter_name=context.parameter_name, + ) + density_curve = self._posterior_density_curve( + context.values, + lower_bound=lower_bound, + upper_bound=upper_bound, + ) + if density_curve is None: + log.warning( + f'Posterior distribution is unavailable for parameter {context.parameter_name}.' + ) + return + + grid, density = density_curve + self._backend.plot_powder( + x=grid, + y_series=[density], + labels=['density'], + axes_labels=[context.label, 'Probability density'], + title=context.title, + height=self.height, + ) + def _posterior_distribution_context( self, param: object, ) -> _PosteriorDistributionContext | None: """Return the context for a posterior distribution plot.""" - posterior_samples, fit_results = self._get_posterior_samples_and_fit_results() - if posterior_samples is None or fit_results is None: + fit_results = self._get_fit_result_for_correlation() + if fit_results is None: + return None + + posterior_samples = getattr(fit_results, 'posterior_samples', None) + if posterior_samples is None: + log.warning('Posterior samples are unavailable. Run a Bayesian fit first.') return None parameter_names = self._resolve_posterior_parameter_names( diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py index f236717b..bac53a93 100644 --- a/src/easydiffraction/project/display.py +++ b/src/easydiffraction/project/display.py @@ -155,14 +155,6 @@ def distribution(self, param: object | None = None) -> None: log.warning('No free parameters found.') return - if plotter.engine == PlotterEngineEnum.ASCII.value: - log.warning( - 'Posterior distribution plots require an explicit parameter ' - 'with the ASCII backend. Iterate over project.free_parameters ' - 'to render them one by one.' - ) - return - for free_parameter in free_parameters: plotter.plot_param_distribution(free_parameter) diff --git a/tests/unit/easydiffraction/display/test_plotting.py b/tests/unit/easydiffraction/display/test_plotting.py index 9793f97b..b1979fa1 100644 --- a/tests/unit/easydiffraction/display/test_plotting.py +++ b/tests/unit/easydiffraction/display/test_plotting.py @@ -715,6 +715,36 @@ def test_build_param_distribution_plot_returns_plotly_figure(): assert figure.layout.yaxis.range is not None +def test_plot_param_distribution_routes_ascii_to_marginal_density(monkeypatch): + from types import SimpleNamespace + + from easydiffraction.display.plotting import Plotter + + plotter, fit_results, posterior_samples = _make_bayesian_plotter_fixture() + captured: dict[str, object] = {} + plotter.engine = 'asciichartpy' + plotter._backend = SimpleNamespace( + plot_powder=lambda **kwargs: captured.setdefault('powder', kwargs) + ) + + plotter.plot_param_distribution(fit_results.parameters[0]) + + values = posterior_samples.flattened()[:, 0] + density_curve = plotter._posterior_density_curve( + values, + lower_bound=fit_results.parameters[0].fit_min, + upper_bound=fit_results.parameters[0].fit_max, + ) + + assert density_curve is not None + assert captured['powder']['labels'] == ['density'] + assert captured['powder']['axes_labels'] == ['length_a', 'Probability density'] + assert captured['powder']['title'] == 'Posterior distribution: length_a' + assert captured['powder']['height'] == plotter.height + np.testing.assert_allclose(captured['powder']['x'], density_curve[0]) + np.testing.assert_allclose(captured['powder']['y_series'][0], density_curve[1]) + + def test_plot_posterior_predictive_summary_uses_consistent_labels_and_styles(monkeypatch): from types import SimpleNamespace @@ -2183,6 +2213,26 @@ def fake_build(self, *, parameters, style, threshold, max_parameters): assert captured['max_parameters'] == DEFAULT_CORRELATION_MAX_PARAMETERS +def test_plot_posterior_pairs_prints_title_before_ascii_backend_warning(monkeypatch): + import easydiffraction.display.plotting as plotting_mod + + from easydiffraction.display.plotting import Plotter + + events: list[tuple[str, str]] = [] + plotter = Plotter() + plotter.engine = 'asciichartpy' + + monkeypatch.setattr(plotting_mod.console, 'paragraph', lambda text: events.append(('title', text))) + monkeypatch.setattr(plotting_mod.log, 'warning', lambda message: events.append(('warning', message))) + + plotter.plot_posterior_pairs() + + assert events == [ + ('title', 'Posterior pair plot'), + ('warning', 'Posterior plots currently require the Plotly plotting backend.'), + ] + + def test_plot_param_correlations_shows_full_table_when_threshold_is_zero(monkeypatch): from easydiffraction.display.plotting import Plotter from easydiffraction.display.tables import TableRenderer diff --git a/tests/unit/easydiffraction/project/test_display.py b/tests/unit/easydiffraction/project/test_display.py index c0787691..b56f8fce 100644 --- a/tests/unit/easydiffraction/project/test_display.py +++ b/tests/unit/easydiffraction/project/test_display.py @@ -274,24 +274,17 @@ def test_posterior_distribution_without_param_plots_all_free_parameters(): ] -def test_posterior_distribution_without_param_warns_once_for_ascii(monkeypatch): - import easydiffraction.project.display as display_mod - +def test_posterior_distribution_without_param_plots_all_free_parameters_for_ascii(): project, calls = _make_project_stub() project.free_parameters = ['a', 'b'] project.rendering.plotter.engine = 'asciichartpy' display = ProjectDisplay(project) - warnings: list[str] = [] - - monkeypatch.setattr(display_mod.log, 'warning', warnings.append) display.posterior.distribution() - assert calls == [] - assert warnings == [ - 'Posterior distribution plots require an explicit parameter ' - 'with the ASCII backend. Iterate over project.free_parameters ' - 'to render them one by one.' + assert calls == [ + ('plot_param_distribution', ('a',), {}), + ('plot_param_distribution', ('b',), {}), ] From 4b58e5d76ae56d9fc374a16148dc313b5c61672f Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 18:13:09 +0200 Subject: [PATCH 05/17] Update console heading color to deep_sky_blue3 --- src/easydiffraction/utils/logging.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/easydiffraction/utils/logging.py b/src/easydiffraction/utils/logging.py index a94aadde..3c4da8ee 100644 --- a/src/easydiffraction/utils/logging.py +++ b/src/easydiffraction/utils/logging.py @@ -682,7 +682,7 @@ def paragraph(cls, title: str) -> None: ---------- title : str Heading text; substrings enclosed in single quotes are - rendered without the bold-blue style. + rendered without the bold deep_sky_blue3 style. """ parts = re.split(r"('.*?')", title) text = Text() @@ -690,7 +690,7 @@ def paragraph(cls, title: str) -> None: if part.startswith("'") and part.endswith("'"): text.append(part) else: - text.append(part, style='bold blue') + text.append(part, style='bold deep_sky_blue3') formatted = f'{text.markup}' if not in_jupyter(): formatted = f'\n{formatted}' From 33e4d1ca8371c1b6054c8c829f9f8db38add6ecd Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 18:13:31 +0200 Subject: [PATCH 06/17] Guard DREAM multiprocessing for direct script entry points --- .../analysis/minimizers/bumps_dream.py | 59 ++++++- .../fitting/test_bumps_dream_support.py | 154 +++++++++++++++++- 2 files changed, 208 insertions(+), 5 deletions(-) diff --git a/src/easydiffraction/analysis/minimizers/bumps_dream.py b/src/easydiffraction/analysis/minimizers/bumps_dream.py index c9af1012..3b2e4d9a 100644 --- a/src/easydiffraction/analysis/minimizers/bumps_dream.py +++ b/src/easydiffraction/analysis/minimizers/bumps_dream.py @@ -4,7 +4,9 @@ from __future__ import annotations +import multiprocessing import random +import sys from dataclasses import dataclass import numpy as np @@ -726,14 +728,63 @@ def _build_mapper(self, problem: FitProblem) -> object | None: if self.parallel == 1: return None - if not can_pickle(problem): + if self._requires_serial_mapper_for_spawn_main_module(): log.warning( - 'DREAM parallel evaluation requires a picklable ' - 'problem; falling back to serial execution.' + 'DREAM parallel evaluation requires an import-safe main ' + 'module on spawn-based multiprocessing; falling back to ' + 'serial execution.' ) return None - return MPMapper.start_mapper(problem, [], cpus=self.parallel) + shared_display_handle = getattr(self.tracker, '_shared_display_handle', None) + activity_indicator = getattr(self.tracker, '_activity_indicator', None) + if shared_display_handle is not None: + self.tracker._set_shared_display_handle(None) + if activity_indicator is not None: + self.tracker._activity_indicator = None + + try: + if not can_pickle(problem): + log.warning( + 'DREAM parallel evaluation requires a picklable ' + 'problem; falling back to serial execution.' + ) + return None + + return MPMapper.start_mapper(problem, [], cpus=self.parallel) + except RuntimeError as error: + message = str(error) + if 'bootstrapping phase' not in message: + raise + log.warning( + 'DREAM parallel evaluation requires an import-safe main ' + 'module on spawn-based multiprocessing; falling back to ' + 'serial execution.' + ) + return None + finally: + if activity_indicator is not None: + self.tracker._activity_indicator = activity_indicator + if shared_display_handle is not None: + self.tracker._set_shared_display_handle(shared_display_handle) + + @staticmethod + def _requires_serial_mapper_for_spawn_main_module() -> bool: + """Return whether direct-script spawn startup should stay serial.""" + start_method = multiprocessing.get_start_method(allow_none=True) + if start_method is None: + start_method = multiprocessing.get_start_method() + if start_method != 'spawn': + return False + + main_module = sys.modules.get('__main__') + if main_module is None: + return False + + return ( + getattr(main_module, '__file__', None) is not None + and getattr(main_module, '__spec__', None) is None + ) @staticmethod def _execute_driver(*, driver: FitDriver, random_seed: int) -> _DreamDriverResult: diff --git a/tests/integration/fitting/test_bumps_dream_support.py b/tests/integration/fitting/test_bumps_dream_support.py index bf87549a..bbc73e0d 100644 --- a/tests/integration/fitting/test_bumps_dream_support.py +++ b/tests/integration/fitting/test_bumps_dream_support.py @@ -3,6 +3,7 @@ from __future__ import annotations +import threading from types import SimpleNamespace from unittest.mock import MagicMock from unittest.mock import patch @@ -33,6 +34,20 @@ def _set_value_from_minimizer(self, value: float) -> None: self.value = value +def _simulate_import_safe_spawn_main_module(monkeypatch) -> None: + import sys + + monkeypatch.setattr( + 'easydiffraction.analysis.minimizers.bumps_dream.multiprocessing.get_start_method', + lambda allow_none=True: 'spawn', + ) + monkeypatch.setitem( + sys.modules, + '__main__', + SimpleNamespace(__file__='pytest_runner.py', __spec__=object()), + ) + + def test_type_info_and_default_init(): from easydiffraction.analysis.minimizers.bumps_dream import BumpsDreamMinimizer from easydiffraction.analysis.minimizers.enums import DreamPopulationInitializationEnum @@ -258,6 +273,7 @@ def test_build_mapper_falls_back_for_serial_and_unpicklable(monkeypatch): warnings: list[str] = [] minimizer.parallel = 0 + _simulate_import_safe_spawn_main_module(monkeypatch) monkeypatch.setattr( 'easydiffraction.analysis.minimizers.bumps_dream.can_pickle', lambda problem: False ) @@ -267,7 +283,143 @@ def test_build_mapper_falls_back_for_serial_and_unpicklable(monkeypatch): ) assert minimizer._build_mapper('problem') is None - assert any('falling back to serial execution' in message for message in warnings) + assert warnings == [ + 'DREAM parallel evaluation requires a picklable ' + 'problem; falling back to serial execution.' + ] + + +def test_build_mapper_temporarily_clears_shared_display_handle(monkeypatch): + from easydiffraction.analysis.minimizers.bumps_dream import BumpsDreamMinimizer + + minimizer = BumpsDreamMinimizer() + minimizer.parallel = 0 + _simulate_import_safe_spawn_main_module(monkeypatch) + handle = object() + activity_indicator = object() + observed_tracker_state: list[tuple[object | None, object | None]] = [] + + minimizer.tracker._set_shared_display_handle(handle) + minimizer.tracker._activity_indicator = activity_indicator + monkeypatch.setattr( + 'easydiffraction.analysis.minimizers.bumps_dream.can_pickle', + lambda problem: observed_tracker_state.append( + ( + minimizer.tracker._shared_display_handle, + minimizer.tracker._activity_indicator, + ) + ) + or True, + ) + monkeypatch.setattr( + 'easydiffraction.analysis.minimizers.bumps_dream.MPMapper.start_mapper', + lambda problem, args, cpus: ('mapper', cpus), + ) + + mapper = minimizer._build_mapper('problem') + + assert mapper == ('mapper', 0) + assert observed_tracker_state == [(None, None)] + assert minimizer.tracker._shared_display_handle is handle + assert minimizer.tracker._activity_indicator is activity_indicator + + +def test_build_mapper_allows_real_can_pickle_with_live_tracker_state(monkeypatch): + from bumps.fitproblem import FitProblem + from bumps.parameter import Parameter as BumpsParameter + + from easydiffraction.analysis.minimizers.bumps import _EasyDiffractionFitness + from easydiffraction.analysis.minimizers.bumps_dream import BumpsDreamMinimizer + + minimizer = BumpsDreamMinimizer() + minimizer.parallel = 0 + _simulate_import_safe_spawn_main_module(monkeypatch) + bumps_params = [BumpsParameter(value=1.0, name='alpha')] + + def objective(values): + _ = minimizer.tracker._activity_indicator + return np.array([float(values[0])], dtype=float) + + problem = FitProblem(_EasyDiffractionFitness(bumps_params, objective)) + minimizer.tracker._set_shared_display_handle(threading.RLock()) + minimizer.tracker._activity_indicator = SimpleNamespace(lock=threading.RLock()) + monkeypatch.setattr( + 'easydiffraction.analysis.minimizers.bumps_dream.MPMapper.start_mapper', + lambda problem, args, cpus: ('mapper', cpus), + ) + + assert minimizer._build_mapper(problem) == ('mapper', 0) + + +def test_build_mapper_falls_back_for_spawn_bootstrap_runtime_error(monkeypatch): + from easydiffraction.analysis.minimizers.bumps_dream import BumpsDreamMinimizer + + minimizer = BumpsDreamMinimizer() + minimizer.parallel = 0 + warnings: list[str] = [] + _simulate_import_safe_spawn_main_module(monkeypatch) + + monkeypatch.setattr( + 'easydiffraction.analysis.minimizers.bumps_dream.can_pickle', lambda problem: True + ) + monkeypatch.setattr( + 'easydiffraction.analysis.minimizers.bumps_dream.MPMapper.start_mapper', + lambda problem, args, cpus: (_ for _ in ()).throw( + RuntimeError('current process has finished its bootstrapping phase') + ), + ) + monkeypatch.setattr( + 'easydiffraction.analysis.minimizers.bumps_dream.log.warning', + lambda message: warnings.append(message), + ) + + assert minimizer._build_mapper('problem') is None + assert warnings == [ + 'DREAM parallel evaluation requires an import-safe main ' + 'module on spawn-based multiprocessing; falling back to ' + 'serial execution.' + ] + + +def test_build_mapper_falls_back_before_starting_spawn_for_direct_script(monkeypatch): + import sys + + from easydiffraction.analysis.minimizers.bumps_dream import BumpsDreamMinimizer + + minimizer = BumpsDreamMinimizer() + minimizer.parallel = 0 + warnings: list[str] = [] + pickle_checks: list[object] = [] + + monkeypatch.setattr( + 'easydiffraction.analysis.minimizers.bumps_dream.multiprocessing.get_start_method', + lambda allow_none=True: 'spawn', + ) + monkeypatch.setitem( + sys.modules, + '__main__', + SimpleNamespace(__file__='docs/docs/tutorials/ed-21.py', __spec__=None), + ) + monkeypatch.setattr( + 'easydiffraction.analysis.minimizers.bumps_dream.can_pickle', + lambda problem: pickle_checks.append(problem) or True, + ) + monkeypatch.setattr( + 'easydiffraction.analysis.minimizers.bumps_dream.MPMapper.start_mapper', + lambda problem, args, cpus: (_ for _ in ()).throw(AssertionError('unexpected mapper')), + ) + monkeypatch.setattr( + 'easydiffraction.analysis.minimizers.bumps_dream.log.warning', + lambda message: warnings.append(message), + ) + + assert minimizer._build_mapper('problem') is None + assert pickle_checks == [] + assert warnings == [ + 'DREAM parallel evaluation requires an import-safe main ' + 'module on spawn-based multiprocessing; falling back to ' + 'serial execution.' + ] def test_run_solver_preserves_parameter_order_and_forwards_init(): From a6947f481fd12ad21d8484d2a8735253c30434ac Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 18:27:21 +0200 Subject: [PATCH 07/17] Remove unused colorama and tabulate dependencies --- pixi.lock | 2 -- pyproject.toml | 2 -- 2 files changed, 4 deletions(-) diff --git a/pixi.lock b/pixi.lock index f1532e2b..4d6614f7 100644 --- a/pixi.lock +++ b/pixi.lock @@ -8145,7 +8145,6 @@ packages: - asciichartpy - asteval - bumps - - colorama - crysfml - cryspy - darkdetect @@ -8162,7 +8161,6 @@ packages: - rich - scipy - sympy - - tabulate - typeguard - typer - uncertainties diff --git a/pyproject.toml b/pyproject.toml index f81f2303..71bf2f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,8 +24,6 @@ classifiers = [ requires-python = '>=3.12' dependencies = [ 'numpy', # Numerical computing library - 'colorama', # Color terminal output - 'tabulate', # Pretty-print tabular data for terminal output 'asciichartpy', # ASCII charts for terminal output 'pooch', # Data downloader 'typer', # Command-line interface creation From 0d5969a067292309dc6294414c317e05e7f6913c Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 18:27:52 +0200 Subject: [PATCH 08/17] Normalize docstring formatting --- src/easydiffraction/analysis/minimizers/bumps_dream.py | 4 +++- src/easydiffraction/display/plotting.py | 4 +++- src/easydiffraction/project/display.py | 4 +++- .../easydiffraction/analysis/fit_helpers/test_reporting.py | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/easydiffraction/analysis/minimizers/bumps_dream.py b/src/easydiffraction/analysis/minimizers/bumps_dream.py index 3b2e4d9a..256ff9a5 100644 --- a/src/easydiffraction/analysis/minimizers/bumps_dream.py +++ b/src/easydiffraction/analysis/minimizers/bumps_dream.py @@ -770,7 +770,9 @@ def _build_mapper(self, problem: FitProblem) -> object | None: @staticmethod def _requires_serial_mapper_for_spawn_main_module() -> bool: - """Return whether direct-script spawn startup should stay serial.""" + """ + Return whether direct-script spawn startup should stay serial. + """ start_method = multiprocessing.get_start_method(allow_none=True) if start_method is None: start_method = multiprocessing.get_start_method() diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index f050199e..a03afde7 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -3502,7 +3502,9 @@ def _plot_posterior_predictive_summary( show_draws: bool, excluded_ranges: tuple[tuple[float, float], ...] = (), ) -> None: - """Render posterior predictive summaries using the active backend.""" + """ + Render posterior predictive summaries using the active backend. + """ if self.engine == PlotterEngineEnum.ASCII.value: self._plot_ascii_posterior_predictive_lines( expt_name=expt_name, diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py index bac53a93..c248c9ad 100644 --- a/src/easydiffraction/project/display.py +++ b/src/easydiffraction/project/display.py @@ -144,7 +144,9 @@ def pairs( ) def distribution(self, param: object | None = None) -> None: - """Plot posterior distributions for one or all free parameters.""" + """ + Plot posterior distributions for one or all free parameters. + """ plotter = self._project.rendering.plotter if param is not None: plotter.plot_param_distribution(param) diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py index bb824f82..9758f70f 100644 --- a/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py +++ b/tests/unit/easydiffraction/analysis/fit_helpers/test_reporting.py @@ -57,7 +57,7 @@ def __init__(self, start, value, uncertainty, name='p', units='u'): assert 'Weighted R-factor (wR)' in out assert 'Bragg R-factor (BR)' in out assert 'Fitted parameters:' in out - # Table border: accept common border glyphs from Rich/tabulate + # Table border: accept common border glyphs from Rich assert any(ch in out for ch in ('╒', '┌', '+', '─')) From 161dcd557083f01a71a00cc302d2d9a7ac42424f Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 18:34:47 +0200 Subject: [PATCH 09/17] Add DREAM multiprocessing CLI workflow issue --- docs/dev/Issues/issues_open.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/dev/Issues/issues_open.md b/docs/dev/Issues/issues_open.md index bb41887f..fab0ce5d 100644 --- a/docs/dev/Issues/issues_open.md +++ b/docs/dev/Issues/issues_open.md @@ -1424,6 +1424,31 @@ threaded because each step's output is the next step's input. --- +## 95. 🟡 Re-Enable DREAM Multiprocessing in CLI Workflows + +**Type:** Performance / CLI robustness + +On macOS and other spawn-based platforms, running Bayesian scripts via +direct CLI entry points such as `python script.py` can fail during BUMPS +`MPMapper` startup because worker processes re-import `__main__` and +re-execute top-level tutorial code. The current defensive workaround is +to fall back to serial execution for these direct-script entry points, +which avoids the crash but disables DREAM multiprocessing in terminal +workflows. + +**Possible solution:** keep the existing tracker-state cleanup before +pickling and mapper startup, but replace the blanket serial fallback +with an EasyDiffraction-controlled multiprocessing context policy. For +direct CLI script entry points, prefer a `fork` context when available +so workers do not re-import the tutorial top level. Keep the existing +behavior for import-safe module entry points and for platforms where +`fork` is unavailable. Document the tradeoff clearly because `fork` on +macOS is less conservative than `spawn`. + +**Depends on:** related to issue 89, but independent. + +--- + ## 90. 🟢 Show Experiment Number/Total During Sequential Fitting **Type:** UX @@ -1546,6 +1571,7 @@ operation is possible (e.g. in automated pipelines or tests). | 87 | Redesign tutorial grouping/categorisation | 🟢 Low | Documentation | | 88 | Fix Dataset 26 description (47 not 57) | 🟢 Low | Data | | 89 | Parallel independent fits for single mode | 🟡 Med | Performance | +| 95 | Re-enable DREAM multiprocessing in CLI workflows | 🟡 Med | Performance / CLI robustness | | 90 | Show experiment number during sequential fitting | 🟢 Low | UX | | 91 | Disable TODO checks in CodeFactor PRs | 🟢 Low | CI / Tooling | | 92 | Make `save()` respect verbosity | 🟢 Low | UX | From abd284dc0e543d142de6c3405ec87435da11e715 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 18:37:04 +0200 Subject: [PATCH 10/17] Resample ASCII plots to terminal width --- src/easydiffraction/display/plotters/ascii.py | 47 +++++++++++++- .../fitting/test_bumps_dream_support.py | 13 ++-- .../display/plotters/test_ascii.py | 64 +++++++++++++++++++ .../easydiffraction/display/test_plotting.py | 10 +-- 4 files changed, 121 insertions(+), 13 deletions(-) diff --git a/src/easydiffraction/display/plotters/ascii.py b/src/easydiffraction/display/plotters/ascii.py index a8d000d1..947bb14a 100644 --- a/src/easydiffraction/display/plotters/ascii.py +++ b/src/easydiffraction/display/plotters/ascii.py @@ -10,6 +10,8 @@ from __future__ import annotations +import shutil + import asciichartpy import numpy as np @@ -26,11 +28,48 @@ 'density': asciichartpy.green, 'resid': asciichartpy.green, } +ASCII_CHART_OFFSET = 3 +ASCII_CHART_LEFT_PADDING = 15 +ASCII_CHART_FALLBACK_POINT_COUNT = 80 class AsciiPlotter(PlotterBase): """Terminal-based plotter using ASCII art.""" + @staticmethod + def _chart_point_count() -> int: + """Return the number of points that fit the current terminal.""" + fallback_columns = ( + ASCII_CHART_FALLBACK_POINT_COUNT + + ASCII_CHART_OFFSET + + ASCII_CHART_LEFT_PADDING + ) + columns = shutil.get_terminal_size( + fallback=(fallback_columns, DEFAULT_HEIGHT) + ).columns + return max(2, columns - ASCII_CHART_OFFSET - ASCII_CHART_LEFT_PADDING) + + @classmethod + def _resample_series_for_chart( + cls, + y_series: object, + ) -> list[list[float]]: + """Return y-series resampled to the available chart width.""" + target_point_count = cls._chart_point_count() + resampled_series: list[list[float]] = [] + for series in y_series: + series_array = np.ravel(np.asarray(series, dtype=float)) + if series_array.size <= target_point_count or series_array.size < 2: + resampled_series.append(series_array.tolist()) + continue + + source_positions = np.linspace(0.0, 1.0, series_array.size) + target_positions = np.linspace(0.0, 1.0, target_point_count) + resampled_series.append( + np.interp(target_positions, source_positions, series_array).tolist() + ) + return resampled_series + @staticmethod def _get_legend_item(label: str) -> str: """ @@ -96,8 +135,12 @@ def plot_powder( if height is None: height = DEFAULT_HEIGHT colors = [DEFAULT_COLORS[label] for label in labels] - config = {'height': height, 'colors': colors} - y_series = [y.tolist() for y in y_series] + config = { + 'height': height, + 'colors': colors, + 'offset': ASCII_CHART_OFFSET, + } + y_series = self._resample_series_for_chart(y_series) chart = asciichartpy.plot(y_series, config) diff --git a/tests/integration/fitting/test_bumps_dream_support.py b/tests/integration/fitting/test_bumps_dream_support.py index bbc73e0d..5a2b44e8 100644 --- a/tests/integration/fitting/test_bumps_dream_support.py +++ b/tests/integration/fitting/test_bumps_dream_support.py @@ -284,8 +284,7 @@ def test_build_mapper_falls_back_for_serial_and_unpicklable(monkeypatch): assert minimizer._build_mapper('problem') is None assert warnings == [ - 'DREAM parallel evaluation requires a picklable ' - 'problem; falling back to serial execution.' + 'DREAM parallel evaluation requires a picklable problem; falling back to serial execution.' ] @@ -303,13 +302,13 @@ def test_build_mapper_temporarily_clears_shared_display_handle(monkeypatch): minimizer.tracker._activity_indicator = activity_indicator monkeypatch.setattr( 'easydiffraction.analysis.minimizers.bumps_dream.can_pickle', - lambda problem: observed_tracker_state.append( - ( + lambda problem: ( + observed_tracker_state.append(( minimizer.tracker._shared_display_handle, minimizer.tracker._activity_indicator, - ) - ) - or True, + )) + or True + ), ) monkeypatch.setattr( 'easydiffraction.analysis.minimizers.bumps_dream.MPMapper.start_mapper', diff --git a/tests/unit/easydiffraction/display/plotters/test_ascii.py b/tests/unit/easydiffraction/display/plotters/test_ascii.py index 84f90bac..728971f0 100644 --- a/tests/unit/easydiffraction/display/plotters/test_ascii.py +++ b/tests/unit/easydiffraction/display/plotters/test_ascii.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import os + import numpy as np @@ -107,3 +109,65 @@ def test_ascii_plotter_plot_powder_meas_vs_calc_announces_plotly_only_bragg_row( assert 'Legend:' in out assert 'Residual (Imeas - Icalc)' in out assert 'Bragg peak subplot rows are available with the Plotly engine only.' in out + + +def test_ascii_plotter_plot_resamples_to_detected_terminal_width(monkeypatch): + from easydiffraction.display.plotters import ascii as ascii_mod + from easydiffraction.display.plotters.ascii import AsciiPlotter + + captured: dict[str, object] = {} + + def fake_plot(series, config): + captured['call'] = (series, config) + return 'chart' + + monkeypatch.setattr( + ascii_mod.shutil, + 'get_terminal_size', + lambda fallback: os.terminal_size((44, 24)), + ) + monkeypatch.setattr(ascii_mod.asciichartpy, 'plot', fake_plot) + + AsciiPlotter().plot_powder( + x=np.arange(256, dtype=float), + y_series=[np.linspace(0.0, 1.0, 256)], + labels=['density'], + axes_labels=['x', 'y'], + title='Width test', + height=5, + ) + + series, config = captured['call'] + assert len(series[0]) == 40 + assert config['offset'] == 3 + + +def test_ascii_plotter_plot_uses_fallback_width_when_terminal_size_unavailable(monkeypatch): + from easydiffraction.display.plotters import ascii as ascii_mod + from easydiffraction.display.plotters.ascii import AsciiPlotter + + captured: dict[str, object] = {} + + def fake_plot(series, config): + captured['call'] = (series, config) + return 'chart' + + monkeypatch.setattr( + ascii_mod.shutil, + 'get_terminal_size', + lambda fallback: os.terminal_size(fallback), + ) + monkeypatch.setattr(ascii_mod.asciichartpy, 'plot', fake_plot) + + AsciiPlotter().plot_powder( + x=np.arange(256, dtype=float), + y_series=[np.linspace(0.0, 1.0, 256)], + labels=['density'], + axes_labels=['x', 'y'], + title='Fallback width test', + height=5, + ) + + series, config = captured['call'] + assert len(series[0]) == 80 + assert config['offset'] == 3 diff --git a/tests/unit/easydiffraction/display/test_plotting.py b/tests/unit/easydiffraction/display/test_plotting.py index b1979fa1..c3242404 100644 --- a/tests/unit/easydiffraction/display/test_plotting.py +++ b/tests/unit/easydiffraction/display/test_plotting.py @@ -718,8 +718,6 @@ def test_build_param_distribution_plot_returns_plotly_figure(): def test_plot_param_distribution_routes_ascii_to_marginal_density(monkeypatch): from types import SimpleNamespace - from easydiffraction.display.plotting import Plotter - plotter, fit_results, posterior_samples = _make_bayesian_plotter_fixture() captured: dict[str, object] = {} plotter.engine = 'asciichartpy' @@ -2222,8 +2220,12 @@ def test_plot_posterior_pairs_prints_title_before_ascii_backend_warning(monkeypa plotter = Plotter() plotter.engine = 'asciichartpy' - monkeypatch.setattr(plotting_mod.console, 'paragraph', lambda text: events.append(('title', text))) - monkeypatch.setattr(plotting_mod.log, 'warning', lambda message: events.append(('warning', message))) + monkeypatch.setattr( + plotting_mod.console, 'paragraph', lambda text: events.append(('title', text)) + ) + monkeypatch.setattr( + plotting_mod.log, 'warning', lambda message: events.append(('warning', message)) + ) plotter.plot_posterior_pairs() From f12784f72e146c554442f2ddb5d6b93c3133df3e Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 18:37:29 +0200 Subject: [PATCH 11/17] Simplify posterior distribution display in tutorials --- docs/docs/tutorials/ed-21.py | 3 +-- docs/docs/tutorials/ed-22.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/docs/tutorials/ed-21.py b/docs/docs/tutorials/ed-21.py index 7c6f9bdb..8129f6e3 100644 --- a/docs/docs/tutorials/ed-21.py +++ b/docs/docs/tutorials/ed-21.py @@ -329,8 +329,7 @@ # multimodality. # %% -for param in project.free_parameters: - project.display.posterior.distribution(param) +project.display.posterior.distribution() # %% [markdown] # Finally, the posterior predictive plot propagates the sampled parameter diff --git a/docs/docs/tutorials/ed-22.py b/docs/docs/tutorials/ed-22.py index bf0f2e19..da04c4b5 100644 --- a/docs/docs/tutorials/ed-22.py +++ b/docs/docs/tutorials/ed-22.py @@ -257,8 +257,7 @@ # multimodality. # %% -for param in project.free_parameters: - project.display.posterior.distribution(param) +project.display.posterior.distribution() # %% [markdown] # Finally, the posterior predictive plot propagates the sampled From 46fa77773abbe4e33f97c7d2dcfb247c23568762 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 18:42:09 +0200 Subject: [PATCH 12/17] Fit ASCII plot range to terminal width --- src/easydiffraction/display/plotting.py | 9 +++++---- .../easydiffraction/display/test_plotting_coverage.py | 8 +++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index a03afde7..d79cc2aa 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -331,10 +331,11 @@ def _auto_x_range_for_ascii( Tuple of ``(x_min, x_max)``, possibly narrowed. """ if self._engine == 'asciichartpy' and (x_min is None or x_max is None): - max_intensity_pos = np.argmax(pattern.intensity_meas) - half_range = 50 - start = max(0, max_intensity_pos - half_range) - end = min(len(x_array) - 1, max_intensity_pos + half_range) + max_intensity_pos = int(np.argmax(pattern.intensity_meas)) + target_point_count = min(len(x_array), AsciiPlotter._chart_point_count()) + start = max(0, max_intensity_pos - target_point_count // 2) + end = min(len(x_array) - 1, start + target_point_count - 1) + start = max(0, end - target_point_count + 1) x_min = x_array[start] x_max = x_array[end] return x_min, x_max diff --git a/tests/unit/easydiffraction/display/test_plotting_coverage.py b/tests/unit/easydiffraction/display/test_plotting_coverage.py index 806e68da..542dfa4f 100644 --- a/tests/unit/easydiffraction/display/test_plotting_coverage.py +++ b/tests/unit/easydiffraction/display/test_plotting_coverage.py @@ -254,11 +254,13 @@ def test_unknown_name_returns_none(self): class TestAutoXRangeForAscii: - def test_narrows_range_for_ascii(self): + def test_narrows_range_for_ascii(self, monkeypatch): + from easydiffraction.display.plotters.ascii import AsciiPlotter from easydiffraction.display.plotting import Plotter p = Plotter() p.engine = 'asciichartpy' + monkeypatch.setattr(AsciiPlotter, '_chart_point_count', lambda: 80) class Ptn: intensity_meas = np.zeros(200) @@ -266,8 +268,8 @@ class Ptn: Ptn.intensity_meas[100] = 10.0 # max at index 100 x_array = np.arange(200, dtype=float) x_min, x_max = p._auto_x_range_for_ascii(Ptn(), x_array, None, None) - assert x_min == 50.0 - assert x_max == 150.0 + assert x_min == 60.0 + assert x_max == 139.0 def test_no_narrowing_when_limits_provided(self): from easydiffraction.display.plotting import Plotter From 5cd349ce056d110a74f05b712bb136a87c985869 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 18:43:25 +0200 Subject: [PATCH 13/17] Use 'processing' label for sampler tracking --- src/easydiffraction/analysis/fit_helpers/tracking.py | 3 ++- .../integration/fitting/test_bayesian_tracker_and_base.py | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/easydiffraction/analysis/fit_helpers/tracking.py b/src/easydiffraction/analysis/fit_helpers/tracking.py index 0915ae92..0f8e4c97 100644 --- a/src/easydiffraction/analysis/fit_helpers/tracking.py +++ b/src/easydiffraction/analysis/fit_helpers/tracking.py @@ -11,6 +11,7 @@ from easydiffraction.analysis.fit_helpers.metrics import calculate_reduced_chi_square from easydiffraction.display.progress import ACTIVITY_LABEL_BURN_IN from easydiffraction.display.progress import ACTIVITY_LABEL_FITTING +from easydiffraction.display.progress import ACTIVITY_LABEL_PROCESSING from easydiffraction.display.progress import ACTIVITY_LABEL_SAMPLING from easydiffraction.display.progress import ActivityIndicator from easydiffraction.display.progress import _TerminalLiveHandle as _SharedTerminalLiveHandle @@ -595,7 +596,7 @@ def _replace_last_tracking_row(self, row: list[str]) -> None: def _default_activity_label(self) -> str: if self._tracking_mode == TRACKING_MODE_SAMPLER: - return ACTIVITY_LABEL_SAMPLING + return ACTIVITY_LABEL_PROCESSING return ACTIVITY_LABEL_FITTING @staticmethod diff --git a/tests/integration/fitting/test_bayesian_tracker_and_base.py b/tests/integration/fitting/test_bayesian_tracker_and_base.py index e6988a0f..91ec0e57 100644 --- a/tests/integration/fitting/test_bayesian_tracker_and_base.py +++ b/tests/integration/fitting/test_bayesian_tracker_and_base.py @@ -127,6 +127,7 @@ def stop(self): assert 'Bayesian sampling complete.' in out assert tracker.best_chi2 == pytest.approx(1.0) assert tracker.best_iteration == 10 + assert ('init', tracking_mod.ACTIVITY_LABEL_PROCESSING, tracker._verbosity) in events assert ('update', tracking_mod.ACTIVITY_LABEL_BURN_IN) in events assert ('update', tracking_mod.ACTIVITY_LABEL_SAMPLING) in events @@ -168,9 +169,9 @@ def stop(self): assert FitProgressTracker._rows_match_on_columns(['1', 'a'], ['1', 'b'], (0,)) is True assert events == [ - ('init', tracking_mod.ACTIVITY_LABEL_SAMPLING, VerbosityEnum.SHORT), + ('init', tracking_mod.ACTIVITY_LABEL_PROCESSING, VerbosityEnum.SHORT), ('start', None), - ('update', tracking_mod.ACTIVITY_LABEL_SAMPLING), + ('update', tracking_mod.ACTIVITY_LABEL_PROCESSING), ('stop', None), ] @@ -274,7 +275,7 @@ def test_tracker_final_rows_cover_fallbacks_and_activity_labels(): tracker._fitting_time = 1.5 assert tracker._final_fit_tracking_row() == ['8', '1.50', '', ''] tracker._tracking_mode = tracking_mod.TRACKING_MODE_SAMPLER - assert tracker._default_activity_label() == tracking_mod.ACTIVITY_LABEL_SAMPLING + assert tracker._default_activity_label() == tracking_mod.ACTIVITY_LABEL_PROCESSING tracker._tracking_mode = tracking_mod.TRACKING_MODE_FIT assert tracker._default_activity_label() == tracking_mod.ACTIVITY_LABEL_FITTING assert ( From 5f6988d266bf108a16924412114c6aa8939c51b3 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 18:46:52 +0200 Subject: [PATCH 14/17] Clean up code formatting --- src/easydiffraction/display/plotters/ascii.py | 8 ++------ .../fitting/test_bumps_dream_support.py | 16 ++++++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/easydiffraction/display/plotters/ascii.py b/src/easydiffraction/display/plotters/ascii.py index 947bb14a..b9320198 100644 --- a/src/easydiffraction/display/plotters/ascii.py +++ b/src/easydiffraction/display/plotters/ascii.py @@ -40,13 +40,9 @@ class AsciiPlotter(PlotterBase): def _chart_point_count() -> int: """Return the number of points that fit the current terminal.""" fallback_columns = ( - ASCII_CHART_FALLBACK_POINT_COUNT - + ASCII_CHART_OFFSET - + ASCII_CHART_LEFT_PADDING + ASCII_CHART_FALLBACK_POINT_COUNT + ASCII_CHART_OFFSET + ASCII_CHART_LEFT_PADDING ) - columns = shutil.get_terminal_size( - fallback=(fallback_columns, DEFAULT_HEIGHT) - ).columns + columns = shutil.get_terminal_size(fallback=(fallback_columns, DEFAULT_HEIGHT)).columns return max(2, columns - ASCII_CHART_OFFSET - ASCII_CHART_LEFT_PADDING) @classmethod diff --git a/tests/integration/fitting/test_bumps_dream_support.py b/tests/integration/fitting/test_bumps_dream_support.py index 5a2b44e8..4bcfed84 100644 --- a/tests/integration/fitting/test_bumps_dream_support.py +++ b/tests/integration/fitting/test_bumps_dream_support.py @@ -374,9 +374,11 @@ def test_build_mapper_falls_back_for_spawn_bootstrap_runtime_error(monkeypatch): assert minimizer._build_mapper('problem') is None assert warnings == [ - 'DREAM parallel evaluation requires an import-safe main ' - 'module on spawn-based multiprocessing; falling back to ' - 'serial execution.' + ( + 'DREAM parallel evaluation requires an import-safe main ' + 'module on spawn-based multiprocessing; falling back to ' + 'serial execution.' + ) ] @@ -415,9 +417,11 @@ def test_build_mapper_falls_back_before_starting_spawn_for_direct_script(monkeyp assert minimizer._build_mapper('problem') is None assert pickle_checks == [] assert warnings == [ - 'DREAM parallel evaluation requires an import-safe main ' - 'module on spawn-based multiprocessing; falling back to ' - 'serial execution.' + ( + 'DREAM parallel evaluation requires an import-safe main ' + 'module on spawn-based multiprocessing; falling back to ' + 'serial execution.' + ) ] From 2c746302e22c99d1c39980b66696d81dd2c3b3ca Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 18:49:05 +0200 Subject: [PATCH 15/17] Add posterior display documentation --- docs/docs/quick-reference/index.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/docs/quick-reference/index.md b/docs/docs/quick-reference/index.md index e0d3fd20..fb6835d3 100644 --- a/docs/docs/quick-reference/index.md +++ b/docs/docs/quick-reference/index.md @@ -306,6 +306,18 @@ project.display.fit.correlations() project.display.pattern(expt_name='hrpt') ``` +After a Bayesian fit, inspect posterior displays: + +```python +project.display.posterior.distribution(param) +project.display.posterior.distribution() +project.display.posterior.pairs() +project.display.posterior.predictive(expt_name='hrpt') +``` + +Call `project.display.posterior.distribution()` without `param` to plot +the marginal distribution for each free parameter one by one. + ## Add Simple Constraints Create aliases from parameter objects, then define a constraint From 2bfa47e2b3c8678b825131ddef4faa47b4424718 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 18:59:50 +0200 Subject: [PATCH 16/17] Refactor plotting and ASCII chart constants --- docs/dev/Issues/issues_open.md | 170 +++++++++--------- src/easydiffraction/display/plotters/ascii.py | 11 +- src/easydiffraction/display/plotting.py | 47 +++-- .../display/plotters/test_ascii.py | 16 +- 4 files changed, 129 insertions(+), 115 deletions(-) diff --git a/docs/dev/Issues/issues_open.md b/docs/dev/Issues/issues_open.md index fab0ce5d..8805fc8f 100644 --- a/docs/dev/Issues/issues_open.md +++ b/docs/dev/Issues/issues_open.md @@ -1489,89 +1489,89 @@ operation is possible (e.g. in automated pipelines or tests). ## Summary -| # | Issue | Severity | Type | -| --- | ------------------------------------------------ | -------- | ---------------- | -| 3 | Rebuild joint-fit weights | 🟡 Med | Fragility | -| 5 | `Analysis` as `DatablockItem` | 🟡 Med | Consistency | -| 8 | Explicit `create()` signatures | 🟡 Med | API safety | -| 9 | Future enum extensions | 🟢 Low | Design | -| 10 | Unify update orchestration | 🟢 Low | Maintainability | -| 11 | Document `_update` contract | 🟢 Low | Maintainability | -| 13 | Suppress redundant dirty-flag sets | 🟢 Low | Performance | -| 14 | Finer-grained change tracking | 🟢 Low | Performance | -| 15 | Validate joint-fit weights | 🟡 Med | Correctness | -| 17 | Use PDF-specific CIF names | 🟢 Low | Naming | -| 18 | Move CIF v2→v1 conversion out of calculator | 🟢 Low | Maintainability | -| 19 | Debug-mode logging for calculator imports | 🟢 Low | Diagnostics | -| 20 | Redirect/suppress CrysPy stderr | 🟢 Low | UX | -| 21 | Clarify CrysPy TOF background CIF tags | 🟡 Med | Correctness | -| 22 | Check SC instrument mapping in CrysPy | 🟢 Low | Correctness | -| 23 | Investigate PyCrysFML pattern length discrepancy | 🟢 Low | Correctness | -| 24 | Process defaults on experiment creation | 🟢 Low | Design | -| 25 | Refactor data `_update` methods | 🟡 Med | Maintainability | -| 26 | Clarify `dtype` usage in data arrays | 🟢 Low | Cleanup | -| 27 | Handle zero uncertainty in Bragg PD | 🟢 Low | Correctness | -| 28 | Clarify Bragg PD data collection description | 🟢 Low | Cleanup | -| 29 | Standardise CIF ID validator pattern | 🟡 Med | Consistency | -| 30 | Make `refinement_status` default an Enum | 🟢 Low | Design | -| 31 | Rename PD data point mixins | 🟢 Low | Naming | -| 32 | Move common methods to `DatablockCollection` | 🟡 Med | Maintainability | -| 33 | Make `_update_categories` abstract | 🟡 Med | Design | -| 34 | Auto-extract `PeakProfileTypeEnum` | 🟢 Low | Design | -| 35 | Rename `BeamModeEnum` members to CWL/TOF | 🟢 Low | Naming | -| 36 | Common `EnumBase` class | 🟢 Low | Design | -| 37 | Rename experiment `.type` property | 🟢 Low | Naming | -| 38 | Fix `@typechecked`/gemmi in factories | 🟡 Med | Bug | -| 39 | Improve `_update_priority` handling | 🟢 Low | Design | -| 40 | Reset `.user_constrained` to `False` | 🟢 Low | Feature | -| 41 | Check `_mark_dirty` in `_set_value` | 🟢 Low | Cleanup | -| 42 | MkDocs type unpacking in validation | 🟢 Low | Docs | -| 43 | Fix summary display inconsistencies | 🟢 Low | UX | -| 44 | Merge parameter record construction | 🟢 Low | Cleanup | -| 45 | Decide alias/constraint descriptor default | 🟢 Low | Design | -| 46 | Rename `JointFitExperiments` id + descriptions | 🟢 Low | Naming | -| 47 | Improve error handling in crystallography | 🟢 Low | Diagnostics | -| 48 | Fix CrysPy TOF instrument default | 🟢 Low | Bug workaround | -| 49 | Automate space group CIF name variants | 🟢 Low | Maintainability | -| 50 | Clarify `Cell._update` minimizer param | 🟢 Low | Cleanup | -| 51 | Access space group for Wyckoff letters | 🟢 Low | Design | -| 52 | Rename line-segment `y` to `intensity` | 🟢 Low | Naming | -| 53 | Move `show()` to `CategoryCollection` | 🟢 Low | Maintainability | -| 54 | Add `point_id` to excluded regions | 🟢 Low | Completeness | -| 55 | Fix Jupyter scroll disabling for MkDocs | 🟢 Low | Docs / UX | -| 56 | Make ASCII plot width configurable | 🟢 Low | UX | -| 57 | Clean up CIF deserialisation helpers | 🟢 Low | Maintainability | -| 58 | Move `ProjectInfo` CIF methods to `serialize` | 🟢 Low | Maintainability | -| 59 | Add CIF name validation in parse | 🟢 Low | Robustness | -| 60 | Unify `mkdir` usage | 🟢 Low | Cleanup | -| 61 | Clarify logger default reaction mode | 🟢 Low | Design | -| 62 | Complete `render_table` → `TableRenderer` | 🟢 Low | Cleanup | -| 63 | Fix calculator `calculate_pattern` signature | 🟢 Low | Design | -| 64 | Check unused-if-loading-from-CIF code | 🟢 Low | Cleanup | -| 65 | Replace all bare `print()` with logging | 🟡 Med | Code quality | -| 66 | Error-handling strategy: `log.error` vs `raise` | 🟡 Med | Design | -| 67 | Custom validation for params and category types | 🟡 Med | Design | -| 68 | `@typechecked` on all public methods? | 🟢 Low | Design | -| 69 | Shorter public API names via `__init__` | 🟢 Low | API ergonomics | -| 70 | Standardise class member ordering + headers | 🟡 Med | Code style | -| 71 | `_update_priority` reference table | 🟢 Low | Documentation | -| 72 | Warn on all switchable-category type changes | 🟡 Med | UX | -| 73 | Unify setter parameter naming | 🟢 Low | Code style | -| 74 | Sync property type hints + custom lint rules | 🟡 Med | Tooling | -| 75 | `show_supported_calculators()` on Analysis | 🟢 Low | API completeness | -| 76 | Consistent `_type` suffix in switchable APIs | 🟡 Med | Naming | -| 79 | Verify analysis CIF serialisation completeness | 🟢 Low | Correctness | -| 80 | Resolve `Any` vs `object` annotation policy | 🟢 Low | Code style | -| 81 | Enforce docstrings on all public methods | 🟡 Med | Code quality | -| 82 | Document `param-docstring-fix` workflow | 🟢 Low | Documentation | -| 83 | Remove redundant parameter listing | 🟢 Low | Cleanup | -| 84 | Serialise `None` as `.` in CIF output | 🟡 Med | Correctness | -| 85 | Retain per-experiment fitted params for plotting | 🟡 Med | Correctness | -| 86 | Auto-resolve `plot_param` x-axis + add units | 🟢 Low | UX | -| 87 | Redesign tutorial grouping/categorisation | 🟢 Low | Documentation | -| 88 | Fix Dataset 26 description (47 not 57) | 🟢 Low | Data | -| 89 | Parallel independent fits for single mode | 🟡 Med | Performance | +| # | Issue | Severity | Type | +| --- | ------------------------------------------------ | -------- | ---------------------------- | +| 3 | Rebuild joint-fit weights | 🟡 Med | Fragility | +| 5 | `Analysis` as `DatablockItem` | 🟡 Med | Consistency | +| 8 | Explicit `create()` signatures | 🟡 Med | API safety | +| 9 | Future enum extensions | 🟢 Low | Design | +| 10 | Unify update orchestration | 🟢 Low | Maintainability | +| 11 | Document `_update` contract | 🟢 Low | Maintainability | +| 13 | Suppress redundant dirty-flag sets | 🟢 Low | Performance | +| 14 | Finer-grained change tracking | 🟢 Low | Performance | +| 15 | Validate joint-fit weights | 🟡 Med | Correctness | +| 17 | Use PDF-specific CIF names | 🟢 Low | Naming | +| 18 | Move CIF v2→v1 conversion out of calculator | 🟢 Low | Maintainability | +| 19 | Debug-mode logging for calculator imports | 🟢 Low | Diagnostics | +| 20 | Redirect/suppress CrysPy stderr | 🟢 Low | UX | +| 21 | Clarify CrysPy TOF background CIF tags | 🟡 Med | Correctness | +| 22 | Check SC instrument mapping in CrysPy | 🟢 Low | Correctness | +| 23 | Investigate PyCrysFML pattern length discrepancy | 🟢 Low | Correctness | +| 24 | Process defaults on experiment creation | 🟢 Low | Design | +| 25 | Refactor data `_update` methods | 🟡 Med | Maintainability | +| 26 | Clarify `dtype` usage in data arrays | 🟢 Low | Cleanup | +| 27 | Handle zero uncertainty in Bragg PD | 🟢 Low | Correctness | +| 28 | Clarify Bragg PD data collection description | 🟢 Low | Cleanup | +| 29 | Standardise CIF ID validator pattern | 🟡 Med | Consistency | +| 30 | Make `refinement_status` default an Enum | 🟢 Low | Design | +| 31 | Rename PD data point mixins | 🟢 Low | Naming | +| 32 | Move common methods to `DatablockCollection` | 🟡 Med | Maintainability | +| 33 | Make `_update_categories` abstract | 🟡 Med | Design | +| 34 | Auto-extract `PeakProfileTypeEnum` | 🟢 Low | Design | +| 35 | Rename `BeamModeEnum` members to CWL/TOF | 🟢 Low | Naming | +| 36 | Common `EnumBase` class | 🟢 Low | Design | +| 37 | Rename experiment `.type` property | 🟢 Low | Naming | +| 38 | Fix `@typechecked`/gemmi in factories | 🟡 Med | Bug | +| 39 | Improve `_update_priority` handling | 🟢 Low | Design | +| 40 | Reset `.user_constrained` to `False` | 🟢 Low | Feature | +| 41 | Check `_mark_dirty` in `_set_value` | 🟢 Low | Cleanup | +| 42 | MkDocs type unpacking in validation | 🟢 Low | Docs | +| 43 | Fix summary display inconsistencies | 🟢 Low | UX | +| 44 | Merge parameter record construction | 🟢 Low | Cleanup | +| 45 | Decide alias/constraint descriptor default | 🟢 Low | Design | +| 46 | Rename `JointFitExperiments` id + descriptions | 🟢 Low | Naming | +| 47 | Improve error handling in crystallography | 🟢 Low | Diagnostics | +| 48 | Fix CrysPy TOF instrument default | 🟢 Low | Bug workaround | +| 49 | Automate space group CIF name variants | 🟢 Low | Maintainability | +| 50 | Clarify `Cell._update` minimizer param | 🟢 Low | Cleanup | +| 51 | Access space group for Wyckoff letters | 🟢 Low | Design | +| 52 | Rename line-segment `y` to `intensity` | 🟢 Low | Naming | +| 53 | Move `show()` to `CategoryCollection` | 🟢 Low | Maintainability | +| 54 | Add `point_id` to excluded regions | 🟢 Low | Completeness | +| 55 | Fix Jupyter scroll disabling for MkDocs | 🟢 Low | Docs / UX | +| 56 | Make ASCII plot width configurable | 🟢 Low | UX | +| 57 | Clean up CIF deserialisation helpers | 🟢 Low | Maintainability | +| 58 | Move `ProjectInfo` CIF methods to `serialize` | 🟢 Low | Maintainability | +| 59 | Add CIF name validation in parse | 🟢 Low | Robustness | +| 60 | Unify `mkdir` usage | 🟢 Low | Cleanup | +| 61 | Clarify logger default reaction mode | 🟢 Low | Design | +| 62 | Complete `render_table` → `TableRenderer` | 🟢 Low | Cleanup | +| 63 | Fix calculator `calculate_pattern` signature | 🟢 Low | Design | +| 64 | Check unused-if-loading-from-CIF code | 🟢 Low | Cleanup | +| 65 | Replace all bare `print()` with logging | 🟡 Med | Code quality | +| 66 | Error-handling strategy: `log.error` vs `raise` | 🟡 Med | Design | +| 67 | Custom validation for params and category types | 🟡 Med | Design | +| 68 | `@typechecked` on all public methods? | 🟢 Low | Design | +| 69 | Shorter public API names via `__init__` | 🟢 Low | API ergonomics | +| 70 | Standardise class member ordering + headers | 🟡 Med | Code style | +| 71 | `_update_priority` reference table | 🟢 Low | Documentation | +| 72 | Warn on all switchable-category type changes | 🟡 Med | UX | +| 73 | Unify setter parameter naming | 🟢 Low | Code style | +| 74 | Sync property type hints + custom lint rules | 🟡 Med | Tooling | +| 75 | `show_supported_calculators()` on Analysis | 🟢 Low | API completeness | +| 76 | Consistent `_type` suffix in switchable APIs | 🟡 Med | Naming | +| 79 | Verify analysis CIF serialisation completeness | 🟢 Low | Correctness | +| 80 | Resolve `Any` vs `object` annotation policy | 🟢 Low | Code style | +| 81 | Enforce docstrings on all public methods | 🟡 Med | Code quality | +| 82 | Document `param-docstring-fix` workflow | 🟢 Low | Documentation | +| 83 | Remove redundant parameter listing | 🟢 Low | Cleanup | +| 84 | Serialise `None` as `.` in CIF output | 🟡 Med | Correctness | +| 85 | Retain per-experiment fitted params for plotting | 🟡 Med | Correctness | +| 86 | Auto-resolve `plot_param` x-axis + add units | 🟢 Low | UX | +| 87 | Redesign tutorial grouping/categorisation | 🟢 Low | Documentation | +| 88 | Fix Dataset 26 description (47 not 57) | 🟢 Low | Data | +| 89 | Parallel independent fits for single mode | 🟡 Med | Performance | | 95 | Re-enable DREAM multiprocessing in CLI workflows | 🟡 Med | Performance / CLI robustness | -| 90 | Show experiment number during sequential fitting | 🟢 Low | UX | -| 91 | Disable TODO checks in CodeFactor PRs | 🟢 Low | CI / Tooling | -| 92 | Make `save()` respect verbosity | 🟢 Low | UX | +| 90 | Show experiment number during sequential fitting | 🟢 Low | UX | +| 91 | Disable TODO checks in CodeFactor PRs | 🟢 Low | CI / Tooling | +| 92 | Make `save()` respect verbosity | 🟢 Low | UX | diff --git a/src/easydiffraction/display/plotters/ascii.py b/src/easydiffraction/display/plotters/ascii.py index b9320198..46746700 100644 --- a/src/easydiffraction/display/plotters/ascii.py +++ b/src/easydiffraction/display/plotters/ascii.py @@ -31,6 +31,7 @@ ASCII_CHART_OFFSET = 3 ASCII_CHART_LEFT_PADDING = 15 ASCII_CHART_FALLBACK_POINT_COUNT = 80 +ASCII_CHART_MIN_POINT_COUNT = 2 class AsciiPlotter(PlotterBase): @@ -43,7 +44,10 @@ def _chart_point_count() -> int: ASCII_CHART_FALLBACK_POINT_COUNT + ASCII_CHART_OFFSET + ASCII_CHART_LEFT_PADDING ) columns = shutil.get_terminal_size(fallback=(fallback_columns, DEFAULT_HEIGHT)).columns - return max(2, columns - ASCII_CHART_OFFSET - ASCII_CHART_LEFT_PADDING) + return max( + ASCII_CHART_MIN_POINT_COUNT, + columns - ASCII_CHART_OFFSET - ASCII_CHART_LEFT_PADDING, + ) @classmethod def _resample_series_for_chart( @@ -55,7 +59,10 @@ def _resample_series_for_chart( resampled_series: list[list[float]] = [] for series in y_series: series_array = np.ravel(np.asarray(series, dtype=float)) - if series_array.size <= target_point_count or series_array.size < 2: + if ( + series_array.size <= target_point_count + or series_array.size < ASCII_CHART_MIN_POINT_COUNT + ): resampled_series.append(series_array.tolist()) continue diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index d79cc2aa..3380141d 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -3835,8 +3835,7 @@ def _plot_posterior_predictive_data( ) return - show_residual = True if plot_options.show_residual is None else plot_options.show_residual - y_resid = y_meas - y_calc if show_residual else None + y_resid = y_meas - y_calc if plot_options.show_residual is not False else None predictive_lower_95 = None predictive_upper_95 = None @@ -3856,14 +3855,13 @@ def _plot_posterior_predictive_data( predictive_draws = None if show_draws: - draws = getattr(summary, 'draws', None) - if draws is None: + if summary.draws is None: log.warning('Posterior predictive draws are unavailable for plotting.') return predictive_draws = np.asarray( [ self._filtered_y_array(draw, summary.x, ctx['x_min'], ctx['x_max']) - for draw in draws + for draw in summary.draws ], dtype=float, ) @@ -3878,26 +3876,27 @@ def _plot_posterior_predictive_data( x_min=ctx['x_min'], x_max=ctx['x_max'], ) - plot_spec = PowderMeasVsCalcSpec( - x=ctx['x_filtered'], - y_meas=y_meas, - y_calc=y_calc, - y_resid=y_resid, - bragg_tick_sets=bragg_tick_sets, - axes_labels=ctx['axes_labels'], - title=f"Posterior predictive for experiment 🔬 '{expt_name}'", - residual_height_fraction=DEFAULT_RESID_HEIGHT, - bragg_peaks_height_fraction=DEFAULT_BRAGG_ROW, - height=self._composite_plot_height(), - y_bkg=y_bkg, - predictive_lower_95=predictive_lower_95, - predictive_upper_95=predictive_upper_95, - predictive_draws=predictive_draws, - y_calc_name=POSTERIOR_POINT_ESTIMATE_TRACE_NAME, - y_calc_line_dash=POSTERIOR_POINT_ESTIMATE_LINE_DASH, - excluded_ranges=excluded_ranges, + self._backend.plot_powder_meas_vs_calc( + plot_spec=PowderMeasVsCalcSpec( + x=ctx['x_filtered'], + y_meas=y_meas, + y_calc=y_calc, + y_resid=y_resid, + bragg_tick_sets=bragg_tick_sets, + axes_labels=ctx['axes_labels'], + title=f"Posterior predictive for experiment 🔬 '{expt_name}'", + residual_height_fraction=DEFAULT_RESID_HEIGHT, + bragg_peaks_height_fraction=DEFAULT_BRAGG_ROW, + height=self._composite_plot_height(), + y_bkg=y_bkg, + predictive_lower_95=predictive_lower_95, + predictive_upper_95=predictive_upper_95, + predictive_draws=predictive_draws, + y_calc_name=POSTERIOR_POINT_ESTIMATE_TRACE_NAME, + y_calc_line_dash=POSTERIOR_POINT_ESTIMATE_LINE_DASH, + excluded_ranges=excluded_ranges, + ) ) - self._backend.plot_powder_meas_vs_calc(plot_spec=plot_spec) @staticmethod def _resolve_posterior_parameter_names( diff --git a/tests/unit/easydiffraction/display/plotters/test_ascii.py b/tests/unit/easydiffraction/display/plotters/test_ascii.py index 728971f0..a0e84dc4 100644 --- a/tests/unit/easydiffraction/display/plotters/test_ascii.py +++ b/tests/unit/easydiffraction/display/plotters/test_ascii.py @@ -113,6 +113,9 @@ def test_ascii_plotter_plot_powder_meas_vs_calc_announces_plotly_only_bragg_row( def test_ascii_plotter_plot_resamples_to_detected_terminal_width(monkeypatch): from easydiffraction.display.plotters import ascii as ascii_mod + from easydiffraction.display.plotters.ascii import ASCII_CHART_LEFT_PADDING + from easydiffraction.display.plotters.ascii import ASCII_CHART_MIN_POINT_COUNT + from easydiffraction.display.plotters.ascii import ASCII_CHART_OFFSET from easydiffraction.display.plotters.ascii import AsciiPlotter captured: dict[str, object] = {} @@ -138,12 +141,17 @@ def fake_plot(series, config): ) series, config = captured['call'] - assert len(series[0]) == 40 - assert config['offset'] == 3 + assert len(series[0]) == max( + ASCII_CHART_MIN_POINT_COUNT, + 44 - ASCII_CHART_OFFSET - ASCII_CHART_LEFT_PADDING, + ) + assert config['offset'] == ASCII_CHART_OFFSET def test_ascii_plotter_plot_uses_fallback_width_when_terminal_size_unavailable(monkeypatch): from easydiffraction.display.plotters import ascii as ascii_mod + from easydiffraction.display.plotters.ascii import ASCII_CHART_FALLBACK_POINT_COUNT + from easydiffraction.display.plotters.ascii import ASCII_CHART_OFFSET from easydiffraction.display.plotters.ascii import AsciiPlotter captured: dict[str, object] = {} @@ -169,5 +177,5 @@ def fake_plot(series, config): ) series, config = captured['call'] - assert len(series[0]) == 80 - assert config['offset'] == 3 + assert len(series[0]) == ASCII_CHART_FALLBACK_POINT_COUNT + assert config['offset'] == ASCII_CHART_OFFSET From c065cd5b79fc54ab0c12f28a615bf613caa3bb53 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 19:01:00 +0200 Subject: [PATCH 17/17] Simplify posterior display calls in tutorials --- docs/docs/tutorials/ed-21.ipynb | 3 +-- docs/docs/tutorials/ed-22.ipynb | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/docs/tutorials/ed-21.ipynb b/docs/docs/tutorials/ed-21.ipynb index f0d5123d..d5fe4574 100644 --- a/docs/docs/tutorials/ed-21.ipynb +++ b/docs/docs/tutorials/ed-21.ipynb @@ -692,8 +692,7 @@ "metadata": {}, "outputs": [], "source": [ - "for param in project.free_parameters:\n", - " project.display.posterior.distribution(param)" + "project.display.posterior.distribution()" ] }, { diff --git a/docs/docs/tutorials/ed-22.ipynb b/docs/docs/tutorials/ed-22.ipynb index 965e4ed8..c6de427d 100644 --- a/docs/docs/tutorials/ed-22.ipynb +++ b/docs/docs/tutorials/ed-22.ipynb @@ -570,8 +570,7 @@ "metadata": {}, "outputs": [], "source": [ - "for param in project.free_parameters:\n", - " project.display.posterior.distribution(param)" + "project.display.posterior.distribution()" ] }, {