Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Load raw TOF imaging data (TIFF stacks, NeXus/HDF5, or pre-normalized transmission)
  2. Normalize sample and open-beam measurements to transmission
  3. Configure isotopes of interest using ENDF nuclear data
  4. Fit resonance models to measured transmission spectra
  5. Map fitted parameters (areal density, temperature) across each pixel

Interfaces

NEREIDS ships in several interfaces:

DeliverableUse 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)
  • R-Matrix Limited (LRF=7)
  • Free Gas Model Doppler broadening
  • Gaussian + exponential resolution broadening
  • Unresolved Resonance Region (LRU=2)

Next Steps

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:

  1. XDG desktop portal (org.freedesktop.portal.FileChooser over D-Bus) — native dialogs on any desktop session (GNOME, KDE, …). Preinstalled on every mainstream desktop, including RHEL 8’s GNOME.
  2. zenity — automatic fallback when no portal is reachable. Recommended for ssh -X sessions and containers: sudo dnf install zenity / sudo apt-get install zenity.
  3. 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

This example loads ENDF resonance data for U-238, computes a theoretical transmission spectrum, and fits it to recover the areal density.

The snippets below are spliced from crates/nereids-fitting/examples/quickstart.rs so the rendered page cannot drift out of sync with the live crate APIs: the example is compile-checked by cargo check --workspace --examples in CI. Run the full example locally with cargo run --example quickstart -p nereids-fitting (first run requires network access to fetch ENDF/B-VIII.1).

Setup

# Cargo.toml
[dependencies]
nereids-core = "0.1"
nereids-endf = "0.1"
nereids-physics = "0.1"
nereids-fitting = "0.1"

Load ENDF Data

use nereids_core::types::Isotope;
use nereids_endf::parser::parse_endf_file2;
use nereids_endf::retrieval::{EndfLibrary, EndfRetriever, mat_number};
use nereids_fitting::lm::{LmConfig, levenberg_marquardt};
use nereids_fitting::parameters::{FitParameter, ParameterSet};
use nereids_fitting::transmission_model::TransmissionFitModel;
use nereids_physics::transmission::{SampleParams, forward_model};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Download and cache ENDF/B-VIII.1 data for U-238 (Z=92, A=238).
    let isotope = Isotope::new(92, 238)?;
    let retriever = EndfRetriever::new();
    let mat = mat_number(&isotope, EndfLibrary::EndfB8_1).expect("U-238 has a known MAT number");
    let (_path, endf_text) = retriever.get_endf_file(&isotope, EndfLibrary::EndfB8_1, mat)?;
    let resonance_data = parse_endf_file2(&endf_text)?;

    println!(
        "U-238: {} resonances, AWR = {:.1}",
        resonance_data.total_resonance_count(),
        resonance_data.awr,
    );

Compute a Forward Model

#![allow(unused)]
fn main() {
    // Energy grid: 1 to 30 eV (covers the 6.67 eV and 20.9 eV resonances).
    let energies: Vec<f64> = (0..2000)
        .map(|i| 1.0 + (i as f64) * 29.0 / 2000.0)
        .collect();

    // Sample: U-238 at 0.001 atoms/barn, room temperature.
    let sample = SampleParams::new(300.0, vec![(resonance_data.clone(), 0.001)])?;

    // No instrument resolution broadening for this example.
    let transmission = forward_model(&energies, &sample, None)?;
    // `transmission[i]` is T(E_i) in [0, 1], with dips at resonance energies.
}

Fit a Measured Spectrum

#![allow(unused)]
fn main() {
    // Simulate measured data (in practice, load from TIFF/NeXus).
    let measured_t = transmission.clone();
    let sigma: Vec<f64> = vec![0.01; measured_t.len()];

    // Set up the fit model: one density parameter at index 0.
    let model = TransmissionFitModel::new(
        energies.clone(),
        vec![resonance_data],
        300.0,                // temperature_k
        None,                 // no instrument resolution
        (vec![0], vec![1.0]), // density_indices, density_ratios
        None,                 // no temperature fitting
        None,                 // no precomputed cross-sections
    )?;

    // Initial guess: density = 0.0005 atoms/barn (non-negative constraint).
    let mut params = ParameterSet::new(vec![FitParameter::non_negative("U-238 density", 0.0005)]);

    let config = LmConfig::default();
    let result = levenberg_marquardt(&model, &measured_t, &sigma, &mut params, &config)?;

    println!("Fitted density: {:.6} atoms/barn", result.params[0]);
    println!("Reduced chi-squared: {:.3}", result.reduced_chi_squared);
    println!("Converged: {}", result.converged);
    Ok(())
}
}

Next Steps

Quickstart: Python

This example uses the NEREIDS Python bindings to load ENDF data, compute a forward model, and fit a transmission spectrum.

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()

Spatial Mapping

For imaging data (3D transmission arrays), use the typed API:

# transmission_3d: shape (n_energies, height, width)
# uncertainty_3d:  shape (n_energies, height, width)

data = nereids.from_transmission(transmission_3d, uncertainty_3d)
result = nereids.spatial_map_typed(
    data,
    energies,
    [u238],
    temperature_k=300.0,
    flight_path_m=25.0,
    delta_t_us=5.0,
    delta_l_m=0.005,
)

# result.density_maps[0] is a 2D array of U-238 areal density at each pixel
# result.converged_map shows which pixels converged
print(f"Converged: {result.n_converged}/{result.n_total} pixels")

