refactor: remove exp_data experimental data package

Detector data handling, energy calibration, peak fitting and
instrumental corrections leave the project scope (theory-only
spectrum shape calculation). Removes the package, its tests, the
exp pipeline integration test, the calibration notebook, and the
ExpSpectrum fixtures from the shared conftest.
This commit is contained in:
2026-10-05 14:31:56 +03:00
parent 67de4e3baa
commit abde581146
20 changed files with 0 additions and 4294 deletions
-44
View File
@@ -3,9 +3,6 @@
import numpy as np
import pytest
from exp_data.spectrum import ExpSpectrum
from exp_data.calibration import CalibrationResult
@pytest.fixture()
def W_low():
@@ -38,47 +35,6 @@ def large_W():
return np.linspace(1.002, 4.9, 1000)
@pytest.fixture()
def synthetic_spectrum():
"""Create a synthetic ExpSpectrum (4096 channels)."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 1000 + np.random.poisson(5, n_channels)
return ExpSpectrum(
energies=energies,
counts=counts,
source="Tc99",
run_id="synthetic",
)
@pytest.fixture()
def calibration_result():
"""Create a synthetic CalibrationResult (linear, E = 0.4 + 0.1*ch)."""
coeffs = np.array([0.1, 0.4]) # polyfit order: [slope, intercept]
cov = np.eye(2) * 1e-6
return CalibrationResult(
coefficients=coeffs,
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=cov,
)
@pytest.fixture()
def full_spectrum():
"""Create a full ExpSpectrum with all fields populated."""
return ExpSpectrum(
energies=np.arange(4096, dtype=np.float64),
counts=np.exp(-np.arange(4096) / 500) * 1000 + np.random.poisson(5, 4096),
source="Tc99",
run_id="run_042",
date="2025-01-15",
dead_time=0.05,
live_time=3420.0,
metadata={
"calibration": {"type": "linear", "coefficients": [0.1, 0.4]},
"livetime": {"fraction": 0.95, "method": "pulser"},
},
)
-55
View File
@@ -1,55 +0,0 @@
"""Shared fixtures for exp_data tests."""
from __future__ import annotations
import numpy as np
import pytest
from exp_data.spectrum import ExpSpectrum
from exp_data.calibration import CalibrationResult
@pytest.fixture
def synthetic_spectrum():
"""Create a synthetic ExpSpectrum (4096 channels)."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 1000 + np.random.poisson(5, n_channels)
return ExpSpectrum(
energies=energies,
counts=counts,
source="Tc99",
run_id="synthetic",
)
@pytest.fixture
def calibration_result():
"""Create a synthetic CalibrationResult (linear, E = 0.4 + 0.1*ch)."""
coeffs = np.array([0.1, 0.4]) # polyfit order: [slope, intercept]
cov = np.eye(2) * 1e-6
return CalibrationResult(
coefficients=coeffs,
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=cov,
)
@pytest.fixture
def full_spectrum():
"""Create a full ExpSpectrum with all fields populated."""
return ExpSpectrum(
energies=np.arange(4096, dtype=np.float64),
counts=np.exp(-np.arange(4096) / 500) * 1000 + np.random.poisson(5, 4096),
source="Tc99",
run_id="run_042",
date="2025-01-15",
dead_time=0.05,
live_time=3420.0,
metadata={
"calibration": {"type": "linear", "coefficients": [0.1, 0.4]},
"livetime": {"fraction": 0.95, "method": "pulser"},
},
)
-138
View File
@@ -1,138 +0,0 @@
"""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
@@ -1,106 +0,0 @@
"""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
@@ -1,114 +0,0 @@
"""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
-142
View File
@@ -1,142 +0,0 @@
"""Unit tests for the LivetimeDetermination class."""
from __future__ import annotations
import numpy as np
import pytest
from exp_data.spectrum import ExpSpectrum
from exp_data.livetime import LivetimeDetermination, LivetimeResult
class TestLivetimeResult:
def test_basic(self):
result = LivetimeResult(
live_time_fraction=0.95,
real_time_sec=3600.0,
pulser_frequency_hz=100.0,
observed_pulser_rate=95.0,
pulser_peak_center_keV=10.0,
pulser_peak_sigma_keV=0.5,
pulser_peak_chi2=1.2,
)
assert result.live_time_fraction == 0.95
assert result.metadata == {}
def test_metadata_populated(self):
result = LivetimeResult(
live_time_fraction=0.95,
real_time_sec=3600.0,
pulser_frequency_hz=100.0,
observed_pulser_rate=95.0,
pulser_peak_center_keV=10.0,
pulser_peak_sigma_keV=0.5,
pulser_peak_chi2=1.2,
metadata={"pulser_peak_channel": 100, "pulser_fit_success": True},
)
assert result.metadata["pulser_peak_channel"] == 100
class TestLivetimeDetermination:
def test_from_file_requires_positive_frequency(self):
"""Test that zero frequency raises ValueError."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.ones(n_channels) * 10
spectrum = ExpSpectrum(energies=energies, counts=counts)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=0.0,
real_time_sec=3600.0,
)
with pytest.raises(ValueError, match="positive"):
determiner.run()
def test_live_time_clamped_to_1(self):
"""Test that live-time fraction > 1.0 is clamped to 1.0."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10 # background
# Add prominent pulser peak at channel 100
pulser_channel = 100
pulser_peak = 500 * np.exp(-0.5 * ((energies - pulser_channel) / 2.0) ** 2)
counts += pulser_peak.astype(float)
spectrum = ExpSpectrum(energies=energies, counts=counts)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=10.0, # low frequency -> observed rate > frequency
real_time_sec=0.01, # very short time -> high observed rate
)
result = determiner.run()
assert result.live_time_fraction == 1.0
def test_live_time_clamped_to_0(self):
"""Test that live-time fraction < 0 is clamped to 0."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10 # background only, no pulser peak
spectrum = ExpSpectrum(energies=energies, counts=counts)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
result = determiner.run()
# With no pulser peak, the fitter may find a tiny spurious peak
# but the observed rate will be negligible -> live_time ~ 0
assert result.live_time_fraction >= 0.0
assert result.live_time_fraction <= 0.01
def test_pulser_peak_detection(self):
"""Test that LivetimeDetermination correctly identifies a pulser peak."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10 # background
# Add pulser peak at channel 100 (10 keV)
pulser_channel = 100
pulser_peak = 500 * np.exp(-0.5 * ((energies - pulser_channel) / 2.0) ** 2)
counts += pulser_peak.astype(float)
spectrum = ExpSpectrum(
energies=energies,
counts=counts,
source="pulser",
)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
result = determiner.run()
assert result.live_time_fraction > 0
assert result.live_time_fraction <= 1.0
assert result.pulser_peak_center_keV == pytest.approx(pulser_channel, abs=5.0)
def test_result_contains_pulser_info(self):
"""Test that result contains pulser peak info."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10
pulser_channel = 100
pulser_peak = 500 * np.exp(-0.5 * ((energies - pulser_channel) / 2.0) ** 2)
counts += pulser_peak.astype(float)
spectrum = ExpSpectrum(energies=energies, counts=counts, source="pulser")
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
result = determiner.run()
assert result.pulser_peak_center_keV > 0
assert result.pulser_peak_sigma_keV > 0
assert result.pulser_peak_chi2 > 0
assert result.pulser_frequency_hz == 100.0
assert result.real_time_sec == 3600.0
-84
View File
@@ -1,84 +0,0 @@
"""Unit tests for ROOT I/O (skip if no ROOT library available)."""
from __future__ import annotations
import numpy as np
import pytest
from exp_data.spectrum import ExpSpectrum
def _has_root_io():
"""Check if any ROOT I/O library is available."""
try:
import ROOT # noqa: F401
return True
except ImportError:
pass
try:
import uproot # noqa: F401
return True
except ImportError:
return False
@pytest.fixture
def sample_spectrum():
"""Create a sample ExpSpectrum for testing."""
return ExpSpectrum(
energies=np.arange(100, dtype=np.float64),
counts=np.random.poisson(10, 100).astype(np.float64),
source="Tc99",
run_id="test_run",
dead_time=0.05,
live_time=3420.0,
date="2025-01-15",
metadata={
"calibration": {"type": "linear", "coefficients": [0.1, 0.4]},
"livetime": {"fraction": 0.95},
},
)
@pytest.mark.skipif(
not _has_root_io(),
reason="Neither PyROOT nor uproot available",
)
class TestRootIOWriteRead:
def test_round_trip(self, sample_spectrum, tmp_path):
"""Test write and read round-trip."""
from exp_data.root_io import write_spectrum, read_spectrum
root_path = tmp_path / "test.root"
write_spectrum(str(root_path), sample_spectrum)
read_back = read_spectrum(str(root_path))
np.testing.assert_array_equal(read_back.energies, sample_spectrum.energies)
np.testing.assert_array_equal(read_back.counts, sample_spectrum.counts)
def test_scalar_fields(self, sample_spectrum, tmp_path):
"""Test that scalar fields survive round-trip."""
from exp_data.root_io import write_spectrum, read_spectrum
root_path = tmp_path / "test.root"
write_spectrum(str(root_path), sample_spectrum)
read_back = read_spectrum(str(root_path))
assert read_back.dead_time == sample_spectrum.dead_time
assert read_back.source == sample_spectrum.source
assert read_back.run_id == sample_spectrum.run_id
def test_metadata(self, sample_spectrum, tmp_path):
"""Test that metadata survives round-trip."""
from exp_data.root_io import write_spectrum, read_spectrum
root_path = tmp_path / "test.root"
write_spectrum(str(root_path), sample_spectrum)
read_back = read_spectrum(str(root_path))
assert "calibration" in read_back.metadata
assert read_back.metadata["calibration"]["type"] == "linear"
assert read_back.metadata["livetime"]["fraction"] == 0.95
-149
View File
@@ -1,149 +0,0 @@
"""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_zero_error(self):
"""Zero counts produce zero error (not sqrt(1))."""
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0]),
counts=np.array([0.0, 100.0]),
)
assert spectrum.errors[0] == 0.0 # sqrt(0) = 0
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_allowed_for_difference_spectra(self):
"""Negative counts are allowed — they arise in cumulative subtraction.
Difference spectra can have negative counts in channels where the true
count is zero and Poisson noise causes the earlier cumulative to slightly
exceed the later one.
"""
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0]),
counts=np.array([10.0, -5.0]),
)
# Should not raise; errors use abs(counts) for negative values
assert spectrum.errors[1] == np.sqrt(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
-380
View File
@@ -1,380 +0,0 @@
"""Integration tests for the experimental data processing pipeline."""
from __future__ import annotations
import numpy as np
import pytest
from exp_data.spectrum import ExpSpectrum
from exp_data.calibration import CalibrationResult
from exp_data.pipeline import SpectrumPipeline
from exp_data.livetime import LivetimeDetermination
def _has_root_io():
"""Check if any ROOT I/O library is available."""
try:
import ROOT # noqa: F401
return True
except ImportError:
pass
try:
import uproot # noqa: F401
return True
except ImportError:
return False
# --- ExpSpectrum.apply_calibration tests ---
class TestApplyCalibration:
def test_returns_new_spectrum(self, synthetic_spectrum, calibration_result):
"""Test that apply_calibration returns a new ExpSpectrum."""
calibrated = synthetic_spectrum.apply_calibration(calibration_result)
assert calibrated is not synthetic_spectrum
assert calibrated.n_channels == synthetic_spectrum.n_channels
def test_energies_recalibrated(self, synthetic_spectrum, calibration_result):
"""Test that energies are recalibrated via the calibration polynomial."""
calibrated = synthetic_spectrum.apply_calibration(calibration_result)
expected = calibration_result(synthetic_spectrum.energies)
np.testing.assert_allclose(calibrated.energies, expected)
def test_counts_preserved(self, synthetic_spectrum, calibration_result):
"""Test that counts are preserved (copied, not modified)."""
calibrated = synthetic_spectrum.apply_calibration(calibration_result)
np.testing.assert_array_equal(calibrated.counts, synthetic_spectrum.counts)
def test_errors_preserved(self, synthetic_spectrum, calibration_result):
"""Test that errors are preserved."""
calibrated = synthetic_spectrum.apply_calibration(calibration_result)
np.testing.assert_array_equal(calibrated.errors, synthetic_spectrum.errors)
def test_metadata_updated_with_calibration(
self, synthetic_spectrum, calibration_result
):
"""Test that metadata includes calibration info."""
calibrated = synthetic_spectrum.apply_calibration(calibration_result)
assert "calibration" in calibrated.metadata
assert calibrated.metadata["calibration"]["type"] == "linear"
assert (
calibrated.metadata["calibration"]["chi2_per_dof"]
== calibration_result.chi2_per_dof
)
def test_scalar_fields_preserved(self, synthetic_spectrum, calibration_result):
"""Test that all scalar fields are preserved."""
spectrum = ExpSpectrum(
energies=synthetic_spectrum.energies,
counts=synthetic_spectrum.counts,
dead_time=0.05,
live_time=3420.0,
source="Tc99",
run_id="run_042",
date="2025-01-15",
)
calibrated = spectrum.apply_calibration(calibration_result)
assert calibrated.dead_time == 0.05
assert calibrated.live_time == 3420.0
assert calibrated.source == "Tc99"
assert calibrated.run_id == "run_042"
assert calibrated.date == "2025-01-15"
def test_metadata_merged_not_replaced(self, synthetic_spectrum, calibration_result):
"""Test that existing metadata is merged, not replaced."""
spectrum = ExpSpectrum(
energies=synthetic_spectrum.energies,
counts=synthetic_spectrum.counts,
metadata={"existing_key": "existing_value"},
)
calibrated = spectrum.apply_calibration(calibration_result)
assert "existing_key" in calibrated.metadata
assert calibrated.metadata["existing_key"] == "existing_value"
assert "calibration" in calibrated.metadata
# --- LivetimeDetermination tests ---
class TestLivetimeDetermination:
def test_pulser_peak_detection(self):
"""Test that LivetimeDetermination correctly identifies a pulser peak."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10 # background
# Add pulser peak at channel 100 (10 keV)
pulser_channel = 100
pulser_peak = 500 * np.exp(-0.5 * ((energies - pulser_channel) / 2.0) ** 2)
counts += pulser_peak.astype(float)
spectrum = ExpSpectrum(
energies=energies,
counts=counts,
source="pulser",
)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
result = determiner.run()
assert result.live_time_fraction > 0
assert result.live_time_fraction <= 1.0
assert result.pulser_peak_center_keV == pytest.approx(pulser_channel, abs=5.0)
def test_invalid_pulser_frequency(self):
"""Test that zero frequency raises ValueError."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.ones(n_channels) * 10
spectrum = ExpSpectrum(energies=energies, counts=counts)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=0.0,
real_time_sec=3600.0,
)
with pytest.raises(ValueError, match="positive"):
determiner.run()
def test_live_time_clamped_to_1(self):
"""Test that live-time fraction > 1.0 is clamped to 1.0."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10 # background
# Add prominent pulser peak at channel 100
pulser_channel = 100
pulser_peak = 500 * np.exp(-0.5 * ((energies - pulser_channel) / 2.0) ** 2)
counts += pulser_peak.astype(float)
spectrum = ExpSpectrum(energies=energies, counts=counts)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=10.0, # low frequency -> observed rate > frequency
real_time_sec=0.01, # very short time -> high observed rate
)
result = determiner.run()
assert result.live_time_fraction == 1.0
def test_live_time_clamped_to_0(self):
"""Test that live-time fraction < 0 is clamped to 0."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10 # background only, no pulser peak
spectrum = ExpSpectrum(energies=energies, counts=counts)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
result = determiner.run()
# With no pulser peak, the fitter may find a tiny spurious peak
# but the observed rate will be negligible -> live_time ~ 0
assert result.live_time_fraction >= 0.0
assert result.live_time_fraction <= 0.01
# --- Pipeline validation tests ---
class TestPipelineValidation:
def test_calib_chi2_error(self):
"""Test that poor calibration fit (chi2 > 3) produces an error message."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
spectrum = ExpSpectrum(
energies=np.arange(100, dtype=np.float64),
counts=np.ones(100) * 10,
)
bad_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=5.0, # poor fit
n_points=2,
covariance=np.eye(2),
)
messages = pipeline._validate(spectrum, bad_calib, None)
assert any("ERROR" in m for m in messages)
def test_calib_chi2_warning(self):
"""Test that marginal calibration fit (1.5 < chi2 < 3) produces a warning."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
spectrum = ExpSpectrum(
energies=np.arange(100, dtype=np.float64),
counts=np.ones(100) * 10,
)
marginal_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=2.0, # marginal fit
n_points=2,
covariance=np.eye(2),
)
messages = pipeline._validate(spectrum, marginal_calib, None)
assert any("WARNING" in m for m in messages)
assert not any("ERROR" in m for m in messages)
def test_insufficient_counts(self):
"""Test that low counts produce an error."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
spectrum = ExpSpectrum(
energies=np.arange(10, dtype=np.float64),
counts=np.array([1.0] * 10), # only 10 counts total
)
good_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=np.eye(2),
)
messages = pipeline._validate(spectrum, good_calib, None)
assert any("ERROR" in m and "counts" in m for m in messages)
def test_negative_energy(self):
"""Test that negative energies are caught after calibration."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
# Create a spectrum with valid positive energies first
spectrum = ExpSpectrum(
energies=np.array([1.0, 2.0, 3.0, 4.0]),
counts=np.array([10.0] * 4),
)
# Manually set energies to negative (simulating bad calibration)
spectrum.energies = np.array([-1.0, 0.0, 1.0, 2.0])
good_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=np.eye(2),
)
messages = pipeline._validate(spectrum, good_calib, None)
assert any("ERROR" in m and "negative" in m.lower() for m in messages)
def test_non_monotonic_energies(self):
"""Test that non-monotonic energies are caught."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
spectrum = ExpSpectrum(
energies=np.array([0.0, 2.0, 1.0, 3.0]),
counts=np.array([10.0] * 4),
)
good_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=np.eye(2),
)
messages = pipeline._validate(spectrum, good_calib, None)
assert any("ERROR" in m and "monoton" in m for m in messages)
def test_low_live_time_fraction_warning(self):
"""Test that low live-time fraction produces a warning."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
spectrum = ExpSpectrum(
energies=np.arange(100, dtype=np.float64),
counts=np.ones(100) * 10,
)
good_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=np.eye(2),
)
bad_livetime = type(
"MockLivetime",
(),
{
"live_time_fraction": 0.3,
},
)()
messages = pipeline._validate(spectrum, good_calib, bad_livetime)
assert any("WARNING" in m and "live-time" in m.lower() for m in messages)
def test_all_checks_pass(self):
"""Test that good data produces no errors."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
spectrum = ExpSpectrum(
energies=np.arange(100, dtype=np.float64),
counts=np.ones(100) * 100,
)
good_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=np.eye(2),
)
good_livetime = type(
"MockLivetime",
(),
{
"live_time_fraction": 0.95,
},
)()
messages = pipeline._validate(spectrum, good_calib, good_livetime)
assert not any("ERROR" in m for m in messages)
# --- ROOT I/O integration tests ---
class TestRootIOIntegration:
@pytest.mark.skipif(
not _has_root_io(),
reason="Neither PyROOT nor uproot available",
)
def test_round_trip(self, full_spectrum, tmp_path):
"""Test write and read round-trip preserves all fields."""
from exp_data.root_io import write_spectrum, read_spectrum
root_path = tmp_path / "test.root"
write_spectrum(str(root_path), full_spectrum)
read_back = read_spectrum(str(root_path))
np.testing.assert_array_equal(read_back.energies, full_spectrum.energies)
np.testing.assert_array_equal(read_back.counts, full_spectrum.counts)
assert read_back.dead_time == full_spectrum.dead_time
assert read_back.source == full_spectrum.source
assert read_back.run_id == full_spectrum.run_id
assert read_back.date == full_spectrum.date
@pytest.mark.skipif(
not _has_root_io(),
reason="Neither PyROOT nor uproot available",
)
def test_metadata_round_trip(self, full_spectrum, tmp_path):
"""Test that metadata survives round-trip."""
from exp_data.root_io import write_spectrum, read_spectrum
root_path = tmp_path / "test.root"
write_spectrum(str(root_path), full_spectrum)
read_back = read_spectrum(str(root_path))
assert "calibration" in read_back.metadata
assert read_back.metadata["calibration"]["type"] == "linear"
assert "livetime" in read_back.metadata
assert read_back.metadata["livetime"]["fraction"] == 0.95
def _has_root_io():
"""Check if any ROOT I/O library is available."""
try:
import ROOT # noqa: F401
return True
except ImportError:
pass
try:
import uproot # noqa: F401
return True
except ImportError:
return False