Skip to content
Open
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
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,51 @@ returned.
disable/re-enable cycle and are re-attached when magnetism is enabled
again. `update_layer` also accepts the magnetism keys one at a time.

## ORSO file handling

- Binary ORSO (`.orb`, NeXus/HDF5) files are read and written alongside
`.ort` text files. The format is detected from the ORSO banner line or
the HDF5 magic bytes, not the file extension, so a file that carries
the banner but fails to parse now raises instead of being silently
re-read as plain text (which dropped the whole header, polarization
included). `.orb` support needs `h5py`, available as the new `orb`
extra; `orsopy` is pinned to `>=1.2`.
- New ORSO export. `Project.save_experiment_as_orso(path, index=None)`
writes an experiment (`.ort` or `.orb`), with the model, when set,
serialized as `data_source.sample.model`. Backed by the new
`save_orso_experiment`, `orso_datasets_from_experiment` and
`sample_to_orso_model` in `easyreflectometry.orso_utils`. A polarized
experiment becomes one file with one `data_set:` block per spin
channel. `Model.as_orso` now returns the ORSO model-language
dictionary (slab representation) rather than the internal `as_dict`.
- Repeating multilayers survive a round trip. Loading resolves the ORSO
stack with `resolve_stack()` instead of flattening it, so a sub-stack
keeps its repetition count and comes back as a `RepeatingMultilayer`;
export writes it with the inline `N ( ... )` stack syntax.
- Units declared in the file are honoured: `Qz` in `1/nm`, lengths in
`nm` (the ORSO default) and SLDs in `1/nm^2` are converted on load,
instead of being read as angstrom-based numbers.
- Resolution and error columns are read more carefully. A column
declared `value_is: FWHM` is converted to sigma on load, `nan` entries
in `sQz` are filled by interpolating over the valid points, and
partially missing error columns warn rather than propagating `nan`
into a fit. Stored `Pointwise` resolutions remain variances, so saved
projects round-trip without migration.
- `Project.load_polarized_experiment_from_file(path)` loads a polarized
experiment from a single multi-dataset ORSO file, classifying each
`data_set:` block by its own `instrument_settings.polarization`
header. Only `pp/pm/mp/mm` are mapped; a file with an unmappable or
duplicated channel raises rather than guessing. Supported by the new
`channel_from_orso_polarization` and
`detect_polarization_channels_per_dataset` in
`easyreflectometry.data`.
- New `easyreflectometry.data.dataset_from_datagroup` builds a
`DataSet1D` from one dataset of an already-loaded `DataGroup`, and
keeps the parsed ORSO header on the dataset as `orso_header` so
exporters can reuse the original provenance. `load_as_dataset` and the
project loaders accept a pre-loaded `DataGroup`, so importing a file
no longer parses it three or four times.

## Documentation

- The documentation is now MkDocs (Material) only. The legacy Sphinx
Expand Down
3 changes: 2 additions & 1 deletion pixi.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ dependencies = [
'scipp',
'refnx',
'refl1d>=1.0.0',
'orsopy',
# >=1.2: model language resolve_to_blocks and nexus (.orb) I/O verified in 1.2.2
'orsopy>=1.2',
'svglib<1.6 ; platform_system=="Linux" or sys_platform == "darwin"',
'xhtml2pdf',
'bumps',
Expand All @@ -37,6 +38,8 @@ dependencies = [
]

[project.optional-dependencies]
# Binary ORSO (.orb / NeXus) read and write support (orsopy uses h5py for it)
orb = ['h5py']
dev = [
'GitPython', # Interact with Git repositories
'build', # Building the package
Expand Down
6 changes: 6 additions & 0 deletions src/easyreflectometry/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,24 @@

from .data_store import DataSet1D
from .data_store import ProjectData
from .measurement import dataset_from_datagroup
from .measurement import load
from .measurement import load_as_dataset
from .measurement import merge_datagroups
from .polarized import PolarizedDataSet
from .polarized import channel_from_orso_polarization
from .polarized import detect_polarization_channel
from .polarized import detect_polarization_channels_per_dataset

__all__ = [
'load',
'load_as_dataset',
'dataset_from_datagroup',
'merge_datagroups',
'ProjectData',
'DataSet1D',
'PolarizedDataSet',
'channel_from_orso_polarization',
'detect_polarization_channel',
'detect_polarization_channels_per_dataset',
]
90 changes: 74 additions & 16 deletions src/easyreflectometry/data/measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,105 @@


import os
from typing import Optional
from typing import TextIO
from typing import Union

import numpy as np
import scipp as sc

from easyreflectometry.data import DataSet1D
from easyreflectometry.orso_utils import is_orso_file
from easyreflectometry.orso_utils import load_data_from_orso_file


def load(fname: Union[TextIO, str]) -> sc.DataGroup:
"""Load data from an ORSO .ort file.
"""Load data from an ORSO file (.ort/.orb) or a plain text file.

The discriminator is the ORSO banner line (or the HDF5 magic for binary
files), **not** the file extension: a file carrying the banner that fails
to parse raises instead of being silently re-read as plain text (which
would drop the entire header, including polarization).

Parameters
----------
fname : Union[TextIO, str]
The file to be read.

Returns
-------
sc.DataGroup
The loaded data.
"""
try:
if is_orso_file(str(fname)):
return load_data_from_orso_file(fname)
except (IndexError, ValueError):
return _load_txt(fname)
return _load_txt(fname)


def load_as_dataset(fname: Union[TextIO, str]) -> DataSet1D:
"""Load data from an ORSO .ort file as a DataSet1D."""
data_group = load(fname)
basename = os.path.splitext(os.path.basename(fname))[0]
data_name = 'R_' + basename
coords_name = 'Qz_' + basename
coords_name = list(data_group['coords'].keys())[0] if coords_name not in data_group['coords'] else coords_name
data_name = list(data_group['data'].keys())[0] if data_name not in data_group['data'] else data_name
def dataset_from_datagroup(data_group: sc.DataGroup, data_key: Optional[str] = None) -> DataSet1D:
"""Build a DataSet1D from one dataset of a loaded DataGroup.

The ORSO header (when present) is attached to the returned dataset as the
``orso_header`` attribute (a plain dict), so exporters can reuse the
original ``data_source``/``reduction`` provenance.

Parameters
----------
data_group : sc.DataGroup
A DataGroup as returned by :func:`load`.
data_key : Optional[str], optional
The data entry to use (e.g. ``'R_0'``). By default, the first entry.

Returns
-------
DataSet1D
The dataset.
"""
if data_key is None:
data_key = list(data_group['data'].keys())[0]

Check warning on line 61 in src/easyreflectometry/data/measurement.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/data/measurement.py#L61

Added line #L61 was not covered by tests
coords_key = 'Qz_' + data_key[len('R_') :]
if coords_key not in data_group['coords']:
coords_key = list(data_group['coords'].keys())[0]

Check warning on line 64 in src/easyreflectometry/data/measurement.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/data/measurement.py#L64

Added line #L64 was not covered by tests
dataset = DataSet1D(
x=data_group['coords'][coords_name].values,
y=data_group['data'][data_name].values,
ye=data_group['data'][data_name].variances,
xe=data_group['coords'][coords_name].variances,
x=data_group['coords'][coords_key].values,
y=data_group['data'][data_key].values,
ye=data_group['data'][data_key].variances,
xe=data_group['coords'][coords_key].variances,
)
header = None
if 'attrs' in data_group and data_key in data_group['attrs']:
try:
header = data_group['attrs'][data_key]['orso_header'].values
except (KeyError, AttributeError):
header = None

Check warning on line 76 in src/easyreflectometry/data/measurement.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/data/measurement.py#L75-L76

Added lines #L75 - L76 were not covered by tests
dataset.orso_header = header
return dataset


def load_as_dataset(fname: Union[TextIO, str], data_group: Optional[sc.DataGroup] = None) -> DataSet1D:
"""Load data from an ORSO .ort file as a DataSet1D.

Parameters
----------
fname : Union[TextIO, str]
The file to be read.
data_group : Optional[sc.DataGroup], optional
Pre-loaded DataGroup for *fname* (avoids re-parsing the file).
By default, None.

Returns
-------
DataSet1D
The (first) dataset in the file.
"""
if data_group is None:
data_group = load(fname)
basename = os.path.splitext(os.path.basename(fname))[0]
data_name = 'R_' + basename
data_name = list(data_group['data'].keys())[0] if data_name not in data_group['data'] else data_name
return dataset_from_datagroup(data_group, data_key=data_name)


def extract_orso_title(data_group: sc.DataGroup, data_name: str) -> str | None:
"""Extract orso title."""
try:
Expand Down
61 changes: 57 additions & 4 deletions src/easyreflectometry/data/polarized.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,60 @@
return _channel_from_filename(path)


def channel_from_orso_polarization(polarization) -> Optional[PolarizationChannel]:
"""Map an ORSO ``instrument_settings.polarization`` value to a spin channel.

Parameters
----------
polarization :
The header value (orsopy ``Polarization`` enum, string, or None).

Returns
-------
Optional[PolarizationChannel]
The mapped channel, or None for absent/unmapped values (``po``, ``mo``,
``op``, ``om``, ``unpolarized``, ``vector`` are deliberately unmapped).
"""
if polarization is None:
return None
value = getattr(polarization, 'value', polarization)
return _ORSO_POLARIZATION_TO_CHANNEL.get(str(value).lower())


def _dataset_polarization(orso_dataset):
"""The declared polarization of one parsed ORSO dataset, or None."""
try:
return orso_dataset.info.data_source.measurement.instrument_settings.polarization
except AttributeError:
return None

Check warning on line 272 in src/easyreflectometry/data/polarized.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/data/polarized.py#L271-L272

Added lines #L271 - L272 were not covered by tests


def detect_polarization_channels_per_dataset(
orso_data,
) -> list[tuple[bool, Optional[PolarizationChannel]]]:
"""Classify every dataset of a parsed ORSO file by its own header.

Unlike :func:`detect_polarization_channel`, which reads only the first
dataset, this honours per-dataset ``polarization:`` overrides in
multi-dataset files.

Parameters
----------
orso_data : list
Parsed ORSO dataset list (as returned by ``orso.load_orso``).

Returns
-------
list[tuple[bool, Optional[PolarizationChannel]]]
Per dataset: (header declares a polarization, mapped channel or None).
"""
result = []
for orso_dataset in orso_data:
polarization = _dataset_polarization(orso_dataset)
result.append((polarization is not None, channel_from_orso_polarization(polarization)))
return result


def _channel_from_orso_header(path: str) -> tuple[bool, Optional[PolarizationChannel]]:
"""Read the polarization of the first dataset in an ORSO file.

Expand All @@ -254,16 +308,15 @@
False when the file is unreadable or carries no polarization field.
"""
try:
from orsopy.fileio import orso
from easyreflectometry.orso_utils import _load_orso_any

orso_data = orso.load_orso(str(path))
orso_data = _load_orso_any(str(path))
polarization = orso_data[0].info.data_source.measurement.instrument_settings.polarization
except Exception:
return False, None
if polarization is None:
return False, None
value = getattr(polarization, 'value', polarization)
return True, _ORSO_POLARIZATION_TO_CHANNEL.get(str(value).lower())
return True, channel_from_orso_polarization(polarization)


def _channel_from_filename(path: str) -> Optional[PolarizationChannel]:
Expand Down
19 changes: 17 additions & 2 deletions src/easyreflectometry/model/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,23 @@ def as_dict(self, skip: Optional[list[str]] = None) -> dict:
return self.to_dict(skip=skip)

def as_orso(self) -> dict:
"""Convert the model to a dictionary suitable for ORSO."""
return self.as_dict()
"""The sample as an ORSO simple-model (``sample.model``) dictionary.

Slab representation: lengths in angstrom, SLDs in 1/angstrom^2,
repeating multilayers via the inline ``N ( ... )`` stack syntax.

Returns
-------
dict
The ORSO model-language dictionary (the content of an .ort file's
``data_source.sample.model`` section).
"""
# Circular import if hoisted to module-top.
from orsopy.fileio import Header

from easyreflectometry.orso_utils import sample_to_orso_model

return Header.asdict(sample_to_orso_model(self.sample))

@classmethod
def from_dict(cls, passed_dict: dict) -> Model:
Expand Down
6 changes: 6 additions & 0 deletions src/easyreflectometry/model/resolution_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ class Pointwise(ResolutionFunction):
widths from the ``[Qz, R, sQz]`` triple loaded from a data file; the returned
widths are consumed by the calculators (refnx ``x_err`` / refl1d ``dq``),
which perform the actual convolution against the model.

Serialization contract: ``as_dict``/``from_dict`` store ``sQz_data_points``
as **variances** (sigma squared). This is deliberately unchanged by the
ORSO ``value_is: FWHM`` support — FWHM columns are converted to sigma at
load time, so stored values are always sigma squared and saved projects
round-trip without migration.
"""

def __init__(self, q_data_points: List[np.ndarray]):
Expand Down
Loading
Loading