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] = {