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:
0
web/app/__init__.py
Normal file
0
web/app/__init__.py
Normal file
18
web/app/config.py
Normal file
18
web/app/config.py
Normal 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
19
web/app/main.py
Normal 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")
|
||||
0
web/app/routers/__init__.py
Normal file
0
web/app/routers/__init__.py
Normal file
66
web/app/routers/background.py
Normal file
66
web/app/routers/background.py
Normal 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
38
web/app/routers/cps.py
Normal 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,
|
||||
}
|
||||
45
web/app/routers/history.py
Normal file
45
web/app/routers/history.py
Normal 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
|
||||
76
web/app/routers/spectrum.py
Normal file
76
web/app/routers/spectrum.py
Normal 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
40
web/app/routers/status.py
Normal 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", []),
|
||||
}
|
||||
Reference in New Issue
Block a user