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:
2026-05-01 19:20:59 +03:00
parent dac78987e6
commit 5f99566a0e
5 changed files with 1412 additions and 5 deletions
+189 -4
View File
@@ -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.