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
+34 -17
View File
@@ -96,6 +96,10 @@ class BranchInfo:
log(ft) value for this branch. log(ft) value for this branch.
transition_type : str transition_type : str
Forbiddenness classification for this branch (A, F1, F1U, F2, ...). 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 level_index: int
@@ -103,6 +107,7 @@ class BranchInfo:
intensity: float intensity: float
log_ft: Optional[float] log_ft: Optional[float]
transition_type: str = "A" transition_type: str = "A"
endpoint_MeV: float = 0.0
def _parse_nuclide_symbol(symbol: str) -> Tuple[str, int, int]: 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) # Extract Q-value from ground state branch (state[7] = endpoint energy in keV)
# and half-life from halflife_data # and half-life from halflife_data
endpoint_keV = 0.0
half_life_str = "unknown" 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: if beta_data:
for k, v in beta_data.items(): for _k, v in beta_data.items():
for state in v: for state in v:
if state[0] == 0: # ground state branch if len(state) > 7 and state[7] is not None:
endpoint_keV = float(state[7]) endpoint_keV = max(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 halflife_data: if halflife_data:
for k, v in halflife_data.items(): for k, v in halflife_data.items():
@@ -447,6 +447,11 @@ def get_decay_info_from_paceENSDF(
intensity=float(state[9]) / 100.0, intensity=float(state[9]) / 100.0,
log_ft=float(state[11]) if state[11] is not None else None, log_ft=float(state[11]) if state[11] is not None else None,
transition_type=branch_transition_type, 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) branches.append(branch)
@@ -497,12 +502,19 @@ def decay_info_to_config(
SpectrumConfig SpectrumConfig
Configuration ready for BetaSpectrum.from_config(). 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 branches: Optional[List[BranchConfig]] = None
if info.branches: if info.branches:
branches = [ branches = [
BranchConfig( 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, transition_type=b.transition_type,
intensity=b.intensity, intensity=b.intensity,
) )
@@ -615,8 +627,13 @@ def load_json_input(filepath: str) -> Dict[str, Any]:
if not path.exists(): if not path.exists():
raise FileNotFoundError(f"Input file not found: {filepath}") raise FileNotFoundError(f"Input file not found: {filepath}")
with open(path, "r") as f: try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(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 # Fill in defaults for missing optional fields
for key, default in DEFAULT_JSON_SCHEMA.items(): for key, default in DEFAULT_JSON_SCHEMA.items():
+17
View File
@@ -163,6 +163,23 @@ class TestCLIPaceENSDF:
assert exc.value.code == 1 assert exc.value.code == 1
assert not (tmp_path / "tc99deg.csv").exists() assert not (tmp_path / "tc99deg.csv").exists()
def test_paceensdf_co60_dry_run_multi_branch(self, capsys: Any) -> None:
"""Co60 parses into a healthy multi-branch config (D3): no crash,
all branch endpoints positive."""
main(["Co60", "--dry-run"])
out = capsys.readouterr().out
assert "Multi-branch mode: 3 branches" in out
def test_paceensdf_co60_export(self, tmp_path: Any) -> None:
out = str(tmp_path / "co60.csv")
main(["Co60", "--output", out])
df = read_export(out)
area = np.trapezoid(df["total_spectrum"], df["energy_MeV"])
assert np.isclose(area, 1.0, rtol=1e-3)
eps = header_lines_of(out)
assert any("E₀=1492.0" in h for h in eps)
assert not any("excluded" in h for h in eps)
def test_cli_builds_paceensdf_config_via_library( def test_cli_builds_paceensdf_config_via_library(
self, monkeypatch: Any, capsys: Any self, monkeypatch: Any, capsys: Any
) -> None: ) -> None:
+38
View File
@@ -294,6 +294,44 @@ class TestDecayInfoToConfig:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.slow
@pytest.mark.requires_ensdf
@pytest.mark.skipif(not _has_paceENSDF(), reason="paceENSDF not installed")
class TestPaceENSDFBranchEndpoints:
"""Per-branch endpoints are the measured transition endpoint energies
(ENSDF column 7), not base-endpoint minus level energy subtractions
with a base taken from an arbitrary first branch (D3)."""
def _branch_endpoints(self, nuclide: str) -> list[float]:
info = get_decay_info_from_paceENSDF(nuclide, "beta_minus")
cfg = decay_info_to_config(info)
assert cfg.branches is not None
return sorted(
(b.endpoint_MeV for b in cfg.branches),
reverse=True,
)
def test_co60_branch_endpoints_positive(self) -> None:
# Measured endpoints: 1492.0, 670.0, 317.88 keV (levels above the
# first branch's endpoint previously produced negative values and
# a hard ValueError from BranchConfig validation).
eps = self._branch_endpoints("Co60")
assert eps == pytest.approx([1.492, 0.670, 0.31788], abs=1e-6)
assert all(e > 0 for e in eps)
def test_tc99_branch_endpoints(self) -> None:
eps = self._branch_endpoints("Tc99")
assert eps == pytest.approx([0.2975, 0.2080], abs=1e-6)
def test_cs137_branch_endpoints(self) -> None:
eps = self._branch_endpoints("Cs137")
assert eps == pytest.approx([1.176, 0.89213, 0.51403], abs=1e-6)
def test_overall_endpoint_is_max_branch_endpoint(self) -> None:
info = get_decay_info_from_paceENSDF("Co60", "beta_minus")
assert info.endpoint_MeV == pytest.approx(1.492, abs=1e-6)
@pytest.mark.requires_ensdf @pytest.mark.requires_ensdf
@pytest.mark.skipif(not _has_paceENSDF(), reason="paceENSDF not installed") @pytest.mark.skipif(not _has_paceENSDF(), reason="paceENSDF not installed")
class TestPaceENSDFIntegration: class TestPaceENSDFIntegration: