Skip to content

Commit 1b83994

Browse files
authored
Merge pull request #137 from scipp/estia-orso
feat: add orso providers for estia
2 parents ec4172e + aecd398 commit 1b83994

File tree

5 files changed

+181
-108
lines changed

5 files changed

+181
-108
lines changed

src/ess/amor/orso.py

Lines changed: 9 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,16 @@
11
# SPDX-License-Identifier: BSD-3-Clause
22
# Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
3-
"""ORSO utilities for Amor."""
3+
from ess.reflectometry.orso import OrsoCorrectionList
44

5-
import numpy as np
6-
import scipp as sc
7-
from orsopy.fileio import base as orso_base
8-
from orsopy.fileio import data_source as orso_data_source
9-
from orsopy.fileio.orso import Column, Orso, OrsoDataset
105

11-
from ..reflectometry.orso import (
12-
OrsoDataSource,
13-
OrsoInstrument,
14-
OrsoIofQDataset,
15-
OrsoReduction,
16-
)
17-
from ..reflectometry.types import ReflectivityOverQ
18-
19-
20-
def build_orso_instrument(events: ReflectivityOverQ) -> OrsoInstrument:
21-
"""Build ORSO instrument metadata from intermediate reduction results for Amor.
22-
23-
This assumes specular reflection and sets the incident angle equal to the computed
24-
scattering angle.
25-
"""
26-
return OrsoInstrument(
27-
orso_data_source.InstrumentSettings(
28-
wavelength=orso_base.ValueRange(*_limits_of_coord(events, "wavelength")),
29-
incident_angle=orso_base.ValueRange(*_limits_of_coord(events, "theta")),
30-
polarization=None, # TODO how can we determine this from the inputs?
31-
)
32-
)
33-
34-
35-
def build_orso_iofq_dataset(
36-
iofq: ReflectivityOverQ,
37-
data_source: OrsoDataSource,
38-
reduction: OrsoReduction,
39-
) -> OrsoIofQDataset:
40-
"""Build an ORSO dataset for reduced I-of-Q data and associated metadata."""
41-
header = Orso(
42-
data_source=data_source,
43-
reduction=reduction,
44-
columns=[
45-
Column("Qz", "1/angstrom", "wavevector transfer"),
46-
Column("R", None, "reflectivity"),
47-
Column("sR", None, "standard deviation of reflectivity"),
48-
Column(
49-
"sQz",
50-
"1/angstrom",
51-
"standard deviation of wavevector transfer resolution",
52-
),
53-
],
6+
def orso_amor_corrections() -> OrsoCorrectionList:
7+
return OrsoCorrectionList(
8+
[
9+
"chopper ToF correction",
10+
"footprint correction",
11+
"supermirror calibration",
12+
]
5413
)
55-
iofq = iofq.hist()
56-
57-
qz = iofq.coords["Q"].to(unit="1/angstrom", copy=False)
58-
if iofq.coords.is_edges("Q"):
59-
qz = sc.midpoints(qz)
60-
r = sc.values(iofq.data)
61-
sr = sc.stddevs(iofq.data)
62-
sqz = iofq.coords["Q_resolution"].to(unit="1/angstrom", copy=False)
63-
64-
data = np.column_stack(tuple(map(_extract_values_array, (qz, r, sr, sqz))))
65-
data = data[np.isfinite(data).all(axis=-1)]
66-
ds = OrsoIofQDataset(OrsoDataset(header, data))
67-
ds.info.reduction.corrections = [
68-
"chopper ToF correction",
69-
"footprint correction",
70-
"supermirror calibration",
71-
]
72-
return ds
73-
74-
75-
def _extract_values_array(var: sc.Variable) -> np.ndarray:
76-
if var.variances is not None:
77-
raise sc.VariancesError(
78-
"ORT columns must not have variances. "
79-
"Store the uncertainties as standard deviations in a separate column."
80-
)
81-
if var.ndim != 1:
82-
raise sc.DimensionError(f"ORT columns must be one-dimensional, got {var.sizes}")
83-
return var.values
84-
85-
86-
def _limits_of_coord(data: sc.DataArray, name: str) -> tuple[float, float, str] | None:
87-
if (coord := _get_coord(data, name)) is None:
88-
return None
89-
min_ = coord.min().value
90-
max_ = coord.max().value
91-
# Explicit conversions to float because orsopy does not like np.float* types.
92-
return float(min_), float(max_), _ascii_unit(coord.unit)
93-
94-
95-
def _get_coord(data: sc.DataArray, name: str) -> sc.Variable | None:
96-
if name in data.coords:
97-
return sc.DataArray(data=data.coords[name], masks=data.masks)
98-
if (data.bins is not None) and (name in data.bins.coords):
99-
# Note that .bins.concat() applies the top-level masks
100-
events = data.bins.concat().value
101-
return sc.DataArray(data=events.coords[name], masks=events.masks)
102-
return None
103-
104-
105-
def _ascii_unit(unit: sc.Unit) -> str:
106-
unit = str(unit)
107-
if unit == "Å":
108-
return "angstrom"
109-
return unit
11014

11115

112-
providers = (build_orso_instrument, build_orso_iofq_dataset)
16+
providers = (orso_amor_corrections,)

src/ess/estia/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
RunType,
1616
SamplePosition,
1717
)
18-
from . import conversions, load, maskings, normalization, resolution, workflow
18+
from . import conversions, load, maskings, normalization, orso, resolution, workflow
1919
from .types import (
2020
AngularResolution,
2121
SampleSizeResolution,
@@ -35,6 +35,7 @@
3535
*maskings.providers,
3636
*workflow.providers,
3737
*normalization.providers,
38+
*orso.providers,
3839
)
3940
"""
4041
List of providers for setting up a Sciline pipeline.

src/ess/estia/orso.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# SPDX-License-Identifier: BSD-3-Clause
2+
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
3+
from ess.reflectometry.orso import OrsoCorrectionList
4+
5+
6+
def orso_estia_corrections() -> OrsoCorrectionList:
7+
return OrsoCorrectionList(
8+
[
9+
"chopper ToF correction",
10+
"footprint correction",
11+
"supermirror calibration",
12+
]
13+
)
14+
15+
16+
providers = (orso_estia_corrections,)

src/ess/reflectometry/orso.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,19 @@
1111
from datetime import datetime, timezone
1212
from typing import NewType
1313

14+
import numpy as np
15+
import scipp as sc
1416
from dateutil.parser import parse as parse_datetime
1517
from orsopy.fileio import base as orso_base
1618
from orsopy.fileio import data_source, orso, reduction
19+
from orsopy.fileio.orso import Column, Orso, OrsoDataset
1720

1821
from .load import load_nx
1922
from .types import (
2023
Filename,
24+
ReducibleData,
2125
ReferenceRun,
26+
ReflectivityOverQ,
2227
SampleRun,
2328
)
2429

@@ -52,6 +57,8 @@
5257
OrsoSampleFilenames = NewType("OrsoSampleFilenames", list[orso_base.File])
5358
"""Collection of filenames used to create the ORSO file"""
5459

60+
OrsoCorrectionList = NewType("OrsoCorrectionList", list[str])
61+
5562

5663
def parse_orso_experiment(filename: Filename[SampleRun]) -> OrsoExperiment:
5764
"""Parse ORSO experiment metadata from raw NeXus data."""
@@ -171,6 +178,95 @@ def build_orso_data_source(
171178
)
172179

173180

181+
def build_orso_instrument(events: ReducibleData[SampleRun]) -> OrsoInstrument:
182+
"""Build ORSO instrument metadata from intermediate reduction results.
183+
184+
This assumes specular reflection and sets the incident angle equal to the computed
185+
scattering angle.
186+
"""
187+
return OrsoInstrument(
188+
data_source.InstrumentSettings(
189+
wavelength=orso_base.ValueRange(*_limits_of_coord(events, "wavelength")),
190+
incident_angle=orso_base.ValueRange(*_limits_of_coord(events, "theta")),
191+
polarization=None, # TODO how can we determine this from the inputs?
192+
)
193+
)
194+
195+
196+
def build_orso_iofq_dataset(
197+
iofq: ReflectivityOverQ,
198+
data_source: OrsoDataSource,
199+
reduction: OrsoReduction,
200+
corrections: OrsoCorrectionList,
201+
) -> OrsoIofQDataset:
202+
"""Build an ORSO dataset for reduced I-of-Q data and associated metadata."""
203+
header = Orso(
204+
data_source=data_source,
205+
reduction=reduction,
206+
columns=[
207+
Column("Qz", "1/angstrom", "wavevector transfer"),
208+
Column("R", None, "reflectivity"),
209+
Column("sR", None, "standard deviation of reflectivity"),
210+
Column(
211+
"sQz",
212+
"1/angstrom",
213+
"standard deviation of wavevector transfer resolution",
214+
),
215+
],
216+
)
217+
iofq = iofq.hist()
218+
219+
qz = iofq.coords["Q"].to(unit="1/angstrom", copy=False)
220+
if iofq.coords.is_edges("Q"):
221+
qz = sc.midpoints(qz)
222+
r = sc.values(iofq.data)
223+
sr = sc.stddevs(iofq.data)
224+
sqz = iofq.coords["Q_resolution"].to(unit="1/angstrom", copy=False)
225+
226+
data = np.column_stack(tuple(map(_extract_values_array, (qz, r, sr, sqz))))
227+
data = data[np.isfinite(data).all(axis=-1)]
228+
ds = OrsoIofQDataset(OrsoDataset(header, data))
229+
ds.info.reduction.corrections = list(corrections)
230+
return ds
231+
232+
233+
def _extract_values_array(var: sc.Variable) -> np.ndarray:
234+
if var.variances is not None:
235+
raise sc.VariancesError(
236+
"ORT columns must not have variances. "
237+
"Store the uncertainties as standard deviations in a separate column."
238+
)
239+
if var.ndim != 1:
240+
raise sc.DimensionError(f"ORT columns must be one-dimensional, got {var.sizes}")
241+
return var.values
242+
243+
244+
def _limits_of_coord(data: sc.DataArray, name: str) -> tuple[float, float, str] | None:
245+
if (coord := _get_coord(data, name)) is None:
246+
return None
247+
min_ = coord.min().value
248+
max_ = coord.max().value
249+
# Explicit conversions to float because orsopy does not like np.float* types.
250+
return float(min_), float(max_), _ascii_unit(coord.unit)
251+
252+
253+
def _get_coord(data: sc.DataArray, name: str) -> sc.Variable | None:
254+
if name in data.coords:
255+
return sc.DataArray(data=data.coords[name], masks=data.masks)
256+
if (data.bins is not None) and (name in data.bins.coords):
257+
# Note that .bins.concat() applies the top-level masks
258+
events = data.bins.concat().value
259+
return sc.DataArray(data=events.coords[name], masks=events.masks)
260+
return None
261+
262+
263+
def _ascii_unit(unit: sc.Unit) -> str:
264+
unit = str(unit)
265+
if unit == "Å":
266+
return "angstrom"
267+
return unit
268+
269+
174270
providers = (
175271
build_orso_data_source,
176272
build_orso_measurement,
@@ -179,4 +275,6 @@ def build_orso_data_source(
179275
parse_orso_owner,
180276
parse_orso_sample,
181277
orso_data_files,
278+
build_orso_instrument,
279+
build_orso_iofq_dataset,
182280
)

tests/estia/mcstas_data_test.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
# SPDX-License-Identifier: BSD-3-Clause
22
# Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
33
# flake8: noqa: F403, F405
4+
from datetime import datetime
5+
from pathlib import Path
6+
from zoneinfo import ZoneInfo
47

58
import numpy as np
69
import pytest
710
import sciline
811
import scipp as sc
12+
from orsopy import fileio
913

1014
from ess.estia import EstiaWorkflow
1115
from ess.estia.data import estia_mcstas_reference_run, estia_mcstas_sample_run
1216
from ess.estia.load import load_mcstas_events
17+
from ess.reflectometry import orso
1318
from ess.reflectometry.types import (
1419
BeamDivergenceLimits,
1520
Filename,
@@ -35,6 +40,7 @@ def estia_mcstas_pipeline() -> sciline.Pipeline:
3540
wf[ZIndexLimits] = sc.scalar(0), sc.scalar(14 * 32)
3641
wf[BeamDivergenceLimits] = sc.scalar(-1.0, unit='deg'), sc.scalar(1.0, unit='deg')
3742
wf[WavelengthBins] = sc.geomspace('wavelength', 3.5, 12, 2001, unit='angstrom')
43+
wf[QBins] = sc.geomspace('Q', 0.005, 0.1, 200, unit='1/angstrom')
3844
wf[ProtonCurrent[SampleRun]] = sc.DataArray(
3945
sc.array(dims=('time',), values=[]),
4046
coords={'time': sc.array(dims=('time',), values=[], unit='s')},
@@ -43,7 +49,30 @@ def estia_mcstas_pipeline() -> sciline.Pipeline:
4349
sc.array(dims=('time',), values=[]),
4450
coords={'time': sc.array(dims=('time',), values=[], unit='s')},
4551
)
46-
52+
wf[orso.OrsoCreator] = orso.OrsoCreator(
53+
fileio.base.Person(
54+
name="Max Mustermann",
55+
affiliation="European Spallation Source ERIC",
56+
contact="[email protected]",
57+
)
58+
)
59+
wf[orso.OrsoExperiment] = orso.OrsoExperiment(
60+
fileio.data_source.Experiment(
61+
title='McStas run',
62+
instrument='Estia',
63+
facility='ESS',
64+
start_date=datetime(2025, 3, 20, tzinfo=ZoneInfo("Europe/Stockholm")),
65+
probe='neutron',
66+
)
67+
)
68+
wf[orso.OrsoOwner] = orso.OrsoOwner(
69+
fileio.base.Person(
70+
name='John Doe',
71+
contact='[email protected]',
72+
affiliation='ESS',
73+
)
74+
)
75+
wf[orso.OrsoSample] = orso.OrsoSample(fileio.data_source.Sample.empty())
4776
return wf
4877

4978

@@ -62,7 +91,6 @@ def test_mcstas_compute_reducible_data(estia_mcstas_pipeline: sciline.Pipeline):
6291

6392
def test_can_compute_reflectivity_curve(estia_mcstas_pipeline: sciline.Pipeline):
6493
estia_mcstas_pipeline[Filename[SampleRun]] = estia_mcstas_sample_run(11)
65-
estia_mcstas_pipeline[QBins] = sc.geomspace('Q', 0.005, 0.1, 200, unit='1/angstrom')
6694
r = estia_mcstas_pipeline.compute(ReflectivityOverQ)
6795
assert "Q" in r.coords
6896
assert "Q_resolution" in r.coords
@@ -80,3 +108,29 @@ def test_can_compute_reflectivity_curve(estia_mcstas_pipeline: sciline.Pipeline)
80108

81109
assert max_q > sc.scalar(0.075, unit='1/angstrom')
82110
assert min_q < sc.scalar(0.007, unit='1/angstrom')
111+
112+
113+
def test_orso_pipeline(estia_mcstas_pipeline: sciline.Pipeline):
114+
estia_mcstas_pipeline[Filename[SampleRun]] = estia_mcstas_sample_run(11)
115+
res = estia_mcstas_pipeline.compute(orso.OrsoIofQDataset)
116+
assert res.info.data_source.experiment.instrument == "Estia"
117+
assert res.info.reduction.software.name == "ess.reflectometry"
118+
assert res.info.reduction.corrections == [
119+
"chopper ToF correction",
120+
"footprint correction",
121+
"supermirror calibration",
122+
]
123+
assert res.data.ndim == 2
124+
assert res.data.shape[1] == 4
125+
assert np.all(res.data[:, 1] >= 0)
126+
assert np.isfinite(res.data).all()
127+
128+
129+
def test_save_reduced_orso_file(
130+
estia_mcstas_pipeline: sciline.Pipeline, output_folder: Path
131+
):
132+
estia_mcstas_pipeline[Filename[SampleRun]] = estia_mcstas_sample_run(11)
133+
res = estia_mcstas_pipeline.compute(orso.OrsoIofQDataset)
134+
fileio.orso.save_orso(
135+
datasets=[res], fname=output_folder / 'estia_reduced_iofq.ort'
136+
)

0 commit comments

Comments
 (0)