Files
beta_shape_pnpi/tests/quality/test_nuclear_data.py
T
unzhakov 36174b5d25 refactor: remove stale detector schema keys and single-mode duplicates
- DEFAULT_JSON_SCHEMA: drop the 8 detector_* keys left over from the
  removed detector_response module; CLI dry-run drops the dead
  'Detector: not configured (Phase 4)' line
- CSV export: single-mode no longer writes branch_1_PhaseSpace /
  branch_1_Radiative duplicates next to the bare PhaseSpace/Radiative
  columns; per-branch core factors only in multi-branch mode
- Visualizer: debug panels fall back to the unprefixed core-factor
  columns for single-mode CSVs instead of skipping them
- Spectrum ylabel now states the actual units: normalized spectrum
  [MeV^-1], not 'Normalized Counts / keV'
- tests: defaults assert detector keys gone; column layout pinned for
  single vs multi; panel and ylabel assertions
2026-10-02 10:21:07 +03:00

430 lines
14 KiB
Python

"""Tests for nuclear_data module (paceENSDF integration and JSON input)."""
import json
import tempfile
from pathlib import Path
from typing import Any, Dict
import numpy as np
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 BetaSpectrum, 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. The detector_* schema keys were
# removed together with the detector_response module; JSON
# defaults carry spectrum physics only.
assert "detector_model" not in loaded
assert loaded["neutrino_mass_MeV"] == 0.0
assert loaded["transition_type"] == "F2"
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_e_step_and_cutoff(self):
"""Verify e_step_MeV and intensity_cutoff kwargs are passed through."""
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=[
BranchInfo(
level_index=0,
level_energy_keV=0.0,
intensity=0.5,
log_ft=5.0,
transition_type="A",
),
BranchInfo(
level_index=1,
level_energy_keV=10.0,
intensity=0.3,
log_ft=6.0,
transition_type="A",
),
],
)
config = decay_info_to_config(info, e_step_MeV=0.005, intensity_cutoff=0.4)
assert config.e_step_MeV == 0.005
assert config.intensity_cutoff == 0.4
# Only branch with intensity >= 0.4 should be included
assert config.branches is not None
assert len(config.branches) == 1
assert config.branches[0].intensity == 0.5
def test_cutoff_single_survivor_endpoint_follows_branch(self):
"""A lone surviving branch must define the spectrum, not the stale
top-level endpoint inherited from the parent decay (D1)."""
info = DecayInfo(
parent_symbol="Cs137",
Z_parent=55,
Z_daughter=56,
A_number=137,
endpoint_keV=1176.0,
endpoint_MeV=1.176,
transition_type="F2",
forbiddenness_code="2F",
parent_J=7.0,
parent_pi=1,
half_life="y",
branches=[
BranchInfo(
level_index=0,
level_energy_keV=0.0,
intensity=0.053,
log_ft=5.0,
transition_type="F2",
),
BranchInfo(
level_index=1,
level_energy_keV=661.66,
intensity=0.947,
log_ft=5.0,
transition_type="A",
),
],
)
cfg = decay_info_to_config(info, intensity_cutoff=0.9)
assert cfg.branches is not None
assert len(cfg.branches) == 1
lone_ep = cfg.branches[0].endpoint_MeV
assert lone_ep == pytest.approx(1.176 - 0.66166)
spectrum = BetaSpectrum.from_config(cfg)
assert not spectrum._is_multi
W, E = spectrum.get_energy_grid(cfg)
# Grid capped at the surviving branch endpoint, not 1.176 MeV.
assert float(E.max()) < lone_ep + 1e-12
values = spectrum(W)
assert np.all(np.isfinite(values))
assert np.all(values >= 0.0)
# ---------------------------------------------------------------------------
# get_decay_info_from_paceENSDF
# ---------------------------------------------------------------------------
@pytest.mark.slow
@pytest.mark.requires_ensdf
@pytest.mark.skipif(not _has_paceENSDF(), reason="paceENSDF not installed")
class TestPaceENSDFBranchEndpoints:
"""Per-branch endpoints are the measured transition endpoint energies
(ENSDF column 7), not base-endpoint minus level energy subtractions
with a base taken from an arbitrary first branch (D3)."""
def _branch_endpoints(self, nuclide: str) -> list[float]:
info = get_decay_info_from_paceENSDF(nuclide, "beta_minus")
cfg = decay_info_to_config(info)
assert cfg.branches is not None
return sorted(
(b.endpoint_MeV for b in cfg.branches),
reverse=True,
)
def test_co60_branch_endpoints_positive(self) -> None:
# Measured endpoints: 1492.0, 670.0, 317.88 keV (levels above the
# first branch's endpoint previously produced negative values and
# a hard ValueError from BranchConfig validation).
eps = self._branch_endpoints("Co60")
assert eps == pytest.approx([1.492, 0.670, 0.31788], abs=1e-6)
assert all(e > 0 for e in eps)
def test_tc99_branch_endpoints(self) -> None:
eps = self._branch_endpoints("Tc99")
assert eps == pytest.approx([0.2975, 0.2080], abs=1e-6)
def test_cs137_branch_endpoints(self) -> None:
eps = self._branch_endpoints("Cs137")
assert eps == pytest.approx([1.176, 0.89213, 0.51403], abs=1e-6)
def test_overall_endpoint_is_max_branch_endpoint(self) -> None:
info = get_decay_info_from_paceENSDF("Co60", "beta_minus")
assert info.endpoint_MeV == pytest.approx(1.492, abs=1e-6)
@pytest.mark.requires_ensdf
@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")