Files
beta_shape_pnpi/exp_data/corrections.py
T
unzhakov 38b44b56e6 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.
2026-05-17 18:58:04 +03:00

269 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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]))