Files
beta_shape_pnpi/beta_spectrum/cli.py
T

550 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Command-line interface for beta-spectrum calculations.
Usage examples:
# From paceENSDF nuclear data
bs_pnpi --nuclide Tc99 --output spectrum.csv
# From custom JSON input
bs_pnpi --input custom_input.json --output spectrum.csv
# Full analysis with plotting
bs_pnpi --nuclide Co60 --plot analysis.png --output spectrum.csv
# With detector response
bs_pnpi --nuclide Tc99 --detector gaussian --output convolved.csv
# Verbose with log file
bs_pnpi --nuclide Tc99 --output spectrum.csv -vv --log-file /tmp/calc.log
# Dry run to inspect resolved configuration
bs_pnpi --nuclide Tc99 --dry-run
"""
from __future__ import annotations
import argparse
import sys
from typing import Any, Dict
from beta_spectrum.logging_utils import LoggingConfig, setup_logging
from beta_spectrum.spectrum import SpectrumConfig
def _build_parser() -> argparse.ArgumentParser:
"""Build the argument parser."""
parser = argparse.ArgumentParser(
prog="bs_pnpi",
description="Beta spectrum calculation toolkit — "
"calculate high-precision beta decay energy spectra.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" bs_pnpi --nuclide Tc99 --output spectrum.csv\n"
" bs_pnpi --input custom_input.json --output spectrum.csv\n"
" bs_pnpi --nuclide Tc99 --plot spectrum.png --output spectrum.csv\n"
" bs_pnpi --nuclide Tc99 --detector gaussian --output convolved.csv\n"
" bs_pnpi --nuclide Tc99 --output spectrum.csv -vv --log-file /tmp/calc.log\n"
" bs_pnpi --nuclide Tc99 --dry-run\n"
),
)
# Version
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {__import__('beta_spectrum').__version__}",
)
# Source selection (mutually exclusive)
source_group = parser.add_mutually_exclusive_group(required=True)
source_group.add_argument(
"--nuclide",
type=str,
help="Nuclide symbol for paceENSDF lookup (e.g. 'Tc99', 'Co60'). "
"Retrieves decay parameters from the ENSDF database via paceENSDF.",
)
source_group.add_argument(
"--input",
type=str,
metavar="FILE",
help="Path to a JSON input file with custom parameters.",
)
# Common parameters
parser.add_argument(
"--e-step",
type=float,
default=0.001,
help="Energy step size in MeV (default: 0.001). All energies are in MeV throughout the toolkit.",
)
# Output
parser.add_argument(
"--output",
type=str,
metavar="FILE",
help="Output CSV file path.",
)
# Plotting
parser.add_argument(
"--plot",
type=str,
metavar="FILE",
help="Save analysis plot to the specified path.",
)
# Detector response
detector_group = parser.add_argument_group("detector response")
detector_group.add_argument(
"--detector",
type=str,
default=None,
metavar="MODEL",
help="Enable detector response convolution. "
"Specify model: 'gaussian', 'gaussian_tail', 'tikhonov', 'tabulated'. "
"All detector parameters are in keV.",
)
detector_group.add_argument(
"--sigma",
type=float,
default=1.0,
metavar="KEV",
help="Resolution parameter sigma in keV (default: 1.0). Energy-dependent part: sigma(E) = sigma_a + sigma_b * sqrt(E).",
)
detector_group.add_argument(
"--tau",
type=float,
default=5.0,
metavar="KEV",
help="Low-energy tail decay constant tau in keV (default: 5.0). Only used with 'gaussian_tail' model.",
)
detector_group.add_argument(
"--tail-fraction",
type=float,
default=0.0,
metavar="FRAC",
help="Low-energy tail fraction (0.0–1.0, default: 0.0). Only used with 'gaussian_tail' model.",
)
# Logging and output control
log_group = parser.add_argument_group("logging and output")
log_group.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="Increase verbosity: -v=INFO (shows source, components, and workflow), "
"-vv=DEBUG (detailed internals of all spectrum components). Repeatable.",
)
log_group.add_argument(
"-q",
"--quiet",
action="store_true",
help="Suppress all terminal output (only errors shown).",
)
log_group.add_argument(
"--log-file",
type=str,
metavar="PATH",
help="Write log messages to the specified file path (absolute or relative). "
"Log file is always opened in append mode at DEBUG level.",
)
log_group.add_argument(
"--dry-run",
action="store_true",
help="Validate input and display resolved configuration without computing spectrum.",
)
log_group.add_argument(
"--intensity-cutoff",
type=float,
default=0.0,
metavar="FRAC",
help="Minimum branch intensity fraction to include in the spectrum "
"(default: 0.0 = include all branches). "
"Branches below this threshold are excluded from the calculation and plots.",
)
return parser
def _parse_detector_args(args: argparse.Namespace) -> Dict[str, Any]:
"""Parse detector-related arguments into a config dict."""
if not args.detector:
return {}
return {
"use_detector_response": True,
"detector_model": args.detector,
"detector_sigma_a_keV": args.sigma,
"detector_tau_keV": args.tau,
"detector_tail_fraction": args.tail_fraction,
}
def _run(args: argparse.Namespace) -> None:
"""Execute the CLI command."""
import time
from beta_spectrum.logging_utils import get_logger
from beta_spectrum.nuclear_data import (
create_config_from_source,
get_decay_info_from_paceENSDF,
json_to_config,
load_json_input,
)
from beta_spectrum.spectrum import (
BetaSpectrum,
BranchConfig,
SpectrumConfig,
)
from beta_spectrum.analyzer import BetaSpectrumAnalyzer
# Determine logging level
if args.quiet:
log_level = "WARNING"
elif args.verbose >= 2:
log_level = "DEBUG"
elif args.verbose >= 1:
log_level = "INFO"
else:
log_level = "WARNING"
# Setup logging
log_config = LoggingConfig(
level=log_level,
log_file=args.log_file,
)
logger = setup_logging(log_config)
logger = get_logger("cli")
logger.info("bs_pnpi starting...")
logger.debug("Verbosity: %s | Log file: %s", log_level, args.log_file or "none")
# Build config
detector_kwargs = _parse_detector_args(args)
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")
# Normalize branch intensities to sum to 1.0
if decay_info.branches:
total_intensity = sum(b.intensity for b in decay_info.branches)
if total_intensity > 0:
branches = [
BranchConfig(
endpoint_MeV=(
(decay_info.endpoint_MeV - b.level_energy_keV / 1000.0)
if b.level_energy_keV > 0
else decay_info.endpoint_MeV
),
transition_type=b.transition_type,
intensity=b.intensity / total_intensity,
)
for b in decay_info.branches
if b.intensity / total_intensity >= args.intensity_cutoff
]
config = SpectrumConfig(
Z_parent=decay_info.Z_parent,
Z_daughter=decay_info.Z_daughter,
A_number=decay_info.A_number,
endpoint_MeV=decay_info.endpoint_MeV,
transition_type=decay_info.transition_type,
e_step_MeV=args.e_step,
branches=branches,
intensity_cutoff=args.intensity_cutoff,
use_detector_response=detector_kwargs.get(
"use_detector_response", False
),
detector_model=detector_kwargs.get("detector_model", "gaussian"),
detector_sigma_a_keV=detector_kwargs.get(
"detector_sigma_a_keV", 1.0
),
detector_tau_keV=detector_kwargs.get("detector_tau_keV", 5.0),
detector_tail_fraction=detector_kwargs.get(
"detector_tail_fraction", 0.0
),
)
else:
config = create_config_from_source(
"paceENSDF",
nuclide=args.nuclide,
e_step_MeV=args.e_step,
use_detector_response=detector_kwargs.get(
"use_detector_response", False
),
detector_model=detector_kwargs.get("detector_model", "gaussian"),
detector_sigma_a_keV=detector_kwargs.get(
"detector_sigma_a_keV", 1.0
),
detector_tau_keV=detector_kwargs.get("detector_tau_keV", 5.0),
detector_tail_fraction=detector_kwargs.get(
"detector_tail_fraction", 0.0
),
)
else:
config = create_config_from_source(
"paceENSDF",
nuclide=args.nuclide,
e_step_MeV=args.e_step,
use_detector_response=detector_kwargs.get(
"use_detector_response", False
),
detector_model=detector_kwargs.get("detector_model", "gaussian"),
detector_sigma_a_keV=detector_kwargs.get("detector_sigma_a_keV", 1.0),
detector_tau_keV=detector_kwargs.get("detector_tau_keV", 5.0),
detector_tail_fraction=detector_kwargs.get(
"detector_tail_fraction", 0.0
),
)
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)
# Dry run: display resolved config and exit
if args.dry_run:
_print_dry_run_output(config, source_type)
return
# Create and calculate spectrum
start_time = time.time()
mode_str = ""
if config.branches and len(config.branches) > 1:
mode_str = f" (multi-branch: {len(config.branches)} branches)"
logger.info(
"Creating BetaSpectrum for %s, endpoint=%.3f MeV%s",
_decay_notation(config.Z_parent, config.Z_daughter, config.A_number),
config.endpoint_MeV,
mode_str,
)
spectrum = BetaSpectrum.from_config(config, logger=logger)
_W, _kinetic_MeV = spectrum.get_energy_grid(config)
_ = spectrum(_W)
elapsed = time.time() - start_time
logger.info(
"Spectrum calculation done in %.3fs (%d points)", elapsed, len(_kinetic_MeV)
)
# Export to CSV
if args.output:
analyzer = BetaSpectrumAnalyzer(spectrum, config, logger=logger)
analyzer.export_to_csv(args.output, source_type=source_type)
logger.info("Spectrum exported to %s", args.output)
# Generate plot
if args.plot:
analyzer = BetaSpectrumAnalyzer(spectrum, config, logger=logger)
analyzer.plot_analysis(
save_path=args.plot,
show_components=(args.verbose >= 2),
)
logger.info("Analysis plot saved to %s", args.plot)
logger.info("Done.")
def _print_dry_run_output(config, source_type: str) -> None:
"""Print resolved configuration for dry-run mode."""
from beta_spectrum.logging_utils import get_git_short_hash
enabled = []
if config.use_phase_space:
enabled.append("phase_space")
if config.use_fermi:
enabled.append("fermi")
if config.use_screening:
enabled.append("screening")
if config.use_finite_size:
enabled.append("finite_size")
if config.use_charge_dist:
enabled.append("charge_dist")
if config.use_radiative:
enabled.append("radiative")
if config.use_exchange:
enabled.append("exchange")
print("=== Dry Run: Resolved Configuration ===")
print(f"Package: beta-spectrum v{__import__('beta_spectrum').__version__}")
print(f"Git commit: {get_git_short_hash()}")
print(f"Source: {source_type}")
print(
f"Nuclide: {_decay_notation(config.Z_parent, config.Z_daughter, config.A_number)}"
)
print(f"Endpoint: {config.endpoint_MeV * 1000:.1f} keV")
print(f"Transition: {config.transition_type}")
print(f"e_step: {config.e_step_MeV:.4f} MeV")
print(f"Corrections: {', '.join(enabled) if enabled else 'none'}")
if config.branches and len(config.branches) > 1:
print(f"\nMulti-branch mode: {len(config.branches)} branches")
if config.intensity_cutoff > 0:
print(
f"Intensity cutoff: {config.intensity_cutoff:.4f} ({config.intensity_cutoff*100:.1f}%)"
)
for i, branch in enumerate(config.branches):
print(
f" Branch {i+1}: E₀={branch.endpoint_MeV*1000:.1f} keV, "
f"transition={branch.transition_type}, "
f"intensity={branch.intensity:.4f}"
)
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")
print("========================================")
def _element_symbol(Z: int) -> str:
"""Get element symbol from atomic number."""
symbols = [
"",
"H",
"He",
"Li",
"Be",
"B",
"C",
"N",
"O",
"F",
"Ne",
"Na",
"Mg",
"Al",
"Si",
"P",
"S",
"Cl",
"Ar",
"K",
"Ca",
"Sc",
"Ti",
"V",
"Cr",
"Mn",
"Fe",
"Co",
"Ni",
"Cu",
"Zn",
"Ga",
"Ge",
"As",
"Se",
"Br",
"Kr",
"Rb",
"Sr",
"Y",
"Zr",
"Nb",
"Mo",
"Tc",
"Ru",
"Rh",
"Pd",
"Ag",
"Cd",
"In",
"Sn",
"Sb",
"Te",
"I",
"Xe",
"Cs",
"Ba",
"La",
"Ce",
"Pr",
"Nd",
"Pm",
"Sm",
"Eu",
"Gd",
"Tb",
"Dy",
"Ho",
"Er",
"Tm",
"Yb",
"Lu",
"Hf",
"Ta",
"W",
"Re",
"Os",
"Ir",
"Pt",
"Au",
"Hg",
"Tl",
"Pb",
"Bi",
"Po",
"At",
"Rn",
"Fr",
"Ra",
"Ac",
"Th",
"Pa",
"U",
"Np",
"Pu",
"Am",
"Cm",
"Bk",
"Cf",
"Es",
"Fm",
]
return symbols[Z] if Z < len(symbols) else f"Z{Z}"
def _nuclide_symbol(Z: int, A: int) -> str:
"""Get nuclide notation like 'Tc99' from atomic and mass numbers."""
return f"{_element_symbol(Z)}{A}"
def _decay_notation(Z_parent: int, Z_daughter: int, A: int) -> str:
"""Get decay notation like 'Tc99 -> Ru99' from atomic and mass numbers."""
return f"{_nuclide_symbol(Z_parent, A)} -> {_nuclide_symbol(Z_daughter, A)}"
def main() -> None:
"""Main entry point for the CLI."""
parser = _build_parser()
args = parser.parse_args()
try:
_run(args)
except KeyboardInterrupt:
print("\nInterrupted by user.", file=sys.stderr)
sys.exit(130)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()