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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user