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:
+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