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

0
web/app/__init__.py Normal file
View File

18
web/app/config.py Normal file
View File

@ -0,0 +1,18 @@
import os
from pathlib import Path
STATE_PATH = Path(os.environ.get("STATE_PATH", "/data/monitor_state.json"))
CPS_LOG_PATH = Path(os.environ.get("CPS_LOG_PATH", "/data/cps_log.jsonl"))
BACKGROUND_PATH = Path(os.environ.get("BACKGROUND_PATH", "/data/background_24h.npy"))
BACKGROUND_SNAPSHOT_PATH = Path(os.environ.get("BACKGROUND_SNAPSHOT_PATH", "/data/background_snapshot.json"))
LOG_DIR = Path(os.environ.get("LOG_DIR", "/logs"))
ISOTOPE_INDEX_PATH = Path(os.environ.get("ISOTOPE_INDEX_PATH", "/models/vega_isotope_index.txt"))
ENERGY_OFFSET = float(os.environ.get("ENERGY_CALIBRATION_OFFSET", "0.33"))
ENERGY_SLOPE = float(os.environ.get("ENERGY_CALIBRATION_SLOPE", "2.97"))
NUM_CHANNELS = 1024
def energy_axis():
"""Generate energy axis in keV from channel numbers."""
return [round(ENERGY_OFFSET + ENERGY_SLOPE * i, 2) for i in range(NUM_CHANNELS)]

19
web/app/main.py Normal file
View File

@ -0,0 +1,19 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from app.routers import status, spectrum, background, history, cps
app = FastAPI(title="Radiacode 103 Dashboard", version="1.0.0")
app.include_router(status.router, prefix="/api")
app.include_router(spectrum.router, prefix="/api/spectrum")
app.include_router(background.router, prefix="/api/background")
app.include_router(history.router, prefix="/api/history")
app.include_router(cps.router, prefix="/api/cps")
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
async def root():
return FileResponse("static/index.html")

View File

View File

@ -0,0 +1,66 @@
import json
from fastapi import APIRouter, HTTPException
from app.config import BACKGROUND_SNAPSHOT_PATH, BACKGROUND_PATH, energy_axis, NUM_CHANNELS
import numpy as np
router = APIRouter()
@router.get("")
async def get_background_info():
"""Background metadata: elapsed time, CPS, top peaks."""
if not BACKGROUND_SNAPSHOT_PATH.exists():
raise HTTPException(status_code=404, detail="Background capture not available yet")
try:
with open(BACKGROUND_SNAPSHOT_PATH) as f:
snapshot = json.load(f)
except (json.JSONDecodeError, OSError):
raise HTTPException(status_code=500, detail="Background snapshot file corrupt")
# Check if full background is available
full_available = BACKGROUND_PATH.exists()
return {
"elapsed_hours": snapshot.get("elapsed_hours", 0),
"live_time_s": snapshot.get("live_time_s", 0),
"total_counts": snapshot.get("total_counts", 0),
"cps": snapshot.get("cps", 0),
"top_peaks": snapshot.get("top_peaks", []),
"full_background_available": full_available,
}
@router.get("/spectrum")
async def get_background_spectrum():
"""Full background spectrum with energy axis."""
if not BACKGROUND_SNAPSHOT_PATH.exists():
raise HTTPException(status_code=404, detail="Background capture not available yet")
try:
with open(BACKGROUND_SNAPSHOT_PATH) as f:
snapshot = json.load(f)
except (json.JSONDecodeError, OSError):
raise HTTPException(status_code=500, detail="Background snapshot file corrupt")
counts = snapshot.get("spectrum", [0] * NUM_CHANNELS)
# If full background file exists, use it for better data
if BACKGROUND_PATH.exists():
try:
bg_data = np.load(str(BACKGROUND_PATH), allow_pickle=True).item()
counts = [round(float(c), 1) for c in bg_data["counts"]]
live_time = float(bg_data["duration"])
except Exception:
live_time = snapshot.get("live_time_s", 0)
else:
live_time = snapshot.get("live_time_s", 0)
return {
"channels": list(range(NUM_CHANNELS)),
"energy_kev": energy_axis(),
"counts": counts,
"live_time_s": live_time,
"cps": snapshot.get("cps", 0),
"top_peaks": snapshot.get("top_peaks", []),
}

38
web/app/routers/cps.py Normal file
View File

@ -0,0 +1,38 @@
from fastapi import APIRouter, Query
from app.config import CPS_LOG_PATH
import json
router = APIRouter()
@router.get("/timeline")
async def get_cps_timeline(hours: int = Query(default=24, ge=1, le=720)):
"""CPS data points for the last N hours."""
if not CPS_LOG_PATH.exists():
return {"data_points": [], "hours": hours}
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(hours=hours)
data_points = []
try:
with open(CPS_LOG_PATH) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
ts = datetime.fromisoformat(entry["ts"])
if ts >= cutoff:
data_points.append(entry)
except (json.JSONDecodeError, KeyError, ValueError):
continue
except OSError:
return {"data_points": [], "hours": hours}
return {
"hours": hours,
"data_points": data_points,
}

View File

