feat: add χ² fitter and C(W) shape factor extraction modules
Add CurveFitter with scipy least_squares, configurable loss functions (soft_l1, huber, cauchy), profile likelihood, confidence intervals, and goodness-of-fit metrics. Add CWExtractor for extracting shape factors from beta spectra via Kurie analysis, parametrization fitting, and g_V/g_A extraction. Includes 32 new tests (18 fitter + 14 CW extractor). Bump version to 0.3.0.
This commit is contained in:
@@ -0,0 +1,697 @@
|
||||
# beta_spectrum/cw_extractor.py
|
||||
|
||||
"""
|
||||
C(W) shape factor extraction from beta spectra.
|
||||
|
||||
Provides methods to:
|
||||
- Extract C(W) from measured beta spectra
|
||||
- Perform Kurie plot analysis for endpoint determination
|
||||
- Fit C(W) parametrization to extract g_V, g_A
|
||||
- Compare extracted C(W) with theoretical predictions
|
||||
|
||||
The beta decay spectrum is:
|
||||
dΓ/dE = C₀ · p·E·(W₀-E)² · F(Z,E) · C(W) · [1 + δ(E)]
|
||||
|
||||
where:
|
||||
p, E = momentum, total energy of electron
|
||||
W₀ = endpoint total energy in m_e units
|
||||
F(Z,E) = Fermi function
|
||||
C(W) = shape factor (what we extract)
|
||||
δ(E) = radiative + screening + other corrections
|
||||
|
||||
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 Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .fitter import CurveFitter, FitResult
|
||||
from .spectrum import SpectrumConfig
|
||||
from .utils import T_to_W, W_to_T, 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[FitResult] = None
|
||||
"""Fit result from Kurie plot endpoint determination."""
|
||||
|
||||
def kurie_plot(self) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
Compute Kurie plot values.
|
||||
|
||||
For allowed transitions (C(W)=1), K(E) should be linear.
|
||||
Deviations from linearity indicate shape factor effects.
|
||||
"""
|
||||
# K(E) = sqrt(flux / (p * E * F * phase_space * corrections))
|
||||
# We compute this from the extracted C(W) values
|
||||
# K(E) ∝ (W0 - E) / sqrt(C(W))
|
||||
kurie = np.sqrt(self.flux) / (
|
||||
momentum(self.energies_W)
|
||||
* np.sqrt(self.energies_W)
|
||||
* (self.endpoint_W - self.energies_W)
|
||||
)
|
||||
kurie = kurie / np.max(kurie) # Normalize
|
||||
return self.energies_keV, kurie
|
||||
|
||||
|
||||
@dataclass
|
||||
class GVAExtractionResult:
|
||||
"""Container for g_V, g_A extraction results."""
|
||||
|
||||
g_V: float
|
||||
"""Extracted vector coupling constant."""
|
||||
|
||||
g_V_error: float
|
||||
"""Uncertainty on g_V."""
|
||||
|
||||
g_A: float
|
||||
"""Extracted axial-vector coupling constant."""
|
||||
|
||||
g_A_error: float
|
||||
"""Uncertainty on g_A."""
|
||||
|
||||
fit_result: FitResult
|
||||
"""Fit result with full covariance matrix."""
|
||||
|
||||
cw_data: np.ndarray
|
||||
"""C(W) data used in fit."""
|
||||
|
||||
cw_errors: np.ndarray
|
||||
"""C(W) uncertainties."""
|
||||
|
||||
energy_W: np.ndarray
|
||||
"""Energy grid for C(W) data."""
|
||||
|
||||
def summary(self) -> str:
|
||||
"""Human-readable summary of g_V, g_A extraction."""
|
||||
lines = [
|
||||
"=" * 60,
|
||||
"g_V and g_A Extraction Results",
|
||||
"=" * 60,
|
||||
f"g_V = {self.g_V:.4f} ± {self.g_V_error:.4f}",
|
||||
f"g_A = {self.g_A:.4f} ± {self.g_A_error:.4f}",
|
||||
f"χ²/ndf = {self.fit_result.chi2_per_dof:.3f}",
|
||||
f"p-value = {self.fit_result.p_value:.4f}",
|
||||
"-" * 60,
|
||||
"Comparison with standard values:",
|
||||
f" g_V / g_A ratio = {self.g_V / self.g_A:.4f} "
|
||||
f"(± {self._ratio_error():.4f})",
|
||||
" Standard model: g_V/g_A ≈ 1.0 for pure Fermi",
|
||||
"=" * 60,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def _ratio_error(self) -> float:
|
||||
"""Error on g_V/g_A ratio using error propagation."""
|
||||
r = self.g_V / self.g_A
|
||||
cov = self.fit_result.covariance
|
||||
dg_V = np.sqrt(cov[0, 0])
|
||||
dg_A = np.sqrt(cov[1, 1])
|
||||
dcorr = cov[0, 1]
|
||||
return abs(r) * np.sqrt(
|
||||
(dg_V / self.g_V) ** 2
|
||||
+ (dg_A / self.g_A) ** 2
|
||||
- 2 * dcorr / (self.g_V * self.g_A)
|
||||
)
|
||||
|
||||
|
||||
class CWExtractor:
|
||||
"""
|
||||
Extract C(W) shape factor from measured beta spectra.
|
||||
|
||||
The extraction works by dividing the measured spectrum by all known
|
||||
theoretical factors:
|
||||
|
||||
C(W) = (dΓ/dE)_measured / [C₀ · p·E·(W₀-E)² · F(Z,E) · (1+δ)]
|
||||
|
||||
where C₀ is an overall normalization constant.
|
||||
|
||||
For allowed transitions, C(W) should be approximately constant (=1).
|
||||
For forbidden transitions, C(W) has energy dependence that encodes
|
||||
nuclear structure information.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: SpectrumConfig,
|
||||
flux: np.ndarray,
|
||||
energies_keV: np.ndarray,
|
||||
flux_errors: Optional[np.ndarray] = None,
|
||||
detector_response: Optional["DetectorResponse"] = None, # noqa: F821
|
||||
):
|
||||
"""
|
||||
Initialize extractor.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config : SpectrumConfig
|
||||
Decay configuration (Z, A, endpoint, corrections).
|
||||
flux : np.ndarray
|
||||
Measured spectrum (counts per energy bin).
|
||||
energies_keV : np.ndarray
|
||||
Kinetic energy grid in keV.
|
||||
flux_errors : np.ndarray, optional
|
||||
Uncertainties on flux. If None, assumed to be sqrt(flux).
|
||||
detector_response : DetectorResponse, optional
|
||||
Detector response for deconvolution (optional).
|
||||
"""
|
||||
self.config = config
|
||||
self.flux = np.asarray(flux, dtype=np.float64)
|
||||
self.energies_keV = np.asarray(energies_keV, dtype=np.float64)
|
||||
self.energies_W = np.array([T_to_W(e / 1000.0) for e in self.energies_keV])
|
||||
self.detector_response = detector_response
|
||||
|
||||
if flux_errors is not None:
|
||||
self.flux_errors = np.asarray(flux_errors, dtype=np.float64)
|
||||
else:
|
||||
# Poisson statistics
|
||||
self.flux_errors = np.sqrt(np.maximum(self.flux, 1.0))
|
||||
|
||||
def _theoretical_factors(self, W: np.ndarray) -> Dict[str, np.ndarray]:
|
||||
"""Compute all known theoretical factors."""
|
||||
factors = {}
|
||||
|
||||
# Phase space: p * E
|
||||
p = momentum(W)
|
||||
factors["phase_space"] = p * W
|
||||
|
||||
# Fermi function
|
||||
from .components.fermi import FermiFunction
|
||||
|
||||
fermi = FermiFunction(self.config.Z_parent, self.config.A_number)
|
||||
factors["fermi"] = fermi(W)
|
||||
|
||||
# Screening
|
||||
if self.config.use_screening:
|
||||
from .components.screening import ScreeningCorrection
|
||||
|
||||
screening = ScreeningCorrection(
|
||||
FermiFunction(self.config.Z_parent, self.config.A_number)
|
||||
)
|
||||
factors["screening"] = screening(W)
|
||||
else:
|
||||
factors["screening"] = np.ones_like(W)
|
||||
|
||||
# Exchange
|
||||
if self.config.use_exchange:
|
||||
from .components.exchange import ExchangeCorrection
|
||||
|
||||
exchange = ExchangeCorrection(Z=self.config.Z_parent)
|
||||
factors["exchange"] = exchange(W)
|
||||
else:
|
||||
factors["exchange"] = np.ones_like(W)
|
||||
|
||||
# Finite size
|
||||
if self.config.use_finite_size:
|
||||
from .components.finite_size import (
|
||||
FiniteSizeL0,
|
||||
ChargeDistributionU,
|
||||
)
|
||||
|
||||
fs_l0 = FiniteSizeL0(Z=self.config.Z_daughter, A=self.config.A_number)
|
||||
fs_u = ChargeDistributionU(Z=self.config.Z_daughter, A=self.config.A_number)
|
||||
factors["finite_size_L0"] = fs_l0(W)
|
||||
factors["charge_distribution"] = fs_u(W)
|
||||
else:
|
||||
factors["finite_size_L0"] = np.ones_like(W)
|
||||
factors["charge_distribution"] = np.ones_like(W)
|
||||
|
||||
# Radiative corrections
|
||||
if self.config.use_radiative:
|
||||
from .components.radiative import RadiativeCorrection
|
||||
|
||||
W0 = T_to_W(self.config.endpoint_MeV)
|
||||
rad = RadiativeCorrection(
|
||||
W0=W0,
|
||||
Z=self.config.Z_parent,
|
||||
A=self.config.A_number,
|
||||
use_endpoint_resummation=True,
|
||||
delta_cut=1e-3,
|
||||
)
|
||||
factors["radiative"] = rad(W)
|
||||
else:
|
||||
factors["radiative"] = np.ones_like(W)
|
||||
|
||||
return factors
|
||||
|
||||
def extract_CW(
|
||||
self,
|
||||
endpoint_keV: Optional[float] = None,
|
||||
normalize_method: str = "endpoint",
|
||||
) -> CWExtractionResult:
|
||||
"""
|
||||
Extract C(W) from measured spectrum.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
endpoint_keV : float, optional
|
||||
Endpoint energy in keV. If None, determined from Kurie plot.
|
||||
normalize_method : str
|
||||
Method for normalization: 'endpoint' or 'fit'.
|
||||
|
||||
Returns
|
||||
-------
|
||||
CWExtractionResult
|
||||
Extracted C(W) values with uncertainties.
|
||||
"""
|
||||
if endpoint_keV is not None:
|
||||
W0 = T_to_W(endpoint_keV / 1000.0)
|
||||
else:
|
||||
W0 = self._determine_endpoint()
|
||||
|
||||
# Compute theoretical factors (excluding C(W))
|
||||
factors = self._theoretical_factors(self.energies_W)
|
||||
|
||||
# Product of all known factors
|
||||
product = factors["phase_space"] * factors["fermi"]
|
||||
for key in [
|
||||
"screening",
|
||||
"exchange",
|
||||
"finite_size_L0",
|
||||
"charge_distribution",
|
||||
"radiative",
|
||||
]:
|
||||
product *= factors[key]
|
||||
|
||||
# Endpoint factor: (W0 - W)^2
|
||||
endpoint_factor = (W0 - self.energies_W) ** 2
|
||||
|
||||
# Overall normalization
|
||||
if normalize_method == "endpoint":
|
||||
# Use flux at lowest energy where C(W) ≈ 1 for allowed decay
|
||||
low_E_mask = self.energies_W < W0 - 0.02
|
||||
if np.any(low_E_mask):
|
||||
C0 = np.median(
|
||||
self.flux[low_E_mask]
|
||||
/ (product[low_E_mask] * endpoint_factor[low_E_mask])
|
||||
)
|
||||
else:
|
||||
C0 = 1.0
|
||||
else:
|
||||
# Fit normalization
|
||||
C0 = self._fit_normalization(W0, product, endpoint_factor)
|
||||
|
||||
# Extract C(W)
|
||||
cw = self.flux / (C0 * product * endpoint_factor)
|
||||
|
||||
# Propagate uncertainties
|
||||
cw_errors = np.maximum(np.abs(cw), 1e-10) * np.sqrt(
|
||||
(self.flux_errors / np.maximum(self.flux, 1e-30)) ** 2
|
||||
+ 0.01**2 # ~1% systematic on theoretical factors
|
||||
)
|
||||
|
||||
# Clip unphysical values
|
||||
cw = np.clip(cw, 0.01, 10.0)
|
||||
cw_errors = np.clip(cw_errors, 0.0, np.inf)
|
||||
|
||||
return CWExtractionResult(
|
||||
energies_W=self.energies_W.copy(),
|
||||
energies_keV=self.energies_keV.copy(),
|
||||
cw_values=cw,
|
||||
cw_errors=cw_errors,
|
||||
flux=self.flux.copy(),
|
||||
flux_errors=self.flux_errors.copy(),
|
||||
endpoint_W=W0,
|
||||
endpoint_keV=W_to_T(W0) * 1000.0,
|
||||
fit_result=None,
|
||||
)
|
||||
|
||||
def kurie_analysis(
|
||||
self,
|
||||
endpoint_keV: Optional[float] = None,
|
||||
) -> Tuple[CWExtractionResult, FitResult]:
|
||||
"""
|
||||
Perform Kurie plot analysis for endpoint determination.
|
||||
|
||||
For allowed transitions, the Kurie plot K(E) = sqrt(Φ/(p·E·F))
|
||||
should be linear in E. The endpoint is where K(E) → 0.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
endpoint_keV : float, optional
|
||||
If provided, use this endpoint. Otherwise fit for it.
|
||||
|
||||
Returns
|
||||
-------
|
||||
cw_result : CWExtractionResult
|
||||
Extracted C(W) values.
|
||||
fit_result : FitResult
|
||||
Kurie plot fit result.
|
||||
"""
|
||||
# First extract C(W) with a rough endpoint
|
||||
cw_result = self.extract_CW(
|
||||
endpoint_keV=endpoint_keV,
|
||||
normalize_method="fit",
|
||||
)
|
||||
|
||||
if endpoint_keV is None:
|
||||
# Fit Kurie plot to determine endpoint
|
||||
fit_result = self._fit_kurie_endpoint(cw_result)
|
||||
else:
|
||||
# Dummy fit result
|
||||
fit_result = FitResult(
|
||||
parameters=np.array([cw_result.endpoint_keV]),
|
||||
covariance=np.eye(1) * 1.0,
|
||||
chi2=0.0,
|
||||
n_points=len(cw_result.energies_keV),
|
||||
n_free=1,
|
||||
model_values=np.zeros_like(cw_result.energies_keV),
|
||||
residuals=np.zeros_like(cw_result.energies_keV),
|
||||
success=True,
|
||||
message="Endpoint fixed, no fit needed",
|
||||
)
|
||||
|
||||
cw_result.fit_result = fit_result
|
||||
|
||||
return cw_result, fit_result
|
||||
|
||||
def fit_CW_parametrization(
|
||||
self,
|
||||
cw_result: CWExtractionResult,
|
||||
parametrization: str = "linear",
|
||||
bounds: Optional[Tuple] = None,
|
||||
) -> FitResult:
|
||||
"""
|
||||
Fit C(W) to a parametrized form.
|
||||
|
||||
Supported parametrizations:
|
||||
- "constant": C(W) = a₀ (allowed transition)
|
||||
- "linear": C(W) = a₀ + a₁·(W - 1) (first-order correction)
|
||||
- "quadratic": C(W) = a₀ + a₁·(W - 1) + a₂·(W - 1)²
|
||||
- "gV_gA": C(W) = g_V²·|M_F|² + g_A²·|M_GT|² + interference terms
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cw_result : CWExtractionResult
|
||||
Extracted C(W) data.
|
||||
parametrization : str
|
||||
Parametrization form.
|
||||
bounds : tuple, optional
|
||||
Parameter bounds (lower, upper).
|
||||
|
||||
Returns
|
||||
-------
|
||||
FitResult
|
||||
Fit results.
|
||||
"""
|
||||
# Define parametrization models
|
||||
models = {
|
||||
"constant": self._model_constant,
|
||||
"linear": self._model_linear,
|
||||
"quadratic": self._model_quadratic,
|
||||
}
|
||||
|
||||
if parametrization not in models:
|
||||
raise ValueError(
|
||||
f"Unknown parametrization: {parametrization}. "
|
||||
f"Use one of: {list(models.keys())}"
|
||||
)
|
||||
|
||||
n_params = {
|
||||
"constant": 1,
|
||||
"linear": 2,
|
||||
"quadratic": 3,
|
||||
}[parametrization]
|
||||
|
||||
# Initial guesses
|
||||
x0 = self._initial_guesses(parametrization, cw_result)
|
||||
|
||||
# Filter out points near endpoint (large uncertainties)
|
||||
mask = cw_result.energies_keV < cw_result.endpoint_keV * 0.95
|
||||
energies = cw_result.energies_W[mask]
|
||||
cw = cw_result.cw_values[mask]
|
||||
cw_err = cw_result.cw_errors[mask]
|
||||
|
||||
if len(energies) < n_params + 2:
|
||||
raise ValueError(
|
||||
f"Not enough data points for {parametrization} fit. "
|
||||
f"Need at least {n_params + 2}, got {len(energies)}"
|
||||
)
|
||||
|
||||
fitter = CurveFitter(
|
||||
model=models[parametrization],
|
||||
x_data=energies,
|
||||
y_data=cw,
|
||||
uncertainties=cw_err,
|
||||
)
|
||||
|
||||
return fitter.fit(x0, bounds, param_names=[f"a{i}" for i in range(n_params)])
|
||||
|
||||
def fit_gV_gA(
|
||||
self,
|
||||
cw_result: CWExtractionResult,
|
||||
M_F: float = 1.0,
|
||||
M_GT: float = 1.0,
|
||||
interference: float = 0.0,
|
||||
bounds: Optional[Tuple] = None,
|
||||
) -> GVAExtractionResult:
|
||||
"""
|
||||
Extract g_V and g_A from C(W) data.
|
||||
|
||||
For mixed transitions:
|
||||
C(W) = g_V²·|M_F|² + g_A²·|M_GT|² + b·g_V·g_A·interference
|
||||
|
||||
where M_F and M_GT are Fermi and Gamow-Teller matrix elements.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cw_result : CWExtractionResult
|
||||
Extracted C(W) data.
|
||||
M_F : float
|
||||
Fermi matrix element (default: 1.0 for pure Fermi).
|
||||
M_GT : float
|
||||
Gamow-Teller matrix element (default: 1.0 for pure GT).
|
||||
interference : float
|
||||
Interference term coefficient (b coefficient).
|
||||
bounds : tuple, optional
|
||||
Parameter bounds (g_V_low, g_A_low, g_V_high, g_A_high).
|
||||
|
||||
Returns
|
||||
-------
|
||||
GVAExtractionResult
|
||||
Extracted g_V, g_A with uncertainties.
|
||||
"""
|
||||
# Filter points near endpoint
|
||||
mask = cw_result.energies_keV < cw_result.endpoint_keV * 0.95
|
||||
energies = cw_result.energies_W[mask]
|
||||
cw = cw_result.cw_values[mask]
|
||||
cw_err = cw_result.cw_errors[mask]
|
||||
|
||||
if len(energies) < 4:
|
||||
raise ValueError(
|
||||
f"Not enough data points for g_V/g_A fit. "
|
||||
f"Need at least 4, got {len(energies)}"
|
||||
)
|
||||
|
||||
# g_V, g_A model
|
||||
def gVgA_model(W, g_V, g_A):
|
||||
return g_V**2 * M_F**2 + g_A**2 * M_GT**2 + interference * g_V * g_A
|
||||
|
||||
# Initial guesses: use Paulsen et al. values as starting point
|
||||
x0 = [0.376, 0.574]
|
||||
|
||||
if bounds is None:
|
||||
bounds = (
|
||||
[0.0, 0.0],
|
||||
[2.0, 2.0],
|
||||
)
|
||||
|
||||
fitter = CurveFitter(
|
||||
model=gVgA_model,
|
||||
x_data=energies,
|
||||
y_data=cw,
|
||||
uncertainties=cw_err,
|
||||
)
|
||||
|
||||
fit_result = fitter.fit(
|
||||
x0,
|
||||
bounds,
|
||||
param_names=["g_V", "g_A"],
|
||||
)
|
||||
|
||||
return GVAExtractionResult(
|
||||
g_V=float(fit_result.parameters[0]),
|
||||
g_V_error=float(np.sqrt(fit_result.covariance[0, 0])),
|
||||
g_A=float(fit_result.parameters[1]),
|
||||
g_A_error=float(np.sqrt(fit_result.covariance[1, 1])),
|
||||
fit_result=fit_result,
|
||||
cw_data=cw,
|
||||
cw_errors=cw_err,
|
||||
energy_W=energies,
|
||||
)
|
||||
|
||||
def _determine_endpoint(self) -> float:
|
||||
"""
|
||||
Determine endpoint energy from Kurie plot linearity.
|
||||
|
||||
Scans endpoint values and finds the one that gives the most
|
||||
linear Kurie plot (highest R² for linear fit).
|
||||
"""
|
||||
# Scan endpoint in keV
|
||||
E_min = self.energies_keV[-1] - 50 # At least 50 keV below max
|
||||
E_max = self.energies_keV[-1] + 10 # Allow slight overshoot
|
||||
|
||||
best_R2 = -np.inf
|
||||
best_endpoint = E_max
|
||||
|
||||
for E_trial in np.linspace(E_min, E_max, 30):
|
||||
W0 = T_to_W(E_trial / 1000.0)
|
||||
|
||||
# Compute Kurie values
|
||||
mask = self.energies_W < W0 - 0.01
|
||||
if not np.any(mask):
|
||||
continue
|
||||
|
||||
W = self.energies_W[mask]
|
||||
flux = self.flux[mask]
|
||||
|
||||
# K(E) = sqrt(flux / (p*E*F))
|
||||
p = momentum(W)
|
||||
from .components.fermi import FermiFunction
|
||||
|
||||
F = FermiFunction()(W, self.config.Z_parent)
|
||||
|
||||
kurie = np.sqrt(flux / (p * W * F))
|
||||
|
||||
# Linear fit: K(E) = a + b*E
|
||||
E_keV = self.energies_keV[mask]
|
||||
try:
|
||||
coeffs = np.polyfit(E_keV, kurie, 1)
|
||||
# R²
|
||||
kurie_fit = np.polyval(coeffs, E_keV)
|
||||
SS_res = np.sum((kurie - kurie_fit) ** 2)
|
||||
SS_tot = np.sum((kurie - np.mean(kurie)) ** 2)
|
||||
R2 = 1 - SS_res / SS_tot if SS_tot > 0 else 0
|
||||
|
||||
if R2 > best_R2:
|
||||
best_R2 = R2
|
||||
best_endpoint = E_trial
|
||||
except np.linalg.LinAlgError:
|
||||
continue
|
||||
|
||||
return T_to_W(best_endpoint / 1000.0)
|
||||
|
||||
def _fit_kurie_endpoint(self, cw_result: CWExtractionResult) -> FitResult:
|
||||
"""Fit Kurie plot to determine endpoint energy."""
|
||||
W0 = cw_result.endpoint_W
|
||||
|
||||
# Kurie plot: K(E) = sqrt(flux / (p*E*F))
|
||||
mask = self.energies_W < W0 - 0.01
|
||||
W = self.energies_W[mask]
|
||||
flux = self.flux[mask]
|
||||
flux_err = self.flux_errors[mask]
|
||||
|
||||
p = momentum(W)
|
||||
from .components.fermi import FermiFunction
|
||||
|
||||
F = FermiFunction()(W, self.config.Z_parent)
|
||||
|
||||
kurie = np.sqrt(flux / (p * W * F))
|
||||
E_keV = self.energies_keV[mask]
|
||||
|
||||
# K(E) = a + b*(E - E0), where K(E0) = 0
|
||||
# So a = -b*(E0 - E0) = 0 at the true endpoint
|
||||
# Linear fit: K(E) = a + b*E
|
||||
def kurie_model(E, a, b):
|
||||
return a + b * E
|
||||
|
||||
fitter = CurveFitter(
|
||||
model=kurie_model,
|
||||
x_data=E_keV,
|
||||
y_data=kurie,
|
||||
uncertainties=flux_err / (2 * kurie * p * W * F),
|
||||
)
|
||||
|
||||
return fitter.fit(
|
||||
x0=[np.mean(kurie), -np.mean(kurie) / E_keV[-1]],
|
||||
param_names=["a", "b"],
|
||||
)
|
||||
|
||||
def _fit_normalization(
|
||||
self, W0: float, product: np.ndarray, endpoint_factor: np.ndarray
|
||||
) -> float:
|
||||
"""Fit overall normalization C₀."""
|
||||
mask = self.energies_W < W0 - 0.02
|
||||
if not np.any(mask):
|
||||
return 1.0
|
||||
|
||||
def norm_model(E, C0):
|
||||
return C0 * product[mask] * endpoint_factor[mask]
|
||||
|
||||
fitter = CurveFitter(
|
||||
model=norm_model,
|
||||
x_data=self.energies_W[mask],
|
||||
y_data=self.flux[mask],
|
||||
uncertainties=self.flux_errors[mask],
|
||||
)
|
||||
|
||||
result = fitter.fit(x0=[1.0])
|
||||
return float(result.parameters[0])
|
||||
|
||||
def _model_constant(self, W: np.ndarray, a0: float) -> np.ndarray:
|
||||
"""C(W) = a₀ (constant)."""
|
||||
return np.full_like(W, a0)
|
||||
|
||||
def _model_linear(self, W: np.ndarray, a0: float, a1: float) -> np.ndarray:
|
||||
"""C(W) = a₀ + a₁·(W - 1)."""
|
||||
return a0 + a1 * (W - 1.0)
|
||||
|
||||
def _model_quadratic(
|
||||
self, W: np.ndarray, a0: float, a1: float, a2: float
|
||||
) -> np.ndarray:
|
||||
"""C(W) = a₀ + a₁·(W - 1) + a₂·(W - 1)²."""
|
||||
return a0 + a1 * (W - 1.0) + a2 * (W - 1.0) ** 2
|
||||
|
||||
def _initial_guesses(
|
||||
self, parametrization: str, cw_result: CWExtractionResult
|
||||
) -> List[float]:
|
||||
"""Generate initial guesses for fit parameters."""
|
||||
# Average C(W) in mid-energy region
|
||||
mid_mask = (cw_result.energies_keV > cw_result.endpoint_keV * 0.2) & (
|
||||
cw_result.energies_keV < cw_result.endpoint_keV * 0.7
|
||||
)
|
||||
if np.any(mid_mask):
|
||||
avg_cw = float(np.mean(cw_result.cw_values[mid_mask]))
|
||||
else:
|
||||
avg_cw = 1.0
|
||||
|
||||
if parametrization == "constant":
|
||||
return [avg_cw]
|
||||
elif parametrization == "linear":
|
||||
return [avg_cw, 0.0]
|
||||
elif parametrization == "quadratic":
|
||||
return [avg_cw, 0.0, 0.0]
|
||||
else:
|
||||
return [avg_cw] * 3
|
||||
Reference in New Issue
Block a user