From 8172ba2541de6b5cc035aa1b5c3f3722e7c21d45 Mon Sep 17 00:00:00 2001 From: Evgeniy Unzhakov Date: Wed, 30 Sep 2026 03:08:13 +0300 Subject: [PATCH] test: adapt existing suites to unconditional core, drop dead detector tests --- tests/common/test_hypothesis_tests.py | 1 - tests/common/test_property_tests.py | 48 ++--- tests/fitter/test_fit_engine.py | 48 ++--- tests/fitter/test_model.py | 51 ++---- tests/integration/test_spectrum_pipeline.py | 193 +------------------- tests/physics/test_multi_branch.py | 82 ++++----- 6 files changed, 103 insertions(+), 320 deletions(-) diff --git a/tests/common/test_hypothesis_tests.py b/tests/common/test_hypothesis_tests.py index 191020f..732a01d 100644 --- a/tests/common/test_hypothesis_tests.py +++ b/tests/common/test_hypothesis_tests.py @@ -10,7 +10,6 @@ seconds to complete. They are intended for CI/full test runs, not for fast feedback during development. """ - import numpy as np from hypothesis import given, settings, strategies as st diff --git a/tests/common/test_property_tests.py b/tests/common/test_property_tests.py index 91b1af7..bc087a4 100644 --- a/tests/common/test_property_tests.py +++ b/tests/common/test_property_tests.py @@ -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"result range: [{np.nanmin(result):.6f}, {np.nanmax(result):.6f}]" ) - assert not np.any(np.isinf(result)), ( - f"{component_name}: Inf values found in output." - ) + assert not np.any( + np.isinf(result) + ), f"{component_name}: Inf values found in output." assert np.all(result > 0), ( f"{component_name}: non-positive values found. " 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) result = component(W_arr) - assert not np.any(np.isnan(result)), ( - f"{component_name}: NaN values found in output." - ) - assert not np.any(np.isinf(result)), ( - f"{component_name}: Inf values found in output." - ) + assert not np.any( + np.isnan(result) + ), f"{component_name}: NaN values found in output." + assert not np.any( + np.isinf(result) + ), f"{component_name}: Inf values found in output." if allow_zero: - assert np.all(result >= 0), ( - f"{component_name}: negative values found. Min = {np.min(result):.10f}" - ) + assert np.all( + result >= 0 + ), f"{component_name}: negative values found. Min = {np.min(result):.10f}" else: - assert np.all(result > 0), ( - f"{component_name}: non-positive values found. Min = {np.min(result):.10f}" - ) + assert np.all( + 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) result = component(W_arr) - assert isinstance(result, np.ndarray), ( - f"{component_name}: output is {type(result)}, expected np.ndarray" - ) - assert result.shape == W_arr.shape, ( - f"{component_name}: shape mismatch. Input {W_arr.shape} → Output {result.shape}" - ) + assert isinstance( + result, np.ndarray + ), f"{component_name}: output is {type(result)}, expected np.ndarray" + assert ( + result.shape == W_arr.shape + ), f"{component_name}: shape mismatch. Input {W_arr.shape} → Output {result.shape}" @pytest.mark.quality @@ -214,9 +214,9 @@ def _test_output_is_ndarray(component, W, component_name="Component"): W_arr = np.asarray(W, dtype=float) result = component(W_arr) - assert isinstance(result, np.ndarray), ( - f"{component_name}: output type is {type(result).__name__}, expected np.ndarray" - ) + assert isinstance( + result, np.ndarray + ), f"{component_name}: output type is {type(result).__name__}, expected np.ndarray" # --------------------------------------------------------------------------- diff --git a/tests/fitter/test_fit_engine.py b/tests/fitter/test_fit_engine.py index 060ba49..43f89b2 100644 --- a/tests/fitter/test_fit_engine.py +++ b/tests/fitter/test_fit_engine.py @@ -9,7 +9,7 @@ from fitter.model import SpectrumModel class TestFitConfig: """Test FitConfig defaults.""" - def test_defaults(self): + def test_defaults(self) -> None: config = FitConfig() assert config.method == "trf" assert config.max_nfev == 1000 @@ -19,11 +19,13 @@ class TestFitConfig: class TestSpectrumFitter: """Test SpectrumFitter.""" - def _make_fitter(self): + def _make_fitter(self) -> "SpectrumFitter": """Create a simple fitter with synthetic data.""" # 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 # Synthetic data @@ -33,7 +35,9 @@ class TestSpectrumFitter: # Mock model 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) model = MockModel() @@ -42,7 +46,7 @@ class TestSpectrumFitter: ) return fitter - def test_fit_basic(self): + def test_fit_basic(self) -> None: fitter = self._make_fitter() 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 len(result.parameters) == 2 - def test_fit_with_bounds(self): + def test_fit_with_bounds(self) -> None: fitter = self._make_fitter() result = fitter.fit( x0=[10.0, 2.0], @@ -61,36 +65,36 @@ class TestSpectrumFitter: ) assert result.success - def test_fit_returns_residuals(self): + def test_fit_returns_residuals(self) -> None: fitter = self._make_fitter() result = fitter.fit(x0=[10.0, 2.0]) assert len(result.residuals) == 200 assert len(result.model_values) == 200 - def test_parameters_property(self): + def test_parameters_property(self) -> None: fitter = self._make_fitter() fitter.fit(x0=[10.0, 2.0]) params = fitter.parameters assert len(params) == 2 - def test_covariance_property(self): + def test_covariance_property(self) -> None: fitter = self._make_fitter() fitter.fit(x0=[10.0, 2.0]) cov = fitter.covariance assert cov.shape == (2, 2) - def test_chi2_per_dof(self): + def test_chi2_per_dof(self) -> None: fitter = self._make_fitter() result = fitter.fit(x0=[10.0, 2.0]) 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() result = fitter.fit(x0=[10.0, 2.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() result = fitter.fit(x0=[10.0, 2.0]) params = result.parameters_with_errors @@ -100,7 +104,7 @@ class TestSpectrumFitter: assert isinstance(err, float) assert err >= 0 - def test_correlation_matrix(self): + def test_correlation_matrix(self) -> None: fitter = self._make_fitter() result = fitter.fit(x0=[10.0, 2.0]) corr = result.correlation_matrix @@ -108,7 +112,7 @@ class TestSpectrumFitter: # Diagonal should be 1 np.testing.assert_allclose(np.diag(corr), 1.0) - def test_summary(self): + def test_summary(self) -> None: fitter = self._make_fitter() result = fitter.fit(x0=[10.0, 2.0], param_names=["norm", "bg"]) summary = result.summary(param_names=["norm", "bg"]) @@ -116,7 +120,7 @@ class TestSpectrumFitter: assert "bg" in summary assert "Fit Results" in summary - def test_profile_likelihood(self): + def test_profile_likelihood(self) -> None: fitter = self._make_fitter() result = fitter.fit(x0=[10.0, 2.0]) 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 np.min(norm_vals) >= 0 # Normalized χ² should be >= 0 - def test_confidence_interval(self): + def test_confidence_interval(self) -> None: fitter = self._make_fitter() result = fitter.fit(x0=[10.0, 2.0]) ci = result.confidence_interval(0, confidence=0.95) @@ -136,7 +140,7 @@ class TestSpectrumFitter: class TestSpectrumFitterWithRealModel: """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 config = SpectrumConfig( @@ -144,16 +148,12 @@ class TestSpectrumFitterWithRealModel: Z_daughter=44, A_number=99, endpoint_MeV=0.294, - detector_model="gaussian", - detector_sigma_a_keV=0.5, - detector_n_channels=200, ) spectrum = BetaSpectrum.from_config(config) - detector = BetaSpectrum.create_detector_from_config(config) - model = SpectrumModel(spectrum, detector) + model = SpectrumModel(spectrum) - # Generate synthetic data from model - energies_keV = detector.channel_energies + # Generate synthetic data from the unconvolved model on a keV grid + energies_keV = np.linspace(1.0, 293.0, 200) true_norm = 5.0 true_bg = 0.1 counts = model.evaluate(energies_keV, true_norm, true_bg) diff --git a/tests/fitter/test_model.py b/tests/fitter/test_model.py index ca0389d..02eb0b8 100644 --- a/tests/fitter/test_model.py +++ b/tests/fitter/test_model.py @@ -10,8 +10,8 @@ from fitter.model import SpectrumModel class TestSpectrumModel: """Test SpectrumModel.""" - def _make_model(self, with_detector: bool = False): - """Create a test SpectrumModel.""" + def _make_model(self) -> tuple[SpectrumModel, SpectrumConfig]: + """Create a test SpectrumModel (no detector response).""" config = SpectrumConfig( Z_parent=43, Z_daughter=44, @@ -20,24 +20,10 @@ class TestSpectrumModel: ) spectrum = BetaSpectrum.from_config(config) - detector = None - 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) + model = SpectrumModel(spectrum) return model, config - def test_evaluate_unconvolved(self): + def test_evaluate_unconvolved(self) -> None: model, config = self._make_model() energies_keV = np.linspace(0.01, 0.29, 50) values = model.evaluate_unconvolved(energies_keV) @@ -45,44 +31,34 @@ class TestSpectrumModel: assert np.all(np.isfinite(values)) assert np.all(values >= 0) - def test_evaluate_no_detector(self): + def test_evaluate_no_detector(self) -> None: model, config = self._make_model() energies_keV = np.linspace(0.01, 0.29, 50) values = model.evaluate(energies_keV, normalization=1.0, background=0.0) assert len(values) == 50 assert np.all(np.isfinite(values)) - def test_evaluate_with_normalization(self): + def test_evaluate_with_normalization(self) -> None: model, config = self._make_model() energies_keV = np.linspace(0.01, 0.29, 50) values1 = model.evaluate(energies_keV, normalization=1.0, background=0.0) values2 = model.evaluate(energies_keV, normalization=2.0, background=0.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() energies_keV = np.linspace(0.01, 0.29, 50) values1 = model.evaluate(energies_keV, normalization=1.0, background=0.0) values2 = model.evaluate(energies_keV, normalization=1.0, background=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() energies_keV = np.linspace(0.01, 0.29, 50) with pytest.raises(RuntimeError): model.convolve(energies_keV) - def test_convolve_with_detector(self): - 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): + def test_default_params(self) -> None: from exp_data.spectrum import ExpSpectrum energies = np.linspace(0, 100, 200) @@ -93,7 +69,7 @@ class TestSpectrumModel: assert norm > 0 assert bg >= 0 - def test_default_bounds(self): + def test_default_bounds(self) -> None: lower, upper = SpectrumModel.default_bounds(100) assert len(lower) == 2 assert len(upper) == 2 @@ -104,8 +80,11 @@ class TestSpectrumModel: class TestSpectrumModelKeVToW: """Test energy unit conversion in SpectrumModel.""" - def test_keV_to_W(self): - model = SpectrumModel(None) # type: ignore + def test_keV_to_W(self) -> None: + 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 W = model._keV_to_W(np.array([0.0])) assert np.isclose(W[0], 1.0) diff --git a/tests/integration/test_spectrum_pipeline.py b/tests/integration/test_spectrum_pipeline.py index 0a30152..95020c3 100644 --- a/tests/integration/test_spectrum_pipeline.py +++ b/tests/integration/test_spectrum_pipeline.py @@ -7,7 +7,6 @@ work together correctly. """ import numpy as np -import pytest from beta_spectrum import BetaSpectrum, SpectrumConfig @@ -61,13 +60,12 @@ class TestBetaSpectrumToggles: """Test that component toggling works correctly.""" 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( 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, @@ -82,14 +80,14 @@ class TestBetaSpectrumToggles: 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.""" + def test_core_equals_phase_space_times_fermi(self): + """Core components are unconditional: with corrections disabled the + spectrum equals phase space × Fermi function exactly.""" 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, @@ -101,8 +99,11 @@ class TestBetaSpectrumToggles: 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" + components = spectrum.calculate_components(W) + 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: @@ -158,179 +159,3 @@ class TestBetaSpectrumRealisticConfig: 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" - - -@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" diff --git a/tests/physics/test_multi_branch.py b/tests/physics/test_multi_branch.py index f81a6c8..9491b79 100644 --- a/tests/physics/test_multi_branch.py +++ b/tests/physics/test_multi_branch.py @@ -21,8 +21,7 @@ We test: 6. **Component extraction**: universal + per-branch components correctly separated. 7. **CSV export**: multi-branch CSV has correct column structure. 8. **Energy grid**: uses max endpoint across all branches. - 9. **Detector response**: multi-branch spectra are convolvable. - 10. **BranchConfig dataclass**: basic construction and validation. + 9. **BranchConfig dataclass**: basic construction and validation. """ import numpy as np @@ -249,8 +248,8 @@ class TestMultiBranchSpectrum: Z_daughter=44, A_number=99, endpoint_MeV=0.350, - # Disable universal components to simplify - use_fermi=False, + # Disable higher-order universal corrections to simplify: + # the universal factor reduces to the core Fermi function. use_screening=False, use_finite_size=False, use_charge_dist=False, @@ -266,8 +265,11 @@ class TestMultiBranchSpectrum: weighted = spectrum.get_branch_normalized_spectra(W) assert len(weighted) == 2 - # With no universal components, total = sum of weighted branch spectra - total_from_branches = weighted[0] + weighted[1] + # Universal factor is the core Fermi function only (corrections off): + # 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) assert np.allclose(total, total_from_branches, rtol=1e-10) @@ -278,8 +280,6 @@ class TestMultiBranchSpectrum: Z_daughter=44, A_number=99, endpoint_MeV=0.350, - use_phase_space=True, - use_fermi=False, use_screening=False, use_finite_size=False, use_charge_dist=False, @@ -293,13 +293,16 @@ class TestMultiBranchSpectrum: spectrum = BetaSpectrum.from_config(config) W, _ = spectrum.get_energy_grid(config) - # Both branches have same endpoint and transition type, - # so phase space should be identical. Total = 0.8*PS + 0.2*PS = PS + # Both branches have same endpoint and transition type, so their + # per-branch cores are identical: sum = 0.8*core + 0.2*core = core. + # Total = universal (Fermi) × sum. total = spectrum(W) branch0 = spectrum.branch_spectra[0](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) @@ -371,8 +374,8 @@ class TestMultiBranchComponents: assert "Fermi" in components assert "branch_0.PhaseSpace" in components - def test_analyzer_branch_spectra_property(self): - """Analyzer should expose branch spectra property.""" + def test_analyzer_physical_branch_spectra(self): + """Analyzer physical branch spectra are core × corrections arrays.""" config = SpectrumConfig( Z_parent=43, Z_daughter=44, @@ -386,8 +389,11 @@ class TestMultiBranchComponents: spectrum = BetaSpectrum.from_config(config) analyzer = BetaSpectrumAnalyzer(spectrum, config) - branch_spectra = analyzer.branch_spectra - assert len(branch_spectra) == 2 + physical = [ + 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): """Total = universal_components * sum(intensity * branch_spectra).""" @@ -481,6 +487,13 @@ class TestMultiBranchCSVExport: # Should have universal 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): """Single-branch CSV should have standard columns.""" config = SpectrumConfig( @@ -582,42 +595,6 @@ class TestMultiBranchTransitionTypes: 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 # --------------------------------------------------------------------------- @@ -705,6 +682,7 @@ class TestMultiBranchEdgeCases: ), ], ) + assert config.branches is not None spectrum = BetaSpectrum.from_config(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), ], ) + assert config.branches is not None spectrum = BetaSpectrum.from_config(config) W, energies = spectrum.get_energy_grid(config) @@ -775,6 +754,7 @@ class TestMultiBranchEdgeCases: # Each branch integral should be proportional to its intensity # (within numerical tolerance, since different endpoints give different # phase space integrals) + assert config.branches is not None for i, (integral, intensity) in enumerate(zip(integrals, config.branches)): assert integral > 0, f"Branch {i} has zero integral"