@ -0,0 +1,45 @@
import json
from fastapi import APIRouter
from app.config import LOG_DIR
router = APIRouter()
@router.get("")
async def list_reports():
"""List all daily detection reports with summaries."""
reports = []
for path in sorted(LOG_DIR.glob("report_*.json"), reverse=True):
try:
with open(path) as f:
data = json.load(f)
iso_count = len(data.get("isotopes_detected", []))
reports.append({
"date": data.get("date", ""),
"live_time_hours": data.get("live_time_hours", 0),
"total_counts": data.get("total_counts", 0),
"cps_mean": data.get("cps_mean", 0),
"background_subtracted": data.get("background_subtracted", False),
"isotope_count": iso_count,
"isotopes": [i["isotope"] for i in data.get("isotopes_detected", [])],
})
except (json.JSONDecodeError, OSError):
continue
return reports
@router.get("/{date}")
async def get_report(date: str):
"""Get a specific day's full report."""
report_path = LOG_DIR / f"report_{date}.json"
if not report_path.exists():
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"No report for {date}")
try:
with open(report_path) as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
raise HTTPException(status_code=500, detail="Report file corrupt")
return data

View File

@ -0,0 +1,76 @@
import json
from fastapi import APIRouter, HTTPException
from app.config import STATE_PATH, BACKGROUND_PATH, energy_axis, NUM_CHANNELS
import numpy as np
router = APIRouter()
@router.get("/current")
async def get_current_spectrum():
"""Current accumulated spectrum with energy axis."""
if not STATE_PATH.exists():
raise HTTPException(status_code=503, detail="Monitor not started yet")
try:
with open(STATE_PATH) as f:
state = json.load(f)
except (json.JSONDecodeError, OSError):
raise HTTPException(status_code=503, detail="Monitor state file corrupt")
return {
"timestamp": state.get("timestamp", ""),
"connected": state.get("connected", False),
"cumulated_live_time_s": state.get("cumulated_live_time_s", 0),
"cumulated_live_time_h": state.get("cumulated_live_time_h", 0),
"cps": state.get("cps", 0),
"total_counts": state.get("total_counts", 0),
"background_subtracted": state.get("background_subtracted", False),
"isotopes_detected": state.get("isotopes_detected", []),
"channels": list(range(NUM_CHANNELS)),
"energy_kev": energy_axis(),
"counts": state.get("counts", [0] * NUM_CHANNELS),
}
@router.get("/difference")
async def get_difference_spectrum():
"""Background-subtracted spectrum (net signal)."""
if not STATE_PATH.exists():
raise HTTPException(status_code=503, detail="Monitor not started yet")
try:
with open(STATE_PATH) as f:
state = json.load(f)
except (json.JSONDecodeError, OSError):
raise HTTPException(status_code=503, detail="Monitor state file corrupt")
counts = np.array(state.get("counts", [0] * NUM_CHANNELS), dtype=np.float64)
live_time = state.get("cumulated_live_time_s", 0)
if live_time <= 0:
raise HTTPException(status_code=503, detail="No live time data yet")
rate = counts / live_time
if BACKGROUND_PATH.exists():
bg_data = np.load(str(BACKGROUND_PATH), allow_pickle=True).item()
bg_counts = bg_data["counts"].astype(np.float64)
bg_live_time = float(bg_data["duration"])
bg_rate = bg_counts / bg_live_time
net_rate = np.clip(rate - bg_rate, 0, None)
net_counts = net_rate * live_time
bg_available = True
else:
net_counts = counts
bg_available = False
return {
"timestamp": state.get("timestamp", ""),
"cumulated_live_time_s": live_time,
"background_subtracted": bg_available,
"channels": list(range(NUM_CHANNELS)),
"energy_kev": energy_axis(),
"counts": [round(float(c), 1) for c in net_counts],
"raw_counts": state.get("counts", []),
}

40
web/app/routers/status.py Normal file
View File

@ -0,0 +1,40 @@
import json
from datetime import datetime
from fastapi import APIRouter, HTTPException
from app.config import STATE_PATH
router = APIRouter()
@router.get("/status")
async def get_status():
"""Current monitor status: connected, CPS, last isotopes detected."""
if not STATE_PATH.exists():
raise HTTPException(status_code=503, detail="Monitor not started yet")
try:
with open(STATE_PATH) as f:
state = json.load(f)
except (json.JSONDecodeError, OSError):
raise HTTPException(status_code=503, detail="Monitor state file corrupt")
# Check staleness (> 5 minutes old)
try:
ts = datetime.fromisoformat(state["timestamp"])
age = (datetime.now() - ts).total_seconds()
state["stale"] = age > 300
state["age_seconds"] = int(age)
except (KeyError, ValueError):
state["stale"] = True
return {
"connected": state.get("connected", False),
"stale": state.get("stale", True),
"age_seconds": state.get("age_seconds", 0),
"timestamp": state.get("timestamp", ""),
"cps": state.get("cps", 0),
"cumulated_live_time_h": state.get("cumulated_live_time_h", 0),
"total_counts": state.get("total_counts", 0),
"background_subtracted": state.get("background_subtracted", False),
"isotopes_detected": state.get("isotopes_detected", []),
}