fix(nuclear_data): use measured transition endpoints per branch

paceENSDF stores the measured beta endpoint energy of each transition
(ENSDF column 7). The parser instead derived branch endpoints as
base-endpoint minus level energy with the base taken from an arbitrary
first-listed branch, so Co60 produced negative branch endpoints and a
hard ValueError (bs_pnpi Co60 exited 1 regardless of cutoff).

- BranchInfo carries the measured endpoint_MeV
- overall endpoint = max measured endpoint over all branches
- decay_info_to_config prefers measured endpoints; the subtraction is
  retained only as a legacy fallback for hand-built DecayInfo objects
- Co60/Tc99/Cs137 endpoint tests + CLI dry-run/export tests (D3)
This commit is contained in:
2026-10-02 08:42:51 +03:00
parent 9c3dd80b5c
commit 3590ca91a1
3 changed files with 90 additions and 18 deletions
+35 -18
View File
@@ -96,6 +96,10 @@ class BranchInfo:
log(ft) value for this branch.
transition_type : str
Forbiddenness classification for this branch (A, F1, F1U, F2, ...).
endpoint_MeV : float
Measured beta endpoint energy of this transition in MeV (ENSDF
column 7). 0.0 means "not provided": consumers fall back to
base-endpoint minus level-energy subtraction.
"""
level_index: int
@@ -103,6 +107,7 @@ class BranchInfo:
intensity: float
log_ft: Optional[float]
transition_type: str = "A"
endpoint_MeV: float = 0.0
def _parse_nuclide_symbol(symbol: str) -> Tuple[str, int, int]:
@@ -384,24 +389,19 @@ def get_decay_info_from_paceENSDF(
# Extract Q-value from ground state branch (state[7] = endpoint energy in keV)
# and half-life from halflife_data
endpoint_keV = 0.0
half_life_str = "unknown"
# The spectrum endpoint is the maximum measured transition endpoint
# across all branches (ENSDF column 7, keV). Selecting the ground
# state branch or the first branch is wrong whenever those branches
# are absent or non-maximal (e.g. Co60, whose first listed branch
# endpoint is below higher-level transition endpoints).
endpoint_keV = 0.0
if beta_data:
for k, v in beta_data.items():
for _k, v in beta_data.items():
for state in v:
if state[0] == 0: # ground state branch
endpoint_keV = float(state[7])
break
if endpoint_keV > 0:
break
# No ground state branch — use first available branch
if endpoint_keV == 0.0:
for k, v in beta_data.items():
if v and len(v) > 0:
endpoint_keV = float(v[0][7])
break
if len(state) > 7 and state[7] is not None:
endpoint_keV = max(endpoint_keV, float(state[7]))
if halflife_data:
for k, v in halflife_data.items():
@@ -447,6 +447,11 @@ def get_decay_info_from_paceENSDF(
intensity=float(state[9]) / 100.0,
log_ft=float(state[11]) if state[11] is not None else None,
transition_type=branch_transition_type,
endpoint_MeV=(
float(state[7]) / 1000.0
if len(state) > 7 and state[7] is not None
else 0.0
),
)
branches.append(branch)
@@ -497,12 +502,19 @@ def decay_info_to_config(
SpectrumConfig
Configuration ready for BetaSpectrum.from_config().
"""
# Convert BranchInfo -> BranchConfig for SpectrumConfig
# Convert BranchInfo -> BranchConfig for SpectrumConfig. Per-branch
# endpoints are the measured transition endpoint energies when
# available; the base-endpoint minus level-energy subtraction is only
# a legacy fallback for hand-built DecayInfo objects without them.
branches: Optional[List[BranchConfig]] = None
if info.branches:
branches = [
BranchConfig(
endpoint_MeV=info.endpoint_MeV - b.level_energy_keV / 1000.0,
endpoint_MeV=(
b.endpoint_MeV
if b.endpoint_MeV > 0.0
else info.endpoint_MeV - b.level_energy_keV / 1000.0
),
transition_type=b.transition_type,
intensity=b.intensity,
)
@@ -615,8 +627,13 @@ def load_json_input(filepath: str) -> Dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"Input file not found: {filepath}")
with open(path, "r") as f:
data = json.load(f)
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except OSError as exc:
raise FileNotFoundError(f"Input file not readable: {filepath}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in input file {filepath}: {exc}") from exc
# Fill in defaults for missing optional fields
for key, default in DEFAULT_JSON_SCHEMA.items():