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
-145
View File
@@ -17,9 +17,6 @@ from beta_spectrum.components.radiative import RadiativeCorrection
from beta_spectrum.utils import T_to_W
from beta_spectrum.constants import ME_MEV
if TYPE_CHECKING:
from beta_spectrum.components.detector_response import DetectorResponse
@dataclass
class BranchConfig:
@@ -61,7 +58,6 @@ class SpectrumConfig:
Configuration for beta spectrum calculation.
Supports declarative detector response specification:
set detector_model, detector_sigma_a_keV, and related parameters
to enable automatic detector smearing via convolve_with_detector().
Supports multi-branch decay via the `branches` field. When branches are
@@ -85,20 +81,6 @@ class SpectrumConfig:
use_radiative: bool = True
use_exchange: bool = True
# Detector response convolution (analytical model)
use_detector_response: bool = False
detector_model: str = "gaussian"
detector_sigma_a_keV: float = 1.0
detector_sigma_b: float = 0.0
detector_tail_fraction: float = 0.0
detector_tau_keV: float = 5.0
detector_fano_factor: float = 0.12
detector_n_channels: int = 4096
detector_channel_energy_range: tuple[float, float] = (
0.0,
0.35,
) # in m_e units (total energy)
# Multi-branch support
branches: Optional[List[BranchConfig]] = None # None → single-branch mode
intensity_cutoff: float = (
@@ -470,133 +452,6 @@ class BetaSpectrum:
result.append(self.branches[i].intensity * np.maximum(0.0, branch_spectrum))
return result
@staticmethod
def create_detector_from_config(
config: SpectrumConfig,
) -> "DetectorResponse":
"""
Create a DetectorResponse from SpectrumConfig detector parameters.
Converts keV-based resolution parameters to m_e units internally.
Parameters
----------
config : SpectrumConfig
Configuration with detector response parameters set.
Returns
-------
DetectorResponse
Detector response object ready for convolution.
"""
from beta_spectrum.components.detector_response import DetectorResponse
from beta_spectrum.utils import T_to_W
sigma_a_me = config.detector_sigma_a_keV / ME_MEV
tau_me = config.detector_tau_keV / ME_MEV
W0 = T_to_W(config.endpoint_MeV)
channel_range = config.detector_channel_energy_range
# Extend range to cover endpoint
if channel_range[1] < W0:
channel_range = (channel_range[0], float(W0 + 0.05))
detector = DetectorResponse.from_gaussian_params(
channel_energy_range=channel_range,
n_channels=config.detector_n_channels,
sigma_a=sigma_a_me,
sigma_b=config.detector_sigma_b,
tail_fraction=config.detector_tail_fraction,
tau=tau_me,
model=config.detector_model,
fano_factor=config.detector_fano_factor,
)
return detector
def convolve_with_detector(
self,
detector_response: "DetectorResponse",
W: Optional[np.ndarray] = None,
config: Optional[SpectrumConfig] = None,
) -> np.ndarray:
"""
Convolve theoretical spectrum with detector response.
Returns the predicted measured spectrum after detector smearing.
Parameters
----------
detector_response : DetectorResponse
Detector response object (analytical or tabulated).
W : np.ndarray, optional
Energy grid in m_e units. If None, generated from config.
config : SpectrumConfig, optional
Used to generate energy grid if W is None.
Returns
-------
np.ndarray
Convolved spectrum (predicted measured counts per channel).
"""
if W is None:
if config is None:
raise ValueError("Either W or config must be provided")
W, _ = self.get_energy_grid(config)
theoretical_spectrum = self(W)
if self._logger:
self._logger.debug(
"Convolution: %d channels, %d spectrum points",
len(detector_response.channel_energies),
len(W),
)
convolved = detector_response.convolve(W, theoretical_spectrum, normalize=True)
return convolved
def convolve_detector(
self,
config: SpectrumConfig,
W: Optional[np.ndarray] = None,
detector_response: Optional["DetectorResponse"] = None,
) -> np.ndarray:
"""
Convolve theoretical spectrum with detector response from config.
Convenience method: creates DetectorResponse from config parameters
and convolves the spectrum in a single call.
Parameters
----------
config : SpectrumConfig
Configuration with detector response parameters set.
W : np.ndarray, optional
Energy grid in m_e units. If None, generated from config.
detector_response : DetectorResponse, optional
Pre-built detector response. If None, created from config.
Returns
-------
np.ndarray
Convolved spectrum (predicted measured counts per channel).
Examples
--------
>>> config = SpectrumConfig(
... Z_parent=43, Z_daughter=44, A_number=99, endpoint_MeV=0.294,
... use_detector_response=True,
... detector_sigma_a_keV=1.0,
... )
>>> spectrum = BetaSpectrum.from_config(config)
>>> W, _ = spectrum.get_energy_grid(config)
>>> convolved = spectrum.convolve_detector(config, W=W)
"""
if detector_response is None:
detector_response = self.create_detector_from_config(config)
return self.convolve_with_detector(detector_response, W=W, config=config)