For raw count data (Poisson-optimal fitting):

data = nereids.from_counts(sample_counts_3d, open_beam_counts_3d)
result = nereids.spatial_map_typed(data, energies, [u238])

Single-Spectrum Fitting

Use fit_spectrum_typed(...) for pre-normalized transmission spectra:

uncertainty = np.full_like(transmission, 0.01)
fit = nereids.fit_spectrum_typed(
    transmission,
    uncertainty,
    energies,
    [(u238, 0.0005)],
    temperature_k=300.0,
)
print(fit.densities, fit.reduced_chi_squared)

Use fit_counts_spectrum_typed(...) for raw sample/open-beam counts:

fit = nereids.fit_counts_spectrum_typed(
    sample_counts_1d,
    open_beam_counts_1d,
    energies,
    [(u238, 0.0005)],
    c=sample_charge / open_beam_charge,
)
print(fit.densities, fit.deviance_per_dof)

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("transmission_stack.tif")

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,
)

NeXus counts are loaded in ascending TOF order. Reverse axis 0 before fitting against the ascending energy centers returned by tof_to_energy_centers(...). The Data I/O and NeXus/TOF chapter covers the full workflow.

Detectability Analysis

Check whether a trace isotope is detectable in a given matrix:

fe56 = nereids.load_endf(26, 56)  # matrix: Fe-56
ag107 = nereids.load_endf(47, 107)  # trace: Ag-107

report = nereids.trace_detectability(
    matrix=fe56,
    matrix_density=0.01,
    trace=ag107,
    trace_ppm=100.0,
    energies=energies,
    i0=1e6,
)

print(f"Detectable: {report.detectable}")
print(f"Peak SNR: {report.peak_snr:.1f} at {report.peak_energy_ev:.2f} eV")

Next Steps

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:

PropertyTypeMeaning
zintAtomic number.
aintMass number.
awrfloatAtomic weight ratio.
n_resonancesintResonance count across parsed ranges.
target_spinfloatTarget spin from the first range.
scattering_radiusfloatEffective scattering radius in fm.
l_valueslist[int]Orbital angular momentum values present in the data.

FitResult

Returned by fit_spectrum_typed(...) and fit_counts_spectrum_typed(...).

PropertyTypeMeaning
densitiesNDArray[float64]Fitted areal densities in atoms/barn.
uncertaintiesNDArray[float64]One-sigma density uncertainties; entries may be NaN when covariance is unavailable.
reduced_chi_squaredfloatPearson chi-squared per degree of freedom for LM/transmission paths.
deviance_per_doffloat or NonePrimary goodness-of-fit for counts-KL fits.
convergedboolWhether the optimizer converged.
iterationsintIteration count.
temperature_kfloat or NoneFitted temperature when fit_temperature=True.
t0_us, l_scalefloat or NoneFitted energy-scale parameters when fit_energy_scale=True.

InputData

Opaque typed 3D input for spatial mapping. Create it with:

data = nereids.from_transmission(transmission, uncertainty)
data = nereids.from_counts(sample_counts, open_beam_counts)

The spectral axis is always axis 0, so arrays have shape (n_energy, height, width).

SpatialResult

Returned by spatial_map_typed(...).

PropertyTypeMeaning
density_mapslist[NDArray[float64]]One (height, width) density map per isotope or isotope group.
uncertainty_mapslist[NDArray[float64]]Per-pixel density uncertainty maps.
chi_squared_mapNDArray[float64]Per-pixel reduced chi-squared for LM/transmission paths.
deviance_per_dof_mapNDArray[float64] or NonePrimary GOF map for counts-KL spatial fits.
converged_mapNDArray[bool_]Per-pixel convergence flags.
n_converged, n_failed, n_totalintPixel fit counts.
temperature_mapNDArray[float64] or NoneFitted temperature map when enabled.
temperature_uncertainty_mapNDArray[float64] or NonePer-pixel 1σ temperature uncertainty (K) when fit_temperature=True. Covariance-only lower bound on the Poisson-KL / joint-Poisson paths: it captures only statistical curvature (inverse Fisher matrix), omits baseline/model noise, and on real data can underestimate the observed per-superpixel scatter by ~3–4×. Pass scale_by_chi2=True for a goodness-of-fit-scaled estimate: σ is multiplied by sqrt of the reduced χ² each pixel’s result reports (Gaussian reduced_chi_squared on the transmission paths, deviance_per_dof on the counts joint-Poisson path). This inflates σ for an under-fit pixel (χ²/dof > 1) and, less commonly, shrinks it for an over-fit one (χ²/dof < 1). The flag is a no-op on the already-χ²-scaled LM transmission path.
anorm_map, background_mapsNDArray[float64] / list[...] or NoneSAMMY Anorm and the polynomial background [BackA, BackB, BackC] per pixel when background=True.
back_d_map, back_f_mapNDArray[float64] or NoneSAMMY exponential background BackD / BackF per pixel when background=True and fit_back_d=True / fit_back_f=True. Counts-KL spatial runs always return None for both (the joint-Poisson dispatch never fits the exponential tail).
t0_us_map, l_scale_mapNDArray[float64] or NoneEnergy-scale maps when enabled.

NexusData

Returned by load_nexus_histogram(...) and load_nexus_events(...).

