Skip to content
ATC
All notes

Every nuclear code spells the same atom differently

8 min read
  • nuclear
  • rust
  • data-modelling
  • nucleide

A nuclide has one of the few genuinely unambiguous identities in scientific software: Z protons, A nucleons, and — for the awkward cases — which excited state the nucleus is sitting in. Three integers. There is no physics ambiguity anywhere in that.

The formats disagree about everything else: field order, separators, whether the state is a letter or an offset, whether the whole thing fits in a machine word, and whether the state exists at all. Between MCNP, Serpent, FLUKA, NIST, Cinder, ALARA and ARMI, U-235 is written 92235, U-235, 235-U, 235U, u:235, 2350920 and nU235.

Nucleide reads nine of those dialects into a single id. Most of that turned out to be string trimming. The rest is deciding what to do when a format cannot represent the answer, and that is the part that needed design rather than parsing. It is also why the module is 1,044 lines.

The id is PyNE’s, and that is the point

NuclideId is a u32 packed as (Z * 1000 + A) * 10_000 + state.

nuclide packed value
U-235 922_350_000
Am-242 952_420_000
Am-242m 952_420_001
Ta-182n 731_820_002

Validation is 1 ≤ Z ≤ 118, Z ≤ A ≤ 999, state ≤ 9, so the largest possible value is 1,189,990,009 — comfortably inside a u32. The A ≤ 999 bound is not arbitrary: it keeps three-digit mass numbers available to every dialect that assumes them.

The packing is PyNE’s nucid convention, not something I invented. I kept it because existing data already speaks it, and a fresh packing would have bought nothing except a conversion pass at every boundary.

Everything else I put at the edge as a free function — dialects::to_zaid, dialects::from_serpent, and so on — never as a method on NuclideId. A core type that grows one method per file format stops being a type and becomes a directory of other people’s decisions.

The same integer means two different nuclides

DialectU-235Am-242Am-242mCarrier
MCNP ZAID922359564295242integer
SZA92235952421095242integer
Cinder235092024209502420951integer
zzllaaam92-U-23595-Am-24295-Am-242mstring
SerpentU-235Am-242Am-242mstring
NIST235U242Am242Amstring
ALARAu:235am:242am:242string
ARMInU235nAm242nAm242mstring
FLUKA235-U——8-char name
U-235, Am-242 and Am-242m in nine naming dialects.
encodingfield layoutintegernucid95Z242A0001S952420001MCNP ZAID95Z242A95242SZA1S095Z242A1095242Cinder242A095Z1S2420951
The same nuclide, four field orders. SZA puts the state first, Cinder puts the mass first, and ZAID has no state field at all — which is why Am-242 needs a special case rather than a rule.Scroll the diagram sideways to see the integers →

The first two rows are why there is no parse_nuclide(s) anywhere in the API. 95242 is Am-242m in MCNP and Am-242 in SZA. Both are internally consistent encodings, and neither is wrong. A parser cannot resolve that from the number, and one that guesses produces an americium inventory that is off by an isomer.

So the caller names the dialect, because only the caller knows what it is holding.

Free-form input still needs a precedence, because free-form input will arrive. normalize_nuclide_name("92235") reads a ZAID, which makes 10010 neon-10 rather than the Cinder encoding of hydrogen-1. One of the two has to lose, so I wrote the precedence into the module header rather than leaving it to whoever reads the code next.

Snapshot data is where this bites

An integer column that came out of one code and is being fed into another is the highest-risk thing in this layer. It looks like a number, so nobody checks its dialect, and it silently resolves to a valid but wrong nuclide.

I special-cased Am-242, because MCNP does

MCNP’s regular rule is ZAID = Z * 1000 + A for ground states, with metastable states adding 300 + 100 * S. U-236m is 92636.

Am-242 breaks it. The rule would put the ground state at 95242 and the first metastable state at 95642. MCNP does the opposite: 95242 is Am-242m, 95642 is Am-242. I apply the swap in both directions:

pub fn to_zaid(nuc: NuclideId) -> u32 {
    let mut state = nuc.state();
    let mut zaid = nuc.z() * 1_000 + nuc.a();
    if zaid == 95_242 && state < 2 {
        state = (state + 1) % 2;
    }
    if state != 0 {
        zaid += 300 + state * 100;
    }
    zaid
}

There is nothing to derive here. It is a property of the code I am interoperating with, and the only correct implementation is to reproduce it exactly. I left a test standing on it.

The heuristic I could not avoid

Some ZAIDs arrive with A ≥ 400. The decoder treats the excess as successive metastable states: each step subtracts 100 from the mass number and adds 1 to the state, until the result is physically plausible. “Plausible” here means A / Z > 3.0, a bound no real nuclide violates.

