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.
|
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:
|
||||||
data = json.load(f)
|
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
|
# Fill in defaults for missing optional fields
|
||||||
for key, default in DEFAULT_JSON_SCHEMA.items():
|
for key, default in DEFAULT_JSON_SCHEMA.items():
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user