Root cause of Am-241 misidentification: the Radiacode 103's CsI(Tl) crystal shifts low-energy peaks upward (59.5 keV → 71.6 keV for Am-241) due to non-proportional scintillation response. The model was trained on theoretical peak positions and couldn't match the shifted real peaks. Changes: - Add inverse CsI(Tl) non-linear correction to inference pipeline (radiacode_monitor.py, web/config.py, test_detection.py) E_apparent = E_true * (1 + 0.37 * exp(-E_true/100)) Corrects channel mapping so peaks appear at theoretical energies - Fix energy calibration: DetectorConfig now uses E = 0.33 + 2.97*ch with 1023 channels, matching the real detector (was energy_min=20, skip_first_channel=True, different channel width) - Add K-escape peaks for CsI(Tl) iodine X-ray escape (E - 28.5 keV) - Add asymmetric peak shapes for low-energy tails (< 200 keV) - Add log1p normalization in dataset and inference (replaces max-norm) - Add background-subtracted training mode (subtract_background flag) - Add low-signal augmentation (0.01-5 Bq activities, 30-300s durations) - Update docker-compose.yml: batch_size=32, duration=30-300s, CSI_NONLINEAR_ALPHA/BETA env vars for detect and web - Web dashboard: apply CsI correction to displayed spectra - Various UI fixes (Chart.js width, zoom/pan, isotope lines) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
119 lines
3.8 KiB
Python
119 lines
3.8 KiB
Python
"""
|
|
Detector Configuration Module
|
|
|
|
Contains configuration parameters for Radiacode gamma spectrometers
|
|
and other detector settings.
|
|
|
|
Energy calibration matches the real Radiacode 103:
|
|
E(keV) = 0.33 + 2.97 * channel_index
|
|
Uses 1023 channels (channel 1023 is overflow, excluded).
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Dict
|
|
import numpy as np
|
|
|
|
|
|
@dataclass
|
|
class DetectorConfig:
|
|
"""Configuration for a gamma spectrometer detector."""
|
|
|
|
name: str
|
|
# Energy calibration: E = calibration_offset + calibration_slope * channel
|
|
# Must match the real detector calibration used in inference.
|
|
calibration_offset_kev: float = 0.33
|
|
calibration_slope_kev: float = 2.97
|
|
|
|
# Number of usable channels (1023 for Radiacode, channel 1023 is overflow)
|
|
num_channels: int = 1023
|
|
|
|
# FWHM at 662 keV (Cs-137 reference) as fraction
|
|
fwhm_at_662: float = 0.084 # 8.4%
|
|
fwhm_uncertainty: float = 0.003 # ±0.3%
|
|
|
|
# Detector crystal type
|
|
crystal_type: str = "CsI(Tl)"
|
|
|
|
# Sensitivity: counts per second at 1 μSv/h for Cs-137
|
|
sensitivity_cps_per_usvh: float = 30.0
|
|
|
|
# Detector volume in cm³
|
|
detector_volume_cm3: float = 1.0
|
|
|
|
def get_energy_bins(self) -> np.ndarray:
|
|
"""Get array of energy bin centers (keV) matching the real detector calibration."""
|
|
channels = np.arange(self.num_channels, dtype=np.float64)
|
|
return self.calibration_offset_kev + self.calibration_slope_kev * channels
|
|
|
|
def get_fwhm_at_energy(self, energy_kev: float) -> float:
|
|
"""
|
|
Calculate FWHM at a given energy.
|
|
|
|
For scintillators, FWHM scales approximately as sqrt(E).
|
|
FWHM(E) = FWHM_662 * sqrt(E/662)
|
|
"""
|
|
return self.fwhm_at_662 * np.sqrt(energy_kev / 662.0) * 662.0
|
|
|
|
def get_sigma_at_energy(self, energy_kev: float) -> float:
|
|
"""Get Gaussian sigma at a given energy."""
|
|
fwhm = self.get_fwhm_at_energy(energy_kev)
|
|
return fwhm / 2.355
|
|
|
|
def energy_to_channel(self, energy_kev: float) -> int:
|
|
"""Convert energy in keV to channel index."""
|
|
channel = int((energy_kev - self.calibration_offset_kev) / self.calibration_slope_kev)
|
|
return max(0, min(self.num_channels - 1, channel))
|
|
|
|
def channel_to_energy(self, channel: int) -> float:
|
|
"""Convert channel index to energy in keV."""
|
|
return self.calibration_offset_kev + self.calibration_slope_kev * channel
|
|
|
|
|
|
# Pre-defined configurations for Radiacode devices
|
|
RADIACODE_CONFIGS: Dict[str, DetectorConfig] = {
|
|
"radiacode_101": DetectorConfig(
|
|
name="Radiacode 101",
|
|
fwhm_at_662=0.095, # 9.5%
|
|
fwhm_uncertainty=0.004,
|
|
crystal_type="CsI(Tl)",
|
|
sensitivity_cps_per_usvh=30.0,
|
|
detector_volume_cm3=1.0,
|
|
),
|
|
"radiacode_102": DetectorConfig(
|
|
name="Radiacode 102",
|
|
fwhm_at_662=0.095, # 9.5%
|
|
fwhm_uncertainty=0.004,
|
|
crystal_type="CsI(Tl)",
|
|
sensitivity_cps_per_usvh=30.0,
|
|
detector_volume_cm3=1.0,
|
|
),
|
|
"radiacode_103": DetectorConfig(
|
|
name="Radiacode 103",
|
|
fwhm_at_662=0.084, # 8.4%
|
|
fwhm_uncertainty=0.003,
|
|
crystal_type="CsI(Tl)",
|
|
sensitivity_cps_per_usvh=30.0,
|
|
detector_volume_cm3=1.0,
|
|
),
|
|
"radiacode_103g": DetectorConfig(
|
|
name="Radiacode 103G",
|
|
fwhm_at_662=0.074, # 7.4% (GAGG crystal)
|
|
fwhm_uncertainty=0.003,
|
|
crystal_type="GAGG(Ce)",
|
|
sensitivity_cps_per_usvh=40.0,
|
|
detector_volume_cm3=1.0,
|
|
),
|
|
"radiacode_110": DetectorConfig(
|
|
name="Radiacode 110",
|
|
fwhm_at_662=0.084, # 8.4%
|
|
fwhm_uncertainty=0.003,
|
|
crystal_type="CsI(Tl)",
|
|
sensitivity_cps_per_usvh=77.0,
|
|
detector_volume_cm3=2.5,
|
|
),
|
|
}
|
|
|
|
|
|
def get_default_config() -> DetectorConfig:
|
|
"""Get the default detector configuration (Radiacode 103)."""
|
|
return RADIACODE_CONFIGS["radiacode_103"] |