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:
2026-05-17 18:58:04 +03:00
parent 745bbae4cd
commit 38b44b56e6
29 changed files with 3538 additions and 2010 deletions
+25
View File
@@ -0,0 +1,25 @@
"""
Experimental data handling for beta spectrometry.
Provides tools for loading, calibrating, and pre-processing
experimental spectra from detector readouts.
"""
from .spectrum import ExpSpectrum
from .calibration import EnergyCalibrator
from .fitters import GaussianFitter, PeakFitter
from .corrections import (
DeadTimeCorrection,
PileUpCorrection,
BackgroundSubtractor,
)
__all__ = [
"ExpSpectrum",
"EnergyCalibrator",
"GaussianFitter",
"PeakFitter",
"DeadTimeCorrection",
"PileUpCorrection",
"BackgroundSubtractor",
]
+291
View File
@@ -0,0 +1,291 @@
"""
Energy calibration for beta spectrometers.
Provides methods to calibrate detector channel numbers to physical
energy values using known spectral lines (X-rays, gamma-rays).
Supports:
- Linear calibration: E = a + b · channel
- Quadratic calibration: E = a + b · channel + c · channel²
- Multi-point calibration with peak fitting
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional
import numpy as np
@dataclass
class CalibrationResult:
"""Result of an energy calibration fit."""
coefficients: np.ndarray
"""Calibration coefficients [a, b, c, ...] depending on polynomial order."""
order: int
"""Polynomial order (1 = linear, 2 = quadratic)."""
chi2_per_dof: float
"""Reduced χ² of the calibration fit."""
n_points: int
"""Number of calibration points used."""
covariance: np.ndarray
"""Parameter covariance matrix."""
def __call__(self, channel: float | np.ndarray) -> np.ndarray:
"""Convert channel number(s) to energy (keV)."""
return np.polyval(self.coefficients, channel)
def invert(self) -> np.poly1d:
"""Return polynomial that converts energy → channel."""
if self.order == 1:
# coefficients from polyfit: [slope, intercept]
b, a = self.coefficients
# E = a + b*ch => ch = (E - a) / b
# np.poly1d([1/b, -a/b]) => (1/b)*E + (-a/b) = (E-a)/b ✓
return np.poly1d([1.0 / b, -a / b])
else:
# For quadratic, compute inverse numerically via interpolation
ch_vals = np.linspace(-10, 200, 1000)
e_vals = np.polyval(self.coefficients[::-1], ch_vals)
from scipy.interpolate import interp1d
f = interp1d(e_vals, ch_vals, kind="linear")
class _InversePoly:
def __init__(self, fn: Any) -> None:
self._fn = fn
def __call__(self, x: float | np.ndarray) -> np.ndarray:
return self._fn(x)
return _InversePoly(f)
def resolution_at(self, energy_keV: float, sigma_channel: float = 1.0) -> float:
"""Estimate energy resolution (σ) at a given energy.
Parameters
----------
energy_keV : float
Energy in keV.
sigma_channel : float
Channel width in channel units (typically 1.0 for integer channels).
Returns
-------
float
Energy resolution σ in keV.
"""
# dE/dch = derivative of calibration polynomial at energy_keV
# coefficients are stored as [a_n, ..., a_1, a_0]
deriv_coeffs = []
coeffs = self.coefficients
for i in range(len(coeffs) - 1):
deriv_coeffs.append(coeffs[i] * (len(coeffs) - i - 1))
if not deriv_coeffs:
return abs(sigma_channel)
dE_dch = np.polyval(deriv_coeffs, energy_keV)
return abs(dE_dch) * sigma_channel
class EnergyCalibrator:
"""Energy calibration for detector spectra.
Parameters
----------
channel_energies : np.ndarray
Energy values (keV) per channel, shape (N_channels,).
If None, channels are assumed to be integer indices.
"""
def __init__(self, channel_energies: Optional[np.ndarray] = None):
self._channel_energies = channel_energies
self._calibration: Optional[CalibrationResult] = None
self._calibration_points: Optional[list[tuple[float, float]]] = None
@property
def is_calibrated(self) -> bool:
"""Whether a calibration has been applied."""
return self._calibration is not None
def add_calibration_point(
self,
channel: int | float,
energy_keV: float,
) -> None:
"""Add a calibration point (known line energy → channel number).
Parameters
----------
channel : int or float
Detector channel number.
energy_keV : float
Known energy of the calibration line (keV).
"""
if self._calibration_points is None:
self._calibration_points = []
self._calibration_points.append((float(channel), float(energy_keV)))
def set_calibration_points(self, points: list[tuple[float, float]]) -> None:
"""Set calibration points directly.
Parameters
----------
points : list of (channel, energy_keV)
Known calibration line positions.
"""
self._calibration_points = [(float(c), float(e)) for c, e in points]
def fit_calibration(
self,
order: int = 1,
channel_energies: Optional[np.ndarray] = None,
) -> CalibrationResult:
"""Fit calibration polynomial using known points.
Parameters
----------
order : int
Polynomial order (1 = linear, 2 = quadratic).
channel_energies : np.ndarray, optional
Full channel-to-energy mapping. If provided, fits to
all points; otherwise fits only to added calibration points.
Returns
-------
CalibrationResult
"""
if channel_energies is not None:
# Fit to full mapping
channels = np.arange(len(channel_energies), dtype=np.float64)
x = channels
y = np.asarray(channel_energies, dtype=np.float64)
elif self._calibration_points is not None:
x = np.array([p[0] for p in self._calibration_points])
y = np.array([p[1] for p in self._calibration_points])
else:
raise ValueError(
"No calibration data available. Use add_calibration_point() "
"or pass channel_energies."
)
# Fit polynomial: E(ch) = a₀ + a₁·ch + a₂·ch² + ...
try:
coeffs, cov = np.polyfit(x, y, order, cov=True)
except ValueError:
# Too few points for covariance estimation
coeffs = np.polyfit(x, y, order)
cov = np.eye(order + 1) * 1e-10
# Compute χ²
y_fit = np.polyval(coeffs[::-1], x)
# Assume uniform uncertainty of 0.1 keV for calibration points
uncertainties = np.full_like(x, 0.1)
chi2 = float(np.sum(((y - y_fit) / uncertainties) ** 2))
n_dof = max(len(x) - order - 1, 1)
return CalibrationResult(
coefficients=coeffs,
order=order,
chi2_per_dof=chi2 / n_dof,
n_points=len(x),
covariance=cov,
)
def apply_calibration(
self,
order: int = 1,
channel_energies: Optional[np.ndarray] = None,
) -> CalibrationResult:
"""Fit and store calibration for future use.
See :meth:`fit_calibration` for parameters.
"""
self._calibration = self.fit_calibration(order, channel_energies)
return self._calibration
def channel_to_energy(self, channel: float | np.ndarray) -> np.ndarray:
"""Convert channel number to energy using current calibration.
Parameters
----------
channel : float or np.ndarray
Channel number(s).
Returns
-------
np.ndarray
Energy in keV.
Raises
------
RuntimeError
If no calibration has been applied.
"""
if self._calibration is None:
raise RuntimeError(
"No calibration applied. Call apply_calibration() first."
)
return self._calibration(channel)
def energy_to_channel(self, energy_keV: float | np.ndarray) -> np.ndarray:
"""Convert energy to channel using current calibration.
Parameters
----------
energy_keV : float or np.ndarray
Energy in keV.
Returns
-------
np.ndarray
Channel number(s).
Raises
------
RuntimeError
If no calibration has been applied.
"""
if self._calibration is None:
raise RuntimeError(
"No calibration applied. Call apply_calibration() first."
)
inv = self._calibration.invert()
return inv(energy_keV)
def get_linear_calibration(
self,
low_energy_keV: float,
high_energy_keV: float,
low_channel: int,
high_channel: int,
) -> CalibrationResult:
"""Create a linear calibration from two known points.
Parameters
----------
low_energy_keV : float
Energy of first calibration line (keV).
high_energy_keV : float
Energy of second calibration line (keV).
low_channel : int
Channel number of first line.
high_channel : int
Channel number of second line.
Returns
-------
CalibrationResult
"""
self.set_calibration_points(
[
(float(low_channel), low_energy_keV),
(float(high_channel), high_energy_keV),
]
)
return self.apply_calibration(order=1)
+268
View File
@@ -0,0 +1,268 @@
"""
Instrumental corrections for experimental spectra.
Provides dead-time correction, pile-up correction, and background
subtraction for beta spectrometry data.
"""
from __future__ import annotations
from typing import Optional
import numpy as np
class DeadTimeCorrection:
"""Dead-time correction for paralyzable or non-paralyzable detectors.
Parameters
----------
dead_time : float
Dead time per event in seconds.
model : str
'non_paralyzable' (default) or 'paralyzable'.
"""
def __init__(self, dead_time: float, model: str = "non_paralyzable"):
self.dead_time = dead_time
self.model = model
assert model in (
"non_paralyzable",
"paralyzable",
), f"Unknown dead-time model: {model}"
assert dead_time >= 0, "Dead time must be non-negative"
def live_time_fraction(self, observed_rate: float) -> float:
"""Calculate live time fraction given observed count rate.
Parameters
----------
observed_rate : float
Observed count rate in counts/second.
Returns
-------
float
Fraction of time the detector is live (0 to 1).
"""
if self.model == "non_paralyzable":
return max(0.0, 1.0 - observed_rate * self.dead_time)
else: # paralyzable
return np.exp(-observed_rate * self.dead_time)
def correct_rate(self, observed_rate: float) -> float:
"""Correct observed rate for dead time.
Parameters
----------
observed_rate : float
Observed count rate (counts/s).
Returns
-------
float
True count rate (counts/s).
"""
live_frac = self.live_time_fraction(observed_rate)
if live_frac <= 0:
return float("inf")
return observed_rate / live_frac
def correct_counts(self, counts: np.ndarray, live_time: float) -> np.ndarray:
"""Correct counts for dead time.
Parameters
----------
counts : np.ndarray
Measured counts per bin.
live_time : float
Total live time in seconds.
Returns
-------
np.ndarray
Dead-time corrected counts.
"""
total_observed = np.sum(counts)
total_true = self.correct_rate(total_observed / live_time) * live_time
if total_observed <= 0:
return counts.copy()
return counts * (total_true / total_observed)
class PileUpCorrection:
"""Simple pile-up correction for beta spectrometry.
Estimates the fraction of pile-up events based on observed count rate
and dead time, and redistributes pile-up events back into the spectrum.
This is a first-order approximation. For precise pile-up correction,
digital pulse processing or anti-pile-up electronics are recommended.
"""
def __init__(self, dead_time: float):
self.dead_time = dead_time
def estimate_pile_up_fraction(self, count_rate: float) -> float:
"""Estimate pile-up fraction from count rate.
For a non-paralyzable detector, pile-up fraction ≈ (count_rate × dead_time)².
Parameters
----------
count_rate : float
Observed count rate in counts/second.
Returns
-------
float
Estimated fraction of events affected by pile-up.
"""
return (count_rate * self.dead_time) ** 2
def correct_spectrum(
self,
counts: np.ndarray,
live_time: float,
background: Optional[np.ndarray] = None,
) -> np.ndarray:
"""Apply first-order pile-up correction.
Parameters
----------
counts : np.ndarray
Measured counts per bin.
live_time : float
Live time in seconds.
background : np.ndarray, optional
Background counts per bin.
Returns
-------
np.ndarray
Pile-up corrected counts.
"""
if background is not None:
net_counts = counts - background
else:
net_counts = counts.copy()
observed_rate = np.sum(net_counts) / live_time
pileup_frac = self.estimate_pile_up_fraction(observed_rate)
# Redistribute pile-up events proportionally to the spectrum shape
if pileup_frac > 0 and np.sum(net_counts) > 0:
correction = net_counts * pileup_frac / (1.0 - pileup_frac)
return net_counts + correction
return net_counts
class BackgroundSubtractor:
"""Background subtraction for beta spectra.
Supports:
- Constant background (from sidebands)
- Polynomial background
- Region-of-interest based background estimation
"""
@staticmethod
def constant_background(
counts: np.ndarray,
energies: np.ndarray,
low_roi: tuple[float, float],
high_roi: tuple[float, float],
) -> np.ndarray:
"""Subtract constant background estimated from sidebands.
Parameters
----------
counts : np.ndarray
Measured counts per bin.
energies : np.ndarray
Energy values (keV) per bin.
low_roi : (E_min, E_max)
Energy range for low-side background estimation.
high_roi : (E_min, E_max)
Energy range for high-side background estimation.
Returns
-------
np.ndarray
Counts with constant background subtracted.
"""
low_mask = (energies >= low_roi[0]) & (energies <= low_roi[1])
high_mask = (energies >= high_roi[0]) & (energies <= high_roi[1])
low_bg = np.mean(counts[low_mask]) if np.any(low_mask) else 0.0
high_bg = np.mean(counts[high_mask]) if np.any(high_mask) else 0.0
constant_bg = (low_bg + high_bg) / 2.0
return counts - constant_bg
@staticmethod
def polynomial_background(
counts: np.ndarray,
energies: np.ndarray,
order: int = 2,
low_roi: tuple[float, float] = (0.0, 0.0),
high_roi: tuple[float, float] = (0.0, 0.0),
) -> np.ndarray:
"""Subtract polynomial background fitted from sidebands.
Parameters
----------
counts : np.ndarray
Measured counts per bin.
energies : np.ndarray
Energy values (keV) per bin.
order : int
Polynomial order for background fit.
low_roi : (E_min, E_max)
Energy range for low-side background fitting.
high_roi : (E_min, E_max)
Energy range for high-side background fitting.
Returns
-------
np.ndarray
Counts with polynomial background subtracted.
"""
low_mask = (energies >= low_roi[0]) & (energies <= low_roi[1])
high_mask = (energies >= high_roi[0]) & (energies <= high_roi[1])
mask = low_mask | high_mask
if not np.any(mask):
return counts
x_fit = energies[mask]
y_fit = counts[mask]
coeffs = np.polyfit(x_fit, y_fit, order)
bg = np.polyval(coeffs[::-1], energies)
return counts - bg
@staticmethod
def roi_average(
counts: np.ndarray,
roi_mask: np.ndarray,
) -> float:
"""Calculate average background from a region of interest.
Parameters
----------
counts : np.ndarray
Measured counts per bin.
roi_mask : np.ndarray
Boolean mask selecting background region.
Returns
-------
float
Average background count per bin.
"""
if not np.any(roi_mask):
return 0.0
return float(np.mean(counts[roi_mask]))
+307
View File
@@ -0,0 +1,307 @@
"""
Simple peak fitters for calibration source analysis.
Provides Gaussian and Gaussian+background fitting for identifying
known X-ray and gamma-ray lines used in energy calibration.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Tuple
import numpy as np
from scipy.optimize import least_squares
@dataclass
class PeakFitResult:
"""Result of a peak fit."""
center_keV: float
"""Peak center energy in keV."""
sigma_keV: float
"""Peak width (σ) in keV."""
amplitude: float
"""Peak amplitude (counts at peak)."""
background: float
"""Constant background level."""
chi2: float
"""Reduced χ²."""
n_dof: int
"""Number of degrees of freedom."""
covariance: np.ndarray
"""Parameter covariance matrix.
Parameter order: [amplitude, center, sigma, background]
"""
success: bool
"""Whether the fit converged."""
def resolution(self) -> float:
"""FWHM in keV."""
return 2.35482 * self.sigma_keV
def resolution_fraction(self) -> float:
"""FWHM / center as a fraction."""
if self.center_keV > 0:
return self.resolution() / self.center_keV
return 0.0
class GaussianFitter:
"""Fit a Gaussian peak on top of constant background.
Model: f(x) = A · exp(-(x - μ)² / (2σ²)) + B
Parameters
----------
energies : np.ndarray
Energy values (keV), shape (N,).
counts : np.ndarray
Measured counts, shape (N,).
uncertainties : np.ndarray, optional
Measurement uncertainties. If None, assumed to be 1.0.
"""
def __init__(
self,
energies: np.ndarray,
counts: np.ndarray,
uncertainties: Optional[np.ndarray] = None,
):
self.energies = np.asarray(energies, dtype=np.float64)
self.counts = np.asarray(counts, dtype=np.float64)
if uncertainties is not None:
self.uncertainties = np.asarray(uncertainties, dtype=np.float64)
else:
self.uncertainties = np.ones_like(self.counts)
# Filter out zero-uncertainty points
valid = self.uncertainties > 0
self.energies = self.energies[valid]
self.counts = self.counts[valid]
self.uncertainties = self.uncertainties[valid]
def _gaussian_background(self, params: np.ndarray, x: np.ndarray) -> np.ndarray:
"""Gaussian + constant background model."""
A, mu, sigma, B = params
return A * np.exp(-0.5 * ((x - mu) / sigma) ** 2) + B
def _residuals(self, params: np.ndarray) -> np.ndarray:
"""Weighted residuals."""
model = self._gaussian_background(params, self.energies)
return (self.counts - model) / self.uncertainties
def _chi2(self, params: np.ndarray) -> float:
"""Compute χ²."""
residuals = self._residuals(params)
return float(np.sum(residuals**2))
def _compute_covariance(self, params: np.ndarray, jac: np.ndarray) -> np.ndarray:
"""Estimate covariance from Jacobian."""
jtj = jac.T @ jac
if np.linalg.cond(jtj) < 1e12:
cov = np.linalg.inv(jtj)
chi2 = self._chi2(params)
ndof = max(len(self.energies) - 4, 1)
cov = cov * chi2 / ndof
return cov
return np.diag([1e10, 1e6, 1e4, 1e6])
def fit(
self,
guess: Optional[Tuple[float, float, float, float]] = None,
bounds: Optional[Tuple[tuple, tuple]] = None,
) -> PeakFitResult:
"""Fit a Gaussian peak to the data.
Parameters
----------
guess : (amplitude, center, sigma, background), optional
Initial parameter guess. Auto-estimated if None.
bounds : ((A_min, μ_min, σ_min, B_min), (A_max, μ_max, σ_max, B_max)), optional
Parameter bounds.
Returns
-------
PeakFitResult
"""
if guess is None:
guess = self._estimate_guess()
if bounds is None:
bounds = (
(0.0, min(self.energies), 0.1, 0.0),
(np.inf, max(self.energies), 50.0, np.inf),
)
result = least_squares(
self._residuals,
x0=guess,
bounds=bounds,
method="trf",
max_nfev=1000,
)
A, mu, sigma, B = result.x
n_dof = max(len(self.energies) - 4, 1)
chi2 = self._chi2(result.x)
# Compute covariance from Jacobian
_, jac = result.fun, result.jac
cov = self._compute_covariance(result.x, jac) if jac is not None else np.eye(4)
return PeakFitResult(
center_keV=mu,
sigma_keV=sigma,
amplitude=A,
background=B,
chi2=chi2 / n_dof if n_dof > 0 else chi2,
n_dof=n_dof,
covariance=cov,
success=result.success,
)
def _estimate_guess(self) -> Tuple[float, float, float, float]:
"""Auto-estimate initial parameters from data."""
# Find the peak
peak_idx = np.argmax(self.counts)
center = self.energies[peak_idx]
amplitude = self.counts[peak_idx]
# Estimate sigma from FWHM
half_max = amplitude / 2.0
left_idx = np.argmax(self.counts[:peak_idx] < half_max)
right_idx = np.argmin(self.counts[peak_idx:] < half_max) + peak_idx
if left_idx < peak_idx and right_idx > peak_idx:
fwhm = self.energies[right_idx] - self.energies[left_idx]
sigma = fwhm / 2.35482
else:
sigma = 1.0 # Default width
# Background estimate from average of edges
n_edge = max(10, len(self.counts) // 20)
background = np.mean(
np.concatenate([self.counts[:n_edge], self.counts[-n_edge:]])
)
return (amplitude - background, center, max(sigma, 0.1), background)
class PeakFitter:
"""Find and fit multiple peaks in a spectrum.
Parameters
----------
energies : np.ndarray
Energy values (keV).
counts : np.ndarray
Measured counts.
uncertainties : np.ndarray, optional
Measurement uncertainties.
"""
def __init__(
self,
energies: np.ndarray,
counts: np.ndarray,
uncertainties: Optional[np.ndarray] = None,
):
self.energies = np.asarray(energies, dtype=np.float64)
self.counts = np.asarray(counts, dtype=np.float64)
if uncertainties is not None:
self.uncertainties = np.asarray(uncertainties, dtype=np.float64)
else:
self.uncertainties = np.sqrt(np.maximum(self.counts, 1.0))
def find_peaks(
self,
min_height: float = 10.0,
min_distance_keV: float = 2.0,
smoothing_window: int = 5,
) -> np.ndarray:
"""Find candidate peak positions.
Parameters
----------
min_height : float
Minimum peak height above background (counts).
min_distance_keV : float
Minimum separation between peaks in keV.
smoothing_window : int
Smoothing window for peak detection (odd number).
Returns
-------
np.ndarray
Energy values of detected peak centers.
"""
from scipy.signal import find_peaks
# Estimate background by smoothing
window = smoothing_window if smoothing_window % 2 == 1 else smoothing_window + 1
if len(self.counts) > window:
from scipy.ndimage import uniform_filter1d
background_est = uniform_filter1d(self.counts, size=window)
else:
background_est = np.full_like(self.counts, np.mean(self.counts))
heights = self.counts - background_est
indices, _ = find_peaks(
heights,
height=min_height,
distance=int(min_distance_keV / max(1.0, np.diff(self.energies).min())),
)
return self.energies[indices]
def fit_peaks(
self,
peak_energies: Optional[np.ndarray] = None,
known_energies: Optional[np.ndarray] = None,
) -> list[PeakFitResult]:
"""Fit peaks at given or auto-detected positions.
Parameters
----------
peak_energies : np.ndarray, optional
Explicit peak center energies to fit.
known_energies : np.ndarray, optional
Known calibration line energies — auto-detect peaks near these.
Returns
-------
list[PeakFitResult]
"""
if known_energies is not None:
# Find peaks near known energies
peak_energies = np.array([self._nearest_channel(e) for e in known_energies])
if peak_energies is None:
peak_energies = self.find_peaks()
results = []
for center in peak_energies:
fitter = GaussianFitter(self.energies, self.counts, self.uncertainties)
result = fitter.fit(guess=(10.0, center, 1.0, 0.0))
if result.success:
results.append(result)
return results
def _nearest_channel(self, target_keV: float) -> float:
"""Find the energy channel closest to target_keV."""
idx = np.argmin(np.abs(self.energies - target_keV))
return self.energies[idx]
+161
View File
@@ -0,0 +1,161 @@
"""
Experimental spectrum container.
Stores measured detector spectra with metadata, uncertainties,
and calibration information.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
@dataclass
class ExpSpectrum:
"""Container for an experimental beta spectrum measurement.
Parameters
----------
energies : np.ndarray
Energy calibration values for each channel (keV), shape (N_channels,).
counts : np.ndarray
Raw counts per channel, shape (N_channels,).
errors : np.ndarray, optional
Statistical uncertainties per channel. Defaults to sqrt(counts).
metadata : dict, optional
Arbitrary metadata (source, run ID, date, etc.).
dead_time : float, optional
Dead time fraction (0.0 to 1.0).
live_time : float, optional
Live acquisition time in seconds.
source : str, optional
Radioactive source identifier.
run_id : str, optional
Unique run identifier.
date : str, optional
Measurement date (ISO format).
"""
energies: np.ndarray
counts: np.ndarray
errors: Optional[np.ndarray] = None
metadata: dict = field(default_factory=dict)
dead_time: float = 0.0
live_time: Optional[float] = None
source: Optional[str] = None
run_id: Optional[str] = None
date: Optional[str] = None
def __post_init__(self) -> None:
"""Validate and compute default errors."""
self.energies = np.asarray(self.energies, dtype=np.float64)
self.counts = np.asarray(self.counts, dtype=np.float64)
assert self.energies.shape == self.counts.shape, (
f"Energies ({self.energies.shape}) and counts ({self.counts.shape}) "
"must have the same shape"
)
assert np.all(self.energies >= 0), "Energies must be non-negative"
assert np.all(self.counts >= 0), "Counts must be non-negative"
if self.errors is None:
# Poisson statistics: σ = √N (handle zero counts gracefully)
self.errors = np.sqrt(np.maximum(self.counts, 1.0))
else:
self.errors = np.asarray(self.errors, dtype=np.float64)
assert self.errors.shape == self.counts.shape
assert np.all(self.errors > 0), "Errors must be positive"
@property
def n_channels(self) -> int:
"""Number of energy channels."""
return len(self.energies)
@property
def energy_range(self) -> tuple[float, float]:
"""(min_energy_keV, max_energy_keV)."""
return (float(self.energies[0]), float(self.energies[-1]))
@property
def total_counts(self) -> float:
"""Total counts in the spectrum."""
return float(np.sum(self.counts))
@property
def live_time_corrected_rate(self) -> Optional[float]:
"""Count rate corrected for dead time, in counts per second."""
if self.live_time is None or self.live_time <= 0:
return None
live_seconds = self.live_time * (1.0 - self.dead_time)
return self.total_counts / live_seconds
def normalize(self) -> np.ndarray:
"""Return spectrum normalized to unit area."""
integral = np.trapezoid(self.counts, self.energies)
if integral > 0:
return self.counts / integral
return self.counts.copy()
def apply_dead_time_correction(self) -> ExpSpectrum:
"""Return a new spectrum corrected for dead time.
Correction: N_true = N_measured / (1 - τ), where τ is dead time fraction.
"""
if self.dead_time <= 0 or self.dead_time >= 1.0:
return ExpSpectrum(
energies=self.energies.copy(),
counts=self.counts.copy(),
errors=self.errors.copy(),
metadata=self.metadata.copy(),
dead_time=self.dead_time,
live_time=self.live_time,
source=self.source,
run_id=self.run_id,
date=self.date,
)
corrected_counts = self.counts / (1.0 - self.dead_time)
corrected_errors = self.errors / (1.0 - self.dead_time)
return ExpSpectrum(
energies=self.energies.copy(),
counts=corrected_counts,
errors=corrected_errors,
metadata=self.metadata.copy(),
dead_time=0.0, # Corrected
live_time=self.live_time,
source=self.source,
run_id=self.run_id,
date=self.date,
)
def to_dict(self) -> dict:
"""Serialize to dictionary."""
return {
"energies": self.energies.tolist(),
"counts": self.counts.tolist(),
"errors": self.errors.tolist(),
"metadata": self.metadata,
"dead_time": self.dead_time,
"live_time": self.live_time,
"source": self.source,
"run_id": self.run_id,
"date": self.date,
}
@classmethod
def from_dict(cls, data: dict) -> ExpSpectrum:
"""Deserialize from dictionary."""
return cls(
energies=np.array(data["energies"], dtype=np.float64),
counts=np.array(data["counts"], dtype=np.float64),
errors=np.array(data["errors"], dtype=np.float64),
metadata=data.get("metadata", {}),
dead_time=data.get("dead_time", 0.0),
live_time=data.get("live_time"),
source=data.get("source"),
run_id=data.get("run_id"),
date=data.get("date"),
)