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:
Jacquin Antoine
2026-05-21 17:35:22 +02:00
parent 3b4446b181
commit 0847a3fc80
21 changed files with 913 additions and 278 deletions

View File

@ -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'}"