Source code for nirs4all.data.synthetic.detectors

"""
Detector simulation for synthetic NIRS data generation.

This module provides detailed simulation of NIR detector characteristics
including spectral response curves, noise models, and nonlinearity effects.

Key Features:
    - Detector spectral response curves (Si, InGaAs, PbS, etc.)
    - Shot noise, thermal noise, read noise, and 1/f noise models
    - Detector nonlinearity simulation
    - Temperature-dependent behavior

References:
    - Rogalski, A. (2002). Infrared Detectors: An Overview.
      Infrared Physics & Technology, 43(3-5), 187-210.
    - Vincent, J. D., Hodges, S., Vampola, J., Stegall, M., & Pierce, G. (2015).
      Fundamentals of Infrared and Visible Detector Operation and Testing.
      Wiley.
    - Burns, D. A., & Ciurczak, E. W. (2007). Handbook of Near-Infrared
      Analysis. CRC Press.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple, Union

import numpy as np
from scipy.interpolate import interp1d

from .instruments import DetectorType


[docs] @dataclass class DetectorSpectralResponse: """ Spectral response curve for a detector. Defines the wavelength-dependent sensitivity (quantum efficiency) of the detector. Attributes: detector_type: Type of detector. wavelengths: Wavelength grid for response curve (nm). response: Relative response at each wavelength (0-1). peak_wavelength: Wavelength of peak response (nm). cutoff_wavelength: Long-wavelength cutoff (nm). short_cutoff: Short-wavelength cutoff (nm). peak_qe: Peak quantum efficiency (0-1). """ detector_type: DetectorType wavelengths: np.ndarray response: np.ndarray peak_wavelength: float cutoff_wavelength: float short_cutoff: float peak_qe: float = 0.7
[docs] def get_response_at(self, wavelengths: np.ndarray) -> np.ndarray: """ Get detector response at specified wavelengths. Args: wavelengths: Wavelengths to evaluate (nm). Returns: Detector response at each wavelength. """ # Interpolate response curve interp_func = interp1d( self.wavelengths, self.response, kind='linear', bounds_error=False, fill_value=0.0 ) return interp_func(wavelengths)
def _create_silicon_response() -> DetectorSpectralResponse: """Create silicon detector spectral response.""" wl = np.array([350, 400, 500, 600, 700, 800, 900, 1000, 1050, 1100, 1150]) resp = np.array([0.1, 0.3, 0.55, 0.7, 0.8, 0.85, 0.75, 0.5, 0.3, 0.1, 0.0]) return DetectorSpectralResponse( detector_type=DetectorType.SI, wavelengths=wl, response=resp, peak_wavelength=850, cutoff_wavelength=1100, short_cutoff=350, peak_qe=0.85 ) def _create_ingaas_response() -> DetectorSpectralResponse: """Create InGaAs detector spectral response.""" wl = np.array([850, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1650, 1700, 1750]) resp = np.array([0.1, 0.3, 0.6, 0.8, 0.9, 0.92, 0.9, 0.85, 0.75, 0.6, 0.3, 0.0]) return DetectorSpectralResponse( detector_type=DetectorType.INGAAS, wavelengths=wl, response=resp, peak_wavelength=1300, cutoff_wavelength=1700, short_cutoff=850, peak_qe=0.92 ) def _create_ingaas_extended_response() -> DetectorSpectralResponse: """Create extended InGaAs detector spectral response.""" wl = np.array([850, 1000, 1200, 1400, 1600, 1800, 2000, 2200, 2400, 2500, 2600]) resp = np.array([0.1, 0.4, 0.65, 0.75, 0.8, 0.75, 0.65, 0.5, 0.3, 0.15, 0.0]) return DetectorSpectralResponse( detector_type=DetectorType.INGAAS_EXTENDED, wavelengths=wl, response=resp, peak_wavelength=1600, cutoff_wavelength=2500, short_cutoff=850, peak_qe=0.80 ) def _create_pbs_response() -> DetectorSpectralResponse: """Create PbS detector spectral response.""" wl = np.array([1000, 1200, 1500, 1800, 2000, 2200, 2500, 2800, 3000, 3200]) resp = np.array([0.1, 0.3, 0.55, 0.7, 0.8, 0.85, 0.75, 0.5, 0.25, 0.0]) return DetectorSpectralResponse( detector_type=DetectorType.PBS, wavelengths=wl, response=resp, peak_wavelength=2200, cutoff_wavelength=3000, short_cutoff=1000, peak_qe=0.85 ) def _create_pbse_response() -> DetectorSpectralResponse: """Create PbSe detector spectral response.""" wl = np.array([1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000, 5200]) resp = np.array([0.1, 0.35, 0.55, 0.7, 0.8, 0.75, 0.55, 0.25, 0.0]) return DetectorSpectralResponse( detector_type=DetectorType.PBSE, wavelengths=wl, response=resp, peak_wavelength=4000, cutoff_wavelength=5000, short_cutoff=1500, peak_qe=0.80 ) def _create_mems_response() -> DetectorSpectralResponse: """Create MEMS-based detector spectral response (typically InGaAs-based).""" wl = np.array([900, 1000, 1200, 1400, 1600, 1800, 2000, 2200, 2400, 2500]) resp = np.array([0.15, 0.35, 0.55, 0.7, 0.75, 0.7, 0.55, 0.35, 0.15, 0.0]) return DetectorSpectralResponse( detector_type=DetectorType.MEMS, wavelengths=wl, response=resp, peak_wavelength=1600, cutoff_wavelength=2400, short_cutoff=900, peak_qe=0.75 ) def _create_mct_response() -> DetectorSpectralResponse: """Create MCT (Mercury Cadmium Telluride) detector spectral response.""" wl = np.array([2000, 3000, 4000, 5000, 6000, 8000, 10000, 12000, 14000]) resp = np.array([0.1, 0.4, 0.65, 0.8, 0.85, 0.8, 0.65, 0.4, 0.0]) return DetectorSpectralResponse( detector_type=DetectorType.MCT, wavelengths=wl, response=resp, peak_wavelength=6000, cutoff_wavelength=12000, short_cutoff=2000, peak_qe=0.85 ) # Registry of detector spectral responses DETECTOR_RESPONSES: Dict[DetectorType, DetectorSpectralResponse] = {} def _register_detector_responses() -> None: """Register all detector spectral responses.""" global DETECTOR_RESPONSES DETECTOR_RESPONSES = { DetectorType.SI: _create_silicon_response(), DetectorType.INGAAS: _create_ingaas_response(), DetectorType.INGAAS_EXTENDED: _create_ingaas_extended_response(), DetectorType.PBS: _create_pbs_response(), DetectorType.PBSE: _create_pbse_response(), DetectorType.MEMS: _create_mems_response(), DetectorType.MCT: _create_mct_response(), } _register_detector_responses()
[docs] def get_detector_response(detector_type: DetectorType) -> DetectorSpectralResponse: """ Get spectral response curve for a detector type. Args: detector_type: Type of detector. Returns: DetectorSpectralResponse object. """ return DETECTOR_RESPONSES[detector_type]
# ============================================================================ # Noise Models # ============================================================================
[docs] @dataclass class NoiseModelConfig: """ Configuration for detector noise model. Attributes: shot_noise_enabled: Enable shot (photon) noise. thermal_noise_enabled: Enable thermal (Johnson) noise. read_noise_enabled: Enable readout noise. flicker_noise_enabled: Enable 1/f (flicker) noise. quantization_noise_enabled: Enable ADC quantization noise. shot_noise_factor: Scaling factor for shot noise. thermal_noise_factor: Scaling factor for thermal noise. read_noise_electrons: Read noise in electrons. flicker_corner_freq: 1/f noise corner frequency (Hz). adc_bits: ADC resolution in bits. full_scale: Full-scale signal level. """ shot_noise_enabled: bool = True thermal_noise_enabled: bool = True read_noise_enabled: bool = True flicker_noise_enabled: bool = False quantization_noise_enabled: bool = False shot_noise_factor: float = 1.0 thermal_noise_factor: float = 1.0 read_noise_electrons: float = 50.0 flicker_corner_freq: float = 100.0 # Hz adc_bits: int = 16 full_scale: float = 3.0 # AU
[docs] @dataclass class DetectorConfig: """ Complete detector configuration. Attributes: detector_type: Type of detector. temperature_k: Operating temperature in Kelvin. integration_time_ms: Integration time in milliseconds. gain: Amplifier gain. noise_model: Noise model configuration. apply_response_curve: Whether to apply spectral response. apply_nonlinearity: Whether to apply detector nonlinearity. nonlinearity_coefficient: Quadratic nonlinearity coefficient. """ detector_type: DetectorType = DetectorType.INGAAS temperature_k: float = 293.0 # 20°C room temperature integration_time_ms: float = 100.0 gain: float = 1.0 noise_model: NoiseModelConfig = field(default_factory=NoiseModelConfig) apply_response_curve: bool = True apply_nonlinearity: bool = False nonlinearity_coefficient: float = 0.02 # Quadratic term
# Detector-specific default noise parameters DETECTOR_NOISE_DEFAULTS = { DetectorType.SI: { "shot_noise_factor": 0.8, "thermal_noise_factor": 0.5, "read_noise_electrons": 30.0, "flicker_noise_enabled": False, }, DetectorType.INGAAS: { "shot_noise_factor": 1.0, "thermal_noise_factor": 0.8, "read_noise_electrons": 50.0, "flicker_noise_enabled": False, }, DetectorType.INGAAS_EXTENDED: { "shot_noise_factor": 1.2, "thermal_noise_factor": 1.2, "read_noise_electrons": 80.0, "flicker_noise_enabled": False, }, DetectorType.PBS: { "shot_noise_factor": 1.5, "thermal_noise_factor": 1.8, "read_noise_electrons": 150.0, "flicker_noise_enabled": True, # PbS has significant 1/f noise }, DetectorType.PBSE: { "shot_noise_factor": 1.4, "thermal_noise_factor": 1.5, "read_noise_electrons": 120.0, "flicker_noise_enabled": True, }, DetectorType.MEMS: { "shot_noise_factor": 1.5, "thermal_noise_factor": 1.0, "read_noise_electrons": 100.0, "flicker_noise_enabled": False, }, DetectorType.MCT: { "shot_noise_factor": 0.7, # Cooled, low noise "thermal_noise_factor": 0.3, "read_noise_electrons": 20.0, "flicker_noise_enabled": False, }, }
[docs] def get_default_noise_config(detector_type: DetectorType) -> NoiseModelConfig: """ Get default noise model configuration for a detector type. Args: detector_type: Type of detector. Returns: NoiseModelConfig with appropriate defaults. """ defaults = DETECTOR_NOISE_DEFAULTS.get(detector_type, {}) return NoiseModelConfig(**defaults)
# ============================================================================ # Detector Simulator # ============================================================================
[docs] class DetectorSimulator: """ Simulate detector effects on NIR spectra. Applies detector spectral response, noise models, and nonlinearity to synthetic spectra. Attributes: config: Detector configuration. rng: Random number generator. Example: >>> config = DetectorConfig(detector_type=DetectorType.INGAAS) >>> simulator = DetectorSimulator(config, random_state=42) >>> spectra_out = simulator.apply(spectra, wavelengths) """ def __init__( self, config: Optional[DetectorConfig] = None, random_state: Optional[int] = None ) -> None: """ Initialize the detector simulator. Args: config: Detector configuration. If None, uses defaults. random_state: Random seed for reproducibility. """ if config is None: config = DetectorConfig() self.config = config self.rng = np.random.default_rng(random_state) # Get spectral response curve self.response = get_detector_response(config.detector_type)
[docs] def apply( self, spectra: np.ndarray, wavelengths: np.ndarray, base_signal_level: float = 1.0 ) -> np.ndarray: """ Apply detector effects to spectra. Args: spectra: Input spectra (n_samples, n_wavelengths). wavelengths: Wavelength array (nm). base_signal_level: Reference signal level for noise scaling. Returns: Spectra with detector effects applied. """ result = spectra.copy() # 1. Apply spectral response (wavelength-dependent sensitivity) if self.config.apply_response_curve: result = self._apply_spectral_response(result, wavelengths) # 2. Apply gain result = result * self.config.gain # 3. Apply detector nonlinearity if self.config.apply_nonlinearity: result = self._apply_nonlinearity(result) # 4. Apply noise model result = self._apply_noise(result, wavelengths, base_signal_level) return result
def _apply_spectral_response( self, spectra: np.ndarray, wavelengths: np.ndarray ) -> np.ndarray: """Apply detector spectral response curve.""" response = self.response.get_response_at(wavelengths) # Avoid amplifying noise where response is very low response = np.maximum(response, 0.01) # Response affects signal (higher response = more signal collected) # but spectroscopically we see absorbance, which is inverse # For realism, we add noise proportional to 1/response noise_scaling = 1.0 / response noise_scaling = np.minimum(noise_scaling, 10.0) # Cap scaling # Store for use in noise application self._response_noise_scaling = noise_scaling return spectra def _apply_nonlinearity(self, spectra: np.ndarray) -> np.ndarray: """ Apply detector nonlinearity. Models: signal_measured = signal_true * (1 + a * signal_true) where a is the nonlinearity coefficient. """ coef = self.config.nonlinearity_coefficient # Quadratic nonlinearity result = spectra * (1 + coef * spectra) return result def _apply_noise( self, spectra: np.ndarray, wavelengths: np.ndarray, base_signal_level: float ) -> np.ndarray: """Apply noise model to spectra.""" n_samples, n_wl = spectra.shape noise_config = self.config.noise_model result = spectra.copy() # Get wavelength-dependent noise scaling from response if hasattr(self, '_response_noise_scaling'): wl_noise_scale = self._response_noise_scaling else: wl_noise_scale = np.ones(n_wl) # Noise level based on integration time # Longer integration = lower noise (sqrt relationship) time_factor = np.sqrt(100.0 / self.config.integration_time_ms) # Base noise level base_noise = 0.001 * time_factor # ~0.001 AU typical for i in range(n_samples): total_noise = np.zeros(n_wl) # Shot noise (signal-dependent, Poisson statistics) if noise_config.shot_noise_enabled: shot_sigma = ( noise_config.shot_noise_factor * base_noise * np.sqrt(np.abs(spectra[i]) + 0.01) ) total_noise += self.rng.normal(0, shot_sigma) # Thermal noise (constant, wavelength-independent) if noise_config.thermal_noise_enabled: thermal_sigma = ( noise_config.thermal_noise_factor * base_noise * self._temperature_factor() ) total_noise += self.rng.normal(0, thermal_sigma, n_wl) # Read noise (constant per pixel) if noise_config.read_noise_enabled: # Convert electrons to AU (rough conversion) read_au = noise_config.read_noise_electrons * 1e-5 total_noise += self.rng.normal(0, read_au, n_wl) # 1/f noise (correlated, low frequency) if noise_config.flicker_noise_enabled: flicker = self._generate_flicker_noise( n_wl, base_noise * 0.5 ) total_noise += flicker # Apply wavelength-dependent scaling total_noise = total_noise * wl_noise_scale result[i] = result[i] + total_noise # Quantization noise if noise_config.quantization_noise_enabled: result = self._apply_quantization(result, noise_config) return result def _temperature_factor(self) -> float: """ Calculate temperature-dependent noise factor. Thermal noise increases with temperature. """ reference_temp = 293.0 # 20°C actual_temp = self.config.temperature_k # Thermal noise proportional to sqrt(T) return np.sqrt(actual_temp / reference_temp) def _generate_flicker_noise( self, n_points: int, amplitude: float ) -> np.ndarray: """ Generate 1/f (flicker) noise. Flicker noise has power spectral density proportional to 1/f. """ # Generate white noise white = self.rng.normal(0, 1, n_points) # Create 1/f filter freqs = np.fft.fftfreq(n_points) freqs[0] = 1e-10 # Avoid division by zero fft_filter = 1.0 / np.sqrt(np.abs(freqs)) fft_filter[0] = 0 # Remove DC # Apply filter in frequency domain fft_white = np.fft.fft(white) fft_pink = fft_white * fft_filter pink = np.real(np.fft.ifft(fft_pink)) # Normalize and scale pink = pink / np.std(pink) * amplitude return pink def _apply_quantization( self, spectra: np.ndarray, config: NoiseModelConfig ) -> np.ndarray: """Apply ADC quantization effects.""" # Calculate step size n_levels = 2 ** config.adc_bits step = config.full_scale / n_levels # Quantize quantized = np.round(spectra / step) * step # Add small uniform noise (quantization noise approximation) q_noise = self.rng.uniform(-step / 2, step / 2, spectra.shape) return quantized + q_noise
# ============================================================================ # Convenience Functions # ============================================================================
[docs] def simulate_detector_effects( spectra: np.ndarray, wavelengths: np.ndarray, detector_type: DetectorType = DetectorType.INGAAS, include_response: bool = True, include_noise: bool = True, random_state: Optional[int] = None ) -> np.ndarray: """ Apply detector effects to spectra with simple API. Args: spectra: Input spectra (n_samples, n_wavelengths). wavelengths: Wavelength array (nm). detector_type: Type of detector to simulate. include_response: Whether to apply spectral response. include_noise: Whether to apply noise. random_state: Random seed. Returns: Spectra with detector effects applied. Example: >>> spectra_out = simulate_detector_effects( ... spectra, wavelengths, ... detector_type=DetectorType.PBS ... ) """ # Create noise config noise_config = get_default_noise_config(detector_type) if not include_noise: noise_config.shot_noise_enabled = False noise_config.thermal_noise_enabled = False noise_config.read_noise_enabled = False noise_config.flicker_noise_enabled = False config = DetectorConfig( detector_type=detector_type, noise_model=noise_config, apply_response_curve=include_response ) simulator = DetectorSimulator(config, random_state) return simulator.apply(spectra, wavelengths)
[docs] def get_detector_wavelength_range(detector_type: DetectorType) -> Tuple[float, float]: """ Get the effective wavelength range for a detector type. Args: detector_type: Type of detector. Returns: Tuple of (min_wavelength, max_wavelength) in nm. """ response = get_detector_response(detector_type) return (response.short_cutoff, response.cutoff_wavelength)
[docs] def list_detector_types() -> List[str]: """ List available detector types. Returns: List of detector type names. """ return [dt.value for dt in DetectorType]
# ============================================================================ # Module-level exports # ============================================================================ __all__ = [ # Data classes "DetectorSpectralResponse", "NoiseModelConfig", "DetectorConfig", # Registry "DETECTOR_RESPONSES", "DETECTOR_NOISE_DEFAULTS", "get_detector_response", "get_default_noise_config", # Simulator "DetectorSimulator", # Convenience functions "simulate_detector_effects", "get_detector_wavelength_range", "list_detector_types", ]