PropertyTypeMeaning
countsNDArray[float64]Counts cube with shape (n_tof, height, width).
tof_edges_usNDArray[float64]TOF bin edges in microseconds, length n_tof + 1.
flight_path_mfloat or NoneFlight path from NeXus metadata when available.
dead_pixelsNDArray[bool_] or NoneDead-pixel mask, True means dead.
n_rotation_anglesintNumber of rotation angles in histogram input.
event_total, event_keptint or NoneEvent 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.

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(...).

Single-Spectrum Fitting

Transmission Data

result = nereids.fit_spectrum_typed(
    transmission,
    uncertainty,
    energies,
    [(u238, 0.0005)],
    temperature_k=300.0,
    solver="lm",
)

Shape contract:

  • transmission, uncertainty, and energies are 1D arrays with the same length.
  • energies is in eV and should be ascending.
  • isotopes supplies (ResonanceData, initial_density) pairs.

Keyword arguments:

OptionMeaning
temperature_k=293.6Sample temperature in kelvin.
fit_temperature=FalseFit sample temperature in addition to densities.
max_iter=200Maximum optimizer iterations.
solver="lm""lm", "kl", "auto", "poisson", or "joint_poisson". "poisson" and "joint_poisson" are aliases used by the counts dispatch and accepted here for symmetry.
background=FalseEnable SAMMY-style transmission background parameters.
fit_back_d=False, fit_back_f=FalseFit optional exponential background terms.
back_d_init=0.01, back_f_init=1.0Initial exponential background values.
fit_energy_scale=FalseFit TOF energy-scale parameters t0_us and l_scale.
t0_init_us=0.0, l_scale_init=1.0Initial energy-scale values.
energy_scale_flight_path_m=25.0Nominal flight path for energy-scale fitting.
resolution=...Tabulated resolution from load_resolution(...). Mutually exclusive with the Gaussian parameters below — pass either resolution= (tabulated) or the flight_path_m/delta_t_us/delta_l_m trio (Gaussian), never both.
flight_path_m=..., delta_t_us=..., delta_l_m=...Gaussian resolution parameters (mutually exclusive with resolution=).
fit_energy_range=(emin, emax)Restrict the cost function to an energy window.
groups=[...]Fit isotope groups instead of individual isotopes.
initial_densities=[...]Initial density guesses when fitting groups.
tzero_jacobian="..."Select the TZERO Jacobian implementation.

Raw Counts

result = nereids.fit_counts_spectrum_typed(
    sample_counts,
    open_beam_counts,
    energies,
    [(u238, 0.0005)],
    solver="auto",
    c=1.0,
)

solver="auto", "kl", "poisson", and "joint_poisson" all route counts data to the counts-KL dispatch. Use c=Q_s / Q_ob when sample and open-beam counts have different proton charge or dwell-time normalization. The primary GOF for this path is FitResult.deviance_per_dof.

Counts fitting accepts the same temperature, background, group, resolution, energy-scale, and fit_energy_range options as transmission fitting. Counts specific options are:

OptionMeaning
detector_background=...Optional 1D detector background spectrum; required when fit_alpha_2=True.
fit_alpha_1=False, fit_alpha_2=FalseFit counts-domain nuisance/background terms.
alpha_1_init=1.0, alpha_2_init=1.0Initial nuisance/background values.
c=1.0Proton-charge ratio Q_s / Q_ob.
enable_polish=True/False/NoneOverride counts-KL polish behavior; None uses the dispatcher default.

Spatial Mapping

Pre-Normalized Transmission Cubes

data = nereids.from_transmission(transmission_3d, uncertainty_3d)
result = nereids.spatial_map_typed(
    data,
    energies,
    [u238],
    initial_densities=[0.0005],
    solver="auto",
)

For from_transmission(...) inputs the default solver="lm" and solver="auto" both route to LM (this is the dispatcher contract in __init__.pyi: “from_transmission + solver="lm" (default for transmission) → LM”). The explicit solver="kl" opt-in for from_transmission runs the legacy Poisson-NLL-on-transmission path. density_maps[0] is the fitted U-238 map.

Raw Count Cubes

data = nereids.from_counts(sample_counts_3d, open_beam_counts_3d)
result = nereids.spatial_map_typed(
    data,
    energies,
    [u238],
    initial_densities=[0.0005],
    solver="auto",
    c=1.0,
)

solver="auto" uses counts-KL for from_counts(...) data and populates deviance_per_dof_map.

Shape contract:

  • sample_counts_3d, open_beam_counts_3d, transmission_3d, and uncertainty_3d use shape (n_energy, height, width).
  • energies.shape == (n_energy,).
  • dead_pixels, when supplied, uses shape (height, width) with True marking pixels to skip.

Keyword arguments:

