Fix: CsI(Tl) non-linear response correction + detector calibration overhaul
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>
This commit is contained in:
@ -30,6 +30,56 @@ CPS_LOG_PATH = os.environ.get("CPS_LOG_PATH", "/data/cps_log.jsonl")
|
||||
ENERGY_OFFSET = float(os.environ.get("ENERGY_CALIBRATION_OFFSET", "0.33"))
|
||||
ENERGY_SLOPE = float(os.environ.get("ENERGY_CALIBRATION_SLOPE", "2.97"))
|
||||
|
||||
# CsI(Tl) non-linear response correction
|
||||
# CsI(Tl) produces more light per keV at low energies, shifting peaks to higher
|
||||
# apparent energies. Model: E_apparent = E_true * (1 + alpha * exp(-E_true/beta))
|
||||
# Calibrated from Am-241 (59.5 keV appears at ~71.6 keV) and K-40 (correct at 1460.8 keV).
|
||||
CSI_NONLINEAR_ALPHA = float(os.environ.get("CSI_NONLINEAR_ALPHA", "0.37"))
|
||||
CSI_NONLINEAR_BETA = float(os.environ.get("CSI_NONLINEAR_BETA", "100.0"))
|
||||
|
||||
|
||||
def correct_csilinear_energy(spectrum_rate, num_channels=1023):
|
||||
"""Apply inverse CsI(Tl) non-linear response correction to spectrum channels.
|
||||
|
||||
CsI(Tl) has non-proportional scintillation response at low energies,
|
||||
causing peaks to appear at higher channels than their true energy position.
|
||||
This function remaps channels so that peaks appear at their theoretical
|
||||
energy positions, matching what the model was trained on.
|
||||
|
||||
For each output channel j (true energy position), we find the input
|
||||
channel i (apparent energy position) where the detector actually placed
|
||||
counts for that true energy.
|
||||
|
||||
Args:
|
||||
spectrum_rate: Array of 1023 channel count rates
|
||||
num_channels: Number of channels
|
||||
|
||||
Returns:
|
||||
Corrected spectrum with peaks at theoretical energy positions
|
||||
"""
|
||||
alpha = CSI_NONLINEAR_ALPHA
|
||||
beta = CSI_NONLINEAR_BETA
|
||||
|
||||
# For each output channel j, compute the apparent energy where
|
||||
# counts for true energy E_true(j) actually appear
|
||||
output_channels = np.arange(num_channels, dtype=np.float64)
|
||||
e_true = ENERGY_OFFSET + ENERGY_SLOPE * output_channels
|
||||
|
||||
# Forward model: E_apparent = E_true * (1 + alpha * exp(-E_true / beta))
|
||||
e_apparent = e_true * (1 + alpha * np.exp(-e_true / beta))
|
||||
|
||||
# Input channel where the detector placed counts for this true energy
|
||||
source_channels = (e_apparent - ENERGY_OFFSET) / ENERGY_SLOPE
|
||||
source_channels = np.clip(source_channels, 0, num_channels - 1.001)
|
||||
|
||||
# Linear interpolation from source channels
|
||||
lower = np.floor(source_channels).astype(int)
|
||||
upper = np.minimum(lower + 1, num_channels - 1)
|
||||
frac = source_channels - lower
|
||||
|
||||
corrected = spectrum_rate[lower] * (1 - frac) + spectrum_rate[upper] * frac
|
||||
return corrected
|
||||
|
||||
# Logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@ -46,10 +96,10 @@ class RadiacodeMonitor:
|
||||
def __init__(self):
|
||||
# Charger le modèle PyTorch
|
||||
device_str = os.environ.get("VEGA_DEVICE", "cpu")
|
||||
self.device = torch.device(device_str)
|
||||
self.torch_device = torch.device(device_str)
|
||||
|
||||
log.info(f"Chargement du modèle depuis {MODEL_PATH} sur {self.device}...")
|
||||
checkpoint = torch.load(MODEL_PATH, map_location=self.device, weights_only=False)
|
||||
log.info(f"Chargement du modèle depuis {MODEL_PATH} sur {self.torch_device}...")
|
||||
checkpoint = torch.load(MODEL_PATH, map_location=self.torch_device, weights_only=False)
|
||||
|
||||
# Importer VegaModel (depuis le volume monté)
|
||||
vega_ml_path = os.environ.get("VEGA_ML_PATH", "/models/vega_ml")
|
||||
@ -86,42 +136,62 @@ class RadiacodeMonitor:
|
||||
else:
|
||||
log.warning(f"Pas de fichier background : {BACKGROUND_PATH}")
|
||||
|
||||
# Connexion persistante au Radiacode
|
||||
self._rc = None
|
||||
self.reconnect_backoff = 0
|
||||
|
||||
# Compteurs cumulés
|
||||
self.cumulated_counts = np.zeros(1024, dtype=np.float64)
|
||||
self.cumulated_live_time = 0.0
|
||||
self.last_report_date = None
|
||||
self.connected = False
|
||||
|
||||
def try_connect(self):
|
||||
"""Tente de se connecter au Radiacode. Retourne le device ou None."""
|
||||
def _connect(self):
|
||||
"""Tente d'établir une connexion persistante au Radiacode."""
|
||||
try:
|
||||
from radiacode import RadiaCode
|
||||
|
||||
device = RadiaCode()
|
||||
log.info("Radiacode connecté")
|
||||
self._rc = RadiaCode()
|
||||
self.connected = True
|
||||
return device
|
||||
self.reconnect_backoff = 0
|
||||
log.info("Radiacode connecté")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.debug(f"Détecteur non disponible : {e}")
|
||||
self._rc = None
|
||||
self.connected = False
|
||||
return None
|
||||
self.reconnect_backoff = min(self.reconnect_backoff + 1, 10)
|
||||
log.debug(f"Détecteur non disponible (retry dans {self.reconnect_backoff} cycles) : {e}")
|
||||
return False
|
||||
|
||||
def _disconnect(self):
|
||||
"""Ferme la connexion au Radiacode."""
|
||||
if self._rc is not None:
|
||||
try:
|
||||
del self._rc
|
||||
except Exception:
|
||||
pass
|
||||
self._rc = None
|
||||
self.connected = False
|
||||
|
||||
def sample_once(self):
|
||||
"""Échantillonne une fois. Retourne True si succès."""
|
||||
device = None
|
||||
try:
|
||||
device = self.try_connect()
|
||||
if device is None:
|
||||
# Établir la connexion si nécessaire
|
||||
if self._rc is None:
|
||||
if self.reconnect_backoff > 0:
|
||||
self.reconnect_backoff -= 1
|
||||
return False
|
||||
if not self._connect():
|
||||
return False
|
||||
|
||||
spectrum = device.spectrum()
|
||||
try:
|
||||
spectrum = self._rc.spectrum()
|
||||
counts = np.array(spectrum.counts, dtype=np.float64)
|
||||
live_time = spectrum.duration.total_seconds()
|
||||
|
||||
if live_time > 0 and counts.sum() > 0:
|
||||
self.cumulated_counts += counts
|
||||
self.cumulated_live_time += live_time
|
||||
device.spectrum_reset()
|
||||
self._rc.spectrum_reset()
|
||||
log.info(
|
||||
f"Échantillon : {counts.sum():.0f} coups en {live_time:.1f}s "
|
||||
f"(cumul : {self.cumulated_live_time/3600:.1f}h)"
|
||||
@ -129,14 +199,9 @@ class RadiacodeMonitor:
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
log.warning(f"Erreur échantillonnage : {e}")
|
||||
log.warning(f"Erreur échantillonnage, reconnexion : {e}")
|
||||
self._disconnect()
|
||||
return False
|
||||
finally:
|
||||
if device:
|
||||
try:
|
||||
del device
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def save_state(self):
|
||||
"""Ecrit l'etat actuel du moniteur dans un fichier JSON atomique."""
|
||||
@ -147,10 +212,10 @@ class RadiacodeMonitor:
|
||||
if self.cumulated_live_time > 0:
|
||||
rate = self.cumulated_counts / self.cumulated_live_time
|
||||
if self.bg_counts is not None and self.bg_live_time is not None:
|
||||
bg_rate = self.bg_counts / self.bg_live_time
|
||||
net_rate = np.clip(rate - bg_rate, 0, None)
|
||||
bg_rate = self.bg_counts[:1023] / self.bg_live_time
|
||||
net_rate = np.clip(rate[:1023] - bg_rate, 0, None)
|
||||
else:
|
||||
net_rate = rate
|
||||
net_rate = rate[:1023]
|
||||
isotopes = self.run_inference(net_rate)
|
||||
|
||||
state = {
|
||||
@ -190,11 +255,15 @@ class RadiacodeMonitor:
|
||||
def run_inference(self, spectrum_rate):
|
||||
"""Exécute l'inférence PyTorch sur le spectre cumulé."""
|
||||
if spectrum_rate.max() > 0:
|
||||
normalized = spectrum_rate / spectrum_rate.max()
|
||||
# Apply CsI(Tl) non-linear correction so peaks appear
|
||||
# at theoretical energy positions (matching training data)
|
||||
corrected = correct_csilinear_energy(spectrum_rate)
|
||||
log_spectrum = np.log1p(np.maximum(corrected, 0))
|
||||
normalized = log_spectrum / log_spectrum.max()
|
||||
else:
|
||||
return []
|
||||
|
||||
tensor = torch.tensor(normalized, dtype=torch.float32).unsqueeze(0).to(self.device)
|
||||
tensor = torch.tensor(normalized, dtype=torch.float32).unsqueeze(0).to(self.torch_device)
|
||||
|
||||
with torch.no_grad():
|
||||
logits, activities = self.model(tensor)
|
||||
@ -227,10 +296,10 @@ class RadiacodeMonitor:
|
||||
rate = self.cumulated_counts / self.cumulated_live_time
|
||||
|
||||
if self.bg_counts is not None and self.bg_live_time is not None:
|
||||
bg_rate = self.bg_counts / self.bg_live_time
|
||||
net_rate = np.clip(rate - bg_rate, 0, None)
|
||||
bg_rate = self.bg_counts[:1023] / self.bg_live_time
|
||||
net_rate = np.clip(rate[:1023] - bg_rate, 0, None)
|
||||
else:
|
||||
net_rate = rate
|
||||
net_rate = rate[:1023]
|
||||
|
||||
results = self.run_inference(net_rate)
|
||||
|
||||
@ -280,7 +349,7 @@ class RadiacodeMonitor:
|
||||
log.info("Radiacode 103 — Moniteur d'isotopes")
|
||||
log.info("=" * 50)
|
||||
log.info(f"Modèle : {MODEL_PATH}")
|
||||
log.info(f"Device : {self.device}")
|
||||
log.info(f"Device : {self.torch_device}")
|
||||
log.info(f"Isotopes : {self.isotope_index.num_isotopes}")
|
||||
log.info(
|
||||
f"Background : {'chargé' if self.bg_counts is not None else 'non disponible'}"
|
||||
|
||||
Reference in New Issue
Block a user