Rust ENDF-6 parser crate with Python bindings and a parity harness - #17
Open
shimwell wants to merge 44 commits into
Open
Rust ENDF-6 parser crate with Python bindings and a parity harness#17shimwell wants to merge 44 commits into
shimwell wants to merge 44 commits into
Conversation
Scaffolds the Rust layer for the port, structured so the two readers can run side by side while it proceeds file by file. crates/endf is the parser: the ENDF-6 record primitives, the tabulated function types, section splitting for every MF/MT, and MF3 as the worked example of a ported file. It has no dependencies — not Arrow, not Python — because a simulation-ready projection of this data belongs in the consumer that needs it, not in a crate everyone reading a file has to pay for. crates/endf-py wraps it with PyO3 so the Python API can keep working while the parser moves underneath it. Files without a Rust parser still split correctly and keep their text as Section::Unparsed, so nothing is lost while the port is incomplete. The parity harness is the load-bearing part. tools/dump_golden.py writes what the Python reader produces for an evaluation; tests/golden.rs reads every such dump, runs the Rust reader over the file it names, and compares. Parsed values are compared bit-for-bit — the dump records the shortest round-tripping decimal and both readers round correctly, so any difference at all is real. Interpolation is sampled at bin midpoints either side of every region boundary and held to 1e-12. Section line counts are checked for every MF, so a new evaluation is useful coverage the day it is added, long before every file is ported. Adding an evaluation is dropping it in tests/data/ and regenerating; the Rust test discovers dumps and follows their SOURCE line. The coverage still wanted — resonance formalisms, MF6 distribution laws, thermal scattering, decay and fission yields, photo-atomic data, and the same nuclide across ENDF/B, JEFF, JENDL and TENDL — is listed as a checklist in crates/endf/tests/golden/README.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
The golden format was record-per-MF, which meant every ported file
needed a new record type on both sides and a new arm in the test's
walker. Replaced with a single generic record:
V <path> <F|I|T> <values...>
Both readers now build a path -> value map and the test compares the two
maps whole. A field that is renamed, dropped or added shows up as a path
on one side and not the other, so the test catches it without knowing
what the field was for. Strings are hex-encoded because ENDF text fields
are fixed-width and carry significant spaces.
Porting a file is now two mirrored dump functions and nothing else.
MF1 is ported on top of that: MT451 descriptive data and directory,
MT452/456 nu-bar, MT455 delayed neutrons, MT458 fission energy release,
MT460 delayed photons. 663 paths compared against the Python reader for
the one fixture, all matching.
MT458's components keep the shape the format stores them in — a
component is polynomial or tabulated, per component, and evaluations mix
the two within one file. That is the same distinction the converted data
has to carry, so preserving it here means it does not have to be
recovered later.
Two upstream inconsistencies are reproduced deliberately, each commented
at the site with a link: MT458 reporting ZA as a float (#14), and the
INTG record's column index (#11).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Covers every representation the format defines: Breit-Wigner (LRF=1 and LRF=2, which are read identically), Reich-Moore, R-matrix limited with its background R-matrix and phase-shift extensions, and the unresolved region in all three cases. Adler-Adler (LRF=4) errors, matching where the Python reader raises NotImplementedError. The unresolved dispatch reproduces upstream issue #15 deliberately: the Python reader branches on LRF where it means LRU, so a URR written with LRF=1 — Case A and Case B, how most actinides write theirs — falls through unread. The Rust reader does the same so the two agree, with a test pinning the behaviour so that fixing it upstream surfaces as a failing test rather than a silent change. Case A and Case B are implemented and switch on with the one-line dispatch fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
MF4 angular distributions in all four LTT forms, including reading past the obsolete energy transformation matrix. MF5 energy distributions in every LF law: arbitrary tabulated, general evaporation, Maxwellian, evaporation, Watt and Madland-Nix. MF6 product distributions in every LAW: continuum energy-angle (which carries the Legendre, Kalbach-Mann and tabulated angular representations), discrete two-body, charged particle elastic, n-body phase space and laboratory energy-angle. These are the shapes an Arrow projection of this data needs columns for, so they keep the structure the format gives them rather than being flattened here. Two upstream behaviours reproduced deliberately: MF4 keeps only the last T and LT across incident energies, as the Python reader does, and an unrecognised MF5 law is an error where the Python reader raises UnboundLocalError. The n-095_Am_244 fixture is now fully ported — every MF it contains has a Rust parser — so the test that checks unported sections keep their text moved to the photo-atomic fixture, where MF23 and MF27 are still outstanding. 2985 paths compared against the Python reader across six fixtures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
MF7 thermal scattering: coherent and incoherent elastic (MT=2), the incoherent inelastic scattering law with its per-temperature blocks and effective temperatures (MT=4), and the general information file (MT=451). MF8 in all three shapes: radioactive nuclide production, fission product yields (MT=454 and MT=459), and decay data (MT=457) with its decay modes, discrete and continuous spectra, and both covariance forms. MF9 and MF10 give the isomeric multiplicities and production cross sections that isomeric branching is derived from. Together these are what a transmutation network is built out of, so they keep the structure the format gives them. MF7's per-temperature LI is dropped upstream in favour of the outer LT (#16); reproduced with a comment pointing there. 3057 paths compared against the Python reader across six fixtures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Energy released by fission, by component, built on the MF=1 MT=458 parser. Covers the polynomial and tabulated forms, the Sher-Beck energy dependence for evaluations that give a single coefficient, and the ENDF/B-VII.1 units correction for second-order coefficients left in MeV. Takes nu-bar as a parameter rather than an IncidentNeutron. Sher-Beck is the only thing that needs it, it needs only the prompt or total neutron yield, and passing that directly keeps this module off the high-level layer, which is not ported yet. Pinned against the Python implementation at three incident energies — thermal, 1 MeV and 14 MeV — for every component plus the total and prompt Q. The golden harness covers parsed sections; derived quantities like these are computed rather than read, so they are pinned separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
ruff check was already clean; this is formatting only. The repository does not use ruff (no config, and 40 of 42 source files would be restyled), so this is confined to the one file added by the port rather than imposed on existing source. Verified to change no output: all six golden files are byte-identical after regenerating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Grouped into two modules rather than one per file, because each group describes one thing between them. photon.rs is MF12 to MF15: how many photons a reaction makes, the cross section for making them, where they go and with what energy. atomic.rs is MF23, 26, 27 and 28: the photon and electron interaction cross sections, the secondary distributions of the electro-atomic ones, the form factors that modify coherent and incoherent scattering, and how the ionised atom relaxes afterwards. MF26 reuses MF6's LAW=1 and LAW=2 readers rather than restating them, so those two are now public and the golden dumper's MF6 distribution walk is factored out and shared. This retires the last of the fixtures' unparsed sections: the photo-atomic and atomic-relaxation files that came in with local-develop are now read rather than only counted. 3184 paths compared, up from 3057, over the same six fixtures. Two test changes follow from that. `unported_files_keep_their_text` was asserting that some fixture still had an unparsed section, which is no longer true, so it now builds a synthetic MF=34 section instead — a test that depends on coverage being incomplete stops testing anything the moment that stops holding. And a new `every_fixture_section_has_a_parser` guards the other direction: a fixture added later that contains a file with no Rust parser now says so by name rather than the coverage quietly slipping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Grouped into one module because MF=40 reuses MF=33's subsection format verbatim and MF=34 is the same idea applied to angular distributions. Covers the NC-type derived covariances, the NI-type explicit ones in every LB layout, and MF=40's per-product subsections. Adds Reader::peek_cont_record, since MF=33's LB says how to read the record it is written in — the Python reader does the same with tell and seek. This completes the ENDF format layer: every file the Python package parses now has a Rust parser. Two upstream defects reproduced deliberately, each commented with a link. MF=33 appends its LTY=0 sub-subsections twice (#12). MF=34 both discards every subsection it parses and fills LB with LS (#18, filed from this work) — the records are still consumed, so nothing desynchronises, and a test pins the behaviour so correcting it upstream surfaces as a failure rather than a silent change. Half the ported parsers have no fixture that exercises them: MF 6, 7, 12, 13, 14, 15, 26, 33, 34 and 40 have never been run against a real evaluation. That list is now pinned in golden.rs as UNCOVERED_BY_ANY_FIXTURE and checked, so it is a maintained fact rather than a remark in a commit message, and the test fails the moment a fixture starts covering one. The golden README says the same in prose, with the fixtures that would close each gap. The synthetic unparsed-section test moved from MF=34 to MF=32, which neither reader parses, so it no longer needs revisiting each time a file is ported. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Half the ported parsers had never been run against a real evaluation.
This closes MF 6, 7, 12, 14, 15, 26, 33 and 34, leaving only MF13 and
MF40. 22563 paths compared across eleven fixtures, up from 3184 across
six, and every one matches the Python reader on first contact.
n-003_Li_006_trimmed MF6 LAW=2 and LAW=4, MF12, MF14, MF33
n-026_Fe_056_trimmed MF2 Reich-Moore, MF6 LAW=1, MF12/14, MF33
n-092_U_235_trimmed MF2 Reich-Moore and a Case C unresolved
region, MF8, MF10, MF15, MF34
e-001_H_000 MF26 in all three of its laws, MF23
tsl-s-CH4 MF7 MT=2 and MT=4
MF2 in particular went from a single LRU=0/LRF=0 range — a scattering
radius and nothing else — to real Reich-Moore parameters and an
unresolved region.
tools/trim_endf.py cuts an evaluation down to chosen sections, keeping
the record structure valid. Full files are impractical as fixtures: U235
is 36 MB whole and 451 KB with ten sections kept. Sections are kept
whole, never truncated, because a truncated one leaves its own NP and NE
counts describing records that are no longer there.
The sampled-interpolation comparison needed a fix the new data exposed.
A tabulated S(alpha, beta) holds zeros, and log-linear interpolation
across one gives NaN — in both languages alike, which is the two readers
agreeing about a real property of the evaluation. Comparing with
subtraction failed on NaN != NaN and reported a difference where there
was none. It now compares NaN against NaN and infinities exactly, since
what is under test is parity and not whether the answer is finite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
The element and sum-rule tables are generated from the Python package's data.py rather than retyped, so the two cannot drift apart through a transcription slip. The functions built on them — gnds_name, zam, atomic_number, sum_rule, temperature_str — are hand-written, and were cross-checked against Python over an exhaustive sweep: every atomic number crossed with several mass numbers and metastable states, every sum rule, and a range of temperatures. 1094 cases, identical. That sweep found a real difference. temperature_str(1200.5) gave 1201K in Rust and 1200K in Python: Python's round breaks ties to even and f64::round breaks them away from zero. The string is used as a key for which temperature a table belongs to, so the two readers would have disagreed about that rather than merely rounding differently. Fixed to round half to even, with the cases pinned in a test. EV_PER_MEV moves here from fission_energy, which now uses the shared one. data.py is a dependency of ace.py, which is next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
ACE is not ENDF — it is the processed, ready-to-sample form NJOY writes — but several of the package's higher-level types can be built from either, so the reader is needed before those can be ported. This covers the Type 1 (ASCII) tables: both the 1.0 and 2.0 header styles, the (IZ, AW) pairs, NXS, JXS and XSS, and the ZAID conventions for metastable state in both the NNDC and MCNP schemes, including MCNP's two exceptions around Am242m and its newer SZA form. Type 2 (binary) tables are a different on-disk layout and are reported as unsupported rather than misparsed. ACE floats need their own parser rather than float_endf: NJOY drops the 'e' from values below 1e-100, writing 1.234567-120, but the field is not fixed-width so the eleven-character rule does not apply. Tested against a real Li6 table, which had to be brought in as a fixture: porting a second file format with no coverage would have reopened a larger gap than the one just closed. Git stores the 1.8 MB file in 373 KB, the same as a gzip would cost, so it is committed plain and neither reader needs to decompress. The golden harness grew a second kind. An ACE golden opens with KIND ace and the Rust side reads it through endf::ace; the map comparison is now shared between the two paths rather than living inside the ENDF walk. XSS is sampled — a spread across the array plus both ends and every JXS entry point, mirrored index for index on both sides — because recording all of it would make the golden the size of the table. Corrupting a single sampled value fails the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
The coverage section was rewritten when the fixtures landed but the checklists below it were not, so the document asserted both that MF6 and MF33 still needed fixtures and that they had them. Replaced with a table of what each fixture actually covers and a list of what is genuinely still missing — MF13 and MF40, Kalbach-Mann and n-body in MF6, the MF2 formalisms other than Reich-Moore, and libraries other than ENDF/B-VIII.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Read from an ACE table rather than from ENDF — the ENDF form is MF=2 with LRU=2, which mf2 already handles; this is the processed form NJOY writes and a transport code samples. Unblocked by the ace port. Kept flat with an explicit [n_energy, 6, n_band] shape rather than reshaped, because that is how the converted data stores it and reshaping twice helps nobody. Testing it needed a fixture that does not exist. No ACE file small enough to keep in the repository has an unresolved block: the nuclides that have one are heavy, their tables run to tens of megabytes, and an ACE table cannot be trimmed because JXS holds absolute offsets into XSS. So tools/make_urr_ace.py builds one. The values are invented but the layout is the format's, which is what the reader is being held to, and every value is distinct so a misplaced index shows rather than cancelling out. It caught nothing, but it means the row-5 heating conversion from MeV is now actually exercised rather than assumed. Li6 covers the other path: JXS(23) is zero, and the reader reports no unresolved region rather than reading whatever sits at index zero. 13 golden files, 22596 paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Discrete, Tabular, Uniform and Mixture, with the CDFs integrated analytically per interpolation law rather than numerically. These are what angle_energy and the from_ace paths in mf4 and mf5 are built from, so they come first. XML serialisation is deliberately left out. The Python module reads and writes these as OpenMC XML elements; adding that would mean an XML dependency in a crate that has none, for a format the Arrow path never touches. Cross-checked against Python over every law, with CDFs, integrals and normalisation printed to seventeen significant figures. That found a difference worth having: Python computes np.diff(np.log(x)), a difference of logs, where I had written log(x[i+1] / x[i]). The two are equal in exact arithmetic and not in floating point, and it showed in the last two digits of the linear-log and log-linear CDFs. Rewritten to subtract logs the way the Python does, after which all 23 lines of the sweep are identical. Note that function.py writes the same idea the other way round — log(x/xi) — in Tabulated1D, and the port matches it there. The rule is to follow each module's own spelling, not to pick one and impose it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
`AngleDistribution` is the interpreted form of MF=4: the parsed section says what the file holds, this says what it means. It comes from either source — ENDF's Legendre coefficients and tabulated densities, or the ACE AND block's equiprobable bins, tabulated densities and isotropic flags. Supporting pieces: - `Legendre`, a Legendre series with numpy's evaluation and integration. Both follow `numpy.polynomial.legendre` expression for expression. Two spellings matter and are commented at the site: NumPy 2 forms the recurrence ratios before multiplying rather than after, which moves the last bits, and the constructor does not trim trailing zeros, which changes how many steps the recurrence takes. - `Tabulated1D::from_ace`, with `ace_len` for callers stepping past a record. - `c` on `Discrete` and `Tabular`: the CDF as the source file gave it, kept verbatim next to the computed `cdf()`. The golden harness gains both paths — the interpreted distribution and `forward_fraction` at four cutoffs for every MF=4 section, and every locator in the ACE AND block. Li6's block covers all three ACE shapes. 25,208 paths across 13 fixtures, up from 22,596. Found while cross-checking: my first `legval` used NumPy 1's associativity and differed from Python in the last two digits of the forward fraction. The golden comparison is exact, so it failed rather than passing quietly.
`AngleEnergy` is the joint distribution of a secondary particle's angle and energy as a processed ACE table holds it: uncorrelated, Kalbach-Mann, correlated, or N-body phase space. It dispatches on the ACE law, so it brings the ACE constructors of mf5.py with it — the Maxwell, evaporation and Watt spectra, the discrete photon, level inelastic scattering and the continuous tabulation, plus the incident grid and outgoing energy readers laws 4, 44 and 61 share. `EnergyDistribution` gains the three shapes that have no ENDF law and can only come from a processed file. The enum now spans both sources, which is what a consumer wants: one type for "the outgoing energy", however the evaluation arrived. ACE law 5 is refused by name rather than guessed at. The Python reader dispatches it to a `GeneralEvaporation.from_ace` that does not exist and dies with an AttributeError, so there is no behaviour to match; filed as issue #19 and pinned by a test. Coverage. The golden harness now walks the whole DLW block of every ACE fixture, following the linked list each reaction carries, and checks the applicability record and the interpreted distribution at each link. Li6 covers laws 3, 33 and 44; `tools/make_laws_ace.py` writes a synthetic table for 2, 4, 7, 9, 11, 61 and 66, with the continuous tabulation carrying one continuous, one discrete and one mixture distribution and the correlated law carrying both isotropic and tabulated cosines. `every_distribution_shape_has_a_fixture` makes that coverage a checked fact: every shape the dumpers can write must appear in some golden file, or the test names the one that does not. 25,785 paths across 14 fixtures, up from 25,208. Li6's DLW block matched bit for bit on the first run, Kalbach-Mann tables included.
`Product` is a secondary particle: its yield, its distributions, and how it was emitted. `Yield` spans the two forms the format uses — a polynomial in incident energy, or a tabulation against it. `radionuclide_production` joins MF=8, MF=9 and MF=10 into what a consumer wants: for each reaction, the nuclides it leaves behind, in which state, and how much of each. `isomer_table` and `level_to_isomeric_state` turn a nuclear level index into an isomeric state, which is what naming a metastable product needs. Two real decay evaluations come with them, In116m1 and In116m2, which between them cover MF=8 MT=457 for the first time — 122 discrete radiations across seven spectra — and give `isomer_table` something real to chain: m1 decays only by beta- and so has no measurable excitation energy, m2 transitions down to m1. They found a parse bug straight away. A discrete radiation record is written with NT = 6 or NT = 12 depending on whether the internal conversion coefficients were evaluated, and both lengths appear inside a single spectrum. The Rust reader was returning zeros for the absent coefficients where the Python reader returns nothing — a zero conversion coefficient is a physical claim, and the wrong one. Now gated on the record length. The MF=8/9/10 join is compared against Python for every ENDF fixture, not only asserted by hand. 26,740 paths across 16 fixtures, up from 25,785.
`Reaction` gathers what an evaluation scatters across its files: the MF=3 cross section and Q values, the MF=4/5/6 distributions of what comes out, the MF=8/9/10 radioactive products. Plus the reaction name tables — `reaction_name` and `reaction_mt`, which agree with the Python package's dictionaries entry for entry across all 412 names and 417 lookups. The intricate parts are the fission neutrons and the activation products. Prompt, total and delayed neutrons come from MF=1; each delayed precursor group's yield is the total scaled by the applicability of its MF=5 spectrum, on a union grid when that applicability is energy-dependent. An MF=10 product gives a production cross section rather than a yield, so the yield is the ratio of two interpolated cross sections, taken where the reaction actually happens. One deliberate difference: the Python `Reaction.from_endf` computes the derived products and then drops them, with a TODO in the source saying they should be stored. They are stored here. The dumper takes them from the helper so they are still compared rather than left unchecked. Coverage. Every reaction of every ENDF fixture is now built and compared against Python — cross sections, Q values, products, yields, applicabilities and distributions. U235's fixture is re-trimmed to carry MF=3 MT=18 and MF=5 MT=455, which brings the six delayed neutron groups with their spectra, and MF=5 LF=5 with them. The reaction yields are the second thing compared to a tolerance rather than exactly, alongside the sampled interpolation: an MF=10 yield divides two interpolated cross sections, and NumPy's array `log` differs from its scalar `log` in the last bit. The tolerance is 1e-12 relative, so a real disagreement still fails. 30,967 paths across 16 fixtures, up from 26,740.
`Reaction::from_ace` reads a reaction out of a processed table: the cross section from its threshold on the nuclide grid, the multiplicity and the frame it implies, the DLW linked list of distributions, the angular distribution from AND, and the photons the reaction produces. The fission path is its own thing. Prompt and total nu may be given singly or both together, and which is which depends on whether a delayed block exists at all. Each delayed group's yield is the total scaled by its probability, and because the probabilities in an ACE file do not sum to one, the group yields are renormalised against what they do sum to. Photon production comes in two forms: a yield taken from ENDF file 12 or 6, or a production cross section from file 13, which becomes a yield once divided by the reaction's own. Two places take no Q value where the Python reader passes no reaction — the delayed spectra and the photon distributions. Law 66 would fail in both, and does not arise; the comment says so rather than papering over it with a zero. Coverage. Every reaction of every ACE fixture is now built and compared against Python, elastic scattering included: 15 reactions for Li6, with its photon production attached to the reactions that make it. The reaction dump is guarded on MTR being present, so the synthetic law table is left to the DLW walk it exists for. 33,588 paths across 16 fixtures, up from 30,967.
`IncidentNeutron` is the nuclide: its identity, its reactions, its energy grid at each temperature, and the unresolved resonance tables. From an ENDF evaluation it is the reactions with an MF=3 cross section; from an ACE table it is rather more work. An ACE table gives three summed cross sections outright — the total, the absorption and the heating number — which become redundant reactions on the shared grid. It may also assign photon production to an MT with no cross section of its own, and give a transmutation reaction only as its separate levels; both cases are filled in by summing the components, starting at the lowest threshold any of them has. That is what `threshold_idx` on `Tabulated1D` is for: a processed cross section starts where its reaction opens, and summing several needs to know where each began. `removal_xs` folds the elastic angular distribution into the total, for point-kernel shielding. It found issue #21: `AngleDistribution.forward_fraction` fills its result with `np.empty` and then writes only the Legendre and tabulated entries. Everything an ACE table produces is neither, so `removal_xs` on ACE data returns uninitialized memory — the same call gives a different answer depending on what ran before it. There is no behaviour to match, so the Rust `forward_fraction` computes the two ACE shapes properly: the tabulated cosine through its own CDF, the isotropic one as the fraction of the interval above the cutoff. The removal cross section is compared against Python on the ENDF path, where the answer is well defined, and deliberately not on the ACE path, where it is not. Coverage. Both constructions are compared against Python for every fixture: the identity, the reaction list, what each redundant reaction is made of, the energy grid, the redundant flags, and the synthesised reactions in full. 33,893 paths across 16 fixtures, up from 33,588.
An ENDF evaluation is highly repetitive and compresses about six to one; the golden dumps, which are as repetitive as the evaluations they come from, about seven. Together that is 9.4 MB of text down to 1.4 MB, and 95,000 lines of the diff down to sixteen binary blobs. Python reads them through `endf.fileutils.open_text` and `open_binary`, which use `lzma` for a `.xz` path and behave exactly as before for anything else. Compressed files are decompressed whole rather than streamed: the readers seek back and forth to find material boundaries, and seeking inside a compressed stream restarts the decoder. `Material`, `get_materials` and `ace.get_tables` all route through them, so the support is in the package rather than in the tests. Rust reads them with `lzma-rs`, which is pure Rust — no C toolchain — and is a `[dev-dependencies]` entry, so the `endf` crate stays dependency-free for anything that uses it. The unit tests keep embedding their fixtures with `include_bytes!` and decompress on use, so they still need no working directory. `isomer_table` gains `isomer_table_from_materials`, which is where the work now happens; the path-taking version is a wrapper. That was needed to test it without the filesystem, and is a better split anyway. Everything else is unchanged and verified: the dumps are still byte-reproducible, 33,893 paths still compare, 103 unit and 5 integration tests pass in debug and release, and `pytest` gives 168 passed, 2 skipped. Decompression costs the Rust suite about a second in debug.
`Decay` is a radioactive decay evaluation: the nuclide, its half-life, the average energies that drive decay heat, each decay mode with the daughter it leaves behind, and the spectrum of each radiation type. `FissionProductYields` is the independent and cumulative yields from MT=454 and MT=459. `sources` turns the spectra into what a source needs: intensities per decay become rates per second by way of the decay constant, and radiation types that emit the same particle are combined — a nuclide emitting both gammas and x-rays gives one photon distribution. That needed `combine_distributions` in `univariate`, which merges the discrete parts and mixes the rest. The decay mode encoding is the fiddly part. RTYP packs a chain of modes as the digits of a decimal — 1.5 is a beta- followed by a neutron — and the Python reader decodes it by formatting the float and stripping zeros and the point. That only works because Python's `str` always writes a fractional part, so `10.0` keeps its trailing zero where Rust's shortest round-trip format would give `10` and lose it. `python_float_str` puts the `.0` back, and the tests pin both the ordinary cases and the two the encoding cannot express. Coverage. The whole of `Decay`, including `sources`, is compared against Python for both In116 decay fixtures — 488 paths each, every discrete line of all seven spectra. 34,512 paths across 16 fixtures.
`IncidentPhoton` is the photon interaction data of one element: `PhotonReaction` per channel, with the MF=27 form factors and anomalous scattering terms attached to the MF=23 reactions they belong to, and `AtomicRelaxation` for how the atom fills a vacancy afterwards. Both sources are covered — the photoatomic sublibrary, and a processed ACE photoatomic table, whose energy grid and cross sections are stored as logarithms and whose transition probabilities are cumulative. `compton_profile_cdfs` and `compton_subshell_map` come too. The second is the awkward one: it walks the two orderings in step and stops at the first occupancy that cannot be made from whole subshells, because past that point the orderings have diverged and any pairing would be invented. One thing is deliberately not ported. `_add_compton_profiles` and `_add_bremsstrahlung` attach data from an HDF5 file shipped with the package, resampled with a cubic spline — auxiliary data rather than evaluated data, and it would cost the crate an HDF5 reader and a spline implementation to carry. An ACE table's own Compton profiles *are* read, since those come from the file. The gap is stated in the crate docs. Coverage. The photon path is compared against Python for all three atomic fixtures — reactions, form factors, binding energies, fluorescence yields, sum-rule components and the relaxation transitions. 34,669 paths across 16 fixtures.
`Chain::from_endf` is the join of three sub-libraries: what a nuclide decays into, what a neutron turns it into, and what its fission leaves behind. With it come `REACTIONS` — all 84 transmutation reactions, the MTs that mean each and what they do to A and Z — `Nuclide` and its decay and reaction paths, `normalise_branch_ratios`, the two stand-in searches, `branch_ratios`/`set_branch_ratios`, and `reduce`. Two things are deliberately absent. The XML serialisation needs an XML writer, and `form_matrix` needs sparse linear algebra; neither reads a nuclear data format, and both belong in a consumer. The module docs say so. Branch normalisation is worth a line: the residual goes into the largest branch, because that is the one it perturbs fractionally least. Dropping it into an arbitrary branch can move a 1e-9 branch by orders of magnitude, and the test pins that. It found issue #22. `replace_missing` steps the atomic number towards stability until it finds a nuclide the library has, with nothing bounding the walk. Given a decay library that is a subset — a reduced chain, a test fixture, a library missing an element — it runs Z down past one and dies on `ATOMIC_SYMBOL[-1]`. A full sub-library always terminates it, which is why it survives. The Rust version stops and returns None, and its caller drops the target rather than inventing one. Coverage. Ten decay evaluations, chosen to close every path the chain follows except Cs137's — barium is left out so the stand-in walk runs — plus two neutron evaluations for the Q values. The whole chain is compared against Python: 63 paths over ten nuclides, their half-lives, decay energies, normalised branching ratios and reaction Q values. The golden harness grows a `KIND chain` form for it, since a chain has several sources rather than one. 34,732 paths across 17 goldens.
`njoy` composes the NJOY input deck and runs it: `ace_deck` builds the deck, `run` stages the tapes and drives the process, `make_ace` concatenates the per-temperature ACE files afterwards. Composing is separated from running so a deck can be inspected, written out and tested without NJOY installed — which is how it is tested here. The deck is held to Python's byte for byte. `tests/reference/` gains the deck the Python `make_ace` composes for Am244 at two temperatures, captured with its `run` stubbed out, and a unit test compares the Rust one against it. That caught the same Python-float trap the decay mode encoding hit: 900 K has to be written `900.0`, not `900`. The formatter that fixes both now lives in `data` as `python_float_str` with a test of its own, since it is a Python convention two unrelated modules depend on. The temporary directory is hand-rolled, because the crate has no dependencies and needs none for this. `Chain::validate` and `Nuclide::validate` come with it. They return what does not add up rather than raising on the first thing, warning, or returning a bare bool depending on three flags — a caller can then report all of it, or ignore it. 129 unit tests, 5 integration, debug and release. The reference deck sits in `tests/reference/` rather than `tests/golden/` so the golden harness does not try to parse it as a dump; that directory has a README saying what it is and how to regenerate it.
The decay fixtures added with the chain caught this. ENDF/B-VIII.0's Xe136 is flagged unstable and given a half-life of zero — the evaluation declining to state it, its real one being some 10^21 years. The Python `decay_constant` divides by it and raises ZeroDivisionError, taking `sources` with it; filed as issue #23. The package already reads zero the right way elsewhere: `Chain.from_endf` skips a nuclide's decay modes when the half-life is zero. So `decay_constant` returns None there, and `sources` an empty map. An infinite decay constant is not a fact about the nuclide. That also fixes a golden I had committed truncated: the generator died part-way through Xe136 and I had redirected its error away. Xe136's dump is now complete, and the generator's failure would now be a failure. Propagated uncertainties join the sampled interpolation as compared to a tolerance rather than exactly. `uncertainties` accumulates a variance and takes its square root where the port applies the derivative directly, so the two differ in the last bit or two. The nominal values still compare exactly, which is the part that carries the physics. 25 goldens, 37,618 paths.
The bindings were four types and three functions. They now expose the layer a consumer actually reads: `Material`, `Tabulated1D`, `CrossSection`, `Product`, `Reaction`, `IncidentNeutron`, `IncidentPhoton`, `Decay`, `Chain` and `AceTable`, with `get_materials`, `get_tables`, `reaction_name`, `reaction_mt` and `gnds_name` beside them. The sum types come across as dicts tagged with a `kind` key rather than as a class per variant — an angle-energy distribution, an outgoing energy law, a univariate density. That is the shape a consumer wants anyway: `kind` is exactly the discriminant an Arrow union column needs, and the alternative is a dozen wrapper classes that express nothing more. It is still not a drop-in for `Material.section_data`, which returns the Python reader's own dictionaries keyed by ENDF field name — one shape per MF. What is exposed is the typed layer above that. The README says so rather than leaving it to be discovered. `endf-py` gains `lzma-rs` so a `.xz` path works there as it does in `endf.fileutils.open_text`; a path that reads in one reader ought to read in the other. The `endf` crate itself stays dependency-free. Tested by comparison, not by assertion of remembered values. `tests/test_rust_bindings.py` runs the extension and the pure-Python reader over the same fixtures and compares: 30 tests over records, materials, cross sections, every reaction of Am244, ACE tables and nuclides, photon data with its atomic relaxation, decay with its source distributions, and a whole depletion chain. It skips itself when the module is not built. CI in `.github/workflows/rust.yml`, three jobs: the crate (fmt, clippy with -D warnings, test in debug and release); the goldens, which must still regenerate byte-identical, since a change that quietly rewrites them would defeat the harness; and the bindings, built as an abi3 wheel with maturin on 3.10 and 3.13 and then tested, importing `_endf` explicitly first so a build that did not take fails rather than skips.
`local-develop` fixes reading an ACE file that contains a value NJOY wrote without its `e` — a three-digit exponent overflows the field, so `6.10562372605e-318` goes out as `6.10562372605-318`. numpy 2 raises where numpy 1 returned a short array, so the recovery never ran (#20). The Rust reader already put the `e` back and got the right answer, but nothing held it to that on a whole file. `tests/synthetic-denormal.ace` now does: eight XSS values, four of them denormal, including a negative one and one that rounds to the nearest subnormal, with ordinary values between them so a reader that mangles the array rather than the token is caught too. Both readers agree on all nine. The exact tokens from the report are in a unit test as well, since a fixture says which file broke and a unit test says which token.
shimwell
force-pushed
the
claude/rust-rewrite-python-layer-1upmmo
branch
from
August 9, 2026 07:23
daf1081 to
054141e
Compare
`Material.section_data` and `material[3, 1]` now hand back the same dictionaries the Python reader does, keyed by the same ENDF field names. 93% of the sections across the fixtures have one — MF 1, 3, 4, 5, 8, 9, 10, 12, 13, 14, 15, 23, 27 and 28. Verified by comparison, not by inspection. `test_section_data_matches` walks both readers' dictionaries recursively over all 21 ENDF fixtures and compares every leaf, so a renamed key, a missing one or a wrong value fails. Writing it caught three: `E_int` is a `Tabulated2D` and not a dict, MF=15 subsections carry `NE`, and MF=14's angular tables are `p_k`. `Tabulated2D` is now a class in the extension for the same reason. A section with no dictionary form is left out of `section_data` rather than half-built, and asking for it by key says so and says why. That set is pinned in `SECTIONS_WITHOUT_A_DICT` and asserted, the same way the crate pins its uncovered parsers, so it cannot shrink or grow quietly. MF 2, 6, 7, 26, 33 and 34 are simply not written yet; MF=8 MT=457 is absent on purpose, since decay data is better reached through `Decay`. pytest is 221 passed, 2 skipped.
MF 33, 34 and 40 were the last three files in the fixtures whose sections came back missing from section_data. They now have dictionaries matching the Python reader's, with the NC/NI subsection split branching on LTY and LB the same way the golden dumper does. MF 34 stays empty, as it is upstream (issue #18), and MF 40 reuses the MF 33 subsection projection for its sub-subsections. That takes the fixtures from 372/400 sections to 375/400. The pinned set in SECTIONS_WITHOUT_A_DICT loses (33, 103), (33, 105) and (34, 51), which is what the assertion is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Six more projections, leaving MF=2 MT=151 as the only section in the fixtures without a dictionary form. MF=6 and MF=26 share LAW=1 and LAW=2, so the two law projections are shared functions rather than written twice. Both files leave the distribution key out entirely for a law that carries no data — LAW<0, 0, 3 and 4 in MF=6, an unrecognised law in MF=26 — as Python does. The upstream quirks are reproduced with them: MT=458 reports ZA as a float because it is read from a CONT record (issue #14), and MF=7 MT=4 stores the outer LT on each additional temperature rather than the LI it read (issue #16). NFC, beta_int/NB/beta_data and the coherent/incoherent keys are conditional in the same places. Fixture coverage goes from 375/400 sections to 386/400. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Every section across the fixtures now has a dictionary form — 400 of 400 — so code written against Material.section_data runs unchanged against either reader, and SECTIONS_WITHOUT_A_DICT is empty. The assertion stays, now guarding against a projection disappearing rather than tracking ones not yet written. MF=2 covers all four representations the reader parses: the bare scattering radius, Breit-Wigner, Reich-Moore and R-matrix limited, plus the unresolved region's three cases. Which unresolved case applies is decided from LFW and LRF rather than from what the struct happens to hold, so a range with no J values keeps the shape its case calls for. An unresolved range with LRF=1 is dispatched past unread upstream (issue #15) and so keeps only its own keys here. MF=8 MT=457 was left out on the argument that Decay is a better shape. It still is, but that is a reason to prefer it, not a reason for the dictionary to be missing. A stable nuclide stops after spin and parity, and a discrete record too short for its internal conversion coefficients gets an empty tuple rather than a zero, matching the slicing upstream. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Material.interpret() was the one method on the Python Material with no counterpart here. It is added to the crate rather than to the bindings, as an Interpretation enum, so a Rust consumer gets it too — NSUB=10 builds an IncidentNeutron, NSUB=3 an IncidentPhoton, anything else is an error. Thermal scattering, NSUB=12, is the case that has no class in either reader. Alongside it, the module-level surface the Python package exposes and the extension did not: zam, temperature_str, photon_reaction_name and photon_reaction_mt, decay_modes, normalise_branch_ratios, and the constant tables ATOMIC_SYMBOL, SUM_RULES, INTERPOLATION_SCHEME, FISSION_MTS, EV_PER_MEV and K_BOLTZMANN. Every one already existed in the crate; what was missing was the boundary. The tables are built at import rather than stored, because the crate holds them as arrays and the Python package as dictionaries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
The depletion-facing part of the API: radionuclide_production, which joins MF=8's identification of a radioactive product to the MF=9 yield and MF=10 production cross section, plus isomer_table and level_to_isomeric_state, which are what turn an MF=8 level index into a named metastable nuclide. RadionuclideProduction is a class rather than a dict, following the rule the rest of the surface uses: it is a concrete type, not a sum type. isomer_table reads through the same path as everything else here rather than through endf::isomer_table, so a compressed evaluation works. Its result is a plain nested dict, matching upstream, and level_to_isomeric_state takes that dict straight back — so a table built by either reader can be passed to either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
MF=8 MT=454 and MT=459 were the last two parsers with no fixture. A real
yield evaluation is megabytes of products and cannot be trimmed — the
yields are the file — so tools/make_nfy_endf.py writes a small one
instead, the way the synthetic ACE fixtures were made.
The fixture is built to catch the mistakes that matter: independent and
cumulative yields differ, so returning one for the other fails; the fast
energy carries one product more than the thermal one, so reusing NFP
across energies fails; and a product isomer exercises the naming path.
The golden harness picks it up on its own and now holds both readers to
403 sections rather than 400.
With a fixture to test against: section_data gains its MF=8 MT=454/459
projection, including the L1 field the format overloads — LE+1 at the
first energy, the interpolation scheme after — which upstream keys
differently in the two cases.
FissionProductYields gains the nuclide it was missing, read from MF=1
MT=451 as upstream reads it, and is exposed to Python. Its yields come
across as {name: (value, uncertainty)} rather than as ufloats, which is
the same pair the Python objects carry once taken apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
**The wheel had the wrong name.** `maturin build -m crates/endf-py/Cargo.toml` run from the repository root falls back to the pyproject.toml in the working directory, which is the pure Python package's, so the wheel came out called `endf` — shadowing the very package it is meant to sit beside — and `pip install endf-py` then found nothing. `crates/endf-py/pyproject.toml` makes the build independent of where it is run from. Reproduced with the exact CI invocation before and after. **The golden drift check was testing the wrong thing.** It regenerated the files and asked git whether the bytes changed. Two problems: the goldens are xz-compressed, and two encoders can write identical content differently, so the check could fail for a reason that says nothing about the reader; and when it did fail all it could report was `Bin 14796 -> 14796 bytes`. `--check` now compares the dump text and prints the offending lines, so CI says which value moved. That immediately showed the real failure: NumPy re-associated the Clenshaw recurrence between 2.2 and 2.4, so `forward_fraction` — a Legendre antiderivative — and the `removal_xs` that folds it in differ in the last bit or two depending on which NumPy wrote the golden. Both now sit in the tolerance category alongside the interpolation samples and the propagated uncertainties, on both sides of the harness. Verified by regenerating every golden under NumPy 2.2 and running the Rust parity tests against them: they pass, where before the change they would not have. `--check` agrees on Python 3.10, 3.11, 3.12 and 3.13, spanning NumPy 2.2.6 to 2.5.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Four parsers were structurally complete and had never been run against a
file, because no evaluation small enough to keep here writes them.
tools/make_shapes_endf.py writes one that does, on top of a new
tools/endf_writer.py that make_nfy_endf.py now shares — verified by
checking that the NFY fixture comes out byte-identical after the move.
Each shape is built around the mistake it is meant to catch: the
Breit-Wigner section has two L values with different resonance counts and
a non-zero QX/LRX pair; LF=12 keeps EFL and EFH on the record that
introduces the subsection rather than the one after; LAW=1 with LANG=2
has NA=1, so the row stride is three rather than two; LAW=6 is the one
body in MF=6 that is a bare CONT; and MF=13 has NK=2, so the total
production record is present, which is the branch a reader drops.
That leaves MF40 as the only parser no fixture reaches, and
UNCOVERED_BY_ANY_FIXTURE says so. Fixtures go from 22 to 23 and compared
sections from 403 to 414, all of which the Rust reader matches.
Writing the fixture found two bugs in the harness itself, both only
reachable with data like this:
- the dumper read `MadlandNix.t_m`, which is spelled `tm`, so LF=12
could never have been dumped;
- it wrote the golden as it went, so that failure left a truncated file
behind, and a truncated golden reports every path after the failure
as missing and buries the real error. The dump is now built whole in
memory and written once complete.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Groundwork for yamc depending on `endf`, without moving anything yet. Each of these is a thing that would have bitten a consumer. **The ACE types were not re-exported.** `IncidentNeutron::from_ace` takes a `MetastableScheme`, which lived behind `endf::ace::` — so calling a root-level method required a module path. `Table`, `TableType`, `get_table`, `get_tables` and `tables_from_str` were in the same position, and ACE is where a transport code starts. All now at the root. **Nothing verified the declared MSRV.** The crate says `rust-version = "1.74"` and consumers will believe it, but every CI job ran stable, which happily accepts APIs stabilised years later — this port used `is_none_or` (1.82) at one point and it was caught by reading, not by testing. A job now builds the library on 1.74. The library only: dev-dependencies reach nobody downstream and are free to need newer. **A publish would have shipped the harness without its fixtures.** `cargo package` included 900 KB of golden dumps, while the evaluations they name live in `tests/` at the repository root, outside the crate — so the tests could not have run from the package regardless. Excluded; the package is 116 KB. The parity harness is a repository concern, which is also the reason the crate is easier to depend on than to move. **tests/public_api.rs** walks a consumer's path — file to nuclide to cross section, ACE table to nuclide, the depletion inputs, a chain — importing only from `endf::` rather than from module paths. It is a shape test, not a value test; the goldens check the numbers. It caught the ACE re-exports above, and it pins that an ENDF evaluation yields no unionised energy grid while an ACE table does, which is the sort of thing a consumer otherwise discovers at runtime. Also a crate-level README with metadata for the publish path. Its two examples were compiled against the real API rather than written from memory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
There were none, of any kind — no `.pyi`, no `py.typed`. `_endf` is a
compiled module, so an editor and a type checker saw nothing whatsoever
in it. That costs more here than for most extensions: the module is
meant to be substitutable for the pure-Python `endf` package, and
swapping one for the other should not cost you every type you had.
`crates/endf-py/_endf.pyi` covers the whole surface — 13 classes with 95
members, 17 functions, 6 constant tables. maturin turns the file into a
PEP 561 package on its own, so the wheel now installs `_endf/py.typed`
and `_endf/__init__.pyi` beside the extension with no packaging changes
beyond the file existing.
A hand-written stub for a compiled module rots silently, because nothing
imports it, so two things hold it in place:
- `tests/test_rust_stub.py` parses the stub and compares its declared
names against the built module in both directions, then checks the
installed stub is the one in the tree. Verified by deleting a method
from the stub and confirming the failure names it.
- `crates/endf-py/typecheck.py` is ordinary use of the module with
every result bound to an explicit annotation, run under
`mypy --strict` in CI. Names matching is not types matching, and
only a type checker catches a getter declared with the wrong one.
Both run in the bindings job. Confirmed the stub does its job by
type-checking three deliberate errors — an int assigned to a str, a
method that does not exist, and a str passed where a float is wanted —
and seeing all three reported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
Cross-checking against OpenMC, ENDFtk and endf-parserpy settled which of the issues raised while porting are genuinely wrong rather than merely unusual. These five are, and each is now fixed on both sides with a test that fails if it comes back. **#15, unresolved ranges with LRF=1.** LRU says resolved or unresolved; LRF only selects the formalism within a range. Both readers tested LRF, so a range with LRU=2 and LRF=1 -- Cases A and B, how most actinides write their unresolved region -- matched no branch. Proven with a built file: the parameters were dropped *and* the records left unread, so the following range came back as LRU=0 with its EL and EH holding the previous range's SPI and AP. tests/synthetic-urr-cases.endf.xz now carries Case A, Case B and a resolved range behind Case A, so the alignment is checked and not just the parse. **#23, zero half-life.** Zero means "not evaluated", not "decays instantly". `decay_constant` divided by it and raised ZeroDivisionError from inside the expression. It now returns None and `sources` returns {}, which is what `Chain.from_endf` already assumed for these nuclides. OpenMC guards the same case; that guard was lost in the port. **#12, MF=33 NC subsections.** The LTY=0 branch appended inside the `if` and again after it, so those subsections appeared twice. **#18, MF=34.** Every subsection was parsed and then dropped, because nothing appended it -- `section_data[34, 51]['subsections']` was always empty. And LB was filled with LS. U235 shows why that matters: LB is 5 throughout while LS is 1, 0, 1, so the blocks were reported as three different matrix types when all three are covariance matrices. **#19, ACE law 5.** Not implemented in any of the three readers, but the dispatch reached a `from_ace` that did not exist and died with AttributeError. Now raises NotImplementedError, as OpenMC does. The goldens move where the fixes change what is read: U235 gains 27 MF=34 paths that were previously absent on both sides, and Xe136's empty decay constant is now compared rather than skipped by both dumpers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
The ENDF photon files were already ported -- MF 23, 26, 27, 28 and the
photon production files, plus ACE photoatomic tables. What was missing
was the two things `IncidentPhoton` carries that no evaluation contains:
Compton profiles and bremsstrahlung. Both are looked up by atomic number
in tabulations that ship beside the package.
Two obstacles, both now gone.
**HDF5.** `compton_profiles.h5` and `density_effect.h5` cannot be read by
a crate with no dependencies. `tools/convert_photon_data.py` rewrites
them as one plain text file, checked bit-exact against every value in
both. 359 kB replaces 805 kB, and it is left uncompressed on purpose:
this is read at runtime, so xz would put a decompressor in the crate's
real dependencies rather than its dev-dependencies. `BREMX.DAT` needed
nothing, being text already.
**The spline.** The bremsstrahlung cross sections are tabulated on 57
electron energies and resampled onto 200. SciPy's `CubicSpline` defaults
to **not-a-knot**, not natural, and getting that wrong would bend every
element's cross sections near the ends of the grid. `spline.rs` is a
transcription of what SciPy does, including the order of the arithmetic.
Neither file is embedded. Together they are 2.5 MB, which does not belong
in every binary that links this crate, and a consumer reading nuclear
data is opening files anyway. `PhotonData::from_files` reads them and
`IncidentPhoton::add_photon_data` attaches them by atomic number, which
is the one visible difference from the Python package -- there the lookup
happens inside `from_endf`, because the package can find its own data
directory and a crate cannot.
Verified by dumping every value from both readers and comparing:
- 49,830 Compton profile, binding energy and density-effect values:
bit-identical
- 600,000 resampled bremsstrahlung cross sections, through two
independent spline implementations: worst relative difference
3.8e-15
SciPy stays on the Python side deliberately, so the two implementations
keep checking each other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
`photon_aux.txt` was built from the two HDF5 files this package inherited from OpenMC. Half of it no longer is: `tools/make_photon_aux.py` downloads Geant4's G4EMLOW data set, verifies it against a pinned SHA-256, and reads `doppler/p-biggs.dat`, `doppler/profile-<Z>.dat` and `doppler/shell-doppler.dat` directly. That archive is the distribution of the Biggs, Mendelsohn and Mann tables, and its own README says so, so the provenance is first-hand and the build is reproducible from it. The result is **bit-identical to the HDF5 it replaces**, for all 100 elements: same pz grid, same J, same occupancies, same binding energies. So this changes where the data comes from and nothing about what it is. G4EMLOW 6.48 is pinned rather than the newest release. Every file in the `doppler` directory is byte-identical from 6.48 through 8.7 — compared file by file rather than assumed — so the newer archives carry the same Compton data at 333 MB instead of 24 MB. The pin is recorded with the reason, so it can be revisited if that ever stops being true. The density effect half is still vendored from `density_effect.h5`. NIST ESTAR publishes those mean excitation energies through a web form rather than as a download, so there is no primary source to fetch; the script says so at the one function that would have to change. The generated file now carries a header naming both sources, and the Rust parser skips comment lines so it can. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SRBs5YezSMaYmof1Ts1Dcj
photon_aux.txt held two unrelated measurements: the Biggs Compton profiles, which now come from Geant4 and can be verified against a pinned archive, and the ESTAR density effect, which is vendored because NIST publishes it through a web form. They only ever shared a file because they shared an HDF5 container, and they have different provenance and different regeneration lifecycles. Splitting them lets each say what it is and where it came from in its own header. compton_profiles_biggs1975.txt fetched from G4EMLOW, verified density_effect_sternheimer1982.txt vendored from density_effect.h5 PhotonData::from_files takes one path per source rather than one per format. The crate never looks a file up by name, so this is the only coupling between it and whatever a consumer calls these on disk, and one argument per source is the shape that lets a consumer name them freely. BREMX.DAT keeps its name. endf.incident_photon reads it by that name, and this branch does not change the Python reader. No values move: the two files concatenate back to the byte content of the one they replace, headers aside.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves the reader to Rust, so the same parser can serve consumers that never load Python — in particular a converter emitting Arrow tables directly.
Additive. No Python is changed or removed except to teach the package to read
.xzfiles; the existingpytestsuite still gives 168 passed, 2 skipped, and 235 passed once the extension module is built.Layout
endfdescribes the formats and nothing more. A simulation-ready projection — reconstructed resonances, unionised grids, an Arrow schema — is a consumer's concern, so it can depend onarrow-rswithout that cost reaching everyone who just wants to read a file.What is ported
Every module of the package, module for module, except where noted under Gaps:
records,function,material, and MF 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 23, 26, 27, 28, 33, 34, 40ace(Type 1 tables),urrunivariate,angle_energy, plusAngleDistributionandEnergyDistributionspanning ENDF and ACE alikeproduct,reaction,incident_neutron,incident_photon,decay,radionuclide_production,chaindata,fission_energy,njoyEach covers every representation the file defines, not only the ones the fixtures happen to use. About 13,300 lines of Rust, with 2,300 of comment and 2,300 of unit test alongside.
The Python extension
endf-pyexposes both layers a consumer reads.The concrete types come across as classes:
Material,Tabulated1D,Tabulated2D,CrossSection,Product,Reaction,IncidentNeutron,IncidentPhoton,Decay,Chain,AceTable,FissionProductYields,RadionuclideProduction. Beside them the free functions —float_endf,int_endf,get_materials,get_tables,ace_tables_from_string,reaction_name,reaction_mt,photon_reaction_name,photon_reaction_mt,gnds_name,zam,temperature_str,decay_modes,normalise_branch_ratios,radionuclide_production,isomer_table,level_to_isomeric_state— and the constant tablesATOMIC_SYMBOL,SUM_RULES,INTERPOLATION_SCHEME,FISSION_MTS,EV_PER_MEV,K_BOLTZMANN.The sum types come across as dicts tagged with a
kindkey rather than as a class per variant:kindis exactly the discriminant an Arrow union column needs, and the alternative is a dozen wrapper classes expressing nothing more.Material.section_datais a drop-in. Every one of the 414 sections across the fixtures comes back as the same dictionary the Python reader builds, keyed by the same ENDF field names, so code reaching intomaterial[3, 1]['sigma']runs unchanged against either reader.Material.interpret()is there too, pickingIncidentNeutronorIncidentPhotonby NSUB.The upstream quirks come with them, because matching means matching: MT=458 reports
ZAas a float since it is read from a CONT record, MF=7 MT=4 stores the outerLTon each additional temperature rather than theLIit read, an unresolved range with LRF=1 is dispatched past unread, and a decay record too short for its internal conversion coefficients yields an empty tuple rather than a zero. Each is commented at its site with the issue that tracks it.How it is tested
tools/dump_golden.pywrites what the Python reader produces for a fixture;crates/endf/tests/golden.rsreads each dump, runs the Rust reader over the file it names, builds the samepath -> valuemap from its own parse, and compares the two maps whole. A field renamed, dropped or added shows up as a path on one side and not the other.Values are compared bit-for-bit. The dump records the shortest round-tripping decimal and both readers round correctly, so any difference is real. Only computed values use a 1e-12 relative tolerance, and
is_interpolatedlists exactly which: sampled interpolation; the MF=10 yields, which divide two interpolated cross sections; the propagated uncertainties, whereuncertaintiesaccumulates a variance and takes its square root; and the forward-scattered fraction with the removal cross section that folds it in, because NumPy re-associated the Clenshaw recurrence between 2.2 and 2.4. Nothing that comes off the file is compared loosely.The dump goes well past the parsed sections — every reaction, every nuclide, the MF=8/9/10 join, decay data with its source distributions, photon data with its atomic relaxation, and a whole depletion chain through a
KIND chaingolden naming ten decay and two neutron evaluations. For ACE it walks the AND block, the whole of DLW following each reaction's linked list, every reaction including elastic, and the nuclide they belong to. The NJOY input deck is held to Python's byte for byte.The bindings are tested the same way:
tests/test_rust_bindings.pyruns the extension and the pure-Python reader over the same fixtures and compares, rather than asserting values written down by hand. It skips itself when the module is not built.cargo clippy --all-targets -- -D warningscargo fmt,ruff check,ruff formatpytestThree kinds of coverage are pinned and asserted rather than described:
UNCOVERED_BY_ANY_FIXTURE, the parsers nothing exercises (MF40 alone, now);DISTRIBUTION_SHAPES, every angular, energy and joint angle-energy shape the dumpers can write; andSECTIONS_WITHOUT_A_DICT, now empty, so asection_dataprojection that stops being built fails rather than quietly disappearing. None can drift in either direction.CI is in
.github/workflows/rust.yml, three jobs: the crate (fmt, clippy, test in both profiles); the goldens, whichpython tools/dump_golden.py --checkholds to what the reader produces; and the bindings, built as an abi3 wheel with maturin on 3.10 and 3.13 and then tested.Fixtures
Twenty-three ENDF evaluations and four ACE tables, stored xz-compressed: an evaluation is highly repetitive and goes about six to one, the dumps about seven, so 9.4 MB of text is 1.5 MB of blobs and 80,000 lines leave the diff. Python reads them through
endf.fileutils.open_text, which handles.xzand leaves anything else alone; Rust reads them withlzma-rs, a pure-Rust dev-dependency, so the crate stays dependency-free for anything that uses it. The extension carries it as a real dependency, so a path that reads in one reader reads in the other.tools/trim_endf.pycuts an evaluation to the sections under test keeping the records valid — U235 is 36 MB whole and 528 KB here. Where no real file small enough to keep holds a shape, one is built:make_urr_ace.pyfor the unresolved resonance block,make_laws_ace.pyfor the ACE laws Li6 does not use,make_denormal_ace.pyfor the float form NJOY writes for a denormal,make_nfy_endf.pyfor the fission product yields, andmake_shapes_endf.pyfor MF2 Breit-Wigner, MF5 LF=12, MF6 LANG=2 and LAW=6, and MF13. The last two share the record writer intools/endf_writer.py. Values are invented; the layout is the format's, which is the part both readers are held to.Gaps, deliberately not hidden
IncidentPhoton::from_aceand a second temperature foradd_temperature_from_aceare written but unexercised._add_compton_profilesand_add_bremsstrahlungattach data from a shipped HDF5 file resampled with a cubic spline — auxiliary data, and it would cost the crate an HDF5 reader and a spline. The chain's XML serialisation needs an XML writer andform_matrixneeds sparse linear algebra. None of the four reads a nuclear data format.kindcolumn,AngleEnergy's four variants are the four shapesdistributions.arrowhas columns for — but theRecordBatchconstruction is not here.Upstream issues found while porting
Twelve. Each is reproduced in Rust deliberately, with a comment linking the issue, so the two readers agree until they are settled:
get_intg_recordwrites every element of a row to the same columnparse_mf33_subsectionappends NC sub-subsections twice whenLTY == 0get_tab2_recordusesint()where the rest of the reader usesint_endf()parse_mf7_mt4reads LI from each temperature record and then discards itparse_mf34discards every subsection it parses, and fills LB with LSAngleEnergy.from_aceraisesAttributeErrorfor ACE law 5forward_fractionreturns uninitialized memory for ACE-derived distributions, soremoval_xsis nondeterministic therechain.replace_missingwalks off the end ofATOMIC_SYMBOLand raisesKeyError: -1Decay.decay_constantraisesZeroDivisionErrorfor a nuclide whose evaluated half-life is zero#15, #18 and #21 are the ones worth attention. Case A and Case B are how most actinides write their URR, and the range comes back with only its header and no error — and with the file position wrong, so a later range is misparsed. In #18 every angular-distribution covariance is read off the stream and thrown away. In #21 the same
removal_xscall gives a different answer depending on what ran before it.Where there is no behaviour worth matching, the port does the defensible thing and says so in a comment: it refuses ACE law 5 by name (#19), computes the forward fraction properly for the ACE shapes (#21), stops the stand-in walk at the edge of the table (#22), and treats a zero half-life as unevaluated rather than as instant decay (#23).
Five differences were found in the other direction, in code written here, all by the exhaustive comparisons:
temperature_str(1200.5)gave1201Kagainst Python's1200K— Python'sroundbreaks ties to even,f64::roundaway from zero. That string keys which temperature a table belongs to.univariateCDFs differed in the last two digits for linear-log and log-linear, because Python subtracts logs (np.diff(np.log(x))) where the port took the log of a ratio.900where Python writes900.0. Python'sstralways writes a fractional part; Rust's shortest form does not. The same trap breaks the decay mode encoding, which reads RTYP by stripping zeros off the formatted float, sopython_float_strlives indataand both use it.All five fixed and pinned.
Building