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
-3
View File
@@ -12,9 +12,6 @@ from .core import BetaSpectrumAnalyzer, _UnifiedBranch
from .csv_export import CSVEmitter
from .visualization import Visualizer
# Re-export the main class for backward compatibility
BetaSpectrumAnalyzer = BetaSpectrumAnalyzer
__all__ = [
"BetaSpectrumAnalyzer",
"CSVEmitter",
+11 -3
View File
@@ -55,12 +55,20 @@ class BetaSpectrumAnalyzer:
self._is_multi = spectrum._is_multi
self._timestamp: Optional[str] = None
# Unified branch view: single-branch treated as one-branch multi-branch
# Unified branch view: single-branch treated as one-branch multi-branch.
# When config.branches carries exactly one branch, that branch (not the
# top-level decay fields) defines the endpoint and transition type.
if not self._is_multi and self.spectrum.branch_spectra == []:
endpoint_MeV = config.endpoint_MeV
transition_type = config.transition_type
if config.branches is not None and len(config.branches) == 1:
lone = config.branches[0]
endpoint_MeV = float(lone.endpoint_MeV)
transition_type = str(lone.transition_type)
self._unified_branches: List[_UnifiedBranch] = [
_UnifiedBranch(
endpoint_MeV=config.endpoint_MeV,
transition_type=config.transition_type,
endpoint_MeV=endpoint_MeV,
transition_type=transition_type,
intensity=1.0,
)
]
+13 -1
View File
@@ -81,6 +81,18 @@ class CSVEmitter:
if config.use_exchange:
enabled.append("exchange")
# The header endpoint must describe the spectrum that is actually
# computed. Single mode with exactly one configured branch follows
# the branch (the top-level endpoint may be stale from the parent
# decay, e.g. after intensity-cutoff filtering).
basic_endpoint_MeV = config.endpoint_MeV
if (
not analyzer._is_multi
and config.branches is not None
and len(config.branches) == 1
):
basic_endpoint_MeV = analyzer._unified_branches[0].endpoint_MeV
parent_symbol = analyzer._element_symbol(config.Z_parent)
daughter_symbol = analyzer._element_symbol(config.Z_daughter)
@@ -99,7 +111,7 @@ class CSVEmitter:
f"# nuclide: {parent_symbol}{config.A_number} "
f"-> {daughter_symbol}{config.A_number} "
f"(Z={config.Z_parent}->{config.Z_daughter})",
f"# basic_endpoint: {config.endpoint_MeV * 1000:.1f} keV",
f"# basic_endpoint: {basic_endpoint_MeV * 1000:.1f} keV",
]
if config.neutrino_mass_MeV > 0:
+23 -2
View File
@@ -95,6 +95,21 @@ class SpectrumConfig:
)
def _effective_single_fields(config: SpectrumConfig) -> Tuple[float, str]:
"""Resolve (endpoint_MeV, transition_type) for single-branch mode.
When config.branches carries exactly one branch (e.g. after intensity
cutoff filtering removed all but one), that branch defines the spectrum:
its endpoint and transition type win over the (possibly stale) top-level
fields inherited from the parent decay. With branches=None the top-level
fields are used as before.
"""
if config.branches is not None and len(config.branches) == 1:
lone = config.branches[0]
return float(lone.endpoint_MeV), str(lone.transition_type)
return float(config.endpoint_MeV), str(config.transition_type)
class BetaSpectrum:
"""
Precise beta spectrum calculator.
@@ -259,7 +274,8 @@ class BetaSpectrum:
conditional.
"""
components: List[SpectrumComponent] = []
W0 = float(T_to_W(config.endpoint_MeV))
eff_endpoint, eff_transition = _effective_single_fields(config)
W0 = float(T_to_W(eff_endpoint))
# Core spectrum components (0-order): always included.
# Convert neutrino mass from MeV to natural units
@@ -267,7 +283,7 @@ class BetaSpectrum:
components.append(
PhaseSpace(
W0=W0,
transition_type=config.transition_type,
transition_type=eff_transition,
m_nu=m_nu_natural,
logger=logger,
)
@@ -419,6 +435,11 @@ class BetaSpectrum:
"""
if self._is_multi and self.branches:
max_endpoint = max(b.endpoint_MeV for b in self.branches)
elif config.branches is not None and len(config.branches) == 1:
# Single-mode with exactly one configured branch: the branch
# endpoint defines the grid, not the (possibly stale) top-level
# endpoint from the parent decay.
max_endpoint = float(config.branches[0].endpoint_MeV)
else:
max_endpoint = config.endpoint_MeV