Assessment of easyreflectometry's ORSO file support against the
ORSO file format specification
and the ORSO simple model language,
compared with refnx, refl1d, and GenX.
reflectometry-lib reads the ORSO .ort format properly via orsopy and its reading support is on par with refnx and GenX and ahead of refl1d,
including the "simple model" language. Where it falls behind the other codes is
writing: it cannot export .ort files at all, and it has no support for the binary
.orb format.
What the library supports today
- Data columns: reads the four mandated columns Qz, R, sR, sQz; errors are stored
as variances and sQz get passed to the Pointwise resolution function
(falling back to 5% FWHM when absent). This matches how
refl1d/refnx treat dq.
- Multiple datasets per file : yes; each
# data_set: block becomes a separate
R_<name> / Qz_<name> pair, and Project.load_all_experiments_from_file converts
them into experiments.
- Header metadata: the full ORSO header is preserved as a dict; two things are
actively used: the experiment title, and the polarization channel (pp/pm/mp/mm).
- ORSO model language ("simple model") : supported for reading
including SLD unit conversion and density-only materials. Not handled
yet: globals overrides (length unit, default roughness) and magnetic fields in
the model language.
Comparison with other codes
| Capability |
reflectometry-lib |
refnx |
refl1d |
GenX |
Read .ort |
✅ (orsopy) |
✅ |
✅ (load4/parse_orso) |
✅ (auto loader) |
Write .ort |
❌ |
✅ |
not verified (no .ort writer found in load4.py; probe save targets refl1d's own format) |
✅ (full export incl. fit results) |
Read binary .orb |
❌ |
✅ (load_nexus fallback) |
✅ (load_nexus) |
unverified (likely ❌; GenX uses its own .hgx HDF) |
Convert 1/nm Q to internal unit |
❌ (unit stored, values passed raw) |
✅ (_data[0] /= 10.0) |
unverified |
— |
Honour value_is: FWHM on sQz |
❌ (assumes σ) |
❌ (assumes σ, ×2.3548 to FWHM) |
✅ (FWHM2sigma) |
— |
| Model language → model |
✅ (with caveats) |
✅ (slabs only, setup_analysis) |
❌ |
✅ (read + write) |
| Write model language back |
❌ |
✅ (update_model) |
❌ |
✅ (analysis section + params) |
| Polarization from header |
partial (dataset 0 only; cannot see per-dataset overrides) |
partial |
✅ (per dataset, incl. po/mo coercion) |
✅ |
| Instrument settings from header |
❌ |
partial |
✅ (angle/wavelength) |
✅ |
Reading is good, arguably the polarization-header handling is better than
refnx's. The clear parity gaps are export (refnx round-trips refined parameters
back into the .ort via OrsoDataset.update_model(); GenX exports the entire fit:
script, parameters, uncertainties, and an ORSO-model-language description) and
.orb (refnx reads/writes it; orsopy 1.2.2 already ships
load_nexus/save_nexus, so method exists but is unused). Model.as_orso()
in model/model.py:282-284 is currently a stub returning the EasyScience dict,
not ORSO.
Reader weaknesses found during the audit
- Positional column reading :
orso_utils.py:223-246 indexes data[:, 0..3]
unconditionally without validating column names against o.info.columns. A
spec-valid 3-column file (sQz is only recommended) raises IndexError, which
load() silently swallows and re-parses as plain text. The file loads but all
ORSO metadata (including polarization) is lost. A file with reordered columns
would be silently mis-read.
- Polarized multi-dataset files rejected :
Project.load_polarized_experiment
(project.py:770-774) requires one file per spin channel, so a single .ort
carrying all four channels as datasets (the format's intended way to store
polarized data) cannot be imported as a polarized experiment, even though each
dataset's header names its channel.
orsopy is unpinned in pyproject.toml:30 : worth at least a lower bound
(≥1.2) since the model-language API surface changed across versions.
- Minor :
Project.load_orso_file stores a raw scipp DataGroup where other
paths store DataSet1D, and instrument settings (wavelength, angles, probe) are
never propagated from the header.
Update tasks (revised priority)
| # |
Task |
Effort |
Impact |
| 1 |
Pin orsopy >= 1.2 in pyproject.toml |
Trivial |
Correctness for fresh installs |
| 2 |
Harden the reader: name-based column validation via info.columns; honour value_is (FWHM→σ); all-nan sQz → leave xe empty so _apply_resolution_function falls back to 5% FWHM; partial-nan sQz needs per-point handling (nan-mask and interpolate over valid points, with documented semantics) — not just the empty-column case; warn on (or remove) the silent resolve_to_layers() → resolve_stack() fallback; decide the Pointwise sigma-vs-variance serialization contract (saved projects round-trip sQz_data_points as variances — changing it silently breaks them; add a migration note); stop swallowing ORSO parse failures. Discriminator: the ORSO banner line (# # ORSO reflectivity data file), not the .ort extension — fall back to _load_txt only when the banner is absent, raise on a bannered file that fails to parse. Review the fallback policy of the three other call sites that tolerate ORSO failure by design: count_datasets_in_file, load_all_experiments_from_file, _apply_experiment_metadata. |
Small–Medium |
High — ends silent metadata loss and mis-smearing |
| 3 |
Project.load_orso_file: deletion is the default — it is unused by the main load_new_experiment path and is a documented footgun. Before deleting, check external callers (the GUI app consumes Project's public API); if one exists, deprecate and reimplement as a thin wrapper over load_new_experiment (DataSet1D + same title/resolution path). Add a model-bearing test through whichever path survives using Ni_example.ort — the existing test uses example.ort, a 0.1-standard file with no sample.model (legacy dimension: columns), so it exercises default_model(), not the ORSO path. |
Small |
Medium |
| 4 |
Preserve the original sample.model (keep globals, materials, sub_stacks); honour length_unit (default nm) and default roughness. Mechanism for repeats: resolve_to_layers() cannot preserve them — orsopy's SubStack.resolve_to_layers() ends with return layers * self.repetitions (verified in 1.2.2). Use resolve_to_blocks() (keeps SubStackType objects intact) and map SubStack with repetitions > 1 → ERL RepeatingMultilayer explicitly; keep density materials unflattened. |
Medium |
High — prerequisite for both correct reads and any exporter |
| 5 |
Polarized import from a single multi-dataset .ort: per-dataset header classification; defined error on ambiguity (two pp, mixed unpolarized); do not coerce po/mo/op/om; vector is explicitly out of scope (a declared-but-unmapped header value already suppresses the filename fallback and yields None — document, don't change); decide API ownership vs load_all_experiments_from_file (multi-spin vs multi-angle look identical at data_set: level); decide per-channel vs shared resolution before task 6, which depends on the answer; fix the repeated re-parsing (count_datasets_in_file + load_as_dataset + _apply_experiment_metadata each reload the file — up to 3-4 full parses per channel); add a real multi-channel fixture |
Medium |
High |
| 6 |
Slab-only .ort exporter: Sample → SampleModel (superphase | layers | subphase), SLD back-conversion to Å⁻², emit Qz/R/sR/sQz as sigma — i.e. sqrt(ye)/sqrt(xe), since DataSet1D stores variances; reuse the preserved orso_header (data_source/reduction) rather than synthesizing one; save_orso; replace the Model.as_orso() stub (verified never called in src/). Explicitly without a GenX-style analysis block (separate product decision). Export semantics must be decided: one .ort per experiment vs one file with N data_set: blocks — the reader critique (weakness #3) demands multi-dataset support, so the writer should be able to produce it too, at least for polarized experiments. Depends on tasks 4 and 5's resolution decision, or repeats round-trip as flattened slabs. |
Medium |
High — refnx-level write support |
| 7 |
.orb read via orsopy load_nexus (catch-up with refnx and refl1d); requires h5py — add as an [orb] extra or document the orsopy nexus extra. .orb write is task 6 with another extension. |
Small (read) |
Medium |
| 7a |
Add the five missing fixture classes to tests/_static/: nan-filled sQz, value_is: FWHM, 1/nm Q units, multi-channel polarized .ort, .orb. Each lands with the task that consumes it (2, 2, 2, 5, 7) but is listed here so the test debt is visible. |
Small |
Enables tasks 2/5/7 verification |
| 8 |
Deferred pending product decisions: magnetism from model language (gate on calculator_supports_magnetism), instrument-settings propagation (needs a home — ERL has no first-class instrument object), GenX-style analysis: export. The ORSO-target-version decision should be resolved first among these: orsopy hard-codes ORSO_VERSION = "1.2" and will stamp every written file with it regardless of what ERL documents. |
— |
— |
Sources
Assessment of
easyreflectometry's ORSO file support against theORSO file format specification
and the ORSO simple model language,
compared with refnx, refl1d, and GenX.
reflectometry-lib reads the ORSO
.ortformat properly viaorsopyand its reading support is on par with refnx and GenX and ahead of refl1d,including the "simple model" language. Where it falls behind the other codes is
writing: it cannot export
.ortfiles at all, and it has no support for the binary.orbformat.What the library supports today
as variances and sQz get passed to the
Pointwiseresolution function(falling back to 5% FWHM when absent). This matches how
refl1d/refnx treat dq.
# data_set:block becomes a separateR_<name>/Qz_<name>pair, andProject.load_all_experiments_from_fileconvertsthem into experiments.
actively used: the experiment title, and the polarization channel (
pp/pm/mp/mm).including SLD unit conversion and density-only materials. Not handled
yet:
globalsoverrides (length unit, default roughness) and magnetic fields inthe model language.
Comparison with other codes
.ortload4/parse_orso).ort.ortwriter found inload4.py; probe save targets refl1d's own format).orbload_nexusfallback)load_nexus).hgxHDF)1/nmQ to internal unit_data[0] /= 10.0)value_is: FWHMon sQzFWHM2sigma)setup_analysis)update_model)po/mocoercion)Reading is good, arguably the polarization-header handling is better than
refnx's. The clear parity gaps are export (refnx round-trips refined parameters
back into the
.ortviaOrsoDataset.update_model(); GenX exports the entire fit:script, parameters, uncertainties, and an ORSO-model-language description) and
.orb(refnx reads/writes it; orsopy 1.2.2 already shipsload_nexus/save_nexus, so method exists but is unused).Model.as_orso()in
model/model.py:282-284is currently a stub returning the EasyScience dict,not ORSO.
Reader weaknesses found during the audit
orso_utils.py:223-246indexesdata[:, 0..3]unconditionally without validating column names against
o.info.columns. Aspec-valid 3-column file (sQz is only recommended) raises
IndexError, whichload()silently swallows and re-parses as plain text. The file loads but allORSO metadata (including polarization) is lost. A file with reordered columns
would be silently mis-read.
Project.load_polarized_experiment(
project.py:770-774) requires one file per spin channel, so a single.ortcarrying all four channels as datasets (the format's intended way to store
polarized data) cannot be imported as a polarized experiment, even though each
dataset's header names its channel.
orsopyis unpinned inpyproject.toml:30: worth at least a lower bound(≥1.2) since the model-language API surface changed across versions.
Project.load_orso_filestores a raw scippDataGroupwhere otherpaths store
DataSet1D, and instrument settings (wavelength, angles, probe) arenever propagated from the header.
Update tasks (revised priority)
orsopy >= 1.2inpyproject.tomlinfo.columns; honourvalue_is(FWHM→σ); all-nan sQz → leavexeempty so_apply_resolution_functionfalls back to 5% FWHM; partial-nan sQz needs per-point handling (nan-mask and interpolate over valid points, with documented semantics) — not just the empty-column case; warn on (or remove) the silentresolve_to_layers()→resolve_stack()fallback; decide thePointwisesigma-vs-variance serialization contract (saved projects round-tripsQz_data_pointsas variances — changing it silently breaks them; add a migration note); stop swallowing ORSO parse failures. Discriminator: the ORSO banner line (# # ORSO reflectivity data file), not the.ortextension — fall back to_load_txtonly when the banner is absent, raise on a bannered file that fails to parse. Review the fallback policy of the three other call sites that tolerate ORSO failure by design:count_datasets_in_file,load_all_experiments_from_file,_apply_experiment_metadata.Project.load_orso_file: deletion is the default — it is unused by the mainload_new_experimentpath and is a documented footgun. Before deleting, check external callers (the GUI app consumesProject's public API); if one exists, deprecate and reimplement as a thin wrapper overload_new_experiment(DataSet1D+ same title/resolution path). Add a model-bearing test through whichever path survives usingNi_example.ort— the existing test usesexample.ort, a 0.1-standard file with nosample.model(legacydimension:columns), so it exercisesdefault_model(), not the ORSO path.sample.model(keepglobals,materials,sub_stacks); honourlength_unit(default nm) and default roughness. Mechanism for repeats:resolve_to_layers()cannot preserve them — orsopy'sSubStack.resolve_to_layers()ends withreturn layers * self.repetitions(verified in 1.2.2). Useresolve_to_blocks()(keepsSubStackTypeobjects intact) and mapSubStackwithrepetitions > 1→ ERLRepeatingMultilayerexplicitly; keep density materials unflattened..ort: per-dataset header classification; defined error on ambiguity (twopp, mixed unpolarized); do not coercepo/mo/op/om;vectoris explicitly out of scope (a declared-but-unmapped header value already suppresses the filename fallback and yields None — document, don't change); decide API ownership vsload_all_experiments_from_file(multi-spin vs multi-angle look identical atdata_set:level); decide per-channel vs shared resolution before task 6, which depends on the answer; fix the repeated re-parsing (count_datasets_in_file+load_as_dataset+_apply_experiment_metadataeach reload the file — up to 3-4 full parses per channel); add a real multi-channel fixture.ortexporter:Sample → SampleModel(superphase | layers | subphase), SLD back-conversion to Å⁻², emit Qz/R/sR/sQz as sigma — i.e.sqrt(ye)/sqrt(xe), sinceDataSet1Dstores variances; reuse the preservedorso_header(data_source/reduction) rather than synthesizing one;save_orso; replace theModel.as_orso()stub (verified never called insrc/). Explicitly without a GenX-styleanalysisblock (separate product decision). Export semantics must be decided: one.ortper experiment vs one file with Ndata_set:blocks — the reader critique (weakness #3) demands multi-dataset support, so the writer should be able to produce it too, at least for polarized experiments. Depends on tasks 4 and 5's resolution decision, or repeats round-trip as flattened slabs..orbread via orsopyload_nexus(catch-up with refnx and refl1d); requiresh5py— add as an[orb]extra or document the orsopy nexus extra..orbwrite is task 6 with another extension.tests/_static/: nan-filled sQz,value_is: FWHM,1/nmQ units, multi-channel polarized.ort,.orb. Each lands with the task that consumes it (2, 2, 2, 5, 7) but is listed here so the test debt is visible.calculator_supports_magnetism), instrument-settings propagation (needs a home — ERL has no first-class instrument object), GenX-styleanalysis:export. The ORSO-target-version decision should be resolved first among these: orsopy hard-codesORSO_VERSION = "1.2"and will stamp every written file with it regardless of what ERL documents.Sources