let mut n = ((zaid - 400) * 10_000) + 1;
loop {
    let aaa = (n / 10_000) % 1_000;
    let zzz = n / 10_000_000;
    if (aaa as f32) / (zzz as f32) <= 3.0 {
        break;
    }
    n -= 999_999;
}

So 95942 becomes Am-242 in state 4, not a nuclide with mass 542:

assert_eq!(from_zaid(95_942).unwrap(), nid(95, 242, 4));
assert_eq!(from_zaid(96_644).unwrap(), nid(96, 244, 1));

This is a heuristic and it knows it. 95942 was written by a person who meant something specific, and the decoder is guessing at it. It is still the right guess, because the alternative is rejecting a deck that the surrounding tools read happily.

The alphabet has a hole in it, and the hole is not mine

Isomer designators are mnopqrstuvxyz: thirteen letters, states 1 through 13, no w. The hole is upstream, so looping over a..=z will eventually produce a letter the parser rejects.

The alphabet also runs past the id. NuclideId caps at state 9, so m through u are the only designators that round-trip; v, x, y and z exist in the table and fail validation on construction.

The reason any of this matters is what the rest of the ecosystem does. PyNE writes m for every metastable state, so Ta-182n — state 2 — comes back as m and re-parses as state 1. I kept the index and write 73-Ta-182n, while still accepting PyNE-style lowercase m–z and uppercase M on input. That is not pedantry: state 1 and state 2 of the same nuclide have different half-lives, and a depletion chain that confuses them gives a different answer.

I let NIST and ALARA lose information, on purpose

NIST and ALARA carry no metastable designator at all. nist(Am-242m) returns 242Am, and parsing 242Am returns ground-state Am-242. The conversion succeeds and quietly discards the only fact that mattered.

I did not turn that into an error. The format genuinely cannot represent the answer, and refusing to emit a NIST name because the nuclide happens to be metastable would make the converter useless for the ordinary case. What I did instead was make the loss explicit: documented per dialect, tested under a name that says what it does (nist_dialect_drops_state), and never described as a round trip.

The case I do reject is the natural element. NuclideId requires A ≥ Z ≥ 1, so there is no catch-all for “uranium, unspecified”. from_zaid(92000) returns NaturalElement("U") instead of inventing U-0, and no formatter will ever emit -nat or a bare symbol. That is the one place where I say no instead of guessing.

The trailing letter is a state, except when it is a library tag

241Pu is plutonium-241. If I strip the trailing letter as an isomer designator, I get 241P plus u — phosphorus-241 in state 9. That is a valid NuclideId: A = 241 ≥ Z = 15, state 9 ≤ 9. No error, wrong element, wrong state, all the way downstream.

So the parser tries the whole remainder as an element symbol first (Pu → 94), and only strips one trailing letter if that fails.

ARMI makes the same point harder. U235_7 — the _7 is the ENDF/B-VII.x library tag. U-2355 — the trailing 5 is ENDF/B-V.2. AM242M — the trailing M is a real state. Three meanings for a trailing character inside one family of labels, and state 7 therefore has to be written GNDS-style, U235_m7, to be unambiguous.

I kept FLUKA’s two bugs

FLUKA names are truncated to eight: BERYLLIU, RUTHERFO, PHOSPHO, HYDROG-1, 235-U.

I kept two upstream quirks on purpose. TRITIUM maps to H-4 — mass 4, not the tritium mass 3. And the C table’s LITHIUM entry is the octal literal 030000000, which as decimal is 30,000,000: Z=3, A=0, a natural element, unrepresentable either way.

Fixing those in a vendored table would be a silent divergence from the code I am interoperating with. The table’s job is to agree with FLUKA, not to be right about physics.

I test the loss as loudly as the round trip

There are 23 tests in the module. The round trips cover every dialect that can round-trip; the loss tests cover every dialect that cannot.

test what it holds down
zaid_round_trips ground state, state 1, and the state-4 heuristic survive both directions
zaid_edge_cases the Am-242 swap and the A ≥ 400 redistribution
nist_dialect_drops_state state 2 in, ground state out — the loss is asserted, not tolerated
normalize_rejects_bare_symbols_and_garbage U is not a nuclide
fluka_to_id_error_paths unknown FLUKA names fail loudly instead of guessing

nist_dialect_drops_state is the one I care about. A converter that silently drops the state is a data-corruption bug with good manners, so the drop gets its own test rather than a comment.

Three rules I would give anyone writing this layer

One canonical id, dialects at the edge. The core type knows its own name; it does not know MCNP’s.

A lossy conversion must be documented, tested as lossy, and never called a round trip. Returning success while discarding the state is the failure mode that survives code review, because the code looks correct.

When one string can mean two nuclides, the caller names the dialect. Guessing is how 95242 becomes the wrong americium, and nobody notices until the inventory is wrong.