feat(output): physical branch spectra, universal factor, CSV-driven plotting
This commit is contained in:
@@ -14,6 +14,8 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from beta_spectrum.components.phase_space import PhaseSpace
|
||||
from beta_spectrum.components.radiative import RadiativeCorrection
|
||||
from beta_spectrum.utils import T_to_W
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -47,7 +49,8 @@ class BetaSpectrumAnalyzer:
|
||||
self.config = config
|
||||
self.W, self.energies_MeV = spectrum.get_energy_grid(config)
|
||||
self._components_cache: Optional[Dict[str, np.ndarray]] = None
|
||||
self._branch_spectra_cache: Optional[List[np.ndarray]] = None
|
||||
self._universal_cache: Optional[np.ndarray] = None
|
||||
self._physical_branch_cache: Dict[int, np.ndarray] = {}
|
||||
self._logger = logger
|
||||
self._is_multi = spectrum._is_multi
|
||||
self._timestamp: Optional[str] = None
|
||||
@@ -82,15 +85,76 @@ class BetaSpectrumAnalyzer:
|
||||
assert self._components_cache is not None
|
||||
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
|
||||
def universal_factor(self) -> np.ndarray:
|
||||
"""Product of the components shared by all branches.
|
||||
|
||||
The universal factor always contains the Fermi function (the core
|
||||
0-order Coulomb correction) and every enabled higher-order
|
||||
correction that does not depend on the branch endpoint
|
||||
(screening, finite size, charge distribution, exchange).
|
||||
|
||||
In single-branch mode this equals the product of all components
|
||||
except the per-branch phase space and radiative corrections.
|
||||
"""
|
||||
if self._universal_cache is None:
|
||||
prod: Optional[np.ndarray] = None
|
||||
for comp in self.spectrum.components:
|
||||
if not self._is_multi and isinstance(
|
||||
comp, (PhaseSpace, RadiativeCorrection)
|
||||
):
|
||||
continue
|
||||
values = comp(self.W)
|
||||
prod = (
|
||||
np.array(values, dtype=np.float64)
|
||||
if prod is None
|
||||
else prod * values
|
||||
)
|
||||
if prod is None:
|
||||
prod = np.ones_like(self.W, dtype=np.float64)
|
||||
self._universal_cache = prod
|
||||
assert self._universal_cache is not None
|
||||
return self._universal_cache
|
||||
|
||||
def physical_branch_spectrum(self, i: int) -> np.ndarray:
|
||||
"""Physical spectrum of branch i: core × corrections, unnormalized.
|
||||
|
||||
The physical branch spectrum is
|
||||
core(phase_space × fermi) × all enabled higher-order corrections,
|
||||
masked to zero beyond the branch endpoint. This is the canonical
|
||||
branch accessor: every consumer (CSV export, plots) must use it so
|
||||
that the Fermi function is always present in branch-level spectra.
|
||||
"""
|
||||
if i in self._physical_branch_cache:
|
||||
return self._physical_branch_cache[i]
|
||||
if i < len(self.spectrum.branch_spectra):
|
||||
raw = self.spectrum.branch_spectra[i](self.W)
|
||||
W0 = T_to_W(self._unified_branches[i].endpoint_MeV)
|
||||
raw = np.where(self.W <= W0, raw, 0.0)
|
||||
physical = self.universal_factor() * raw
|
||||
else:
|
||||
# Single-branch mode: the spectrum itself is the full physical branch
|
||||
physical = np.asarray(self.spectrum(self.W), dtype=np.float64)
|
||||
self._physical_branch_cache[i] = physical
|
||||
return physical
|
||||
|
||||
def total_integral(self) -> float:
|
||||
"""Integral of the raw total spectrum over kinetic energy.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the integral is not positive (e.g. all branches are
|
||||
degenerate because the neutrino mass exceeds every endpoint).
|
||||
"""
|
||||
total_raw = self.spectrum(self.W)
|
||||
integral = float(np.trapezoid(total_raw, self.energies_MeV))
|
||||
if integral <= 0.0:
|
||||
raise ValueError(
|
||||
"Total spectrum integral is non-positive: every branch is "
|
||||
"degenerate (check neutrino_mass vs branch endpoints and "
|
||||
"intensity_cutoff)"
|
||||
)
|
||||
assert self._branch_spectra_cache is not None
|
||||
return self._branch_spectra_cache
|
||||
return integral
|
||||
|
||||
def total_spectrum(self, normalize: bool = True) -> np.ndarray:
|
||||
"""Calculate the total spectrum."""
|
||||
@@ -98,6 +162,12 @@ class BetaSpectrumAnalyzer:
|
||||
|
||||
if normalize:
|
||||
integral = np.trapezoid(total, self.energies_MeV)
|
||||
if integral <= 0.0:
|
||||
raise ValueError(
|
||||
"Total spectrum integral is non-positive: every branch "
|
||||
"is degenerate (check neutrino_mass vs branch endpoints "
|
||||
"and intensity_cutoff)"
|
||||
)
|
||||
total = total / integral
|
||||
|
||||
return total
|
||||
@@ -111,36 +181,12 @@ class BetaSpectrumAnalyzer:
|
||||
"""Number of branches (unified view)."""
|
||||
return len(self._unified_branches)
|
||||
|
||||
def _get_branch_spectrum(self, i: int) -> np.ndarray:
|
||||
"""Get the spectrum array for branch i."""
|
||||
if i < len(self.spectrum.branch_spectra):
|
||||
bs_obj = self.spectrum.branch_spectra[i]
|
||||
# branch_spectra stores BetaSpectrum objects — call them to get arrays
|
||||
if hasattr(bs_obj, "__call__"):
|
||||
val = bs_obj(self.W)
|
||||
# Clamp to zero beyond endpoint (phase space goes negative)
|
||||
W0 = T_to_W(self._unified_branches[i].endpoint_MeV)
|
||||
return np.where(self.W <= W0, val, 0.0)
|
||||
return np.asarray(bs_obj)
|
||||
# Single-branch: the spectrum itself is the only branch
|
||||
return self.spectrum(self.W)
|
||||
|
||||
def _get_component_key(self, i: int, name: str) -> str:
|
||||
"""Get the canonical key for a per-branch component."""
|
||||
if self._is_multi:
|
||||
return f"branch_{i}.{name}"
|
||||
return name
|
||||
|
||||
def _has_component(self, i: int, name: str) -> bool:
|
||||
"""Check if branch i has a given component."""
|
||||
key = self._get_component_key(i, name)
|
||||
return key in self.components
|
||||
|
||||
def _get_component(self, i: int, name: str) -> np.ndarray:
|
||||
"""Get a per-branch component array."""
|
||||
key = self._get_component_key(i, name)
|
||||
return self.components[key]
|
||||
|
||||
def _element_symbol(self, Z: int) -> str:
|
||||
"""Return chemical symbol for atomic number Z."""
|
||||
symbols = [
|
||||
|
||||
Reference in New Issue
Block a user