feat: v0.4.0 package restructuring — split into 4 modules
Separate monolithic beta_spectrum package into:
beta_spectrum — pure theory (components, spectrum, CLI, visualize)
exp_data — experimental data handling (spectrum, calibration,
fitters, corrections)
fitter — analysis framework (model, fit_engine, extractor,
result)
beta_spectrum.visualize — plotting (BetaSpectrumAnalyzer)
Phase 1 — exp_data module:
ExpSpectrum dataclass with metadata, normalization, serialization
EnergyCalibrator (linear/quadratic calibration)
GaussianFitter + PeakFitter for calibration sources
DeadTimeCorrection, PileUpCorrection, BackgroundSubtractor
43 new tests, ruff + mypy clean
Phase 2 — fitter module:
SpectrumModel wraps BetaSpectrum + DetectorResponse with convolution
SpectrumFitter with χ² minimization, covariance, profile likelihood,
confidence intervals, robust loss functions
CWExtractor + GVAExtractor for C(W) and g_V/g_A extraction
AnalysisResult + FitSummary for physics-specific results
CurveFitter general-purpose callable model fitter
34 new tests, ruff + mypy clean
Phase 3 — clean up beta_spectrum:
Remove cw_extractor.py and fitter.py (moved to fitter/)
Clean __init__.py to export only theory classes
Add backward-compat methods to CWExtractor
Fix BetaSpectrumAnalyzer._get_branch_spectrum returning BetaSpectrum
objects instead of numpy arrays (plot fix)
Phase 4 — package structure:
Auto-discovered by setuptools (4 packages)
All imports updated across 26 source files
Black formatting applied
322 tests pass (262 original + 60 new)
ruff + mypy clean on all source files
Bug fixes:
- Fix multi-branch spectrum plotting: universal component keys
(Fermi, Screening, etc.) don't have per-branch prefix; fixed
fallback lookup.
- Fix negative phase space beyond endpoint: BetaSpectrum returns
negative values for W > W0; clamp branch spectra to zero.
This commit is contained in:
+237
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Theoretical model for fitting — wraps BetaSpectrum + DetectorResponse.
|
||||
|
||||
Provides a callable interface that evaluates the theoretical spectrum,
|
||||
optionally convolved with detector response, on an arbitrary energy grid.
|
||||
|
||||
Usage:
|
||||
model = SpectrumModel(beta_spectrum, detector_response, channel_energies)
|
||||
y_model = model(channel_energies, normalization, background)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beta_spectrum.spectrum import BetaSpectrum
|
||||
from beta_spectrum.components.detector_response import DetectorResponse
|
||||
from exp_data.spectrum import ExpSpectrum
|
||||
|
||||
|
||||
class SpectrumModel:
|
||||
"""Theoretical beta spectrum model for fitting to experimental data.
|
||||
|
||||
Wraps a :class:`beta_spectrum.BetaSpectrum` and optionally a
|
||||
:class:`beta_spectrum.DetectorResponse` to produce a callable model
|
||||
that can be fitted against experimental spectra.
|
||||
|
||||
The model computes:
|
||||
y(E) = A · [S(E) ⊗ R(E)] + B
|
||||
|
||||
where S(E) is the theoretical spectrum, R(E) is the detector response,
|
||||
⊗ denotes convolution, A is a normalization factor, and B is a constant
|
||||
background level.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
spectrum : BetaSpectrum
|
||||
Theoretical spectrum calculator.
|
||||
detector_response : DetectorResponse, optional
|
||||
Detector response function. If None, no convolution is applied.
|
||||
channel_energies : np.ndarray, optional
|
||||
Energy values for detector channels (keV). Used to set up
|
||||
the response if detector_response is None.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
spectrum: "BetaSpectrum",
|
||||
detector_response: Optional["DetectorResponse"] = None,
|
||||
channel_energies: Optional[np.ndarray] = None,
|
||||
):
|
||||
self.spectrum = spectrum
|
||||
self.detector_response = detector_response
|
||||
self.channel_energies = channel_energies
|
||||
|
||||
# Cache for pre-computed unconvolved spectrum
|
||||
self._cached_W: Optional[np.ndarray] = None
|
||||
self._cached_spectral: Optional[np.ndarray] = None
|
||||
|
||||
def evaluate_unconvolved(
|
||||
self,
|
||||
energies_keV: Optional[np.ndarray] = None,
|
||||
) -> np.ndarray:
|
||||
"""Evaluate the theoretical spectrum (unconvolved).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
energies_keV : np.ndarray, optional
|
||||
Energy grid in keV. Uses the spectrum's internal grid if None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
np.ndarray
|
||||
Theoretical spectrum values.
|
||||
"""
|
||||
if energies_keV is not None:
|
||||
W = self._keV_to_W(energies_keV)
|
||||
return self.spectrum(W)
|
||||
return self.spectrum(
|
||||
self.spectrum._W_grid
|
||||
if hasattr(self.spectrum, "_W_grid") and self.spectrum._W_grid is not None
|
||||
else np.array([])
|
||||
)
|
||||
|
||||
def _keV_to_W(self, energies_keV: np.ndarray) -> np.ndarray:
|
||||
"""Convert kinetic energy in keV to total energy in m_e units."""
|
||||
return energies_keV / 510.998950 + 1.0
|
||||
|
||||
def convolve(self, energies_keV: np.ndarray) -> np.ndarray:
|
||||
"""Convolve theoretical spectrum with detector response.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
energies_keV : np.ndarray
|
||||
Energy grid in keV for the detector channels.
|
||||
|
||||
Returns
|
||||
-------
|
||||
np.ndarray
|
||||
Convolved spectrum on the given energy grid.
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If no detector response is available.
|
||||
"""
|
||||
if self.detector_response is None:
|
||||
raise RuntimeError(
|
||||
"No detector response configured. "
|
||||
"Provide a DetectorResponse to SpectrumModel."
|
||||
)
|
||||
|
||||
# The detector's channel_energies are in m_e units
|
||||
W = self.detector_response.channel_energies
|
||||
# Clip W to valid range (W >= 1.0) before spectrum evaluation
|
||||
W_safe = np.maximum(W, 1.0 + 1e-12)
|
||||
spectral = self.spectrum(W_safe)
|
||||
|
||||
# Clip negative/NaN values from spectrum (numerical artifacts)
|
||||
spectral = np.maximum(
|
||||
np.nan_to_num(spectral, nan=0.0, posinf=0.0, neginf=0.0), 0.0
|
||||
)
|
||||
|
||||
# Convolve: detector_response.convolve(W, spectrum, normalize)
|
||||
return self.detector_response.convolve(W, spectral, normalize=True)
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
energies_keV: np.ndarray,
|
||||
normalization: float = 1.0,
|
||||
background: float = 0.0,
|
||||
) -> np.ndarray:
|
||||
"""Evaluate the full theoretical model (with optional convolution).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
energies_keV : np.ndarray
|
||||
Energy grid in keV.
|
||||
normalization : float
|
||||
Multiplicative normalization factor.
|
||||
background : float
|
||||
Constant background level.
|
||||
|
||||
Returns
|
||||
-------
|
||||
np.ndarray
|
||||
Model prediction: A · (S ⊗ R) + B
|
||||
"""
|
||||
if self.detector_response is not None:
|
||||
spectral = self.convolve(energies_keV)
|
||||
else:
|
||||
spectral = self.spectrum(self._keV_to_W(energies_keV))
|
||||
|
||||
return normalization * spectral + background
|
||||
|
||||
def residual(
|
||||
self,
|
||||
params: np.ndarray,
|
||||
exp_energies: np.ndarray,
|
||||
exp_counts: np.ndarray,
|
||||
exp_errors: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""Compute weighted residuals for fitting.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
params : np.ndarray
|
||||
Fit parameters [normalization, background, ...].
|
||||
exp_energies : np.ndarray
|
||||
Experimental energy grid (keV).
|
||||
exp_counts : np.ndarray
|
||||
Experimental counts.
|
||||
exp_errors : np.ndarray
|
||||
Experimental uncertainties.
|
||||
|
||||
Returns
|
||||
-------
|
||||
np.ndarray
|
||||
Weighted residuals: (exp - model) / σ
|
||||
"""
|
||||
model_values = self.evaluate(exp_energies, params[0], params[1])
|
||||
return (exp_counts - model_values) / exp_errors
|
||||
|
||||
@staticmethod
|
||||
def default_params(
|
||||
exp_spectrum: "ExpSpectrum",
|
||||
) -> tuple[float, float]:
|
||||
"""Estimate default fit parameters from experimental data.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
exp_spectrum : ExpSpectrum
|
||||
Experimental spectrum.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(normalization, background)
|
||||
Initial parameter estimates.
|
||||
"""
|
||||
# Background from low-energy sideband
|
||||
mask_low = exp_spectrum.energies < np.percentile(exp_spectrum.energies, 5)
|
||||
background = (
|
||||
float(np.mean(exp_spectrum.counts[mask_low])) if np.any(mask_low) else 0.0
|
||||
)
|
||||
|
||||
# Normalization from total counts / integral of unconvolved model
|
||||
total_counts = float(np.sum(exp_spectrum.counts))
|
||||
unconvolved = exp_spectrum.counts - background
|
||||
norm = total_counts / max(
|
||||
float(np.trapezoid(unconvolved, exp_spectrum.energies)), 1e-10
|
||||
)
|
||||
|
||||
return (norm, background)
|
||||
|
||||
@staticmethod
|
||||
def default_bounds(
|
||||
n_channels: int,
|
||||
) -> tuple[list[float], list[float]]:
|
||||
"""Estimate default parameter bounds.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
n_channels : int
|
||||
Number of energy channels.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(lower_bounds, upper_bounds)
|
||||
Parameter bounds for [normalization, background].
|
||||
"""
|
||||
return (
|
||||
[0.01, 0.0],
|
||||
[100.0, 1e6],
|
||||
)
|
||||
Reference in New Issue
Block a user