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