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
+373
View File
@@ -0,0 +1,373 @@
"""
C(W) shape factor and g_V/g_A extraction from fitted spectra.
Provides methods to:
- Extract C(W) from fitted spectrum vs measured spectrum
- Perform Kurie plot analysis for endpoint determination
- Fit C(W) parametrization to extract g_V, g_A
The beta decay spectrum is:
dΓ/dE = C₀ · p·E·(W₀-E)² · F(Z,E) · C(W) · [1 + δ(E)]
For allowed transitions with C(W) = 1, the Kurie plot
K(E) = sqrt(dΓ/dE / (p·E·F(Z,E)·[1+δ(E)]))
is linear in E with slope -(W₀-E₀)/E₀.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Optional, Tuple
import numpy as np
if TYPE_CHECKING:
from beta_spectrum.spectrum import SpectrumConfig
from beta_spectrum.utils import T_to_W, momentum
@dataclass
class CWExtractionResult:
"""Container for C(W) extraction results."""
energies_W: np.ndarray
"""Total energy grid in m_e units."""
energies_keV: np.ndarray
"""Kinetic energy grid in keV."""
cw_values: np.ndarray
"""Extracted C(W) values."""
cw_errors: np.ndarray
"""Uncertainties on C(W) values."""
flux: np.ndarray
"""Measured flux (counts per energy bin)."""
flux_errors: np.ndarray
"""Uncertainties on flux."""
endpoint_W: float
"""Endpoint energy W₀ in m_e units."""
endpoint_keV: float
"""Endpoint kinetic energy in keV."""
fit_result: Optional[Any] = None # FitResult from SpectrumFitter
def kurie_plot(self) -> Tuple[np.ndarray, np.ndarray]:
"""Compute Kurie plot values.
Returns
-------
(energies_keV, kurie_values)
"""
p = momentum(self.energies_W)
E = self.energies_W
# C(W) = (dΓ/dE) / (C₀ · p·E·(W₀-E)² · F · corrections)
# For Kurie plot: K(E) = sqrt(C(W) / (p·E·(W₀-E)²))
# Simplified: K(E) = sqrt(counts / (p·E·(W₀-E)²))
phase_space = p * E * (self.endpoint_W - E) ** 2
mask = phase_space > 0
kurie = np.zeros_like(self.energies_keV)
kurie[mask] = np.sqrt(
self.flux[mask] / (phase_space[mask] * self.energies_keV[mask] ** 2)
)
return self.energies_keV[mask], kurie[mask]
@dataclass
class GVAExtractionResult:
"""Container for g_V and g_A extraction results."""
gv_eff: float
"""Effective vector coupling constant."""
gv_error: float
"""1σ uncertainty on g_V."""
ga_eff: float
"""Effective axial coupling constant."""
ga_error: float
"""1σ uncertainty on g_A."""
correlation: float
"""Correlation coefficient between g_V and g_A."""
chi2_per_dof: float
"""Reduced χ²."""
def summary(self) -> str:
lines = [
"=" * 60,
"g_V / g_A Extraction",
"=" * 60,
f"g_V^eff = {self.gv_eff:.6f} ± {self.gv_error:.6f}",
f"g_A^eff = {self.ga_eff:.6f} ± {self.ga_error:.6f}",
f"Correlation(g_V, g_A) = {self.correlation:.3f}",
f"χ²/ndof = {self.chi2_per_dof:.3f}",
"=" * 60,
]
return "\n".join(lines)
class CWExtractor:
"""Extract C(W) shape factor from fitted spectrum.
Parameters
----------
config : SpectrumConfig
Configuration used for the theoretical model.
"""
def __init__(self, config: "SpectrumConfig"):
self.config = config
def extract(
self,
measured_counts: np.ndarray,
measured_errors: np.ndarray,
energies_keV: np.ndarray,
model_counts: np.ndarray,
endpoint_keV: float,
) -> CWExtractionResult:
"""Extract C(W) from measured spectrum and model prediction.
Parameters
----------
measured_counts : np.ndarray
Measured counts per channel.
measured_errors : np.ndarray
Measurement uncertainties.
energies_keV : np.ndarray
Energy grid in keV.
model_counts : np.ndarray
Theoretical model counts (from fit).
endpoint_keV : float
Endpoint energy in keV.
Returns
-------
CWExtractionResult
"""
W = T_to_W(energies_keV)
W0 = T_to_W(endpoint_keV)
# C(W) ∝ measured / model (ratio gives shape factor)
# For allowed transitions, model includes phase space + Fermi + corrections
cw_values = np.where(model_counts > 0, measured_counts / model_counts, 1.0)
cw_errors = np.where(
model_counts > 0,
measured_errors / model_counts,
0.0,
)
# Clip C(W) to reasonable range
cw_values = np.clip(cw_values, 0.01, 10.0)
# Ensure errors are positive
cw_errors = np.maximum(cw_errors, 1e-10)
return CWExtractionResult(
energies_W=W,
energies_keV=energies_keV,
cw_values=cw_values,
cw_errors=cw_errors,
flux=measured_counts,
flux_errors=measured_errors,
endpoint_W=W0,
endpoint_keV=endpoint_keV,
)
def fit_parametrization(
self,
cw_result: "CWExtractionResult",
parametrization: str = "constant",
) -> np.ndarray:
"""Fit C(W) parametrization to extracted values.
Parameters
----------
cw_result : CWExtractionResult
Extracted C(W) values.
parametrization : str
Parametrization type: 'constant', 'linear', 'quadratic'.
Returns
-------
np.ndarray
Fitted parameters [a0, a1, ...].
Raises
------
ValueError
If parametrization is not recognized.
"""
valid = cw_result.energies_W > 0
W = cw_result.energies_W[valid] - 1.0
cw = cw_result.cw_values[valid]
if len(W) < 3:
raise ValueError("Not enough points for fit")
if parametrization == "constant":
coeffs, cov = np.polyfit(W, cw, 0, cov=True)
return np.array([float(coeffs)])
elif parametrization == "linear":
coeffs, cov = np.polyfit(W, cw, 1, cov=True)
return coeffs
elif parametrization == "quadratic":
coeffs, cov = np.polyfit(W, cw, 2, cov=True)
return coeffs
else:
raise ValueError(f"Unknown parametrization: {parametrization}")
def fit_gV_gA(
self,
cw_result: "CWExtractionResult",
M_F: float = 1.0,
M_GT: float = 0.0,
) -> "GVAExtractionResult":
"""Extract effective g_V and g_A from C(W) values.
Parameters
----------
cw_result : CWExtractionResult
Extracted C(W) values.
M_F : float
Fermi matrix element.
M_GT : float
Gamow-Teller matrix element.
Returns
-------
GVAExtractionResult
"""
return GVAExtractor.extract_from_curve(
cw_values=cw_result.cw_values,
energies_W=cw_result.energies_W,
endpoint_W=cw_result.endpoint_W,
)
def kurie_analysis(
self,
endpoint_keV: float,
) -> Tuple["CWExtractionResult", Optional[Any]]:
"""Perform Kurie plot analysis to extract C(W).
Placeholder method: in a full implementation, this would
use the fitted model to compute C(W) from experimental data.
Returns
-------
(CWExtractionResult, fit_result)
Extracted C(W) and optional fit result.
"""
# For now, return a dummy result — the real implementation
# would use the fitted spectrum model
energies_keV = np.linspace(5, endpoint_keV * 0.98, 50)
W = T_to_W(energies_keV / 1000.0)
cw_values = np.ones_like(W)
cw_errors = np.ones_like(W) * 0.05
flux = np.ones_like(W) * 10.0
flux_errors = np.ones_like(W) * 3.0
W0 = T_to_W(endpoint_keV / 1000.0)
result = CWExtractionResult(
energies_W=W,
energies_keV=energies_keV,
cw_values=cw_values,
cw_errors=cw_errors,
flux=flux,
flux_errors=flux_errors,
endpoint_W=W0,
endpoint_keV=endpoint_keV,
)
return result, None
class GVAExtractor:
"""Extract effective g_V and g_A from C(W) shape factor.
For allowed transitions:
C(W) ≈ 1 + (g_A/g_V)² · (corrections)
The ratio g_A^eff/g_V^eff can be extracted from the shape of C(W).
"""
@staticmethod
def extract_from_curve(
cw_values: np.ndarray,
energies_W: np.ndarray,
endpoint_W: float,
transition_type: str = "F",
) -> GVAExtractionResult:
"""Extract g_V and g_A from C(W) values.
Parameters
----------
cw_values : np.ndarray
Extracted C(W) values.
energies_W : np.ndarray
Total energy grid.
endpoint_W : float
Endpoint energy W₀.
transition_type : str
Transition type (F, GT, etc.).
Returns
-------
GVAExtractionResult
"""
# For allowed transitions, C(W) ≈ 1 + b·(W - W₀) where b depends on g_A/g_V
# Simple linear fit of C(W) vs energy near endpoint
mask = (energies_W > 0) & (energies_W < endpoint_W) & (cw_values > 0)
if np.sum(mask) < 3:
return GVAExtractionResult(
gv_eff=1.0,
gv_error=0.0,
ga_eff=0.0,
ga_error=0.0,
correlation=0.0,
chi2_per_dof=0.0,
)
# Fit C(W) = a + b·(W - W₀)
x = energies_W[mask] - endpoint_W
y = cw_values[mask]
# Linear fit
coeffs, cov = np.polyfit(x, y, 1, cov=True)
a, b = coeffs
# For allowed transitions: g_V ≈ 1 (CVC), g_A extracted from slope
gv_eff = 1.0
gv_error = 0.0
ga_eff = b * endpoint_W # Simplified relation
ga_error = np.sqrt(cov[1, 1]) * endpoint_W
correlation = (
cov[0, 1] / (np.sqrt(cov[0, 0]) * np.sqrt(cov[1, 1]))
if cov[0, 0] * cov[1, 1] > 0
else 0.0
)
# χ² estimate
y_fit = a + b * x
chi2 = float(np.sum(((y - y_fit) / np.maximum(np.abs(y) * 0.01, 1e-10)) ** 2))
ndof = max(len(x) - 2, 1)
return GVAExtractionResult(
gv_eff=gv_eff,
gv_error=gv_error,
ga_eff=ga_eff,
ga_error=ga_error,
correlation=float(correlation),
chi2_per_dof=chi2 / ndof if ndof > 0 else chi2,
)