diff --git a/src/easyreflectometry/summary/summary.py b/src/easyreflectometry/summary/summary.py index b65d4949..b43945f4 100644 --- a/src/easyreflectometry/summary/summary.py +++ b/src/easyreflectometry/summary/summary.py @@ -4,6 +4,7 @@ import contextlib import io import logging +from dataclasses import dataclass from html import escape from importlib.metadata import PackageNotFoundError from importlib.metadata import version @@ -17,6 +18,9 @@ from xhtml2pdf import pisa from easyreflectometry import Project +from easyreflectometry.calculators import PolarizationChannel +from easyreflectometry.data import DataSet1D +from easyreflectometry.data import PolarizedDataSet from easyreflectometry.sample import paths from .html_templates import HTML_DATA_COLLECTION_TEMPLATE @@ -53,6 +57,8 @@ def _silence_pdf_converter(): _NAME_MAX_LEN = 20 # Fixed per-channel colors for polarized experiments, shared with the app so a # channel keeps the same color in the GUI and in the report. +# Color of an ordinary experiment that has no model to take one from. +_DEFAULT_COLOR = '#0173B2' _CHANNEL_COLORS = {'pp': '#0173B2', 'pm': '#029E73', 'mp': '#CC78BC', 'mm': '#DE8F05'} # Custom href scheme used to pass the full name to QML via TextEdit.hoveredLink. _TOOLTIP_SCHEME = 'nametooltip' @@ -149,6 +155,43 @@ def _truncate_name(name: str, max_len: int = _NAME_MAX_LEN) -> str: return f'{short}…' +@dataclass(frozen=True) +class _ReflectivitySeries: + """One entry of the reflectivity figure: a measured dataset, the curve it is + compared to, or both, drawn in one color and shifted by one scale factor. + + `measured` is None when no experiment is loaded and a model is drawn alone; + `calculated` is None when the curve cannot be calculated (e.g. a spin-flip + channel of a non-magnetic model): no overlay is better than a wrong one. + """ + + label: str + measured: DataSet1D | None + calculated: DataSet1D | None + color: str + offset: int = 0 + + @property + def scale(self) -> float: + return 10.0**self.offset + + @property + def legend(self) -> str: + """The label, with the factor the series is shifted by when it is shifted.""" + if self.offset == 0: + return self.label + return f'{self.label} (×10)' if self.offset == 1 else f'{self.label} (×10^{self.offset})' + + +@dataclass(frozen=True) +class _SldSeries: + """The SLD profile of one model, in the model's color.""" + + label: str + profile: DataSet1D + color: str + + class Summary: def __init__(self, project: Project): """Init function.""" @@ -210,83 +253,162 @@ def save_pdf_summary(self, filename: str) -> None: print('An error occured when generating PDF summary!') def save_sld_plot(self, filename: str) -> None: - """Save sld plot.""" + """Save the SLD figure: one profile per model.""" fig = plt.figure() ax = fig.add_subplot(1, 1, 1) - sld = self._project.sld_data_for_model_at_index(0) - ax.plot(sld.x, sld.y, color='blue') + for series in self._sld_series(): + ax.plot(series.profile.x, series.profile.y, color=series.color, label=series.label) ax.set_xlabel('z (Å)') ax.set_ylabel('SLD (Å⁻²)') - fig.legend(['SLD']) + if ax.has_data(): + ax.legend(loc='best') fig.savefig(filename, dpi=600) plt.close() - def _measured_series(self) -> list: - """Measured data of experiment 0 as ``(label, dataset, color, channel)`` entries. + def save_fit_experiment_plot(self, filename: str) -> None: + """Save the reflectivity figure; see :meth:`_reflectivity_series` for what it draws.""" + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1) + ax.set_yscale('log') + + for series in self._reflectivity_series(): + # One legend entry per series: on the curve when there is one, else on the data. + if series.measured is not None: + ax.plot( + series.measured.x, + np.asarray(series.measured.y) * series.scale, + ls='', + marker='.', + color=series.color, + alpha=0.6, + label=series.legend if series.calculated is None else None, + ) + if series.calculated is not None: + ax.plot( + series.calculated.x, + np.asarray(series.calculated.y) * series.scale, + ls='-', + color=series.color, + zorder=10, + label=series.legend, + ) - One entry per measured spin channel for a polarized experiment (each - with its own channel color and channel key), a single channel-less - entry for an ordinary experiment, and an empty list when no experiment - is loaded. - """ - try: - experiment = self._project.experimental_data_for_model_at_index(0) - except IndexError: - return [] + ax.set_xlabel('Q (Å⁻¹)') + ax.set_ylabel('Reflectivity') + if ax.has_data(): + ax.legend(loc='best') + fig.savefig(filename, dpi=600) + plt.close() - channels = self._project.experiment_channels_at_index(0) - if not channels: - return [('Experiment', experiment, 'red', None)] + def _sld_series(self) -> list[_SldSeries]: + """The SLD profile of every model, each in its model's color.""" return [ - (f'Experiment ({channel.value})', experiment[channel], _CHANNEL_COLORS[channel.value], channel) - for channel in channels + _SldSeries(model.name, self._project.sld_data_for_model_at_index(index), model.color) + for index, model in enumerate(self._project.models) ] - def _model_curve(self, channel=None): - """Calculated curve for experiment 0, per spin channel when asked. + def _drawn_datasets(self) -> list[tuple[int, PolarizationChannel | None]]: + """``(experiment key, channel)`` of every measured dataset the reflectivity figure draws. + + The datasets of the last recorded fit, when there is one and they are still + loaded, so that the figure describes the fit the report's numbers come from. + Otherwise every dataset of every experiment: one per spin channel of a + polarized experiment, one for an ordinary experiment. + """ + experiments = self._project.experiments + run = self._project.last_fit + if run is not None: + fitted = [] + for key, _name, channel in run.inputs: + entry = (key, None if channel is None else PolarizationChannel(channel)) + if entry not in fitted and self._is_loaded(*entry): + fitted.append(entry) + if fitted: + return fitted + + drawn = [] + for key in sorted(experiments): + channels = self._project.experiment_channels_at_index(key) + drawn.extend([(key, channel) for channel in channels] if channels else [(key, None)]) + return drawn + + def _is_loaded(self, key, channel: PolarizationChannel | None) -> bool: + """Whether the project still holds the dataset of `key` and `channel`. + + The experiment at a recorded key may have been replaced since the fit. + """ + experiment = self._project.experiments.get(key) + if isinstance(experiment, PolarizedDataSet): + return channel is not None and channel in experiment.available_channels + return experiment is not None and channel is None + + def _reflectivity_series(self) -> list[_ReflectivitySeries]: + """What the reflectivity figure draws, shared by the static and the interactive figure. + + Each measured dataset of :meth:`_drawn_datasets` with the curve the fit compares + it to: its experiment's model, at its q, with its resolution. An ordinary + experiment is drawn in its model's color, a polarized one in one color per + spin channel. With more than one experiment, the n-th is shifted up by 10^n + so that the curves do not overlap. With no experiment loaded, the calculated + curve of every model over the project's q range, shifted the same way. + """ + drawn = self._drawn_datasets() + if not drawn: + models = self._project.models + shift = len(models) > 1 + return [ + _ReflectivitySeries( + label=model.name, + measured=None, + calculated=self._project.model_data_for_model_at_index(index), + color=model.color, + offset=index if shift else 0, + ) + for index, model in enumerate(models) + ] + + keys = list(dict.fromkeys(key for key, _channel in drawn)) + shift = len(keys) > 1 + series = [] + for key, channel in drawn: + experiment = self._project.experiments[key] + name = experiment.name or f'Experiment {key}' + if channel is None: + label, measured = name, experiment + color = getattr(experiment.model, 'color', None) or _DEFAULT_COLOR + else: + label, measured = f'{name} ({channel.value})', experiment[channel] + color = _CHANNEL_COLORS[channel.value] + series.append( + _ReflectivitySeries( + label=label, + measured=measured, + calculated=self._calculated_curve(key, channel), + color=color, + offset=keys.index(key) if shift else 0, + ) + ) + return series + + def _calculated_curve(self, key, channel: PolarizationChannel | None = None) -> DataSet1D | None: + """The curve the fit compares the dataset of `key` and `channel` to, or None. - Returns None when the requested channel cannot be calculated (e.g. a - spin-flip channel on a non-magnetic model) — an incorrect overlay is - worse than none. + None when it cannot be calculated: a spin-flip channel of a non-magnetic + model, a calculator without magnetism, or an experiment without a model. """ try: - return self._project.model_data_for_model_at_index(0, channel=channel) + return self._project.model_data_for_experiment_at_index(key, channel=channel) except (ValueError, NotImplementedError) as exception: logging.getLogger(__name__).warning( - 'No calculated curve for channel %s: %s', getattr(channel, 'value', channel), exception + 'No calculated curve for experiment %s, channel %s: %s', + key, + getattr(channel, 'value', channel), + exception, ) return None - def save_fit_experiment_plot(self, filename: str) -> None: - """Save fit experiment plot.""" - fig = plt.figure() - ax = fig.add_subplot(1, 1, 1) - legends = [] - - measured_series = self._measured_series() - # One calculated curve per measured channel; the ordinary single curve - # when the experiment is unpolarized or nothing is loaded. - model_channels = [entry[3] for entry in measured_series] or [None] - for channel in model_channels: - model = self._model_curve(channel) - if model is None: - continue - color = 'blue' if channel is None else _CHANNEL_COLORS[channel.value] - ax.plot(model.x, np.log10(model.y), color=color) - legends.append('Model' if channel is None else f'Model ({channel.value})') - - for label, dataset, color, _channel in measured_series: - ax.plot(dataset.x, np.log10(dataset.y), color=color) - legends.append(label) - - ax.set_xlabel('Q (Å⁻¹)') - ax.set_ylabel('Reflectivity') - fig.legend(legends) - fig.savefig(filename, dpi=600) - plt.close() - def _project_information_section(self) -> str: """Project information section.""" html_project = HTML_PROJECT_INFORMATION_TEMPLATE @@ -463,21 +585,20 @@ def _interactive_figures_section(self) -> str: return html_figures def _sld_plotly_figure(self): - """Interactive SLD profile figure.""" + """Interactive SLD figure: one profile per model.""" import plotly.graph_objects as go - sld = self._project.sld_data_for_model_at_index(0) - fig = go.Figure() - fig.add_trace( - go.Scatter( - x=np.asarray(sld.x), - y=np.asarray(sld.y), - mode='lines', - name='SLD', - line={'color': 'blue'}, + for series in self._sld_series(): + fig.add_trace( + go.Scatter( + x=np.asarray(series.profile.x), + y=np.asarray(series.profile.y), + mode='lines', + name=series.label, + line={'color': series.color}, + ) ) - ) fig.update_layout( xaxis_title='z (Å)', yaxis_title='SLD (Å⁻²)', @@ -488,40 +609,36 @@ def _sld_plotly_figure(self): return fig def _fit_experiment_plotly_figure(self): - """Interactive reflectivity (model vs. experiment) figure.""" + """Interactive reflectivity figure; see :meth:`_reflectivity_series` for what it draws.""" import plotly.graph_objects as go fig = go.Figure() - - measured_series = self._measured_series() - # One calculated curve per measured channel; the ordinary single curve - # when the experiment is unpolarized or nothing is loaded. - model_channels = [entry[3] for entry in measured_series] or [None] - for channel in model_channels: - model = self._model_curve(channel) - if model is None: - continue - color = 'blue' if channel is None else _CHANNEL_COLORS[channel.value] - fig.add_trace( - go.Scatter( - x=np.asarray(model.x), - y=np.asarray(model.y), - mode='lines', - name='Model' if channel is None else f'Model ({channel.value})', - line={'color': color}, + for index, series in enumerate(self._reflectivity_series()): + # The data and its curve toggle together under one legend entry. + group = f'series-{index}' + if series.measured is not None: + fig.add_trace( + go.Scatter( + x=np.asarray(series.measured.x), + y=np.asarray(series.measured.y) * series.scale, + mode='markers', + name=series.legend, + legendgroup=group, + showlegend=series.calculated is None, + marker={'color': series.color, 'size': 4}, + ) ) - ) - - for label, dataset, color, _channel in measured_series: - fig.add_trace( - go.Scatter( - x=np.asarray(dataset.x), - y=np.asarray(dataset.y), - mode='markers', - name=label, - marker={'color': color, 'size': 4}, + if series.calculated is not None: + fig.add_trace( + go.Scatter( + x=np.asarray(series.calculated.x), + y=np.asarray(series.calculated.y) * series.scale, + mode='lines', + name=series.legend, + legendgroup=group, + line={'color': series.color}, + ) ) - ) fig.update_layout( xaxis_title='Q (Å⁻¹)', diff --git a/tests/summary/test_summary.py b/tests/summary/test_summary.py index 4eb95752..fcfa37be 100644 --- a/tests/summary/test_summary.py +++ b/tests/summary/test_summary.py @@ -7,9 +7,11 @@ import numpy as np import pytest from easyscience import global_object +from numpy.testing import assert_allclose from easyreflectometry import Project from easyreflectometry.contrasts import ReplaceMaterial +from easyreflectometry.data import DataSet1D from easyreflectometry.model.resolution_functions import PercentageFwhm from easyreflectometry.sample import Material from easyreflectometry.summary import Summary @@ -296,21 +298,32 @@ def polarized_project(self, tmp_path) -> Project: project.default_model() project.load_polarized_experiment({ 'pp': self._write_channel_file(tmp_path, 'sample_uu.txt'), + 'pm': self._write_channel_file(tmp_path, 'sample_ud.txt'), 'mm': self._write_channel_file(tmp_path, 'sample_dd.txt'), }) return project - def test_measured_series_one_entry_per_channel(self, polarized_project: Project) -> None: + def test_reflectivity_series_one_entry_per_channel(self, polarized_project: Project) -> None: # When summary = Summary(polarized_project) # Then - series = summary._measured_series() + series = summary._reflectivity_series() - # Expect - assert [entry[0] for entry in series] == ['Experiment (pp)', 'Experiment (mm)'] - assert series[0][2] != series[1][2] # distinct channel colors - assert [entry[3].value for entry in series] == ['pp', 'mm'] + # Expect: one series per measured channel, each in its own channel color, none shifted + name = polarized_project.experiments[0].name + assert [entry.label for entry in series] == [f'{name} (pp)', f'{name} (pm)', f'{name} (mm)'] + assert len({entry.color for entry in series}) == 3 + assert all(entry.offset == 0 for entry in series) + + def test_spin_flip_channel_of_a_non_magnetic_model_has_no_curve(self, polarized_project: Project) -> None: + # When: a non-magnetic model cannot produce a spin-flip cross-section + series = {entry.label[-3:-1]: entry for entry in Summary(polarized_project)._reflectivity_series()} + + # Then Expect: its data is drawn without a misleading overlay + assert series['pm'].measured is not None + assert series['pm'].calculated is None + assert series['pp'].calculated is not None @pytest.fixture def project(self) -> Project: @@ -319,41 +332,152 @@ def project(self) -> Project: project.default_model() return project - def test_measured_series_unpolarized_and_empty(self, project: Project) -> None: + def test_reflectivity_series_unpolarized_and_empty(self, project: Project) -> None: # When summary = Summary(project) - # Then Expect: nothing loaded - assert summary._measured_series() == [] + # Then Expect: with nothing loaded, the model's curve alone + series = summary._reflectivity_series() + assert len(series) == 1 + assert series[0].measured is None and series[0].calculated is not None + assert series[0].label == project.models[0].name # When an ordinary experiment is loaded project.load_experiment_for_model_at_index(os.path.join(PATH_STATIC, 'example.ort')) - # Expect one channel-less entry - series = summary._measured_series() + # Expect one series, the data and its curve, in the model's color + series = summary._reflectivity_series() assert len(series) == 1 - assert series[0][0] == 'Experiment' and series[0][3] is None + assert series[0].label == project.experiments[0].name + assert series[0].measured is project.experiments[0] + assert series[0].color == project.models[0].color + assert_allclose(series[0].calculated.x, series[0].measured.x) - def test_model_curve_skips_uncalculable_channel(self, polarized_project: Project) -> None: - # When: a non-magnetic model cannot produce a spin-flip cross-section + def test_fit_experiment_figure_has_a_legend_entry_per_channel(self, polarized_project: Project) -> None: + # When summary = Summary(polarized_project) - # Then Expect: no misleading overlay is generated for it - assert summary._model_curve('pm') is None - assert summary._model_curve('pp') is not None + # Then + figure = summary._fit_experiment_plotly_figure() + + # Expect: data and curve of a channel share a legend entry. A non-magnetic model + # calculates only pp (as the unpolarized curve), so pm and mm are drawn as data alone. + name = polarized_project.experiments[0].name + legend = [trace.name for trace in figure.data if trace.showlegend is not False] + assert legend == [f'{name} (pp)', f'{name} (pm)', f'{name} (mm)'] + assert [trace.mode for trace in figure.data] == ['markers', 'lines', 'markers', 'markers'] + + +class TestSummaryMultipleExperiments: + """Report figures of several contrasts, each with its own experiment.""" - def test_fit_experiment_figure_has_a_trace_per_channel(self, polarized_project: Project) -> None: + Q = np.linspace(0.01, 0.25, 60) + + @pytest.fixture + def contrasts(self) -> Project: + """The default model and two contrasts, each with synthetic data from its own model.""" + global_object.map._clear() + project = Project() + project.default_model() + d2o = project._materials[1] + project.add_contrast(0, 'H2O', [ReplaceMaterial(d2o, Material(sld=-0.56, isld=0, name='H2O'))]) + project.add_contrast(0, 'CMSi', [ReplaceMaterial(d2o, Material(sld=2.07, isld=0, name='CMSi'))]) + experiments = {} + for index, model in enumerate(project.models): + y = project.model_data_for_model_at_index(index, q_range=self.Q).y + experiments[index] = DataSet1D( + name=f'{model.name} data', x=self.Q, y=y, ye=(0.02 * y) ** 2, model=model, auto_background=False + ) + project.experiments = experiments + return project + + def test_one_series_per_experiment_in_its_models_color(self, contrasts: Project) -> None: # When - summary = Summary(polarized_project) + series = Summary(contrasts)._reflectivity_series() + + # Then Expect: every experiment, shifted a decade apart, each on its own model's curve + assert [entry.label for entry in series] == ['Model data', 'H2O data', 'CMSi data'] + assert [entry.color for entry in series] == [model.color for model in contrasts.models] + assert [entry.offset for entry in series] == [0, 1, 2] + assert [entry.legend for entry in series] == ['Model data', 'H2O data (×10)', 'CMSi data (×10^2)'] + for entry, experiment in zip(series, contrasts.experiments.values()): + assert entry.measured is experiment + # The data was made from its own model, so its curve passes through it. + assert_allclose(entry.calculated.y, experiment.y, rtol=1e-8) + + def test_a_recorded_fit_draws_its_own_experiments(self, contrasts: Project) -> None: + # When: the last fit used two of the three experiments + prepared = contrasts.prepare_fit([contrasts.experiments[2], contrasts.experiments[0]]) + contrasts.record_fit(prepared, None, status='failed') # Then - figure = summary._fit_experiment_plotly_figure() + series = Summary(contrasts)._reflectivity_series() - # Expect: one measured trace per channel plus the calculable model curves - names = [trace.name for trace in figure.data] - assert 'Experiment (pp)' in names - assert 'Experiment (mm)' in names - assert 'Model (pp)' in names + # Expect: only those, in the fit's order + assert [entry.label for entry in series] == ['CMSi data', 'Model data'] + assert [entry.offset for entry in series] == [0, 1] + + def test_a_fit_whose_experiments_are_gone_draws_every_experiment(self, contrasts: Project) -> None: + # When: the only fitted experiment has been removed since + contrasts.record_fit(contrasts.prepare_fit([contrasts.experiments[1]]), None, status='failed') + contrasts.remove_experiment(1) + + # Then + series = Summary(contrasts)._reflectivity_series() + + # Expect: the figure falls back to every experiment still loaded + assert [entry.label for entry in series] == ['Model data', 'CMSi data'] + + def test_without_experiments_every_model_is_drawn(self, contrasts: Project) -> None: + # When + contrasts.experiments = {} + + # Then + series = Summary(contrasts)._reflectivity_series() + + # Expect: one calculated curve per model, shifted the same way + assert [entry.label for entry in series] == [model.name for model in contrasts.models] + assert all(entry.measured is None for entry in series) + assert [entry.offset for entry in series] == [0, 1, 2] + + def test_one_sld_profile_per_model(self, contrasts: Project) -> None: + # When + summary = Summary(contrasts) + + # Then + series = summary._sld_series() + figure = summary._sld_plotly_figure() + + # Expect + names = [model.name for model in contrasts.models] + assert [entry.label for entry in series] == names + assert [entry.color for entry in series] == [model.color for model in contrasts.models] + assert [trace.name for trace in figure.data] == names + + def test_interactive_reflectivity_figure_shifts_every_experiment(self, contrasts: Project) -> None: + # When + figure = Summary(contrasts)._fit_experiment_plotly_figure() + + # Then Expect: data and curve per experiment, shifted by its legend's factor + assert len(figure.data) == 6 + legend = [trace.name for trace in figure.data if trace.showlegend is not False] + assert legend == ['Model data', 'H2O data (×10)', 'CMSi data (×10^2)'] + for offset, experiment in enumerate(contrasts.experiments.values()): + measured, calculated = figure.data[2 * offset], figure.data[2 * offset + 1] + assert measured.legendgroup == calculated.legendgroup + assert_allclose(measured.y, np.asarray(experiment.y) * 10.0**offset) + + def test_static_figures_are_saved(self, contrasts: Project, tmp_path) -> None: + # When + summary = Summary(contrasts) + + # Then + summary.save_fit_experiment_plot(tmp_path / 'reflectivity.png') + summary.save_sld_plot(tmp_path / 'sld.png') + + # Expect + assert (tmp_path / 'reflectivity.png').exists() + assert (tmp_path / 'sld.png').exists() class TestSummarySharedParameters: