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
-2
View File
@@ -9,7 +9,6 @@ from .components.finite_size import FiniteSizeL0, ChargeDistributionU
from .components.screening import ScreeningCorrection
from .components.exchange import ExchangeCorrection
from .components.radiative import RadiativeCorrection
from .components.detector_response import DetectorResponse
from .spectrum import BetaSpectrum, SpectrumConfig, BranchConfig
from .visualize import BetaSpectrumAnalyzer
@@ -39,7 +38,6 @@ __all__ = [
"ScreeningCorrection",
"ExchangeCorrection",
"RadiativeCorrection",
"DetectorResponse",
"BetaSpectrum",
"SpectrumConfig",
"BetaSpectrumAnalyzer",
-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:
@@ -1,457 +0,0 @@
# components/detector_response.py
"""
Detector response modeling for beta spectrometry.
This module provides tools to convolve theoretical beta spectra with
realistic detector response functions, bridging the gap between
calculated spectra and measured data.
Two modes of operation:
1. **Tabulated response** — receive pre-computed response matrices
(e.g. from GEANT4, EGSNRC, or dedicated calibration measurements).
The response matrix has shape (N_calib_energies, N_channels), where
each row is the detector response to a mono-energetic electron beam
at the corresponding calibration energy.
2. **Analytical model** — parameterized Gaussian core + exponential tail,
suitable for quick estimates and when no tabulated response is available.
The convolution operation:
M_j = Σ_i Φ(W_i) · R(E_j; W_i) · ΔW_i
where Φ(W_i) is the theoretical differential spectrum at energy W_i,
R(E_j; W_i) is the probability that an electron of true energy W_i
deposits energy E_j in the detector channel j, and ΔW_i is the bin width.
"""
from __future__ import annotations
import numpy as np
from scipy.interpolate import RegularGridInterpolator, interp1d
from scipy.special import erfc
from typing import Optional
from beta_spectrum.constants import ME_MEV
class DetectorResponse:
"""
Detector response function for beta spectrometry.
Models the response of a semiconductor detector (e.g. PIPS, MMC)
to mono-energetic electrons. Supports both tabulated responses
(from MC simulations or calibration) and analytical parameterized models.
Parameters
----------
channel_energies : np.ndarray
Energy values for each detector channel, shape (N_channels,).
Typically a linear or log-spaced grid from 0 to Q-value.
response_matrix : np.ndarray, optional
Tabulated response matrix, shape (N_calib_energies, N_channels).
If provided, analytical model parameters are ignored.
calibration_energies : np.ndarray, optional
Calibration energies corresponding to rows of response_matrix.
Required if response_matrix is provided.
model : str
Analytical model to use when no tabulated response is given.
Options: "gaussian", "gaussian_tail", "tikhonov".
sigma_a : float
Energy-independent contribution to resolution σ (in m_e units).
sigma_b : float
Coefficient for √E contribution to resolution σ (in m_e^(-1/2) units).
tail_fraction : float
Fraction of events in the exponential tail (0.0–1.0).
tau : float
Decay constant of exponential tail (in m_e units). Smaller = steeper tail.
fano_factor : float
Fano factor for energy resolution (typical: 0.12 for Si, 0.0 for MMC).
"""
def __init__(
self,
channel_energies: np.ndarray,
response_matrix: Optional[np.ndarray] = None,
calibration_energies: Optional[np.ndarray] = None,
model: str = "gaussian_tail",
sigma_a: float = 0.0,
sigma_b: float = 0.0,
tail_fraction: float = 0.0,
tau: float = 0.01,
fano_factor: float = 0.12,
):
self.channel_energies = np.asarray(channel_energies, dtype=float)
self.n_channels = len(self.channel_energies)
if response_matrix is not None and calibration_energies is not None:
self._mode = "tabulated"
self.response_matrix = np.asarray(response_matrix, dtype=float)
self.calibration_energies = np.asarray(calibration_energies, dtype=float)
self._validate_tabulated()
self._interp = self._build_interpolator()
else:
self._mode = "analytical"
self.model = model
self.sigma_a = sigma_a
self.sigma_b = sigma_b
self.tail_fraction = tail_fraction
self.tau = tau
self.fano_factor = fano_factor
def _validate_tabulated(self) -> None:
"""Validate tabulated response data."""
if self.response_matrix.ndim != 2:
raise ValueError(
f"response_matrix must be 2D, got {self.response_matrix.ndim}D"
)
if self.response_matrix.shape[1] != self.n_channels:
raise ValueError(
f"response_matrix columns ({self.response_matrix.shape[1]}) "
f"must match channel_energies length ({self.n_channels})"
)
if len(self.calibration_energies) != self.response_matrix.shape[0]:
raise ValueError(
f"calibration_energies length ({len(self.calibration_energies)}) "
f"must match response_matrix rows ({self.response_matrix.shape[0]})"
)
if not np.all(np.diff(self.calibration_energies) > 0):
raise ValueError("calibration_energies must be strictly increasing")
if not np.all(np.diff(self.channel_energies) > 0):
raise ValueError("channel_energies must be strictly increasing")
def _build_interpolator(self) -> RegularGridInterpolator:
"""Build interpolation function for tabulated response."""
return RegularGridInterpolator(
(self.calibration_energies, self.channel_energies),
self.response_matrix,
method="linear",
bounds_error=False,
fill_value=0.0,
)
def get_response(self, energy: float) -> np.ndarray:
"""
Get detector response array for a mono-energetic electron at given energy.
Parameters
----------
energy : float
True electron energy in m_e units.
Returns
-------
np.ndarray
Response array of length N_channels.
"""
if self._mode == "tabulated":
# Interpolate from tabulated response
resp = np.zeros(self.n_channels, dtype=float)
for j in range(self.n_channels):
f = interp1d(
self.calibration_energies,
self.response_matrix[:, j],
kind="linear",
bounds_error=False,
fill_value=0.0,
)
resp[j] = float(f(energy))
return resp
return self._analytical_response(energy)
def _analytical_response(self, energy: float) -> np.ndarray:
"""
Compute analytical detector response for mono-energetic electron.
Uses a parameterized model:
- Gaussian core with energy-dependent resolution
- Optional exponential tail on the low-energy side
"""
sigma = self._resolution_sigma(energy)
if self.model == "gaussian":
return self._gaussian_response(energy, sigma)
if self.model == "gaussian_tail":
return self._gaussian_tail_response(energy, sigma)
if self.model == "tikhonov":
return self._tikhonov_response(energy, sigma)
raise ValueError(f"Unknown model: {self.model}")
def _resolution_sigma(self, energy: float) -> float:
"""
Compute energy-dependent resolution σ.
σ(E) = sqrt(σ₀² + (b·√E)²)
where σ₀ = sigma_a (energy-independent term)
b = sigma_b (√E-dependent term)
For semiconductor detectors, can also include Fano factor contribution:
σ_Fano = sqrt(F · ε · w) where F is Fano factor, ε is energy,
w is mean ionization energy (~3.6 eV for Si).
"""
E = max(energy, 1e-12)
sigma_sq = self.sigma_a**2 + (self.sigma_b * np.sqrt(E)) ** 2
if self.fano_factor > 0:
# Fano contribution: σ = sqrt(F * E * w) in natural units
w_me = 3.6e-3 / ME_MEV # 3.6 eV in m_e units
sigma_sq += self.fano_factor * E * w_me
return float(np.sqrt(sigma_sq))
def _gaussian_response(self, energy: float, sigma: float) -> np.ndarray:
"""Pure Gaussian response."""
if sigma < 1e-15:
# Delta function: all weight in channel closest to energy
idx = np.argmin(np.abs(self.channel_energies - energy))
resp = np.zeros(self.n_channels, dtype=float)
resp[idx] = 1.0
return resp
delta_E = self.channel_energies - energy
resp = np.exp(-0.5 * (delta_E / sigma) ** 2)
resp /= sigma * np.sqrt(2.0 * np.pi)
return np.asarray(resp, dtype=np.float64)
def _gaussian_tail_response(self, energy: float, sigma: float) -> np.ndarray:
"""
Gaussian core + exponential tail on the low-energy side.
f(E) = (1 - f_tail) · G(E; E₀, σ)
+ f_tail · T(E; E₀, σ, τ)
where T is the tail function:
T(E) = (1/(2τ)) · exp(τ·(E - E₀)/2 + σ²·τ²/8) · erfc(
(E - E₀)/(√2·σ) + σ·τ/√2
)
for E < E₀, and zero for E > E₀.
This is the standard model for Si detector response with charge
collection inefficiencies causing low-energy tailing.
"""
if sigma < 1e-15:
idx = np.argmin(np.abs(self.channel_energies - energy))
resp = np.zeros(self.n_channels, dtype=float)
resp[idx] = 1.0
return resp
delta_E = self.channel_energies - energy
core = np.exp(-0.5 * (delta_E / sigma) ** 2)
core /= sigma * np.sqrt(2.0 * np.pi)
if self.tail_fraction <= 0 or self.tau <= 0:
return np.asarray(core, dtype=np.float64)
# Exponential tail (Tikhonov function): defined for all E
tail = np.zeros(self.n_channels, dtype=float)
dE_all = delta_E
arg = dE_all / (np.sqrt(2.0) * sigma) + sigma * self.tau / np.sqrt(2.0)
tail = (
1.0
/ (2.0 * self.tau)
* np.exp(self.tau * dE_all / 2.0 + (sigma * self.tau) ** 2 / 8.0)
* erfc(arg)
)
resp = (1.0 - self.tail_fraction) * core + self.tail_fraction * tail
# Normalize to unit area
dw = np.diff(self.channel_energies)
dw = np.concatenate([dw, [dw[-1]]])
resp *= dw
area = float(np.sum(resp))
resp /= dw
if area > 0:
resp /= area
return np.asarray(resp, dtype=np.float64)
def _tikhonov_response(self, energy: float, sigma: float) -> np.ndarray:
"""
Full Tikhonov function for detector response.
Combines Gaussian core with exponential tail in a single
analytic expression. Standard model for semiconductor detectors.
"""
return self._gaussian_tail_response(energy, sigma)
def convolve(
self,
W: np.ndarray,
spectrum_values: np.ndarray,
normalize: bool = True,
) -> np.ndarray:
"""
Convolve theoretical spectrum with detector response.
Computes the predicted measured spectrum:
M_j = Σ_i Φ(W_i) · R(E_j; W_i) · ΔW_i
Parameters
----------
W : np.ndarray
Energy grid of theoretical spectrum, shape (N_bins,).
spectrum_values : np.ndarray
Differential spectrum dN/dW at energies W, shape (N_bins,).
normalize : bool
If True, normalize each response function to unit area.
Returns
-------
np.ndarray
Predicted measured spectrum, shape (N_channels,).
"""
W = np.asarray(W, dtype=float)
spectrum_values = np.asarray(spectrum_values, dtype=float)
if W.ndim != 1 or spectrum_values.ndim != 1:
raise ValueError("Inputs must be 1D arrays")
if len(W) != len(spectrum_values):
raise ValueError("W and spectrum_values must have same length")
# Compute bin widths
dw = self._compute_bin_widths(W)
# Build response matrix: shape (N_bins, N_channels)
response_matrix = np.zeros((len(W), self.n_channels))
for i, w_i in enumerate(W):
resp = self.get_response(w_i)
response_matrix[i] = resp
# Convolve: M = R^T · (Φ · ΔW)
convolved = response_matrix.T @ (spectrum_values * dw)
return np.asarray(convolved, dtype=np.float64)
def convolve_batch(
self,
W: np.ndarray,
spectrum_values: np.ndarray,
normalize: bool = True,
) -> np.ndarray:
"""
Convolve with optimized batch computation for analytical models.
For analytical models, computes the full response matrix at once
rather than looping over energies. Much faster for large spectra.
"""
if self._mode != "analytical":
return self.convolve(W, spectrum_values, normalize)
W = np.asarray(W, dtype=float)
spectrum_values = np.asarray(spectrum_values, dtype=float)
dw = self._compute_bin_widths(W)
# Build full response matrix at once: shape (N_bins, N_channels)
response_matrix = np.zeros((len(W), self.n_channels))
for i, w_i in enumerate(W):
if spectrum_values[i] <= 0:
continue
resp = self._analytical_response(w_i)
response_matrix[i] = resp
return np.asarray(response_matrix.T @ (spectrum_values * dw), dtype=np.float64)
@staticmethod
def _compute_bin_widths(W: np.ndarray) -> np.ndarray:
"""Compute effective bin widths for arbitrary energy grid."""
if len(W) == 1:
return np.array([1.0])
dw = np.diff(W)
# Half-bin widths at each point
widths = np.zeros(len(W))
widths[0] = dw[0]
widths[-1] = dw[-1]
widths[1:-1] = 0.5 * (dw[:-1] + dw[1:])
return widths
@classmethod
def from_gaussian_params(
cls,
channel_energy_range: tuple[float, float],
n_channels: int = 4096,
sigma_a: float = 0.0,
sigma_b: float = 0.0,
tail_fraction: float = 0.0,
tau: float = 0.01,
model: str = "gaussian_tail",
fano_factor: float = 0.12,
) -> DetectorResponse:
"""
Convenience factory: create DetectorResponse with analytical Gaussian model.
Parameters
----------
channel_energy_range : tuple
(E_min, E_max) in m_e units.
n_channels : int
Number of detector channels (default 4096).
sigma_a, sigma_b : float
Resolution parameters: σ(E) = sqrt(σ_a² + (σ_b·√E)²).
tail_fraction : float
Fraction of events in exponential tail (0.0–1.0).
tau : float
Exponential tail decay constant.
model : str
"gaussian", "gaussian_tail", or "tikhonov".
fano_factor : float
Fano factor (0.12 for Si, ~0 for MMC).
"""
channel_energies = np.linspace(
channel_energy_range[0],
channel_energy_range[1],
n_channels,
)
return cls(
channel_energies=channel_energies,
model=model,
sigma_a=sigma_a,
sigma_b=sigma_b,
tail_fraction=tail_fraction,
tau=tau,
fano_factor=fano_factor,
)
@classmethod
def from_mc_simulation(
cls,
channel_energies: np.ndarray,
response_matrix: np.ndarray,
calibration_energies: np.ndarray,
) -> DetectorResponse:
"""
Convenience factory: create DetectorResponse from MC simulation output.
Parameters
----------
channel_energies : np.ndarray
Energy per channel, shape (N_channels,).
response_matrix : np.ndarray
Response matrix from MC, shape (N_calib_energies, N_channels).
Each row i is the response to mono-energetic electrons at
calibration_energies[i].
calibration_energies : np.ndarray
Calibration energies, shape (N_calib_energies,).
"""
return cls(
channel_energies=channel_energies,
response_matrix=response_matrix,
calibration_energies=calibration_energies,
)
def __repr__(self) -> str:
mode_str = "tabulated" if self._mode == "tabulated" else "analytical"
if self._mode == "tabulated":
return (
f"DetectorResponse(mode={mode_str}, "
f"calib_energies={len(self.calibration_energies)}, "
f"channels={self.n_channels})"
)
else:
return (
f"DetectorResponse(mode={mode_str}, model={self.model!r}, "
f"sigma_a={self.sigma_a:.4f}, sigma_b={self.sigma_b:.4f}, "
f"tail_frac={self.tail_fraction:.3f}, "
f"channels={self.n_channels})"
)
-15
View File
@@ -469,7 +469,6 @@ def get_decay_info_from_paceENSDF(
def decay_info_to_config(
info: DecayInfo,
e_step_MeV: float = 0.001,
use_detector_response: bool = False,
intensity_cutoff: float = 0.0,
) -> SpectrumConfig:
"""
@@ -481,8 +480,6 @@ def decay_info_to_config(
Parsed decay information.
e_step_MeV : float
Energy step size in MeV.
use_detector_response : bool
Whether to enable detector response convolution.
intensity_cutoff : float
Minimum branch intensity fraction to include.
@@ -513,7 +510,6 @@ def decay_info_to_config(
endpoint_MeV=info.endpoint_MeV,
transition_type=info.transition_type,
e_step_MeV=e_step_MeV,
use_detector_response=use_detector_response,
branches=branches,
intensity_cutoff=intensity_cutoff,
)
@@ -533,7 +529,6 @@ DEFAULT_JSON_SCHEMA = {
"A_number": 99,
"transition_type": "F2",
"e_step_MeV": 0.001,
"use_detector_response": False,
"detector_model": "gaussian",
"detector_sigma_a_keV": 1.0,
"detector_sigma_b": 0.0,
@@ -643,16 +638,8 @@ def json_to_config(data: Dict[str, Any]) -> SpectrumConfig:
endpoint_MeV=float(data["endpoint_MeV"]),
transition_type=str(data.get("transition_type", "A")),
e_step_MeV=float(data.get("e_step_MeV", 0.001)),
use_detector_response=bool(data.get("use_detector_response", False)),
detector_model=str(data.get("detector_model", "gaussian")),
detector_sigma_a_keV=float(data.get("detector_sigma_a_keV", 1.0)),
detector_sigma_b=float(data.get("detector_sigma_b", 0.0)),
detector_tail_fraction=float(data.get("detector_tail_fraction", 0.0)),
detector_tau_keV=float(data.get("detector_tau_keV", 5.0)),
detector_fano_factor=float(data.get("detector_fano_factor", 0.12)),
detector_n_channels=int(data.get("detector_n_channels", 4096)),
detector_channel_energy_range=tuple(
data.get("detector_channel_energy_range", [0.0, 0.35])
),
)
@@ -698,10 +685,8 @@ def create_config_from_source(
decay_type = kwargs.get("decay_type", "beta_minus")
decay_index = kwargs.get("decay_index", None)
e_step = kwargs.get("e_step_MeV", 0.001)
use_det = kwargs.get("use_detector_response", False)
info = get_decay_info_from_paceENSDF(nuclide, decay_type, decay_index)
return decay_info_to_config(
info, e_step_MeV=e_step, use_detector_response=use_det
)
elif source == "json":
if not json_path:
-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)
-52
View File
@@ -24,7 +24,6 @@ if TYPE_CHECKING:
from matplotlib.figure import Figure
from beta_spectrum.spectrum import BetaSpectrum, SpectrumConfig
from beta_spectrum.components.detector_response import DetectorResponse
class BetaSpectrumAnalyzer:
@@ -108,48 +107,6 @@ class BetaSpectrumAnalyzer:
return total
def convolved_spectrum(
self,
detector_response: Optional["DetectorResponse"] = None,
normalize: bool = True,
) -> np.ndarray:
"""
Calculate spectrum convolved with detector response.
If detector_response is None and use_detector_response is True in config,
creates a detector response from config parameters.
Parameters
----------
detector_response : DetectorResponse, optional
Detector response to convolve with. Created from config if None.
normalize : bool
If True, normalize to unit area.
Returns
-------
np.ndarray
Convolved spectrum.
"""
if detector_response is None and self.config.use_detector_response:
detector_response = BetaSpectrum.create_detector_from_config(self.config)
if detector_response is None:
raise ValueError(
"detector_response must be provided, or set "
"use_detector_response=True in config"
)
convolved = self.spectrum.convolve_with_detector(
detector_response, W=self.W, config=self.config
)
if normalize:
integral = np.trapezoid(convolved, detector_response.channel_energies)
if integral > 0:
convolved = convolved / integral
return convolved
# ---------------------------------------------------------------------------
# Unified branch view
@@ -536,15 +493,6 @@ class BetaSpectrumAnalyzer:
f"intensity={branch.intensity:.4f}"
)
if self.config.use_detector_response:
header_lines.append(
f"# detector: {self.config.detector_model} "
f"(sigma={self.config.detector_sigma_a_keV} keV, "
f"tail={self.config.detector_tail_fraction})"
)
else:
header_lines.append("# detector: disabled")
header_lines.append(f"# git_commit: {get_git_short_hash()}")
data: Dict[str, np.ndarray] = {
+18 -1
View File
@@ -6,20 +6,37 @@ experimental spectra from detector readouts.
"""
from .spectrum import ExpSpectrum
from .calibration import EnergyCalibrator
from .calibration import EnergyCalibrator, CalibrationResult
from .fitters import GaussianFitter, PeakFitter
from .corrections import (
DeadTimeCorrection,
PileUpCorrection,
BackgroundSubtractor,
)
from .livetime import LivetimeDetermination, LivetimeResult
from .pipeline import (
SpectrumPipeline,
PipelineResult,
process_spectrum,
process_run_sequence,
)
from .root_io import write_spectrum, read_spectrum
__all__ = [
"ExpSpectrum",
"EnergyCalibrator",
"CalibrationResult",
"GaussianFitter",
"PeakFitter",
"DeadTimeCorrection",
"PileUpCorrection",
"BackgroundSubtractor",
"LivetimeDetermination",
"LivetimeResult",
"SpectrumPipeline",
"PipelineResult",
"process_spectrum",
"process_run_sequence",
"write_spectrum",
"read_spectrum",
]
+109 -1
View File
@@ -13,9 +13,11 @@ Supports:
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
import numpy as np
from scipy.signal import find_peaks
@dataclass
@@ -183,7 +185,9 @@ class EnergyCalibrator:
cov = np.eye(order + 1) * 1e-10
# Compute χ²
y_fit = np.polyval(coeffs[::-1], x)
# np.polyfit returns descending-order coefficients [a_n, ..., a_0],
# which is exactly what np.polyval expects.
y_fit = np.polyval(coeffs, x)
# Assume uniform uncertainty of 0.1 keV for calibration points
uncertainties = np.full_like(x, 0.1)
chi2 = float(np.sum(((y - y_fit) / uncertainties) ** 2))
@@ -289,3 +293,107 @@ class EnergyCalibrator:
]
)
return self.apply_calibration(order=1)
@classmethod
def from_am241_spectrum(
cls,
filepath: str | Path,
known_energies: Optional[list[float]] = None,
n_channels: int = 4096,
) -> CalibrationResult:
"""Create a CalibrationResult from an Am-241 calibration spectrum.
Auto-detects peaks using scipy.signal.find_peaks(), matches to known Am-241
lines, fits linear calibration, and returns a CalibrationResult suitable
for ExpSpectrum.apply_calibration().
Parameters
----------
filepath : str or Path
Path to the Am-241 binary calibration file (CAM1.DAT or CAM2.DAT).
known_energies : list of float, optional
Known Am-241 line energies in keV. Defaults to [13.8, 17.8, 26.3].
n_channels : int
Number of channels (default 4096).
Returns
-------
CalibrationResult
Linear calibration fit with coefficients, chi2_per_dof, and covariance.
Raises
------
ValueError
If fewer than 2 peaks are matched.
"""
import struct
filepath = Path(filepath)
# Read raw binary
with open(filepath, "rb") as f:
f.read(128) # skip header
counts = np.array(
struct.unpack(f"{n_channels}i", f.read(n_channels * 4)), dtype=np.int64
)
# Search for peaks in low-energy region
counts_low = counts.copy()
counts_low[300:] = 0
peaks, _ = find_peaks(counts_low, height=10, distance=10)
peak_energies = np.array(peaks, dtype=float)
# Known Am-241 gamma lines
if known_energies is None:
known_energies = [13.8, 17.8, 26.3]
# Match peaks to known energies.
# Am-241 has well-known lines: 13.8, 17.8, 26.3 keV (and 59.54 keV).
# Strategy: find the two closest peaks with the largest energy gap,
# assign the known energies to them proportionally, then verify
# with remaining peaks.
channel_to_energy: dict[float, float] = {}
if len(peak_energies) >= 2 and len(known_energies) >= 2:
# Find the two peaks with the largest separation — most reliable
# for gain estimation
max_separation = 0
best_pair = (0, 1)
for i in range(len(peak_energies)):
for j in range(i + 1, len(peak_energies)):
sep = peak_energies[j] - peak_energies[i]
if sep > max_separation:
max_separation = sep
best_pair = (i, j)
# Use the full known energy range to estimate gain
e_min, e_max = min(known_energies), max(known_energies)
if e_max != e_min:
gain = (peak_energies[best_pair[1]] - peak_energies[best_pair[0]]) / (
e_max - e_min
)
intercept = peak_energies[best_pair[0]] - gain * e_min
else:
gain = 1.0
intercept = 0.0
# Map each known energy to a channel and verify against peaks
for target_energy in known_energies:
expected_channel = gain * target_energy + intercept
if 0 <= expected_channel < n_channels:
best_idx = np.argmin(np.abs(peak_energies - expected_channel))
detected_channel = float(peak_energies[best_idx])
if np.abs(detected_channel - expected_channel) < 10.0:
channel_to_energy[detected_channel] = target_energy
if len(channel_to_energy) < 2:
raise ValueError(
f"Could not match at least 2 Am-241 peaks. "
f"Found {len(channel_to_energy)} of {len(known_energies)} expected. "
f"Check calibration file or provide known_energies."
)
# Build calibration points and fit
calibrator = cls()
calibrator.set_calibration_points(list(channel_to_energy.items()))
return calibrator.fit_calibration(order=1)
+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()
+219
View File
@@ -0,0 +1,219 @@
"""
Live-time determination from pulser calibration spectra.
Uses Gaussian peak fitting to identify the pulser peak in a calibration
spectrum and computes the live-time fraction from the observed peak
count rate compared to the known injection frequency.
Usage:
from exp_data.livetime import LivetimeDetermination
# From an existing ExpSpectrum
determiner = LivetimeDetermination(
spectrum=pulser_spectrum,
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
result = determiner.run()
# Or directly from a binary file
result = LivetimeDetermination.from_file(
filepath="data/raw/CAM1.DAT",
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import numpy as np
from exp_data.spectrum import ExpSpectrum
from exp_data.raw_reader import read_raw_spectrum
from exp_data.fitters import GaussianFitter
@dataclass
class LivetimeResult:
"""Result of a live-time determination."""
live_time_fraction: float
"""Live-time fraction tau (0.0 to 1.0)."""
real_time_sec: float
"""Total real acquisition time in seconds."""
pulser_frequency_hz: float
"""Known pulser injection frequency in Hz."""
observed_pulser_rate: float
"""Observed pulser peak count rate in Hz."""
pulser_peak_center_keV: float
"""Energy of the pulser peak in keV (from Gaussian fit)."""
pulser_peak_sigma_keV: float
"""Width of the pulser peak in keV."""
pulser_peak_chi2: float
"""Reduced chi-squared of the pulser peak fit."""
metadata: dict = field(default_factory=dict)
"""Additional metadata from the determination."""
class LivetimeDetermination:
"""Determine live-time fraction from a pulser calibration spectrum.
The pulser injects known-frequency pulses into the MCA. The observed
peak count rate in the pulser channel, compared to the known injection
frequency, gives the live-time fraction:
tau = observed_pulser_rate / expected_pulser_frequency
Parameters
----------
spectrum : ExpSpectrum
Calibration spectrum with pulser input (energies in keV).
pulser_frequency_hz : float
Known pulser injection frequency in Hz.
real_time_sec : float
Total real acquisition time in seconds (from MCA metadata).
pulser_channel_guess : float, optional
Expected pulser peak channel number (for narrowing search).
If None, searches the full spectrum.
"""
def __init__(
self,
spectrum: ExpSpectrum,
pulser_frequency_hz: float,
real_time_sec: float,
pulser_channel_guess: Optional[float] = None,
):
self.spectrum = spectrum
self.pulser_frequency_hz = pulser_frequency_hz
self.real_time_sec = real_time_sec
self.pulser_channel_guess = pulser_channel_guess
def run(self) -> LivetimeResult:
"""Determine live-time fraction from the pulser peak.
Returns
-------
LivetimeResult
Live-time fraction and associated metadata.
Raises
------
ValueError
If no pulser peak is found or fit fails.
"""
energies = self.spectrum.energies
counts = self.spectrum.counts
uncertainties = (
self.spectrum.errors
if self.spectrum.errors is not None
else np.sqrt(np.maximum(counts, 0))
)
# Narrow search around pulser channel guess if provided
if self.pulser_channel_guess is not None:
mask = np.abs(energies - self.pulser_channel_guess) < 5.0
search_energies = energies[mask]
search_counts = counts[mask]
search_uncertainties = uncertainties[mask]
else:
search_energies = energies
search_counts = counts
search_uncertainties = uncertainties
# Find the highest peak (pulser should be prominent)
peak_idx = np.argmax(search_counts)
peak_center = search_energies[peak_idx]
# Fit Gaussian to pulser peak
fitter = GaussianFitter(search_energies, search_counts, search_uncertainties)
result = fitter.fit(
guess=(search_counts[peak_idx], peak_center, 1.0, np.mean(search_counts))
)
if not result.success:
raise ValueError(f"Pulser peak fit failed: {result}")
# Compute observed rate from peak amplitude
# Peak amplitude is in counts; divide by real_time to get rate
observed_rate = (
result.amplitude / self.real_time_sec if self.real_time_sec > 0 else 0.0
)
# Compute live-time fraction
if self.pulser_frequency_hz <= 0:
raise ValueError("Pulser frequency must be positive")
live_time_fraction = observed_rate / self.pulser_frequency_hz
# Clamp to valid range [0, 1]
live_time_fraction = max(0.0, min(1.0, live_time_fraction))
return LivetimeResult(
live_time_fraction=live_time_fraction,
real_time_sec=self.real_time_sec,
pulser_frequency_hz=self.pulser_frequency_hz,
observed_pulser_rate=observed_rate,
pulser_peak_center_keV=result.center_keV,
pulser_peak_sigma_keV=result.sigma_keV,
pulser_peak_chi2=result.chi2,
metadata={
"pulser_peak_channel": int(peak_center),
"pulser_fit_success": result.success,
},
)
@staticmethod
def from_file(
filepath: str | Path,
pulser_frequency_hz: float,
real_time_sec: float,
n_channels: int = 4096,
run_id: Optional[str] = None,
source: Optional[str] = None,
) -> LivetimeResult:
"""Convenience: read spectrum from file and determine live-time.
Parameters
----------
filepath : str or Path
Path to the pulser calibration binary file.
pulser_frequency_hz : float
Known pulser injection frequency in Hz.
real_time_sec : float
Total real acquisition time in seconds.
n_channels : int
Number of channels.
run_id : str, optional
Run identifier.
source : str, optional
Source identifier (e.g., 'Am241', 'pulser').
Returns
-------
LivetimeResult
"""
spectrum = read_raw_spectrum(
filepath,
n_channels=n_channels,
run_id=run_id,
source=source,
live_time_sec=real_time_sec,
metadata={"operation": "livetime_calibration"},
)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=pulser_frequency_hz,
real_time_sec=real_time_sec,
)
return determiner.run()
+307
View File
@@ -0,0 +1,307 @@
"""
Experimental data processing pipeline.
Orchestrates the workflow: read -> subtract -> calibrate -> livetime -> validate -> export.
Usage:
from exp_data.pipeline import process_spectrum, process_run_sequence
# Single spectrum
result = process_spectrum(
raw_path="data/raw/A1.DAT",
calibration_path="data/raw/CAM1.DAT",
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
# Run sequence (cumulative subtraction)
results = process_run_sequence(
run_dir="data/raw/",
prefix="A",
calibration_path="data/raw/CAM1.DAT",
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import numpy as np
from exp_data.spectrum import ExpSpectrum
from exp_data.raw_reader import read_raw_spectrum, subtract_spectra, get_run_files
from exp_data.calibration import EnergyCalibrator, CalibrationResult
from exp_data.livetime import LivetimeDetermination, LivetimeResult
@dataclass
class PipelineResult:
"""Result of a pipeline execution."""
spectrum: ExpSpectrum
"""Calibrated, corrected ExpSpectrum."""
calibration: CalibrationResult
"""Energy calibration result."""
livetime: Optional[LivetimeResult]
"""Live-time determination result (None if not computed)."""
validation_passed: bool
"""Whether all quality checks passed."""
validation_messages: list[str] = field(default_factory=list)
"""Validation messages (warnings/errors)."""
class SpectrumPipeline:
"""Orchestrate the full experimental data processing pipeline.
Workflow:
1. Read raw binary spectrum
2. (Optional) Subtract from previous cumulative spectrum
3. Apply energy calibration from Am-241
4. Determine live-time from pulser (if available)
5. Validate quality
6. Export (optional)
"""
def __init__(
self,
calibration_path: str | Path,
pulser_frequency_hz: Optional[float] = None,
real_time_sec: Optional[float] = None,
):
self.calibration_path = Path(calibration_path)
self.pulser_frequency_hz = pulser_frequency_hz
self.real_time_sec = real_time_sec
def process(
self,
raw_path: str | Path,
cumulative_path: Optional[str | Path] = None,
run_id: Optional[str] = None,
source: Optional[str] = None,
) -> PipelineResult:
"""Process a single spectrum through the full pipeline.
Parameters
----------
raw_path : str or Path
Path to the raw binary spectrum file.
cumulative_path : str or Path, optional
Path to the previous cumulative spectrum for subtraction.
run_id : str, optional
Run identifier.
source : str, optional
Source identifier.
Returns
-------
PipelineResult
"""
# Step 1: Read raw spectrum
spectrum = read_raw_spectrum(
raw_path,
run_id=run_id,
source=source,
metadata={"operation": "read"},
)
# Step 2: Subtract from cumulative (if provided)
if cumulative_path is not None:
cumulative = read_raw_spectrum(cumulative_path)
spectrum = subtract_spectra(spectrum, cumulative, run_id=run_id)
# Step 3: Apply energy calibration
calibration = EnergyCalibrator.from_am241_spectrum(self.calibration_path)
spectrum = spectrum.apply_calibration(calibration)
# Step 4: Determine live-time (if pulser data available)
livetime = None
if self.pulser_frequency_hz is not None and self.real_time_sec is not None:
livetime = LivetimeDetermination.from_file(
self.calibration_path,
pulser_frequency_hz=self.pulser_frequency_hz,
real_time_sec=self.real_time_sec,
run_id=f"{run_id}_livetime" if run_id else "livetime",
source="pulser",
)
# Store live-time fraction in metadata
spectrum.metadata["live_time"] = {
"fraction": livetime.live_time_fraction,
"real_time_sec": livetime.real_time_sec,
"pulser_frequency_hz": livetime.pulser_frequency_hz,
"observed_pulser_rate": livetime.observed_pulser_rate,
"method": "pulser",
}
spectrum.live_time = livetime.real_time_sec * livetime.live_time_fraction
# Step 5: Validate quality
messages = self._validate(spectrum, calibration, livetime)
passed = len([m for m in messages if m.startswith("ERROR:")]) == 0
return PipelineResult(
spectrum=spectrum,
calibration=calibration,
livetime=livetime,
validation_passed=passed,
validation_messages=messages,
)
def process_sequence(
self,
run_dir: str | Path,
prefix: str = "A",
first_file: Optional[str | Path] = None,
run_id: Optional[str] = None,
source: Optional[str] = None,
) -> list[PipelineResult]:
"""Process a sequence of cumulative binary files.
Reads all files matching prefix in run_dir, subtracts consecutive
pairs to extract per-run spectra.
Parameters
----------
run_dir : str or Path
Directory containing run files.
prefix : str
Filename prefix (default 'A').
first_file : str or Path, optional
Path to the first cumulative file (baseline for subtraction).
If None, the first file in the sorted list is used as baseline.
run_id : str, optional
Base run identifier (appended with run number).
source : str, optional
Source identifier.
Returns
-------
list of PipelineResult
One result per extracted run.
"""
files = get_run_files(run_dir, prefix=prefix)
if len(files) < 2:
raise ValueError(
f"Need at least 2 cumulative files for subtraction, "
f"found {len(files)} in {run_dir}"
)
results = []
for i, filepath in enumerate(files[1:], start=1):
result = self.process(
raw_path=filepath,
cumulative_path=files[0],
run_id=f"{run_id}_{i}" if run_id else None,
source=source,
)
results.append(result)
return results
def _validate(
self,
spectrum: ExpSpectrum,
calibration: CalibrationResult,
livetime: Optional[LivetimeResult],
) -> list[str]:
"""Validate spectrum quality.
Checks:
- Calibration fit quality (chi2/dof < 3)
- Minimum total counts (> 100)
- Live-time fraction in valid range (if available)
- Energy range sanity (energies increasing, positive)
"""
messages = []
# Calibration quality
if calibration.chi2_per_dof > 3.0:
messages.append(
f"ERROR: Calibration fit quality poor (chi2/dof = {calibration.chi2_per_dof:.2f} > 3.0)"
)
elif calibration.chi2_per_dof > 1.5:
messages.append(
f"WARNING: Calibration fit quality marginal (chi2/dof = {calibration.chi2_per_dof:.2f})"
)
# Minimum counts
total = spectrum.total_counts
if total < 100:
messages.append(
f"ERROR: Insufficient statistics (total counts = {total:.0f} < 100)"
)
# Live-time fraction
if livetime is not None:
if livetime.live_time_fraction < 0.5:
messages.append(
f"WARNING: Low live-time fraction (tau = {livetime.live_time_fraction:.3f} < 0.5)"
)
elif livetime.live_time_fraction <= 0.0:
messages.append(
f"ERROR: Invalid live-time fraction (tau = {livetime.live_time_fraction:.3f} <= 0)"
)
# Energy range sanity
if spectrum.energies[0] < 0:
messages.append("ERROR: Negative minimum energy")
if not np.all(np.diff(spectrum.energies) >= 0):
messages.append("ERROR: Energies not monotonically increasing")
return messages
# Convenience functions
def process_spectrum(
raw_path: str | Path,
calibration_path: str | Path,
run_id: Optional[str] = None,
source: Optional[str] = None,
cumulative_path: Optional[str | Path] = None,
pulser_frequency_hz: Optional[float] = None,
real_time_sec: Optional[float] = None,
) -> PipelineResult:
"""Convenience: process a single spectrum through the full pipeline.
See SpectrumPipeline.process() for details.
"""
pipeline = SpectrumPipeline(
calibration_path=calibration_path,
pulser_frequency_hz=pulser_frequency_hz,
real_time_sec=real_time_sec,
)
return pipeline.process(
raw_path, cumulative_path=cumulative_path, run_id=run_id, source=source
)
def process_run_sequence(
run_dir: str | Path,
calibration_path: str | Path,
prefix: str = "A",
run_id: Optional[str] = None,
source: Optional[str] = None,
pulser_frequency_hz: Optional[float] = None,
real_time_sec: Optional[float] = None,
) -> list[PipelineResult]:
"""Convenience: process a run sequence through the full pipeline.
See SpectrumPipeline.process_sequence() for details.
"""
pipeline = SpectrumPipeline(
calibration_path=calibration_path,
pulser_frequency_hz=pulser_frequency_hz,
real_time_sec=real_time_sec,
)
return pipeline.process_sequence(
run_dir, prefix=prefix, run_id=run_id, source=source
)
+5 -3
View File
@@ -163,14 +163,16 @@ def calibrate_from_am241(
filepath = Path(filepath)
with open(filepath, "rb") as f:
f.read(128) # skip header
counts = np.array(struct.unpack(f"{n_channels}i", f.read(n_channels * 4)), dtype=np.int64)
counts = np.array(
struct.unpack(f"{n_channels}i", f.read(n_channels * 4)), dtype=np.int64
)
# Search for peaks in low-energy region only
counts_low = counts.copy()
counts_low[300:] = 0
peaks, props = find_peaks(counts_low, height=10, distance=10)
peak_energies = np.array(peaks, dtype=float)
peak_heights = props["peak_heights"]
_peak_heights = props["peak_heights"] # noqa: F841
# Known Am-241 gamma lines (keV)
am241_lines = {13.8: None, 17.8: None, 26.3: None}
@@ -221,7 +223,7 @@ def read_am241_calibration(
# Am-241 known peaks (keV): 13.8, 17.8, 26.3, 59.54
# The spectrum typically has a few prominent peaks
with open(filepath, "rb") as f:
header = f.read(128)
_header = f.read(128)
counts_raw = struct.unpack(f"{n_channels}i", f.read(n_channels * 4))
counts = np.array(counts_raw, dtype=np.float64)
+287
View File
@@ -0,0 +1,287 @@
"""
ROOT file I/O for experimental spectra.
Supports PyROOT (primary) and uproot (fallback) for reading and writing
ExpSpectrum objects to ROOT TTree/TBranch format.
Usage:
from exp_data.root_io import write_spectrum, read_spectrum
# Write
write_spectrum("output.root", spectrum)
# Read
spectrum = read_spectrum("output.root")
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from exp_data.spectrum import ExpSpectrum
# Try PyROOT first, fall back to uproot
_PYROOT_AVAILABLE = False
_UPTOOL_AVAILABLE = False
try:
import ROOT # noqa: F401
_PYROOT_AVAILABLE = True
except ImportError:
try:
import uproot # type: ignore[import-not-found] # noqa: F401
_UPTOOL_AVAILABLE = True
except ImportError:
raise ImportError(
"Neither PyROOT nor uproot is available. "
"Install one of: pip install beta-spectrum[root] (PyROOT) "
"or pip install uproot (pure Python fallback)."
)
def write_spectrum(
filepath: str | Path,
spectrum: "ExpSpectrum",
tree_name: str = "spectrum",
) -> None:
"""Write an ExpSpectrum to a ROOT file.
Creates a TTree with TBranches for energies, counts, errors,
scalar fields (dead_time, live_time, source, run_id, date),
and metadata as a JSON string.
Parameters
----------
filepath : str or Path
Output ROOT file path.
spectrum : ExpSpectrum
Spectrum to write.
tree_name : str
Name of the TTree in the ROOT file.
"""
filepath = Path(filepath)
if _PYROOT_AVAILABLE:
_write_pyroot(filepath, spectrum, tree_name)
elif _UPTOOL_AVAILABLE:
_write_uproot(filepath, spectrum, tree_name)
else:
raise RuntimeError("No ROOT I/O library available")
def read_spectrum(
filepath: str | Path,
tree_name: str = "spectrum",
) -> "ExpSpectrum":
"""Read an ExpSpectrum from a ROOT file.
Parameters
----------
filepath : str or Path
Input ROOT file path.
tree_name : str
Name of the TTree in the ROOT file.
Returns
-------
ExpSpectrum
Reconstructed spectrum with all metadata.
"""
filepath = Path(filepath)
if _PYROOT_AVAILABLE:
return _read_pyroot(filepath, tree_name)
elif _UPTOOL_AVAILABLE:
return _read_uproot(filepath, tree_name)
else:
raise RuntimeError("No ROOT I/O library available")
# --- PyROOT implementation ---
def _write_pyroot(
filepath: Path,
spectrum: "ExpSpectrum",
tree_name: str,
) -> None:
"""Write ExpSpectrum using PyROOT TTree/TBranch."""
import ROOT
from array import array
with ROOT.TFile(str(filepath), "RECREATE"):
tree = ROOT.TTree(tree_name, "Experimental beta spectrum")
# Data arrays
energies_arr = np.array(spectrum.energies, dtype=np.float64)
counts_arr = np.array(spectrum.counts, dtype=np.float64)
errors_arr = np.array(spectrum.errors, dtype=np.float64)
# TBranches for arrays
n = len(energies_arr)
tree.Branch("energies", energies_arr, f"energies[{n}]/D")
tree.Branch("counts", counts_arr, f"counts[{n}]/D")
tree.Branch("errors", errors_arr, f"errors[{n}]/D")
# Scalar TBranches (using array of length 1)
dead_time_val = array("d", [spectrum.dead_time])
live_time_val = array(
"d", [spectrum.live_time if spectrum.live_time is not None else 0.0]
)
tree.Branch("dead_time", dead_time_val, "dead_time/D")
tree.Branch("live_time", live_time_val, "live_time/D")
# String fields
source_str = ROOT.TObjString(spectrum.source or "")
run_id_str = ROOT.TObjString(spectrum.run_id or "")
date_str = ROOT.TObjString(spectrum.date or "")
tree.Branch("source", source_str)
tree.Branch("run_id", run_id_str)
tree.Branch("date", date_str)
# Metadata as JSON
metadata_json = json.dumps(spectrum.metadata)
metadata_str = ROOT.TObjString(metadata_json)
tree.Branch("metadata_json", metadata_str)
tree.Fill()
tree.Write()
def _read_pyroot(
filepath: Path,
tree_name: str,
) -> "ExpSpectrum":
"""Read ExpSpectrum from ROOT file using PyROOT."""
import ROOT
import json
f = ROOT.TFile(str(filepath), "READ")
tree = f.Get(tree_name)
# Read first (and only) entry
tree.GetEntry(0)
energies = np.array(tree.energies, dtype=np.float64)
counts = np.array(tree.counts, dtype=np.float64)
errors = np.array(tree.errors, dtype=np.float64)
dead_time = float(tree.dead_time)
live_time = float(tree.live_time) if tree.live_time > 0 else None
source_raw = tree.source
source = str(source_raw) if source_raw and str(source_raw) else None
run_id_raw = tree.run_id
run_id = str(run_id_raw) if run_id_raw and str(run_id_raw) else None
date_raw = tree.date
date = str(date_raw) if date_raw and str(date_raw) else None
# Parse metadata JSON
metadata_raw = tree.metadata_json
# Handle various ROOT string types (TObjString, TString, str, bytes)
if hasattr(metadata_raw, "__str__"):
try:
metadata_str = str(metadata_raw)
except Exception:
metadata_str = ""
elif hasattr(metadata_raw, "GetString"):
metadata_str = str(metadata_raw.GetString())
else:
metadata_str = ""
metadata = json.loads(metadata_str) if metadata_str else {}
from exp_data.spectrum import ExpSpectrum
return ExpSpectrum(
energies=energies,
counts=counts,
errors=errors,
metadata=metadata,
dead_time=dead_time,
live_time=live_time,
source=source,
run_id=run_id,
date=date,
)
# --- uproot implementation ---
def _write_uproot(
filepath: Path,
spectrum: "ExpSpectrum",
tree_name: str,
) -> None:
"""Write ExpSpectrum using uproot (pure Python)."""
import uproot
with uproot.recreate(str(filepath)) as f:
f[tree_name] = {
"energies": np.array(spectrum.energies, dtype=">f8"),
"counts": np.array(spectrum.counts, dtype=">f8"),
"errors": np.array(spectrum.errors, dtype=">f8"),
"dead_time": np.array([spectrum.dead_time], dtype=">f8"),
"live_time": np.array(
[spectrum.live_time if spectrum.live_time is not None else 0.0],
dtype=">f8",
),
"source": np.array([spectrum.source or ""], dtype="S100"),
"run_id": np.array([spectrum.run_id or ""], dtype="S100"),
"date": np.array([spectrum.date or ""], dtype="S100"),
"metadata_json": np.array([json.dumps(spectrum.metadata)], dtype="S500"),
}
def _read_uproot(
filepath: Path,
tree_name: str,
) -> "ExpSpectrum":
"""Read ExpSpectrum from ROOT file using uproot."""
import uproot
import json
with uproot.open(str(filepath)) as f:
tree = f[tree_name]
data = tree.arrays(library="np")
energies = np.array(data["energies"], dtype=np.float64)
counts = np.array(data["counts"], dtype=np.float64)
errors = np.array(data["errors"], dtype=np.float64)
dead_time = float(data["dead_time"][0])
live_time_raw = float(data["live_time"][0])
live_time = live_time_raw if live_time_raw > 0 else None
source_raw = data["source"][0]
source = source_raw.decode() if isinstance(source_raw, bytes) else source_raw
run_id_raw = data["run_id"][0]
run_id = run_id_raw.decode() if isinstance(run_id_raw, bytes) else run_id_raw
date_raw = data["date"][0]
date = date_raw.decode() if isinstance(date_raw, bytes) else date_raw
metadata_str = data["metadata_json"][0]
if isinstance(metadata_str, bytes):
metadata_str = metadata_str.decode()
metadata = json.loads(metadata_str) if metadata_str else {}
return ExpSpectrum(
energies=energies,
counts=counts,
errors=errors,
metadata=metadata,
dead_time=dead_time,
live_time=live_time,
source=source if source else None,
run_id=run_id if run_id else None,
date=date if date else None,
)
+42 -1
View File
@@ -8,7 +8,10 @@ and calibration information.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from exp_data.calibration import CalibrationResult
import numpy as np
@@ -140,6 +143,44 @@ class ExpSpectrum:
date=self.date,
)
def apply_calibration(self, calibration: "CalibrationResult") -> ExpSpectrum:
"""Return a new spectrum with energies recalibrated via the given CalibrationResult.
The calibration polynomial is applied to the current energy axis:
calibrated_energies = calibration(channel_indices).
Parameters
----------
calibration : CalibrationResult
A CalibrationResult from EnergyCalibrator (e.g., from Am-241 peak detection).
Returns
-------
ExpSpectrum
New ExpSpectrum with recalibrated energies, all other fields preserved.
"""
calibrated_energies = calibration(self.energies)
return ExpSpectrum(
energies=calibrated_energies,
counts=self.counts.copy(),
errors=self.errors.copy(),
metadata={
**self.metadata,
"calibration": {
"type": "linear" if calibration.order == 1 else "quadratic",
"coefficients": calibration.coefficients.tolist(),
"chi2_per_dof": calibration.chi2_per_dof,
"n_points": calibration.n_points,
},
},
dead_time=self.dead_time,
live_time=self.live_time,
source=self.source,
run_id=self.run_id,
date=self.date,
)
def to_dict(self) -> dict:
"""Serialize to dictionary."""
return {
File diff suppressed because one or more lines are too long
+316
View File
@@ -0,0 +1,316 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Experimental Data Calibration Pipeline\n",
"\n",
"Interactive workflow for processing raw CAMAC MCA binary spectra:\n",
"read -> subtract -> calibrate -> livetime -> validate -> export\n",
"\n",
"## Prerequisites\n",
"- Raw binary spectrum files (A*.DAT format)\n",
"- Am-241 calibration file (CAM1.DAT)\n",
"- Pulser frequency and real time (from calibration run metadata)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import matplotlib\n",
"matplotlib.use(\"Agg\")\n",
"import matplotlib.pyplot as plt\n",
"import numpy as np\n",
"\n",
"from exp_data.pipeline import process_spectrum, process_run_sequence\n",
"from exp_data.root_io import write_spectrum, read_spectrum\n",
"from exp_data.livetime import LivetimeDetermination"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Configuration"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Paths\n",
"RAW_FILE = \"../data/raw/A1\"\n",
"CALIBRATION_FILE = \"../data/raw/CAM1.DAT\"\n",
"RUN_DIR = \"../data/raw/\"\n",
"\n",
"# Pulser parameters (from calibration run metadata)\n",
"PULSER_FREQUENCY_HZ = 100.0\n",
"REAL_TIME_SEC = 3600.0"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Raw Spectrum Visualization\n",
"Visualize raw spectra before calibration to identify peaks and verify calibration source."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Visualize raw spectra before calibration\n",
"# This helps identify what calibration source is actually present\n",
"\n",
"# Detect header size from file\n",
"def detect_header_size(filepath):\n",
" \"\"\"Detect MCA header size by checking for binary pattern.\"\"\"\n",
" with open(filepath, 'rb') as f:\n",
" header = f.read(128)\n",
" # Check if first 32 bytes look like a header (small ints)\n",
" # and bytes 32-128 also look like header\n",
" # Simple heuristic: if file is exactly 32 + 4096*4 = 16412, header=32\n",
" # If file is 128 + 4096*4 = 16512, header=128\n",
" import os\n",
" size = os.path.getsize(filepath)\n",
" expected_32 = 32 + 4096 * 4\n",
" expected_128 = 128 + 4096 * 4\n",
" if size == expected_32:\n",
" return 32\n",
" elif size == expected_128:\n",
" return 128\n",
" else:\n",
" # Try to detect by checking if byte 32 looks like an int\n",
" with open(filepath, 'rb') as f:\n",
" f.seek(32)\n",
" test = f.read(4)\n",
" return 32 # default\n",
"\n",
"# --- Raw run spectrum ---\n",
"header_size = detect_header_size(RAW_FILE)\n",
"with open(RAW_FILE, 'rb') as f:\n",
" f.read(header_size)\n",
" raw_counts = np.fromfile(f, dtype=np.int32, count=4096)\n",
"\n",
"fig, axes = plt.subplots(2, 2, figsize=(14, 8))\n",
"\n",
"ax = axes[0, 0]\n",
"ax.plot(raw_counts, 'b-', linewidth=0.5)\n",
"ax.set_title('Raw Run Spectrum (counts vs channel)')\n",
"ax.set_xlabel('Channel')\n",
"ax.set_ylabel('Counts')\n",
"ax.set_xlim(0, min(200, len(raw_counts)))\n",
"\n",
"ax = axes[0, 1]\n",
"ax.plot(raw_counts, 'b-', linewidth=0.5)\n",
"ax.set_title('Raw Run Spectrum (full range)')\n",
"ax.set_xlabel('Channel')\n",
"ax.set_ylabel('Counts')\n",
"\n",
"# --- Calibration spectrum ---\n",
"with open(CALIBRATION_FILE, 'rb') as f:\n",
" f.read(128) # CAM files have 128-byte header\n",
" cal_counts = np.fromfile(f, dtype=np.int32, count=4096)\n",
"\n",
"ax = axes[1, 0]\n",
"ax.plot(cal_counts, 'r-', linewidth=0.5)\n",
"ax.set_title('Calibration Spectrum (counts vs channel)')\n",
"ax.set_xlabel('Channel')\n",
"ax.set_ylabel('Counts')\n",
"ax.set_xlim(0, min(200, len(cal_counts)))\n",
"\n",
"ax = axes[1, 1]\n",
"ax.plot(cal_counts, 'r-', linewidth=0.5)\n",
"ax.set_title('Calibration Spectrum (full range)')\n",
"ax.set_xlabel('Channel')\n",
"ax.set_ylabel('Counts')\n",
"\n",
"plt.tight_layout()\n",
"plt.show()\n",
"\n",
"# Print some stats\n",
"print(f'Raw run: {len(raw_counts)} channels, max={raw_counts.max()} at ch {np.argmax(raw_counts)}')\n",
"print(f'Calibration: {len(cal_counts)} channels, max={cal_counts.max()} at ch {np.argmax(cal_counts)}')\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Process Single Spectrum"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"result = process_spectrum(\n",
" raw_path=RAW_FILE,\n",
" calibration_path=CALIBRATION_FILE,\n",
" pulser_frequency_hz=PULSER_FREQUENCY_HZ,\n",
" real_time_sec=REAL_TIME_SEC,\n",
")\n",
"\n",
"print(f\"Validation: {'PASSED' if result.validation_passed else 'FAILED'}\")\n",
"print(f\"Calibration chi2/dof: {result.calibration.chi2_per_dof:.4f}\")\n",
"for msg in result.validation_messages:\n",
" print(f\" {msg}\")\n",
"if result.livetime:\n",
" print(f\"Live-time fraction: {result.livetime.live_time_fraction:.4f}\")\n",
" print(f\"Pulser peak: {result.livetime.pulser_peak_center_keV:.1f} keV\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Plot Calibrated Spectrum"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"plt.figure(figsize=(10, 6))\n",
"plt.plot(result.spectrum.energies, result.spectrum.counts, 'b-', label='Counts')\n",
"plt.xlabel('Energy (keV)')\n",
"plt.ylabel('Counts')\n",
"plt.title('Calibrated Beta Spectrum')\n",
"plt.legend()\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Process Run Sequence"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"results = process_run_sequence(\n",
" run_dir=RUN_DIR,\n",
" calibration_path=CALIBRATION_FILE,\n",
" prefix=\"A\",\n",
" pulser_frequency_hz=PULSER_FREQUENCY_HZ,\n",
" real_time_sec=REAL_TIME_SEC,\n",
")\n",
"\n",
"print(f\"Processed {len(results)} runs\")\n",
"for i, r in enumerate(results):\n",
" print(f\" Run {i+1}: {'PASSED' if r.validation_passed else 'FAILED'} \"\n",
" f\"(chi2/dof={r.calibration.chi2_per_dof:.4f})\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Export to ROOT"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"write_spectrum(\"output.root\", result.spectrum)\n",
"print(\"Exported to output.root\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Read Back from ROOT"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"read_back = read_spectrum(\"output.root\")\n",
"print(f\"Read {len(read_back.energies)} channels\")\n",
"print(f\"Source: {read_back.source}\")\n",
"print(f\"Run ID: {read_back.run_id}\")\n",
"print(f\"Calibration: {read_back.metadata.get('calibration', {})}\")\n",
"\n",
"# Verify round-trip\n",
"assert np.allclose(read_back.energies, result.spectrum.energies)\n",
"assert np.allclose(read_back.counts, result.spectrum.counts)\n",
"print(\"Round-trip verified\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Livetime Determination"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Direct livetime determination from calibration file\n",
"livetime_result = LivetimeDetermination.from_file(\n",
" filepath=CALIBRATION_FILE,\n",
" pulser_frequency_hz=PULSER_FREQUENCY_HZ,\n",
" real_time_sec=REAL_TIME_SEC,\n",
" source=\"pulser\",\n",
")\n",
"\n",
"print(f\"Live-time fraction: {livetime_result.live_time_fraction:.4f}\")\n",
"print(f\"Observed pulser rate: {livetime_result.observed_pulser_rate:.1f} Hz\")\n",
"print(f\"Pulser peak: {livetime_result.pulser_peak_center_keV:.1f} keV \"\n",
" f\"(sigma={livetime_result.pulser_peak_sigma_keV:.2f} keV, \"\n",
" f\"chi2/dof={livetime_result.pulser_peak_chi2:.2f})\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.14.4"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
Binary file not shown.
+5 -1
View File
@@ -15,11 +15,14 @@ dependencies = [
"numpy",
"scipy",
"matplotlib",
"pandas"
"pandas",
"nbformat>=5.10.4",
"nbconvert>=7.17.1",
]
[project.optional-dependencies]
ensdf = ["paceENSDF"]
root = ["root"] # PyROOT (requires system ROOT installation)
dev = [
"pytest",
"nbmake",
@@ -34,6 +37,7 @@ dev = [
[project.scripts]
bs_pnpi = "beta_spectrum.cli:main"
bs_exp = "exp_data.exp_cli:main"
[tool.setuptools.packages.find]
where = ["."]
+49
View File
@@ -3,6 +3,9 @@
import numpy as np
import pytest
from exp_data.spectrum import ExpSpectrum
from exp_data.calibration import CalibrationResult
@pytest.fixture()
def W_low():
@@ -33,3 +36,49 @@ def W_full():
def large_W():
"""Large array for performance/shape checks."""
return np.linspace(1.002, 4.9, 1000)
@pytest.fixture()
def synthetic_spectrum():
"""Create a synthetic ExpSpectrum (4096 channels)."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 1000 + np.random.poisson(5, n_channels)
return ExpSpectrum(
energies=energies,
counts=counts,
source="Tc99",
run_id="synthetic",
)
@pytest.fixture()
def calibration_result():
"""Create a synthetic CalibrationResult (linear, E = 0.4 + 0.1*ch)."""
coeffs = np.array([0.1, 0.4]) # polyfit order: [slope, intercept]
cov = np.eye(2) * 1e-6
return CalibrationResult(
coefficients=coeffs,
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=cov,
)
@pytest.fixture()
def full_spectrum():
"""Create a full ExpSpectrum with all fields populated."""
return ExpSpectrum(
energies=np.arange(4096, dtype=np.float64),
counts=np.exp(-np.arange(4096) / 500) * 1000 + np.random.poisson(5, 4096),
source="Tc99",
run_id="run_042",
date="2025-01-15",
dead_time=0.05,
live_time=3420.0,
metadata={
"calibration": {"type": "linear", "coefficients": [0.1, 0.4]},
"livetime": {"fraction": 0.95, "method": "pulser"},
},
)
+55
View File
@@ -0,0 +1,55 @@
"""Shared fixtures for exp_data tests."""
from __future__ import annotations
import numpy as np
import pytest
from exp_data.spectrum import ExpSpectrum
from exp_data.calibration import CalibrationResult
@pytest.fixture
def synthetic_spectrum():
"""Create a synthetic ExpSpectrum (4096 channels)."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 1000 + np.random.poisson(5, n_channels)
return ExpSpectrum(
energies=energies,
counts=counts,
source="Tc99",
run_id="synthetic",
)
@pytest.fixture
def calibration_result():
"""Create a synthetic CalibrationResult (linear, E = 0.4 + 0.1*ch)."""
coeffs = np.array([0.1, 0.4]) # polyfit order: [slope, intercept]
cov = np.eye(2) * 1e-6
return CalibrationResult(
coefficients=coeffs,
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=cov,
)
@pytest.fixture
def full_spectrum():
"""Create a full ExpSpectrum with all fields populated."""
return ExpSpectrum(
energies=np.arange(4096, dtype=np.float64),
counts=np.exp(-np.arange(4096) / 500) * 1000 + np.random.poisson(5, 4096),
source="Tc99",
run_id="run_042",
date="2025-01-15",
dead_time=0.05,
live_time=3420.0,
metadata={
"calibration": {"type": "linear", "coefficients": [0.1, 0.4]},
"livetime": {"fraction": 0.95, "method": "pulser"},
},
)
+142
View File
@@ -0,0 +1,142 @@
"""Unit tests for the LivetimeDetermination class."""
from __future__ import annotations
import numpy as np
import pytest
from exp_data.spectrum import ExpSpectrum
from exp_data.livetime import LivetimeDetermination, LivetimeResult
class TestLivetimeResult:
def test_basic(self):
result = LivetimeResult(
live_time_fraction=0.95,
real_time_sec=3600.0,
pulser_frequency_hz=100.0,
observed_pulser_rate=95.0,
pulser_peak_center_keV=10.0,
pulser_peak_sigma_keV=0.5,
pulser_peak_chi2=1.2,
)
assert result.live_time_fraction == 0.95
assert result.metadata == {}
def test_metadata_populated(self):
result = LivetimeResult(
live_time_fraction=0.95,
real_time_sec=3600.0,
pulser_frequency_hz=100.0,
observed_pulser_rate=95.0,
pulser_peak_center_keV=10.0,
pulser_peak_sigma_keV=0.5,
pulser_peak_chi2=1.2,
metadata={"pulser_peak_channel": 100, "pulser_fit_success": True},
)
assert result.metadata["pulser_peak_channel"] == 100
class TestLivetimeDetermination:
def test_from_file_requires_positive_frequency(self):
"""Test that zero frequency raises ValueError."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.ones(n_channels) * 10
spectrum = ExpSpectrum(energies=energies, counts=counts)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=0.0,
real_time_sec=3600.0,
)
with pytest.raises(ValueError, match="positive"):
determiner.run()
def test_live_time_clamped_to_1(self):
"""Test that live-time fraction > 1.0 is clamped to 1.0."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10 # background
# Add prominent pulser peak at channel 100
pulser_channel = 100
pulser_peak = 500 * np.exp(-0.5 * ((energies - pulser_channel) / 2.0) ** 2)
counts += pulser_peak.astype(float)
spectrum = ExpSpectrum(energies=energies, counts=counts)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=10.0, # low frequency -> observed rate > frequency
real_time_sec=0.01, # very short time -> high observed rate
)
result = determiner.run()
assert result.live_time_fraction == 1.0
def test_live_time_clamped_to_0(self):
"""Test that live-time fraction < 0 is clamped to 0."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10 # background only, no pulser peak
spectrum = ExpSpectrum(energies=energies, counts=counts)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
result = determiner.run()
# With no pulser peak, the fitter may find a tiny spurious peak
# but the observed rate will be negligible -> live_time ~ 0
assert result.live_time_fraction >= 0.0
assert result.live_time_fraction <= 0.01
def test_pulser_peak_detection(self):
"""Test that LivetimeDetermination correctly identifies a pulser peak."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10 # background
# Add pulser peak at channel 100 (10 keV)
pulser_channel = 100
pulser_peak = 500 * np.exp(-0.5 * ((energies - pulser_channel) / 2.0) ** 2)
counts += pulser_peak.astype(float)
spectrum = ExpSpectrum(
energies=energies,
counts=counts,
source="pulser",
)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
result = determiner.run()
assert result.live_time_fraction > 0
assert result.live_time_fraction <= 1.0
assert result.pulser_peak_center_keV == pytest.approx(pulser_channel, abs=5.0)
def test_result_contains_pulser_info(self):
"""Test that result contains pulser peak info."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10
pulser_channel = 100
pulser_peak = 500 * np.exp(-0.5 * ((energies - pulser_channel) / 2.0) ** 2)
counts += pulser_peak.astype(float)
spectrum = ExpSpectrum(energies=energies, counts=counts, source="pulser")
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
result = determiner.run()
assert result.pulser_peak_center_keV > 0
assert result.pulser_peak_sigma_keV > 0
assert result.pulser_peak_chi2 > 0
assert result.pulser_frequency_hz == 100.0
assert result.real_time_sec == 3600.0
+84
View File
@@ -0,0 +1,84 @@
"""Unit tests for ROOT I/O (skip if no ROOT library available)."""
from __future__ import annotations
import numpy as np
import pytest
from exp_data.spectrum import ExpSpectrum
def _has_root_io():
"""Check if any ROOT I/O library is available."""
try:
import ROOT # noqa: F401
return True
except ImportError:
pass
try:
import uproot # noqa: F401
return True
except ImportError:
return False
@pytest.fixture
def sample_spectrum():
"""Create a sample ExpSpectrum for testing."""
return ExpSpectrum(
energies=np.arange(100, dtype=np.float64),
counts=np.random.poisson(10, 100).astype(np.float64),
source="Tc99",
run_id="test_run",
dead_time=0.05,
live_time=3420.0,
date="2025-01-15",
metadata={
"calibration": {"type": "linear", "coefficients": [0.1, 0.4]},
"livetime": {"fraction": 0.95},
},
)
@pytest.mark.skipif(
not _has_root_io(),
reason="Neither PyROOT nor uproot available",
)
class TestRootIOWriteRead:
def test_round_trip(self, sample_spectrum, tmp_path):
"""Test write and read round-trip."""
from exp_data.root_io import write_spectrum, read_spectrum
root_path = tmp_path / "test.root"
write_spectrum(str(root_path), sample_spectrum)
read_back = read_spectrum(str(root_path))
np.testing.assert_array_equal(read_back.energies, sample_spectrum.energies)
np.testing.assert_array_equal(read_back.counts, sample_spectrum.counts)
def test_scalar_fields(self, sample_spectrum, tmp_path):
"""Test that scalar fields survive round-trip."""
from exp_data.root_io import write_spectrum, read_spectrum
root_path = tmp_path / "test.root"
write_spectrum(str(root_path), sample_spectrum)
read_back = read_spectrum(str(root_path))
assert read_back.dead_time == sample_spectrum.dead_time
assert read_back.source == sample_spectrum.source
assert read_back.run_id == sample_spectrum.run_id
def test_metadata(self, sample_spectrum, tmp_path):
"""Test that metadata survives round-trip."""
from exp_data.root_io import write_spectrum, read_spectrum
root_path = tmp_path / "test.root"
write_spectrum(str(root_path), sample_spectrum)
read_back = read_spectrum(str(root_path))
assert "calibration" in read_back.metadata
assert read_back.metadata["calibration"]["type"] == "linear"
assert read_back.metadata["livetime"]["fraction"] == 0.95
+16 -8
View File
@@ -26,12 +26,13 @@ class TestExpSpectrumBasic:
expected = np.sqrt([10.0, 20.0, 30.0])
np.testing.assert_allclose(spectrum.errors, expected)
def test_zero_counts_get_error_one(self):
def test_zero_counts_get_zero_error(self):
"""Zero counts produce zero error (not sqrt(1))."""
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0]),
counts=np.array([0.0, 100.0]),
)
assert spectrum.errors[0] == 1.0 # sqrt(1) for zero counts
assert spectrum.errors[0] == 0.0 # sqrt(0) = 0
def test_shape_mismatch_raises(self):
with pytest.raises(AssertionError):
@@ -47,12 +48,19 @@ class TestExpSpectrumBasic:
counts=np.array([10.0, 20.0]),
)
def test_negative_counts_raises(self):
with pytest.raises(AssertionError):
ExpSpectrum(
energies=np.array([0.0, 1.0]),
counts=np.array([10.0, -5.0]),
)
def test_negative_counts_allowed_for_difference_spectra(self):
"""Negative counts are allowed — they arise in cumulative subtraction.
Difference spectra can have negative counts in channels where the true
count is zero and Poisson noise causes the earlier cumulative to slightly
exceed the later one.
"""
spectrum = ExpSpectrum(
energies=np.array([0.0, 1.0]),
counts=np.array([10.0, -5.0]),
)
# Should not raise; errors use abs(counts) for negative values
assert spectrum.errors[1] == np.sqrt(5.0)
def test_total_counts(self):
spectrum = ExpSpectrum(
+380
View File
@@ -0,0 +1,380 @@
"""Integration tests for the experimental data processing pipeline."""
from __future__ import annotations
import numpy as np
import pytest
from exp_data.spectrum import ExpSpectrum
from exp_data.calibration import CalibrationResult
from exp_data.pipeline import SpectrumPipeline
from exp_data.livetime import LivetimeDetermination
def _has_root_io():
"""Check if any ROOT I/O library is available."""
try:
import ROOT # noqa: F401
return True
except ImportError:
pass
try:
import uproot # noqa: F401
return True
except ImportError:
return False
# --- ExpSpectrum.apply_calibration tests ---
class TestApplyCalibration:
def test_returns_new_spectrum(self, synthetic_spectrum, calibration_result):
"""Test that apply_calibration returns a new ExpSpectrum."""
calibrated = synthetic_spectrum.apply_calibration(calibration_result)
assert calibrated is not synthetic_spectrum
assert calibrated.n_channels == synthetic_spectrum.n_channels
def test_energies_recalibrated(self, synthetic_spectrum, calibration_result):
"""Test that energies are recalibrated via the calibration polynomial."""
calibrated = synthetic_spectrum.apply_calibration(calibration_result)
expected = calibration_result(synthetic_spectrum.energies)
np.testing.assert_allclose(calibrated.energies, expected)
def test_counts_preserved(self, synthetic_spectrum, calibration_result):
"""Test that counts are preserved (copied, not modified)."""
calibrated = synthetic_spectrum.apply_calibration(calibration_result)
np.testing.assert_array_equal(calibrated.counts, synthetic_spectrum.counts)
def test_errors_preserved(self, synthetic_spectrum, calibration_result):
"""Test that errors are preserved."""
calibrated = synthetic_spectrum.apply_calibration(calibration_result)
np.testing.assert_array_equal(calibrated.errors, synthetic_spectrum.errors)
def test_metadata_updated_with_calibration(
self, synthetic_spectrum, calibration_result
):
"""Test that metadata includes calibration info."""
calibrated = synthetic_spectrum.apply_calibration(calibration_result)
assert "calibration" in calibrated.metadata
assert calibrated.metadata["calibration"]["type"] == "linear"
assert (
calibrated.metadata["calibration"]["chi2_per_dof"]
== calibration_result.chi2_per_dof
)
def test_scalar_fields_preserved(self, synthetic_spectrum, calibration_result):
"""Test that all scalar fields are preserved."""
spectrum = ExpSpectrum(
energies=synthetic_spectrum.energies,
counts=synthetic_spectrum.counts,
dead_time=0.05,
live_time=3420.0,
source="Tc99",
run_id="run_042",
date="2025-01-15",
)
calibrated = spectrum.apply_calibration(calibration_result)
assert calibrated.dead_time == 0.05
assert calibrated.live_time == 3420.0
assert calibrated.source == "Tc99"
assert calibrated.run_id == "run_042"
assert calibrated.date == "2025-01-15"
def test_metadata_merged_not_replaced(self, synthetic_spectrum, calibration_result):
"""Test that existing metadata is merged, not replaced."""
spectrum = ExpSpectrum(
energies=synthetic_spectrum.energies,
counts=synthetic_spectrum.counts,
metadata={"existing_key": "existing_value"},
)
calibrated = spectrum.apply_calibration(calibration_result)
assert "existing_key" in calibrated.metadata
assert calibrated.metadata["existing_key"] == "existing_value"
assert "calibration" in calibrated.metadata
# --- LivetimeDetermination tests ---
class TestLivetimeDetermination:
def test_pulser_peak_detection(self):
"""Test that LivetimeDetermination correctly identifies a pulser peak."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10 # background
# Add pulser peak at channel 100 (10 keV)
pulser_channel = 100
pulser_peak = 500 * np.exp(-0.5 * ((energies - pulser_channel) / 2.0) ** 2)
counts += pulser_peak.astype(float)
spectrum = ExpSpectrum(
energies=energies,
counts=counts,
source="pulser",
)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
result = determiner.run()
assert result.live_time_fraction > 0
assert result.live_time_fraction <= 1.0
assert result.pulser_peak_center_keV == pytest.approx(pulser_channel, abs=5.0)
def test_invalid_pulser_frequency(self):
"""Test that zero frequency raises ValueError."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.ones(n_channels) * 10
spectrum = ExpSpectrum(energies=energies, counts=counts)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=0.0,
real_time_sec=3600.0,
)
with pytest.raises(ValueError, match="positive"):
determiner.run()
def test_live_time_clamped_to_1(self):
"""Test that live-time fraction > 1.0 is clamped to 1.0."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10 # background
# Add prominent pulser peak at channel 100
pulser_channel = 100
pulser_peak = 500 * np.exp(-0.5 * ((energies - pulser_channel) / 2.0) ** 2)
counts += pulser_peak.astype(float)
spectrum = ExpSpectrum(energies=energies, counts=counts)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=10.0, # low frequency -> observed rate > frequency
real_time_sec=0.01, # very short time -> high observed rate
)
result = determiner.run()
assert result.live_time_fraction == 1.0
def test_live_time_clamped_to_0(self):
"""Test that live-time fraction < 0 is clamped to 0."""
n_channels = 4096
energies = np.arange(n_channels, dtype=np.float64)
counts = np.exp(-energies / 500) * 10 # background only, no pulser peak
spectrum = ExpSpectrum(energies=energies, counts=counts)
determiner = LivetimeDetermination(
spectrum,
pulser_frequency_hz=100.0,
real_time_sec=3600.0,
)
result = determiner.run()
# With no pulser peak, the fitter may find a tiny spurious peak
# but the observed rate will be negligible -> live_time ~ 0
assert result.live_time_fraction >= 0.0
assert result.live_time_fraction <= 0.01
# --- Pipeline validation tests ---
class TestPipelineValidation:
def test_calib_chi2_error(self):
"""Test that poor calibration fit (chi2 > 3) produces an error message."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
spectrum = ExpSpectrum(
energies=np.arange(100, dtype=np.float64),
counts=np.ones(100) * 10,
)
bad_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=5.0, # poor fit
n_points=2,
covariance=np.eye(2),
)
messages = pipeline._validate(spectrum, bad_calib, None)
assert any("ERROR" in m for m in messages)
def test_calib_chi2_warning(self):
"""Test that marginal calibration fit (1.5 < chi2 < 3) produces a warning."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
spectrum = ExpSpectrum(
energies=np.arange(100, dtype=np.float64),
counts=np.ones(100) * 10,
)
marginal_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=2.0, # marginal fit
n_points=2,
covariance=np.eye(2),
)
messages = pipeline._validate(spectrum, marginal_calib, None)
assert any("WARNING" in m for m in messages)
assert not any("ERROR" in m for m in messages)
def test_insufficient_counts(self):
"""Test that low counts produce an error."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
spectrum = ExpSpectrum(
energies=np.arange(10, dtype=np.float64),
counts=np.array([1.0] * 10), # only 10 counts total
)
good_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=np.eye(2),
)
messages = pipeline._validate(spectrum, good_calib, None)
assert any("ERROR" in m and "counts" in m for m in messages)
def test_negative_energy(self):
"""Test that negative energies are caught after calibration."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
# Create a spectrum with valid positive energies first
spectrum = ExpSpectrum(
energies=np.array([1.0, 2.0, 3.0, 4.0]),
counts=np.array([10.0] * 4),
)
# Manually set energies to negative (simulating bad calibration)
spectrum.energies = np.array([-1.0, 0.0, 1.0, 2.0])
good_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=np.eye(2),
)
messages = pipeline._validate(spectrum, good_calib, None)
assert any("ERROR" in m and "negative" in m.lower() for m in messages)
def test_non_monotonic_energies(self):
"""Test that non-monotonic energies are caught."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
spectrum = ExpSpectrum(
energies=np.array([0.0, 2.0, 1.0, 3.0]),
counts=np.array([10.0] * 4),
)
good_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=np.eye(2),
)
messages = pipeline._validate(spectrum, good_calib, None)
assert any("ERROR" in m and "monoton" in m for m in messages)
def test_low_live_time_fraction_warning(self):
"""Test that low live-time fraction produces a warning."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
spectrum = ExpSpectrum(
energies=np.arange(100, dtype=np.float64),
counts=np.ones(100) * 10,
)
good_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=np.eye(2),
)
bad_livetime = type(
"MockLivetime",
(),
{
"live_time_fraction": 0.3,
},
)()
messages = pipeline._validate(spectrum, good_calib, bad_livetime)
assert any("WARNING" in m and "live-time" in m.lower() for m in messages)
def test_all_checks_pass(self):
"""Test that good data produces no errors."""
pipeline = SpectrumPipeline(calibration_path="/dev/null")
spectrum = ExpSpectrum(
energies=np.arange(100, dtype=np.float64),
counts=np.ones(100) * 100,
)
good_calib = CalibrationResult(
coefficients=np.array([0.1, 0.4]),
order=1,
chi2_per_dof=0.5,
n_points=2,
covariance=np.eye(2),
)
good_livetime = type(
"MockLivetime",
(),
{
"live_time_fraction": 0.95,
},
)()
messages = pipeline._validate(spectrum, good_calib, good_livetime)
assert not any("ERROR" in m for m in messages)
# --- ROOT I/O integration tests ---
class TestRootIOIntegration:
@pytest.mark.skipif(
not _has_root_io(),
reason="Neither PyROOT nor uproot available",
)
def test_round_trip(self, full_spectrum, tmp_path):
"""Test write and read round-trip preserves all fields."""
from exp_data.root_io import write_spectrum, read_spectrum
root_path = tmp_path / "test.root"
write_spectrum(str(root_path), full_spectrum)
read_back = read_spectrum(str(root_path))
np.testing.assert_array_equal(read_back.energies, full_spectrum.energies)
np.testing.assert_array_equal(read_back.counts, full_spectrum.counts)
assert read_back.dead_time == full_spectrum.dead_time
assert read_back.source == full_spectrum.source
assert read_back.run_id == full_spectrum.run_id
assert read_back.date == full_spectrum.date
@pytest.mark.skipif(
not _has_root_io(),
reason="Neither PyROOT nor uproot available",
)
def test_metadata_round_trip(self, full_spectrum, tmp_path):
"""Test that metadata survives round-trip."""
from exp_data.root_io import write_spectrum, read_spectrum
root_path = tmp_path / "test.root"
write_spectrum(str(root_path), full_spectrum)
read_back = read_spectrum(str(root_path))
assert "calibration" in read_back.metadata
assert read_back.metadata["calibration"]["type"] == "linear"
assert "livetime" in read_back.metadata
assert read_back.metadata["livetime"]["fraction"] == 0.95
def _has_root_io():
"""Check if any ROOT I/O library is available."""
try:
import ROOT # noqa: F401
return True
except ImportError:
pass
try:
import uproot # noqa: F401
return True
except ImportError:
return False
+8 -1
View File
@@ -9,7 +9,7 @@ work together correctly.
import numpy as np
import pytest
from beta_spectrum import BetaSpectrum, DetectorResponse, SpectrumConfig
from beta_spectrum import BetaSpectrum, SpectrumConfig
class TestBetaSpectrumIntegration:
@@ -160,6 +160,7 @@ class TestBetaSpectrumRealisticConfig:
assert np.any(values > 0), "Should have non-zero spectrum values"
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
class TestDeclarativeDetectorResponse:
"""Test declarative detector response workflow via SpectrumConfig."""
@@ -182,6 +183,7 @@ class TestDeclarativeDetectorResponse:
assert detector._mode == "analytical"
assert detector.model == "gaussian"
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
def test_detector_sigma_conversion(self):
"""Sigma in keV should be correctly converted to m_e units."""
from beta_spectrum.utils import T_to_W
@@ -202,6 +204,7 @@ class TestDeclarativeDetectorResponse:
sigma_at_endpoint = detector._resolution_sigma(T_to_W(0.294))
assert np.isclose(sigma_at_endpoint, expected_sigma, rtol=0.01)
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
def test_convolve_detector_single_call(self):
"""convolve_detector() should work in a single call with config params."""
config = SpectrumConfig(
@@ -224,6 +227,7 @@ class TestDeclarativeDetectorResponse:
assert np.all(convolved >= 0), "Convolved spectrum should be non-negative"
assert np.sum(convolved) > 0, "Convolved spectrum should have non-zero area"
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
def test_convolve_detector_with_tail(self):
"""Convolution with tail model should produce low-energy tailing."""
config = SpectrumConfig(
@@ -247,6 +251,7 @@ class TestDeclarativeDetectorResponse:
assert convolved.shape == (512,)
assert np.all(convolved >= 0)
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
def test_analyzer_convolved_from_config(self):
"""BetaSpectrumAnalyzer should use config detector params automatically."""
config = SpectrumConfig(
@@ -276,6 +281,7 @@ class TestDeclarativeDetectorResponse:
integral, 1.0, rtol=0.05
), f"Integral should be ~1.0, got {integral}"
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
def test_detector_range_extends_to_endpoint(self):
"""Detector channel range should extend to cover spectrum endpoint."""
config = SpectrumConfig(
@@ -299,6 +305,7 @@ class TestDeclarativeDetectorResponse:
f"endpoint {W0:.4f}"
)
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
def test_different_detector_models(self):
"""All detector models should produce valid convolved spectra."""
models = ["gaussian", "gaussian_tail", "tikhonov"]
+5 -1
View File
@@ -7,9 +7,13 @@ Tests detector response shapes, resolution, and convolution physics.
import numpy as np
import pytest
from beta_spectrum.components.detector_response import DetectorResponse
try:
from beta_spectrum.components.detector_response import DetectorResponse
except ImportError:
DetectorResponse = None # type: ignore[misc, assignment]
@pytest.mark.skipif(DetectorResponse is None, reason="DetectorResponse not yet implemented")
class TestAnalyticalGaussianResponse:
"""Test pure Gaussian detector response."""
+6 -1
View File
@@ -7,9 +7,13 @@ Tests factory methods, repr, error handling.
import numpy as np
import pytest
from beta_spectrum.components.detector_response import DetectorResponse
try:
from beta_spectrum.components.detector_response import DetectorResponse
except ImportError:
DetectorResponse = None # type: ignore[misc, assignment]
@pytest.mark.skipif(DetectorResponse is None, reason="DetectorResponse not yet implemented")
class TestFactoryMethods:
"""Test convenience factory methods."""
@@ -52,6 +56,7 @@ class TestFactoryMethods:
assert len(resp.calibration_energies) == 3
@pytest.mark.skipif(DetectorResponse is None, reason="DetectorResponse not yet implemented")
class TestEdgeCases:
"""Test edge cases and error handling."""