Files
radiacode/detect/radiacode_monitor.py
Jacquin Antoine 1e0c1a5ea5 Dashboard web FastAPI + Chart.js
- 4 vues : spectre temps reel, historique detections, background, timeline CPS
- API REST : /api/status, /api/spectrum/current, /api/spectrum/difference,
  /api/background, /api/background/spectrum, /api/history, /api/cps/timeline
- Frontend vanilla JS + Chart.js (pas de Node.js, leger pour Pi 4)
- Moniteur modifie pour exporter son etat dans /data/monitor_state.json
  et le CPS dans /data/cps_log.jsonl chaque cycle
- Nouveau conteneur Docker 'web' sur port 8080
- Theme sombre, calibration energie (E = 0.33 + 2.97 * canal)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-19 13:33:07 +02:00

309 lines
11 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"))
# 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.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)
# 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}")
# 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."""
try:
from radiacode import RadiaCode
device = RadiaCode()
log.info("Radiacode connecté")
self.connected = True
return device
except Exception as e:
log.debug(f"Détecteur non disponible : {e}")
self.connected = False
return None
def sample_once(self):
"""Échantillonne une fois. Retourne True si succès."""
device = None
try:
device = self.try_connect()
if device is None:
return False
spectrum = device.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()
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 : {e}")
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."""
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 / self.bg_live_time
net_rate = np.clip(rate - bg_rate, 0, None)
else:
net_rate = rate
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:
normalized = spectrum_rate / spectrum_rate.max()
else:
return []
tensor = torch.tensor(normalized, dtype=torch.float32).unsqueeze(0).to(self.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 / self.bg_live_time
net_rate = np.clip(rate - bg_rate, 0, None)
else:
net_rate = rate
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.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()