fix(spectrum): lone branch defines endpoint in single-branch mode

When config.branches carries exactly one branch (e.g. after
--intensity-cutoff filtering), the branch endpoint/transition define
the spectrum, energy grid, analyzer unified branch view and CSV
header. The stale top-level endpoint inherited from the parent decay
no longer leaks into physics (proven wrong on Cs137: spectrum
extended to 1176 keV while the lone survivor ends at 514 keV).
This commit is contained in:
2026-10-01 22:04:17 +03:00
parent 6bb7b950c7
commit ee7aedc80a
6 changed files with 179 additions and 10 deletions
+84
View File
@@ -438,6 +438,90 @@ class TestVisualizerBuiltFromCSV:
assert (tmp_path / "analysis.png").exists()
def make_single_survivor_config(**over: Any) -> SpectrumConfig:
"""A config whose top-level endpoint exceeds a lone branch's endpoint.
This mirrors the intensity_cutoff / decay_info_to_config path where the
parent endpoint_MeV keeps the strongest-decay value while the only
surviving branch sits far below it.
"""
over.setdefault("endpoint_MeV", 1.176)
over.setdefault("transition_type", "F2")
over.setdefault(
"branches",
[BranchConfig(endpoint_MeV=0.514341, transition_type="A", intensity=1.0)],
)
return SpectrumConfig(
Z_parent=55,
Z_daughter=56,
A_number=137,
e_step_MeV=0.001,
**over,
)
class TestSurvivingBranchEndpointFollows:
"""When config.branches has exactly 1 entry, single-mode spectrum,
energy grid and analyzer must follow the branch endpoint/transition,
not the top-level endpoint fields."""
def test_single_mode_builds_from_branch_endpoint(self) -> None:
config = make_single_survivor_config()
spectrum = BetaSpectrum.from_config(config)
assert not spectrum._is_multi
W, E = spectrum.get_energy_grid(config)
# The energy grid must be capped at the lone branch endpoint
# (~514 keV), not the stale top-level endpoint (1176 keV), and
# stay non-negative everywhere it covers.
assert float(E.max()) < 0.514341 + 1e-12
values = spectrum(W)
assert np.all(np.isfinite(values))
assert np.all(values >= 0.0)
def test_single_mode_matches_reference_bare_config(self) -> None:
"""Branch-driven single mode == a bare config set to the branch endpoint."""
branch_ep = 0.514341
cfg_branch = make_single_survivor_config()
cfg_ref = SpectrumConfig(
Z_parent=55,
Z_daughter=56,
A_number=137,
endpoint_MeV=branch_ep,
transition_type="A",
e_step_MeV=0.001,
)
spec_b = BetaSpectrum.from_config(cfg_branch)
spec_ref = BetaSpectrum.from_config(cfg_ref)
Wb, Eb = spec_b.get_energy_grid(cfg_branch)
Wr, Er = spec_ref.get_energy_grid(cfg_ref)
vb = spec_b(Wb)
vr = spec_ref(Wr)
# The branch-driven grid and values must be identical to a bare
# config built directly at the branch endpoint/transition.
assert len(Eb) == len(Er)
assert np.allclose(Eb, Er, rtol=0, atol=1e-12)
assert np.allclose(vb, vr, rtol=1e-10)
def test_analyzer_unified_view_follows_branch(self) -> None:
config = make_single_survivor_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
ub = analyzer._unified_branches[0]
assert ub.endpoint_MeV == pytest.approx(0.514341)
assert ub.transition_type == "A"
def test_csv_header_reports_branch_endpoint(self, tmp_path: Any) -> None:
config = make_single_survivor_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, _ = export_and_read(analyzer, tmp_path)
header = header_lines_of(path)
# The lone surviving branch must be reported at its own endpoint,
# not the 1176 keV top-level endpoint.
assert any("514.3 keV" in h for h in header)
assert not any("1176.0 keV" in h for h in header)
class TestAnalyzerUniversalFactor:
"""analyzer.universal_factor() = product of components shared by all."""