feat: per-branch transition types from ENSDF and raw-value plotting

- Add transition_type field to BranchInfo and extract per-branch
  forbiddenness codes from paceENSDF (not defaulting all to 'A')
- Fix _multi_branch_call to use np.maximum instead of np.where mask
  so branches smoothly approach zero at endpoint
- Rewrite _plot_multi_branch_spectra and _plot_multi_branch_debug Panel 1
  to plot raw (un-normalized) branch contributions so total = sum of branches
- Remove independent unit-area normalization that made small branches
  appear larger than total
This commit is contained in:
2026-05-08 08:33:12 +03:00
parent 572e222c78
commit 3216c50943
2 changed files with 688 additions and 34 deletions
+17 -10
View File
@@ -94,12 +94,15 @@ class BranchInfo:
Branch intensity (fraction of decays). Branch intensity (fraction of decays).
log_ft : float log_ft : float
log(ft) value for this branch. log(ft) value for this branch.
transition_type : str
Forbiddenness classification for this branch (A, F1, F1U, F2, ...).
""" """
level_index: int level_index: int
level_energy_keV: float level_energy_keV: float
intensity: float intensity: float
log_ft: Optional[float] log_ft: Optional[float]
transition_type: str = "A"
def _parse_nuclide_symbol(symbol: str) -> Tuple[str, int, int]: def _parse_nuclide_symbol(symbol: str) -> Tuple[str, int, int]:
@@ -422,24 +425,28 @@ def get_decay_info_from_paceENSDF(
for k, v in beta_data.items(): for k, v in beta_data.items():
for state in v: for state in v:
# Use ground state branch (level_index == 0) for forbiddenness # Extract per-branch forbiddenness code
if state[0] == 0 and len(state) > 15 and state[15] is not None: branch_transition_type = "A" # default
if len(state) > 15 and state[15] is not None:
code = str(state[15]) code = str(state[15])
if code in FORBIDDENNESS_MAP: if code in FORBIDDENNESS_MAP:
forbidden_code = code branch_transition_type = FORBIDDENNESS_MAP[code]
transition_type = FORBIDDENNESS_MAP[code]
# Fallback: use first branch's forbiddenness if no ground state # Use ground state branch (level_index == 0) for overall forbiddenness
elif transition_type == "A" and len(state) > 15 and state[15] is not None: if state[0] == 0:
code = str(state[15]) forbidden_code = (
if code in FORBIDDENNESS_MAP: str(state[15])
forbidden_code = code if len(state) > 15 and state[15] is not None
transition_type = FORBIDDENNESS_MAP[code] else "0A"
)
transition_type = branch_transition_type
branch = BranchInfo( branch = BranchInfo(
level_index=int(state[0]), level_index=int(state[0]),
level_energy_keV=float(state[1]), level_energy_keV=float(state[1]),
intensity=float(state[8]), intensity=float(state[8]),
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,
) )
branches.append(branch) branches.append(branch)
+671 -24
View File
@@ -29,6 +29,40 @@ if TYPE_CHECKING:
from beta_spectrum.components.detector_response import DetectorResponse from beta_spectrum.components.detector_response import DetectorResponse
@dataclass
class BranchConfig:
"""
Configuration for a single decay branch.
Each branch corresponds to a decay to a specific daughter nuclear state,
with its own endpoint energy (Q_value - excitation_energy) and transition type.
Attributes
----------
endpoint_MeV : float
Effective endpoint energy for this branch (Q_value - excitation_energy).
transition_type : str
Forbiddenness classification for this branch (A, F1, F1U, F2, ...).
intensity : float
Branch intensity as a fraction of total decays (0.0–1.0).
Intensities across all branches should sum to 1.0 (100%).
"""
endpoint_MeV: float
transition_type: str = "A"
intensity: float = 1.0
def __post_init__(self) -> None:
if self.intensity < 0 or self.intensity > 1.0:
raise ValueError(
f"Branch intensity must be in [0, 1], got {self.intensity}"
)
if self.endpoint_MeV <= 0:
raise ValueError(
f"Branch endpoint_MeV must be positive, got {self.endpoint_MeV}"
)
@dataclass @dataclass
class SpectrumConfig: class SpectrumConfig:
""" """
@@ -37,6 +71,10 @@ class SpectrumConfig:
Supports declarative detector response specification: Supports declarative detector response specification:
set detector_model, detector_sigma_a_keV, and related parameters set detector_model, detector_sigma_a_keV, and related parameters
to enable automatic detector smearing via convolve_with_detector(). to enable automatic detector smearing via convolve_with_detector().
Supports multi-branch decay via the `branches` field. When branches are
provided, the total spectrum is the intensity-weighted sum of individual
branch spectra. Each branch has its own endpoint energy and transition type.
""" """
Z_parent: int Z_parent: int
@@ -69,34 +107,67 @@ class SpectrumConfig:
0.35, 0.35,
) # in m_e units (total energy) ) # in m_e units (total energy)
# Multi-branch support
branches: Optional[List[BranchConfig]] = None # None → single-branch mode
intensity_cutoff: float = (
0.0 # Ignore branches below this fraction (default: 0.0 = all)
)
class BetaSpectrum: class BetaSpectrum:
""" """
Precise beta spectrum calculator. Precise beta spectrum calculator.
Combines basic spectrum shape (fermi * phase_space) with mutplicative corrections. Supports both single-branch and multi-branch decay modes.
In single-branch mode (branches=None), the spectrum is the product of
all enabled correction factors.
In multi-branch mode (branches provided), each branch is calculated
independently with its own endpoint energy and transition type, then
summed with intensity weighting.
Parameters Parameters
---------- ----------
components : List[SpectrumComponent] components : List[SpectrumComponent], optional
List of enabled spectral components. List of enabled universal spectral components (Fermi, screening, etc.).
Only used in multi-branch mode or for universal components.
branch_spectra : List[BetaSpectrum], optional
List of per-branch spectrum calculators (each with its own PhaseSpace, etc.).
Only used in multi-branch mode.
branches : List[BranchConfig], optional
Branch configuration metadata (intensities, endpoint energies).
logger : logging.Logger, optional logger : logging.Logger, optional
Logger for debug/info output. Logger for debug/info output.
""" """
def __init__( def __init__(
self, self,
components: List[SpectrumComponent], components: Optional[List[SpectrumComponent]] = None,
branch_spectra: Optional[List[BetaSpectrum]] = None,
branches: Optional[List[BranchConfig]] = None,
logger: Optional[logging.Logger] = None, logger: Optional[logging.Logger] = None,
): ):
self.components = components self.components = components or []
self._component_names = [self._get_component_name(c) for c in components] self._component_names = [self._get_component_name(c) for c in self.components]
self.branch_spectra = branch_spectra or []
self.branches = branches or []
self._logger = logger self._logger = logger
self._is_multi = len(self.branch_spectra) > 0
def __call__(self, W: np.ndarray) -> np.ndarray: def __call__(self, W: np.ndarray) -> np.ndarray:
""" """
Calculate total spectrum as a product of enabled components Calculate spectrum.
In single-branch mode: product of all components.
In multi-branch mode: intensity-weighted sum of branch spectra.
""" """
if self._is_multi:
return self._multi_branch_call(W)
return self._single_branch_call(W)
def _single_branch_call(self, W: np.ndarray) -> np.ndarray:
"""Calculate spectrum as product of components (single branch)."""
result: np.ndarray = np.ones_like(W, dtype=float) result: np.ndarray = np.ones_like(W, dtype=float)
for comp in self.components: for comp in self.components:
if self._logger: if self._logger:
@@ -106,21 +177,74 @@ class BetaSpectrum:
result = result * comp(W) result = result * comp(W)
return result.astype(np.float64) return result.astype(np.float64)
def _multi_branch_call(self, W: np.ndarray) -> np.ndarray:
"""Calculate total spectrum as intensity-weighted sum of branches.
The total is: (universal_components_product) * sum(intensity_i * branch_spectrum_i)
"""
# Sum of intensity-weighted branch spectra
branch_sum = np.zeros_like(W, dtype=float)
for i, branch_spec in enumerate(self.branch_spectra):
if self._logger:
self._logger.debug(
"Evaluating branch %d/%d (endpoint=%.3f MeV, intensity=%.4f)",
i + 1,
len(self.branch_spectra),
self.branches[i].endpoint_MeV,
self.branches[i].intensity,
)
branch_spectrum = branch_spec(W)
# Mask out energies beyond this branch's endpoint
W0_branch = T_to_W(self.branches[i].endpoint_MeV)
mask = W <= W0_branch
branch_sum += self.branches[i].intensity * np.where(
mask, branch_spectrum, 0.0
)
# Multiply by universal components
result: np.ndarray = np.ones_like(W, dtype=float)
for comp in self.components:
result = result * comp(W)
return np.asarray((result * branch_sum).astype(np.float64), dtype=np.float64)
def _get_component_name(self, comp: SpectrumComponent) -> str: def _get_component_name(self, comp: SpectrumComponent) -> str:
""" """Get human-readable component name."""
Get human-readable component names
"""
class_name = comp.__class__.__name__ class_name = comp.__class__.__name__
return class_name.replace("Correction", "").replace("Function", "") return class_name.replace("Correction", "").replace("Function", "")
def calculate_components(self, W: np.ndarray) -> Dict[str, np.ndarray]: def calculate_components(self, W: np.ndarray) -> Dict[str, np.ndarray]:
""" """
Calculate individual components for debugging and analysis Calculate individual components.
In single-branch mode: returns per-component values.
In multi-branch mode: returns universal components + per-branch
branch-dependent components (PhaseSpace, Radiative).
""" """
components_dict = {} if self._is_multi:
return self._multi_branch_components(W)
result: Dict[str, np.ndarray] = {}
for name, comp in zip(self._component_names, self.components): for name, comp in zip(self._component_names, self.components):
components_dict[name] = comp(W) result[name] = comp(W)
return components_dict return result
def _multi_branch_components(self, W: np.ndarray) -> Dict[str, np.ndarray]:
"""Return universal + per-branch components for multi-branch mode."""
result: Dict[str, np.ndarray] = {}
# Universal components (same for all branches)
for name, comp in zip(self._component_names, self.components):
result[name] = comp(W)
# Per-branch components (branch-dependent)
for i, branch_spec in enumerate(self.branch_spectra):
branch_name = f"branch_{i}"
branch_components = branch_spec.calculate_components(W)
for comp_name, values in branch_components.items():
result[f"{branch_name}.{comp_name}"] = values
return result
@classmethod @classmethod
def from_config( def from_config(
@@ -129,6 +253,9 @@ class BetaSpectrum:
""" """
Create a BetaSpectrum from configuration. Create a BetaSpectrum from configuration.
In single-branch mode: creates a single spectrum calculator.
In multi-branch mode: creates per-branch calculators + universal components.
Parameters Parameters
---------- ----------
config : SpectrumConfig config : SpectrumConfig
@@ -136,6 +263,15 @@ class BetaSpectrum:
logger : logging.Logger, optional logger : logging.Logger, optional
Logger for progress output. Logger for progress output.
""" """
if config.branches is not None and len(config.branches) > 1:
return cls._from_config_multi(config, logger)
return cls._from_config_single(config, logger)
@classmethod
def _from_config_single(
cls, config: SpectrumConfig, logger: Optional[logging.Logger] = None
) -> BetaSpectrum:
"""Create single-branch spectrum calculator."""
components: List[SpectrumComponent] = [] components: List[SpectrumComponent] = []
W0 = float(T_to_W(config.endpoint_MeV)) W0 = float(T_to_W(config.endpoint_MeV))
@@ -185,17 +321,163 @@ class BetaSpectrum:
return cls(components, logger=logger) return cls(components, logger=logger)
@classmethod
def _from_config_multi(
cls, config: SpectrumConfig, logger: Optional[logging.Logger] = None
) -> BetaSpectrum:
"""Create multi-branch spectrum calculator.
Creates per-branch calculators (each with its own PhaseSpace, Radiative, etc.)
and universal components (Fermi, screening, finite_size, etc.) shared across
all branches.
Note: Radiative correction is branch-dependent (depends on W0), so it goes
into each branch's calculator, not the universal components.
"""
if logger:
logger.info("Multi-branch mode: %d branches", len(config.branches))
# Create per-branch calculators
branch_spectra: List[BetaSpectrum] = []
for i, branch in enumerate(config.branches):
if logger:
logger.info(
" Branch %d: endpoint=%.3f MeV, transition=%s, intensity=%.4f",
i + 1,
branch.endpoint_MeV,
branch.transition_type,
branch.intensity,
)
# Create a per-branch config with the branch's endpoint and transition type
branch_config = SpectrumConfig(
Z_parent=config.Z_parent,
Z_daughter=config.Z_daughter,
A_number=config.A_number,
endpoint_MeV=branch.endpoint_MeV,
transition_type=branch.transition_type,
e_step_MeV=config.e_step_MeV,
# Branch-dependent components
use_phase_space=config.use_phase_space,
use_radiative=config.use_radiative,
# Universal components disabled (handled separately)
use_fermi=False,
use_screening=False,
use_finite_size=False,
use_charge_dist=False,
use_exchange=False,
)
branch_spectrum = cls._from_config_single(branch_config, logger)
branch_spectra.append(branch_spectrum)
# Create universal components (shared across all branches)
# Note: Radiative is NOT included here because it depends on W0
universal_components: List[SpectrumComponent] = []
if config.use_fermi:
universal_components.append(
FermiFunction(Z=config.Z_daughter, A=config.A_number, logger=logger)
)
if config.use_finite_size:
universal_components.append(
FiniteSizeL0(Z=config.Z_daughter, A=config.A_number, logger=logger)
)
if config.use_charge_dist:
universal_components.append(
ChargeDistributionU(
Z=config.Z_daughter, A=config.A_number, logger=logger
)
)
if config.use_screening:
universal_components.append(
ScreeningCorrection(
FermiFunction(Z=config.Z_parent, A=config.A_number, logger=logger),
logger=logger,
)
)
if config.use_exchange:
universal_components.append(
ExchangeCorrection(Z=config.Z_parent, logger=logger)
)
return cls(
components=universal_components,
branch_spectra=branch_spectra,
branches=list(config.branches),
logger=logger,
)
def get_energy_grid(self, config: SpectrumConfig) -> Tuple[np.ndarray, np.ndarray]: def get_energy_grid(self, config: SpectrumConfig) -> Tuple[np.ndarray, np.ndarray]:
""" """
Generate array of energy values for spectrum calculation. Generate array of energy values for spectrum calculation.
In multi-branch mode, uses the maximum endpoint across all branches.
""" """
kinetic_MeV = np.arange( if self._is_multi and self.branches:
config.e_step_MeV, config.endpoint_MeV, config.e_step_MeV max_endpoint = max(b.endpoint_MeV for b in self.branches)
) else:
max_endpoint = config.endpoint_MeV
kinetic_MeV = np.arange(config.e_step_MeV, max_endpoint, config.e_step_MeV)
W = T_to_W(kinetic_MeV) W = T_to_W(kinetic_MeV)
return W, kinetic_MeV.astype(np.float64) return W, kinetic_MeV.astype(np.float64)
def get_branch_spectra(self, W: np.ndarray) -> List[np.ndarray]:
"""
Get individual branch spectra for multi-branch mode.
Each branch spectrum is masked to zero beyond its endpoint.
Parameters
----------
W : np.ndarray
Energy grid in m_e units.
Returns
-------
List[np.ndarray]
Per-branch spectrum arrays (unweighted). Empty in single-branch mode.
"""
if not self._is_multi:
return []
result = []
for i, branch_spec in enumerate(self.branch_spectra):
branch_spectrum = branch_spec(W)
# Use np.maximum to smoothly approach zero at endpoint,
# and clamp negative values beyond endpoint to zero
result.append(np.maximum(0.0, branch_spectrum))
return result
def get_branch_normalized_spectra(self, W: np.ndarray) -> List[np.ndarray]:
"""
Get intensity-weighted branch spectra for multi-branch mode.
Each branch spectrum is masked to zero beyond its endpoint.
Parameters
----------
W : np.ndarray
Energy grid in m_e units.
Returns
-------
List[np.ndarray]
Per-branch spectrum arrays weighted by intensity. Empty in single-branch mode.
"""
if not self._is_multi:
return []
result = []
for i, branch_spec in enumerate(self.branch_spectra):
branch_spectrum = branch_spec(W)
result.append(self.branches[i].intensity * np.maximum(0.0, branch_spectrum))
return result
@staticmethod @staticmethod
def create_detector_from_config( def create_detector_from_config(
config: SpectrumConfig, config: SpectrumConfig,
@@ -353,7 +635,9 @@ class BetaSpectrumAnalyzer:
self.config = config self.config = config
self.W, self.energies_MeV = spectrum.get_energy_grid(config) self.W, self.energies_MeV = spectrum.get_energy_grid(config)
self._components_cache: Optional[Dict[str, np.ndarray]] = None self._components_cache: Optional[Dict[str, np.ndarray]] = None
self._branch_spectra_cache: Optional[List[np.ndarray]] = None
self._logger = logger self._logger = logger
self._is_multi = spectrum._is_multi
@property @property
def components(self) -> Dict[str, np.ndarray]: def components(self) -> Dict[str, np.ndarray]:
@@ -362,6 +646,16 @@ class BetaSpectrumAnalyzer:
assert self._components_cache is not None assert self._components_cache is not None
return self._components_cache return self._components_cache
@property
def branch_spectra(self) -> List[np.ndarray]:
"""Intensity-weighted branch spectra."""
if self._branch_spectra_cache is None:
self._branch_spectra_cache = self.spectrum.get_branch_normalized_spectra(
self.W
)
assert self._branch_spectra_cache is not None
return self._branch_spectra_cache
def total_spectrum(self, normalize: bool = True) -> np.ndarray: def total_spectrum(self, normalize: bool = True) -> np.ndarray:
""" """
Calculate the total_spectrum Calculate the total_spectrum
@@ -425,23 +719,39 @@ class BetaSpectrumAnalyzer:
""" """
Create visualization of the spectrum and all correction factors. Create visualization of the spectrum and all correction factors.
In single-branch mode: standard 4-panel debug view or single spectrum plot.
In multi-branch mode: spectrum plot with individual branches shown,
and debug view with vertical layout for branch comparison.
Parameters Parameters
---------- ----------
save_path : str, optional save_path : str, optional
Path to save the figure. Path to save the figure.
show_components : bool show_components : bool
If True, show full debug view (4-panel with all corrections). If True, show full debug view.
If False, show only the spectrum plot with nuclear data header. If False, show only the spectrum plot with nuclear data header.
If True, use logarithmic y-scale. Set to False for
linear scale, which is better when branch intensities are similar.
""" """
total = self.total_spectrum(normalize=True) total = self.total_spectrum(normalize=True)
components = self.components components = self.components
commit = get_git_short_hash(6) commit = get_git_short_hash(6)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
if show_components: if self._is_multi:
self._plot_debug_view(total, components, save_path, commit, timestamp) if show_components:
self._plot_multi_branch_debug(
total, components, save_path, commit, timestamp
)
else:
self._plot_multi_branch_spectra(
total, components, save_path, commit, timestamp
)
else: else:
self._plot_spectrum_only(total, save_path, commit, timestamp) if show_components:
self._plot_debug_view(total, components, save_path, commit, timestamp)
else:
self._plot_spectrum_only(total, save_path, commit, timestamp)
def _add_id_textbox(self, ax: "Axes", commit: str, timestamp: str) -> None: def _add_id_textbox(self, ax: "Axes", commit: str, timestamp: str) -> None:
"""Add ID text box with commit hash and timestamp to plot.""" """Add ID text box with commit hash and timestamp to plot."""
@@ -780,6 +1090,302 @@ class BetaSpectrumAnalyzer:
] ]
return symbols[Z] if Z < len(symbols) else f"Z{Z}" return symbols[Z] if Z < len(symbols) else f"Z{Z}"
# ---------------------------------------------------------------------------
# Multi-branch plotting
# ---------------------------------------------------------------------------
def _get_branch_label(self, i: int) -> str:
"""Generate branch label for legend."""
if i >= len(self.spectrum.branches):
return f"Branch {i+1}"
branch = self.spectrum.branches[i]
return f"Branch {i+1}: {branch.transition_type}, E₀={branch.endpoint_MeV*1000:.1f} keV"
def _plot_multi_branch_spectra(
self,
total: np.ndarray,
components: Dict[str, np.ndarray],
save_path: Optional[str],
commit: str,
timestamp: str,
) -> None:
"""Plot total spectrum with individual branch decomposition.
All curves use raw (un-normalized) values so that the total is
the arithmetic sum of branch contributions, and branch amplitudes
are proportional to their intensities.
"""
fig, ax = plt.subplots(figsize=(12, 7))
branch_colors = [
"#1f77b4",
"#ff7f0e",
"#2ca02c",
"#d62728",
"#9467bd",
"#8c564b",
"#e377c2",
"#7f7f7f",
"#bcbd22",
"#17becf",
]
# Compute branch contributions (universal * intensity * branch_spectrum)
branch_contribs = []
for i, branch_spec in enumerate(self.spectrum.branch_spectra):
universal_product = np.ones_like(self.W)
for name in [
"Fermi",
"Screening",
"FiniteSizeL0",
"ChargeDistributionU",
"Exchange",
]:
if name in components:
universal_product *= components[name]
bs = self.spectrum.get_branch_spectra(self.W)
contrib = universal_product * self.spectrum.branches[i].intensity * bs[i]
branch_contribs.append(contrib)
# Total = arithmetic sum of all branches (raw, un-normalized)
total_raw = sum(branch_contribs)
ax.plot(
self.energies_MeV,
total_raw,
"k-",
lw=2.5,
label="Total spectrum",
zorder=10,
)
# Plot each branch contribution (raw, un-normalized)
for i, (contrib, branch) in enumerate(
zip(branch_contribs, self.spectrum.branches)
):
color = branch_colors[i % len(branch_colors)]
ax.plot(
self.energies_MeV,
contrib,
color=color,
lw=1.5,
alpha=0.7,
label=self._get_branch_label(i),
)
ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=12)
ax.set_ylabel("Normalized Counts", fontsize=12)
parent = self._element_symbol(self.config.Z_parent)
daughter = self._element_symbol(self.config.Z_daughter)
ax.set_title(
f"Beta-decay: {parent}{self.config.A_number} -> {daughter}{self.config.A_number} — {len(self.spectrum.branches)} branches",
fontsize=14,
)
ax.set_yscale("log")
ax.grid(True, alpha=0.3)
ax.legend(loc="best", fontsize=10, ncol=min(2, len(self.spectrum.branches) + 1))
self._add_nuclear_data_header(ax)
self._add_id_textbox(ax, commit, timestamp)
plt.tight_layout()
if save_path:
fig.savefig(save_path, dpi=150, bbox_inches="tight")
print(f"Figure saved to {save_path}")
def _plot_multi_branch_debug(
self,
total: np.ndarray,
components: Dict[str, np.ndarray],
save_path: Optional[str],
commit: str,
timestamp: str,
) -> None:
"""Debug view for multi-branch: vertical layout with 4 panels.
Panel 1: Total spectrum + intensity-scaled branch spectra (log scale)
Panel 2: Fermi function + all phase spaces (shape comparison, no intensity)
Panel 3: Universal W0-independent corrections (Fermi, Screening, etc.)
Panel 4: W0-dependent corrections (Radiative for each branch)
"""
n_branches = len(self.spectrum.branches)
fig, axes = plt.subplots(4, 1, figsize=(12, 16), sharex=True)
branch_colors = [
"#1f77b4",
"#ff7f0e",
"#2ca02c",
"#d62728",
"#9467bd",
"#8c564b",
"#e377c2",
"#7f7f7f",
"#bcbd22",
"#17becf",
]
parent = self._element_symbol(self.config.Z_parent)
daughter = self._element_symbol(self.config.Z_daughter)
# Panel 1: Total spectrum + per-branch spectra
# All curves use raw (un-normalized) values so total = sum of branches
branch_contribs = []
for i, branch_spec in enumerate(self.spectrum.branch_spectra):
universal_product = np.ones_like(self.W)
for name in [
"Fermi",
"Screening",
"FiniteSizeL0",
"ChargeDistributionU",
"Exchange",
]:
if name in components:
universal_product *= components[name]
bs = self.spectrum.get_branch_spectra(self.W)
contrib = universal_product * self.spectrum.branches[i].intensity * bs[i]
branch_contribs.append(contrib)
total_raw = sum(branch_contribs)
ax = axes[0]
ax.plot(
self.energies_MeV,
total_raw,
"k-",
lw=2,
label="Total spectrum",
zorder=10,
)
for i, (contrib, branch) in enumerate(
zip(branch_contribs, self.spectrum.branches)
):
color = branch_colors[i % len(branch_colors)]
ax.plot(
self.energies_MeV,
contrib,
color=color,
lw=1.5,
alpha=0.7,
label=f"Branch {i+1}: {branch.transition_type}, "
f"E₀={branch.endpoint_MeV*1000:.1f} keV",
)
ax.set_yscale("log")
ax.set_ylabel("Normalized Counts", fontsize=10)
ax.set_title(
f"Multi-branch: {parent}{self.config.A_number} -> {daughter}{self.config.A_number} ({n_branches} branches)",
fontsize=12,
fontweight="bold",
)
ax.grid(True, alpha=0.3)
ax.legend(fontsize=8, loc="best", ncol=min(2, n_branches + 1))
# Panel 2: Fermi function + all phase spaces (shape comparison)
# Phase space is masked beyond endpoint before normalizing to avoid
# negative values from the sqrt term
ax = axes[1]
if "Fermi" in components:
fermi_vals = components["Fermi"]
fermi_scaled = fermi_vals / np.max(fermi_vals)
ax.plot(
self.energies_MeV,
fermi_scaled,
"r-",
lw=1.5,
label="Fermi (normalized)",
alpha=0.8,
)
for i, (branch_spec, branch) in enumerate(
zip(self.spectrum.branch_spectra, self.spectrum.branches)
):
color = branch_colors[i % len(branch_colors)]
if f"branch_{i}.PhaseSpace" in components:
ps_vals = components[f"branch_{i}.PhaseSpace"]
# Mask beyond endpoint to avoid negative values
W0_branch = T_to_W(branch.endpoint_MeV)
mask = self.W <= W0_branch
ps_masked = np.where(mask, ps_vals, 0.0)
ps_max = np.max(ps_masked)
if ps_max > 0:
ps_scaled = ps_masked / ps_max
else:
ps_scaled = ps_masked
ax.plot(
self.energies_MeV,
ps_scaled,
color=color,
lw=1.5,
alpha=0.7,
label=f"PS Branch {i+1} (normalized)",
)
ax.set_ylabel("Normalized Factor", fontsize=10)
ax.set_title(
"Fermi Function & Phase Space Shapes", fontsize=11, fontweight="bold"
)
ax.grid(True, alpha=0.3)
ax.legend(fontsize=8, loc="best")
# Panel 3: Universal W0-independent corrections
ax = axes[2]
universal_components = [
"Screening",
"FiniteSizeL0",
"ChargeDistributionU",
"Exchange",
]
colors = ["green", "orange", "purple", "brown"]
for name, color in zip(universal_components, colors):
if name in components:
values = components[name]
values_scaled = values / np.max(values)
ax.plot(
self.energies_MeV,
values_scaled,
color=color,
lw=1.5,
label=f"{name} (normalized)",
alpha=0.8,
)
ax.set_ylabel("Normalized Factor", fontsize=10)
ax.set_title(
"Universal W0-Independent Corrections", fontsize=11, fontweight="bold"
)
ax.grid(True, alpha=0.3)
ax.legend(fontsize=8, loc="best")
# Panel 4: W0-dependent corrections (Radiative for each branch)
ax = axes[3]
for i, (branch_spec, branch) in enumerate(
zip(self.spectrum.branch_spectra, self.spectrum.branches)
):
color = branch_colors[i % len(branch_colors)]
if f"branch_{i}.Radiative" in components:
rad_vals = components[f"branch_{i}.Radiative"]
ax.plot(
self.energies_MeV,
rad_vals,
color=color,
lw=1.5,
alpha=0.7,
label=f"Branch {i+1} Radiative (E₀={branch.endpoint_MeV*1000:.1f} keV)",
)
ax.set_ylabel("Radiative Correction", fontsize=10)
ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=10)
ax.set_title(
"W0-Dependent Corrections (Radiative)", fontsize=11, fontweight="bold"
)
ax.grid(True, alpha=0.3)
ax.legend(fontsize=8, loc="best", ncol=min(2, n_branches + 1))
self._add_id_textbox(axes[3], commit, timestamp)
plt.tight_layout()
if save_path:
fig.savefig(save_path, dpi=150, bbox_inches="tight")
print(f"Figure saved to {save_path}")
def export_to_csv(self, filename: str, source_type: str = "unknown") -> None: def export_to_csv(self, filename: str, source_type: str = "unknown") -> None:
""" """
Export spectrum data to CSV with metadata header. Export spectrum data to CSV with metadata header.
@@ -934,6 +1540,17 @@ class BetaSpectrumAnalyzer:
f"# e_step: {self.config.e_step_MeV:.4f} MeV", f"# e_step: {self.config.e_step_MeV:.4f} MeV",
] ]
if self._is_multi and self.spectrum.branches:
header_lines.append(f"# branches: {len(self.spectrum.branches)}")
for i, branch in enumerate(self.spectrum.branches):
header_lines.append(
f"# branch_{i+1}: E₀={branch.endpoint_MeV*1000:.1f} keV, "
f"transition={branch.transition_type}, "
f"intensity={branch.intensity:.4f}"
)
else:
header_lines.append("# branches: 1 (single)")
if self.config.use_detector_response: if self.config.use_detector_response:
header_lines.append( header_lines.append(
f"# detector: {self.config.detector_model} " f"# detector: {self.config.detector_model} "
@@ -952,8 +1569,37 @@ class BetaSpectrumAnalyzer:
"energy_MeV": self.energies_MeV, "energy_MeV": self.energies_MeV,
"spectrum": total, "spectrum": total,
} }
for name, values in components.items():
data[name] = values if self._is_multi and self.spectrum.branches:
# Multi-branch mode: total spectrum + per-branch spectra + components
for i, (branch, branch_spec) in enumerate(
zip(self.spectrum.branches, self.spectrum.branch_spectra)
):
data[f"branch_{i+1}_spectrum"] = branch_spec(self.W)
data[f"branch_{i+1}_intensity"] = np.full_like(
self.energies_MeV, branch.intensity
)
data[f"branch_{i+1}_endpoint"] = np.full_like(
self.energies_MeV, branch.endpoint_MeV
)
data[f"branch_{i+1}_transition"] = np.full_like(
self.energies_MeV, branch.transition_type, dtype=object
)
# Per-branch components (PhaseSpace, Radiative, etc.)
for i, branch_spec in enumerate(self.spectrum.branch_spectra):
branch_comps = branch_spec.calculate_components(self.W)
for comp_name, values in branch_comps.items():
data[f"branch_{i+1}_{comp_name}"] = values
# Universal components only (skip per-branch keys)
for name, values in components.items():
if not name.startswith("branch_"):
data[f"universal_{name}"] = values
else:
# Single-branch mode: standard export
for name, values in components.items():
data[name] = values
df = pd.DataFrame(data) df = pd.DataFrame(data)
df.to_csv(filename, index=False, float_format="%.4e") df.to_csv(filename, index=False, float_format="%.4e")
@@ -965,7 +1611,8 @@ class BetaSpectrumAnalyzer:
f.write("\n".join(header_lines) + "\n" + content) f.write("\n".join(header_lines) + "\n" + content)
if self._logger: if self._logger:
self._logger.info("CSV exported to %s with metadata header", filename) mode = "multi-branch" if self._is_multi else "single-branch"
self._logger.info("CSV exported to %s (%s mode)", filename, mode)
def get_data(self) -> Dict[str, Any]: def get_data(self) -> Dict[str, Any]:
"""Get all numerical data for custom analysis.""" """Get all numerical data for custom analysis."""