OptionMeaning
temperature_k=293.6, fit_temperature=FalseFixed or fitted sample temperature.
initial_densities=[...]Initial density guesses.
dead_pixels=...(height, width) skip mask.
max_iter=200Maximum per-pixel optimizer iterations.
solver="auto"Dispatch from input type unless explicitly set.
background=FalseEnable SAMMY-style background for LM/transmission paths.
fit_back_d=False, fit_back_f=FalseFit the SAMMY exponential background tail (BackD * exp(-BackF / √E)). Requires background=True. Per-pixel back_d_map / back_f_map are populated on the returned SpatialResult (issue #538).
back_d_init=0.01, back_f_init=1.0Initial values for the exponential tail.
fit_alpha_1=False, fit_alpha_2=FalseFit counts-domain nuisance/background terms.
alpha_1_init=1.0, alpha_2_init=1.0Initial nuisance/background values.
c=1.0Proton-charge ratio for counts-KL spatial fitting.
enable_polish=True/False/NoneOverride counts-KL polish behavior; None auto-disables polish for multi-pixel maps.
fit_energy_scale=FalseFit per-pixel t0_us and l_scale maps.
t0_init_us=0.0, l_scale_init=1.0Initial energy-scale values.
energy_scale_flight_path_m=25.0Nominal flight path for energy-scale fitting.
resolution=...Tabulated resolution from load_resolution(...). Mutually exclusive with the Gaussian parameters below — pass either resolution= (tabulated) or the flight_path_m/delta_t_us/delta_l_m trio (Gaussian), never both.
flight_path_m=..., delta_t_us=..., delta_l_m=...Gaussian resolution parameters (mutually exclusive with resolution=).
groups=[...]Fit isotope groups instead of individual isotopes.
tzero_jacobian="..."Select the TZERO Jacobian implementation.
fit_energy_range=(emin, emax)Restrict the cost function to an energy window.

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 spatial_map(dead_pixels=...).

# 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

result = nereids.spatial_map(cube, energies, isotopes, dead_pixels=mask)

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 pathSourceWhat it shows
/ (this page)Hand-maintained docs/guide/src/python-api.mdCurated narrative tour of the typed APIs
/python/pdoc over the installed nereids wheel and nereids/__init__.pyi stubsAuto-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-fitting/examples/quickstart.rs).

Data I/O and NeXus/TOF

This page documents the Python-facing TIFF, NeXus, normalization, and TOF energy-grid 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, normalized transmission cubes, uncertainty cubes, and the arrays passed to from_counts(...) and from_transmission(...).

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 — per the #646 masking policy, 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 _10 would sort before _2 lexicographically);
  • 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. Pass sum_chunks=False for 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 ValueError on the default summing path — never a silent stack or a partial sum. With sum_chunks=False there 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 via chunk_inconsistent (and a UserWarning) instead of raising — this is exactly the case you reach for sum_chunks=False to 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): raise ValueError naming the file, frame, flat index, and value. For corrupt readout pixels, mask them per acquisition with detect_bad_pixels(...) instead of relaxing the policy.
  • "clip": clamp negative values to 0.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)

Normalization

Raw sample and open-beam arrays can be normalized to transmission:

transmission, uncertainty = nereids.normalize(
    sample_counts,
    open_beam_counts,
    pc_sample=sample_proton_charge,
    pc_ob=open_beam_proton_charge,
)

The formula is:

T = (C_sample / C_open_beam) * (PC_open_beam / PC_sample)

sample_counts and open_beam_counts must have identical shape. Optional dark_current is a 2D (height, width) array.

For fitting raw counts directly, prefer from_counts(...) or fit_counts_spectrum_typed(...) so the counts-KL dispatch can use the counts-domain likelihood.

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: float64 array 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 suitable for NEREIDS fitting:

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:

energies = nereids.tof_to_energy_centers(sample.tof_edges_us, flight_path_m)

# load_nexus_histogram returns ascending TOF. Reverse axis 0 to align with
# ascending energy centers.
sample_counts = sample.counts[::-1, :, :]
open_beam_counts = open_beam.counts[::-1, :, :]

data = nereids.from_counts(sample_counts, open_beam_counts)
result = nereids.spatial_map_typed(data, energies, [u238], c=charge_ratio)

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.

Counts vs Transmission Fitting

Use counts APIs when you have raw sample and open-beam counts:

fit = nereids.fit_counts_spectrum_typed(
    sample_counts_1d,
    open_beam_counts_1d,
    energies,
    [(u238, 0.0005)],
    c=charge_ratio,
)

Use transmission APIs when your data is already normalized:

fit = nereids.fit_spectrum_typed(
    transmission_1d,
    uncertainty_1d,
    energies,
    [(u238, 0.0005)],
)

For spatial maps, the same distinction is encoded by the input constructor:

counts_data = nereids.from_counts(sample_counts_3d, open_beam_counts_3d)
trans_data = nereids.from_transmission(transmission_3d, uncertainty_3d)

Instrument Resolution: Models and Calibration

A measured resonance dip is broadened by several largely separate effects: the instrument resolution (moderator pulse + flight-path + detector timing — a beamline property), Doppler broadening (sample temperature), the background / normalization (the measurement), and self-shielding / multiple scattering in optically-thick (black) resonances (the sample). On a single spectrum these trade off — too narrow a resolution looks like too hot a sample — so the instrument resolution must be characterized separately and then held fixed when fitting unknown samples.

This page covers (1) the three resolution models NEREIDS provides and (2) the calibrate → pin → fit procedure that determines the resolution from a known standard.

The three resolution models

All three are passed to forward_model and the fitters the same way; only the kernel source differs.

Gaussian (analytical)

Energy-dependent Gaussian from instrument timing parameters:

import numpy as np, nereids
hf = nereids.load_endf(72, 177)              # Hf-177
energies = np.linspace(2.0, 30.0, 2000)

T = nereids.forward_model(
    energies, [(hf, 5e-5)], temperature_k=300.0,
    flight_path_m=25.0, delta_t_us=1.0, delta_l_m=1e-3,
)

