Every nuclear code spells the same atom differently
- 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
| Dialect | U-235 | Am-242 | Am-242m | Carrier |
|---|---|---|---|---|
| MCNP ZAID | 92235 | 95642 | 95242 | integer |
| SZA | 92235 | 95242 | 1095242 | integer |
| Cinder | 2350920 | 2420950 | 2420951 | integer |
| zzllaaam | 92-U-235 | 95-Am-242 | 95-Am-242m | string |
| Serpent | U-235 | Am-242 | Am-242m | string |
| NIST | 235U | 242Am | 242Am | string |
| ALARA | u:235 | am:242 | am:242 | string |
| ARMI | nU235 | nAm242 | nAm242m | string |
| FLUKA | 235-U | — | — | 8-char name |
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.
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.