Nucleide

Depletion

Burnup matrices, the Bateman equation, and the CRAM solver used by Nucleide.

Nucleide solves nuclide transmutation over a single time step using a burnup matrix formulation and the Chebyshev Rational Approximation Method (CRAM) for the matrix exponential.

Overview

For a vector of nuclide atom densities n(t)\mathbf{n}(t), the linearized depletion equation is

dndt=A n,n(t)=eAt n(0),\frac{d\mathbf{n}}{dt} = \mathbf{A}\,\mathbf{n}, \qquad \mathbf{n}(t) = e^{\mathbf{A}t}\,\mathbf{n}(0),

where A\mathbf{A} is the burnup matrix. Each column of A\mathbf{A} describes how a parent nuclide is lost and what it produces; each row describes how a daughter nuclide is gained.

Where this lives in the code

Matrix construction is in crates/depletion/src/matrix.rs and the CRAM solver is in crates/depletion/src/cram.rs. The public driver is deplete in crates/depletion/src/lib.rs.

Building the burnup matrix

Nucleide builds A\mathbf{A} from a Chain and a set of one-group reaction rates.

One column of the burnup matrix: the diagonal removes the parent; off-diagonal entries produce daughters.

Decay

For nuclide ii with half-life t1/2,it_{1/2,i}, the decay constant is

λi=ln⁡2t1/2,i.\lambda_i = \frac{\ln 2}{t_{1/2,i}}.

The diagonal entry loses atoms at rate λi\lambda_i:

Aii  −=  λi.A_{ii} \;\mathrel{-}=\; \lambda_i.

Each decay mode with branching ratio bi→jb_{i \to j} adds production to the off-diagonal:

Aji  +=  λi bi→j.A_{ji} \;\mathrel{+}=\; \lambda_i \, b_{i \to j}.

A decay entry may omit target (or set target="nothing", the same convention as target-less reactions): the daughter falls outside the modeled chain, so the diagonal loss still applies but no gain term is built — pure loss. An explicit target naming a nuclide absent from the chain stays a loud error, never silent loss.

Reactions

For a one-group reaction rate σϕ\sigma\phi of type rr on nuclide ii:

  • Loss: Aii−=σϕA_{ii} \mathrel{-}= \sigma\phi once per reaction type, even if the chain lists multiple branched entries for that type.
  • Production: each reaction entry with branching ratio bb and target jj contributes Aji  +=  σϕ b.A_{ji} \;\mathrel{+}=\; \sigma\phi \, b.
  • Light-particle secondaries: reactions such as (n,α)(n,\alpha), (n,p)(n,p), and (n,d)(n,d) emit light nuclides that are tracked when they appear in the chain (e.g. (n,α)→He-4(n,\alpha) \to \mathrm{He\text{-}4}).
  • Fission: if fission yields yi→ky_{i \to k} are present, production is distributed over all fission products: Aki  +=  σϕ yi→k.A_{ki} \;\mathrel{+}=\; \sigma\phi \, y_{i \to k}.

The final matrix A\mathbf{A} has units of s−1\mathrm{s}^{-1} and is sparse, with one diagonal entry per chain nuclide even if it is stable.

CRAM: matrix exponential by partial fractions

CRAM evaluates the action of the matrix exponential eAte^{\mathbf{A}t} on n0\mathbf{n}_0 in incomplete partial fraction (IPF) product form:

nk  =  nk−1  +  2 Re ⁣[αk (At−θkI)−1nk−1],n(t)=α0 nm,\mathbf{n}_k \;=\; \mathbf{n}_{k-1} \;+\; 2 \, \mathrm{Re}\!\left[ \alpha_k \, (\mathbf{A}t - \theta_k \mathbf{I})^{-1} \mathbf{n}_{k-1} \right], \qquad \mathbf{n}(t) = \alpha_0 \, \mathbf{n}_m,

where m=8m = 8 for order-16 and m=24m = 24 for order-48. The coefficients α0\alpha_0, αk\alpha_k, and θk\theta_k are pre-computed complex numbers published by Pusa. Each pole is applied sequentially to the running solution vector; these IPF coefficients are not interchangeable with the residues of the classic partial-fraction sum form.

