Summary
Level ordering in FAC output is not reproducible between independent runs, because levels are sorted purely by energy (CompareLevels, faclib/structure.c:3407, default _sort_levs = 1) and two runs with different configuration spaces yield slightly different energies for the same physical levels. This makes a very natural workflow — cross-referencing level indices between per-charge-state runs — silently mis-identify levels. It cost our group a subtle, three-year-latent bug in an NLTE code, and I suspect other index-based post-processing pipelines are exposed to the same trap.
Setup (a common DIY collisional-radiative workflow)
One run per charge state, in separate directories: the run for ion charge q includes the (q+1)-ion target configurations (needed for AITable/RRTable), so its .lev file contains two blocks — block 1: the q-ion levels; block 2: the (q+1)-ion target levels. The next directory runs the (q+1) ion as its primary species (its own block 1).
A natural way to stitch charge states together is to map the free-level indices in the rr/ai tables into the next run's level list by a flat index offset, implicitly assuming:
block 2 of run q and block 1 of run q+1 enumerate the same levels in the same order.
Observation: that assumption fails for every adjacent charge pair we checked
For our N (Z=7) and Ne (Z=10) datasets, block 2 of run q and block 1 of run q+1 contain the same set of levels but in different orders. Positional agreement of configuration names between the two blocks, per adjacent pair:
- N: 15.6% / 39.8% / 57.6% / 66.2% / 45.5% / 21.1% (q = 0…5)
- Ne: 26.6% / 18.7% / 25.7% / 33.5% / 54.6% / 84.6% / 67.9% / 55.8% / 15.8% (q = 0…8)
The mechanism is clear from the source: with the default _sort_levs = 1, CompareLevels orders purely by energy. The two runs diagonalize with different configuration lists (each run also carries its own ionization targets, etc.), so near-degenerate levels reshuffle; for strongly mixed levels even the dominant-component name can differ between runs. Within each run everything is perfectly self-consistent — we verified that the (free index, 2J) pairs in the rr file agree with that run's own block 2 for 133,876/133,876 rows — the inconsistency only appears across runs.
The failure is silent: indices stay in range, thresholds are still read from the rr rows themselves (so they look right), and only the identity of the final level is wrong. In our code, 52–62% of bound-free channels had their final level mis-assigned (mostly within the same complex — near-degenerate reshuffles — with ~10% landing in a different complex). Two independent re-implementations inherited the same assumption and agreed with each other, so internal cross-checks never caught it; it was finally exposed by the third-party consistency check "2J from the rr row vs 2J of the level the index points to".
Suggestions
Any of the following would remove the trap (roughly in decreasing order of usefulness):
- A canonical, run-independent sort option, e.g.
structure:sort_levs = 3: sort by (ncomplex, name, 2J, energy). The sorting infrastructure and the option switch already exist (structure:sort_levs = 0/1/2), so this looks like a small, backward-compatible addition — though label instability for strongly mixed levels means a perfect canonical order is subtle, and you will know best whether name-based keys are robust enough here.
- Stable level identifiers usable as join keys across runs (emitted in
.lev and referenced by rr/ai).
- At minimum, a prominent documentation warning that level order/indices are not comparable across independent runs, and that cross-run joins must match on (name, 2J, energy) rather than position.
Workaround (for anyone who hits this)
Match block-2 rows of run q to block-1 rows of run q+1 by (configuration name, 2J) groups, pairing within each group in energy order (after subtracting each block's own minimum energy), with nearest-energy assignment for the leftovers where labels swapped. This restored 100.000% cross-run 2J consistency for both our N and Ne datasets.
Version
Data generated with FAC 1.1.5-era builds; the sorting logic is unchanged at current master (6196889). Happy to provide the input decks, the two .lev files of a failing pair, or the comparison script — and thanks for maintaining FAC.
Summary
Level ordering in FAC output is not reproducible between independent runs, because levels are sorted purely by energy (
CompareLevels,faclib/structure.c:3407, default_sort_levs = 1) and two runs with different configuration spaces yield slightly different energies for the same physical levels. This makes a very natural workflow — cross-referencing level indices between per-charge-state runs — silently mis-identify levels. It cost our group a subtle, three-year-latent bug in an NLTE code, and I suspect other index-based post-processing pipelines are exposed to the same trap.Setup (a common DIY collisional-radiative workflow)
One run per charge state, in separate directories: the run for ion charge
qincludes the(q+1)-ion target configurations (needed forAITable/RRTable), so its.levfile contains two blocks — block 1: theq-ion levels; block 2: the(q+1)-ion target levels. The next directory runs the(q+1)ion as its primary species (its own block 1).A natural way to stitch charge states together is to map the free-level indices in the
rr/aitables into the next run's level list by a flat index offset, implicitly assuming:Observation: that assumption fails for every adjacent charge pair we checked
For our N (Z=7) and Ne (Z=10) datasets, block 2 of run
qand block 1 of runq+1contain the same set of levels but in different orders. Positional agreement of configuration names between the two blocks, per adjacent pair:The mechanism is clear from the source: with the default
_sort_levs = 1,CompareLevelsorders purely by energy. The two runs diagonalize with different configuration lists (each run also carries its own ionization targets, etc.), so near-degenerate levels reshuffle; for strongly mixed levels even the dominant-component name can differ between runs. Within each run everything is perfectly self-consistent — we verified that the(free index, 2J)pairs in therrfile agree with that run's own block 2 for 133,876/133,876 rows — the inconsistency only appears across runs.The failure is silent: indices stay in range, thresholds are still read from the
rrrows themselves (so they look right), and only the identity of the final level is wrong. In our code, 52–62% of bound-free channels had their final level mis-assigned (mostly within the same complex — near-degenerate reshuffles — with ~10% landing in a different complex). Two independent re-implementations inherited the same assumption and agreed with each other, so internal cross-checks never caught it; it was finally exposed by the third-party consistency check "2J from therrrow vs 2J of the level the index points to".Suggestions
Any of the following would remove the trap (roughly in decreasing order of usefulness):
structure:sort_levs = 3: sort by (ncomplex, name, 2J, energy). The sorting infrastructure and the option switch already exist (structure:sort_levs= 0/1/2), so this looks like a small, backward-compatible addition — though label instability for strongly mixed levels means a perfect canonical order is subtle, and you will know best whether name-based keys are robust enough here..levand referenced byrr/ai).Workaround (for anyone who hits this)
Match block-2 rows of run
qto block-1 rows of runq+1by (configuration name, 2J) groups, pairing within each group in energy order (after subtracting each block's own minimum energy), with nearest-energy assignment for the leftovers where labels swapped. This restored 100.000% cross-run 2J consistency for both our N and Ne datasets.Version
Data generated with FAC 1.1.5-era builds; the sorting logic is unchanged at current master (6196889). Happy to provide the input decks, the two
.levfiles of a failing pair, or the comparison script — and thanks for maintaining FAC.