test: complete migration to structured test suite
Phase 1: Removed 6 duplicate old test files (fermi, finite_size, screening, exchange, radiative, phase_space) — their tests are now in physics/ and quality/ using shared helpers from common/. Phase 2: Migrated remaining 7 old test files: - test_spectrum.py → integration/test_spectrum_pipeline.py (integration tests) - test_fitter.py → physics/test_fitter.py (fit quality tests) - test_cw_extractor.py → physics/test_cw_extractor.py (extraction accuracy) - test_multi_branch.py → physics/test_multi_branch.py (branching physics) - test_detector_response.py → physics/ + quality/ (split into physics + API) - test_nuclear_data.py → quality/test_nuclear_data.py (parsing/API) - test_logging_utils.py → quality/test_logging_utils.py (API tests) Removed 12 old flat files, created 4 new files. Total: 251 tests passing (6 skipped), down from 230 → 251 (net +21 from better coverage, not duplicates). Test structure: - tests/common/ — shared property/API helpers (eliminates ~18 duplicates) - tests/physics/ — 10 files, unique physics behavior tests - tests/quality/ — 8 files, API contract + parsing tests - tests/integration/ — 1 file, end-to-end pipeline tests
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
API / quality tests for DetectorResponse.
|
||||
|
||||
Tests factory methods, repr, error handling.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from beta_spectrum.components.detector_response import DetectorResponse
|
||||
|
||||
|
||||
class TestFactoryMethods:
|
||||
"""Test convenience factory methods."""
|
||||
|
||||
def test_from_gaussian_params(self):
|
||||
"""from_gaussian_params should create correct DetectorResponse."""
|
||||
resp = DetectorResponse.from_gaussian_params(
|
||||
channel_energy_range=(0.0, 5.0),
|
||||
n_channels=1024,
|
||||
sigma_a=0.005,
|
||||
sigma_b=0.01,
|
||||
tail_fraction=0.2,
|
||||
tau=0.01,
|
||||
model="gaussian_tail",
|
||||
fano_factor=0.12,
|
||||
)
|
||||
|
||||
assert resp.n_channels == 1024
|
||||
assert np.isclose(resp.channel_energies[0], 0.0)
|
||||
assert np.isclose(resp.channel_energies[-1], 5.0)
|
||||
assert resp._mode == "analytical"
|
||||
assert resp.tail_fraction == 0.2
|
||||
|
||||
def test_from_mc_simulation(self):
|
||||
"""from_mc_simulation should create tabulated DetectorResponse."""
|
||||
channel_energies = np.linspace(0.0, 5.0, 512)
|
||||
calib_energies = np.array([1.0, 2.0, 3.0])
|
||||
response_matrix = np.zeros((3, 512))
|
||||
for i, e in enumerate(calib_energies):
|
||||
idx = np.argmin(np.abs(channel_energies - e))
|
||||
response_matrix[i, idx] = 1.0
|
||||
|
||||
resp = DetectorResponse.from_mc_simulation(
|
||||
channel_energies=channel_energies,
|
||||
response_matrix=response_matrix,
|
||||
calibration_energies=calib_energies,
|
||||
)
|
||||
|
||||
assert resp._mode == "tabulated"
|
||||
assert resp.n_channels == 512
|
||||
assert len(resp.calibration_energies) == 3
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error handling."""
|
||||
|
||||
def test_repr_analytical(self):
|
||||
"""__repr__ should show analytical mode info."""
|
||||
channel_energies = np.linspace(0.0, 5.0, 100)
|
||||
resp = DetectorResponse(channel_energies, model="gaussian", sigma_a=0.01)
|
||||
repr_str = repr(resp)
|
||||
assert "analytical" in repr_str
|
||||
assert "gaussian" in repr_str
|
||||
|
||||
def test_repr_tabulated(self):
|
||||
"""__repr__ should show tabulated mode info."""
|
||||
channel_energies = np.linspace(0.0, 5.0, 100)
|
||||
calib_energies = np.array([1.0, 2.0])
|
||||
response_matrix = np.zeros((2, 100))
|
||||
resp = DetectorResponse(
|
||||
channel_energies=channel_energies,
|
||||
response_matrix=response_matrix,
|
||||
calibration_energies=calib_energies,
|
||||
)
|
||||
repr_str = repr(resp)
|
||||
assert "tabulated" in repr_str
|
||||
|
||||
def test_invalid_model(self):
|
||||
"""Should reject unknown model names."""
|
||||
channel_energies = np.linspace(0.0, 5.0, 100)
|
||||
resp = DetectorResponse(channel_energies, model="unknown_model")
|
||||
try:
|
||||
resp.get_response(2.0)
|
||||
assert False, "Should have raised ValueError"
|
||||
except ValueError as e:
|
||||
assert "Unknown model" in str(e)
|
||||
|
||||
def test_convolve_mismatched_lengths(self):
|
||||
"""Should reject mismatched W and spectrum_values lengths."""
|
||||
channel_energies = np.linspace(0.0, 5.0, 100)
|
||||
resp = DetectorResponse(channel_energies, model="gaussian")
|
||||
W = np.linspace(0.0, 5.0, 100)
|
||||
spectrum = np.zeros(50)
|
||||
try:
|
||||
resp.convolve(W, spectrum)
|
||||
assert False, "Should have raised ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def test_non_1d_input(self):
|
||||
"""Should reject non-1D inputs."""
|
||||
channel_energies = np.linspace(0.0, 5.0, 100)
|
||||
resp = DetectorResponse(channel_energies, model="gaussian")
|
||||
W = np.array([[0.0, 5.0]])
|
||||
spectrum = np.array([[1.0, 1.0]])
|
||||
try:
|
||||
resp.convolve(W, spectrum)
|
||||
assert False, "Should have raised ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def test_tabulated_validation_wrong_columns(self):
|
||||
"""Should reject response matrix with wrong column count."""
|
||||
channel_energies = np.linspace(0.0, 5.0, 100)
|
||||
calib_energies = np.array([1.0, 2.0])
|
||||
response_matrix = np.zeros((2, 200))
|
||||
|
||||
try:
|
||||
DetectorResponse(
|
||||
channel_energies=channel_energies,
|
||||
response_matrix=response_matrix,
|
||||
calibration_energies=calib_energies,
|
||||
)
|
||||
assert False, "Should have raised ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Tests for logging infrastructure."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from beta_spectrum.logging_utils import (
|
||||
LoggingConfig,
|
||||
_find_git_root,
|
||||
get_git_short_hash,
|
||||
get_logger,
|
||||
setup_logging,
|
||||
)
|
||||
|
||||
|
||||
class TestLoggingConfig:
|
||||
"""Test LoggingConfig dataclass defaults and behavior."""
|
||||
|
||||
def test_default_level(self):
|
||||
config = LoggingConfig()
|
||||
assert config.level == "WARNING"
|
||||
|
||||
def test_default_log_file(self):
|
||||
config = LoggingConfig()
|
||||
assert config.log_file is None
|
||||
|
||||
def test_custom_level(self):
|
||||
config = LoggingConfig(level="DEBUG")
|
||||
assert config.level == "DEBUG"
|
||||
|
||||
def test_custom_log_file(self):
|
||||
config = LoggingConfig(log_file="test.log")
|
||||
assert config.log_file == "test.log"
|
||||
|
||||
|
||||
class TestSetupLogging:
|
||||
"""Test logging setup and configuration."""
|
||||
|
||||
def test_setup_returns_logger(self):
|
||||
logger = setup_logging()
|
||||
assert isinstance(logger, logging.Logger)
|
||||
assert logger.name == "beta_spectrum"
|
||||
|
||||
def test_setup_sets_level(self):
|
||||
config = LoggingConfig(level="DEBUG")
|
||||
logger = setup_logging(config)
|
||||
assert logger.level == logging.DEBUG
|
||||
|
||||
def test_setup_info_level(self):
|
||||
config = LoggingConfig(level="INFO")
|
||||
logger = setup_logging(config)
|
||||
assert logger.level == logging.INFO
|
||||
|
||||
def test_setup_creates_console_handler(self):
|
||||
config = LoggingConfig()
|
||||
logger = setup_logging(config)
|
||||
handlers = [h for h in logger.handlers if isinstance(h, logging.StreamHandler)]
|
||||
assert len(handlers) == 1
|
||||
|
||||
def test_setup_creates_file_handler(self, tmp_path):
|
||||
log_file = str(tmp_path / "test.log")
|
||||
config = LoggingConfig(log_file=log_file)
|
||||
logger = setup_logging(config)
|
||||
file_handlers = [
|
||||
h for h in logger.handlers if isinstance(h, logging.FileHandler)
|
||||
]
|
||||
assert len(file_handlers) == 1
|
||||
assert os.path.exists(log_file)
|
||||
|
||||
def test_setup_prevents_duplicate_handlers(self):
|
||||
config = LoggingConfig()
|
||||
logger1 = setup_logging(config)
|
||||
logger2 = setup_logging(config)
|
||||
assert logger1 is logger2
|
||||
assert len(logger1.handlers) == 1 # Only console handler
|
||||
|
||||
def test_setup_with_file_prevents_duplicates(self, tmp_path):
|
||||
log_file = str(tmp_path / "test2.log")
|
||||
config = LoggingConfig(log_file=log_file)
|
||||
logger1 = setup_logging(config)
|
||||
setup_logging(config) # Second call should not add handlers
|
||||
assert len(logger1.handlers) == 2 # Console + File
|
||||
|
||||
|
||||
class TestGetLogger:
|
||||
"""Test getting named loggers."""
|
||||
|
||||
def test_get_logger_creates_child(self):
|
||||
setup_logging()
|
||||
logger = get_logger("test_module")
|
||||
assert logger.name == "beta_spectrum.test_module"
|
||||
assert logger.parent is not None
|
||||
|
||||
def test_get_logger_nested(self):
|
||||
setup_logging()
|
||||
logger = get_logger("spectrum.components")
|
||||
assert logger.name == "beta_spectrum.spectrum.components"
|
||||
|
||||
|
||||
class TestGitShortHash:
|
||||
"""Test git hash retrieval."""
|
||||
|
||||
def test_returns_short_hash(self):
|
||||
result = get_git_short_hash()
|
||||
assert len(result) == 7
|
||||
assert all(c in "0123456789abcdef" for c in result)
|
||||
|
||||
def test_returns_custom_length(self):
|
||||
result = get_git_short_hash(length=5)
|
||||
assert len(result) == 5
|
||||
|
||||
def test_returns_unknown_outside_repo(self):
|
||||
# Patch _find_git_root to return a non-existent path
|
||||
import unittest.mock
|
||||
|
||||
with unittest.mock.patch(
|
||||
"beta_spectrum.logging_utils._find_git_root",
|
||||
return_value="/nonexistent/path",
|
||||
):
|
||||
result = get_git_short_hash()
|
||||
assert result == "unknown"
|
||||
|
||||
def test_find_git_root(self):
|
||||
root = _find_git_root()
|
||||
assert os.path.exists(os.path.join(root, ".git"))
|
||||
|
||||
|
||||
class TestCSVMetadataHeader:
|
||||
"""Test CSV export metadata header."""
|
||||
|
||||
def test_header_contains_version(self):
|
||||
from beta_spectrum.spectrum import (
|
||||
BetaSpectrum,
|
||||
BetaSpectrumAnalyzer,
|
||||
SpectrumConfig,
|
||||
)
|
||||
|
||||
config = SpectrumConfig(
|
||||
Z_parent=43,
|
||||
Z_daughter=44,
|
||||
A_number=99,
|
||||
endpoint_MeV=0.294,
|
||||
transition_type="F2",
|
||||
)
|
||||
spectrum = BetaSpectrum.from_config(config)
|
||||
analyzer = BetaSpectrumAnalyzer(spectrum, config)
|
||||
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
|
||||
filename = f.name
|
||||
|
||||
analyzer.export_to_csv(filename, source_type="paceENSDF")
|
||||
|
||||
with open(filename, "r") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
assert any("# beta-spectrum v" in line for line in lines)
|
||||
assert any("0.3.0" in line for line in lines)
|
||||
os.unlink(filename)
|
||||
|
||||
def test_header_contains_timestamp(self):
|
||||
from beta_spectrum.spectrum import (
|
||||
BetaSpectrum,
|
||||
BetaSpectrumAnalyzer,
|
||||
SpectrumConfig,
|
||||
)
|
||||
|
||||
config = SpectrumConfig(
|
||||
Z_parent=43,
|
||||
Z_daughter=44,
|
||||
A_number=99,
|
||||
endpoint_MeV=0.294,
|
||||
transition_type="F2",
|
||||
)
|
||||
spectrum = BetaSpectrum.from_config(config)
|
||||
analyzer = BetaSpectrumAnalyzer(spectrum, config)
|
||||
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
|
||||
filename = f.name
|
||||
|
||||
analyzer.export_to_csv(filename, source_type="json")
|
||||
|
||||
with open(filename, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
assert "# timestamp:" in content
|
||||
os.unlink(filename)
|
||||
|
||||
def test_header_contains_nuclide_info(self):
|
||||
from beta_spectrum.spectrum import (
|
||||
BetaSpectrum,
|
||||
BetaSpectrumAnalyzer,
|
||||
SpectrumConfig,
|
||||
)
|
||||
|
||||
config = SpectrumConfig(
|
||||
Z_parent=43,
|
||||
Z_daughter=44,
|
||||
A_number=99,
|
||||
endpoint_MeV=0.294,
|
||||
transition_type="F2",
|
||||
)
|
||||
spectrum = BetaSpectrum.from_config(config)
|
||||
analyzer = BetaSpectrumAnalyzer(spectrum, config)
|
||||
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
|
||||
filename = f.name
|
||||
|
||||
analyzer.export_to_csv(filename, source_type="paceENSDF")
|
||||
|
||||
with open(filename, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
assert "# nuclide: Tc99 -> Ru99" in content
|
||||
assert "Z=43->44" in content
|
||||
os.unlink(filename)
|
||||
|
||||
def test_header_contains_git_commit(self):
|
||||
from beta_spectrum.spectrum import (
|
||||
BetaSpectrum,
|
||||
BetaSpectrumAnalyzer,
|
||||
SpectrumConfig,
|
||||
)
|
||||
|
||||
config = SpectrumConfig(
|
||||
Z_parent=43,
|
||||
Z_daughter=44,
|
||||
A_number=99,
|
||||
endpoint_MeV=0.294,
|
||||
transition_type="F2",
|
||||
)
|
||||
spectrum = BetaSpectrum.from_config(config)
|
||||
analyzer = BetaSpectrumAnalyzer(spectrum, config)
|
||||
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
|
||||
filename = f.name
|
||||
|
||||
analyzer.export_to_csv(filename, source_type="cli")
|
||||
|
||||
with open(filename, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
assert "# git_commit:" in content
|
||||
os.unlink(filename)
|
||||
|
||||
def test_header_contains_corrections(self):
|
||||
from beta_spectrum.spectrum import (
|
||||
BetaSpectrum,
|
||||
BetaSpectrumAnalyzer,
|
||||
SpectrumConfig,
|
||||
)
|
||||
|
||||
config = SpectrumConfig(
|
||||
Z_parent=43,
|
||||
Z_daughter=44,
|
||||
A_number=99,
|
||||
endpoint_MeV=0.294,
|
||||
transition_type="F2",
|
||||
)
|
||||
spectrum = BetaSpectrum.from_config(config)
|
||||
analyzer = BetaSpectrumAnalyzer(spectrum, config)
|
||||
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
|
||||
filename = f.name
|
||||
|
||||
analyzer.export_to_csv(filename, source_type="paceENSDF")
|
||||
|
||||
with open(filename, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
assert "# corrections:" in content
|
||||
assert "phase_space" in content
|
||||
assert "fermi" in content
|
||||
os.unlink(filename)
|
||||
|
||||
|
||||
class TestCLILogging:
|
||||
"""Test CLI logging integration."""
|
||||
|
||||
def test_verbose_flag_sets_info(self):
|
||||
"""Test that -v sets INFO level."""
|
||||
from beta_spectrum.cli import _build_parser
|
||||
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--nuclide", "Tc99", "--dry-run"])
|
||||
assert args.verbose == 0
|
||||
|
||||
def test_double_verbose_flag(self):
|
||||
"""Test that -vv sets DEBUG level."""
|
||||
from beta_spectrum.cli import _build_parser
|
||||
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--nuclide", "Tc99", "--dry-run", "-vv"])
|
||||
assert args.verbose == 2
|
||||
|
||||
def test_quiet_flag(self, capsys):
|
||||
"""Test that -q suppresses output."""
|
||||
from beta_spectrum.cli import _build_parser
|
||||
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--nuclide", "Tc99", "--dry-run", "-q"])
|
||||
assert args.quiet is True
|
||||
|
||||
def test_log_file_flag(self, capsys):
|
||||
"""Test that --log-file sets log file path."""
|
||||
from beta_spectrum.cli import _build_parser
|
||||
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(
|
||||
["--nuclide", "Tc99", "--dry-run", "--log-file", "/tmp/test.log"]
|
||||
)
|
||||
assert args.log_file == "/tmp/test.log"
|
||||
|
||||
def test_dry_run_flag(self, capsys):
|
||||
"""Test that --dry-run is recognized."""
|
||||
from beta_spectrum.cli import _build_parser
|
||||
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--nuclide", "Tc99", "--dry-run"])
|
||||
assert args.dry_run is True
|
||||
|
||||
def test_version_flag(self, capsys):
|
||||
"""Test that --version prints version."""
|
||||
from beta_spectrum.cli import _build_parser
|
||||
|
||||
parser = _build_parser()
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
parser.parse_args(["--version"])
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
def test_removed_mode_flag(self):
|
||||
"""Test that --mode option has been removed."""
|
||||
from beta_spectrum.cli import _build_parser
|
||||
|
||||
parser = _build_parser()
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(["--nuclide", "Tc99", "--mode", "beta_minus"])
|
||||
|
||||
def test_removed_transition_type_flag(self):
|
||||
"""Test that --transition-type option has been removed."""
|
||||
from beta_spectrum.cli import _build_parser
|
||||
|
||||
parser = _build_parser()
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(["--nuclide", "Tc99", "--transition-type", "A"])
|
||||
|
||||
def test_removed_decay_index_flag(self):
|
||||
"""Test that --decay-index option has been removed."""
|
||||
from beta_spectrum.cli import _build_parser
|
||||
|
||||
parser = _build_parser()
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(["--nuclide", "Tc99", "--decay-index", "0"])
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Tests for nuclear_data module (paceENSDF integration and JSON input)."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
# ruff: noqa: E402, ANN201, ANN001
|
||||
from beta_spectrum.nuclear_data import ( # noqa: E402
|
||||
BranchInfo,
|
||||
DecayInfo,
|
||||
create_config_from_source,
|
||||
decay_info_to_config,
|
||||
get_decay_info_from_paceENSDF,
|
||||
json_to_config,
|
||||
load_json_input,
|
||||
validate_json_input,
|
||||
_parse_nuclide_symbol,
|
||||
)
|
||||
from beta_spectrum.spectrum import SpectrumConfig
|
||||
|
||||
|
||||
def _has_paceENSDF() -> bool: # noqa: ANN201
|
||||
"""Check if paceENSDF is available."""
|
||||
try:
|
||||
import paceENSDF # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_nuclide_symbol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseNuclideSymbol:
|
||||
"""Test nuclide symbol parsing."""
|
||||
|
||||
def test_tc99(self):
|
||||
element, Z, A = _parse_nuclide_symbol("Tc99")
|
||||
assert element == "Tc"
|
||||
assert Z == 43
|
||||
assert A == 99
|
||||
|
||||
def test_co60(self):
|
||||
element, Z, A = _parse_nuclide_symbol("Co60")
|
||||
assert element == "Co"
|
||||
assert Z == 27
|
||||
assert A == 60
|
||||
|
||||
def test_he3(self):
|
||||
element, Z, A = _parse_nuclide_symbol("He3")
|
||||
assert element == "He"
|
||||
assert Z == 2
|
||||
assert A == 3
|
||||
|
||||
def test_invalid_symbol(self):
|
||||
with pytest.raises(ValueError, match="Cannot parse nuclide symbol"):
|
||||
_parse_nuclide_symbol("99Tc")
|
||||
|
||||
def test_unknown_element(self):
|
||||
with pytest.raises(ValueError, match="Unknown element symbol"):
|
||||
_parse_nuclide_symbol("Xx99")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_json_input
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateJsonInput:
|
||||
"""Test JSON input validation."""
|
||||
|
||||
def test_valid_input(self):
|
||||
data = {
|
||||
"endpoint_MeV": 0.5,
|
||||
"Z_parent": 43,
|
||||
"Z_daughter": 44,
|
||||
"A_number": 99,
|
||||
}
|
||||
validate_json_input(data) # should not raise
|
||||
|
||||
def test_missing_endpoint(self):
|
||||
data = {"Z_parent": 43, "Z_daughter": 44, "A_number": 99}
|
||||
with pytest.raises(ValueError, match="Missing required field"):
|
||||
validate_json_input(data)
|
||||
|
||||
def test_negative_endpoint(self):
|
||||
data = {
|
||||
"endpoint_MeV": -0.5,
|
||||
"Z_parent": 43,
|
||||
"Z_daughter": 44,
|
||||
"A_number": 99,
|
||||
}
|
||||
with pytest.raises(ValueError, match="endpoint_MeV must be positive"):
|
||||
validate_json_input(data)
|
||||
|
||||
def test_invalid_transition_type(self):
|
||||
data = {
|
||||
"endpoint_MeV": 0.5,
|
||||
"Z_parent": 43,
|
||||
"Z_daughter": 44,
|
||||
"A_number": 99,
|
||||
"transition_type": "invalid",
|
||||
}
|
||||
with pytest.raises(ValueError, match="Invalid transition_type"):
|
||||
validate_json_input(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_json_input / json_to_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestJsonInput:
|
||||
"""Test JSON file loading and conversion."""
|
||||
|
||||
def _write_json(self, data: Dict[str, Any]) -> Path:
|
||||
"""Write data to a temp JSON file."""
|
||||
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
|
||||
json.dump(data, tmp)
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
|
||||
def test_load_and_convert(self):
|
||||
data = {
|
||||
"parent_symbol": "Tc99",
|
||||
"Z_parent": 43,
|
||||
"Z_daughter": 44,
|
||||
"A_number": 99,
|
||||
"endpoint_MeV": 0.294,
|
||||
"transition_type": "F2",
|
||||
"e_step_MeV": 0.001,
|
||||
}
|
||||
path = self._write_json(data)
|
||||
try:
|
||||
loaded = load_json_input(str(path))
|
||||
config = json_to_config(loaded)
|
||||
assert isinstance(config, SpectrumConfig)
|
||||
assert config.Z_parent == 43
|
||||
assert config.Z_daughter == 44
|
||||
assert config.A_number == 99
|
||||
assert abs(config.endpoint_MeV - 0.294) < 1e-6
|
||||
assert config.transition_type == "F2"
|
||||
assert config.e_step_MeV == 0.001
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
def test_defaults_filled(self):
|
||||
data = {
|
||||
"Z_parent": 43,
|
||||
"Z_daughter": 44,
|
||||
"A_number": 99,
|
||||
"endpoint_MeV": 0.294,
|
||||
}
|
||||
path = self._write_json(data)
|
||||
try:
|
||||
loaded = load_json_input(str(path))
|
||||
# Defaults should be filled in
|
||||
assert loaded["detector_model"] == "gaussian"
|
||||
assert loaded["detector_sigma_a_keV"] == 1.0
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
def test_file_not_found(self):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_json_input("/nonexistent/file.json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# decay_info_to_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDecayInfoToConfig:
|
||||
"""Test conversion from DecayInfo to SpectrumConfig."""
|
||||
|
||||
def test_basic_conversion(self):
|
||||
info = DecayInfo(
|
||||
parent_symbol="Tc99",
|
||||
Z_parent=43,
|
||||
Z_daughter=44,
|
||||
A_number=99,
|
||||
endpoint_keV=294000.0,
|
||||
endpoint_MeV=0.294,
|
||||
transition_type="A",
|
||||
forbiddenness_code="0A",
|
||||
parent_J=4.5,
|
||||
parent_pi=1,
|
||||
half_life="y",
|
||||
branches=[],
|
||||
)
|
||||
config = decay_info_to_config(info)
|
||||
assert isinstance(config, SpectrumConfig)
|
||||
assert config.Z_parent == 43
|
||||
assert config.Z_daughter == 44
|
||||
assert config.A_number == 99
|
||||
assert abs(config.endpoint_MeV - 0.294) < 1e-6
|
||||
assert config.transition_type == "A"
|
||||
|
||||
def test_with_detector_response(self):
|
||||
info = DecayInfo(
|
||||
parent_symbol="Co60",
|
||||
Z_parent=27,
|
||||
Z_daughter=28,
|
||||
A_number=60,
|
||||
endpoint_keV=317860.0,
|
||||
endpoint_MeV=0.31786,
|
||||
transition_type="A",
|
||||
forbiddenness_code="1A",
|
||||
parent_J=0.0,
|
||||
parent_pi=1,
|
||||
half_life="y",
|
||||
branches=[],
|
||||
)
|
||||
config = decay_info_to_config(
|
||||
info, use_detector_response=True, e_step_MeV=0.005
|
||||
)
|
||||
assert config.use_detector_response is True
|
||||
assert config.e_step_MeV == 0.005
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_decay_info_from_paceENSDF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_paceENSDF(), reason="paceENSDF not installed")
|
||||
class TestPaceENSDFIntegration:
|
||||
"""Test paceENSDF data retrieval."""
|
||||
|
||||
def test_tc99_beta_minus(self):
|
||||
info = get_decay_info_from_paceENSDF("Tc99", "beta_minus")
|
||||
assert isinstance(info, DecayInfo)
|
||||
assert info.parent_symbol == "Tc99"
|
||||
assert info.Z_parent == 43
|
||||
assert info.Z_daughter == 44
|
||||
assert info.A_number == 99
|
||||
assert info.endpoint_MeV > 0
|
||||
assert len(info.branches) > 0
|
||||
|
||||
def test_co60_beta_minus(self):
|
||||
info = get_decay_info_from_paceENSDF("Co60", "beta_minus")
|
||||
assert isinstance(info, DecayInfo)
|
||||
assert info.parent_symbol == "Co60"
|
||||
assert info.Z_parent == 27
|
||||
assert info.Z_daughter == 28
|
||||
assert info.A_number == 60
|
||||
assert info.endpoint_MeV > 0
|
||||
|
||||
def test_branch_info(self):
|
||||
info = get_decay_info_from_paceENSDF("Tc99", "beta_minus")
|
||||
for branch in info.branches:
|
||||
assert isinstance(branch, BranchInfo)
|
||||
assert branch.level_index >= 0
|
||||
assert branch.level_energy_keV >= 0
|
||||
assert 0 < branch.intensity <= 1.0
|
||||
if branch.log_ft is not None:
|
||||
assert branch.log_ft > 0
|
||||
|
||||
def test_tc99_intensity_values(self):
|
||||
"""Tc99 intensities from ENSDF: GS=99.9984%, 89.5keV=0.0016%."""
|
||||
info = get_decay_info_from_paceENSDF("Tc99", "beta_minus")
|
||||
gs = next(b for b in info.branches if b.level_energy_keV == 0.0)
|
||||
ex = next(b for b in info.branches if b.level_energy_keV == 89.5)
|
||||
assert abs(gs.intensity - 0.999984) < 1e-6
|
||||
assert abs(ex.intensity - 0.000016) < 1e-6
|
||||
|
||||
def test_invalid_nuclide(self):
|
||||
with pytest.raises(ValueError, match="Unknown element symbol"):
|
||||
get_decay_info_from_paceENSDF("Xx99", "beta_minus")
|
||||
|
||||
def test_no_decay_data(self):
|
||||
# Pb208 is stable, no beta-minus decay data
|
||||
with pytest.raises(ValueError, match="No BM decay data found"):
|
||||
get_decay_info_from_paceENSDF("Pb208", "beta_minus")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_config_from_source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateConfigFromSource:
|
||||
"""Test the unified config creation interface."""
|
||||
|
||||
def _write_json(self, data: Dict[str, Any]) -> Path:
|
||||
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
|
||||
json.dump(data, tmp)
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
|
||||
def test_json_source(self):
|
||||
data = {
|
||||
"Z_parent": 43,
|
||||
"Z_daughter": 44,
|
||||
"A_number": 99,
|
||||
"endpoint_MeV": 0.294,
|
||||
}
|
||||
path = self._write_json(data)
|
||||
try:
|
||||
config = create_config_from_source("json", json_path=str(path))
|
||||
assert isinstance(config, SpectrumConfig)
|
||||
assert config.Z_parent == 43
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
def test_unknown_source(self):
|
||||
with pytest.raises(ValueError, match="Unknown source"):
|
||||
create_config_from_source("unknown", nuclide="Tc99")
|
||||
|
||||
def test_paceENSDF_requires_nuclide(self):
|
||||
with pytest.raises(ValueError, match="nuclide parameter required"):
|
||||
create_config_from_source("paceENSDF")
|
||||
|
||||
def test_json_requires_path(self):
|
||||
with pytest.raises(ValueError, match="json_path parameter required"):
|
||||
create_config_from_source("json")
|
||||
Reference in New Issue
Block a user