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
+138
View File
@@ -0,0 +1,138 @@
"""Tests for exp_data.calibration."""
import numpy as np
import pytest
from exp_data.calibration import CalibrationResult, EnergyCalibrator
class TestCalibrationResult:
"""Test CalibrationResult."""
def test_linear_call(self):
# polyfit returns [slope, intercept] = [0.5, 5.0]
# polyval([0.5, 5.0], ch) = 0.5*ch + 5.0
result = CalibrationResult(
coefficients=np.array([0.5, 5.0]), # E = 0.5*ch + 5.0
order=1,
chi2_per_dof=0.01,
n_points=2,
covariance=np.eye(2),
)
np.testing.assert_allclose(result(0), 5.0)
np.testing.assert_allclose(result(10), 10.0)
def test_quadratic_call(self):
# polyfit returns [a2, a1, a0] = [0.01, 0.1, 1.0]
# polyval([0.01, 0.1, 1.0], ch) = 0.01*ch² + 0.1*ch + 1.0
result = CalibrationResult(
coefficients=np.array([0.01, 0.1, 1.0]),
order=2,
chi2_per_dof=0.01,
n_points=3,
covariance=np.eye(3),
)
np.testing.assert_allclose(result(0), 1.0, rtol=1e-10)
# ch=10: 0.01*100 + 0.1*10 + 1.0 = 1 + 1 + 1 = 3
np.testing.assert_allclose(result(10), 3.0)
def test_invert(self):
result = CalibrationResult(
coefficients=np.array([0.5, 5.0]), # E = 0.5*ch + 5.0
order=1,
chi2_per_dof=0.01,
n_points=2,
covariance=np.eye(2),
)
inv = result.invert()
# E=10 => ch = (10-5)/0.5 = 10
np.testing.assert_allclose(inv(10.0), 10.0, rtol=0.01)
def test_resolution_at(self):
result = CalibrationResult(
coefficients=np.array([1.0, 0.0]), # E = 1.0*ch + 0
order=1,
chi2_per_dof=0.01,
n_points=2,
covariance=np.eye(2),
)
# dE/dch = 1.0, resolution = 1.0 * 1.0 = 1.0 keV
res = result.resolution_at(50.0, sigma_channel=1.0)
np.testing.assert_allclose(res, 1.0)
def test_resolution_at_quadratic(self):
result = CalibrationResult(
coefficients=np.array([0.02, 1.0, 0.0]), # E = 0.02*ch² + 1.0*ch
order=2,
chi2_per_dof=0.01,
n_points=3,
covariance=np.eye(3),
)
# dE/dch = 2*0.02*ch + 1.0 = 0.04*ch + 1.0
# at ch=50: dE/dch = 0.04*50 + 1.0 = 3.0
expected = 0.04 * 50.0 + 1.0
res = result.resolution_at(50.0, sigma_channel=1.0)
np.testing.assert_allclose(res, expected)
class TestEnergyCalibrator:
"""Test EnergyCalibrator."""
def test_not_calibrated_initially(self):
cal = EnergyCalibrator()
assert not cal.is_calibrated
def test_get_linear_calibration(self):
cal = EnergyCalibrator()
result = cal.get_linear_calibration(
low_energy_keV=0.0,
high_energy_keV=100.0,
low_channel=0,
high_channel=100,
)
assert cal.is_calibrated
assert result.order == 1
np.testing.assert_allclose(result(50), 50.0)
def test_apply_calibration_with_points(self):
cal = EnergyCalibrator()
cal.add_calibration_point(0, 0.0)
cal.add_calibration_point(50, 50.0)
cal.add_calibration_point(100, 100.0)
result = cal.fit_calibration(order=1)
assert result.order == 1
# Perfect fit on collinear points
np.testing.assert_allclose(result(50), 50.0, rtol=1e-6)
def test_channel_to_energy_raises_without_calibration(self):
cal = EnergyCalibrator()
with pytest.raises(RuntimeError):
cal.channel_to_energy(50)
def test_energy_to_channel_raises_without_calibration(self):
cal = EnergyCalibrator()
with pytest.raises(RuntimeError):
cal.energy_to_channel(50.0)
def test_full_channel_mapping(self):
"""Calibrate using full channel-to-energy mapping."""
# Simulate: E = 0.5 + 0.1*ch + 0.001*ch²
n_channels = 200
channels = np.arange(n_channels, dtype=np.float64)
energies = 0.5 + 0.1 * channels + 0.001 * channels**2
cal = EnergyCalibrator()
result = cal.fit_calibration(order=2, channel_energies=energies)
assert result.order == 2
np.testing.assert_allclose(result(channels), energies, rtol=1e-10)
def test_quadratic_calibration(self):
cal = EnergyCalibrator()
cal.add_calibration_point(0, 0.0)
cal.add_calibration_point(50, 52.0)
cal.add_calibration_point(100, 108.0) # slight quadratic deviation
result = cal.fit_calibration(order=2)
assert result.order == 2
+106
View File
@@ -0,0 +1,106 @@
"""Tests for exp_data.corrections."""
import numpy as np
from exp_data.corrections import (
DeadTimeCorrection,
PileUpCorrection,
BackgroundSubtractor,
)
class TestDeadTimeCorrection:
"""Test dead-time correction."""
def test_non_paralyzable_live_time(self):
dt = DeadTimeCorrection(dead_time=0.001, model="non_paralyzable")
# At 100 cps: live = 1 - 100*0.001 = 0.9
assert np.isclose(dt.live_time_fraction(100.0), 0.9)
def test_non_paralyzable_correct_rate(self):
dt = DeadTimeCorrection(dead_time=0.01, model="non_paralyzable")
# True rate = observed / (1 - obs*dt) = 90 / (1 - 90*0.01) = 90 / 0.1 = 900
assert np.isclose(dt.correct_rate(90.0), 900.0, rtol=0.01)
def test_paralyzable_live_time(self):
dt = DeadTimeCorrection(dead_time=0.001, model="paralyzable")
# At low rate: exp(-0.1) ≈ 0.905
frac = dt.live_time_fraction(100.0)
assert np.isclose(frac, np.exp(-0.1), rtol=1e-5)
def test_correct_counts(self):
dt = DeadTimeCorrection(dead_time=0.01, model="non_paralyzable")
counts = np.array([100.0, 200.0, 300.0])
live_time = 10.0
corrected = dt.correct_counts(counts, live_time)
# Total observed rate = 600/10 = 60 cps
# True rate = 60 / (1 - 60*0.01) = 60 / 0.94 = 63.83 cps
# Scale factor = 63.83/60 = 1.0638
scale = 1.0 / (1 - 60.0 * 0.01)
expected = counts * scale
np.testing.assert_allclose(corrected, expected)
class TestPileUpCorrection:
"""Test pile-up correction."""
def test_pile_up_fraction(self):
pu = PileUpCorrection(dead_time=0.001)
# At 100 cps: (0.1)² = 0.01
frac = pu.estimate_pile_up_fraction(100.0)
assert np.isclose(frac, 0.01)
def test_correct_spectrum(self):
pu = PileUpCorrection(dead_time=0.001)
counts = np.array([100.0, 200.0, 300.0])
live_time = 10.0
corrected = pu.correct_spectrum(counts, live_time)
# Pile-up correction adds a small fraction back
assert np.all(corrected >= counts)
class TestBackgroundSubtractor:
"""Test background subtraction."""
def test_constant_background(self):
energies = np.linspace(0, 100, 200)
counts = 50 * np.exp(-0.5 * ((energies - 50) / 5) ** 2) + 10.0 # peak + bg
corrected = BackgroundSubtractor.constant_background(
counts,
energies,
low_roi=(0.0, 10.0),
high_roi=(90.0, 100.0),
)
# Peak should still be visible, background removed
assert np.max(corrected) > 0
# Background regions should be near zero
assert np.mean(corrected[energies <= 10]) < 5
assert np.mean(corrected[energies >= 90]) < 5
def test_polynomial_background(self):
energies = np.linspace(0, 100, 200)
counts = 50 * np.exp(-0.5 * ((energies - 50) / 5) ** 2) + 10.0 + 0.05 * energies
corrected = BackgroundSubtractor.polynomial_background(
counts,
energies,
order=1,
low_roi=(0.0, 10.0),
high_roi=(90.0, 100.0),
)
assert np.max(corrected) > 0
def test_roi_average(self):
counts = np.array([10.0, 12.0, 50.0, 10.0, 11.0])
mask = np.array([True, True, False, True, True])
avg = BackgroundSubtractor.roi_average(counts, mask)
np.testing.assert_allclose(avg, 10.75)
def test_roi_average_empty(self):
counts = np.array([10.0, 20.0])
mask = np.array([False, False])
avg = BackgroundSubtractor.roi_average(counts, mask)
assert avg == 0.0
+114
View File
@@ -0,0 +1,114 @@
"""Tests for exp_data.fitters."""
import numpy as np
from exp_data.fitters import GaussianFitter, PeakFitter
class TestGaussianFitter:
"""Test Gaussian peak fitting."""
def _make_synthetic_peak(
self,
center=50.0,
sigma=2.0,
amplitude=100.0,
background=10.0,
n_points=500,
energy_range=(0, 100),
):
"""Create synthetic Gaussian peak with noise."""
energies = np.linspace(energy_range[0], energy_range[1], n_points)
counts = (
amplitude * np.exp(-0.5 * ((energies - center) / sigma) ** 2) + background
)
counts += np.random.RandomState(42).normal(
0, np.sqrt(np.maximum(counts, 1)), n_points
)
return energies, counts
def test_fit_returns_valid_result(self):
energies, counts = self._make_synthetic_peak()
fitter = GaussianFitter(energies, counts)
result = fitter.fit()
assert result.success
assert result.chi2 > 0
assert result.n_dof > 0
assert result.covariance.shape == (4, 4)
def test_fit_recovers_parameters(self):
center, sigma, amplitude, background = 50.0, 2.0, 100.0, 10.0
energies, counts = self._make_synthetic_peak(
center=center, sigma=sigma, amplitude=amplitude, background=background
)
fitter = GaussianFitter(energies, counts)
result = fitter.fit()
np.testing.assert_allclose(result.center_keV, center, rtol=0.05)
np.testing.assert_allclose(result.sigma_keV, sigma, rtol=0.1)
np.testing.assert_allclose(result.amplitude, amplitude, rtol=0.15)
def test_resolution_property(self):
energies, counts = self._make_synthetic_peak(sigma=2.0)
fitter = GaussianFitter(energies, counts)
result = fitter.fit()
expected_fwhm = 2.35482 * 2.0
np.testing.assert_allclose(result.resolution(), expected_fwhm, rtol=0.05)
def test_resolution_fraction(self):
energies, counts = self._make_synthetic_peak(center=50.0, sigma=2.0)
fitter = GaussianFitter(energies, counts)
result = fitter.fit()
expected_frac = (2.35482 * 2.0) / 50.0
np.testing.assert_allclose(
result.resolution_fraction(), expected_frac, rtol=0.05
)
def test_auto_guess(self):
energies, counts = self._make_synthetic_peak()
fitter = GaussianFitter(energies, counts)
result = fitter.fit()
assert result.success
class TestPeakFitter:
"""Test multi-peak detection and fitting."""
def _make_multi_peak(self):
"""Create spectrum with two peaks."""
energies = np.linspace(0, 100, 1000)
counts = np.zeros_like(energies)
counts += 50 * np.exp(-0.5 * ((energies - 20) / 2) ** 2)
counts += 80 * np.exp(-0.5 * ((energies - 70) / 3) ** 2)
counts += 5 # constant background
counts += np.random.RandomState(42).normal(
0, np.sqrt(np.maximum(counts, 1)), len(energies)
)
return energies, counts
def test_find_peaks(self):
energies, counts = self._make_multi_peak()
fitter = PeakFitter(energies, counts)
peaks = fitter.find_peaks(min_height=10.0, min_distance_keV=10.0)
assert len(peaks) >= 1 # At least one peak should be found
def test_fit_peaks_with_known_energies(self):
energies, counts = self._make_multi_peak()
fitter = PeakFitter(energies, counts)
results = fitter.fit_peaks(known_energies=np.array([20.0, 70.0]))
assert len(results) >= 1
for r in results:
assert r.success
assert r.n_dof > 0
def test_fit_peaks_auto_detect(self):
energies, counts = self._make_multi_peak()
fitter = PeakFitter(energies, counts)
results = fitter.fit_peaks()
assert len(results) >= 1
+141
View File
@@ -0,0 +1,141 @@
"""Tests for exp_data.spectrum."""
import numpy as np
import pytest
from exp_data.spectrum import ExpSpectrum
class TestExpSpectrumBasic:
"""Basic ExpSpectrum construction and properties."""
def test_construct_with_errors(self):
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0, 2.0]),
counts=np.array([10.0, 20.0, 30.0]),
errors=np.array([3.16, 4.47, 5.48]),
)
assert spectrum.n_channels == 3
np.testing.assert_array_equal(spectrum.energies, [0.0, 1.0, 2.0])
def test_automatic_poisson_errors(self):
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0, 2.0]),
counts=np.array([10.0, 20.0, 30.0]),
)
expected = np.sqrt([10.0, 20.0, 30.0])
np.testing.assert_allclose(spectrum.errors, expected)
def test_zero_counts_get_error_one(self):
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0]),
counts=np.array([0.0, 100.0]),
)
assert spectrum.errors[0] == 1.0 # sqrt(1) for zero counts
def test_shape_mismatch_raises(self):
with pytest.raises(AssertionError):
ExpSpectrum(
energies=np.array([0.0, 1.0]),
counts=np.array([10.0, 20.0, 30.0]),
)
def test_negative_energies_raises(self):
with pytest.raises(AssertionError):
ExpSpectrum(
energies=np.array([-1.0, 0.0]),
counts=np.array([10.0, 20.0]),
)
def test_negative_counts_raises(self):
with pytest.raises(AssertionError):
ExpSpectrum(
energies=np.array([0.0, 1.0]),
counts=np.array([10.0, -5.0]),
)
def test_total_counts(self):
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0, 2.0]),
counts=np.array([10.0, 20.0, 30.0]),
)
assert spectrum.total_counts == 60.0
def test_energy_range(self):
spectrum = ExpSpectrum(
energies=np.array([0.5, 1.5, 2.5]),
counts=np.array([10.0, 20.0, 30.0]),
)
assert spectrum.energy_range == (0.5, 2.5)
def test_normalize(self):
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0, 2.0]),
counts=np.array([10.0, 20.0, 30.0]),
)
norm = spectrum.normalize()
integral = np.trapezoid(norm, spectrum.energies)
assert np.isclose(integral, 1.0, rtol=1e-10)
class TestExpSpectrumDeadTime:
"""Dead-time correction on ExpSpectrum."""
def test_no_correction_when_dead_time_zero(self):
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0]),
counts=np.array([10.0, 20.0]),
)
corrected = spectrum.apply_dead_time_correction()
np.testing.assert_array_equal(corrected.counts, spectrum.counts)
def test_correction_applied(self):
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0]),
counts=np.array([10.0, 20.0]),
dead_time=0.1,
)
corrected = spectrum.apply_dead_time_correction()
expected = spectrum.counts / 0.9
np.testing.assert_allclose(corrected.counts, expected, rtol=1e-10)
assert corrected.dead_time == 0.0
def test_live_time_corrected_rate(self):
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0]),
counts=np.array([10.0, 20.0]),
dead_time=0.1,
live_time=10.0,
)
rate = spectrum.live_time_corrected_rate
assert rate is not None
# Total counts / (live_time * (1 - dead_time))
expected = 30.0 / (10.0 * 0.9)
assert np.isclose(rate, expected)
class TestExpSpectrumSerialization:
"""Dict round-trip for ExpSpectrum."""
def test_to_from_dict(self):
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0, 2.0]),
counts=np.array([10.0, 20.0, 30.0]),
dead_time=0.05,
live_time=100.0,
source="Tc99",
run_id="run_042",
date="2025-01-15",
metadata={"detector": "MMC", "temperature": "25 mK"},
)
recovered = ExpSpectrum.from_dict(spectrum.to_dict())
np.testing.assert_allclose(recovered.energies, spectrum.energies)
np.testing.assert_allclose(recovered.counts, spectrum.counts)
assert recovered.dead_time == spectrum.dead_time
assert recovered.live_time == spectrum.live_time
assert recovered.source == spectrum.source
assert recovered.run_id == spectrum.run_id
assert recovered.date == spectrum.date
assert recovered.metadata == spectrum.metadata
+161
View File
@@ -0,0 +1,161 @@
"""Tests for fitter.extractor."""
import numpy as np
from fitter.extractor import (
CWExtractor,
GVAExtractor,
CWExtractionResult,
GVAExtractionResult,
)
class TestCWExtractor:
"""Test CWExtractor."""
def test_extract(self):
from beta_spectrum import SpectrumConfig
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
)
extractor = CWExtractor(config)
energies_keV = np.linspace(0.01, 0.29, 50)
measured = np.linspace(10.0, 0.1, 50)
errors = np.sqrt(measured)
model = np.linspace(9.5, 0.09, 50)
result = extractor.extract(
measured_counts=measured,
measured_errors=errors,
energies_keV=energies_keV,
model_counts=model,
endpoint_keV=294.0,
)
assert isinstance(result, CWExtractionResult)
assert len(result.cw_values) == 50
assert len(result.energies_keV) == 50
assert np.isclose(result.endpoint_keV, 294.0)
assert np.all(np.isfinite(result.cw_values))
def test_extract_with_zero_model(self):
from beta_spectrum import SpectrumConfig
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
)
extractor = CWExtractor(config)
energies_keV = np.linspace(0.01, 0.29, 50)
measured = np.ones(50) * 5.0
errors = np.ones(50) * 2.0
model = np.zeros(50) # All zeros
result = extractor.extract(
measured_counts=measured,
measured_errors=errors,
energies_keV=energies_keV,
model_counts=model,
endpoint_keV=294.0,
)
# C(W) should be 1.0 where model is zero (default)
assert np.all(result.cw_values == 1.0)
class TestGVAExtractor:
"""Test GVAExtractor."""
def test_extract_from_curve_basic(self):
energies_W = np.linspace(1.1, 1.57, 100)
endpoint_W = 1.575 # ~294 keV
cw_values = np.ones_like(energies_W) # C(W) = 1 for allowed
result = GVAExtractor.extract_from_curve(
cw_values=cw_values,
energies_W=energies_W,
endpoint_W=endpoint_W,
)
assert isinstance(result, GVAExtractionResult)
assert np.isclose(result.gv_eff, 1.0)
assert result.chi2_per_dof >= 0
def test_extract_from_curve_with_slope(self):
energies_W = np.linspace(1.1, 1.57, 100)
endpoint_W = 1.575
# C(W) with slight slope
cw_values = 1.0 + 0.1 * (energies_W - endpoint_W)
result = GVAExtractor.extract_from_curve(
cw_values=cw_values,
energies_W=energies_W,
endpoint_W=endpoint_W,
)
assert isinstance(result, GVAExtractionResult)
# g_A should be non-zero due to slope
assert abs(result.ga_eff) > 0
def test_extract_from_curve_insufficient_data(self):
energies_W = np.array([1.0, 1.5])
endpoint_W = 1.575
cw_values = np.array([1.0, 1.0])
result = GVAExtractor.extract_from_curve(
cw_values=cw_values,
energies_W=energies_W,
endpoint_W=endpoint_W,
)
assert isinstance(result, GVAExtractionResult)
assert result.chi2_per_dof == 0.0
def test_gva_summary(self):
result = GVAExtractionResult(
gv_eff=1.0,
gv_error=0.01,
ga_eff=0.5,
ga_error=0.02,
correlation=-0.3,
chi2_per_dof=1.2,
)
summary = result.summary()
assert "g_V" in summary
assert "g_A" in summary
assert "1.0" in summary
assert "0.5" in summary
assert "1.2" in summary
class TestCWExtractionResult:
"""Test CWExtractionResult methods."""
def test_kurie_plot(self):
energies_W = np.linspace(1.1, 1.57, 100)
endpoint_W = 1.575
result = CWExtractionResult(
energies_W=energies_W,
energies_keV=energies_W * 510.998950 - 510.998950,
cw_values=np.ones_like(energies_W),
cw_errors=np.zeros_like(energies_W),
flux=np.ones_like(energies_W) * 10.0,
flux_errors=np.ones_like(energies_W) * 3.0,
endpoint_W=endpoint_W,
endpoint_keV=294.0,
)
E_keV, kurie = result.kurie_plot()
assert len(E_keV) > 0
assert len(kurie) > 0
assert np.all(np.isfinite(kurie))
assert np.all(kurie >= 0)
+176
View File
@@ -0,0 +1,176 @@
"""Tests for fitter.fit_engine."""
import numpy as np
from fitter.fit_engine import SpectrumFitter, FitConfig
from fitter.model import SpectrumModel
class TestFitConfig:
"""Test FitConfig defaults."""
def test_defaults(self):
config = FitConfig()
assert config.method == "trf"
assert config.max_nfev == 1000
assert config.loss == "linear"
class TestSpectrumFitter:
"""Test SpectrumFitter."""
def _make_fitter(self):
"""Create a simple fitter with synthetic data."""
# Create a simple linear model
def linear_model(energies_keV, norm, bg):
return norm * (1.0 - energies_keV / 100.0) + bg
# Synthetic data
energies = np.linspace(0.1, 99.9, 200)
counts = 10.0 * (1.0 - energies / 100.0) + 2.0
counts += np.random.RandomState(42).normal(0, 0.5, len(energies))
# Mock model
class MockModel:
def evaluate(self, energies_keV, norm, bg):
return linear_model(energies_keV, norm, bg)
model = MockModel()
fitter = SpectrumFitter(
model, energies, counts, exp_errors=np.ones_like(counts) * 0.5
)
return fitter
def test_fit_basic(self):
fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0], param_names=["norm", "bg"])
assert result.success
assert result.n_points == 200
assert result.n_free == 2
assert result.chi2 > 0
assert result.chi2_per_dof > 0
assert len(result.parameters) == 2
def test_fit_with_bounds(self):
fitter = self._make_fitter()
result = fitter.fit(
x0=[10.0, 2.0],
bounds=([1.0, 0.0], [20.0, 10.0]),
)
assert result.success
def test_fit_returns_residuals(self):
fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0])
assert len(result.residuals) == 200
assert len(result.model_values) == 200
def test_parameters_property(self):
fitter = self._make_fitter()
fitter.fit(x0=[10.0, 2.0])
params = fitter.parameters
assert len(params) == 2
def test_covariance_property(self):
fitter = self._make_fitter()
fitter.fit(x0=[10.0, 2.0])
cov = fitter.covariance
assert cov.shape == (2, 2)
def test_chi2_per_dof(self):
fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0])
assert result.chi2_per_dof == result.chi2 / result.n_free
def test_p_value(self):
fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0])
assert 0.0 <= result.p_value <= 1.0
def test_parameters_with_errors(self):
fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0])
params = result.parameters_with_errors
assert len(params) == 2
for name, (val, err) in params.items():
assert isinstance(val, float)
assert isinstance(err, float)
assert err >= 0
def test_correlation_matrix(self):
fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0])
corr = result.correlation_matrix
assert corr.shape == (2, 2)
# Diagonal should be 1
np.testing.assert_allclose(np.diag(corr), 1.0)
def test_summary(self):
fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0], param_names=["norm", "bg"])
summary = result.summary(param_names=["norm", "bg"])
assert "norm" in summary
assert "bg" in summary
assert "Fit Results" in summary
def test_profile_likelihood(self):
fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0])
param_vals, chi2_vals, norm_vals = result.profile_likelihood(0, n_points=10)
assert len(param_vals) == 10
assert len(chi2_vals) == 10
assert len(norm_vals) == 10
assert np.min(norm_vals) >= 0 # Normalized χ² should be >= 0
def test_confidence_interval(self):
fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0])
ci = result.confidence_interval(0, confidence=0.95)
assert len(ci) == 2
assert ci[0] <= ci[1]
class TestSpectrumFitterWithRealModel:
"""Test SpectrumFitter with a real SpectrumModel."""
def test_fit_with_real_model(self):
from beta_spectrum import BetaSpectrum, SpectrumConfig
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
detector_model="gaussian",
detector_sigma_a_keV=0.5,
detector_n_channels=200,
)
spectrum = BetaSpectrum.from_config(config)
detector = BetaSpectrum.create_detector_from_config(config)
model = SpectrumModel(spectrum, detector)
# Generate synthetic data from model
energies_keV = detector.channel_energies
true_norm = 5.0
true_bg = 0.1
counts = model.evaluate(energies_keV, true_norm, true_bg)
counts = np.maximum(counts, 0) # Clip negatives
counts += np.random.RandomState(42).normal(
0, np.sqrt(np.maximum(counts, 1)), len(energies_keV)
)
counts = np.maximum(counts, 0)
fitter = SpectrumFitter(
model,
energies_keV,
counts,
exp_errors=np.sqrt(np.maximum(counts, 1)),
)
result = fitter.fit(x0=[1.0, 0.0], param_names=["norm", "bg"])
assert result.success
# Fit should complete without error
assert len(result.parameters) == 2
+119
View File
@@ -0,0 +1,119 @@
"""Tests for fitter.model."""
import numpy as np
import pytest
from beta_spectrum import BetaSpectrum, SpectrumConfig
from fitter.model import SpectrumModel
class TestSpectrumModel:
"""Test SpectrumModel."""
def _make_model(self, with_detector: bool = False):
"""Create a test SpectrumModel."""
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
)
spectrum = BetaSpectrum.from_config(config)
detector = None
if with_detector:
detector = BetaSpectrum.create_detector_from_config(
SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
detector_model="gaussian",
detector_sigma_a_keV=1.0,
detector_n_channels=100,
)
)
model = SpectrumModel(spectrum, detector)
return model, config
def test_evaluate_unconvolved(self):
model, config = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50)
values = model.evaluate_unconvolved(energies_keV)
assert len(values) == 50
assert np.all(np.isfinite(values))
assert np.all(values >= 0)
def test_evaluate_no_detector(self):
model, config = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50)
values = model.evaluate(energies_keV, normalization=1.0, background=0.0)
assert len(values) == 50
assert np.all(np.isfinite(values))
def test_evaluate_with_normalization(self):
model, config = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50)
values1 = model.evaluate(energies_keV, normalization=1.0, background=0.0)
values2 = model.evaluate(energies_keV, normalization=2.0, background=0.0)
np.testing.assert_allclose(values2, values1 * 2.0)
def test_evaluate_with_background(self):
model, config = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50)
values1 = model.evaluate(energies_keV, normalization=1.0, background=0.0)
values2 = model.evaluate(energies_keV, normalization=1.0, background=5.0)
np.testing.assert_allclose(values2, values1 + 5.0)
def test_convolve_requires_detector(self):
model, _ = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50)
with pytest.raises(RuntimeError):
model.convolve(energies_keV)
def test_convolve_with_detector(self):
model, _ = self._make_model(with_detector=True)
energies_keV = model.detector_response.channel_energies
values = model.convolve(energies_keV)
assert len(values) == model.detector_response.n_channels
assert np.all(np.isfinite(values))
# Convolution can produce small negative values due to numerical effects
# — check that most values are non-negative
assert np.sum(values >= 0) > len(values) * 0.9
def test_default_params(self):
from exp_data.spectrum import ExpSpectrum
energies = np.linspace(0, 100, 200)
counts = 50 * np.exp(-0.5 * ((energies - 50) / 5) ** 2) + 10
exp_spec = ExpSpectrum(energies=energies, counts=counts)
norm, bg = SpectrumModel.default_params(exp_spec)
assert norm > 0
assert bg >= 0
def test_default_bounds(self):
lower, upper = SpectrumModel.default_bounds(100)
assert len(lower) == 2
assert len(upper) == 2
assert lower[0] < upper[0]
assert lower[1] < upper[1]
class TestSpectrumModelKeVToW:
"""Test energy unit conversion in SpectrumModel."""
def test_keV_to_W(self):
model = SpectrumModel(None) # type: ignore
# 0 keV → W = 1.0
W = model._keV_to_W(np.array([0.0]))
assert np.isclose(W[0], 1.0)
# 511 keV → W = 2.0
W = model._keV_to_W(np.array([510.998950]))
assert np.isclose(W[0], 2.0)
# 294 keV → W ≈ 1.575
W = model._keV_to_W(np.array([294.0]))
assert np.isclose(W[0], 294.0 / 510.998950 + 1.0)
+100
View File
@@ -0,0 +1,100 @@
"""Tests for fitter.result."""
import numpy as np
from fitter.result import AnalysisResult, FitSummary
class MockFitResult:
"""Mock FitResult for testing AnalysisResult."""
def __init__(self):
self.success = True
self.chi2 = 12.5
self.n_points = 100
self.n_free = 3
self.parameters = np.array([5.0, 0.1, 2.0])
self.covariance = np.array(
[
[0.01, 0.0, 0.0],
[0.0, 0.001, 0.0],
[0.0, 0.0, 0.04],
]
)
self.model_values = np.ones(100)
self.residuals = np.zeros(100)
self.message = "Optimization terminated successfully."
@property
def chi2_per_dof(self):
return self.chi2 / (self.n_points - self.n_free)
@property
def p_value(self):
from scipy.stats import chi2
return 1.0 - chi2.cdf(self.chi2, self.n_points - self.n_free)
@property
def parameters_with_errors(self):
errors = np.sqrt(np.diag(self.covariance))
return {
f"p{i}": (float(self.parameters[i]), float(errors[i]))
for i in range(len(self.parameters))
}
class TestFitSummary:
"""Test FitSummary."""
def test_str(self):
summary = FitSummary(
nuclide="Tc99",
converged=True,
chi2_per_dof=1.234,
p_value=0.0567,
parameters={"norm": (5.0, 0.1), "bg": (0.1, 0.01)},
)
text = str(summary)
assert "Tc99" in text
assert "1.234" in text
assert "5.0" in text
assert "Converged: True" in text
class TestAnalysisResult:
"""Test AnalysisResult."""
def test_summary(self):
mock = MockFitResult()
result = AnalysisResult(
fit_result=mock,
nuclide="Tc99",
metadata={"param_names": ["norm", "bg", "endpoint"]},
)
summary = result.summary
assert summary.nuclide == "Tc99"
assert summary.converged is True
assert summary.chi2_per_dof > 0
def test_to_dict(self):
mock = MockFitResult()
result = AnalysisResult(
fit_result=mock,
nuclide="Tc99",
gv_eff=0.97,
ga_eff=0.57,
endpoint_keV=294.0,
)
d = result.to_dict()
assert d["nuclide"] == "Tc99"
assert d["gv_eff"] == 0.97
assert d["ga_eff"] == 0.57
assert d["endpoint_keV"] == 294.0
assert d["converged"] is True
def test_empty_result(self):
mock = MockFitResult()
result = AnalysisResult(fit_result=mock)
summary = result.summary
assert summary.nuclide == ""
-675
View File
@@ -1,675 +0,0 @@
# tests/test_cw_extractor.py — Tests for C(W) shape factor extraction
"""
Why test this?
--------------
The C(W) extractor is the core scientific tool for extracting the shape factor
from measured beta spectra. It must correctly handle:
1. Energy unit conversions (keV ↔ m_e units)
2. Theoretical factor computation (Fermi, screening, radiative, etc.)
3. Normalization of extracted C(W)
4. Kurie plot endpoint determination
5. g_V, g_A extraction from C(W) data
We test with synthetic data where the true answer is known, so we can verify
the extraction recovers the correct values.
"""
import numpy as np
import pytest
from beta_spectrum import (
BetaSpectrum,
CWExtractor,
CWExtractionResult,
GVAExtractionResult,
SpectrumConfig,
T_to_W,
W_to_T,
)
class TestCWExtractionBasic:
"""Basic C(W) extraction tests."""
def _make_synthetic_data(
self,
endpoint_keV: float = 294.0,
n_points: int = 100,
seed: int = 42,
):
"""Create synthetic beta spectrum with C(W) = 1 (allowed decay)."""
np.random.seed(seed)
# Energy grid
energies_keV = np.linspace(5, endpoint_keV * 0.98, n_points)
# Build theoretical spectrum with C(W) = 1
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=endpoint_keV / 1000.0,
transition_type="F1",
use_screening=True,
use_exchange=True,
use_finite_size=True,
use_radiative=True,
)
spectrum = BetaSpectrum.from_config(config)
W, E_MeV = spectrum.get_energy_grid(config)
# Interpolate to our energy grid
from scipy.interpolate import interp1d
flux_interp = interp1d(
E_MeV * 1000, spectrum(W), kind="linear", fill_value=0.0, bounds_error=False
)
flux_theory = flux_interp(energies_keV)
# Add noise (Poisson-like)
noise_std = np.sqrt(np.maximum(flux_theory, 1e-10))
flux = flux_theory + np.random.normal(0, noise_std, len(flux_theory))
flux = np.maximum(flux, 0.0) # Flux cannot be negative
flux_errors = np.sqrt(np.maximum(flux, 1e-10))
return energies_keV, flux, flux_errors, config
def test_CW_extraction_allowed_decay(self):
"""For allowed decay (C(W)=1), extracted C(W) should be ~1."""
energies_keV, flux, flux_errors, config = self._make_synthetic_data()
extractor = CWExtractor(config, flux, energies_keV, flux_errors)
result = extractor.extract_CW(endpoint_keV=294.0)
# C(W) should be close to 1 in mid-energy region
mid_mask = (energies_keV > 50) & (energies_keV < 200)
if np.any(mid_mask):
avg_cw = np.mean(result.cw_values[mid_mask])
assert (
abs(avg_cw - 1.0) < 0.3
), f"Allowed decay C(W) should be ~1, got {avg_cw}"
def test_CW_extraction_preserves_endpoint(self):
"""Extracted C(W) should report correct endpoint."""
energies_keV, flux, flux_errors, config = self._make_synthetic_data()
extractor = CWExtractor(config, flux, energies_keV, flux_errors)
result = extractor.extract_CW(endpoint_keV=294.0)
assert (
abs(result.endpoint_keV - 294.0) < 1.0
), f"Endpoint should be ~294 keV, got {result.endpoint_keV}"
def test_CW_extraction_errors_positive(self):
"""C(W) uncertainties should be positive."""
energies_keV, flux, flux_errors, config = self._make_synthetic_data()
extractor = CWExtractor(config, flux, energies_keV, flux_errors)
result = extractor.extract_CW(endpoint_keV=294.0)
assert np.all(result.cw_errors > 0), "All errors should be positive"
def test_CW_extraction_clipped(self):
"""C(W) values should be clipped to [0.01, 10.0]."""
energies_keV, flux, flux_errors, config = self._make_synthetic_data()
extractor = CWExtractor(config, flux, energies_keV, flux_errors)
result = extractor.extract_CW(endpoint_keV=294.0)
assert np.all(result.cw_values >= 0.01), "C(W) should be >= 0.01"
assert np.all(result.cw_values <= 10.0), "C(W) should be <= 10.0"
class TestCWParametrization:
"""Test C(W) parametrization fitting."""
def _make_CW_data(
self,
endpoint_keV: float = 294.0,
n_points: int = 60,
seed: int = 42,
):
"""Create synthetic C(W) data with known parametrization."""
np.random.seed(seed)
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=endpoint_keV / 1000.0,
transition_type="F1",
use_screening=True,
use_exchange=True,
use_finite_size=True,
use_radiative=True,
)
# Generate C(W) values with known form
W = np.linspace(T_to_W(0.05), T_to_W(endpoint_keV * 0.95), n_points)
E_keV = np.array([W_to_T(w) for w in W])
# Linear C(W): C(W) = 1.0 + 0.5*(W-1)
true_a0, true_a1 = 1.0, 0.5
cw_true = true_a0 + true_a1 * (W - 1.0)
cw_noise = cw_true + np.random.normal(0, 0.05, len(W))
# C(W) = flux / (C0 * phase_space * Fermi * corrections * (W0-W)^2)
# So: flux = C(W) * C0 * phase_space * Fermi * corrections * (W0-W)^2
p = np.sqrt(np.maximum(W**2 - 1.0, 0.0))
from beta_spectrum.components.fermi import FermiFunction
F = FermiFunction(Z=config.Z_parent, A=config.A_number)(W)
C0 = 1.0 # Normalization
endpoint_factor = (T_to_W(endpoint_keV / 1000.0) - W) ** 2
flux_synthetic = cw_noise * C0 * p * W * F * endpoint_factor
flux_synthetic = np.maximum(flux_synthetic, 0)
flux_errors = np.sqrt(np.maximum(flux_synthetic, 1.0))
return E_keV, flux_synthetic, flux_errors, config, true_a0, true_a1
def test_constant_parametrization(self):
"""Fit C(W) = a₀ with known constant value."""
np.random.seed(42)
energies_keV = np.linspace(10, 250, 50)
W = np.array([T_to_W(e / 1000.0) for e in energies_keV])
cw_true = np.full_like(W, 1.0)
cw = cw_true + np.random.normal(0, 0.05, len(W))
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
transition_type="F1",
use_screening=True,
use_exchange=True,
use_finite_size=True,
use_radiative=True,
)
extractor = CWExtractor(
config, cw, energies_keV, flux_errors=np.ones_like(cw) * 0.05
)
cw_result = CWExtractionResult(
energies_W=W.copy(),
energies_keV=energies_keV.copy(),
cw_values=cw.copy(),
cw_errors=np.ones_like(cw) * 0.05,
flux=cw.copy(),
flux_errors=np.ones_like(cw) * 0.05,
endpoint_W=T_to_W(0.294),
endpoint_keV=294.0,
)
result = extractor.fit_CW_parametrization(cw_result, parametrization="constant")
assert (
abs(result.parameters[0] - 1.0) < 0.1
), f"Constant C(W) should be ~1, got {result.parameters[0]}"
def test_linear_parametrization(self):
"""Fit C(W) = a₀ + a₁·(W-1) with known coefficients."""
np.random.seed(42)
energies_keV = np.linspace(10, 250, 50)
W = np.array([T_to_W(e / 1000.0) for e in energies_keV])
true_a0, true_a1 = 1.0, 0.5
cw = true_a0 + true_a1 * (W - 1.0) + np.random.normal(0, 0.05, len(W))
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
transition_type="F1",
use_screening=True,
use_exchange=True,
use_finite_size=True,
use_radiative=True,
)
extractor = CWExtractor(
config, cw, energies_keV, flux_errors=np.ones_like(cw) * 0.05
)
cw_result = CWExtractionResult(
energies_W=W.copy(),
energies_keV=energies_keV.copy(),
cw_values=cw.copy(),
cw_errors=np.ones_like(cw) * 0.05,
flux=cw.copy(),
flux_errors=np.ones_like(cw) * 0.05,
endpoint_W=T_to_W(0.294),
endpoint_keV=294.0,
)
result = extractor.fit_CW_parametrization(cw_result, parametrization="linear")
assert (
abs(result.parameters[0] - true_a0) < 0.15
), f"a₀: expected {true_a0}, got {result.parameters[0]}"
assert (
abs(result.parameters[1] - true_a1) < 0.5
), f"a₁: expected {true_a1}, got {result.parameters[1]}"
def test_quadratic_parametrization(self):
"""Fit C(W) = a₀ + a₁·(W-1) + a₂·(W-1)² with known coefficients."""
np.random.seed(42)
energies_keV = np.linspace(10, 250, 60)
W = np.array([T_to_W(e / 1000.0) for e in energies_keV])
true_a0, true_a1, true_a2 = 1.0, 0.3, -0.1
cw = true_a0 + true_a1 * (W - 1.0) + true_a2 * (W - 1.0) ** 2
cw += np.random.normal(0, 0.03, len(W))
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
transition_type="F1",
use_screening=True,
use_exchange=True,
use_finite_size=True,
use_radiative=True,
)
extractor = CWExtractor(
config, cw, energies_keV, flux_errors=np.ones_like(cw) * 0.03
)
cw_result = CWExtractionResult(
energies_W=W.copy(),
energies_keV=energies_keV.copy(),
cw_values=cw.copy(),
cw_errors=np.ones_like(cw) * 0.03,
flux=cw.copy(),
flux_errors=np.ones_like(cw) * 0.03,
endpoint_W=T_to_W(0.294),
endpoint_keV=294.0,
)
result = extractor.fit_CW_parametrization(
cw_result, parametrization="quadratic"
)
assert abs(result.parameters[0] - true_a0) < 0.15
assert abs(result.parameters[1] - true_a1) < 0.5
assert abs(result.parameters[2] - true_a2) < 0.5
def test_invalid_parametrization(self):
"""Invalid parametrization should raise ValueError."""
np.random.seed(42)
energies_keV = np.linspace(10, 250, 30)
W = np.array([T_to_W(e / 1000.0) for e in energies_keV])
cw = np.ones_like(W)
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
)
extractor = CWExtractor(config, cw, energies_keV)
cw_result = CWExtractionResult(
energies_W=W.copy(),
energies_keV=energies_keV.copy(),
cw_values=cw.copy(),
cw_errors=np.ones_like(cw) * 0.05,
flux=cw.copy(),
flux_errors=np.ones_like(cw) * 0.05,
endpoint_W=T_to_W(0.294),
endpoint_keV=294.0,
)
with pytest.raises(ValueError, match="Unknown parametrization"):
extractor.fit_CW_parametrization(cw_result, parametrization="invalid")
class TestGVAExtraction:
"""Test g_V, g_A extraction from C(W) data."""
def test_gV_gA_pure_fermi(self):
"""For pure Fermi transition, g_A ≈ 0."""
np.random.seed(42)
energies_keV = np.linspace(10, 250, 50)
W = np.array([T_to_W(e / 1000.0) for e in energies_keV])
# Pure Fermi: C(W) = g_V² (constant)
true_gV = 0.5
cw = true_gV**2 + np.random.normal(0, 0.02, len(W))
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
transition_type="F1",
use_screening=True,
use_exchange=True,
use_finite_size=True,
use_radiative=True,
)
extractor = CWExtractor(
config, cw, energies_keV, flux_errors=np.ones_like(cw) * 0.02
)
cw_result = CWExtractionResult(
energies_W=W.copy(),
energies_keV=energies_keV.copy(),
cw_values=cw.copy(),
cw_errors=np.ones_like(cw) * 0.02,
flux=cw.copy(),
flux_errors=np.ones_like(cw) * 0.02,
endpoint_W=T_to_W(0.294),
endpoint_keV=294.0,
)
result = extractor.fit_gV_gA(cw_result, M_F=1.0, M_GT=0.0)
assert isinstance(result, GVAExtractionResult)
assert (
abs(result.g_V - true_gV) < 0.15
), f"g_V: expected {true_gV}, got {result.g_V}"
def test_gV_gA_pure_gt(self):
"""For pure Gamow-Teller, g_V ≈ 0."""
np.random.seed(42)
energies_keV = np.linspace(10, 250, 50)
W = np.array([T_to_W(e / 1000.0) for e in energies_keV])
# Pure GT: C(W) = g_A² (constant)
true_gA = 0.8
cw = true_gA**2 + np.random.normal(0, 0.02, len(W))
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
transition_type="F1",
use_screening=True,
use_exchange=True,
use_finite_size=True,
use_radiative=True,
)
extractor = CWExtractor(
config, cw, energies_keV, flux_errors=np.ones_like(cw) * 0.02
)
cw_result = CWExtractionResult(
energies_W=W.copy(),
energies_keV=energies_keV.copy(),
cw_values=cw.copy(),
cw_errors=np.ones_like(cw) * 0.02,
flux=cw.copy(),
flux_errors=np.ones_like(cw) * 0.02,
endpoint_W=T_to_W(0.294),
endpoint_keV=294.0,
)
result = extractor.fit_gV_gA(cw_result, M_F=0.0, M_GT=1.0)
assert isinstance(result, GVAExtractionResult)
assert (
abs(result.g_A - true_gA) < 0.15
), f"g_A: expected {true_gA}, got {result.g_A}"
def test_gV_gA_mixed_transition(self):
"""For mixed transition, extract both g_V and g_A."""
np.random.seed(42)
energies_keV = np.linspace(10, 250, 50)
W = np.array([T_to_W(e / 1000.0) for e in energies_keV])
# Mixed: C(W) = g_V² + g_A² (constant for simplicity)
true_gV, true_gA = 0.4, 0.6
cw = true_gV**2 + true_gA**2 + np.random.normal(0, 0.02, len(W))
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
transition_type="F1",
use_screening=True,
use_exchange=True,
use_finite_size=True,
use_radiative=True,
)
extractor = CWExtractor(
config, cw, energies_keV, flux_errors=np.ones_like(cw) * 0.02
)
cw_result = CWExtractionResult(
energies_W=W.copy(),
energies_keV=energies_keV.copy(),
cw_values=cw.copy(),
cw_errors=np.ones_like(cw) * 0.02,
flux=cw.copy(),
flux_errors=np.ones_like(cw) * 0.02,
endpoint_W=T_to_W(0.294),
endpoint_keV=294.0,
)
result = extractor.fit_gV_gA(cw_result, M_F=1.0, M_GT=1.0)
assert isinstance(result, GVAExtractionResult)
# Check that sum of squares is correct
reconstructed = result.g_V**2 + result.g_A**2
expected = true_gV**2 + true_gA**2
assert (
abs(reconstructed - expected) < 0.15
), f"g_V²+g_A²: expected {expected}, got {reconstructed}"
def test_gV_gA_summary(self):
"""summary() should return a non-empty string with key info."""
np.random.seed(42)
energies_keV = np.linspace(10, 250, 50)
W = np.array([T_to_W(e / 1000.0) for e in energies_keV])
cw = np.full_like(W, 0.5)
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
)
extractor = CWExtractor(config, cw, energies_keV)
cw_result = CWExtractionResult(
energies_W=W.copy(),
energies_keV=energies_keV.copy(),
cw_values=cw.copy(),
cw_errors=np.ones_like(cw) * 0.02,
flux=cw.copy(),
flux_errors=np.ones_like(cw) * 0.02,
endpoint_W=T_to_W(0.294),
endpoint_keV=294.0,
)
result = extractor.fit_gV_gA(cw_result, M_F=1.0, M_GT=0.0)
summary = result.summary()
assert "g_V" in summary
assert "g_A" in summary
assert "χ²" in summary
assert "p-value" in summary
class TestKurieAnalysis:
"""Test Kurie plot analysis."""
def test_kurie_analysis_allowed_decay(self):
"""Kurie analysis on allowed decay should produce reasonable C(W)."""
np.random.seed(42)
energies_keV = np.linspace(5, 280, 80)
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
transition_type="F1",
use_screening=True,
use_exchange=True,
use_finite_size=True,
use_radiative=True,
)
spectrum = BetaSpectrum.from_config(config)
W, E_MeV = spectrum.get_energy_grid(config)
flux_theory = spectrum(W)
# Interpolate to our energy grid
from scipy.interpolate import interp1d
flux_interp = interp1d(
E_MeV * 1000, flux_theory, kind="linear", fill_value=0.0, bounds_error=False
)
flux = flux_interp(energies_keV)
# Add noise
flux += np.random.normal(0, 0.02 * np.max(flux), len(flux))
extractor = CWExtractor(config, flux, energies_keV)
cw_result, fit_result = extractor.kurie_analysis(endpoint_keV=294.0)
assert isinstance(cw_result, CWExtractionResult)
assert cw_result.fit_result is not None
assert np.all(cw_result.cw_values > 0)
def test_kurie_plot_values(self):
"""Kurie plot should produce valid values."""
np.random.seed(42)
energies_keV = np.linspace(10, 250, 50)
W = np.array([T_to_W(e / 1000.0) for e in energies_keV])
cw = np.ones_like(W) * 1.0
cw_result = CWExtractionResult(
energies_W=W.copy(),
energies_keV=energies_keV.copy(),
cw_values=cw.copy(),
cw_errors=np.ones_like(cw) * 0.05,
flux=cw.copy(),
flux_errors=np.ones_like(cw) * 0.05,
endpoint_W=T_to_W(0.294),
endpoint_keV=294.0,
)
E_plot, kurie = cw_result.kurie_plot()
assert len(E_plot) == len(energies_keV)
assert len(kurie) == len(energies_keV)
assert np.all(kurie >= 0), "Kurie values should be non-negative"
class TestEdgeCases:
"""Test edge cases and error handling."""
def test_not_enough_points_for_fit(self):
"""Fitting with too few points should raise ValueError."""
np.random.seed(42)
energies_keV = np.array([10.0, 20.0, 30.0])
W = np.array([T_to_W(e / 1000.0) for e in energies_keV])
cw = np.ones_like(W)
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
)
extractor = CWExtractor(config, cw, energies_keV)
cw_result = CWExtractionResult(
energies_W=W.copy(),
energies_keV=energies_keV.copy(),
cw_values=cw.copy(),
cw_errors=np.ones_like(cw) * 0.05,
flux=cw.copy(),
flux_errors=np.ones_like(cw) * 0.05,
endpoint_W=T_to_W(0.294),
endpoint_keV=294.0,
)
with pytest.raises(ValueError, match="Not enough data points"):
extractor.fit_CW_parametrization(cw_result, parametrization="quadratic")
def test_not_enough_points_for_gV_gA(self):
"""g_V/g_A fit with too few points should raise ValueError."""
np.random.seed(42)
energies_keV = np.array([10.0, 20.0, 30.0])
W = np.array([T_to_W(e / 1000.0) for e in energies_keV])
cw = np.ones_like(W)
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
)
extractor = CWExtractor(config, cw, energies_keV)
cw_result = CWExtractionResult(
energies_W=W.copy(),
energies_keV=energies_keV.copy(),
cw_values=cw.copy(),
cw_errors=np.ones_like(cw) * 0.05,
flux=cw.copy(),
flux_errors=np.ones_like(cw) * 0.05,
endpoint_W=T_to_W(0.294),
endpoint_keV=294.0,
)
with pytest.raises(ValueError, match="Not enough data points"):
extractor.fit_gV_gA(cw_result, M_F=1.0, M_GT=1.0)
def test_default_uncertainties(self):
"""Default uncertainties should be sqrt(flux)."""
np.random.seed(42)
energies_keV = np.linspace(10, 250, 30)
flux = np.array(
[
100.0,
200.0,
150.0,
180.0,
120.0,
90.0,
80.0,
70.0,
60.0,
50.0,
45.0,
40.0,
35.0,
30.0,
25.0,
20.0,
18.0,
15.0,
12.0,
10.0,
8.0,
7.0,
6.0,
5.0,
4.0,
3.0,
2.0,
1.5,
1.0,
0.5,
]
)
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
)
extractor = CWExtractor(config, flux, energies_keV)
expected_unc = np.sqrt(np.maximum(flux, 1.0))
np.testing.assert_allclose(extractor.flux_errors, expected_unc)
+1 -1
View File
@@ -18,7 +18,7 @@ We test:
import numpy as np
from beta_spectrum import CurveFitter, FitConfig
from fitter.fit_engine import CurveFitter, FitConfig
class TestBasicFitting:
+1 -1
View File
@@ -30,10 +30,10 @@ import pytest
from beta_spectrum import (
BetaSpectrum,
BetaSpectrumAnalyzer,
BranchConfig,
SpectrumConfig,
)
from beta_spectrum.visualize import BetaSpectrumAnalyzer
from beta_spectrum.utils import T_to_W
# ---------------------------------------------------------------------------