fix: resolve 4 fatal bugs — json_to_config, create_config_from_source, Am-241 constant, CLI positional args
This commit is contained in:
+50
-23
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from beta_spectrum.logging_utils import LoggingConfig, setup_logging
|
from beta_spectrum.logging_utils import LoggingConfig, setup_logging
|
||||||
from beta_spectrum.spectrum import SpectrumConfig
|
from beta_spectrum.spectrum import SpectrumConfig
|
||||||
@@ -52,8 +53,18 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
version=f"%(prog)s {__import__('beta_spectrum').__version__}",
|
version=f"%(prog)s {__import__('beta_spectrum').__version__}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Source selection (mutually exclusive)
|
# Source selection (positional or flag)
|
||||||
source_group = parser.add_mutually_exclusive_group(required=True)
|
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(
|
source_group.add_argument(
|
||||||
"--nuclide",
|
"--nuclide",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -171,12 +182,36 @@ def _run(args: argparse.Namespace) -> None:
|
|||||||
logger.info("bs_pnpi starting...")
|
logger.info("bs_pnpi starting...")
|
||||||
logger.debug("Verbosity: %s | Log file: %s", log_level, args.log_file or "none")
|
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
|
# Build config
|
||||||
source_type: str
|
|
||||||
config: SpectrumConfig
|
config: SpectrumConfig
|
||||||
if args.nuclide:
|
if source_type == "paceENSDF":
|
||||||
logger.info("Source: paceENSDF nuclide=%s", args.nuclide)
|
logger.info("Source: paceENSDF nuclide=%s", source_value)
|
||||||
decay_info = get_decay_info_from_paceENSDF(args.nuclide, "beta_minus")
|
decay_info = get_decay_info_from_paceENSDF(source_value, "beta_minus")
|
||||||
|
|
||||||
# Normalize branch intensities to sum to 1.0
|
# Normalize branch intensities to sum to 1.0
|
||||||
if decay_info.branches:
|
if decay_info.branches:
|
||||||
@@ -208,24 +243,21 @@ def _run(args: argparse.Namespace) -> None:
|
|||||||
else:
|
else:
|
||||||
config = create_config_from_source(
|
config = create_config_from_source(
|
||||||
"paceENSDF",
|
"paceENSDF",
|
||||||
nuclide=args.nuclide,
|
nuclide=source_value,
|
||||||
e_step_MeV=args.e_step,
|
e_step_MeV=args.e_step,
|
||||||
|
intensity_cutoff=args.intensity_cutoff,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
config = create_config_from_source(
|
config = create_config_from_source(
|
||||||
"paceENSDF",
|
"paceENSDF",
|
||||||
nuclide=args.nuclide,
|
nuclide=source_value,
|
||||||
e_step_MeV=args.e_step,
|
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:
|
else:
|
||||||
logger.error("No source specified (use --nuclide or --input)")
|
logger.info("Source: JSON file=%s", source_value)
|
||||||
sys.exit(1)
|
data = load_json_input(source_value)
|
||||||
|
config = json_to_config(data)
|
||||||
|
|
||||||
# Dry run: display resolved config and exit
|
# Dry run: display resolved config and exit
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
@@ -318,13 +350,8 @@ def _print_dry_run_output(config, source_type: str) -> None:
|
|||||||
else:
|
else:
|
||||||
print("Mode: single-branch")
|
print("Mode: single-branch")
|
||||||
|
|
||||||
if config.use_detector_response:
|
# Detector response is a Phase 4 feature — not yet implemented
|
||||||
print(
|
print("Detector: not configured (Phase 4)")
|
||||||
f"Detector: {config.detector_model} (sigma={config.detector_sigma_a_keV} keV, "
|
|
||||||
f"tail={config.detector_tail_fraction})"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print("Detector: disabled")
|
|
||||||
print("========================================")
|
print("========================================")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ def _get_decay_mode_symbol(
|
|||||||
}
|
}
|
||||||
if decay_type not in mapping:
|
if decay_type not in mapping:
|
||||||
raise ValueError(
|
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]
|
return mapping[decay_type]
|
||||||
|
|
||||||
@@ -638,9 +638,6 @@ def json_to_config(data: Dict[str, Any]) -> SpectrumConfig:
|
|||||||
endpoint_MeV=float(data["endpoint_MeV"]),
|
endpoint_MeV=float(data["endpoint_MeV"]),
|
||||||
transition_type=str(data.get("transition_type", "A")),
|
transition_type=str(data.get("transition_type", "A")),
|
||||||
e_step_MeV=float(data.get("e_step_MeV", 0.001)),
|
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_type = kwargs.get("decay_type", "beta_minus")
|
||||||
decay_index = kwargs.get("decay_index", None)
|
decay_index = kwargs.get("decay_index", None)
|
||||||
e_step = kwargs.get("e_step_MeV", 0.001)
|
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)
|
info = get_decay_info_from_paceENSDF(nuclide, decay_type, decay_index)
|
||||||
return decay_info_to_config(
|
return decay_info_to_config(
|
||||||
|
info,
|
||||||
|
e_step_MeV=e_step,
|
||||||
|
intensity_cutoff=intensity_cutoff,
|
||||||
)
|
)
|
||||||
elif source == "json":
|
elif source == "json":
|
||||||
if not json_path:
|
if not json_path:
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ class EnergyCalibrator:
|
|||||||
filepath : str or Path
|
filepath : str or Path
|
||||||
Path to the Am-241 binary calibration file (CAM1.DAT or CAM2.DAT).
|
Path to the Am-241 binary calibration file (CAM1.DAT or CAM2.DAT).
|
||||||
known_energies : list of float, optional
|
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
|
n_channels : int
|
||||||
Number of channels (default 4096).
|
Number of channels (default 4096).
|
||||||
|
|
||||||
@@ -352,8 +352,11 @@ class EnergyCalibrator:
|
|||||||
peak_counts = counts_search[peaks]
|
peak_counts = counts_search[peaks]
|
||||||
|
|
||||||
# Known Am-241 gamma lines (keV)
|
# 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:
|
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
|
# Match peaks to known energies using a simpler, more robust
|
||||||
# approach: find the two strongest peaks that span the largest
|
# approach: find the two strongest peaks that span the largest
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ def calibrate_from_am241(
|
|||||||
Path to the Am-241 binary file (CAM1.DAT or CAM2.DAT).
|
Path to the Am-241 binary file (CAM1.DAT or CAM2.DAT).
|
||||||
known_peaks
|
known_peaks
|
||||||
Mapping of energy_keV -> expected channel. If None, auto-detects
|
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
|
n_channels
|
||||||
Number of channels.
|
Number of channels.
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ def calibrate_from_am241(
|
|||||||
_peak_heights = props["peak_heights"] # noqa: F841
|
_peak_heights = props["peak_heights"] # noqa: F841
|
||||||
|
|
||||||
# Known Am-241 gamma lines (keV)
|
# 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:
|
if known_peaks:
|
||||||
am241_lines = known_peaks
|
am241_lines = known_peaks
|
||||||
else:
|
else:
|
||||||
@@ -220,7 +220,7 @@ def read_am241_calibration(
|
|||||||
"""
|
"""
|
||||||
filepath = Path(filepath)
|
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
|
# The spectrum typically has a few prominent peaks
|
||||||
with open(filepath, "rb") as f:
|
with open(filepath, "rb") as f:
|
||||||
_header = f.read(128)
|
_header = f.read(128)
|
||||||
@@ -232,7 +232,7 @@ def read_am241_calibration(
|
|||||||
metadata = {
|
metadata = {
|
||||||
"source": "Am241",
|
"source": "Am241",
|
||||||
"calibration_type": "energy",
|
"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(
|
return ExpSpectrum(
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from exp_data.raw_reader import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Energy calibration from Am-241 source: E = offset + gain * channel
|
# 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"
|
CALIBRATION_FILE = "data/raw/CAM1.DAT"
|
||||||
_calib_offset, _calib_gain = calibrate_from_am241(CALIBRATION_FILE)
|
_calib_offset, _calib_gain = calibrate_from_am241(CALIBRATION_FILE)
|
||||||
ENERGY_OFFSET = _calib_offset
|
ENERGY_OFFSET = _calib_offset
|
||||||
@@ -234,7 +234,9 @@ def main():
|
|||||||
# Save results
|
# Save results
|
||||||
results_file = output_dir / "rate_analysis.csv"
|
results_file = output_dir / "rate_analysis.csv"
|
||||||
with open(results_file, "w") as f:
|
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)):
|
for i in range(len(run_ids)):
|
||||||
f.write(
|
f.write(
|
||||||
f"{run_ids[i]},{total_rates[i]:.0f},{low_rates[i]:.0f},"
|
f"{run_ids[i]},{total_rates[i]:.0f},{low_rates[i]:.0f},"
|
||||||
@@ -265,7 +267,9 @@ def main():
|
|||||||
# Endpoint
|
# Endpoint
|
||||||
axes[1, 0].plot(run_ids, endpoints, "r-", lw=1.5)
|
axes[1, 0].plot(run_ids, endpoints, "r-", lw=1.5)
|
||||||
axes[1, 0].axhline(
|
axes[1, 0].axhline(
|
||||||
TC99_ENDPOINT, color="gray", ls="--",
|
TC99_ENDPOINT,
|
||||||
|
color="gray",
|
||||||
|
ls="--",
|
||||||
label=f"Tc-99 Q ≈ {TC99_ENDPOINT} keV",
|
label=f"Tc-99 Q ≈ {TC99_ENDPOINT} keV",
|
||||||
)
|
)
|
||||||
axes[1, 0].set_xlabel("Run number")
|
axes[1, 0].set_xlabel("Run number")
|
||||||
|
|||||||
@@ -202,7 +202,8 @@ class TestDecayInfoToConfig:
|
|||||||
assert abs(config.endpoint_MeV - 0.294) < 1e-6
|
assert abs(config.endpoint_MeV - 0.294) < 1e-6
|
||||||
assert config.transition_type == "A"
|
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(
|
info = DecayInfo(
|
||||||
parent_symbol="Co60",
|
parent_symbol="Co60",
|
||||||
Z_parent=27,
|
Z_parent=27,
|
||||||
@@ -215,13 +216,30 @@ class TestDecayInfoToConfig:
|
|||||||
parent_J=0.0,
|
parent_J=0.0,
|
||||||
parent_pi=1,
|
parent_pi=1,
|
||||||
half_life="y",
|
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(
|
config = decay_info_to_config(info, e_step_MeV=0.005, intensity_cutoff=0.4)
|
||||||
info, use_detector_response=True, e_step_MeV=0.005
|
|
||||||
)
|
|
||||||
assert config.use_detector_response is True
|
|
||||||
assert config.e_step_MeV == 0.005
|
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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user