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:
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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]))
|
||||
@@ -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]
|
||||
@@ -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"),
|
||||
)
|
||||
Reference in New Issue
Block a user