test: adapt existing suites to unconditional core, drop dead detector tests

This commit is contained in:
2026-09-30 03:08:13 +03:00
parent 7dc2ec79d1
commit 8172ba2541
6 changed files with 103 additions and 320 deletions
-1
View File
@@ -10,7 +10,6 @@ seconds to complete. They are intended for CI/full test runs, not for
fast feedback during development. fast feedback during development.
""" """
import numpy as np import numpy as np
from hypothesis import given, settings, strategies as st from hypothesis import given, settings, strategies as st
+24 -24
View File
@@ -82,9 +82,9 @@ def _test_positive_values(component, W, component_name="Component"):
f"W range: [{W_arr.min():.3f}, {W_arr.max():.3f}], " f"W range: [{W_arr.min():.3f}, {W_arr.max():.3f}], "
f"result range: [{np.nanmin(result):.6f}, {np.nanmax(result):.6f}]" f"result range: [{np.nanmin(result):.6f}, {np.nanmax(result):.6f}]"
) )
assert not np.any(np.isinf(result)), ( assert not np.any(
f"{component_name}: Inf values found in output." np.isinf(result)
) ), f"{component_name}: Inf values found in output."
assert np.all(result > 0), ( assert np.all(result > 0), (
f"{component_name}: non-positive values found. " f"{component_name}: non-positive values found. "
f"Min = {np.min(result):.10f} at W = {W_arr[np.argmin(result)][0]:.4f}" f"Min = {np.min(result):.10f} at W = {W_arr[np.argmin(result)][0]:.4f}"
@@ -108,21 +108,21 @@ def _test_non_negative_values(
W_arr = np.asarray(W, dtype=float) W_arr = np.asarray(W, dtype=float)
result = component(W_arr) result = component(W_arr)
assert not np.any(np.isnan(result)), ( assert not np.any(
f"{component_name}: NaN values found in output." np.isnan(result)
) ), f"{component_name}: NaN values found in output."
assert not np.any(np.isinf(result)), ( assert not np.any(
f"{component_name}: Inf values found in output." np.isinf(result)
) ), f"{component_name}: Inf values found in output."
if allow_zero: if allow_zero:
assert np.all(result >= 0), ( assert np.all(
f"{component_name}: negative values found. Min = {np.min(result):.10f}" result >= 0
) ), f"{component_name}: negative values found. Min = {np.min(result):.10f}"
else: else:
assert np.all(result > 0), ( assert np.all(
f"{component_name}: non-positive values found. Min = {np.min(result):.10f}" result > 0
) ), f"{component_name}: non-positive values found. Min = {np.min(result):.10f}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -136,12 +136,12 @@ def _test_output_shape_preserved(component, W, component_name="Component"):
W_arr = np.asarray(W, dtype=float) W_arr = np.asarray(W, dtype=float)
result = component(W_arr) result = component(W_arr)
assert isinstance(result, np.ndarray), ( assert isinstance(
f"{component_name}: output is {type(result)}, expected np.ndarray" result, np.ndarray
) ), f"{component_name}: output is {type(result)}, expected np.ndarray"
assert result.shape == W_arr.shape, ( assert (
f"{component_name}: shape mismatch. Input {W_arr.shape} → Output {result.shape}" result.shape == W_arr.shape
) ), f"{component_name}: shape mismatch. Input {W_arr.shape} → Output {result.shape}"
@pytest.mark.quality @pytest.mark.quality
@@ -214,9 +214,9 @@ def _test_output_is_ndarray(component, W, component_name="Component"):
W_arr = np.asarray(W, dtype=float) W_arr = np.asarray(W, dtype=float)
result = component(W_arr) result = component(W_arr)
assert isinstance(result, np.ndarray), ( assert isinstance(
f"{component_name}: output type is {type(result).__name__}, expected np.ndarray" result, np.ndarray
) ), f"{component_name}: output type is {type(result).__name__}, expected np.ndarray"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+24 -24
View File
@@ -9,7 +9,7 @@ from fitter.model import SpectrumModel
class TestFitConfig: class TestFitConfig:
"""Test FitConfig defaults.""" """Test FitConfig defaults."""
def test_defaults(self): def test_defaults(self) -> None:
config = FitConfig() config = FitConfig()
assert config.method == "trf" assert config.method == "trf"
assert config.max_nfev == 1000 assert config.max_nfev == 1000
@@ -19,11 +19,13 @@ class TestFitConfig:
class TestSpectrumFitter: class TestSpectrumFitter:
"""Test SpectrumFitter.""" """Test SpectrumFitter."""
def _make_fitter(self): def _make_fitter(self) -> "SpectrumFitter":
"""Create a simple fitter with synthetic data.""" """Create a simple fitter with synthetic data."""
# Create a simple linear model # Create a simple linear model
def linear_model(energies_keV, norm, bg): def linear_model(
energies_keV: np.ndarray, norm: float, bg: float
) -> np.ndarray:
return norm * (1.0 - energies_keV / 100.0) + bg return norm * (1.0 - energies_keV / 100.0) + bg
# Synthetic data # Synthetic data
@@ -33,7 +35,9 @@ class TestSpectrumFitter:
# Mock model # Mock model
class MockModel: class MockModel:
def evaluate(self, energies_keV, norm, bg): def evaluate(
self, energies_keV: np.ndarray, norm: float, bg: float
) -> np.ndarray:
return linear_model(energies_keV, norm, bg) return linear_model(energies_keV, norm, bg)
model = MockModel() model = MockModel()
@@ -42,7 +46,7 @@ class TestSpectrumFitter:
) )
return fitter return fitter
def test_fit_basic(self): def test_fit_basic(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0], param_names=["norm", "bg"]) result = fitter.fit(x0=[10.0, 2.0], param_names=["norm", "bg"])
@@ -53,7 +57,7 @@ class TestSpectrumFitter:
assert result.chi2_per_dof > 0 assert result.chi2_per_dof > 0
assert len(result.parameters) == 2 assert len(result.parameters) == 2
def test_fit_with_bounds(self): def test_fit_with_bounds(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit( result = fitter.fit(
x0=[10.0, 2.0], x0=[10.0, 2.0],
@@ -61,36 +65,36 @@ class TestSpectrumFitter:
) )
assert result.success assert result.success
def test_fit_returns_residuals(self): def test_fit_returns_residuals(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
assert len(result.residuals) == 200 assert len(result.residuals) == 200
assert len(result.model_values) == 200 assert len(result.model_values) == 200
def test_parameters_property(self): def test_parameters_property(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
fitter.fit(x0=[10.0, 2.0]) fitter.fit(x0=[10.0, 2.0])
params = fitter.parameters params = fitter.parameters
assert len(params) == 2 assert len(params) == 2
def test_covariance_property(self): def test_covariance_property(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
fitter.fit(x0=[10.0, 2.0]) fitter.fit(x0=[10.0, 2.0])
cov = fitter.covariance cov = fitter.covariance
assert cov.shape == (2, 2) assert cov.shape == (2, 2)
def test_chi2_per_dof(self): def test_chi2_per_dof(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
assert result.chi2_per_dof == result.chi2 / result.n_free assert result.chi2_per_dof == result.chi2 / result.n_free
def test_p_value(self): def test_p_value(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
assert 0.0 <= result.p_value <= 1.0 assert 0.0 <= result.p_value <= 1.0
def test_parameters_with_errors(self): def test_parameters_with_errors(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
params = result.parameters_with_errors params = result.parameters_with_errors
@@ -100,7 +104,7 @@ class TestSpectrumFitter:
assert isinstance(err, float) assert isinstance(err, float)
assert err >= 0 assert err >= 0
def test_correlation_matrix(self): def test_correlation_matrix(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
corr = result.correlation_matrix corr = result.correlation_matrix
@@ -108,7 +112,7 @@ class TestSpectrumFitter:
# Diagonal should be 1 # Diagonal should be 1
np.testing.assert_allclose(np.diag(corr), 1.0) np.testing.assert_allclose(np.diag(corr), 1.0)
def test_summary(self): def test_summary(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0], param_names=["norm", "bg"]) result = fitter.fit(x0=[10.0, 2.0], param_names=["norm", "bg"])
summary = result.summary(param_names=["norm", "bg"]) summary = result.summary(param_names=["norm", "bg"])
@@ -116,7 +120,7 @@ class TestSpectrumFitter:
assert "bg" in summary assert "bg" in summary
assert "Fit Results" in summary assert "Fit Results" in summary
def test_profile_likelihood(self): def test_profile_likelihood(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
param_vals, chi2_vals, norm_vals = result.profile_likelihood(0, n_points=10) param_vals, chi2_vals, norm_vals = result.profile_likelihood(0, n_points=10)
@@ -125,7 +129,7 @@ class TestSpectrumFitter:
assert len(norm_vals) == 10 assert len(norm_vals) == 10
assert np.min(norm_vals) >= 0 # Normalized χ² should be >= 0 assert np.min(norm_vals) >= 0 # Normalized χ² should be >= 0
def test_confidence_interval(self): def test_confidence_interval(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
ci = result.confidence_interval(0, confidence=0.95) ci = result.confidence_interval(0, confidence=0.95)
@@ -136,7 +140,7 @@ class TestSpectrumFitter:
class TestSpectrumFitterWithRealModel: class TestSpectrumFitterWithRealModel:
"""Test SpectrumFitter with a real SpectrumModel.""" """Test SpectrumFitter with a real SpectrumModel."""
def test_fit_with_real_model(self): def test_fit_with_real_model(self) -> None:
from beta_spectrum import BetaSpectrum, SpectrumConfig from beta_spectrum import BetaSpectrum, SpectrumConfig
config = SpectrumConfig( config = SpectrumConfig(
@@ -144,16 +148,12 @@ class TestSpectrumFitterWithRealModel:
Z_daughter=44, Z_daughter=44,
A_number=99, A_number=99,
endpoint_MeV=0.294, endpoint_MeV=0.294,
detector_model="gaussian",
detector_sigma_a_keV=0.5,
detector_n_channels=200,
) )
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
detector = BetaSpectrum.create_detector_from_config(config) model = SpectrumModel(spectrum)
model = SpectrumModel(spectrum, detector)
# Generate synthetic data from model # Generate synthetic data from the unconvolved model on a keV grid
energies_keV = detector.channel_energies energies_keV = np.linspace(1.0, 293.0, 200)
true_norm = 5.0 true_norm = 5.0
true_bg = 0.1 true_bg = 0.1
counts = model.evaluate(energies_keV, true_norm, true_bg) counts = model.evaluate(energies_keV, true_norm, true_bg)
+15 -36
View File
@@ -10,8 +10,8 @@ from fitter.model import SpectrumModel
class TestSpectrumModel: class TestSpectrumModel:
"""Test SpectrumModel.""" """Test SpectrumModel."""
def _make_model(self, with_detector: bool = False): def _make_model(self) -> tuple[SpectrumModel, SpectrumConfig]:
"""Create a test SpectrumModel.""" """Create a test SpectrumModel (no detector response)."""
config = SpectrumConfig( config = SpectrumConfig(
Z_parent=43, Z_parent=43,
Z_daughter=44, Z_daughter=44,
@@ -20,24 +20,10 @@ class TestSpectrumModel:
) )
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
detector = None model = SpectrumModel(spectrum)
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 return model, config
def test_evaluate_unconvolved(self): def test_evaluate_unconvolved(self) -> None:
model, config = self._make_model() model, config = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50) energies_keV = np.linspace(0.01, 0.29, 50)
values = model.evaluate_unconvolved(energies_keV) values = model.evaluate_unconvolved(energies_keV)
@@ -45,44 +31,34 @@ class TestSpectrumModel:
assert np.all(np.isfinite(values)) assert np.all(np.isfinite(values))
assert np.all(values >= 0) assert np.all(values >= 0)
def test_evaluate_no_detector(self): def test_evaluate_no_detector(self) -> None:
model, config = self._make_model() model, config = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50) energies_keV = np.linspace(0.01, 0.29, 50)
values = model.evaluate(energies_keV, normalization=1.0, background=0.0) values = model.evaluate(energies_keV, normalization=1.0, background=0.0)
assert len(values) == 50 assert len(values) == 50
assert np.all(np.isfinite(values)) assert np.all(np.isfinite(values))
def test_evaluate_with_normalization(self): def test_evaluate_with_normalization(self) -> None:
model, config = self._make_model() model, config = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50) energies_keV = np.linspace(0.01, 0.29, 50)
values1 = model.evaluate(energies_keV, normalization=1.0, background=0.0) values1 = model.evaluate(energies_keV, normalization=1.0, background=0.0)
values2 = model.evaluate(energies_keV, normalization=2.0, background=0.0) values2 = model.evaluate(energies_keV, normalization=2.0, background=0.0)
np.testing.assert_allclose(values2, values1 * 2.0) np.testing.assert_allclose(values2, values1 * 2.0)
def test_evaluate_with_background(self): def test_evaluate_with_background(self) -> None:
model, config = self._make_model() model, config = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50) energies_keV = np.linspace(0.01, 0.29, 50)
values1 = model.evaluate(energies_keV, normalization=1.0, background=0.0) values1 = model.evaluate(energies_keV, normalization=1.0, background=0.0)
values2 = model.evaluate(energies_keV, normalization=1.0, background=5.0) values2 = model.evaluate(energies_keV, normalization=1.0, background=5.0)
np.testing.assert_allclose(values2, values1 + 5.0) np.testing.assert_allclose(values2, values1 + 5.0)
def test_convolve_requires_detector(self): def test_convolve_requires_detector(self) -> None:
model, _ = self._make_model() model, _ = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50) energies_keV = np.linspace(0.01, 0.29, 50)
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
model.convolve(energies_keV) model.convolve(energies_keV)
def test_convolve_with_detector(self): def test_default_params(self) -> None:
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 from exp_data.spectrum import ExpSpectrum
energies = np.linspace(0, 100, 200) energies = np.linspace(0, 100, 200)
@@ -93,7 +69,7 @@ class TestSpectrumModel:
assert norm > 0 assert norm > 0
assert bg >= 0 assert bg >= 0
def test_default_bounds(self): def test_default_bounds(self) -> None:
lower, upper = SpectrumModel.default_bounds(100) lower, upper = SpectrumModel.default_bounds(100)
assert len(lower) == 2 assert len(lower) == 2
assert len(upper) == 2 assert len(upper) == 2
@@ -104,8 +80,11 @@ class TestSpectrumModel:
class TestSpectrumModelKeVToW: class TestSpectrumModelKeVToW:
"""Test energy unit conversion in SpectrumModel.""" """Test energy unit conversion in SpectrumModel."""
def test_keV_to_W(self): def test_keV_to_W(self) -> None:
model = SpectrumModel(None) # type: ignore config = SpectrumConfig(
Z_parent=43, Z_daughter=44, A_number=99, endpoint_MeV=0.294
)
model = SpectrumModel(BetaSpectrum.from_config(config))
# 0 keV → W = 1.0 # 0 keV → W = 1.0
W = model._keV_to_W(np.array([0.0])) W = model._keV_to_W(np.array([0.0]))
assert np.isclose(W[0], 1.0) assert np.isclose(W[0], 1.0)
+9 -184
View File
@@ -7,7 +7,6 @@ work together correctly.
""" """
import numpy as np import numpy as np
import pytest
from beta_spectrum import BetaSpectrum, SpectrumConfig from beta_spectrum import BetaSpectrum, SpectrumConfig
@@ -61,13 +60,12 @@ class TestBetaSpectrumToggles:
"""Test that component toggling works correctly.""" """Test that component toggling works correctly."""
def test_all_disabled(self): def test_all_disabled(self):
"""With all corrections disabled, only phase space remains — still valid output.""" """With all corrections disabled, the core remains — still valid output."""
config = SpectrumConfig( config = SpectrumConfig(
Z_parent=19, Z_parent=19,
Z_daughter=20, Z_daughter=20,
A_number=40, A_number=40,
endpoint_MeV=2.5, endpoint_MeV=2.5,
use_fermi=False,
use_screening=False, use_screening=False,
use_finite_size=False, use_finite_size=False,
use_charge_dist=False, use_charge_dist=False,
@@ -82,14 +80,14 @@ class TestBetaSpectrumToggles:
assert len(values) > 0, "Should return non-empty array" assert len(values) > 0, "Should return non-empty array"
assert np.all(np.isfinite(values)), "All values must be finite" assert np.all(np.isfinite(values)), "All values must be finite"
def test_fermi_only(self): def test_core_equals_phase_space_times_fermi(self):
"""With only fermi function enabled: should give F₀(Z,W) — not just phase space.""" """Core components are unconditional: with corrections disabled the
spectrum equals phase space × Fermi function exactly."""
config_minimal = SpectrumConfig( config_minimal = SpectrumConfig(
Z_parent=19, Z_parent=19,
Z_daughter=20, Z_daughter=20,
A_number=40, A_number=40,
endpoint_MeV=2.5, endpoint_MeV=2.5,
use_phase_space=False,
use_screening=False, use_screening=False,
use_finite_size=False, use_finite_size=False,
use_charge_dist=False, use_charge_dist=False,
@@ -101,8 +99,11 @@ class TestBetaSpectrumToggles:
W, _ = spectrum.get_energy_grid(config_minimal) W, _ = spectrum.get_energy_grid(config_minimal)
values = spectrum(W) values = spectrum(W)
# Fermi function alone is ~1 for Z=20 at mid-energy components = spectrum.calculate_components(W)
assert np.all(np.isfinite(values)), "Fermi-only output must be finite" assert set(components.keys()) == {"PhaseSpace", "Fermi"}
expected = components["PhaseSpace"] * components["Fermi"]
assert np.allclose(values, expected, rtol=1e-10)
assert np.all(np.isfinite(values)), "Core-only output must be finite"
class TestBetaSpectrumComponentExtraction: class TestBetaSpectrumComponentExtraction:
@@ -158,179 +159,3 @@ class TestBetaSpectrumRealisticConfig:
assert np.all(np.isfinite(values)), "C-14 must not produce NaN/inf" assert np.all(np.isfinite(values)), "C-14 must not produce NaN/inf"
# Phase space should peak in the bulk for low-Q decay # Phase space should peak in the bulk for low-Q decay
assert np.any(values > 0), "Should have non-zero spectrum values" assert np.any(values > 0), "Should have non-zero spectrum values"
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
class TestDeclarativeDetectorResponse:
"""Test declarative detector response workflow via SpectrumConfig."""
def test_create_detector_from_config(self):
"""SpectrumConfig detector params should create valid 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)
from beta_spectrum.components.detector_response import DetectorResponse
assert isinstance(detector, DetectorResponse)
assert detector.n_channels == 512
assert detector._mode == "analytical"
assert detector.model == "gaussian"
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
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)
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
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"
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
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)
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
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}"
)
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
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}"
)
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
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"
+31 -51
View File
@@ -21,8 +21,7 @@ We test:
6. **Component extraction**: universal + per-branch components correctly separated. 6. **Component extraction**: universal + per-branch components correctly separated.
7. **CSV export**: multi-branch CSV has correct column structure. 7. **CSV export**: multi-branch CSV has correct column structure.
8. **Energy grid**: uses max endpoint across all branches. 8. **Energy grid**: uses max endpoint across all branches.
9. **Detector response**: multi-branch spectra are convolvable. 9. **BranchConfig dataclass**: basic construction and validation.
10. **BranchConfig dataclass**: basic construction and validation.
""" """
import numpy as np import numpy as np
@@ -249,8 +248,8 @@ class TestMultiBranchSpectrum:
Z_daughter=44, Z_daughter=44,
A_number=99, A_number=99,
endpoint_MeV=0.350, endpoint_MeV=0.350,
# Disable universal components to simplify # Disable higher-order universal corrections to simplify:
use_fermi=False, # the universal factor reduces to the core Fermi function.
use_screening=False, use_screening=False,
use_finite_size=False, use_finite_size=False,
use_charge_dist=False, use_charge_dist=False,
@@ -266,8 +265,11 @@ class TestMultiBranchSpectrum:
weighted = spectrum.get_branch_normalized_spectra(W) weighted = spectrum.get_branch_normalized_spectra(W)
assert len(weighted) == 2 assert len(weighted) == 2
# With no universal components, total = sum of weighted branch spectra # Universal factor is the core Fermi function only (corrections off):
total_from_branches = weighted[0] + weighted[1] # total = fermi × sum(weighted branch spectra)
components = spectrum.calculate_components(W)
fermi = components["Fermi"]
total_from_branches = fermi * (weighted[0] + weighted[1])
total = spectrum(W) total = spectrum(W)
assert np.allclose(total, total_from_branches, rtol=1e-10) assert np.allclose(total, total_from_branches, rtol=1e-10)
@@ -278,8 +280,6 @@ class TestMultiBranchSpectrum:
Z_daughter=44, Z_daughter=44,
A_number=99, A_number=99,
endpoint_MeV=0.350, endpoint_MeV=0.350,
use_phase_space=True,
use_fermi=False,
use_screening=False, use_screening=False,
use_finite_size=False, use_finite_size=False,
use_charge_dist=False, use_charge_dist=False,
@@ -293,13 +293,16 @@ class TestMultiBranchSpectrum:
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
W, _ = spectrum.get_energy_grid(config) W, _ = spectrum.get_energy_grid(config)
# Both branches have same endpoint and transition type, # Both branches have same endpoint and transition type, so their
# so phase space should be identical. Total = 0.8*PS + 0.2*PS = PS # per-branch cores are identical: sum = 0.8*core + 0.2*core = core.
# Total = universal (Fermi) × sum.
total = spectrum(W) total = spectrum(W)
branch0 = spectrum.branch_spectra[0](W) branch0 = spectrum.branch_spectra[0](W)
branch1 = spectrum.branch_spectra[1](W) branch1 = spectrum.branch_spectra[1](W)
components = spectrum.calculate_components(W)
fermi = components["Fermi"]
expected = 0.8 * branch0 + 0.2 * branch1 expected = fermi * (0.8 * branch0 + 0.2 * branch1)
assert np.allclose(total, expected, rtol=1e-10) assert np.allclose(total, expected, rtol=1e-10)
@@ -371,8 +374,8 @@ class TestMultiBranchComponents:
assert "Fermi" in components assert "Fermi" in components
assert "branch_0.PhaseSpace" in components assert "branch_0.PhaseSpace" in components
def test_analyzer_branch_spectra_property(self): def test_analyzer_physical_branch_spectra(self):
"""Analyzer should expose branch spectra property.""" """Analyzer physical branch spectra are core × corrections arrays."""
config = SpectrumConfig( config = SpectrumConfig(
Z_parent=43, Z_parent=43,
Z_daughter=44, Z_daughter=44,
@@ -386,8 +389,11 @@ class TestMultiBranchComponents:
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config) analyzer = BetaSpectrumAnalyzer(spectrum, config)
branch_spectra = analyzer.branch_spectra physical = [
assert len(branch_spectra) == 2 analyzer.physical_branch_spectrum(i) for i in range(analyzer._n_branches)
]
assert len(physical) == 2
assert physical[0].shape == analyzer.W.shape
def test_total_equals_universal_times_branch_sum(self): def test_total_equals_universal_times_branch_sum(self):
"""Total = universal_components * sum(intensity * branch_spectra).""" """Total = universal_components * sum(intensity * branch_spectra)."""
@@ -481,6 +487,13 @@ class TestMultiBranchCSVExport:
# Should have universal columns # Should have universal columns
assert "Fermi" in df.columns assert "Fermi" in df.columns
# Branch columns are physical spectra: their sum equals total_spectrum
total = df["total_spectrum"].to_numpy()
branch_sum = (
df["branch_1_spectrum"].to_numpy() + df["branch_2_spectrum"].to_numpy()
)
assert np.allclose(branch_sum, total, rtol=1e-3)
def test_single_branch_csv_columns(self, tmp_path): def test_single_branch_csv_columns(self, tmp_path):
"""Single-branch CSV should have standard columns.""" """Single-branch CSV should have standard columns."""
config = SpectrumConfig( config = SpectrumConfig(
@@ -582,42 +595,6 @@ class TestMultiBranchTransitionTypes:
assert np.all(values >= 0) assert np.all(values >= 0)
# ---------------------------------------------------------------------------
# Multi-branch with detector response
# ---------------------------------------------------------------------------
class TestMultiBranchDetectorResponse:
"""Test multi-branch with detector response convolution."""
def test_multi_branch_with_detector(self):
"""Multi-branch spectrum should be convolvable with detector response."""
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.350,
use_detector_response=True,
detector_model="gaussian",
detector_sigma_a_keV=1.0,
detector_n_channels=256,
detector_fano_factor=0.0,
branches=[
BranchConfig(endpoint_MeV=0.294, transition_type="F2", intensity=0.7),
BranchConfig(endpoint_MeV=0.150, transition_type="A", intensity=0.3),
],
)
spectrum = BetaSpectrum.from_config(config)
W, _ = spectrum.get_energy_grid(config)
detector = BetaSpectrum.create_detector_from_config(config)
convolved = spectrum.convolve_with_detector(detector, W=W, config=config)
assert convolved.shape == (256,)
assert np.all(convolved >= 0), "Convolved spectrum should be non-negative"
assert np.sum(convolved) > 0, "Convolved spectrum should have non-zero area"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Edge cases # Edge cases
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -705,6 +682,7 @@ class TestMultiBranchEdgeCases:
), ),
], ],
) )
assert config.branches is not None
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
W, energies = spectrum.get_energy_grid(config) W, energies = spectrum.get_energy_grid(config)
@@ -748,6 +726,7 @@ class TestMultiBranchEdgeCases:
BranchConfig(endpoint_MeV=0.3, transition_type="F1", intensity=0.2), BranchConfig(endpoint_MeV=0.3, transition_type="F1", intensity=0.2),
], ],
) )
assert config.branches is not None
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
W, energies = spectrum.get_energy_grid(config) W, energies = spectrum.get_energy_grid(config)
@@ -775,6 +754,7 @@ class TestMultiBranchEdgeCases:
# Each branch integral should be proportional to its intensity # Each branch integral should be proportional to its intensity
# (within numerical tolerance, since different endpoints give different # (within numerical tolerance, since different endpoints give different
# phase space integrals) # phase space integrals)
assert config.branches is not None
for i, (integral, intensity) in enumerate(zip(integrals, config.branches)): for i, (integral, intensity) in enumerate(zip(integrals, config.branches)):
assert integral > 0, f"Branch {i} has zero integral" assert integral > 0, f"Branch {i} has zero integral"