Why the IPF product form?

Each step (At−θkI)−1nk−1(\mathbf{A}t - \theta_k \mathbf{I})^{-1}\mathbf{n}_{k-1} is a sparse linear solve rather than a full matrix exponential. Nucleide uses a sparse LU factorization from the linalg facade, and the symbolic pattern is reused across all poles.

Coefficients

Nucleide ships the CRAM-16 and CRAM-48 coefficient sets verbatim. CRAM-16 is cheaper; CRAM-48 is the production default.

Coefficient provenance (checkable)

Both sets are the incomplete-partial-fraction (IPF) coefficients, not the residues of the classic partial-fraction sum form — the two forms are not interchangeable (see the IPF product form above). Order-16 follows Pusa–Leppänen (2010)pusa-2010; the order-48 set follows the higher-order treatment in Pusa (2016)pusa-2016. The code comment in crates/depletion/src/cram.rs cites this as “Pusa & Li”; that string is imprecise and should name the two references above (with the exact published table for order-48 once confirmed against the coefficients — no table number is claimed here). Pinned anchors straight from the code: α0=2.124853710495224×10−16\alpha_0 = 2.124853710495224 \times 10^{-16} (order-16), α0=2.258038182743983×10−47\alpha_0 = 2.258038182743983 \times 10^{-47} (order-48); order-16 k=1k{=}1 pole θ1=3.509103608414918+8.436198985884374 i\theta_1 = 3.509103608414918 + 8.436198985884374\,i with α1=5.464930576870210×103−3.797983575308356×104 i\alpha_1 = 5.464930576870210{\times}10^3 - 3.797983575308356{\times}10^4\,i. The order-16 table below is a full transcription guard; the order-48 anchors are pinned by the coefficient_tests::pinned_coefficient_anchors transcription-guard test in crates/depletion/src/cram.rs (recorded in the decision log — coefficient values must not change).

kRe(θ)Im(θ)Re(α)Im(α)
13.5091036084149188.4361989858843745464.93057687021-37979.83575308356
25.9481522689511773.58745736201832290.45112476907548-1115.537522430261
3-5.26497134344264716.22022147316793234.4818070467641-422.8020157070496
41.41937589718566610.9253634844967294.53304067358312-295.1294291446048
56.4161776990994351.194122393370139728.3792954673409-120564.6080220011
64.9931747377179975.99688171360394236.48229059594851-115.5509621409682
7-1.41392846248888613.4977256988927525.47321630156819-26.39500283021502
8-10.8439170786969919.2774461671816523.94538338734709-5.650522971778156
CRAM-16 poles and residues (real and imaginary parts).

The CRAM-16 scaling constant is α0=2.124853710495224×10−16\alpha_0 = 2.124853710495224 \times 10^{-16}.

Analytic Bateman fast path

For decay-only chains the burnup matrix is lower-triangular Λ\mathbf{\Lambda} and the depletion equation admits a closed form (Bateman, Proc. Cambridge Philos. Soc. 15 (1910), 423–427)bateman-1910:

dNdt=Λ N,Λjj=−λj,Λij=bj→i λj    (i>j),λj=ln⁡2t1/2,j,(E1)\frac{d\mathbf{N}}{dt} = \mathbf{\Lambda}\,\mathbf{N}, \qquad \Lambda_{jj} = -\lambda_j, \qquad \Lambda_{ij} = b_{j \to i}\,\lambda_j \;\; (i > j), \qquad \lambda_j = \frac{\ln 2}{t_{1/2,j}}, \tag{E1}

with λj=0\lambda_j = 0 for stable nuclides. Λ\mathbf{\Lambda} is assembled from the same decay data as A\mathbf{A} (spontaneous-fission daughters skipped, He-4\mathrm{He\text{-}4}/H-1\mathrm{H\text{-}1} secondaries from alpha/proton decay included).

