""" Integration tests for the full BetaSpectrum pipeline. These are integration tests that verify end-to-end workflows. They are NOT component-level tests — they test that components work together correctly. """ import numpy as np import pytest from beta_spectrum import BetaSpectrum, DetectorResponse, SpectrumConfig class TestBetaSpectrumIntegration: """Full pipeline integration tests.""" def test_from_config_basic(self): """The most basic use case: create a spectrum from config and evaluate it. This is the entry point shown in README.md — if this fails, nothing works. We test with a medium-Z nucleus (Z=20 calcium) which exercises all components. """ config = SpectrumConfig( Z_parent=19, Z_daughter=20, A_number=40, endpoint_MeV=2.5 ) spectrum = BetaSpectrum.from_config(config) W, E_MeV = spectrum.get_energy_grid(config) assert len(W) == len(E_MeV), "Energy grids must have same length" assert len(W) > 0, "Grid must not be empty" def test_spectrum_evaluates_without_error(self): """Calling the spectrum object should return a valid numpy array.""" config = SpectrumConfig( Z_parent=19, Z_daughter=20, A_number=40, endpoint_MeV=2.5 ) spectrum = BetaSpectrum.from_config(config) W, _ = spectrum.get_energy_grid(config) values = spectrum(W) assert isinstance(values, np.ndarray), "Output must be a numpy array" assert values.shape == W.shape, "Output shape must match input shape" def test_all_components_positive(self): """Every component and the total product must be positive.""" config = SpectrumConfig( Z_parent=19, Z_daughter=20, A_number=40, endpoint_MeV=2.5 ) spectrum = BetaSpectrum.from_config(config) W, _ = spectrum.get_energy_grid(config) # Filter out values exactly at the endpoint (where phase space is zero) mask = np.isfinite(spectrum(W)) & (spectrum(W) > 0) assert np.any(mask), "At least some finite positive values expected" class TestBetaSpectrumToggles: """Test that component toggling works correctly.""" def test_all_disabled(self): """With all corrections disabled, only phase space remains — still valid output.""" config = SpectrumConfig( Z_parent=19, Z_daughter=20, A_number=40, endpoint_MeV=2.5, use_fermi=False, use_screening=False, use_finite_size=False, use_charge_dist=False, use_exchange=False, use_radiative=False, ) spectrum = BetaSpectrum.from_config(config) W, _ = spectrum.get_energy_grid(config) values = spectrum(W) assert len(values) > 0, "Should return non-empty array" assert np.all(np.isfinite(values)), "All values must be finite" def test_fermi_only(self): """With only fermi function enabled: should give F₀(Z,W) — not just phase space.""" config_minimal = SpectrumConfig( Z_parent=19, Z_daughter=20, A_number=40, endpoint_MeV=2.5, use_phase_space=False, use_screening=False, use_finite_size=False, use_charge_dist=False, use_exchange=False, use_radiative=False, ) spectrum = BetaSpectrum.from_config(config_minimal) W, _ = spectrum.get_energy_grid(config_minimal) values = spectrum(W) # Fermi function alone is ~1 for Z=20 at mid-energy assert np.all(np.isfinite(values)), "Fermi-only output must be finite" class TestBetaSpectrumComponentExtraction: """Test calculate_components() returns correct per-component data.""" def test_component_names_match(self): """Component names should correspond to class names (minus suffixes).""" config = SpectrumConfig( Z_parent=19, Z_daughter=20, A_number=40, endpoint_MeV=2.5 ) spectrum = BetaSpectrum.from_config(config) W, _ = spectrum.get_energy_grid(config) components = spectrum.calculate_components(W) assert isinstance(components, dict), "Must return a dictionary" assert len(components) > 0, "Should have at least one component" class TestBetaSpectrumRealisticConfig: """Test with realistic physics parameters.""" def test_uranium_spectrum(self): """Heavy nucleus (Z=92) — tests numerical stability at extreme Z. Uranium-238 decays via alpha, but we can still evaluate the beta spectrum shape for testing purposes. High Z stresses the Fermi function's loggamma. """ config = SpectrumConfig( Z_parent=91, Z_daughter=92, A_number=238, endpoint_MeV=0.5 ) spectrum = BetaSpectrum.from_config(config) W, E = spectrum.get_energy_grid(config) values = spectrum(W) assert np.all(np.isfinite(values)), "Uranium must not produce NaN/inf" assert len(values) > 10, f"Grid should have many points: got {len(values)}" def test_low_Z_spectrum(self): """Light nucleus (Z=6 carbon-14 beta decay).""" config = SpectrumConfig( Z_parent=5, Z_daughter=6, A_number=14, endpoint_MeV=0.156, # ~156 keV for C-14 ) spectrum = BetaSpectrum.from_config(config) W, E = spectrum.get_energy_grid(config) values = spectrum(W) assert np.all(np.isfinite(values)), "C-14 must not produce NaN/inf" # Phase space should peak in the bulk for low-Q decay assert np.any(values > 0), "Should have non-zero spectrum values" class TestDeclarativeDetectorResponse: """Test declarative detector response workflow via SpectrumConfig.""" def test_create_detector_from_config(self): """SpectrumConfig detector params should create valid DetectorResponse.""" config = SpectrumConfig( Z_parent=43, Z_daughter=44, A_number=99, endpoint_MeV=0.294, detector_model="gaussian", detector_sigma_a_keV=1.0, detector_n_channels=512, ) detector = BetaSpectrum.create_detector_from_config(config) assert isinstance(detector, DetectorResponse) assert detector.n_channels == 512 assert detector._mode == "analytical" assert detector.model == "gaussian" def test_detector_sigma_conversion(self): """Sigma in keV should be correctly converted to m_e units.""" from beta_spectrum.utils import T_to_W config = SpectrumConfig( Z_parent=43, Z_daughter=44, A_number=99, endpoint_MeV=0.294, detector_sigma_a_keV=1.0, detector_fano_factor=0.0, ) detector = BetaSpectrum.create_detector_from_config(config) # 1.0 keV = 1.0/511.0 m_e ≈ 0.00196 expected_sigma = 1.0 / 0.510998950 sigma_at_endpoint = detector._resolution_sigma(T_to_W(0.294)) assert np.isclose(sigma_at_endpoint, expected_sigma, rtol=0.01) def test_convolve_detector_single_call(self): """convolve_detector() should work in a single call with config params.""" config = SpectrumConfig( Z_parent=43, Z_daughter=44, A_number=99, endpoint_MeV=0.294, detector_model="gaussian", detector_sigma_a_keV=1.0, detector_n_channels=512, detector_fano_factor=0.0, ) spectrum = BetaSpectrum.from_config(config) W, _ = spectrum.get_energy_grid(config) convolved = spectrum.convolve_detector(config, W=W) assert convolved.shape == (512,) assert np.all(convolved >= 0), "Convolved spectrum should be non-negative" assert np.sum(convolved) > 0, "Convolved spectrum should have non-zero area" def test_convolve_detector_with_tail(self): """Convolution with tail model should produce low-energy tailing.""" config = SpectrumConfig( Z_parent=43, Z_daughter=44, A_number=99, endpoint_MeV=0.294, detector_model="gaussian_tail", detector_sigma_a_keV=1.0, detector_tail_fraction=0.2, detector_tau_keV=5.0, detector_n_channels=512, detector_fano_factor=0.0, ) spectrum = BetaSpectrum.from_config(config) W, _ = spectrum.get_energy_grid(config) convolved = spectrum.convolve_detector(config, W=W) assert convolved.shape == (512,) assert np.all(convolved >= 0) def test_analyzer_convolved_from_config(self): """BetaSpectrumAnalyzer should use config detector params automatically.""" config = SpectrumConfig( Z_parent=43, Z_daughter=44, A_number=99, endpoint_MeV=0.294, use_detector_response=True, detector_model="gaussian", detector_sigma_a_keV=1.0, detector_n_channels=256, detector_fano_factor=0.0, ) spectrum = BetaSpectrum.from_config(config) from beta_spectrum import BetaSpectrumAnalyzer analyzer = BetaSpectrumAnalyzer(spectrum, config) detector = BetaSpectrum.create_detector_from_config(config) convolved = analyzer.convolved_spectrum(detector, normalize=True) assert convolved.shape == (256,) # Normalized spectrum should integrate to ~1 over channel energies integral = np.trapezoid(convolved, detector.channel_energies) assert np.isclose( integral, 1.0, rtol=0.05 ), f"Integral should be ~1.0, got {integral}" def test_detector_range_extends_to_endpoint(self): """Detector channel range should extend to cover spectrum endpoint.""" config = SpectrumConfig( Z_parent=43, Z_daughter=44, A_number=99, endpoint_MeV=0.294, detector_channel_energy_range=(0.0, 0.2), # Less than endpoint detector_n_channels=256, detector_fano_factor=0.0, ) detector = BetaSpectrum.create_detector_from_config(config) # Range should extend beyond endpoint in m_e units from beta_spectrum.utils import T_to_W W0 = T_to_W(0.294) assert detector.channel_energies[-1] >= W0, ( f"Detector range {detector.channel_energies[-1]:.4f} should cover " f"endpoint {W0:.4f}" ) def test_different_detector_models(self): """All detector models should produce valid convolved spectra.""" models = ["gaussian", "gaussian_tail", "tikhonov"] for model in models: config = SpectrumConfig( Z_parent=43, Z_daughter=44, A_number=99, endpoint_MeV=0.294, detector_model=model, detector_sigma_a_keV=1.0, detector_n_channels=256, detector_fano_factor=0.0, ) if model == "gaussian_tail": config.detector_tail_fraction = 0.1 config.detector_tau_keV = 5.0 spectrum = BetaSpectrum.from_config(config) W, _ = spectrum.get_energy_grid(config) convolved = spectrum.convolve_detector(config, W=W) assert convolved.shape == (256,) assert np.all(convolved >= 0), f"Model {model} produced negative values"