Introduction
NEREIDS (Neutron rEsonance REsolved Imaging Data Analysis System) is a Rust-based library for neutron resonance imaging at the VENUS beamline, Spallation Neutron Source (SNS), Oak Ridge National Laboratory (ORNL).
What It Does
NEREIDS provides end-to-end analysis for time-of-flight (TOF) neutron resonance imaging: input hyperspectral TOF data, output spatially resolved isotopic and elemental composition maps.
The analysis pipeline:
- Load raw TOF imaging data (TIFF stacks or NeXus/HDF5 counts)
- Configure isotopes of interest using ENDF nuclear data
- Fit the counts-domain resonance model to the sample and open-beam counts
- Map fitted parameters (areal density, temperature) across each pixel
Interfaces
NEREIDS ships in several interfaces:
| Deliverable | Use case |
|---|---|
Rust library (nereids-* crates) | Embed in Rust applications, maximum performance |
Python bindings (pip install nereids) | Jupyter notebooks, scripting, integration with NumPy/SciPy |
Desktop GUI (nereids-gui) | Interactive analysis with visual feedback |
MCP server (nereids-mcp) | Local AI-agent assisted analysis through manifest-driven workflows |
Relationship to SAMMY
NEREIDS implements the same physics as SAMMY (a Fortran code for multilevel R-matrix analysis of neutron data, ORNL/TM-9179/R8), rewritten in Rust with modern tooling. All physics modules reference specific SAMMY source files and equation numbers in their documentation.
Key formalisms from SAMMY:
- Reich-Moore R-matrix (LRF=3)
- Breit-Wigner, single- and multi-level (LRF=1/2)
- Free Gas Model Doppler broadening
The Pipeline Map describes the physics NEREIDS implements and where the code does not yet follow it.
Next Steps
- Install NEREIDS for your platform
- Try the Rust quickstart or Python quickstart
- Explore the GUI walkthrough for interactive analysis
Pipeline Map (interactive)
The interactive pipeline map is the physics-first design contract for NEREIDS and the recommended starting point for understanding the analysis: the physics spine (resonances → Doppler → Beer-Lambert → time-of-flight → instrument kernel → counts), the four fitting modes run live on-screen, the data taxonomy, and the API contract the code is reconciled against.
It is a standalone HTML page (open it in a browser; it needs no server). It is the single description of the physics NEREIDS implements, and it says plainly which parts are not implemented yet.
Installation
Rust Library
Add the top-level orchestration crate (re-exports all lower-level crates):
[dependencies]
nereids-pipeline = "0.1"
Or add individual crates (nereids-core, nereids-endf, nereids-physics,
nereids-fitting, nereids-io) for finer-grained dependency control.
Requirements: Rust edition 2024 (rustc 1.85+).
Optional: HDF5 support
The nereids-io crate has an optional hdf5 feature for NeXus file support:
[dependencies]
nereids-io = { version = "0.1", features = ["hdf5"] }
This requires the HDF5 C library to be installed on your system.
Python Bindings
pip install nereids
Requirements: Python 3.10+ and NumPy.
Optional extras published by the nereids package:
pip install "nereids[mcp]" # installs the MCP server dependency
pip install "nereids[gui]" # pulls in the GUI wheel package when available
MCP Server
The MCP server is installed as an optional Python extra:
pip install "nereids[mcp]"
nereids-mcp
See the MCP server chapter for client configuration and manifest-driven workflows.
Desktop GUI
Python Wheel
pip install "nereids[gui]"
nereids-gui
The [gui] extra pulls in the separately-published nereids-gui wheel,
which is what provides the nereids-gui console script (it is not declared
in the base nereids package). If the install resolves but nereids-gui
is not found on PATH, the nereids-gui wheel has not been published for
your platform/Python version — verify with:
which nereids-gui # should print a path; empty output means missing
pip show nereids-gui # should print metadata; "not installed" means the
# extra resolved a different way
You can also install the GUI distribution directly:
pip install nereids-gui
nereids-gui
macOS (Homebrew)
brew tap ornlneutronimaging/nereids
brew install --cask nereids
From Source
git clone https://github.com/ornlneutronimaging/NEREIDS.git
cd NEREIDS
cargo run --release -p nereids-gui
Building from source requires CMake (for HDF5) and a Rust toolchain.
Linux system dependencies
The Linux wheel is built for manylinux_2_28, so it runs on any
x86_64 distribution with glibc ≥ 2.28: RHEL/AlmaLinux/Rocky 8+,
Ubuntu 20.04+, Debian 10+, Fedora 29+, and newer.
File dialogs use a three-tier chain with no hard system dependencies:
- XDG desktop portal (
org.freedesktop.portal.FileChooserover D-Bus) — native dialogs on any desktop session (GNOME, KDE, …). Preinstalled on every mainstream desktop, including RHEL 8’s GNOME. - zenity — automatic fallback when no portal is reachable.
Recommended for
ssh -Xsessions and containers:sudo dnf install zenity/sudo apt-get install zenity. - Built-in file browser — rendered by the GUI itself, works in every environment (root, containers, no D-Bus). Selected automatically when neither portal nor zenity is available; the GUI shows a banner saying so.
The rest of the UI is the standard egui/winit/GL stack. Desktop Linux distros ship these; minimal / container / server installs may not:
Debian / Ubuntu (apt):
sudo apt-get install -y \
libxcursor1 libx11-xcb1 libxi6 libxrandr2 \
libxinerama1 libxxf86vm1 libxkbcommon-x11-0 libwayland-client0 \
libgl1 libgl1-mesa-dri libegl1
libgl1-mesa-dri is needed even with LIBGL_ALWAYS_SOFTWARE=1
(below) because the software rasteriser is shipped as a Mesa DRI
driver.
Fedora / RHEL (dnf):
sudo dnf install -y \
libXcursor libXi libXrandr libXinerama libxkbcommon-x11 \
libwayland-client libwayland-cursor \
mesa-libGL mesa-libEGL mesa-dri-drivers
No GTK packages and no development headers are required — neither at runtime nor for building from source (the dialog stack has no build-time system libraries; only CMake for HDF5, as noted above).
Headless / Docker / VM fallback:
If the GUI fails at startup with a GL initialisation error (common in
Docker without GPU passthrough, or over SSH-X without GLX), force
software rasterisation by setting LIBGL_ALWAYS_SOFTWARE=1 before
launching the GUI:
export LIBGL_ALWAYS_SOFTWARE=1
cargo run --release -p nereids-gui # from source
# or, if installed as a binary:
nereids-gui
Development Setup
For contributors working on NEREIDS itself:
git clone https://github.com/ornlneutronimaging/NEREIDS.git
cd NEREIDS
# Build everything
cargo build --workspace
# Run tests
cargo test --workspace --exclude nereids-python
# Build Python bindings (requires pixi)
pixi run build
pixi run test-python
See Contributing for the full development workflow.
Quickstart: Rust
Add the crates you need:
[dependencies]
nereids-core = "0.3"
nereids-endf = "0.3"
nereids-physics = "0.3"
nereids-pipeline = "0.3"
The fitting entry point is nereids_pipeline::counts_fit::fit_counts. It takes
- a
Measurement: raw open-beam and sample counts on the same time-of-flight bin edges, the sample run’s proton-charge ratio, a normalization, a time background, and one or more regions, each with its background, pixel count, gamma transmission and material; - a
Calibration:t0, the flight path and the Ikeda-Carpenter pulse.
It returns a CountsFit carrying the fitted areal densities, temperatures,
backgrounds, timing, pulse numbers and covariance, plus the deviance and a
convergence flag. nereids_pipeline::counts_map::fit_map fits patches of
pixels as one counts fit.
Inputs come from nereids_endf (resonance data), nereids_io (raw counts)
and nereids_pipeline::open_beam::fit_open_beam (the incident beam fitted to
the open-beam counts). The counts_fit integration tests under
crates/nereids-pipeline/tests/ are a worked end-to-end example.
Quickstart: Python
This example uses the NEREIDS Python bindings to load ENDF data, compute a forward model, and fit raw open-beam and sample counts in the time domain.
Setup
pip install nereids numpy matplotlib
Load ENDF Data and Compute Transmission
import nereids
import numpy as np
# Load ENDF/B-VIII.1 resonance data for U-238
u238 = nereids.load_endf(92, 238)
print(f"U-238: {u238.n_resonances} resonances, AWR = {u238.awr:.1f}")
# Energy grid: 1 to 30 eV
energies = np.linspace(1.0, 30.0, 2000)
# Compute transmission for 0.001 atoms/barn at 300 K
transmission = nereids.forward_model(
energies,
[(u238, 0.001)],
temperature_k=300.0,
flight_path_m=25.0,
delta_t_us=5.0,
delta_l_m=0.005,
)
Plot the Spectrum
import matplotlib.pyplot as plt
plt.figure(figsize=(10, 4))
plt.plot(energies, transmission, linewidth=0.8)
plt.xlabel("Energy (eV)")
plt.ylabel("Transmission")
plt.title("U-238 Forward Model (0.001 at/barn, 300 K)")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Counts Fitting
Use the original detector time-bin edges in microseconds. Do not reconstruct them from energy centers. Build the measurement and fit the material:
edges = np.asarray(time_edges_us, dtype=np.float64)
region = nereids.Region(
open_counts=open_beam_counts_1d,
sample_counts=sample_counts_1d,
pixels=1.0,
background=[nereids.Value.known(0.0)] * 3,
gamma_transmission=nereids.Value.known(1.0),
material=nereids.Material(
isotopes=[(u238, nereids.Value.fitted(0.0005))],
temperature_k=nereids.Value.known(300.0),
),
)
measurement = nereids.Measurement(
time_edges_us=edges,
charge_ratio=sample_charge / open_beam_charge,
normalization=nereids.Value.fitted(1.0),
regions=[region],
)
calibration = nereids.Calibration(
t0_us=nereids.Value.known(3.0),
flight_path_m=nereids.Value.known(25.0),
pulse=nereids.Pulse(
alpha0=nereids.Value.known(0.35),
alpha1=nereids.Value.known(0.05),
beta0=nereids.Value.known(0.25),
beta1=nereids.Value.known(0.0),
r=nereids.Value.known(0.15),
fwhm_squared_us2=nereids.Value.known(0.0),
energy_span_ev=(1.0, 200.0),
),
)
fit = nereids.fit_counts(measurement, calibration)
print(fit.regions[0].densities, fit.deviance, fit.converged)
Every fitted quantity is a Value: known holds it, fitted frees it from a start, within bounds it, and measured gives it a Gaussian prior. The pulse is the moderator model; a tabulated instrument resolution is not part of this engine.
Counts Mapping
For imaging data, fit every patch of a counts cube in one fit:
map_measurement = nereids.MapMeasurement(
time_edges_us=edges,
open_counts=open_beam_counts_3d, # (n_bins, height, width)
sample_counts=sample_counts_3d, # (n_bins, height, width)
excluded=np.zeros((height, width), dtype=bool),
sample=np.ones((height, width), dtype=bool),
empty=np.zeros((height, width), dtype=bool),
material=material,
charge_ratio=1.0,
normalization=nereids.Value.fitted(1.0),
background=[nereids.Value.known(0.0)] * 3,
binning=1,
)
counts_map = nereids.fit_map(map_measurement, calibration)
print(counts_map.densities[0].shape, counts_map.converged)
See the Python API reference for argument details, shape contracts, and result objects.
TIFF and NeXus Data
TIFF and NeXus loaders use the spectral axis first:
stack = nereids.load_tiff_stack("counts_stack.tif")
sample = nereids.load_nexus_histogram("sample.nxs")
open_beam = nereids.load_nexus_histogram("open_beam.nxs")
edges = sample.tof_edges_us
The Data I/O and NeXus/TOF chapter covers the full workflow.
Next Steps
- See the GUI walkthrough for interactive analysis
- Use the MCP server for local AI-agent assisted workflows
- Browse the Python API reference
- Read Data I/O and NeXus/TOF for TIFF, NeXus, and TOF data handling
- Explore the Architecture chapter for the crate structure
- Browse the API Reference for the full Rust docs
Python API Reference
The nereids Python package is a PyO3 layer over the Rust pipeline. This
page is a curated narrative reference covering the typed APIs Python users
reach for most often, with argument tables, array-shape contracts, and
dispatch rules.
For the exhaustive auto-generated reference (every function, class, and
attribute exported by the package), see the generated Python API
reference built by pdoc from the
installed wheel and the shipped nereids/__init__.pyi type stubs.
Install the base package with:
pip install nereids
Optional extras are:
pip install "nereids[mcp]" # MCP server console script
pip install "nereids[gui]" # GUI wheel dependency, when available for your platform
Data Objects
ResonanceData
Returned by load_endf(...), load_endf_file(...), and
create_resonance_data(...).
Important properties:
| Property | Type | Meaning |
|---|---|---|
z | int | Atomic number. |
a | int | Mass number. |
awr | float | Atomic weight ratio. |
n_resonances | int | Resonance count across parsed ranges. |
target_spin | float | Target spin from the first evaluable range (parse-and-skip placeholders are skipped). |
scattering_radius | float | Effective scattering radius in fm, from the first evaluable range. |
l_values | list[int] | Orbital angular momentum values present in the data. |
has_unevaluated_ranges | bool | True when the file carries parsed-but-not-evaluated spans (URR, LRU=0, or LRF=1/2 with a competitive width, LRX=1); their resonances contribute no cross section. |
skipped_ranges | list[str] | One description per parsed-but-not-evaluated span. |
Counts Fit Objects
A counts fit is built from raw open-beam and sample counts binned on shared time-bin edges (microseconds), not an energy grid.
| Object | Meaning |
|---|---|
| Value | One fitted quantity: known, fitted from a start, within bounds, or measured with a Gaussian prior. |
| Material | Isotopes each with an areal density in atoms/barn, and a temperature in K. |
| Region | Both runs’ counts for one set of pixels, its background [b0, b1, b2, d, f], its area in pixels, the gamma transmission, and the material. |
| RoomBackground | Counts per microsecond per pixel per frame that follow each run’s duration, and each run’s frames. |
| Spread | The share of each detector cell’s counts recorded elsewhere, the spread kernel, and each region’s cells. |
| Measurement | The time-bin edges, charge ratio, normalization, time and room backgrounds and regions of one fit. |
| Pulse | Ikeda-Carpenter pulse parameters and the energy span they hold over. |
| Calibration | Timing offset t0 (microseconds), flight path (m), and Pulse. |
| RegionFit | One region’s fitted densities, temperature, background, predicted counts and overdispersion. |
| CountsFit | Every region’s RegionFit plus the shared normalization, t0, flight path, pulse numbers, covariance and deviance. |
| Patch | What a patch’s non-excluded pixels lie behind: sample, mixed, or outside. |
| MapMeasurement | A cube of both runs’ counts, the sample/empty/excluded masks, the material and the patch side. |
| CountsMap | Per-patch fits, densities, density standard deviations, temperature and shared covariance. |
NO_TIME_BACKGROUND is a module tuple of three known Values, [0, 0, 1], that Measurement and MapMeasurement accept as the time background.
Value
Value.known(value), Value.fitted(start), Value.within(start, lower, upper) and Value.measured(value, sd) build the four kinds. The kind property returns “known”, “fitted”, “within” or “measured”; value, start, lower, upper and sd return the matching fields (None where a kind has no such field).
PhononSpectrum
PhononSpectrum(step_ev, density) is a crystal’s phonon density of states: values every step_ev eV from 0, the first 0, quadratic in the energy up to the first point and linear between the others, scaled to unit area. PhononSpectrum.debye(theta_k, points=1000) is Debye’s, 3w2/w_D3 up to w_D = ktheta_k. max_ev is the highest phonon energy, and effective_temperature_k(temperature_k) the free gas’s temperature whose broadening has the lattice’s variance.
Material
Material(isotopes, temperature_k, library=“endf8.1”, thickness_variance=None, lattice=None, covariances=[]) takes a list of (ResonanceData, Value) pairs, each density in atoms/barn, a temperature Value in K, and the relative variance v of the areal density over the region’s pixels, known 0 by default: the region transmits (1 + v*D)**(-1/v), the mean for a Gamma-distributed areal density, D the optical depth at the mean density. lattice is the PhononSpectrum the nuclei vibrate with: the cross sections are broadened by the harmonic lattice and temperature_k is the thermodynamic temperature. None broadens by the free gas, for which a solid’s temperature is its effective one. covariances pairs an index into isotopes with that isotope’s ResonanceCovariance, which the fit’s covariance carries.
ResonanceCovariance
ResonanceCovariance holds the covariance of the energy, neutron width and capture width, in that order, of each listed resonance of one evaluation. resonances lists them as (range, L-group, resonance) indices into the ResonanceData it was read with, and covariance is the matrix over the 3 * len(resonances) parameters, in eV**2.
load_resonance_covariance(data, window_ev, path=None, library=“endf8.1”, mat=None) reads the File 32 covariance of the energy, neutron width and capture width of data’s Reich-Moore resonances between window_ev = (low, high) eV, from the ENDF file at path or from the library’s evaluation of data’s isotope, and returns None when the evaluation has none. Read it with the same evaluation as data: its resonances are indices into data.
Region
Region(open_counts, sample_counts, pixels, background, gamma_transmission, material=None, open_live=None, sample_live=None, capture_gammas=None, in_scatter=None, timing_offset_us=None) holds one region. background is b(E) = b0 + b1/sqrt(E) + b2sqrt(E) + dexp(-f/sqrt(E)) as five Values [b0, b1, b2, d, f], f 0 or more; three Values [b0, b1, b2] hold d and f at 0. The optional live fractions are per bin in (0, 1]. capture_gammas is the share epsilon of the sample’s captures whose gammas the region records, known 0 by default; the sample run gains c_qaepsilonphi(1 - T)sum(nsigma_gamma)/D. in_scatter is the share Omega of the neutrons scattered once in the sample and leaving through its back face that the region records, known 0 by default; the sample run gains c_qaOmegaphisum(nsigma_sG), G each isotope’s share of single scatters leaving through the back face at the mean areal density. timing_offset_us is dt in microseconds, known 0 by default: the region’s pixels record every count dt later than t0’s clock, a neutron of flight time u at t0 + dt + u and the time background dt late. A fitted dt needs t0 or another region’s dt known or measured, and a detector spread needs every region’s dt known and the same.
Measurement
Measurement(time_edges_us, charge_ratio, normalization, regions, time_background=None, room_background=None, spread=None) holds one open-beam/sample run pair. time_edges_us are the shared time-bin edges in microseconds (length = bins + 1), charge_ratio is the sample run’s proton charge over the open-beam run’s, and normalization is the sample run’s normalization a. time_background is [C, A, tau] or NO_TIME_BACKGROUND. room_background is RoomBackground(rate, frames) or None: rate counts per microsecond per pixel per frame in both runs, not scaled by the charge ratio, the normalization or the gamma transmission, and frames the (open-beam, sample) frames. spread is Spread(share, kernel, grid, cells) or None: region r records (1 - h)mu_r + hsum(F_rqmu_q) of the counts mu arriving in the regions, F_rq the share of region q’s spread counts recorded in region r’s cells, for the kernel’s (weight, length in cells) terms sum(wexp(-r/s)/(2pis**2)) on a grid of (rows, columns) cells.
Pulse
Pulse(alpha0, alpha1, beta0, beta1, r, fwhm_squared_us2, energy_span_ev, n_tau=600, line_span_ev=None, r_high=None, detector_tail=None): the Ikeda-Carpenter laws alpha = alpha0sqrt(E) + alpha1 and beta = beta0sqrt(E) + beta1 in 1/microsecond, the storage fraction R over energy_span_ev (eV), or linear in sqrt(E) from R at its low end to r_high at its high end when r_high is given, and the proton triangle with fwhm_squared_us2 equal to h^2 in microseconds squared. detector_tail is (f, tau_D): the detector records a fraction f of the counts late by an exponential delay of mean tau_D microseconds; None records none late. Each parameter is a Value so it can be held or fitted.
Calibration
Calibration(t0_us, flight_path_m, pulse) gives the timing offset in microseconds and the flight path in metres. Both are Values, known or fitted. Calibration.from_pulse_calibration_file(path) reads a pulse calibration file: t0 and the flight path are fitted from the calibrated ones, and the fitted pulse numbers are fitted from theirs under the calibration’s prior, its mean and covariance (Pulse.has_prior); the others are known.
RegionFit and CountsFit
RegionFit.densities are the fitted areal densities in atoms/barn, temperature_k the temperature in K (None without a material), predicted_open and predicted_sample the counts per bin the fit predicts, overdispersion the [open-beam, sample] values (None when unmeasured), excluded_bins the sample bins the material’s library leaves out, and timing_offset_us the region’s timing offset in microseconds.
CountsFit.regions holds one RegionFit per region. normalization, t0_us, flight_path_m, alpha, beta, r, fwhm_squared_us2, r_high and detector_tail (each None without one), time_background and room_background are the shared fitted values; covariance is the fitted-quantity covariance (None when the fit did not converge), unbounded_mean and unbounded_covariance the same Gaussian without the bounds, on_bound the bounds flags, deviance the half Poisson deviance over the overdispersion, and converged the fit status. measured_pulls and pulse_consistency are None when unavailable.
Patch, MapMeasurement and CountsMap
Patch.kind is “sample”, “mixed” or “outside”. MapMeasurement(time_edges_us, open_counts, sample_counts, excluded, sample, empty, material, charge_ratio, normalization, background, binning=1, empty_background=None, open_live=None, sample_live=None, time_background=None, gamma_transmission=None, room_background=None) holds the (time bin, row, column) counts and the (row, column) masks; its patches() method returns each patch’s kind. time_background is [C, A, tau] per pixel, shared by every patch, or NO_TIME_BACKGROUND by default; gamma_transmission is that of every sample patch, known 1 by default, and the empty pixels take 1.
CountsMap exposes the per-patch fits (fits, failed, empty), density maps densities and density_sd in atoms/barn, temperature_k and temperature_sd_k in K, the two overdispersion maps, identified, and the shared normalization, time_background, room_background, t0_us, flight_path_m, alpha, beta, r, fwhm_squared_us2, r_high, detector_tail, shared, shared_covariance, deviance and converged. n_fitted counts the fitted patches.
quantities names a patch’s fitted quantities. covariance((row, column)) is that patch’s covariance of them, the shared quantities’ uncertainty included, and sensitivity((row, column)) their change per unit change of each shared quantity. cross_covariance(a, b) is the covariance of patch a’s quantities with patch b’s that the shared quantities carry. Each returns None where a patch has no fit; covariance and cross_covariance also return None where the map has no shared covariance.
identified is True where the map converged, the patch did not fail, and each fitted density and the fitted temperature has a finite standard deviation, or, for a density, is 0. Whether the model describes a patch’s counts is its overdispersion; neither says whether the values are right.
NexusData
Returned by load_nexus_histogram(...) and load_nexus_events(...).
| Property | Type | Meaning |
|---|---|---|
counts | NDArray[float64] | Counts cube with shape (n_tof, height, width). |
tof_edges_us | NDArray[float64] | TOF bin edges in microseconds, length n_tof + 1. |
flight_path_m | float or None | Flight path from NeXus metadata when available. |
dead_pixels | NDArray[bool_] or None | Dead-pixel mask, True means dead. |
n_rotation_angles | int | Number of rotation angles in histogram input. |
event_total, event_kept | int or None | Event loader statistics. |
ENDF Loading
u238 = nereids.load_endf(92, 238, library="endf8.1")
u238_local = nereids.load_endf_file("examples/data/u238_ex027.endf")
load_endf(...) fetches and caches evaluated nuclear data. Supported library
names include endf8.0, endf8.1, jeff3.3, jendl5, tendl2023, and
cendl3.2. First use can require network access; cached files are reused
afterwards. load_endf_file(...) parses a local ENDF file and does not
download data.
Both loaders evaluate resolved LRF=3 ranges, LRF=1/2 ranges without a
competitive width (LRX=0), and LRF=7 ranges in the Reich-Moore form (KRM=3)
without fission pairs. An LRF=7 range with an unsupported option raises
ValueError. So does a file with no evaluable range (for example one
whose only range is unresolved) — loading it would yield zero cross-section
everywhere. A mixed
evaluation loads, emits a UserWarning naming the skipped spans, and flags
them via has_unevaluated_ranges / skipped_ranges.
Forward Modeling
import numpy as np
import nereids
u238 = nereids.load_endf(92, 238)
energies = np.linspace(1.0, 30.0, 2000)
transmission = nereids.forward_model(
energies,
[(u238, 0.001)],
temperature_k=300.0,
flight_path_m=25.0,
delta_t_us=5.0,
delta_l_m=0.005,
)
forward_model(...) returns a 1D float64 transmission spectrum on the
input energy grid. Pass either isotopes=[(ResonanceData, density), ...] or
groups=[(IsotopeGroup, density), ...], but not both. Gaussian resolution is
enabled by the flight_path_m, delta_t_us, and delta_l_m parameters.
Tabulated resolution can be supplied with resolution=load_resolution(...).
delta_t_us and delta_l_m are W-parameters, the width in
exp(-x^2/W^2), not standard deviations.
The conversions are sigma = W/sqrt(2) and FWHM = 2*sqrt(ln 2)*W = 1.6651*W.
This is SAMMY’s convention, shared with the Doppler width, so the two kernels
compose without a conversion.
A measured 1-sigma timing jitter must be converted before it is passed here;
supplying it unconverted gives a kernel 29 % too narrow, and a resolution that
is too narrow is absorbed into a fitted temperature that is too high.
Two helpers do the conversion, so no one has to scale by hand:
| Function | Converts | Formula |
|---|---|---|
width_from_sigma(sigma) | a standard deviation | W = sigma * sqrt(2) |
width_from_fwhm(fwhm) | a full width at half maximum | W = FWHM / (2*sqrt(ln 2)) |
resolution = nereids.resolution_broaden(
energies,
cross_sections,
flight_path_m=25.0,
delta_t_us=nereids.width_from_sigma(0.5), # 0.5 us measured 1-sigma
delta_l_m=nereids.width_from_sigma(2e-3),
)
width_from_fwhm is also the conversion SAMMY’s Deltag needs, so a timing
width read from a SAMMY .inp can be passed straight through it.
Counts Fitting
fit_counts(measurement, calibration) fits the areal densities and temperatures of the regions’ materials, each region’s background terms and gamma transmission, the normalization, the time background, and the timing offset, flight path and pulse numbers that are not known, to the raw counts of both runs.
import numpy as np
import nereids
u238 = nereids.load_endf(92, 238)
edges = np.linspace(200.0, 400.0, 401) # microseconds, bins + 1
open_counts = np.array([...]) # one per bin
sample_counts = np.array([...])
region = nereids.Region(
open_counts=open_counts,
sample_counts=sample_counts,
pixels=1.0,
background=[nereids.Value.known(0.0)] * 3,
gamma_transmission=nereids.Value.known(1.0),
material=nereids.Material(
isotopes=[(u238, nereids.Value.fitted(0.001))],
temperature_k=nereids.Value.fitted(293.6),
),
)
measurement = nereids.Measurement(
time_edges_us=edges,
charge_ratio=1.0,
normalization=nereids.Value.fitted(1.0),
regions=[region],
)
pulse = nereids.Pulse(
alpha0=nereids.Value.known(0.35),
alpha1=nereids.Value.known(0.05),
beta0=nereids.Value.known(0.25),
beta1=nereids.Value.known(0.0),
r=nereids.Value.known(0.15),
fwhm_squared_us2=nereids.Value.known(0.0),
energy_span_ev=(1.0, 200.0),
)
calibration = nereids.Calibration(
t0_us=nereids.Value.known(3.0),
flight_path_m=nereids.Value.known(25.0),
pulse=pulse,
)
result = nereids.fit_counts(measurement, calibration)
print(result.regions[0].densities, result.deviance, result.converged)
The measurement is keyed on time-bin edges in microseconds; convert to or from an energy axis with tof_to_energy and energy_to_tof when needed. Every Value is either held (known) or fitted, so a fit can free or fix each density, temperature, background term, normalization, t0, flight path and pulse number independently. The material’s library controls which sample bins are left out of the likelihood. A tabulated instrument resolution is not part of this engine: the pulse is the moderator model.
Counts Mapping
fit_map(map, calibration) fits one material across patches of pixels as one counts fit, with the empty pixels pinning the normalization. Build the MapMeasurement from (time bin, row, column) open-beam and sample counts, the (row, column) excluded/sample/empty masks, the material, and the patch side.
map_measurement = nereids.MapMeasurement(
time_edges_us=edges,
open_counts=open_cube, # (n_bins, height, width)
sample_counts=sample_cube, # (n_bins, height, width)
excluded=np.zeros((height, width), dtype=bool),
sample=np.ones((height, width), dtype=bool),
empty=np.zeros((height, width), dtype=bool),
material=material,
charge_ratio=1.0,
normalization=nereids.Value.fitted(1.0),
background=[nereids.Value.known(0.0)] * 3,
binning=1,
)
counts_map = nereids.fit_map(map_measurement, calibration)
print(counts_map.densities[0].shape, counts_map.converged)
CountsMap.densities holds one (patch row, patch column) map per isotope in atoms/barn; density_sd holds the matching standard deviations. Patches of kind “mixed” and “outside” are not fitted.
Pixel Masks
Pixel masks exist only to exclude pipeline-corrupting pixels (dead or
hot/railed detector defects). They are not a data-quality or coverage
filter: low-count pixels are alive and must be kept (the KL-domain fitters
handle them), and coverage/thickness inhomogeneity is a model concern, not a
masking concern. Downstream, a masked pixel is hard-excluded — never
fitted, NaN in the result maps. Masks are (height, width) boolean
arrays, True = exclude, and feed directly into
the excluded mask of a MapMeasurement.
# Recommended entry point: dead ∪ hot over sample AND open beam.
mask = nereids.detect_bad_pixels(sample, open_beam=open_beam)
# Individual criteria:
dead = nereids.detect_dead_pixels(sample) # exactly zero in every TOF bin
hot = nereids.detect_hot_pixels(sample, k_mad=6.0) # railed/hot point defects
gone = nereids.detect_dead_pixels_chunked([chunk_a, chunk_b]) # intermittent deadness
excluded = mask # pass as MapMeasurement(excluded=...)
detect_bad_pixels(sample, open_beam=None, hot_k_mad=6.0)
Union mask dead(sample) ∪ hot(sample) [∪ dead(ob) ∪ hot(ob)] — deadness
and hotness are per-acquisition, so a mask built from one stack alone misses
failures in the other. This is the validating entry point (rejects
non-finite/negative counts and empty TOF axes with ValueError).
hot_k_mad=None disables the hot screen (dead-only mask).
detect_dead_pixels(data)
Legacy single-stack detector: flags pixels that are exactly 0.0 in every
TOF bin. Assumes counts already validated finite and non-negative; prefer
detect_bad_pixels for new code.
detect_hot_pixels(data, k_mad=6.0)
Two-stage hot/railed screen on raw counts: a global robust cut on log
total counts (median + k_mad · max(1.4826 · MAD, Poisson floor)) nominates
candidates, and a local 8-neighbor confirmation (≥ 10× the neighbors’
median, iterated to a fixpoint) keeps contiguous bright scene regions —
open-beam areas, slit apertures — unmasked while catching isolated point,
line, and small-cluster defects. Pass raw detected counts, not
proton-charge-normalized rates or transmission ratios: scaling breaks the
Poisson floor.
detect_dead_pixels_chunked(chunks)
Intermittent deadness: a pixel dead for part of the acquisition is invisible in a summed stack (uniformly reduced counts, no zeros). Given per-chunk stacks (e.g. per-run splits or event data re-histogrammed in time windows), flags pixels all-zero in any chunk. Chunk so that live pixels expect λ ≥ 20 counts each (false-flag probability ≤ m·e^(−λ)). Spatial dims must match across chunks; the TOF axis may differ.
TIFF and NeXus I/O
stack = nereids.load_tiff_stack("transmission_stack.tif", pixel_policy="allow")
folder_stack, info = nereids.load_tiff_folder(
"frames",
pattern="frame_*.tif",
sum_chunks=True, # sum chunked VENUS runs element-wise (default)
pixel_policy="reject", # "reject" | "clip" | "allow" (default "reject")
return_info=True, # also return the load-provenance dict
)
info["n_chunks"] # DAQ chunks detected (1 if not chunked)
info["chunks_summed"] # True when they were summed element-wise
info["n_clipped_pixels"] # pixels clamped under pixel_policy="clip"
# full key set: n_files, n_chunks, chunk_ids, chunks_summed,
# n_clipped_pixels, chunk_inconsistent, n_unrecognized_files,
# unrecognized_examples
edges_us = nereids.read_tof_sidecar(
"run_764/run_764_Spectra.txt",
n_frames=folder_stack.shape[0],
)
sample = nereids.load_nexus_histogram("sample.nxs")
open_beam = nereids.load_nexus_histogram("open_beam.nxs")
energies = nereids.tof_to_energy_centers(
sample.tof_edges_us,
sample.flight_path_m or 25.0,
)
health = nereids.run_health("sample.nxs") # RunHealth: pause/beam-dip fractions
load_tiff_folder detects chunked VENUS folders
(<prefix>_<chunk>_<frame>.tif) and sums chunks element-wise by default,
emitting a UserWarning naming the summed chunks (and one with the
clipped-pixel count under pixel_policy="clip"); return_info=True
returns the load provenance as a second value. read_tof_sidecar
converts a VENUS *_Spectra.txt sidecar (frame start times in
seconds — the left bin edges, verified on measured autoreduce output)
into the N+1 ascending microsecond TOF bin edges that
tof_to_energy_centers expects. Negative or non-finite pixels are rejected
at load time unless pixel_policy says otherwise. run_health returns a
RunHealth summary of the /entry/DASlogs pause and beam-power logs using
last-value-held time-weighted integration (SNS PV-name defaults).
See Data I/O and NeXus/TOF for ordering and pairing rules, chunk semantics, the pixel-value policy, and run health.
Beam-State Filtering (DASlogs and Event Banks)
Facility NeXus files record slow-control PVs under /entry/DASlogs/<pv> as
transition logs: each value takes effect at its timestamp and persists
until the next entry. Averaging the value array directly is wrong whenever
entries are unevenly spaced — on a real VENUS run the entry-mean of the
pause log read 0.43 while the time-weighted pause fraction was 0.90.
read_run_log(path, pv)
Returns a RunLog with times (seconds since run start), values,
duration_s, offset_iso (ISO-8601 epoch of the clock), and
n_dropped_corrupt — the number of corrupt device-reconnect records
(backward time jumps or subnormal garbage payloads, both seen in real SNS
files) dropped from the log.
intervals_where(times, values, duration_s, min_value=None, max_value=None)
Derives (t_start, t_end) intervals where the PV satisfies the bounds,
under correct step-function semantics: the last value persists to
duration_s (padded one f32 ULP — SNS records duration in float32 while
pulse times are float64, and the final pulse of about half of real runs is
stamped just beyond it), time before the first entry never matches, NaN
never matches, and adjacent segments merge.
intervals_intersect(a, b)
Composes conditions across PVs (e.g. not-paused AND beam power above threshold). Inputs are validated and normalised (sorted, merged).
load_nexus_bank_spectrum(path, bank, n_bins, tof_min_us, tof_max_us, keep_intervals=None)
Loads one NXevent_data bank (e.g. "monitor1") as a BankSpectrum — a 1-D
TOF spectrum (tof_edges_us, counts) with retention statistics
(pulses_total/pulses_kept, events_total/events_kept, drop counters,
pulse_time_offset_iso). With keep_intervals, only pulses whose
event_time_zero falls inside the intervals (half-open, DASlogs clock)
are histogrammed. units attributes are required on both event datasets
(never guess a scale); a bank with zero events loads gracefully to a zero
spectrum — on VENUS every imaging-detector bank is empty because tpx1 is
frame-mode, and only monitors carry events.
pause = nereids.read_run_log("run.nxs.h5", "pause")
live = nereids.intervals_where(
pause.times, pause.values, pause.duration_s, max_value=0.5
)
power = nereids.read_run_log("run.nxs.h5", "BL10:Det:rtdl:BeamPowerAvg")
stable = nereids.intervals_where(
power.times, power.values, power.duration_s, min_value=1.5
)
keep = nereids.intervals_intersect(live, stable)
mon = nereids.load_nexus_bank_spectrum(
"run.nxs.h5", "monitor1",
n_bins=500, tof_min_us=0.0, tof_max_us=16667.0,
keep_intervals=keep,
)
print(mon.pulses_kept, "/", mon.pulses_total, "pulses in stable beam")
Element and Utility APIs
nereids.element_symbol(92) # "U"
nereids.element_name(92) # "Uranium"
nereids.parse_isotope_str("U-238") # (92, 238)
nereids.natural_abundance(92, 238)
nereids.natural_isotopes(26)
nereids.tof_to_energy(tof_us, flight_path_m)
nereids.energy_to_tof(energy_ev, flight_path_m)
How This Page Is Generated
The published docs site renders three things side by side:
| Site path | Source | What it shows |
|---|---|---|
/ (this page) | Hand-maintained docs/guide/src/python-api.md | Curated narrative tour of the typed APIs |
/python/ | pdoc over the installed nereids wheel and nereids/__init__.pyi stubs | Auto-generated exhaustive reference |
/api/ | cargo doc (rustdoc) | Rust crate API reference |
To rebuild the whole site locally:
pixi run doc-build # depends on: doc-guide, doc-api, doc-python
pixi run doc # serves target/book/ at http://localhost:8000
doc-python invokes pdoc -o target/book/python --no-show-source nereids
after pixi run build has produced an importable wheel. Whenever
bindings/python/python/nereids/__init__.pyi or PyO3 docstrings in
bindings/python/src/lib.rs change, both the auto-generated python/
reference and any affected sections of this curated page should be reviewed
in the same PR.
This page does not execute notebooks or compile-test Python snippets. The
Rust quickstart on this site IS compile-tested by cargo check --workspace --examples (see crates/nereids-pipeline/src/counts_fit.rs).
Data I/O and NeXus/TOF
This page documents the Python-facing TIFF, NeXus and TOF behavior outside the MCP-specific workflow page.
Axis Convention
NEREIDS uses the spectral axis first:
(n_energy_or_tof, height, width)
This applies to TIFF stacks, NeXus counts, and the raw count arrays passed to the counts fit.
For single spectra, use 1D arrays with shape (n_energy,).
TIFF Stacks
Use load_tiff_stack(...) for a multi-frame TIFF:
import nereids
transmission = nereids.load_tiff_stack("transmission_stack.tif", pixel_policy="allow")
# transmission.shape == (n_frames, height, width)
Use load_tiff_folder(...) for a directory of single-frame TIFF files:
counts = nereids.load_tiff_folder("frames", pattern="frame_*.tif")
Folders that do not follow the chunked VENUS naming convention below are
sorted lexicographically by filename. Use zero-padded names such as
frame_0001.tif, frame_0002.tif, and so on. The optional pattern
matches filenames, not full paths, and supports * and ?.
Note: load-time coverage/thickness masking is deliberately not part of the loaders. Coverage and sample thickness are model concerns handled downstream, not I/O concerns.
Chunked VENUS folders
The VENUS DAQ sometimes splits one run into several chunks, each covering
the full TOF frame range, with files named <prefix>_<chunk>_<frame>.tif
(for example run_764_00042.tif). When every filename in the folder follows
this convention with a single common prefix, load_tiff_folder(...) detects
the layout automatically:
- frames are ordered by numeric frame index (identical to lexicographic
order for zero-padded names, and correct where
_10would sort before_2lexicographically); - chunks covering identical frame ranges are summed element-wise into a
single
(n_frames, height, width)stack — the physical stack is the sum, not a concatenation. Passsum_chunks=Falsefor the legacy lexicographic concatenation. The flag only affects folders with two or more chunks: single-chunk (and non-chunk-patterned) folders load identically either way — chunk-patterned names in numeric frame order, others lexicographically; - ragged chunks (differing frame counts or frame sets) or duplicate
(chunk, frame) pairs raise
ValueErroron the default summing path — never a silent stack or a partial sum. Withsum_chunks=Falsethere is nothing to corrupt, so the same inconsistent folder loads as the lexicographic concatenation of every file (frame count = the sum of all files) and the irregularity is reported viachunk_inconsistent(and aUserWarning) instead of raising — this is exactly the case you reach forsum_chunks=Falseto inspect raw frames.
Folders with two or more distinct prefixes fall back to legacy
lexicographic loading (summing across prefixes would merge different runs);
use pattern to select one run, e.g. pattern="run_764_*".
A mixed folder — at least one chunk-patterned name alongside files that
do not match (a stray overview TIFF, a misnamed frame) — also falls back
to lexicographic loading, and the Python loader emits a UserWarning
counting the non-conforming files (naming up to three); check for stray
TIFFs or use pattern to exclude them. The provenance dict reports the
same via n_unrecognized_files / unrecognized_examples.
Because summing changes the data semantics versus a per-file read, the
Python loader emits a UserWarning naming the chunk count and ids (and
the sum_chunks=False escape hatch) whenever chunks were summed, and a
UserWarning with the clipped-pixel count when pixel_policy="clip"
clamped anything. Pass return_info=True to get the full provenance as
a second return value:
counts, info = nereids.load_tiff_folder("run_764", return_info=True)
# info == {"n_files": ..., "n_chunks": ..., "chunk_ids": [...],
# "chunks_summed": ..., "n_clipped_pixels": ...,
# "n_unrecognized_files": ..., "unrecognized_examples": [...],
# "chunk_inconsistent": ...}
One acquisition per folder. The chunk heuristic assumes the folder
holds a single acquisition — the VENUS autoreduce layout, where each run
gets its own directory (verified on IPTS-37432 output; the <chunk>
field in real names is a run-ish id, e.g. ..._ob_0_116_00000.tif). It
cannot distinguish same-prefix sibling runs co-located in one folder
from DAQ chunks — such siblings would be summed. When a folder may hold
multiple runs, select one with pattern or pass sum_chunks=False.
Pixel-value policy
Raw detector counts are non-negative by construction, so a negative or
non-finite pixel signals file corruption or a signed-type readout bug. Both
TIFF loaders take a pixel_policy keyword:
"reject"(default): raiseValueErrornaming the file, frame, flat index, and value. For corrupt readout pixels, mask them per acquisition withdetect_bad_pixels(...)instead of relaxing the policy."clip": clamp negative values to0.0; NaN still raises (clipping a NaN would invent data)."allow": accept all values verbatim — required for pre-normalized transmission stacks, where noise around zero legitimately produces small negative values.
TOF sidecar (*_Spectra.txt)
Autoreduced VENUS folders ship a <run>_Spectra.txt sidecar whose first
CSV column is each frame’s start time in seconds (one row per TOF frame).
read_tof_sidecar(...) converts it to the N+1 ascending microsecond bin
edges that tof_to_energy_centers(...) expects, synthesizing the closing
edge from the last frame width. Bin uniformity is not required — MCP
shutter segments change the frame width mid-run.
The start-time = left-bin-edge semantics is established from measured
VENUS autoreduce output (IPTS-37432, OB run 19385): the sidecar holds
exactly one row per TIFF frame, starts at 1.12 µs — not zero; the
autoreduce already drops the pre-trigger bins — in uniform 160 ns steps,
and every time value is an exact integer multiple of the 160 ns bin
width (1.12 µs = 7 × 0.16 µs). Bin centers would sit at
half-multiples, so shutter_time is definitively the frame start (left
bin edge). Note that PLEIADES’s sidecar helper uses these values
directly as frame TOFs, which differs from the true bin centers by half
a bin width; NEREIDS uses edges plus geometric-mean centers. A constant
offset of this kind is absorbed by the fitted t₀ in the energy-scale
fit.
A hand-made sidecar whose first start time is exactly 0 s still parses
(0 is a valid TOF edge), but the t = 0 edge cannot be energy-converted —
E is undefined at t = 0. Crop the first frame from both the stack
and the edges (stack[1:], edges[1:]) before conversion.
A complete VENUS run folder loads to a stack plus energy axis in three calls:
counts = nereids.load_tiff_folder("run_764") # chunk-aware
edges_us = nereids.read_tof_sidecar(
"run_764/run_764_Spectra.txt",
n_frames=counts.shape[0], # validated
)
energies = nereids.tof_to_energy_centers(edges_us, flight_path_m=25.0)
Counts
The fit takes raw sample and open-beam counts directly and forms no
transmission. Load both arms with the same shape and keep them on the TOF bin
edges; pass them to the counts fit (fit_counts for one spectrum, fit_map
for a map of pixel patches). See the Python API.
NeXus Histogram Loading
For agent-orchestrated NeXus workflows driven by a manifest, see MCP Server. This section covers the raw-Python loader.
Use probe_nexus(...) to inspect a file without loading full data:
meta = nereids.probe_nexus("sample.nxs")
print(meta.has_histogram, meta.has_events, meta.flight_path_m)
Use load_nexus_histogram(...) for pre-histogrammed data:
sample = nereids.load_nexus_histogram("sample.nxs")
open_beam = nereids.load_nexus_histogram("open_beam.nxs")
assert sample.counts.shape[0] == sample.tof_edges_us.shape[0] - 1
The loader reads VENUS/rustpix-style histogram data from
/entry/histogram/counts and returns:
counts:float64array with shape(n_tof, height, width).tof_edges_us: ascending TOF bin edges in microseconds.flight_path_m: optional file metadata.dead_pixels: optional(height, width)mask.
Histogram files may contain multiple rotation angles. The default
multi_angle_mode="error" rejects those files because silently summing
projection angles loses information. Choose explicitly:
summed = nereids.load_nexus_histogram("scan.nxs", multi_angle_mode="sum")
angle0 = nereids.load_nexus_histogram(
"scan.nxs",
multi_angle_mode="select",
angle_index=0,
)
NeXus Event Loading
Use load_nexus_events(...) when event data must be histogrammed at load
time:
events = nereids.load_nexus_events(
"events.nxs",
n_bins=2000,
tof_min_us=10.0,
tof_max_us=50000.0,
height=512,
width=512,
)
The event loader reads /entry/neutrons/event_time_offset, /x, and /y,
bins events into a linear TOF grid, and returns the same NexusData shape
contract as the histogram loader.
Run Health (DASlogs)
A run can be paused mid-acquisition or suffer accelerator beam dips; both
silently reduce the effective exposure of the summed stack.
run_health(...) summarizes the slow-control logs under /entry/DASlogs:
health = nereids.run_health("sample.nxs")
print(health.pause_fraction) # time-weighted fraction spent paused
print(health.beam_dip_fraction) # fraction with power < 0.5 * median
print(health.median_power, health.duration_s)
DASlogs PVs log transitions, not regular samples — a run paused for 90%
of its duration may contain just two pause entries, so entry means are
wrong. All fractions use last-value-held time-weighted integration over
the run window (/entry/duration when present, else the latest log
timestamp, a lower bound).
The PV-name defaults are the SNS ones (pause, proton_charge); other
facilities pass their own names, and the dip threshold is adjustable:
health = nereids.run_health(
"sample.nxs",
pause_pv="pause",
power_pv="proton_charge",
power_dip_fraction=0.5,
)
Absent PVs (or a missing DASlogs group) yield None fields — absence is
not an error. A PV that is present but malformed (length mismatch,
non-finite entries, decreasing timestamps) raises ValueError.
TOF Edges to Energy Centers
NeXus loaders return counts in ascending TOF order. Neutron energy decreases
as TOF increases, so direct TOF-bin conversion would be descending in energy.
tof_to_energy_centers(...) returns ascending energy centers for display and
forward-model checks; the counts fit itself works on TOF bin edges:
flight_path_m = sample.flight_path_m or 25.0
energies = nereids.tof_to_energy_centers(
sample.tof_edges_us,
flight_path_m,
delay_us=0.0,
)
When pairing these energies with NeXus counts, keep arrays aligned. The MCP workflow reverses counts to the same ascending-energy order before fitting. For direct Python workflows, use this pattern:
# load_nexus_histogram returns counts on ascending TOF bin edges; the counts
# fit consumes those edges and the counts directly.
edges_us = sample.tof_edges_us
sample_counts = sample.counts
open_beam_counts = open_beam.counts
If you construct an energy grid yourself, make sure the grid and every
spectral array use the same order and that energies.shape[0] matches the
first array dimension.
Fitting counts
Raw sample and open-beam counts go straight to the counts fit:
fit = nereids.fit_counts(measurement, calibration) # one spectrum
counts_map = nereids.fit_map(map_measurement, calibration) # pixel patches
Build the Measurement/MapMeasurement from the TOF edges, the two count
cubes and the material, and the Calibration from t0, the flight path and
the pulse. See the Python API for the constructors.
MCP Server
NEREIDS can run as a local Model Context Protocol (MCP) server so an AI agent can inspect neutron-resonance inputs, validate a dataset manifest, and launch spectrum or density-map fitting through the Python bindings. The MCP server is intended for local agent orchestration, for example a user prompt such as “help me process the data here” in a directory that contains a NEREIDS manifest.
The MCP interface is experimental. It is useful for demos and agent-assisted workflows, but the Python and Rust APIs remain the stable interfaces for scripted analysis.
See also: Data I/O and NeXus/TOF for the raw-Python NeXus, TIFF, normalization, and TOF energy-grid flow that the MCP workflow wraps.
Installation
Install the optional MCP dependency and run the stdio server:
pip install "nereids[mcp]"
nereids-mcp
From a source checkout, build the Python extension first:
pip install maturin
maturin develop --release -m bindings/python/Cargo.toml
pip install "fastmcp>=3.0"
python -m nereids.mcp
In the repository, use pixi run -e mcp test-mcp to build and test the isolated
MCP environment. fastmcp is kept out of the default environment because it
can conflict with conda metadata packages.
Behavior when fastmcp is not installed
The two entry points differ on purpose:
| Entry point | Without fastmcp |
|---|---|
nereids.mcp.main() (called by the nereids-mcp console script) | Raises ImportError("fastmcp is required for the MCP server. Install it with: pip install nereids[mcp]"). |
nereids.mcp.mcp (lazy attribute) | Raises AttributeError with the same install instruction embedded in the message. |
The attribute path raises AttributeError rather than ImportError so that
attribute-walking tools (pdoc, IDE introspection, hasattr) can treat mcp
as absent rather than crashing. Callers who explicitly catch ImportError
to detect a “MCP not installed” state should switch to either calling
nereids.mcp.main() (which still raises ImportError) or using
hasattr(nereids.mcp, "mcp") as the feature-detection probe.
Client Configuration
An MCP client can launch the server with the nereids-mcp console script:
{
"mcpServers": {
"nereids": {
"command": "nereids-mcp"
}
}
}
Tools
The server exposes two groups of tools.
Low-level physics tools operate on an in-memory isotope registry:
list_isotopes(z)lists naturally occurring isotopes.load_endf(isotope, library="endf8.1")loads resonance data such as"U-238"or"Fe-56"into the registry.get_resonance_parameters(isotope)returns loaded resonance metadata.compute_cross_sections(...),compute_transmission(...), andforward_model(...)run direct physics calculations.
Workflow tools operate on a dataset directory or a manifest path:
extract_resonance_manifest(dataset_path)reads the manifest and returns parsed metadata.validate_resonance_dataset(dataset_path)checks required paths, isotope entries, and resolution configuration.process_resonance_dataset(dataset_path, output_dir=None, max_pixels=None, dry_run=False)runs the analysis and writes compact result artifacts.
Dataset Manifests
The workflow tools look for one of these files in the dataset directory:
manifest_intermediate.mdsmcp_manifest.mdnereids_manifest.mdnereids_mcp.jsonanalysis.json
Markdown manifests must contain JSON-compatible frontmatter between ---
delimiters. Pure JSON manifests must contain the frontmatter object directly.
The workflow configuration may be placed under analysis, workflow, or
processing; otherwise the root object is treated as the workflow.
Minimal counts manifest:
---
{
"name": "u238-counts-demo",
"tool": "nereids",
"analysis": {
"mode": "single_spectrum",
"data": {
"kind": "counts_npz",
"path": "counts.npz",
"time_edges_key": "time_edges_us"
},
"calibration": {
"t0_us": 3.0,
"flight_path_m": 25.0,
"pulse": {
"alpha0": 0.35,
"alpha1": 0.05,
"beta0": 0.25,
"beta1": 0.0,
"r": 0.15,
"fwhm_squared_us2": 0.0,
"energy_span_ev": [1.0, 200.0]
}
},
"isotopes": [
{
"isotope": "U-238",
"initial_density": 0.001,
"library": "endf8.1"
}
],
"fit": {
"fit_domain": "counts",
"normalization": 1.0,
"background": [0.0, 0.0, 0.0],
"temperature_k": 293.6
},
"output": {
"directory": "output"
}
}
}
---
The fit is always the counts domain: fit_domain must be “counts”. An
instrument resolution block is rejected, because the counts fit uses the
Ikeda-Carpenter pulse in the calibration block, not a tabulated resolution.
Each fitted quantity is a number (known) or an object with a kind
(“known”, “fitted”, “within” or “measured”) and the matching fields, so a
manifest can free or fix the temperature, a background term, the
normalization, t0, the flight path and each pulse number. The pulse’s
“r_high” is the storage fraction at the high end of its energy span, linear in
sqrt(E) from “r” at the low end; without it the storage fraction is “r”
throughout. Its “detector_tail”, [f, tau_D], records a fraction f of the counts
late by an exponential delay of mean tau_D microseconds; without it no count is
late.
Isotope densities are fitted from their initial_density values. All
isotopes in a material must use the same ENDF library; its library also
determines which resonance windows the fit excludes.
Unsupported fit settings are rejected. Removed solver or parameter options
cannot silently change the requested fit.
Supported Workflow Inputs
The fit is counts-only.
single_spectrum fits one region. It supports:
- counts_npz or counts: a .npz file with 1D or 3D sample_counts and open_beam_counts, plus the original time-bin edges in microseconds. The edges come from time_edges_us (or time_edges_key) and must increase in the counts’ order. Energy centers cannot reconstruct detector bin edges.
For a single spectrum, fit.time_background supplies [C, A, tau], each a
number or Value specification, and fit.gamma_transmission supplies the
sample’s gamma transmission. The additive time term enters after the pulse.
It is distinct from the beam-borne fit.background scattering terms,
[b0, b1, b2], [b0, b1, b2, d, f] or an object with b0, b1, b2 and
optional d, f.
fit.room_background supplies {rate, frames}: counts per microsecond per
pixel per frame in both runs, and the [open-beam, sample] frames.
fit.thickness_variance supplies the relative variance of the sample’s areal
density over the pixels a fit sums, fit.capture_gammas the share of the
sample’s captures whose gammas the detector records, and fit.in_scatter the
share of the sample’s forward single scatters the detector records.
fit.debye_temperature_k broadens the sample’s cross sections by a harmonic
lattice with Debye’s spectrum at that Debye temperature, and makes the fitted
temperature the thermodynamic one; without it they are broadened by the free
gas.
The single_spectrum result returns every fitted quantity of the region:
densities with their standard deviations, temperature, thickness variance,
capture gammas, in-scatter, timing offset, background terms, gamma
transmission and the two runs’ overdispersions, and the fit’s normalization,
t0, flight path, pulse, time and room backgrounds and spread share.
density_map and spatial_map fit every patch of a counts cube. They support counts_npz/counts and nexus_histogram/nexus with 3D counts. Optional .npz arrays excluded, sample and empty are (row, column) boolean masks; without them every pixel is treated as behind the sample. binning sets the patch side in pixels.
Map workflows take fit.time_background and fit.room_background per
pixel, shared by every patch, and fit.gamma_transmission for every sample
patch. The map’s .npz holds each patch’s densities, their standard
deviations, temperature, thickness variance, capture gammas, in-scatter,
timing offset, gamma transmission and background terms (on the last axis),
NaN where a patch has no fit. The map’s identified
array marks the patches whose fitted densities, unless 0, and fitted
temperature each have a finite standard deviation; it is not physical or
thermometry validation. Masks are cropped with the counts when an ROI or
pixel limit is selected.
Paired arrays must have matching shapes, and the time-bin edge count must be one more than the first axis of the data arrays. For NeXus inputs the sample and open-beam TOF edges must match.
Example NeXus density-map manifest:
---
{
"name": "venus-nexus-density-map",
"tool": "nereids",
"analysis": {
"mode": "density_map",
"data": {
"kind": "nexus",
"sample_path": "sample.nxs",
"open_beam_path": "open_beam.nxs",
"binning": 4
},
"calibration": {
"t0_us": {"kind": "fitted", "value": 3.0},
"flight_path_m": 25.0,
"pulse": {
"alpha0": 0.35,
"alpha1": 0.05,
"beta0": 0.25,
"beta1": 0.0,
"r": 0.15,
"fwhm_squared_us2": 0.0,
"energy_span_ev": [1.0, 200.0]
}
},
"isotopes": [
{"isotope": "U-238", "initial_density": 0.001}
],
"fit": {
"fit_domain": "counts",
"normalization": {"kind": "fitted", "value": 1.0},
"temperature_k": {"kind": "fitted", "value": 293.6}
}
}
}
---
An active instrument resolution is not part of this workflow.
Result Files
process_resonance_dataset(...) writes outputs under the configured output
directory, or under <dataset>/output by default.
For single_spectrum, it writes:
nereids_spectrum_fit.npznereids_mcp_result.json
For density_map or spatial_map, it writes:
nereids_density_map.npznereids_mcp_result.json
The JSON summary is strict JSON: non-finite fit values are represented as
null, not NaN or Infinity.
GUI Walkthrough
The NEREIDS desktop application provides interactive neutron resonance imaging analysis with visual feedback at every step.
The screenshots on this page cover the current guided workflow screens: landing, load, configure, analyze, results, studio, and periodic table. When workflow labels, solver controls, or project-file behavior changes, refresh these images together with this page.
Launch
# Homebrew (macOS)
brew install --cask ornlneutronimaging/nereids/nereids
# Or pip
pip install nereids-gui
nereids-gui
# Or from source
cargo run --release -p nereids-gui
Landing Page
The landing page opens the counts fitting workflow.
Decision Wizard
After selecting Load & Fit Data, a short wizard asks:
- Fitting type: Single spectrum or spatial map
- Data format: “Raw Events (HDF5/NeXus)” or “Histogram, Pre-Normalization”
The wizard configures a dynamic pipeline with only the steps relevant to your data format. Six distinct pipelines are available.
Pipeline Steps
Load
Select sample data, open beam, and spectrum files. Supports multi-frame TIFF stacks, TIFF folders, and NeXus/HDF5 event data. The GUI auto-detects the file format and loads data when all fields are filled.
Normalize
For histogram/pre-normalization pipelines, including TIFF pair and HDF5
sample/open-beam counts, the Normalize step computes transmission from sample
and open-beam measurements. Raw event pipelines bin events first. Transmission
pipelines skip normalization because T(E) = I/I0 is already supplied.
Configure
Select isotopes of interest from the periodic table. ENDF nuclear data is fetched automatically from IAEA servers and cached locally. Each isotope shows a status badge (Pending, Fetching, Loaded, Failed).
Configure beamline parameters (flight path, timing). The solver is the counts-domain Poisson/KL engine.
Analyze
The solver is the counts-domain Poisson/KL engine, which needs both the sample and open-beam count cubes. Run the fit; for maps a progress bar tracks the patches and clicking a pixel shows its fit. Fit feedback shows green (good) or red (failed) status.
Draw regions of interest (ROI) with Shift+drag. Multiple ROIs are supported with move, select, and delete operations.
Restricting the fit energy range (SAMMY EMIN/EMAX)
By default NEREIDS fits the entire loaded energy grid. The advanced solver
panel exposes a “Restrict fit energy range” checkbox (equivalent to
SAMMY’s EMIN/EMAX analysis limits) that limits the cost
function to a user-specified [E_min, E_max] window in eV. Common uses:
- Resolved-resonance region only — exclude the unresolved-resonance and high-energy tails where the model can’t fit;
- Single resonance triplet — focus on a specific feature for fine-grained density / temperature work;
- SAMMY parity — match the EMIN/EMAX restriction used in a reference SAMMY fit so the comparison is apples-to-apples.
When the checkbox is on, two grey dashed vertical lines on the spectrum plot mark the active boundaries (visible on the energy-eV axis). The reduced χ² and degrees-of-freedom reported in the fit details count only bins inside the active range.
Resolution-kernel margin (automatic): the broadening kernel pulls model
contributions from outside the user range. NEREIDS handles this transparently
by extending the data slice by ~5×FWHM on each side and masking the cost
function back to [E_min, E_max] — so resonances near the boundaries are
correctly broadened without the user picking a custom margin. This follows
the same endpoint-extension principle as SAMMY’s auxiliary grid (general
construction: user manual Sec. III.A.2(c); the quantitative
[Emin − Wmin, Emax + Wmax] statement, with W the resolution width at each
limit, appears in the Leal-Hwang procedure of Sec. III.B.2); NEREIDS uses a
deliberately conservative ~5×FWHM margin.
The setting persists in .nrd.h5 project files (Option<(f64, f64)>,
default None = full grid for backwards compatibility).
Results
View density maps for each fitted isotope. Summary statistics show convergence rate, median chi-squared, and isotope count. Open results in Studio for detailed exploration.
Studio Mode
Studio provides a “Final Cut”-style workspace for exploring results:
- Document tabs: Analysis
- Main viewer: density map with colormap selection and colorbar
- Spectrum panel: click any pixel to see its fitted spectrum
- Bottom dock: Isotopes, Residuals, Provenance, and Export panels
- Inspector sidebar: per-pixel parameter values
Tools
Periodic Table
Interactive 18-column periodic table for selecting isotopes. Click an element to see its natural isotopes with abundance percentages. Supports multi-select with density input. ENDF availability hints are shown for each isotope based on the currently selected data library.
Project Files
Save and load analysis sessions as HDF5 project files (.nrd.h5):
- Cmd+S (macOS) / Ctrl+S (Linux): quick-save
- File > Save: save with dialog
- File > Open: load a saved project
Project files store raw data, pipeline configuration, and results. Embedded data mode bundles everything into a single portable file.
Notebook Status
The tutorial notebooks live under examples/notebooks/. They are examples,
not the API contract: the contract is the Python type stubs, the Python and
Rust tests, the mdBook guide and Rustdoc.
Notebooks
| Notebook group | Contents |
|---|---|
foundations/ | Cross-section, broadening and transmission physics. |
building_blocks/ | ENDF loading and element utilities. |
Counts-fitting, mapping and I/O tutorials will be re-added against
fit_counts and fit_map.
Notebooks are not executed by pixi run doc-guide, pixi run doc-build or
GitHub Pages publishing. Before using one as release evidence, run it manually
with a controlled data and network policy. Notebooks that call
load_endf(...) may need network access on first run; ENDF files are cached
after retrieval.
Architecture
NEREIDS is organized as a Rust workspace with seven library crates, a GUI application, and Python bindings.
Crate Dependency Graph
endf-mat (standalone lookup tables)
|
nereids-core (types, constants)
/ | \
nereids-endf | nereids-io
(ENDF) | (TIFF, NeXus)
\ |
nereids-physics
(cross-sections)
\
nereids-fitting
(LM, Poisson)
|
nereids-pipeline ── nereids-io
(orchestration)
/ \
nereids-python nereids-gui
(PyO3 bindings) (egui desktop)
Crate Overview
| Crate | Purpose |
|---|---|
endf-mat | Zero-dependency lookup tables: element symbols, MAT numbers, natural abundances, ZA encoding |
nereids-core | Core types (Isotope, Resonance), physical constants, element data, error types |
nereids-endf | ENDF file retrieval from IAEA, local caching, File 2 resonance parameter parsing |
nereids-physics | Cross-section calculation (Reich-Moore, SLBW, MLBW), Doppler/resolution broadening, Beer-Lambert transmission |
nereids-io | TIFF stack and NeXus/HDF5 loading, TOF-to-energy conversion, normalization, export |
nereids-fitting | Poisson/KL counts optimizer |
nereids-pipeline | Counts-domain fitting: one region or a map of pixel patches |
nereids-python | PyO3 Python bindings (not published to crates.io) |
nereids-gui | egui desktop application (not published to crates.io) |
Data Flow
The standard analysis pipeline processes data through these stages:
Raw TOF counts (TIFF/NeXus), sample and open beam
│
▼
ENDF data (resonance parameters) [nereids-endf]
│
▼
Forward model (cross-sections → Doppler → pulse → counts) [nereids-physics]
│
▼
Counts fit (Poisson/KL on the two count arms) [nereids-fitting, nereids-pipeline]
│
▼
Density and temperature, one region or a map of patches [output]
Key Design Decisions
Exact SAMMY Physics
All physics modules implement the exact formalisms from the SAMMY Fortran code, with no ad-hoc approximations. Every module references specific SAMMY source files and equation numbers. See the API documentation for details.
Workspace Architecture
The workspace is structured so that each crate has a single responsibility
and minimal dependencies. nereids-core is the foundation with zero internal
dependencies. Higher-level crates compose lower-level ones.
See ADR 0001 for the full rationale.
Parallel counts mapping
fit_map fits patches of pixels as one counts fit and propagates the
cross-patch covariance.
Python Bindings and MCP
The Python bindings expose the counts fit (fit_counts) and counts map
(fit_map) directly, plus ENDF loading, forward-model and TOF helpers
(load_endf, forward_model, tof_to_energy_centers). TIFF/NeXus I/O
helpers (load_tiff_stack, load_tiff_folder, probe_nexus,
load_nexus_histogram, load_nexus_events) expose the same spectral-axis
conventions as nereids-io. NumPy arrays are zero-copy where possible via the
numpy crate integration.
The MCP server is a thin Python package layer over these bindings. It exposes low-level physics tools and manifest-driven workflow tools for local AI-agent orchestration; it does not add a separate fitting engine.
Physics Reference
The physics NEREIDS implements is described in one place: the Pipeline Map. Each module’s equations and SAMMY references are in the API documentation.
Contributing
Development Setup
git clone https://github.com/ornlneutronimaging/NEREIDS.git
cd NEREIDS
cargo build --workspace
For Python binding development, use pixi:
pixi run build # maturin release build
pixi run test-python # pytest
Pre-Commit Checklist
Run these three commands before every commit:
cargo fmt --all
cargo clippy --workspace --exclude nereids-python --all-targets -- -D warnings
cargo test --workspace --exclude nereids-python
cargo fmtapplies formatting (not just--check)cargo clippytreats all warnings as errorsnereids-pythonis excluded because it requires PyO3/maturin build setup
Branch and PR Workflow
- Create a feature branch from
main - Make changes, commit with GPG signatures (
git commit -S) - Push and open a PR against
main - All PRs go through the review pipeline before merge
The repository uses a single remote (origin = ornlneutronimaging/NEREIDS).
All branches and PRs are pushed directly.
Code Guidelines
Physics Modules
- Implement exact SAMMY physics – no ad-hoc approximations
- Reference SAMMY source files and equation numbers in doc comments
- Validate against SAMMY’s own test cases as ground truth
General
- Validate configuration up-front in public entry points
- Guard NaN with
.is_finite()(NaN bypasses comparison guards) - Guard empty collections explicitly (
.is_empty()) - Use named constants instead of magic numbers
- Prefer
return Err(...)for input validation, notdebug_assert!
Testing
# Rust tests
cargo test --workspace --exclude nereids-python
# Python tests (requires pixi)
pixi run test-python
# Build docs locally
cd docs/guide && mdbook build && mdbook serve
Documentation and Release Checklist
Before a release or a documentation-focused PR, verify:
pixi run doc-guidebuilds the mdBook guide.pixi run doc-buildbuilds mdBook, builds Rustdoc, and copies Rustdoc totarget/book/api.- The Python API reference matches the shipped
bindings/python/python/nereids/__init__.pyistubs for public functions, arguments, array shapes, and result objects. - The Data I/O and NeXus/TOF page matches current TIFF,
NeXus, and
tof_to_energy_centers(...)behavior. - PyPI metadata in
pyproject.tomllists current optional extras, includingmcpandgui. - GUI installation docs cover both
pip install "nereids[gui]"and the directnereids-guipackage, plus Homebrew/source options. - GUI screenshots still match the current landing, guided workflow, studio, and tools screens.
- Notebook release notes state whether notebooks were smoke-run, which subset was run, and which require ENDF network access or external PLEIADES/Git LFS data.
- Rust quickstart snippets have been checked against current public APIs.
Project Structure
NEREIDS/
crates/
endf-mat/ # Element/MAT lookup tables
nereids-core/ # Core types and constants
nereids-endf/ # ENDF retrieval and parsing
nereids-physics/ # Cross-section physics
nereids-fitting/ # Optimization engines
nereids-io/ # Data I/O (TIFF, NeXus)
nereids-pipeline/ # Orchestration
bindings/python/ # PyO3 Python bindings
apps/gui/ # egui desktop application
docs/
guide/ # mdBook user guide (this site)
adr/ # Architecture decision records
references/ # Reference materials
Useful Commands
| Task | Command |
|---|---|
| Build all | cargo build --workspace |
| Run tests | cargo test --workspace --exclude nereids-python |
| Format | cargo fmt --all |
| Lint | cargo clippy --workspace --exclude nereids-python --all-targets -- -D warnings |
| Build Python | pixi run build |
| Test Python | pixi run test-python |
| Build docs | cd docs/guide && mdbook build |
| Build full docs site | pixi run doc-build |
| Serve docs | cd docs/guide && mdbook serve |
| Build rustdoc | cargo doc --workspace --no-deps --exclude nereids-python |