Tabulated UDR (Monte-Carlo file)

UDR = User-Defined Resolution — SAMMY’s term for a numerical resolution function supplied as a (time/energy, weight) table (SAMMY manual, “User-Defined Numerical Resolution Function”). In NEREIDS this is a measured/simulated asymmetric kernel, e.g. a VENUS FTS file:

udr = nereids.load_resolution("fts_bl10.txt", flight_path_m=25.0)
T = nereids.forward_model(energies, [(hf, 5e-5)], temperature_k=300.0, resolution=udr)

Ikeda–Carpenter (analytical moderator model)

A physically-grounded analytic moderator pulse — α(E) (fast rate), β (slow storage rate), R (storage fraction), with optional proton-burst and channel terms. Build parameter laws with EnergyLaw, then synthesize the kernel:

ic = nereids.IkedaCarpenter(
    flight_path_m=25.0, e_min_ev=0.5e-3, e_max_ev=1000.0,
    alpha=nereids.EnergyLaw.sqrt_e(0.30, 0.0),   # α(E) = 0.30·√E
    beta=0.10,
    r=nereids.EnergyLaw.exp_mev(25.0),           # R(E) → 0 in the eV regime
)
tab = ic.as_tabulated()                          # a TabulatedResolution
T = nereids.forward_model(energies, [(hf, 5e-5)], temperature_k=300.0, resolution=tab)

ic.kernel_at(energy_ev) returns the (tof_offsets_us, weights) kernel at one energy for inspection.

Performance note. An IkedaCarpenter synthesizes its kernel table once at construction and caches it (as_tabulated() just clones it). The cost to know about is that IC is not plan-cached when fitting the t0/L energy-scale at run time: unlike a loaded TabulatedResolution, its broadening plan is rebuilt each energy-scale evaluation, so an IC run-time fit over a large grid is slower than the tabulated path. Resolution calibration is once-per-experiment, so the calibrate → pin → fit workflow is unaffected; for production spatial maps, pin the calibrated kernel via as_tabulated() (which is plan-cached).

The calibrate → pin → fit procedure

Instrument resolution and flight-path geometry are beamline constants; density, temperature, background and normalization are per-measurement. So:

  1. Calibrate — measure a calibrant of known density and temperature; fit the resolution parameters with ρ, T fixed.
  2. Pin — keep the calibrated resolution; switch to the sample (same geometry).
  3. Fit — fit the sample ρ / T / both with the resolution pinned; re-fit background and normalization per measurement.

calibrate_resolution

cal = nereids.calibrate_resolution(
    energies, data, uncertainty,
    family="udr_corr",                  # "gaussian" | "udr_corr" | "ic"
    isotopes=[(hf, 5e-5)],              # KNOWN calibrant composition + density
    temperature_k=300.0,                # KNOWN calibrant temperature
    base_udr=udr,                       # required for family="udr_corr"
    restarts=2,
)
print(cal)                # ResolutionCalibration(family=udr_corr, chi2/dof=..., converged=..., n_free_params=2, bounds_hit=[])
print(cal.params())       # decoded fitted parameters
calibrated = cal.as_tabulated()         # pin this into the sample fit

The families calibrate different knobs:

familyfitsmeaning
gaussianΔt, ΔLanalytical Gaussian width
udr_corrs(E)=s0·(E/E_ref)^p on a base UDRtrust the MC shape, calibrate its width
icα(E)=e^c0·√E+e^c1, β, R (⊗ PSR triangle)full bounded analytic moderator shape

The ic family fits the complete bounded Ikeda–Carpenter shape: the prompt law α(E) = e^{c0}·√E + e^{c1} is positive at every energy by construction (the coefficients are exp-encoded — a calibration can no longer return an a1 < 0 that flips α negative below the fit window), and the storage rate β and mixing fraction R ∈ [0, 1] are free within physics bounds. The kernel is convolved with the SNS PSR (accumulator-ring) channel triangle — FWHM psr_fwhm_ns (default 350 ns, the value the VENUS FTS file header records as already folded into the tabulated kernel; 0 disables). Pass fit_psr=True to also fit the triangle FWHM as a 5th parameter. The PSR fold applies to ic only: tabulated/UDR kernels already carry it in the file and are never re-folded.

Every result echoes n_free_params and bounds_hit — a list of "name:lower" / "name:upper" strings for parameters pinned at a box bound. A pinned bound flags a degenerate direction: e.g. an eV-regime calibrant with no storage tail drives R → 0 ("r:lower"), and on that β↔R ridge the reported β carries no information.

Use .as_tabulated() for udr_corr / ic (a TabulatedResolution to pass as resolution=); use .gaussian_params()(delta_t_us, delta_l_m) for the Gaussian family. For ic, cal.params() returns the decoded {a0, a1, beta, r, psr_fwhm_us} (the raw theta is ln/box-encoded optimizer space).

Pin and fit the sample

fit = nereids.fit_spectrum_typed(
    sample_T, sample_unc, energies, [(hf, 1e-4)],
    temperature_k=300.0, fit_temperature=True,   # recover ρ and T
    resolution=calibrated,                        # PINNED
)
print(fit.densities, fit.temperature_k, fit.reduced_chi_squared)

Choosing a calibrant (important)

