Cross-section perturbation pipelines
Three pipelines live in kika/sampling/ for sampling perturbed nuclear data:
Pipeline |
Perturbs |
Driven by |
Output |
|---|---|---|---|
|
σ(E) on a multigroup grid |
MF33 cov |
ACE |
|
σ(E) on the MF33 native grid |
MF33 cov |
PENDF (+ optional ACE) |
|
MF4 Legendre coefficients |
MF34 cov |
ENDF (+ optional ACE) |
|
both σ(E) and MF4 |
MF33 + MF34 |
ENDF + PENDF + ACE |
This guide focuses on pendf_perturbation and combined_perturbation — the
two newer pipelines built on the MF33 native grid. ace_perturbation is the
older multigroup path; prefer the others.
How the new pipelines are wired
Both new pipelines split into two stages so the combined orchestrator can reuse the single-physics building blocks without re-running NJOY:
Stage A — sample on the covariance native grid, write a perturbed tape (PENDF or ENDF) to disk. No NJOY downstream of RECONR.
Stage B — given a
(perturbed ENDF, perturbed PENDF)pair, run NJOY (skipping RECONR) → ACE + xsdir.
pendf_perturbation runs Stage A then optionally Stage B with the
original ENDF + the perturbed PENDF.
combined_perturbation runs Stage A for MF33 and Stage A for MF34
independently, then calls Stage B with the perturbed ENDF + perturbed
PENDF for sample i.
The decoupling matters because MF33↔MF34 cross-correlations are
structurally zero in our methodology (see
manuscript/notes/mf3_mf4_cross_correlations_methodology.md), so the two
samplings are genuinely independent and pairing-by-index is correct.
Files at a glance
kika/sampling/
├── pendf_perturbation.py # MF33 σ → PENDF (+ ACE)
│ └── perturb_PENDF_files()
├── mf33_sampling.py # cov assembly + MF3 rewrite helpers (unit-testable)
│ ├── load_mf33_covariance()
│ ├── apply_factors_to_pendf_mf3()
│ └── perturb_pointwise_xs()
├── endf_perturbation.py # MF34 → MF4 ENDF (pre-existing)
│ └── perturb_ENDF_files()
├── combined_perturbation.py # MF33 + MF34 paired
│ └── perturb_ENDF_PENDF_combined()
├── run_pendf_perturbation.py # CLI wrapper
├── run_combined_perturbation.py # CLI wrapper
└── generators.py, utils.py, diagnostics.py (shared sampling math + parquet)
kika/processing/njoy_pendf_cache.py
# SHA256-keyed PENDF cache — runs NJOY RECONR on first call,
# returns cached tape on subsequent calls. Used by both new pipelines.
kika/njoy/
├── run_njoy.py # full chain (RECONR → ACER)
│ ├── run_njoy() # original
│ └── run_njoy_with_pendf() # injects an externally-generated PENDF
└── templates.py
├── NJOY_INPUT_TEMPLATE # standard chain
└── NJOY_INPUT_TEMPLATE_WITH_PENDF # RECONR replaced by `moder 26 -21`
tests/
├── test_mf33_sampling.py # unit tests (no NJOY)
├── test_pendf_perturbation.py # integration (skips without NJOY+ENDF)
└── test_combined_perturbation.py
Pipeline 1 — perturb_PENDF_files (MF33 → σ)
Samples cross-section perturbation factors directly on the MF33 native energy grid (no multigroup), rewrites MF3 in the PENDF, and optionally chains through NJOY broadr → acer to produce ACE.
Sequential flow
perturb_PENDF_files()
for each ENDF file:
1. parse MF33 from ENDF
2. cached PENDF via NJOY RECONR (kika.processing.njoy_pendf_cache)
3. assemble unified covariance on the MF33 native union grid
(load_mf33_covariance — converts absolute LB-types to relative,
resolves NC LTY=0 sum-rules using PENDF MF3 σ(E))
4. extract per-MT thresholds from the PENDF (for threshold-bin pinning)
5. generate_samples(cov, ...) → factors of shape (N, n_MTs × n_native_bins)
6. write the per-isotope parquet of factors
7. for each sample s:
a. apply_factors_to_pendf_mf3(...)
σ_new(E_k) = σ_orig(E_k) × factor[g(E_k)] piecewise constant
b. if "ace" in output_formats:
_stage_b_run_njoy_for_pair(original ENDF, perturbed PENDF)
Quick start
from kika.sampling.pendf_perturbation import perturb_PENDF_files
summary = perturb_PENDF_files(
endf_files="/path/to/Fe-56.endf",
mt_list=[2, 102], # MTs to perturb
num_samples=100,
output_formats=("pendf", "ace"),
njoy_exe="/home/MONLEON-JUAN/NJOY2016/build/njoy",
ace_temperatures=[293.6],
ace_library_name="endfb81",
pendf_cache_dir="/mnt/c/Users/MONLEON-DE-LA-JAN/AppData/Local/Temp/kika_pendf_cache",
output_dir="/path/to/output",
seed=42,
nprocs=4,
)
For a PENDF-only run (no NJOY post-processing), drop ACE:
output_formats=("pendf",) # no NJOY post-PENDF; NJOY only used for RECONR
Key knobs
All knobs live as ALL_CAPS constants at the top of pendf_perturbation.py
and as defaults in perturb_PENDF_files. The ones that actually move the
needle:
space="log"— sampling space (preserves σ > 0; locked-in default).pin_threshold_bins=True— below-threshold bins forced to factor 1.max_relative_std=3.0— 300 % cap on per-bin σ relative std.psd_method="auto"+autofix=None— autofix is off by default; set to"soft"/"medium"/"hard"only if a covariance refuses to sample.verbose_diagnostics— single integer gating all extra logging (0 = off, 1 = summaries, 2 = per-MT).
Pipeline 2 — perturb_ENDF_PENDF_combined (MF33 + MF34)
Pairs the σ-perturbed PENDF with the angular-perturbed ENDF, sample by sample, and runs NJOY once per pair to produce one ACE per sample with both perturbations applied.
Sequential flow
perturb_ENDF_PENDF_combined()
Stage A.MF34: perturb_ENDF_files(generate_ace=False)
→ mf4/endf/<zaid>/<NNNN>/<base>_<NNNN>.endf
Stage A.MF33: perturb_PENDF_files(output_formats=("pendf",))
→ mf3/pendf/<zaid>/<NNNN>/<base>_<NNNN>.pendf
Stage B : for each sample s:
_stage_b_run_njoy_for_pair(
endf_path = perturbed ENDF for sample s,
pendf_path = perturbed PENDF for sample s,
...)
→ ace/<library>/<TK>/<zaid>/<NNNN>/...
Seed handling
A single seed=S reproduces the same paired set: MF33 sampling uses S,
MF34 uses S + 1. The two draws stay independent (no MF33↔MF34 correlation
is implied) but are repeatable.
Quick start
from kika.sampling.combined_perturbation import perturb_ENDF_PENDF_combined
summary = perturb_ENDF_PENDF_combined(
endf_files="/path/to/Fe-56.endf",
mt_list_mf33=[2, 102, 103], # σ MTs
mt_list_mf34=[2], # angular MTs (often only MT=2 has MF34)
legendre_coeffs=[1, 2, 3],
num_samples=200,
generate_ace=True,
njoy_exe="/home/MONLEON-JUAN/NJOY2016/build/njoy",
ace_temperatures=[293.6],
ace_library_name="endfb81",
pendf_cache_dir="/mnt/c/Users/MONLEON-DE-LA-JAN/AppData/Local/Temp/kika_pendf_cache",
output_dir="/path/to/output",
seed=42,
nprocs=8,
)
Set generate_ace=False to land just the perturbed ENDFs and PENDFs (no
NJOY ACER pass) — useful for inspecting the inputs before committing to a
long run.
Output layout
ACE/xsdir are co-located per-sample. All isotopes and temperatures for
sample NNNN share the same ace/<NNNN>/ directory; the filename
<ZAID>.<ext> disambiguates them. The extension is caller-supplied
via extensions=['06c'] (paired with temperatures=[600]), not derived
from a hardcoded K_TO_SUFFIX lookup.
pendf_perturbation
output_dir/
├── pendf_perturbation_<ts>.log
├── perturbation_matrix_<ts>_master.parquet # (N, n_MTs × n_bins)
├── pendf/<zaid>/<NNNN>/<base>_<NNNN>.pendf
├── ace/<NNNN>/<ZAID>.<ext> # if "ace" in output_formats
├── ace/<NNNN>/xsdir/<ZAID>.<ext>.xsdir # one per (ZAID, ext)
├── njoy_files/<NNNN>/ # if keep_njoy_io
└── xsdir/... # if xsdir_file given (master)
combined_perturbation
Same flat layout as the single-physics pipelines. Both sub-pipelines write
their tapes, parquets, and logs directly into output_dir/ — no
intermediate mf3/ / mf4/ subtree. Filenames don’t collide
(endf_perturbation_* vs. pendf_perturbation_* vs.
combined_perturbation_*).
output_dir/
├── combined_perturbation_<ts>.log # orchestrator
├── endf_perturbation_<ts>.log # MF34 sub-step
├── pendf_perturbation_<ts>.log # MF33 sub-step
├── endf_perturbation_factors_<ts>.parquet # Legendre factors
├── perturbation_matrix_<ts>_master.parquet # σ factors
├── endf/<zaid>/<NNNN>/<base>_<NNNN>.endf # MF34-perturbed ENDF
├── pendf/<zaid>/<NNNN>/<base>_<NNNN>.pendf # MF33-perturbed PENDF
├── ace/<NNNN>/<ZAID>.<ext> # paired ACEs (Stage B)
├── ace/<NNNN>/xsdir/<ZAID>.<ext>.xsdir # paired xsdir snippets
├── njoy_files/recon/<zaid>/<base>_recon.{input,output}
│ # RECONR deck + stdout
│ # (only if keep_njoy_io)
├── njoy_files/<NNNN>/<base>.{input,output} # acer-chain I/O
│ # (only if keep_njoy_io)
└── xsdir/... # if xsdir_file given (master)
The two parquet matrices stay separate by design — one per perturbation kind. Don’t try to merge them; they have different column schemas (per-MT × native bin for σ vs. per-MT × Legendre × energy bin for angular).
When keep_njoy_io=True, the pipeline preserves both NJOY runs:
First pass — RECONR, run once per ENDF (cached) to produce the pointwise PENDF that MF33 sampling needs. Lands at
njoy_files/recon/<zaid>/<base>_recon.inputand.output.Second pass — broadr → heatr → purr → gaspr → acer, run once per sample with the perturbed PENDF on tape 26. Lands at
njoy_files/<temp_str>/<zaid>/<NNNN>/<base>_<NNNN>.{input,output}.
Cache hits skip RECONR; the pipeline still copies the previously-saved
RECONR I/O from the cache directory into njoy_files/recon/ so the
audit trail is complete.
PENDF cache
Lives at pendf_cache_dir (default = tempfile.gettempdir()/kika_pendf_cache,
override on WSL to /mnt/c/Users/MONLEON-DE-LA-JAN/AppData/Local/Temp/kika_pendf_cache).
Files are keyed by <sha256(ENDF bytes)>_<tolerance>.pendf, so a byte
edit to the ENDF invalidates the cache automatically.
MF4 (angular) perturbation defaults
perturb_ENDF_files (also routed via combined_perturbation Stage A.MF34)
samples the raw MF34 covariance without modifying it:
psd_method="none"(default) — no PSD repair. SVD silently folds negative eigenvalues to their magnitude; one WARN line per (MT, block) is emitted when |λ_min|/λ_max > 1e-8.higham_projection=False(default) — opt-in Higham repair. Setting to True overridespsd_methodto"higham"; the two are mutually exclusive.decomposition_method="svd"(default).enforce_positivity=True(default) — after each sample is assembled (factor × baseline → ENDF a_l), the per-energy angular probability densityf(μ) = (1/2) Σ (2l+1) a_l P_l(μ)is evaluated onpositivity_check_points=101evenly-spaced μ points in [-1, 1]. Iff(μ) < 0anywhere on the grid, the coefficient vector is projected to the L²-nearest non-negative set via SLSQP (witha_0=1and the high-l tail outside the perturbed band pinned at their baselines).
Every projection is logged. In verbose mode each event prints a line with the sample index, MT, incident energy, the worst pre-projection σ(μ), and the largest coefficient shift. Both modes emit per-MT summary totals at the end of the file’s processing.
The positivity primitives live in kika/sampling/mf4_positivity.py
(check_mf4_positivity, project_mf4_to_positive). They are pure
functions and have a standalone test suite in tests/test_mf4_positivity.py.
Verifying a run
After perturb_PENDF_files completes, the contract is that for each sample
s, MT m, and pointwise PENDF energy E_k:
σ_new(E_k) = σ_orig(E_k) × factor[g(E_k)]
with g = searchsorted(union_grid, E_k, side="right") - 1 and
factor = 1.0 whenever E_k falls outside the MF33 coverage.
Equality holds at the ENDF-6 numerical-format precision floor (~5e-7 relative). To check on a real run:
import glob, numpy as np, pandas as pd
from kika.endf import read_endf
from kika.sampling.mf33_sampling import (
load_mf33_covariance, extract_mt_param_blocks, perturb_pointwise_xs,
)
ENDF, PENDF_CACHE, OUT = ... # paths
cov, mf3, union_grid, mts = load_mf33_covariance(ENDF, PENDF_CACHE, [2])
mt_blocks = extract_mt_param_blocks(cov)
master = pd.read_parquet(sorted(glob.glob(f"{OUT}/perturbation_matrix_*_master.parquet"))[0])
factors_flat = master.iloc[0][[c for c in master.columns if c != "Sample_ID"]].to_numpy()
fblock = factors_flat[mt_blocks[2]]
ref = np.asarray(mf3[2].cross_sections)
e = np.asarray(mf3[2].energies)
expected, _, _ = perturb_pointwise_xs(e, ref, fblock, np.asarray(union_grid))
pert_xs = np.asarray(read_endf(
sorted(glob.glob(f"{OUT}/pendf/<zaid>/0001/*.pendf"))[0],
mf_numbers=[3]
).get_file(3).sections[2].cross_sections)
rel = np.abs(pert_xs - expected) / np.maximum(expected, 1e-30)
assert rel.max() < 2e-5
For a combined_perturbation run, the natural sanity check is that
distinct sample ACEs have different energy grids (NJOY’s adaptive
linearizer responds to even small σ changes), which by itself proves the
σ perturbation propagated through broadr/heatr/purr/acer.
Warnings you may see (and what they mean)
UserWarning: Skipping MF sections without parsers: [6, 8, 10, 12, 13, 14]Emitted bykika.endf.parsers.parse_endfwhenever an ENDF tape contains MF sections kika doesn’t have a parser for (energy-angle distributions, decay/yield, photon production). Benign — the pipeline only needs MF1/2/3/4/33/34 and ignores the rest. MF32, the resonance-parameter covariance, used to appear in this list and no longer does: it is read as of 2026-08-10, though nothing in the sampling pipeline consumes it yet.Unexpected MT152 in MF2 (only MT151 expected), skippingENDF/B-VIII added MT152 (energy-dependent self-shielding) under MF2. The parser only reads MT151 (resonance parameters) and skips the rest cleanly.MTx: skipping MTy (circular NC reference)The MF33 NC LTY=0 sum-rule resolver detected an MT referring back to one already being resolved up the call stack. The recursion is bounded byMF33MT.resolve_nc_lty0’s_resolvingguard — safe to ignore.RuntimeWarning: divide by zero encountered in logatmf33.pyWas emitted byMF33MT._bin_average_xswhen a covariance bin edge was exactly 0 eV. The current code guardsnp.log(grid[g])against non-positive edges and just skips the degenerate bin (output 0). If you still see it, the kika install lags behind the local source.
Common gotchas
NJOY
tape21vs-21. NJOY/gfortran treatstapeNNandtape-NNas the same Fortran unit, so you can’t open one as formatted (ASCII PENDF input) and another as unformatted (binary scratch) in the same run.NJOY_INPUT_TEMPLATE_WITH_PENDFroutes the ASCII PENDF throughtape26for this reason. If you copy the template into a new pipeline, keep the input unit out of the {21, 22, 23, 24, 25, 40, 41, 43, 44} range that the chain uses internally.update_directory=Falsewhen rewriting MF3 in a PENDF. NJOY MODER reads PENDF tapes via tape units, not via the MF1/MT451 directory; if you ask the writer to update the directory you’ll waste cycles and risk inconsistencies.apply_factors_to_pendf_mf3already passes this.MF33 absolute → relative conversion needs PENDF MF3. If MF33 carries any LB-type ∈ {0, 1, 2} block,
load_mf33_covarianceconverts it to relative using bin-averaged σ(E) from the PENDF. A missing PENDF means those blocks are silently dropped (with a warning). Keep the cache reachable.MF33 native grid coverage gaps. MF33 typically stops at 20 MeV; PENDF goes higher. Out-of-coverage points get factor = 1.0 (no perturbation). The diagnostics dict logs
frac_out_of_coverageper sample × MT — non-zero is fine, but if you’re seeing > 0.1 it means half your high-energy points aren’t being touched.Combined pipeline pair-mismatch. The pair of
(perturbed ENDF, perturbed PENDF)sampleiis matched purely by directory naming (<base>_NNNN.endf↔<base>_NNNN.pendf). If you intercept either Stage A and rename or remove files, the Stage B loop will skip the affected samples with a[WARN] [COMB] ... missing inputsline.PSD warnings are warnings.
psd_method="auto"will clip mildly negative eigenvalues before SVD and log a warning; this is the intended path. Don’t enableautofixunless you’ve confirmed clipping is materially changing the spectrum (final-matrix PSD checks are warn-only by design).
Reference: the test fixtures
Integration tests in tests/test_pendf_perturbation.py and
tests/test_combined_perturbation.py look for:
NJOY_EXECUTABLEenv var or any of the hard-coded fallbacks (e.g./home/MONLEON-JUAN/NJOY2016/build/njoy).KIKA_ENDF_FILESenv var pointing to a directory holdingFe56_jeff4.0_n.endf, or the default<repo>/files/endf/.
Both skip cleanly when either is absent. For a quick smoke on this WSL
host the JENDL-5 Fe-56 tape at
/mnt/c/Users/MONLEON-DE-LA-JAN/Documents/endf_files/260560.jendl5 works
well — it carries MF34 only for MT=2 and exercises an NC LTY=0
sub-subsection in MF33, both of which are good corners to hit.