test: adapt existing suites to unconditional core, drop dead detector tests
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user