The fit absorbs every unmodeled broadening into the “resolution”, so a poor calibrant yields a contaminated, non-transferable result:

  • Use non-black resonances (T_min ≈ 0.2–0.8). Geometric thinness is not enough — at a strong resonance the cross-section is thousands of barns, so even a thin foil is optically thick at the peak and its self-shielding / multiple scattering would be soaked into the “resolution”.
  • Model the background / normalization during calibration (fit_background=True), then re-fit it per sample (it does not transfer).
  • Same geometry for calibrant and sample (flight path, sample-to-detector).

Guidance

  • Density / isotopic characterization is robust to the resolution-model choice; temperature is sensitive — calibrate carefully before trusting a fitted temperature.
  • The resolution width is well-determined; the resolution family is not, from a calibrant alone. The ic kernel anchors its mode at zero offset, so a right-skewed pulse’s centroid lags by ~1/α(E), shifting a broadened dip’s apparent energy. For the a1=0 prompt law that lag is ≈1/√E — the same basis as a flight-path (L_scale) error (leading-order for a1≠0) — so absolute dip position cannot distinguish an asymmetric kernel from a small L miscalibration. By default calibrate_resolution pins the energy scale (fit_t0=fit_l_scale=False): a pure shape/width fit on the already energy-calibrated grid. The cross-family χ² then discriminates on shape and position, which is honest only when (t0, L) are independently known; otherwise the position part is confounded with L.
  • To handle position honestly, fit the shared energy scale under a metrology prior. Set fit_t0=True / fit_l_scale=True (centered at t0_center_us / l_scale_center, with Gaussian priors t0_prior_us / l_scale_prior from the instrument’s flight-path / timing metrology). The fit reports position_t0_us, position_l_scale, and prior_penalty. Do not fit position with a flat prior — a free L_scale absorbs the asymmetric-kernel lag and corrupts the calibrated width. With a weak prior, family discrimination collapses toward the position-independent skew/tail evidence only (χ²/dof ≈ 1.1–1.3 in synthetic Hf-177 studies), so report it as a function of the prior strength rather than claiming strong discrimination.
  • A worked end-to-end example (build the models, calibrate, pin, fit) is in examples/notebooks/workflows/06_resolution_calibration.ipynb.

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 Pixi environment, fastmcp is intentionally not a default dependency because it can conflict with conda metadata packages. Install it manually in the environment when MCP support is needed.

Behavior when fastmcp is not installed

The two entry points differ on purpose:

Entry pointWithout 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(...), forward_model(...), and detect_isotopes(...) 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.md
  • smcp_manifest.md
  • nereids_manifest.md
  • nereids_mcp.json
  • analysis.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 single-spectrum manifest:

---
{
  "name": "u238-spectrum-demo",
  "tool": "nereids",
  "physics": "neutron-resonance",
  "analysis": {
    "mode": "single_spectrum",
    "data": {
      "kind": "transmission_npz",
      "path": "spectrum.npz"
    },
    "isotopes": [
      {
        "isotope": "U-238",
        "initial_density": 0.001,
        "library": "endf8.1"
      }
    ],
    "fit": {
      "solver": "lm",
      "max_iter": 100
    },
    "resolution": {
      "kind": "gaussian",
      "flight_path_m": 25.0,
      "delta_t_us": 0.5,
      "delta_l_m": 0.005
    },
    "output": {
      "directory": "output"
    }
  }
}
---

For synthetic demo data, resolution can be disabled:

"resolution": {"kind": "none"}

For real instrument data, use Gaussian resolution parameters or a tabulated resolution file. Synthetic data often does not need an instrument resolution file; real experiments normally do.

Supported Workflow Inputs

single_spectrum fits one spectrum. It supports:

  • counts_npz or counts: a .npz file with sample_counts, open_beam_counts, and energies_ev by default. Arrays may be 1D spectra or 3D cubes; 3D cubes are summed over pixels before fitting one spectrum.
  • transmission_npz, transmission, or spectrum: a .npz or text file with transmission, optional uncertainty, and an energy grid.

density_map and spatial_map fit every pixel in a 3D cube. They support:

  • transmission_npz or transmission: a .npz file with 3D transmission and optional 3D uncertainty arrays.
  • transmission_tiff or tiff: a multi-frame TIFF transmission stack plus an energy_grid entry.
  • counts_npz or counts: a .npz file with 3D sample_counts and open_beam_counts arrays.
  • nexus_histogram or nexus: sample and open-beam NeXus histogram files, configured with sample_path and open_beam_path.

Paired arrays must have matching shapes. The number of energy points must match the first axis of the data arrays. Energy grids must be strictly monotonic. See Data I/O and NeXus/TOF for the energy-ordering contract (descending grids are reversed with the aligned arrays before fitting).

For NeXus histogram inputs, MCP follows the conventions documented in Data I/O and NeXus/TOF. Briefly: NeXus loaders return counts in ascending TOF order; the workflow derives ascending energy centers with tof_to_energy_centers(...) and reverses counts along axis 0 to match before fitting. If the manifest does not specify flight_path_m, the loader metadata is used when available, with a 25 m fallback. delay_us defaults to 0.

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",
      "flight_path_m": 25.0,
      "delay_us": 0.0
    },
    "isotopes": [
      {"isotope": "U-238", "initial_density": 0.001}
    ],
    "fit": {
      "solver": "lm",
      "max_iter": 100
    },
    "resolution": {
      "kind": "gaussian",
      "flight_path_m": 25.0,
      "delta_t_us": 0.5,
      "delta_l_m": 0.005
    }
  }
}
---

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.npz
  • nereids_mcp_result.json

For density_map or spatial_map, it writes:

  • nereids_density_map.npz
  • nereids_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, forward model, detectability, 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 presents three entry points:

  • Load & Fit Data – open the wizard for single-spectrum or spatial-map fitting
  • Forward Model – explore theoretical transmission spectra without loading data
  • Detectability – estimate trace-isotope sensitivity before an experiment

Decision Wizard

After selecting Load & Fit Data, a short wizard asks:

  1. Fitting type: Single spectrum or spatial map
  2. Data format: “Raw Events (HDF5/NeXus)”, “Histogram, Pre-Normalization”, or “Transmission (Already Normalized)” — quoted as the wizard cards label them

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 resolution) and toggle instrument-resolution broadening. (The solver choice — Levenberg-Marquardt or Poisson KL divergence — is set on the Analyze step.)

Analyze

Choose the solver (Levenberg-Marquardt or Poisson KL divergence), then run the fit. For spatial maps, a progress bar tracks per-pixel fitting with rayon parallelism. Click any pixel to inspect its individual fit. Fit feedback shows green (good fit) 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: switch between Analysis, Forward Model, and Detectability
  • 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

Forward Model

Compute theoretical transmission spectra for arbitrary isotope mixtures. Adjust densities with sliders and see the spectrum update in real-time. Hero spectrum layout with per-isotope contribution lines.

Detectability

Analyze whether a trace isotope is detectable in a given matrix material. Multi-matrix support with resolution broadening. Shows a delta-T spectrum and verdict badges (DETECTABLE / NOT DETECTABLE / OPAQUE MATRIX).

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 intended as user-facing examples, not as the primary API contract. The API contract is the Python type stubs, Python tests, Rust tests, mdBook guide, and Rustdoc.

Verification Status

Notebook groupCurrent statusSmoke-tested in CIExternal requirements
foundations/Current examples for cross-sections, broadening, URR, and transmission physics.No. Covered indirectly by Rust/Python physics tests.ENDF downloads on first run for notebooks that call load_endf(...).
building_blocks/Current examples for ENDF loading, fitting, grouped isotopes, custom resolution, and TIFF I/O.No. Core APIs are covered by tests/test_nereids.py.ENDF downloads on first run; TIFF notebook may require local generated files.
workflows/Current end-to-end synthetic workflows.No.ENDF downloads on first run.
applications/Reference-data workflow.No.Requires external PLEIADES/Git LFS data, not bundled in normal PyPI installs.

As of the docs workflow in this repository, notebooks are not executed by pixi run doc-guide, pixi run doc-build, or GitHub Pages publishing. Before using a notebook as release evidence, run it manually or add a notebook execution job with controlled data and network policy.

First-Run Network Behavior

Notebooks that call nereids.load_endf(...) may need network access the first time they fetch an isotope/library combination. ENDF files are cached locally after retrieval. To avoid network access, use local fixtures and nereids.load_endf_file(...) where practical.

Reference Data

The application notebook uses larger reference data from the PLEIADES test data repository via tests/data/pleiades_data/. That data is a submodule and uses Git LFS. It is not guaranteed to be available in a fresh source checkout unless submodules and LFS objects have been initialized.

git submodule init
git submodule update
cd tests/data/pleiades_data
git lfs pull

Release Expectation

For a release, document one of these states in the release notes:

  • notebooks were smoke-run locally with the exact package version,
  • notebooks were not run and remain tutorial examples only, or
  • a subset was run, with any skipped notebooks and data/network reasons listed explicitly.

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

CratePurpose
endf-matZero-dependency lookup tables: element symbols, MAT numbers, natural abundances, ZA encoding
nereids-coreCore types (Isotope, Resonance), physical constants, element data, error types
nereids-endfENDF file retrieval from IAEA, local caching, File 2 resonance parameter parsing
nereids-physicsCross-section calculation (Reich-Moore, SLBW, RML, URR), Doppler/resolution broadening, Beer-Lambert transmission
nereids-ioTIFF stack and NeXus/HDF5 loading, TOF-to-energy conversion, normalization, export
nereids-fittingLevenberg-Marquardt and Poisson KL divergence optimizers, transmission fit model
nereids-pipelineSingle-spectrum fitting, per-pixel spatial mapping (rayon), trace detectability
nereids-pythonPyO3 Python bindings (not published to crates.io)
nereids-guiegui desktop application (not published to crates.io)

Data Flow

The standard analysis pipeline processes data through these stages:

Raw TOF data (TIFF/NeXus)
    │
    ▼
Normalization (sample / open_beam → transmission)     [nereids-io]
    │
    ▼
Energy conversion (TOF bin edges → energy centers)    [nereids-io]
    │
    ▼
ENDF data (fetch resonance parameters from IAEA)      [nereids-endf]
    │
    ▼
Forward model (cross-sections → broadening → T(E))    [nereids-physics]
    │
    ▼
Fitting (minimize |T_measured - T_model|)              [nereids-fitting]
    │
    ▼
Spatial mapping (fit each pixel in parallel)           [nereids-pipeline]
    │
    ▼
