Pipeline complet Radiacode 103 - identification automatique d'isotopes
- VegaModel CNN-FCNN 34.5M params, 82 isotopes, val acc 99.89% - Generation 50k spectres synthetiques 1D (12-24h durees) - Entrainement 100 epochs sur RTX 5060 Ti (CUDA 12.8, Blackwell) - Detection continue avec soustraction du background - Capture background 24h avec gestion deconnexion - Docker Compose : conteneur train (GPU) + detect (CPU/USB) - Modele entraite inclus (vega_best.pt, 395 Mo) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
22
detect/Dockerfile
Normal file
22
detect/Dockerfile
Normal file
@ -0,0 +1,22 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libusb-1.0-0 \
|
||||
usbutils \
|
||||
build-essential \
|
||||
libglib2.0-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY radiacode_monitor.py .
|
||||
COPY capture_background.py .
|
||||
|
||||
CMD ["python", "radiacode_monitor.py"]
|
||||
88
detect/capture_background.py
Normal file
88
detect/capture_background.py
Normal file
@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Capture le bruit de fond du détecteur sur 24h (sans source).
|
||||
Gère le débranchement/rebranchement du détecteur.
|
||||
À lancer séparément avant le moniteur :
|
||||
docker-compose run --rm detect python capture_background.py
|
||||
"""
|
||||
import numpy as np
|
||||
import time
|
||||
import json
|
||||
import os
|
||||
|
||||
SAMPLE_INTERVAL = int(os.environ.get("SAMPLE_INTERVAL", "60"))
|
||||
TARGET_DURATION = int(os.environ.get("TARGET_DURATION", str(86400))) # 24h
|
||||
OUTPUT_PATH = os.environ.get("BACKGROUND_PATH", "/data/background_24h.npy")
|
||||
SNAPSHOT_PATH = os.environ.get("SNAPSHOT_PATH", "/data/background_snapshot.json")
|
||||
|
||||
BG_COUNTS = np.zeros(1024, dtype=np.float64)
|
||||
BG_LIVE_TIME = 0.0
|
||||
device = None
|
||||
|
||||
def save_snapshot():
|
||||
"""Save a human-readable snapshot of current background."""
|
||||
cps = BG_COUNTS.sum() / BG_LIVE_TIME if BG_LIVE_TIME > 0 else 0
|
||||
# Approximate energy calibration for RC-103: E ≈ 0.33 + 2.97*ch
|
||||
peaks = []
|
||||
max_c = BG_COUNTS.max()
|
||||
if max_c > 0:
|
||||
for i, c in enumerate(BG_COUNTS):
|
||||
if c > max_c * 0.03:
|
||||
energy = 0.33 + 2.97 * i
|
||||
peaks.append({"channel": i, "energy_kev": round(energy, 1), "counts": round(float(c), 1)})
|
||||
|
||||
snapshot = {
|
||||
"elapsed_hours": round((time.time() - start) / 3600, 2),
|
||||
"live_time_s": round(BG_LIVE_TIME, 1),
|
||||
"total_counts": round(float(BG_COUNTS.sum()), 0),
|
||||
"cps": round(cps, 2),
|
||||
"top_peaks": sorted(peaks, key=lambda x: -x["counts"])[:15],
|
||||
"spectrum": [round(float(c), 1) for c in BG_COUNTS],
|
||||
}
|
||||
with open(SNAPSHOT_PATH, "w") as f:
|
||||
json.dump(snapshot, f, indent=2)
|
||||
|
||||
print(f"Capture du bruit de fond pendant {TARGET_DURATION/3600:.0f}h...")
|
||||
print("Assurez-vous qu'aucune source radioactive n'est a proximite du detecteur.")
|
||||
print()
|
||||
|
||||
start = time.time()
|
||||
while (time.time() - start) < TARGET_DURATION:
|
||||
time.sleep(SAMPLE_INTERVAL)
|
||||
try:
|
||||
if device is None:
|
||||
from radiacode import RadiaCode
|
||||
|
||||
device = RadiaCode()
|
||||
device.spectrum_reset()
|
||||
print("Radiacode connecte.")
|
||||
|
||||
spectrum = device.spectrum()
|
||||
BG_COUNTS += np.array(spectrum.counts, dtype=np.float64)
|
||||
BG_LIVE_TIME += spectrum.duration.total_seconds()
|
||||
device.spectrum_reset()
|
||||
elapsed = time.time() - start
|
||||
cps = BG_COUNTS.sum() / BG_LIVE_TIME if BG_LIVE_TIME > 0 else 0
|
||||
print(
|
||||
f"Background : {elapsed/3600:.1f}h / {TARGET_DURATION/3600:.1f}h "
|
||||
f"({BG_LIVE_TIME:.0f}s live, {BG_COUNTS.sum():.0f} coups, {cps:.1f} CPS)",
|
||||
flush=True,
|
||||
)
|
||||
save_snapshot()
|
||||
except Exception as e:
|
||||
print(f"\nErreur : {e}, reconnexion...")
|
||||
device = None
|
||||
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH) if os.path.dirname(OUTPUT_PATH) else ".", exist_ok=True)
|
||||
np.save(
|
||||
OUTPUT_PATH,
|
||||
{
|
||||
"counts": BG_COUNTS,
|
||||
"duration": BG_LIVE_TIME,
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
)
|
||||
print(f"\n\nBackground sauvegarde : {OUTPUT_PATH}")
|
||||
print(f" Duree live : {BG_LIVE_TIME/3600:.1f}h")
|
||||
print(f" Total coups : {BG_COUNTS.sum():.0f}")
|
||||
print(f" CPS moyen : {BG_COUNTS.sum()/BG_LIVE_TIME:.1f}")
|
||||
248
detect/radiacode_monitor.py
Normal file
248
detect/radiacode_monitor.py
Normal file
@ -0,0 +1,248 @@
|
||||
#!/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"))
|
||||
|
||||
# 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
|
||||
|
||||
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é")
|
||||
return device
|
||||
except Exception as e:
|
||||
log.debug(f"Détecteur non disponible : {e}")
|
||||
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 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()
|
||||
|
||||
self.sample_once()
|
||||
time.sleep(SAMPLE_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
monitor = RadiacodeMonitor()
|
||||
monitor.run()
|
||||
3
detect/requirements.txt
Normal file
3
detect/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
radiacode>=0.3.5
|
||||
numpy>=1.24.0
|
||||
torch>=2.0.0
|
||||
Reference in New Issue
Block a user