- Add neutrino mass parameter to SpectrumConfig and CLI interface - Update PhaseSpace component to handle neutrino mass effects properly - Update CSV headers to show basic endpoint and all transition types - Add --csv-verbosity option to CLI with 'full' and 'concise' options - Fix comparison plots to properly show linear scale variations - Add comprehensive tests for neutrino mass functionality
512 lines
15 KiB
Python
512 lines
15 KiB
Python
"""
|
|
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
|
|
|
|
# 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 pathlib import Path
|
|
|
|
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 (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,
|
|
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.",
|
|
)
|
|
parser.add_argument(
|
|
"--neutrino-mass",
|
|
type=float,
|
|
default=0.0,
|
|
help="Neutrino mass in MeV (default: 0.0). Set to non-zero value to include neutrino mass effects in phase space calculation.",
|
|
)
|
|
|
|
# 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.",
|
|
)
|
|
|
|
# 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.",
|
|
)
|
|
log_group.add_argument(
|
|
"--csv-verbosity",
|
|
type=str,
|
|
default="full",
|
|
choices=["full", "concise"],
|
|
help="CSV output verbosity: 'full' (all components) or 'concise' (total spectrum and normalized branch components only) (default: full)",
|
|
)
|
|
|
|
return parser
|
|
|
|
|
|
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.visualize 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")
|
|
|
|
# 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
|
|
config: SpectrumConfig
|
|
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:
|
|
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,
|
|
neutrino_mass_MeV=args.neutrino_mass,
|
|
branches=branches,
|
|
intensity_cutoff=args.intensity_cutoff,
|
|
)
|
|
else:
|
|
config = create_config_from_source(
|
|
"paceENSDF",
|
|
nuclide=source_value,
|
|
e_step_MeV=args.e_step,
|
|
intensity_cutoff=args.intensity_cutoff,
|
|
neutrino_mass_MeV=args.neutrino_mass,
|
|
)
|
|
else:
|
|
config = create_config_from_source(
|
|
"paceENSDF",
|
|
nuclide=source_value,
|
|
e_step_MeV=args.e_step,
|
|
intensity_cutoff=args.intensity_cutoff,
|
|
neutrino_mass_MeV=args.neutrino_mass,
|
|
)
|
|
else:
|
|
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:
|
|
_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, csv_verbosity=args.csv_verbosity
|
|
)
|
|
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"neutrino_mass_MeV: {config.neutrino_mass_MeV:.6f}")
|
|
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")
|
|
|
|
# Detector response is a Phase 4 feature — not yet implemented
|
|
print("Detector: not configured (Phase 4)")
|
|
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()
|