- Fix chi2 calculation bug in calibration.py (remove incorrect coeffs[::-1] reversal in np.polyval) - Fix broken beta_spectrum/cli.py (remove orphaned detector response add_argument calls and unused imports) - Add ExpSpectrum.apply_calibration() and EnergyCalibrator.from_am241_spectrum() - Add LivetimeDetermination class for pulser-based livetime - Add SpectrumPipeline orchestration (read→subtract→calibrate→livetime→validate) - Add ROOT I/O with PyROOT primary + uproot fallback - Add bs_exp CLI with process, livetime, export subcommands - Add exp_calibration notebook with pre-calibration visualization - Add comprehensive tests (73 passing) - Add bs_exp console script and [root] extra to pyproject.toml - Remove obsolete detector_response module and unused notebooks
422 lines
13 KiB
Python
422 lines
13 KiB
Python
"""
|
|
Command-line interface for experimental data processing.
|
|
|
|
Usage examples:
|
|
# Process a single spectrum
|
|
bs_exp process --raw A1.DAT --calibration CAM1.DAT --output output.root
|
|
|
|
# Process a run sequence
|
|
bs_exp process --run-dir ./data/raw/ --prefix A --calibration CAM1.DAT
|
|
|
|
# Determine live-time from a pulser calibration
|
|
bs_exp livetime --file CAM1.DAT --frequency 100.0 --real-time 3600.0
|
|
|
|
# Export calibrated spectrum to ROOT
|
|
bs_exp export --input spectrum.root --output processed.root
|
|
|
|
# Dry run to inspect pipeline configuration
|
|
bs_exp process --raw A1.DAT --calibration CAM1.DAT --dry-run
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
"""Build the argument parser."""
|
|
parser = argparse.ArgumentParser(
|
|
prog="bs_exp",
|
|
description="Experimental data processing toolkit - "
|
|
"calibrate, process, and export beta decay spectra.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=(
|
|
"Examples:\n"
|
|
" bs_exp process --raw A1.DAT --calibration CAM1.DAT --output output.root\n"
|
|
" bs_exp process --run-dir ./data/ --prefix A --calibration CAM1.DAT\n"
|
|
" bs_exp livetime --file CAM1.DAT --frequency 100.0 --real-time 3600.0\n"
|
|
" bs_exp export --input spectrum.root --output processed.root\n"
|
|
" bs_exp process --raw A1.DAT --calibration CAM1.DAT --dry-run\n"
|
|
),
|
|
)
|
|
|
|
# Version
|
|
parser.add_argument(
|
|
"--version",
|
|
action="version",
|
|
version="%(prog)s 0.4.0",
|
|
)
|
|
|
|
# Subcommands
|
|
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
|
|
# --- process subcommand ---
|
|
process_parser = subparsers.add_parser(
|
|
"process",
|
|
help="Process raw binary spectra through the full pipeline",
|
|
description=(
|
|
"Read raw binary spectra, subtract cumulative runs, "
|
|
"apply energy calibration, determine live-time, and export."
|
|
),
|
|
)
|
|
process_parser.add_argument(
|
|
"--raw",
|
|
type=str,
|
|
metavar="FILE",
|
|
help="Path to the raw binary spectrum file (A*.DAT).",
|
|
)
|
|
process_parser.add_argument(
|
|
"--cumulative",
|
|
type=str,
|
|
metavar="FILE",
|
|
help="Path to the previous cumulative spectrum for subtraction.",
|
|
)
|
|
process_parser.add_argument(
|
|
"--run-dir",
|
|
type=str,
|
|
metavar="DIR",
|
|
help="Directory containing cumulative run files (for sequence processing).",
|
|
)
|
|
process_parser.add_argument(
|
|
"--prefix",
|
|
type=str,
|
|
default="A",
|
|
help="Filename prefix for run files (default: 'A').",
|
|
)
|
|
process_parser.add_argument(
|
|
"--calibration",
|
|
type=str,
|
|
required=True,
|
|
metavar="FILE",
|
|
help="Path to the Am-241 calibration binary file (CAM1.DAT).",
|
|
)
|
|
process_parser.add_argument(
|
|
"--pulser-frequency",
|
|
type=float,
|
|
default=None,
|
|
metavar="HZ",
|
|
help="Pulser injection frequency in Hz (for live-time determination).",
|
|
)
|
|
process_parser.add_argument(
|
|
"--real-time",
|
|
type=float,
|
|
default=None,
|
|
metavar="SEC",
|
|
help="Real acquisition time in seconds (for live-time determination).",
|
|
)
|
|
process_parser.add_argument(
|
|
"--output",
|
|
type=str,
|
|
metavar="FILE",
|
|
help="Output ROOT file path.",
|
|
)
|
|
process_parser.add_argument(
|
|
"--run-id",
|
|
type=str,
|
|
default=None,
|
|
help="Run identifier prefix.",
|
|
)
|
|
process_parser.add_argument(
|
|
"--source",
|
|
type=str,
|
|
default=None,
|
|
help="Source identifier (e.g., 'Tc99').",
|
|
)
|
|
|
|
# --- livetime subcommand ---
|
|
livetime_parser = subparsers.add_parser(
|
|
"livetime",
|
|
help="Determine live-time fraction from a pulser calibration spectrum",
|
|
description=(
|
|
"Read a pulser calibration spectrum, identify the pulser "
|
|
"peak, and compute the live-time fraction."
|
|
),
|
|
)
|
|
livetime_parser.add_argument(
|
|
"--file",
|
|
type=str,
|
|
required=True,
|
|
metavar="FILE",
|
|
help="Path to the pulser calibration binary file.",
|
|
)
|
|
livetime_parser.add_argument(
|
|
"--frequency",
|
|
type=float,
|
|
required=True,
|
|
metavar="HZ",
|
|
help="Pulser injection frequency in Hz.",
|
|
)
|
|
livetime_parser.add_argument(
|
|
"--real-time",
|
|
type=float,
|
|
required=True,
|
|
metavar="SEC",
|
|
help="Real acquisition time in seconds.",
|
|
)
|
|
livetime_parser.add_argument(
|
|
"--output",
|
|
type=str,
|
|
metavar="FILE",
|
|
help="Output ROOT file with livetime metadata.",
|
|
)
|
|
|
|
# --- export subcommand ---
|
|
export_parser = subparsers.add_parser(
|
|
"export",
|
|
help="Export calibrated ExpSpectrum to ROOT format",
|
|
description="Export an ExpSpectrum (from ROOT or dict) to ROOT file format.",
|
|
)
|
|
export_parser.add_argument(
|
|
"--input",
|
|
type=str,
|
|
required=True,
|
|
metavar="FILE",
|
|
help="Input file (ROOT or JSON dict).",
|
|
)
|
|
export_parser.add_argument(
|
|
"--output",
|
|
type=str,
|
|
required=True,
|
|
metavar="FILE",
|
|
help="Output ROOT file path.",
|
|
)
|
|
|
|
# --- dry-run (global, added to all subparsers) ---
|
|
for sub in [process_parser, livetime_parser, export_parser]:
|
|
sub.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Validate input and display configuration without processing.",
|
|
)
|
|
|
|
# Logging and output (global)
|
|
log_group = parser.add_argument_group("logging and output")
|
|
log_group.add_argument(
|
|
"-v",
|
|
"--verbose",
|
|
action="count",
|
|
default=0,
|
|
help="Increase verbosity: -v=INFO, -vv=DEBUG. 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.",
|
|
)
|
|
|
|
return parser
|
|
|
|
|
|
def _run(args: argparse.Namespace) -> None:
|
|
"""Execute the CLI command."""
|
|
# 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 (minimal - reuse beta_spectrum.logging_utils if available)
|
|
try:
|
|
from beta_spectrum.logging_utils import setup_logging, get_logger
|
|
|
|
log_config = type(
|
|
"LoggingConfig", (), {"level": log_level, "log_file": args.log_file}
|
|
)()
|
|
logger = setup_logging(log_config)
|
|
logger = get_logger("exp")
|
|
except ImportError:
|
|
import logging
|
|
|
|
logging.basicConfig(level=getattr(logging, log_level))
|
|
logger = logging.getLogger("exp")
|
|
|
|
logger.info("bs_exp starting...")
|
|
|
|
if args.command == "process":
|
|
_cmd_process(args, logger)
|
|
elif args.command == "livetime":
|
|
_cmd_livetime(args, logger)
|
|
elif args.command == "export":
|
|
_cmd_export(args, logger)
|
|
else:
|
|
parser = _build_parser()
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
|
|
def _cmd_process(args, logger) -> None:
|
|
"""Process subcommand handler."""
|
|
from exp_data.pipeline import process_spectrum, process_run_sequence
|
|
from exp_data.root_io import write_spectrum
|
|
|
|
# Check PyROOT/uproot availability
|
|
from exp_data.root_io import _PYROOT_AVAILABLE, _UPTOOL_AVAILABLE
|
|
|
|
if not _PYROOT_AVAILABLE and not _UPTOOL_AVAILABLE:
|
|
logger.error("No ROOT I/O library available. Install: pip install uproot")
|
|
sys.exit(1)
|
|
|
|
if args.dry_run:
|
|
logger.info("Dry run: pipeline configuration validated")
|
|
logger.info(" Calibration: %s", args.calibration)
|
|
logger.info(" Pulser frequency: %s Hz", args.pulser_frequency_hz or "N/A")
|
|
logger.info(" Real time: %s s", args.real_time or "N/A")
|
|
return
|
|
|
|
if args.raw:
|
|
# Single spectrum
|
|
result = process_spectrum(
|
|
raw_path=args.raw,
|
|
calibration_path=args.calibration,
|
|
cumulative_path=args.cumulative,
|
|
run_id=args.run_id,
|
|
source=args.source,
|
|
pulser_frequency_hz=args.pulser_frequency_hz,
|
|
real_time_sec=args.real_time,
|
|
)
|
|
|
|
logger.info("Pipeline complete:")
|
|
logger.info(
|
|
" Validation: %s", "PASSED" if result.validation_passed else "FAILED"
|
|
)
|
|
for msg in result.validation_messages:
|
|
logger.info(" %s", msg)
|
|
logger.info(" Calibration chi2/dof: %.4f", result.calibration.chi2_per_dof)
|
|
|
|
if result.livetime:
|
|
logger.info(
|
|
" Live-time fraction: %.4f", result.livetime.live_time_fraction
|
|
)
|
|
|
|
if args.output:
|
|
write_spectrum(args.output, result.spectrum)
|
|
logger.info(" Exported to %s", args.output)
|
|
|
|
elif args.run_dir:
|
|
# Run sequence
|
|
results = process_run_sequence(
|
|
run_dir=args.run_dir,
|
|
calibration_path=args.calibration,
|
|
prefix=args.prefix,
|
|
run_id=args.run_id,
|
|
source=args.source,
|
|
pulser_frequency_hz=args.pulser_frequency_hz,
|
|
real_time_sec=args.real_time,
|
|
)
|
|
|
|
logger.info("Processed %d runs", len(results))
|
|
for i, result in enumerate(results):
|
|
logger.info(
|
|
" Run %d: %s (chi2/dof=%.4f)",
|
|
i + 1,
|
|
"PASSED" if result.validation_passed else "FAILED",
|
|
result.calibration.chi2_per_dof,
|
|
)
|
|
|
|
if args.output:
|
|
# Export last result
|
|
write_spectrum(args.output, results[-1].spectrum)
|
|
logger.info(" Exported last run to %s", args.output)
|
|
else:
|
|
logger.error("Specify --raw FILE or --run-dir DIR")
|
|
sys.exit(1)
|
|
|
|
|
|
def _cmd_livetime(args, logger) -> None:
|
|
"""Livetime subcommand handler."""
|
|
from exp_data.livetime import LivetimeDetermination
|
|
from exp_data.root_io import write_spectrum
|
|
from exp_data.spectrum import ExpSpectrum
|
|
|
|
result = LivetimeDetermination.from_file(
|
|
filepath=args.file,
|
|
pulser_frequency_hz=args.frequency,
|
|
real_time_sec=args.real_time,
|
|
source="pulser",
|
|
)
|
|
|
|
logger.info("Live-time determination:")
|
|
logger.info(" Live-time fraction: %.4f", result.live_time_fraction)
|
|
logger.info(" Observed pulser rate: %.1f Hz", result.observed_pulser_rate)
|
|
logger.info(
|
|
" Pulser peak: %.2f keV (sigma=%.2f keV, chi2/dof=%.2f)",
|
|
result.pulser_peak_center_keV,
|
|
result.pulser_peak_sigma_keV,
|
|
result.pulser_peak_chi2,
|
|
)
|
|
|
|
if args.output:
|
|
# Create a dummy spectrum with livetime metadata
|
|
import numpy as np
|
|
|
|
dummy = ExpSpectrum(
|
|
energies=np.array([0.0]),
|
|
counts=np.array([0.0]),
|
|
errors=np.array([0.0]),
|
|
metadata={
|
|
"livetime": {
|
|
"fraction": result.live_time_fraction,
|
|
"pulser_frequency_hz": result.pulser_frequency_hz,
|
|
"observed_pulser_rate": result.observed_pulser_rate,
|
|
}
|
|
},
|
|
live_time=result.real_time_sec * result.live_time_fraction,
|
|
source="pulser",
|
|
)
|
|
write_spectrum(args.output, dummy)
|
|
logger.info(" Livetime metadata exported to %s", args.output)
|
|
|
|
|
|
def _cmd_export(args, logger) -> None:
|
|
"""Export subcommand handler."""
|
|
from exp_data.root_io import write_spectrum, read_spectrum
|
|
|
|
# Read input spectrum
|
|
if str(args.input).endswith(".root"):
|
|
spectrum = read_spectrum(args.input)
|
|
elif str(args.input).endswith(".json"):
|
|
import json
|
|
from exp_data.spectrum import ExpSpectrum
|
|
|
|
with open(args.input) as f:
|
|
data = json.load(f)
|
|
spectrum = ExpSpectrum.from_dict(data)
|
|
else:
|
|
logger.error("Unsupported input format. Use .root or .json")
|
|
sys.exit(1)
|
|
|
|
# Write output
|
|
write_spectrum(args.output, spectrum)
|
|
logger.info("Exported to %s", args.output)
|
|
|
|
|
|
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()
|