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:
@@ -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
|
||||
Reference in New Issue
Block a user