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
+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]