When the diagonal entries are distinct and the chain is acyclic, the eigendecomposition Λ=C D C−1\mathbf{\Lambda} = \mathbf{C}\,\mathbf{D}\,\mathbf{C}^{-1} with D=diag(−λ)\mathbf{D} = \mathrm{diag}(-\lambda) has the unit-diagonal recursion (Amaku–Pascholati–Vanin)amaku-2010

Cii=1,Cij=∑k=ji−1Λik CkjΛjj−Λii    (i>j),(E2,E3)C_{ii} = 1, \qquad C_{ij} = \sum_{k=j}^{i-1} \frac{\Lambda_{ik}\,C_{kj}}{\Lambda_{jj} - \Lambda_{ii}} \;\; (i > j), \tag{E2,E3} Cii−1=1,Cij−1=−∑k=ji−1Cik Ckj−1    (i>j),(E4)C^{-1}_{ii} = 1, \qquad C^{-1}_{ij} = -\sum_{k=j}^{i-1} C_{ik}\,C^{-1}_{kj} \;\; (i > j), \tag{E4} N(t)=C diag(e−λt) C−1 N0.(E5)\mathbf{N}(t) = \mathbf{C}\,\mathrm{diag}(e^{-\lambda t})\,\mathbf{C}^{-1}\,\mathbf{N}_0. \tag{E5}

Activity and cumulative-decay observables follow (E8) of crates/depletion/src/bateman.rs: Ai=λiNiA_i = \lambda_i N_i (implemented by activity_vec in crates/depletion/src/integrate.rs) with the diagonal cumulative integral ∫0te−λs ds=(1−e−λt)/λ\int_0^t e^{-\lambda s}\,ds = (1 - e^{-\lambda t})/\lambda (λ=0\lambda = 0 limit →t\to t; cumulative_decays in crates/depletion/src/inventory.rs). Labels (E6)–(E7) are intentionally unassigned in both the code and this page so the two stay consistent; a code-side close-the-gap renumber is left to the depletion owner (no formula changes either way).

C\mathbf{C}/C−1\mathbf{C}^{-1} are time-independent, so Nucleide builds them once per chain (BatemanCache in crates/depletion/src/bateman.rs, generated at runtime from the crate’s own chain data) and reuses them across timesteps; each solve is two triangular matvecs. The single-chain Bateman sum is the special case of this form for a linear chain, and the two/three-nuclide A→B→CA \to B \to C formulas in the worked example above drop out directly.

Two precision variants share the cache: bateman (plain accumulation) and bateman_hp (f64-careful summation only — per-row terms sorted by magnitude and summed with Neumaier compensation, plus exp_m1 forms for small λt\lambda t; no extended-precision types, no new dependencies). On a synthetic 10-order-spread chain (year-scale parent, microsecond-scale progeny) the HP variant beats-or-matches standard summation while both conserve atoms to 10−810^{-8}.

Fast-path limits (D1–D4)

The dispatcher falls back to CRAM-48 — never an error — when the closed form does not apply: (D1) near-equal decay constants with a live coupling path (∣λj−λi∣≤10−12max⁡|\lambda_j - \lambda_i| \le 10^{-12}\max, uncoupled pairs such as two stables take the limit form Cij=0C_{ij} = 0 instead); (D2) cyclic or out-of-order decay topology (any gain above the diagonal); (D3) stable nuclides (λ=0\lambda = 0, handled inline via e0=1e^0 = 1 limit forms); (D4) reactions or fission active (assembled matrix differs from the pure-decay reconstruction). A zero timestep returns the input exactly. Select the kernel with method="cram16", "cram48", "bateman", or "bateman_hp" on deplete, deplete_series, DepletionSystem.solve, and Inventory.decay (default "cram48").

Algorithm flow

Worked example: sequential decay chain

Consider a three-nuclide chain:

A→λAB→λBC,A \xrightarrow{\lambda_A} B \xrightarrow{\lambda_B} C,

with λA=10−6 s−1\lambda_A = 10^{-6}\,\mathrm{s}^{-1}, λB=10−5 s−1\lambda_B = 10^{-5}\,\mathrm{s}^{-1}, and CC stable. The burnup matrix is

A=[−λA00λA−λB00λB0].\mathbf{A} = \begin{bmatrix} -\lambda_A & 0 & 0 \\ \lambda_A & -\lambda_B & 0 \\ 0 & \lambda_B & 0 \end{bmatrix}.

Starting from nA(0)=1015n_A(0) = 10^{15} atoms and nB(0)=nC(0)=0n_B(0) = n_C(0) = 0, the analytic solution after t=105 st = 10^5\,\mathrm{s} is

  1. nA(t)=nA(0) e−λAt=1015 e−0.1≈9.0484×1014n_A(t) = n_A(0)\,e^{-\lambda_A t} = 10^{15}\,e^{-0.1} \approx 9.0484 \times 10^{14}.

  2. nB(t)=nA(0) λAλB−λA (e−λAt−e−λBt)≈5.9662×1013n_B(t) = n_A(0)\,\frac{\lambda_A}{\lambda_B - \lambda_A}\, (e^{-\lambda_A t} - e^{-\lambda_B t}) \approx 5.9662 \times 10^{13}.

  3. nC(t)=nA(0)−nA(t)−nB(t)≈3.5501×1013n_C(t) = n_A(0) - n_A(t) - n_B(t) \approx 3.5501 \times 10^{13}.

Nucleide’s CRAM-48 result matches this analytic solution to better than one part in 10710^7 for nBn_B and one part in 10910^9 for nCn_C. This exact chain is exercised in crates/depletion/src/lib.rs as analytic_two_step_decay_cram48.

Loading chart…

Time-series integrators

For schedules with per-step rates, crates/depletion/src/integrate.rs chains single-step CRAM solves. Each Step carries a length dt in seconds plus prescribed one-group rates in the same layout as DepletionSystem::build; the matrix is rebuilt from each step’s rates, so time-varying rates are handled exactly per step. Integrator selects the method: Predictor (one CRAM solve per step), Cecm (predictor plus a corrector re-solve from the step start with the averaged matrix), or Cf4 (fourth-order commutator-free quasi-Magnus, two half-step CRAM solves). Each step’s exponential additionally honors the Method kernel: Cram runs the CRAM order named above, while Bateman/BatemanHp evaluate the analytic closed form per step (steps with live rates fall back to CRAM-48; CF4 stages scale the timestep rather than the matrix so per-stage systems stay in decay form). Because rates here are prescribed rather than recomputed from the predicted composition, the corrector matrix equals the predictor matrix and Cecm reproduces Predictor up to roundoff; the two-solve structure is kept so flux-coupled extensions can plug a recomputed matrix into the same code path. Under the same piecewise-constant reconstruction each Cf4 stage is a half-step exponential, so its accuracy is at least as good as Predictor on the analytic test chain.

Results come back as TimeSeries with one row per time node in chain-nuclide order (times[0] == 0.0 echoes the initial state):

  • times: cumulative time nodes in seconds.
  • atoms: atom counts per node.
  • activity: Ai=λiNiA_i = \lambda_i N_i in Bq, clamped at zero.
  • decay_heat: Hi=λiNiEiH_i = \lambda_i N_i E_i in W, clamped at zero.

Per-nuclide decay energies resolve chain-first: the chain’s decay_energy attribute (eV converted to MeV) when positive and finite, else the ENDF/B-VII.1 prompt-decay-energy table (nucleide_nuclei::data::decay_energy_mev_by_name), else 0.0 (stable and synthetic nuclides) — one evaluated table, no parallel hand-kept fallback.

Python exposure

nucleide.depletion.deplete_series wraps integrate with an integrator argument ("predictor", "cecm", "cf4") and a method argument ("cram16", "cram48", "bateman", "bateman_hp"; an explicitly non-default method overrides order). The per-step decay_heat maps use the same chain → table → 0.0 resolution as the core, so they match integrate exactly; the core t = 0 row is omitted. See the Run depletion tutorial.

Year-length convention

Production code uses the Julian year: 1 yr = 365.25 d = 31,557,600 s (SECONDS_PER_YEAR in crates/depletion/src/inventory.rs; the y unit in crates/alara-io/src/schedule.rs and crates/alara-io/src/deck.rs). Rationale: the Julian year is the standard year-scale interval for time-unit conversion, and both the depletion solver and the ALARA schedule/deck layers convert through it.

Two deliberate neighbors differ and must not be “unified” by accident:

  • crates/fispact-io/src/inventory.rs uses a 365-day year to match the ALARA reference tool’s file-format convention — format fidelity, not physics.
  • Some test anchors divide by the Gregorian mean year 365.2425 d (3.1556952×1073.1556952 \times 10^7 s/yr), e.g. the Cs-137 30.08 yr anchor in the nuclei half-life tests. The gap between conventions there is ≈6×10−4\approx 6 \times 10^{-4} yr against a 0.010.01 yr tolerance, so no validation gate moves either way.

Decision (no gate movement): keep the Julian production convention, document the split here, and migrate test anchors to 31,557,600 s/yr only where the current tolerance already hides the drift — tolerances themselves stay untouched. The alara-io and fispact-io year definitions are referenced from here rather than restated.

Assumptions and limitations

  • One-group reaction rates: CRAM in Nucleide uses a single scalar rate per reaction channel. Multigroup fluxes must be collapsed externally.
  • Fixed cross sections over the step: A\mathbf{A} is treated as constant during tt; multi-step schedules rebuild it from each step’s prescribed rates, with no flux renormalization between steps.
  • Fission yields are single-energy: the yield set at the lowest incident neutron energy is used, matching OpenMC’s default.
  • Decay branching ratios: chains read from XML keep the file values verbatim; only programmatically built chains are renormalized (the largest branch is adjusted so the modes sum to exactly 1.0).
  • Stable nuclides have zero diagonal: they act as sinks in the chain. The Bateman fast path handles them through limit forms (e0=1e^0 = 1).
  • CRAM order: order-16 is adequate for short, simple chains; order-48 is recommended for production burnup matrices. Pure-decay chains may use the bateman/bateman_hp closed form instead (same method= selector; automatic CRAM-48 fallback outside decay-only triangular topology).
  • Bateman conditioning: the closed form sums alternating-sign terms, so long chains (tens of members) or extreme half-life spreads can suffer catastrophic cancellation in float64 — bateman_hp (sorted Neumaier summation) extends the usable range, but ill-conditioned chains should use CRAM-48, which remains the default. The automatic fallback covers non-triangular topology, not ill-conditioning: pick the method deliberately for long chains.

Adjacent capabilities found in PyNE, OpenMC, and the literature that Nucleide does not implement:

  • Multi-step time integration: OpenMC ships predictor-corrector integrators (CECM, CELI, CF4, EPCRK4, and their SI variants) that recompute reaction rates between steps; Nucleide’s Integrator::{Predictor, Cecm, Cf4} uses prescribed per-step rates, so Cecm currently degenerates to Predictor.
  • Substepping: OpenMC’s CRAM solver can subdivide a step while reusing the LU factorizations; Nucleide has no substep parameter.
  • Transport-coupled depletion: OpenMC normalizes tally reaction rates to absolute rates from a power or source normalization; Nucleide assumes rates are already given in s−1\mathrm{s}^{-1}.
  • Energy-dependent fission yields: OpenMC selects between constant, thermal, and thermal+fast yield modes; Nucleide uses the lowest-energy yield set.
  • Feed and removal terms: OpenMC adds transfer rates and external source vectors to the burnup matrix for MSR-style continuous processing.
  • Chain truncation: PyNE’s transmutagen-generated solver truncates the transmutation tree by density tolerance; Nucleide uses a fixed chain.
  • Krylov subspace methods: an alternative to CRAM for the Bateman equations; see Josey et al. (2017)josey-2017.
  • Precomputed decay datasets: the radioactivedecay package ships precomputed decay matrices for fast lookup; Nucleide’s BatemanCache follows the same time-independent-cache idea but generates the eigendecomposition at runtime from its own chain data (clean-room textbook implementation, no vendored data).