366 lines
13 KiB
Python
366 lines
13 KiB
Python
# tests/test_spectrum.py — Integration tests for the full BetaSpectrum pipeline
|
|
|
|
"""
|
|
Why test this?
|
|
--------------
|
|
Unit tests verify each component in isolation. But integration tests catch bugs that
|
|
only appear when components interact: wrong energy grid, type mismatches between
|
|
components, or numerical errors accumulating across multiplicative factors.
|
|
|
|
The `from_config()` factory method is the user-facing API — it's what people call
|
|
in real code. If this doesn't work for a realistic configuration, nothing else matters.
|
|
|
|
We test:
|
|
|
|
1. **End-to-end pipeline**: from_config → energy grid → spectrum evaluation → all components valid.
|
|
This is the "smoke test" that catches the most common user errors.
|
|
2. **Selective component toggling**: Disabling a component shouldn't break anything.
|
|
Users need to be able to turn off corrections for debugging or comparison studies.
|
|
3. **Positive final spectrum**: The product of all factors must remain positive —
|
|
if any component goes negative, the entire spectrum is wrong.
|
|
4. **Component extraction works**: calculate_components() should return per-component values
|
|
that match calling each component individually.
|
|
|
|
Common practice: Integration tests are fewer but more expensive (they test more code paths).
|
|
They serve as a safety net — if they pass, you can be confident the system works "as designed".
|
|
"""
|
|
|
|
import numpy as np
|
|
|
|
from beta_spectrum import BetaSpectrum, SpectrumConfig
|
|
|
|
|
|
class TestBetaSpectrumIntegration:
|
|
"""Full pipeline integration tests."""
|
|
|
|
def test_from_config_basic(self):
|
|
"""The most basic use case: create a spectrum from config and evaluate it.
|
|
|
|
This is the entry point shown in README.md — if this fails, nothing works.
|
|
We test with a medium-Z nucleus (Z=20 calcium) which exercises all components.
|
|
"""
|
|
config = SpectrumConfig(
|
|
Z_parent=19, Z_daughter=20, A_number=40, endpoint_MeV=2.5
|
|
)
|
|
|
|
spectrum = BetaSpectrum.from_config(config)
|
|
W, E_MeV = spectrum.get_energy_grid(config)
|
|
|
|
assert len(W) == len(E_MeV), "Energy grids must have same length"
|
|
assert len(W) > 0, "Grid must not be empty"
|
|
|
|
def test_spectrum_evaluates_without_error(self):
|
|
"""Calling the spectrum object should return a valid numpy array."""
|
|
config = SpectrumConfig(
|
|
Z_parent=19, Z_daughter=20, A_number=40, endpoint_MeV=2.5
|
|
)
|
|
spectrum = BetaSpectrum.from_config(config)
|
|
W, _ = spectrum.get_energy_grid(config)
|
|
|
|
values = spectrum(W)
|
|
|
|
assert isinstance(values, np.ndarray), "Output must be a numpy array"
|
|
assert values.shape == W.shape, "Output shape must match input shape"
|
|
|
|
def test_all_components_positive(self):
|
|
"""Every component and the total product must be positive."""
|
|
config = SpectrumConfig(
|
|
Z_parent=19, Z_daughter=20, A_number=40, endpoint_MeV=2.5
|
|
)
|
|
spectrum = BetaSpectrum.from_config(config)
|
|
W, _ = spectrum.get_energy_grid(config)
|
|
|
|
# Filter out values exactly at the endpoint (where phase space is zero)
|
|
mask = np.isfinite(spectrum(W)) & (spectrum(W) > 0)
|
|
assert np.any(mask), "At least some finite positive values expected"
|
|
|
|
|
|
class TestBetaSpectrumToggles:
|
|
"""Test that component toggling works correctly."""
|
|
|
|
def test_all_disabled(self):
|
|
"""With all corrections disabled, only phase space remains — still valid output."""
|
|
config = SpectrumConfig(
|
|
Z_parent=19,
|
|
Z_daughter=20,
|
|
A_number=40,
|
|
endpoint_MeV=2.5,
|
|
use_fermi=False,
|
|
use_screening=False,
|
|
use_finite_size=False,
|
|
use_charge_dist=False,
|
|
use_exchange=False,
|
|
use_radiative=False,
|
|
)
|
|
|
|
spectrum = BetaSpectrum.from_config(config)
|
|
W, _ = spectrum.get_energy_grid(config)
|
|
values = spectrum(W)
|
|
|
|
assert len(values) > 0, "Should return non-empty array"
|
|
assert np.all(np.isfinite(values)), "All values must be finite"
|
|
|
|
def test_fermi_only(self):
|
|
"""With only fermi function enabled: should give F₀(Z,W) — not just phase space."""
|
|
config_minimal = SpectrumConfig(
|
|
Z_parent=19,
|
|
Z_daughter=20,
|
|
A_number=40,
|
|
endpoint_MeV=2.5,
|
|
use_phase_space=False,
|
|
use_screening=False,
|
|
use_finite_size=False,
|
|
use_charge_dist=False,
|
|
use_exchange=False,
|
|
use_radiative=False,
|
|
)
|
|
|
|
spectrum = BetaSpectrum.from_config(config_minimal)
|
|
W, _ = spectrum.get_energy_grid(config_minimal)
|
|
values = spectrum(W)
|
|
|
|
# Fermi function alone is ~1 for Z=20 at mid-energy
|
|
assert np.all(np.isfinite(values)), "Fermi-only output must be finite"
|
|
|
|
|
|
class TestBetaSpectrumComponentExtraction:
|
|
"""Test calculate_components() returns correct per-component data."""
|
|
|
|
def test_component_names_match(self):
|
|
"""Component names should correspond to class names (minus suffixes)."""
|
|
config = SpectrumConfig(
|
|
Z_parent=19, Z_daughter=20, A_number=40, endpoint_MeV=2.5
|
|
)
|
|
spectrum = BetaSpectrum.from_config(config)
|
|
|
|
W, _ = spectrum.get_energy_grid(config)
|
|
components = spectrum.calculate_components(W)
|
|
|
|
assert isinstance(components, dict), "Must return a dictionary"
|
|
assert len(components) > 0, "Should have at least one component"
|
|
|
|
|
|
class TestBetaSpectrumAnalyzer:
|
|
"""Test the analyzer utilities."""
|
|
|
|
def test_total_spectrum_returns_array(self):
|
|
config = SpectrumConfig(
|
|
Z_parent=19, Z_daughter=20, A_number=40, endpoint_MeV=2.5
|
|
)
|
|
spectrum = BetaSpectrum.from_config(config)
|
|
_analyzer = type("Analyzer", (), {"spectrum": spectrum, "config": config})()
|
|
|
|
# We can't easily instantiate the full Analyzer without matplotlib backend,
|
|
# but we verify the spectrum itself works end-to-end.
|
|
W, E = spectrum.get_energy_grid(config)
|
|
total = spectrum(W)
|
|
|
|
assert len(total) == len(E), "Spectrum and energy arrays must match"
|
|
|
|
|
|
class TestBetaSpectrumRealisticConfig:
|
|
"""Test with realistic physics parameters."""
|
|
|
|
def test_uranium_spectrum(self):
|
|
"""Heavy nucleus (Z=92) — tests numerical stability at extreme Z.
|
|
|
|
Uranium-238 decays via alpha, but we can still evaluate the beta spectrum
|
|
shape for testing purposes. High Z stresses the Fermi function's loggamma.
|
|
"""
|
|
config = SpectrumConfig(
|
|
Z_parent=91, Z_daughter=92, A_number=238, endpoint_MeV=0.5
|
|
)
|
|
|
|
spectrum = BetaSpectrum.from_config(config)
|
|
W, E = spectrum.get_energy_grid(config)
|
|
values = spectrum(W)
|
|
|
|
assert np.all(np.isfinite(values)), "Uranium must not produce NaN/inf"
|
|
assert len(values) > 10, f"Grid should have many points: got {len(values)}"
|
|
|
|
def test_low_Z_spectrum(self):
|
|
"""Light nucleus (Z=6 carbon-14 beta decay)."""
|
|
config = SpectrumConfig(
|
|
Z_parent=5,
|
|
Z_daughter=6,
|
|
A_number=14,
|
|
endpoint_MeV=0.156, # ~156 keV for C-14
|
|
)
|
|
|
|
spectrum = BetaSpectrum.from_config(config)
|
|
W, E = spectrum.get_energy_grid(config)
|
|
values = spectrum(W)
|
|
|
|
assert np.all(np.isfinite(values)), "C-14 must not produce NaN/inf"
|
|
# Phase space should peak in the bulk for low-Q decay
|
|
assert np.any(values > 0), "Should have non-zero spectrum values"
|
|
|
|
|
|
class TestDeclarativeDetectorResponse:
|
|
"""Test declarative detector response workflow via SpectrumConfig."""
|
|
|
|
def test_create_detector_from_config(self):
|
|
"""SpectrumConfig detector params should create valid DetectorResponse."""
|
|
from beta_spectrum import DetectorResponse
|
|
|
|
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=512,
|
|
)
|
|
|
|
detector = BetaSpectrum.create_detector_from_config(config)
|
|
|
|
assert isinstance(detector, DetectorResponse)
|
|
assert detector.n_channels == 512
|
|
assert detector._mode == "analytical"
|
|
assert detector.model == "gaussian"
|
|
|
|
def test_detector_sigma_conversion(self):
|
|
"""Sigma in keV should be correctly converted to m_e units."""
|
|
from beta_spectrum.utils import T_to_W
|
|
|
|
config = SpectrumConfig(
|
|
Z_parent=43,
|
|
Z_daughter=44,
|
|
A_number=99,
|
|
endpoint_MeV=0.294,
|
|
detector_sigma_a_keV=1.0,
|
|
detector_fano_factor=0.0,
|
|
)
|
|
|
|
detector = BetaSpectrum.create_detector_from_config(config)
|
|
|
|
# 1.0 keV = 1.0/511.0 m_e ≈ 0.00196
|
|
expected_sigma = 1.0 / 0.510998950
|
|
sigma_at_endpoint = detector._resolution_sigma(T_to_W(0.294))
|
|
assert np.isclose(sigma_at_endpoint, expected_sigma, rtol=0.01)
|
|
|
|
def test_convolve_detector_single_call(self):
|
|
"""convolve_detector() should work in a single call with config params."""
|
|
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=512,
|
|
detector_fano_factor=0.0,
|
|
)
|
|
|
|
spectrum = BetaSpectrum.from_config(config)
|
|
W, _ = spectrum.get_energy_grid(config)
|
|
|
|
convolved = spectrum.convolve_detector(config, W=W)
|
|
|
|
assert convolved.shape == (512,)
|
|
assert np.all(convolved >= 0), "Convolved spectrum should be non-negative"
|
|
assert np.sum(convolved) > 0, "Convolved spectrum should have non-zero area"
|
|
|
|
def test_convolve_detector_with_tail(self):
|
|
"""Convolution with tail model should produce low-energy tailing."""
|
|
config = SpectrumConfig(
|
|
Z_parent=43,
|
|
Z_daughter=44,
|
|
A_number=99,
|
|
endpoint_MeV=0.294,
|
|
detector_model="gaussian_tail",
|
|
detector_sigma_a_keV=1.0,
|
|
detector_tail_fraction=0.2,
|
|
detector_tau_keV=5.0,
|
|
detector_n_channels=512,
|
|
detector_fano_factor=0.0,
|
|
)
|
|
|
|
spectrum = BetaSpectrum.from_config(config)
|
|
W, _ = spectrum.get_energy_grid(config)
|
|
|
|
convolved = spectrum.convolve_detector(config, W=W)
|
|
|
|
assert convolved.shape == (512,)
|
|
assert np.all(convolved >= 0)
|
|
|
|
def test_analyzer_convolved_from_config(self):
|
|
"""BetaSpectrumAnalyzer should use config detector params automatically."""
|
|
config = SpectrumConfig(
|
|
Z_parent=43,
|
|
Z_daughter=44,
|
|
A_number=99,
|
|
endpoint_MeV=0.294,
|
|
use_detector_response=True,
|
|
detector_model="gaussian",
|
|
detector_sigma_a_keV=1.0,
|
|
detector_n_channels=256,
|
|
detector_fano_factor=0.0,
|
|
)
|
|
|
|
spectrum = BetaSpectrum.from_config(config)
|
|
from beta_spectrum import BetaSpectrumAnalyzer
|
|
|
|
analyzer = BetaSpectrumAnalyzer(spectrum, config)
|
|
detector = BetaSpectrum.create_detector_from_config(config)
|
|
|
|
convolved = analyzer.convolved_spectrum(detector, normalize=True)
|
|
|
|
assert convolved.shape == (256,)
|
|
# Normalized spectrum should integrate to ~1 over channel energies
|
|
integral = np.trapezoid(convolved, detector.channel_energies)
|
|
assert np.isclose(
|
|
integral, 1.0, rtol=0.05
|
|
), f"Integral should be ~1.0, got {integral}"
|
|
|
|
def test_detector_range_extends_to_endpoint(self):
|
|
"""Detector channel range should extend to cover spectrum endpoint."""
|
|
config = SpectrumConfig(
|
|
Z_parent=43,
|
|
Z_daughter=44,
|
|
A_number=99,
|
|
endpoint_MeV=0.294,
|
|
detector_channel_energy_range=(0.0, 0.2), # Less than endpoint
|
|
detector_n_channels=256,
|
|
detector_fano_factor=0.0,
|
|
)
|
|
|
|
detector = BetaSpectrum.create_detector_from_config(config)
|
|
|
|
# Range should extend beyond endpoint in m_e units
|
|
from beta_spectrum.utils import T_to_W
|
|
|
|
W0 = T_to_W(0.294)
|
|
assert detector.channel_energies[-1] >= W0, (
|
|
f"Detector range {detector.channel_energies[-1]:.4f} should cover "
|
|
f"endpoint {W0:.4f}"
|
|
)
|
|
|
|
def test_different_detector_models(self):
|
|
"""All detector models should produce valid convolved spectra."""
|
|
models = ["gaussian", "gaussian_tail", "tikhonov"]
|
|
|
|
for model in models:
|
|
config = SpectrumConfig(
|
|
Z_parent=43,
|
|
Z_daughter=44,
|
|
A_number=99,
|
|
endpoint_MeV=0.294,
|
|
detector_model=model,
|
|
detector_sigma_a_keV=1.0,
|
|
detector_n_channels=256,
|
|
detector_fano_factor=0.0,
|
|
)
|
|
if model == "gaussian_tail":
|
|
config.detector_tail_fraction = 0.1
|
|
config.detector_tau_keV = 5.0
|
|
|
|
spectrum = BetaSpectrum.from_config(config)
|
|
W, _ = spectrum.get_energy_grid(config)
|
|
|
|
convolved = spectrum.convolve_detector(config, W=W)
|
|
|
|
assert convolved.shape == (256,)
|
|
assert np.all(convolved >= 0), f"Model {model} produced negative values"
|