feat: experimental data calibration pipeline with ROOT I/O

- 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
This commit is contained in:
2026-05-21 16:01:25 +03:00
parent caa4a77ebd
commit 5c2b12c0e6
27 changed files with 3065 additions and 773 deletions
+421
View File
@@ -0,0 +1,421 @@
"""
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()