Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 212 additions & 95 deletions src/easyreflectometry/summary/summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import contextlib
import io
import logging
from dataclasses import dataclass

Check warning on line 7 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L7

Added line #L7 was not covered by tests
from html import escape
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version
Expand All @@ -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

Check warning on line 23 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L21-L23

Added lines #L21 - L23 were not covered by tests
from easyreflectometry.sample import paths

from .html_templates import HTML_DATA_COLLECTION_TEMPLATE
Expand Down Expand Up @@ -53,6 +57,8 @@
_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'

Check warning on line 61 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L61

Added line #L61 was not covered by tests
_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'
Expand Down Expand Up @@ -149,6 +155,43 @@
return f'<a style="color:inherit;text-decoration:none;" href="{_TOOLTIP_SCHEME}:{encoded}" title="{safe}">{short}…</a>'


@dataclass(frozen=True)
class _ReflectivitySeries:

Check warning on line 159 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L158-L159

Added lines #L158 - L159 were not covered by tests
"""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

Check warning on line 172 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L168-L172

Added lines #L168 - L172 were not covered by tests

@property
def scale(self) -> float:
return 10.0**self.offset

Check warning on line 176 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L174-L176

Added lines #L174 - L176 were not covered by tests

@property
def legend(self) -> str:

Check warning on line 179 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L178-L179

Added lines #L178 - L179 were not covered by tests
"""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})'

Check warning on line 183 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L181-L183

Added lines #L181 - L183 were not covered by tests


@dataclass(frozen=True)
class _SldSeries:

Check warning on line 187 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L186-L187

Added lines #L186 - L187 were not covered by tests
"""The SLD profile of one model, in the model's color."""

label: str
profile: DataSet1D
color: str

Check warning on line 192 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L190-L192

Added lines #L190 - L192 were not covered by tests


class Summary:
def __init__(self, project: Project):
"""Init function."""
Expand Down Expand Up @@ -210,83 +253,162 @@
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)

Check warning on line 261 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L260-L261

Added lines #L260 - L261 were not covered by tests

ax.set_xlabel('z (Å)')
ax.set_ylabel('SLD (Å⁻²)')
fig.legend(['SLD'])
if ax.has_data():
ax.legend(loc='best')

Check warning on line 266 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L265-L266

Added lines #L265 - L266 were not covered by tests
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:

Check warning on line 270 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L270

Added line #L270 was not covered by tests
"""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')

Check warning on line 274 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L272-L274

Added lines #L272 - L274 were not covered by tests

for series in self._reflectivity_series():

Check warning on line 276 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L276

Added line #L276 was not covered by tests
# One legend entry per series: on the curve when there is one, else on the data.
if series.measured is not None:
ax.plot(

Check warning on line 279 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L278-L279

Added lines #L278 - L279 were not covered by tests
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(

Check warning on line 289 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L288-L289

Added lines #L288 - L289 were not covered by tests
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()

Check warning on line 303 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L298-L303

Added lines #L298 - L303 were not covered by tests

channels = self._project.experiment_channels_at_index(0)
if not channels:
return [('Experiment', experiment, 'red', None)]
def _sld_series(self) -> list[_SldSeries]:

Check warning on line 305 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L305

Added line #L305 was not covered by tests
"""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]]:

Check warning on line 312 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L312

Added line #L312 was not covered by tests
"""``(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

Check warning on line 329 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L320-L329

Added lines #L320 - L329 were not covered by tests

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

Check warning on line 335 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L331-L335

Added lines #L331 - L335 were not covered by tests

def _is_loaded(self, key, channel: PolarizationChannel | None) -> bool:

Check warning on line 337 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L337

Added line #L337 was not covered by tests
"""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

Check warning on line 345 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L342-L345

Added lines #L342 - L345 were not covered by tests

def _reflectivity_series(self) -> list[_ReflectivitySeries]:

Check warning on line 347 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L347

Added line #L347 was not covered by tests
"""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 [

Check warning on line 361 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L357-L361

Added lines #L357 - L361 were not covered by tests
_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

Check warning on line 380 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L372-L380

Added lines #L372 - L380 were not covered by tests
else:
label, measured = f'{name} ({channel.value})', experiment[channel]
color = _CHANNEL_COLORS[channel.value]
series.append(

Check warning on line 384 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L382-L384

Added lines #L382 - L384 were not covered by tests
_ReflectivitySeries(
label=label,
measured=measured,
calculated=self._calculated_curve(key, channel),
color=color,
offset=keys.index(key) if shift else 0,
)
)
return series

Check warning on line 393 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L393

Added line #L393 was not covered by tests

def _calculated_curve(self, key, channel: PolarizationChannel | None = None) -> DataSet1D | None:

Check warning on line 395 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L395

Added line #L395 was not covered by tests
"""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)

Check warning on line 402 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L402

Added line #L402 was not covered by tests
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
Expand Down Expand Up @@ -463,21 +585,20 @@
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(

Check warning on line 593 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L592-L593

Added lines #L592 - L593 were not covered by tests
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 (Å⁻²)',
Expand All @@ -488,40 +609,36 @@
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()):

Check warning on line 616 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L616

Added line #L616 was not covered by tests
# The data and its curve toggle together under one legend entry.
group = f'series-{index}'
if series.measured is not None:
fig.add_trace(

Check warning on line 620 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L618-L620

Added lines #L618 - L620 were not covered by tests
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(

Check warning on line 632 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L631-L632

Added lines #L631 - L632 were not covered by tests
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 (Å⁻¹)',
Expand Down
Loading
Loading