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>
This commit is contained in:
Jacquin Antoine
2026-05-19 13:33:07 +02:00
parent 27ef0727e8
commit 1e0c1a5ea5
22 changed files with 1031 additions and 2 deletions

View File

@ -25,6 +25,10 @@ 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(
@ -86,6 +90,7 @@ class RadiacodeMonitor:
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."""
@ -94,9 +99,11 @@ class RadiacodeMonitor:
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):
@ -131,6 +138,55 @@ class RadiacodeMonitor:
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:
@ -239,7 +295,12 @@ class RadiacodeMonitor:
self.generate_report()
self.last_report_date = now.date()
self.sample_once()
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)