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
-84
View File
@@ -11,9 +11,6 @@ Usage examples:
# 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
@@ -25,7 +22,6 @@ 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
@@ -95,39 +91,6 @@ def _build_parser() -> argparse.ArgumentParser:
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(
@@ -169,20 +132,6 @@ def _build_parser() -> argparse.ArgumentParser:
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
@@ -223,8 +172,6 @@ def _run(args: argparse.Namespace) -> None:
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:
@@ -257,49 +204,18 @@ def _run(args: argparse.Namespace) -> None:
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: