Files
radiacode/detect/radiacode_monitor.py
Jacquin Antoine 0847a3fc80 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>
2026-05-21 17:35:22 +02:00

378 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Radiacode 103 — Identification automatique d'isotopes
Cycle de 24h avec détection branché/débranché
Fonctionne en Docker sur machine GPU (dev) ou RPi 4 (production)
"""
import numpy as np
import torch
import time
import json
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
# Configuration via variables d'environnement
MODEL_PATH = os.environ.get("MODEL_PATH", "/models/vega_best.pt")
ISOTOPE_INDEX_PATH = os.environ.get("ISOTOPE_INDEX_PATH", "/models/vega_isotope_index.txt")
BACKGROUND_PATH = os.environ.get("BACKGROUND_PATH", "/data/background_24h.npy")
LOG_DIR = Path(os.environ.get("LOG_DIR", "/logs"))
LOG_DIR.mkdir(parents=True, exist_ok=True)
THRESHOLD = float(os.environ.get("THRESHOLD", "0.5"))
SAMPLE_INTERVAL = int(os.environ.get("SAMPLE_INTERVAL", "60"))
REPORT_HOUR = int(os.environ.get("REPORT_HOUR", "0"))
MIN_LIVE_TIME = int(os.environ.get("MIN_LIVE_TIME", "3600"))
STATE_PATH = os.environ.get("STATE_PATH", "/data/monitor_state.json")
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,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler(LOG_DIR / "radiacode.log"),
],
)
log = logging.getLogger(__name__)
class RadiacodeMonitor:
def __init__(self):
# Charger le modèle PyTorch
device_str = os.environ.get("VEGA_DEVICE", "cpu")
self.torch_device = torch.device(device_str)
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")
if vega_ml_path not in sys.path:
sys.path.insert(0, vega_ml_path)
from training.vega.model import VegaModel, VegaConfig
from training.vega.isotope_index import IsotopeIndex
self.model_config = VegaConfig(**checkpoint["model_config"])
self.model = VegaModel(self.model_config)
self.model.load_state_dict(checkpoint["model_state_dict"])
self.model.eval()
log.info(
f"Modèle chargé : {self.model_config.num_isotopes} isotopes, "
f"{self.model.count_parameters():,} paramètres"
)
# Charger l'index des isotopes
self.isotope_index = IsotopeIndex.load(Path(ISOTOPE_INDEX_PATH))
# Charger le bruit de fond de référence
self.bg_counts = None
self.bg_live_time = None
bg_path = Path(BACKGROUND_PATH)
if bg_path.exists():
bg_data = np.load(str(bg_path), allow_pickle=True).item()
self.bg_counts = bg_data["counts"].astype(np.float64)
self.bg_live_time = float(bg_data["duration"])
log.info(
f"Background chargé : {self.bg_live_time/3600:.1f}h, "
f"{self.bg_counts.sum():.0f} coups"
)
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 _connect(self):
"""Tente d'établir une connexion persistante au Radiacode."""
try:
from radiacode import RadiaCode
self._rc = RadiaCode()
self.connected = True
self.reconnect_backoff = 0
log.info("Radiacode connecté")
return True
except Exception as e:
self._rc = None
self.connected = False
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."""
# É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
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
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)"
)
return True
return False
except Exception as e:
log.warning(f"Erreur échantillonnage, reconnexion : {e}")
self._disconnect()
return False
def save_state(self):
"""Ecrit l'etat actuel du moniteur dans un fichier JSON atomique."""
energy_kev = [round(ENERGY_OFFSET + ENERGY_SLOPE * i, 2) for i in range(1024)]
cps = float(self.cumulated_counts.sum() / self.cumulated_live_time) if self.cumulated_live_time > 0 else 0.0
isotopes = []
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[:1023] / self.bg_live_time
net_rate = np.clip(rate[:1023] - bg_rate, 0, None)
else:
net_rate = rate[:1023]
isotopes = self.run_inference(net_rate)
state = {
"timestamp": datetime.now().isoformat(),
"connected": self.connected,
"cumulated_live_time_s": round(self.cumulated_live_time, 1),
"cumulated_live_time_h": round(self.cumulated_live_time / 3600, 2),
"total_counts": int(self.cumulated_counts.sum()),
"cps": round(cps, 2),
"background_subtracted": self.bg_counts is not None,
"isotopes_detected": isotopes,
"energy_kev": energy_kev,
"counts": [round(float(c), 1) for c in self.cumulated_counts],
}
state_path = Path(STATE_PATH)
state_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = state_path.with_suffix(".tmp")
with open(tmp_path, "w") as f:
json.dump(state, f)
os.replace(tmp_path, state_path)
def log_cps(self, counts, live_time):
"""Ajoute un point CPS au journal horodaté."""
cps = float(counts.sum() / live_time) if live_time > 0 else 0.0
entry = {
"ts": datetime.now().isoformat(),
"cps": round(cps, 2),
"live_time_s": round(live_time, 1),
"total_counts": int(counts.sum()),
}
log_path = Path(CPS_LOG_PATH)
log_path.parent.mkdir(parents=True, exist_ok=True)
with open(log_path, "a") as f:
f.write(json.dumps(entry) + "\n")
def run_inference(self, spectrum_rate):
"""Exécute l'inférence PyTorch sur le spectre cumulé."""
if spectrum_rate.max() > 0:
# 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.torch_device)
with torch.no_grad():
logits, activities = self.model(tensor)
probs = torch.sigmoid(logits).cpu().numpy()[0]
activities = activities.cpu().numpy()[0] * self.model_config.max_activity_bq
results = []
for i in range(len(probs)):
if probs[i] >= THRESHOLD:
results.append(
{
"isotope": self.isotope_index.index_to_name(i),
"probability": float(probs[i]),
"activity_bq": float(activities[i]),
}
)
return sorted(results, key=lambda x: -x["probability"])
def generate_report(self):
"""Génère le rapport quotidien."""
if self.cumulated_live_time < MIN_LIVE_TIME:
log.warning(
f"Pas assez de données ({self.cumulated_live_time/3600:.1f}h < "
f"{MIN_LIVE_TIME/3600:.1f}h minimum). Pas de rapport."
)
return
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[:1023] / self.bg_live_time
net_rate = np.clip(rate[:1023] - bg_rate, 0, None)
else:
net_rate = rate[:1023]
results = self.run_inference(net_rate)
now = datetime.now()
report = {
"date": now.isoformat(),
"live_time_hours": self.cumulated_live_time / 3600,
"total_counts": int(self.cumulated_counts.sum()),
"cps_mean": float(self.cumulated_counts.sum() / self.cumulated_live_time),
"background_subtracted": self.bg_counts is not None,
"isotopes_detected": results,
}
report_path = LOG_DIR / f"report_{now.strftime('%Y-%m-%d')}.json"
with open(report_path, "w") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
# Affichage
print(f"\n{'='*50}")
print(f" RAPPORT — {now.strftime('%d/%m/%Y')}")
print(f"{'='*50}")
print(f" Live time : {self.cumulated_live_time/3600:.1f}h")
print(f" Comptages : {self.cumulated_counts.sum():.0f}")
print(f" CPS moyen : {self.cumulated_counts.sum()/self.cumulated_live_time:.1f}")
print(
f" Background : {'soustrait' if self.bg_counts is not None else 'non soustrait'}"
)
print()
if results:
for r in results:
print(
f" {r['isotope']:>10s} : {r['probability']*100:5.1f}% — {r['activity_bq']:.1f} Bq"
)
else:
print(" (background uniquement)")
print(f"{'='*50}\n")
log.info(f"Rapport sauvegardé : {report_path}")
# Reset pour le cycle suivant
self.cumulated_counts = np.zeros(1024, dtype=np.float64)
self.cumulated_live_time = 0.0
def run(self):
"""Boucle principale."""
log.info("=" * 50)
log.info("Radiacode 103 — Moniteur d'isotopes")
log.info("=" * 50)
log.info(f"Modèle : {MODEL_PATH}")
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'}"
)
log.info(f"Seuil : {THRESHOLD}")
log.info(f"Intervalle : {SAMPLE_INTERVAL}s")
while True:
now = datetime.now()
if self.last_report_date != now.date() and now.hour == REPORT_HOUR:
self.generate_report()
self.last_report_date = now.date()
success = self.sample_once()
if success:
self.save_state()
self.log_cps(self.cumulated_counts, self.cumulated_live_time)
else:
self.save_state()
time.sleep(SAMPLE_INTERVAL)
if __name__ == "__main__":
monitor = RadiacodeMonitor()
monitor.run()