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:
@@ -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",
|
||||
|
||||
@@ -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})"
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
Reference in New Issue
Block a user