Density maps, chi² maps, convergence maps              [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 Physics Reference 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 Spatial Mapping

Per-pixel fitting uses rayon for data parallelism. The outer pixel loop runs on a dedicated thread pool to avoid deadlocking with inner parallel operations (cross-section calculation, broadening).

Python Bindings and MCP

The Python bindings expose a high-level API (load_endf, load_endf_file, forward_model, fit_spectrum_typed, fit_counts_spectrum_typed, spatial_map_typed) that maps directly to the Rust pipeline. Typed input constructors (from_transmission, from_counts) select the fitting dispatch for spatial maps. TIFF/NeXus I/O helpers (load_tiff_stack, load_tiff_folder, probe_nexus, load_nexus_histogram, load_nexus_events, tof_to_energy_centers) 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

NEREIDS implements exact SAMMY physics for neutron resonance imaging. This chapter is a navigation guide to the rustdoc API documentation, not a standalone physics textbook.

All implementations reference specific sections of the SAMMY manual and SAMMY Fortran source files. See the rustdoc for each module for detailed equations and citations.

Cross-Section Formalisms

FormalismENDF LRFModuleSAMMY Reference
Reich-MooreLRF=3reich_mooreManual Sec. II, rml/
Breit-Wigner (single- and multi-level)LRF=1,2slbwManual Sec. II, mlb/
R-Matrix LimitedLRF=7rmatrix_limitedManual Sec. II
Unresolved Resonance RegionLRU=2urrManual Sec. VIII.A, acs/ (FITACS)

The urr module computes energy-averaged Hauser-Feshbach cross-sections from the average resonance parameters. The width-fluctuation correction is not yet implemented: the AMUN/AMUF degrees of freedom are parsed from ENDF File 2 but not yet used in the cross-section computation.

The penetrability and channel modules provide the underlying nuclear physics: hard-sphere phase shifts, penetrability factors, wave numbers, and statistical spin weights.

Broadening Models

Doppler Broadening

Free Gas Model (FGM) convolution accounting for thermal motion of target nuclei.

  • Module: doppler
  • SAMMY reference: fgm/ module (Dopfgm), manual Sec. III.B.1
  • Key function: doppler_broaden() — exact Free Gas Model convolution integral in velocity space (manual Eq. III B1.7, w²-weighted integrand); no psi/chi (Voigt) approximation is used

Resolution Broadening

Instrument resolution broadening from flight-path uncertainty, timing jitter, and moderator pulse width.

  • Module: resolution
  • SAMMY reference: convolution/ module, manual Sec. III.C
  • Supports: Gaussian convolution, Gaussian + exponential tail, tabulated resolution functions

Transmission Model

Beer-Lambert transmission: T(E) = exp(-sum_i n_i sigma_i(E))

Where n_i is the areal density (atoms/barn) and sigma_i(E) is the broadened total cross-section for isotope i.

  • Module: transmission
  • SAMMY reference: cro/, xxx/ modules, manual Sec. II; transmission experiments Sec. III.E.1
  • Handles multi-isotope samples with shared Doppler temperature (one global temperature parameter, optionally fitted jointly with densities)

Fitting Engines

Levenberg-Marquardt

Standard nonlinear least-squares minimization for Gaussian-distributed data.

  • Module: lm
  • SAMMY reference: fit/ module, manual Sec. IV
  • Parameters: areal densities with optional bounds, optional temperature fitting

Poisson KL Divergence

Maximum-likelihood fitting for low-count data where Gaussian statistics break down.

  • Module: joint_poisson – counts-domain joint-Poisson fit (conditional binomial deviance); the production path for counts data
  • Module: poisson – transmission-domain Poisson likelihood (projected damped Gauss-Newton); used for the transmission + PoissonKL combination
  • Reference: TRINIDI approach (trinidi/reconstruct.py)

ENDF Nuclear Data

Resonance parameters are sourced from evaluated nuclear data libraries (ENDF/B from NNDC with IAEA fallback; the other libraries from IAEA):

Supported libraries: ENDF/B-VIII.0, ENDF/B-VIII.1, JEFF-3.3, JENDL-5, TENDL-2023, CENDL-3.2.

Further Reading

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 fmt applies formatting (not just --check)
  • cargo clippy treats all warnings as errors
  • nereids-python is excluded because it requires PyO3/maturin build setup

Branch and PR Workflow

  1. Create a feature branch from main
  2. Make changes, commit with GPG signatures (git commit -S)
  3. Push and open a PR against main
  4. 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, not debug_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-guide builds the mdBook guide.
  • pixi run doc-build builds mdBook, builds Rustdoc, and copies Rustdoc to target/book/api.
  • The Python API reference matches the shipped bindings/python/python/nereids/__init__.pyi stubs for public functions, arguments, array shapes, and result objects.
  • The Data I/O and NeXus/TOF page matches current TIFF, NeXus, normalization, and tof_to_energy_centers(...) behavior.
  • PyPI metadata in pyproject.toml lists current optional extras, including mcp and gui.
  • GUI installation docs cover both pip install "nereids[gui]" and the direct nereids-gui package, 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

TaskCommand
Build allcargo build --workspace
Run testscargo test --workspace --exclude nereids-python
Formatcargo fmt --all
Lintcargo clippy --workspace --exclude nereids-python --all-targets -- -D warnings
Build Pythonpixi run build
Test Pythonpixi run test-python
Build docscd docs/guide && mdbook build
Build full docs sitepixi run doc-build
Serve docscd docs/guide && mdbook serve
Build rustdoccargo doc --workspace --no-deps --exclude nereids-python