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
IkedaCarpentersynthesizes 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 thet0/Lenergy-scale at run time: unlike a loadedTabulatedResolution, 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 viaas_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:
- Calibrate — measure a calibrant of known density and temperature; fit
the resolution parameters with
ρ, Tfixed. - Pin — keep the calibrated resolution; switch to the sample (same geometry).
- 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:
| family | fits | meaning |
|---|---|---|
gaussian | Δt, ΔL | analytical Gaussian width |
udr_corr | s(E)=s0·(E/E_ref)^p on a base UDR | trust 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.
Pass intervals=True to also measure cal.intervals, the one-sigma
(lower, upper) range of each fitted parameter in the same order as theta.
Each bound is where the fit’s chi-squared, minimized over the other
parameters, rises by one.
The two sides differ, and by a lot: a kernel narrower than the line it
broadens leaves no trace, so chi-squared is flat below the intrinsic width and
climbs steeply above it.
A bound sitting exactly on the parameter’s box edge means the data does not
constrain that side at all.
It is off by default because measuring it re-minimizes the other parameters at
every trial point and costs several times the calibration.
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.
Those two are W-parameters, the width in exp(-x^2/W^2), so sigma = W/sqrt(2)
and FWHM = 1.6651*W — convert before comparing them against a metrology
number, or convert the metrology number with nereids.width_from_sigma. 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)
Fit the sample and the calibrant together
Pinning the calibrated resolution reports the temperature as more certain than it is. Width and temperature broaden the line the same way, so pinning throws away the uncertainty that belongs to their degeneracy. Summarising the calibration into a prior does not recover it: the calibration’s own uncertainty is neither Gaussian nor separable, and for the Gaussian family the two width parameters trade off almost exactly.
fit_with_calibrant puts both spectra in one objective against one shared
resolution, so nothing has to be summarised:
fit = nereids.fit_with_calibrant(
sample_T, sample_unc, energies, [(hf, 1e-4)],
cal_T, cal_unc, cal_energies, [(hf, 5e-5)], 300.0, # KNOWN calibrant
temperature_k=300.0, fit_temperature=True,
flight_path_m=25.0, delta_t_us=dt, delta_l_m=dl, # seeds, not pins
)
print(fit.temperature_k, fit.temperature_k_unc)
print(fit.delta_t_us, fit.delta_l_m) # fitted, not pinned
The calibrant’s densities and temperature stay fixed – that is what makes it a
calibrant – and the resolution is the only thing the two spectra share.
delta_t_us / delta_l_m are starting values here rather than pins, and a
standalone calibrate_resolution result is the natural seed.
Expect a larger temperature_k_unc than the pinned route reports.
That is the correction, not a regression: the pinned number is the uncertainty
of a temperature measured with a resolution assumed exact, and the resolution
is not exact.
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
ickernel 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 thea1=0prompt law that lag is ≈1/√E — the same basis as a flight-path (L_scale) error (leading-order fora1≠0) — so absolute dip position cannot distinguish an asymmetric kernel from a smallLmiscalibration. By defaultcalibrate_resolutionpins 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 withL. - To handle position honestly, fit the shared energy scale under a metrology
prior. Set
fit_t0=True/fit_l_scale=True(centered att0_center_us/l_scale_center, with Gaussian priorst0_prior_us/l_scale_priorfrom the instrument’s flight-path / timing metrology). The fit reportsposition_t0_us,position_l_scale, andprior_penalty. Do not fit position with a flat prior — a freeL_scaleabsorbs 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.