fix: resolve 4 fatal bugs — json_to_config, create_config_from_source, Am-241 constant, CLI positional args

This commit is contained in:
2026-05-26 14:57:35 +03:00
parent 38e97213c5
commit 8b0ced4b65
6 changed files with 99 additions and 46 deletions
+50 -23
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import argparse
import sys
from pathlib import Path
from beta_spectrum.logging_utils import LoggingConfig, setup_logging
from beta_spectrum.spectrum import SpectrumConfig
@@ -52,8 +53,18 @@ def _build_parser() -> argparse.ArgumentParser:
version=f"%(prog)s {__import__('beta_spectrum').__version__}",
)
# Source selection (mutually exclusive)
source_group = parser.add_mutually_exclusive_group(required=True)
# Source selection (positional or flag)
parser.add_argument(
"source",
nargs="?",
default=None,
help="Nuclide symbol (e.g. 'Tc99') or JSON file path. "
"Can also use --nuclide <symbol> or --input <file>. "
"Auto-detected: .json extension → JSON input, otherwise → paceENSDF.",
)
# Source selection flags (backward compatible, mutually exclusive)
source_group = parser.add_mutually_exclusive_group(required=False)
source_group.add_argument(
"--nuclide",
type=str,
@@ -171,12 +182,36 @@ def _run(args: argparse.Namespace) -> None:
logger.info("bs_pnpi starting...")
logger.debug("Verbosity: %s | Log file: %s", log_level, args.log_file or "none")
# Resolve source: positional arg takes precedence, then flags
source_value = args.source
if source_value is None and args.nuclide:
source_value = args.nuclide
elif source_value is None and args.input:
source_value = args.input
elif source_value is not None:
# Auto-detect from positional argument
if source_value.endswith(".json") or Path(source_value).exists():
source_value = source_value
# else: keep as-is (nuclide symbol)
if source_value is None:
logger.error(
"Required: specify nuclide or JSON file "
"(e.g., 'bs_pnpi Tc99' or 'bs_pnpi --nuclide Tc99')"
)
sys.exit(1)
# Determine source type
if source_value.endswith(".json"):
source_type = "json"
else:
source_type = "paceENSDF"
# Build config
source_type: str
config: SpectrumConfig
if args.nuclide:
logger.info("Source: paceENSDF nuclide=%s", args.nuclide)
decay_info = get_decay_info_from_paceENSDF(args.nuclide, "beta_minus")
if source_type == "paceENSDF":
logger.info("Source: paceENSDF nuclide=%s", source_value)
decay_info = get_decay_info_from_paceENSDF(source_value, "beta_minus")
# Normalize branch intensities to sum to 1.0
if decay_info.branches:
@@ -208,24 +243,21 @@ def _run(args: argparse.Namespace) -> None:
else:
config = create_config_from_source(
"paceENSDF",
nuclide=args.nuclide,
nuclide=source_value,
e_step_MeV=args.e_step,
intensity_cutoff=args.intensity_cutoff,
)
else:
config = create_config_from_source(
"paceENSDF",
nuclide=args.nuclide,
nuclide=source_value,
e_step_MeV=args.e_step,
intensity_cutoff=args.intensity_cutoff,
)
source_type = "paceENSDF"
elif args.input:
logger.info("Source: JSON file=%s", args.input)
data = load_json_input(args.input)
config = json_to_config(data)
source_type = "json"
else:
logger.error("No source specified (use --nuclide or --input)")
sys.exit(1)
logger.info("Source: JSON file=%s", source_value)
data = load_json_input(source_value)
config = json_to_config(data)
# Dry run: display resolved config and exit
if args.dry_run:
@@ -318,13 +350,8 @@ def _print_dry_run_output(config, source_type: str) -> None:
else:
print("Mode: single-branch")
if config.use_detector_response:
print(
f"Detector: {config.detector_model} (sigma={config.detector_sigma_a_keV} keV, "
f"tail={config.detector_tail_fraction})"
)
else:
print("Detector: disabled")
# Detector response is a Phase 4 feature — not yet implemented
print("Detector: not configured (Phase 4)")
print("========================================")
+5 -4
View File
@@ -272,7 +272,7 @@ def _get_decay_mode_symbol(
}
if decay_type not in mapping:
raise ValueError(
f"Unknown decay type '{decay_type}'. " f"Supported: {list(mapping.keys())}"
f"Unknown decay type '{decay_type}'. Supported: {list(mapping.keys())}"
)
return mapping[decay_type]
@@ -638,9 +638,6 @@ def json_to_config(data: Dict[str, Any]) -> SpectrumConfig:
endpoint_MeV=float(data["endpoint_MeV"]),
transition_type=str(data.get("transition_type", "A")),
e_step_MeV=float(data.get("e_step_MeV", 0.001)),
detector_model=str(data.get("detector_model", "gaussian")),
detector_channel_energy_range=tuple(
),
)
@@ -685,8 +682,12 @@ def create_config_from_source(
decay_type = kwargs.get("decay_type", "beta_minus")
decay_index = kwargs.get("decay_index", None)
e_step = kwargs.get("e_step_MeV", 0.001)
intensity_cutoff = kwargs.get("intensity_cutoff", 0.0)
info = get_decay_info_from_paceENSDF(nuclide, decay_type, decay_index)
return decay_info_to_config(
info,
e_step_MeV=e_step,
intensity_cutoff=intensity_cutoff,
)
elif source == "json":
if not json_path:
+5 -2
View File
@@ -312,7 +312,7 @@ class EnergyCalibrator:
filepath : str or Path
Path to the Am-241 binary calibration file (CAM1.DAT or CAM2.DAT).
known_energies : list of float, optional
Known Am-241 line energies in keV. Defaults to [13.8, 17.8, 26.3].
Known Am-241 line energies in keV. Defaults to [13.9, 17.8, 26.3, 59.54].
n_channels : int
Number of channels (default 4096).
@@ -352,8 +352,11 @@ class EnergyCalibrator:
peak_counts = counts_search[peaks]
# Known Am-241 gamma lines (keV)
# Am-241 decays to Np-237; Np K X-rays at 13.9 keV (Kα), 17.8 keV (Kβ),
# 20.8 keV (L-series); Np gamma at 26.3 keV; Np/Am gamma at 59.54 keV.
# Source: LNHb Table de Radionucléides, IAEA nuclear data.
if known_energies is None:
known_energies = [13.0, 17.8, 26.3, 59.54]
known_energies = [13.9, 17.8, 26.3, 59.54]
# Match peaks to known energies using a simpler, more robust
# approach: find the two strongest peaks that span the largest
+4 -4
View File
@@ -151,7 +151,7 @@ def calibrate_from_am241(
Path to the Am-241 binary file (CAM1.DAT or CAM2.DAT).
known_peaks
Mapping of energy_keV -> expected channel. If None, auto-detects
Am-241 peaks at 13.8, 17.8, 26.3 keV.
Am-241 peaks at 13.9, 17.8, 26.3 keV.
n_channels
Number of channels.
@@ -175,7 +175,7 @@ def calibrate_from_am241(
_peak_heights = props["peak_heights"] # noqa: F841
# Known Am-241 gamma lines (keV)
am241_lines = {13.8: None, 17.8: None, 26.3: None}
am241_lines = {13.9: None, 17.8: None, 26.3: None}
if known_peaks:
am241_lines = known_peaks
else:
@@ -220,7 +220,7 @@ def read_am241_calibration(
"""
filepath = Path(filepath)
# Am-241 known peaks (keV): 13.8, 17.8, 26.3, 59.54
# Am-241 known peaks (keV): 13.9, 17.8, 26.3, 59.54
# The spectrum typically has a few prominent peaks
with open(filepath, "rb") as f:
_header = f.read(128)
@@ -232,7 +232,7 @@ def read_am241_calibration(
metadata = {
"source": "Am241",
"calibration_type": "energy",
"known_peaks_keV": [13.8, 17.8, 26.3, 59.54],
"known_peaks_keV": [13.9, 17.8, 26.3, 59.54],
}
return ExpSpectrum(
+7 -3
View File
@@ -29,7 +29,7 @@ from exp_data.raw_reader import (
)
# Energy calibration from Am-241 source: E = offset + gain * channel
# Determined automatically from known Am-241 gamma lines (13.8, 17.8, 26.3 keV)
# Determined automatically from known Am-241 gamma lines (13.9, 17.8, 26.3 keV)
CALIBRATION_FILE = "data/raw/CAM1.DAT"
_calib_offset, _calib_gain = calibrate_from_am241(CALIBRATION_FILE)
ENERGY_OFFSET = _calib_offset
@@ -234,7 +234,9 @@ def main():
# Save results
results_file = output_dir / "rate_analysis.csv"
with open(results_file, "w") as f:
f.write("run,total_counts,low_energy_counts,high_energy_counts,low_fraction,endpoint_keV\n")
f.write(
"run,total_counts,low_energy_counts,high_energy_counts,low_fraction,endpoint_keV\n"
)
for i in range(len(run_ids)):
f.write(
f"{run_ids[i]},{total_rates[i]:.0f},{low_rates[i]:.0f},"
@@ -265,7 +267,9 @@ def main():
# Endpoint
axes[1, 0].plot(run_ids, endpoints, "r-", lw=1.5)
axes[1, 0].axhline(
TC99_ENDPOINT, color="gray", ls="--",
TC99_ENDPOINT,
color="gray",
ls="--",
label=f"Tc-99 Q ≈ {TC99_ENDPOINT} keV",
)
axes[1, 0].set_xlabel("Run number")
+24 -6
View File
@@ -202,7 +202,8 @@ class TestDecayInfoToConfig:
assert abs(config.endpoint_MeV - 0.294) < 1e-6
assert config.transition_type == "A"
def test_with_detector_response(self):
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,
@@ -215,13 +216,30 @@ class TestDecayInfoToConfig:
parent_J=0.0,
parent_pi=1,
half_life="y",
branches=[],
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, use_detector_response=True, e_step_MeV=0.005
)
assert config.use_detector_response is True
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
# ---------------------------------------------------------------------------