feat(detector): add detector response module, convolution, and integration
- Analytical detector response: gaussian, gaussian_tail, tikhonov models - Low-energy tail modeling for charge collection effects - Response matrix convolution via R^T · (Φ·ΔW) - Declarative API: create_detector_from_config(), convolve_detector() - BetaSpectrumAnalyzer.convolved_spectrum() for auto-convolution - Integration tests: TestBetaSpectrumIntegration, TestDeclarativeDetectorResponse - Documentation: docs/08-detector-response.md
This commit is contained in:
@@ -9,6 +9,7 @@ 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, BetaSpectrumAnalyzer
|
||||
|
||||
@@ -27,9 +28,10 @@ __all__ = [
|
||||
"ScreeningCorrection",
|
||||
"ExchangeCorrection",
|
||||
"RadiativeCorrection",
|
||||
"DetectorResponse",
|
||||
"BetaSpectrum",
|
||||
"SpectrumConfig",
|
||||
"BetaSpectrumAnalyzer",
|
||||
]
|
||||
|
||||
__version__ = "0.1.2"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
# 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
|
||||
|
||||
|
||||
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):
|
||||
"""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):
|
||||
"""Build interpolation function for tabulated response."""
|
||||
interp = RegularGridInterpolator(
|
||||
(self.calibration_energies, self.channel_energies),
|
||||
self.response_matrix,
|
||||
method="linear",
|
||||
bounds_error=False,
|
||||
fill_value=0.0,
|
||||
)
|
||||
return interp
|
||||
|
||||
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
|
||||
# Need to interpolate along energy axis only
|
||||
resp = np.zeros(self.n_channels)
|
||||
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] = f(energy)
|
||||
return resp
|
||||
else:
|
||||
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)
|
||||
elif self.model == "gaussian_tail":
|
||||
return self._gaussian_tail_response(energy, sigma)
|
||||
elif self.model == "tikhonov":
|
||||
return self._tikhonov_response(energy, sigma)
|
||||
else:
|
||||
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 / 0.511 # 3.6 eV in m_e units
|
||||
sigma_sq += self.fano_factor * E * w_me
|
||||
return 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)
|
||||
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 resp
|
||||
|
||||
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)
|
||||
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 core
|
||||
|
||||
# Exponential tail (Tikhonov function): defined for all E
|
||||
tail = np.zeros(self.n_channels)
|
||||
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 = np.sum(resp)
|
||||
resp /= dw
|
||||
if area > 0:
|
||||
resp /= area
|
||||
|
||||
return resp
|
||||
|
||||
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 convolved
|
||||
|
||||
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 response_matrix.T @ (spectrum_values * dw)
|
||||
|
||||
@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})"
|
||||
)
|
||||
+189
-4
@@ -1,7 +1,7 @@
|
||||
# spectrum.py
|
||||
import numpy as np
|
||||
import matplotlib.pyplot as plt
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from typing import List, Dict, Optional, Tuple, TYPE_CHECKING
|
||||
from dataclasses import dataclass
|
||||
|
||||
from beta_spectrum.base import SpectrumComponent
|
||||
@@ -14,11 +14,20 @@ from beta_spectrum.components.radiative import RadiativeCorrection
|
||||
|
||||
from beta_spectrum.utils import T_to_W
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from beta_spectrum.components.detector_response import DetectorResponse
|
||||
|
||||
ME_ME = 0.510998950 # electron rest mass in MeV
|
||||
|
||||
|
||||
@dataclass
|
||||
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().
|
||||
"""
|
||||
|
||||
Z_parent: int
|
||||
@@ -37,6 +46,20 @@ 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)
|
||||
|
||||
|
||||
class BetaSpectrum:
|
||||
"""
|
||||
@@ -83,9 +106,7 @@ class BetaSpectrum:
|
||||
W0 = T_to_W(config.endpoint_MeV)
|
||||
|
||||
if config.use_phase_space:
|
||||
components.append(
|
||||
PhaseSpace(W0=W0, transition_type=config.transition_type)
|
||||
)
|
||||
components.append(PhaseSpace(W0=W0, transition_type=config.transition_type))
|
||||
|
||||
if config.use_fermi:
|
||||
components.append(FermiFunction(Z=config.Z_daughter, A=config.A_number))
|
||||
@@ -122,6 +143,127 @@ class BetaSpectrum:
|
||||
|
||||
return W, kinetic_MeV
|
||||
|
||||
@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_ME
|
||||
tau_me = config.detector_tau_keV / ME_ME
|
||||
|
||||
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], 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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class BetaSpectrumAnalyzer:
|
||||
"""
|
||||
@@ -155,6 +297,49 @@ 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
|
||||
|
||||
def plot_analysis(self, save_path: Optional[str] = None):
|
||||
"""
|
||||
Create visualization of the spectrum and all correction.
|
||||
|
||||
Reference in New Issue
Block a user