diff --git a/.gitignore b/.gitignore index ea8131f..886ff86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Modeles entraines +# Modeles entraines (checkpoints intermediaires uniquement) models/vega_epoch_*.pt models/vega_final.pt diff --git a/detect/radiacode_monitor.py b/detect/radiacode_monitor.py index 505c4c1..792d890 100644 --- a/detect/radiacode_monitor.py +++ b/detect/radiacode_monitor.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index daa77d8..4fc1e06 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,8 @@ services: - MODEL_PATH=/models/vega_best.pt - ISOTOPE_INDEX_PATH=/models/vega_isotope_index.txt - BACKGROUND_PATH=/data/background_24h.npy + - STATE_PATH=/data/monitor_state.json + - CPS_LOG_PATH=/data/cps_log.jsonl - VEGA_ML_PATH=/models/vega_ml - VEGA_DEVICE=cpu - LOG_DIR=/logs @@ -52,4 +54,25 @@ services: depends_on: train: condition: service_completed_successfully + restart: unless-stopped + + web: + build: + context: ./web + dockerfile: Dockerfile + ports: + - "8080:8080" + volumes: + - ./data:/data:ro + - ./logs:/logs:ro + - ./models/vega_isotope_index.txt:/models/vega_isotope_index.txt:ro + environment: + - STATE_PATH=/data/monitor_state.json + - CPS_LOG_PATH=/data/cps_log.jsonl + - BACKGROUND_PATH=/data/background_24h.npy + - BACKGROUND_SNAPSHOT_PATH=/data/background_snapshot.json + - LOG_DIR=/logs + - ISOTOPE_INDEX_PATH=/models/vega_isotope_index.txt + - ENERGY_CALIBRATION_OFFSET=0.33 + - ENERGY_CALIBRATION_SLOPE=2.97 restart: unless-stopped \ No newline at end of file diff --git a/models/vega_best.pt b/models/vega_best.pt index 763e501..35c58c6 100644 Binary files a/models/vega_best.pt and b/models/vega_best.pt differ diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..f7b2514 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ +COPY static/ ./static/ + +EXPOSE 8080 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "1"] \ No newline at end of file diff --git a/web/app/__init__.py b/web/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/app/config.py b/web/app/config.py new file mode 100644 index 0000000..45396be --- /dev/null +++ b/web/app/config.py @@ -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)] \ No newline at end of file diff --git a/web/app/main.py b/web/app/main.py new file mode 100644 index 0000000..06a3284 --- /dev/null +++ b/web/app/main.py @@ -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") \ No newline at end of file diff --git a/web/app/routers/__init__.py b/web/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/app/routers/background.py b/web/app/routers/background.py new file mode 100644 index 0000000..9442a24 --- /dev/null +++ b/web/app/routers/background.py @@ -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", []), + } \ No newline at end of file diff --git a/web/app/routers/cps.py b/web/app/routers/cps.py new file mode 100644 index 0000000..fedce69 --- /dev/null +++ b/web/app/routers/cps.py @@ -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, + } \ No newline at end of file diff --git a/web/app/routers/history.py b/web/app/routers/history.py new file mode 100644 index 0000000..f8b431d --- /dev/null +++ b/web/app/routers/history.py @@ -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 \ No newline at end of file diff --git a/web/app/routers/spectrum.py b/web/app/routers/spectrum.py new file mode 100644 index 0000000..666039b --- /dev/null +++ b/web/app/routers/spectrum.py @@ -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", []), + } \ No newline at end of file diff --git a/web/app/routers/status.py b/web/app/routers/status.py new file mode 100644 index 0000000..7edd894 --- /dev/null +++ b/web/app/routers/status.py @@ -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", []), + } \ No newline at end of file diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..0a5c747 --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +numpy>=1.24.0 \ No newline at end of file diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..e622044 --- /dev/null +++ b/web/static/css/style.css @@ -0,0 +1,181 @@ +:root { + --bg: #1a1a2e; + --bg-card: #16213e; + --text: #e0e0e0; + --text-dim: #888; + --accent: #0f3460; + --accent-bright: #4fc3f7; + --green: #4caf50; + --red: #f44336; + --yellow: #ff9800; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +header { + background: var(--bg-card); + padding: 12px 20px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #333; +} + +header h1 { + font-size: 1.2em; + color: var(--accent-bright); +} + +#status-bar { + display: flex; + align-items: center; + gap: 16px; + font-size: 0.9em; +} + +.status-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--red); +} + +.status-dot.connected { background: var(--green); } + +nav { + background: var(--accent); + display: flex; +} + +nav a { + flex: 1; + text-align: center; + padding: 10px; + color: var(--text-dim); + text-decoration: none; + font-size: 0.95em; + transition: all 0.2s; +} + +nav a:hover { background: rgba(255,255,255,0.1); } +nav a.active { color: var(--accent-bright); border-bottom: 2px solid var(--accent-bright); } + +main { padding: 16px; } + +.tab-content { display: none; } +.tab-content.active { display: block; } + +.chart-container { + background: var(--bg-card); + border-radius: 8px; + padding: 12px; + margin-bottom: 12px; +} + +.controls { + display: flex; + gap: 12px; + margin-bottom: 12px; + align-items: center; +} + +.controls label { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9em; + color: var(--text-dim); + cursor: pointer; +} + +.controls button { + background: var(--accent); + color: var(--text); + border: 1px solid #444; + padding: 6px 14px; + border-radius: 4px; + cursor: pointer; + font-size: 0.85em; +} + +.controls button:hover { background: var(--accent-bright); color: #000; } + +#isotopes-table, #peaks-table { + background: var(--bg-card); + border-radius: 8px; + padding: 12px; +} + +.isotope-row { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid #333; + font-size: 0.9em; +} + +.isotope-row:last-child { border-bottom: none; } +.isotope-name { color: var(--accent-bright); font-weight: bold; min-width: 80px; } +.isotope-prob { color: var(--green); min-width: 60px; } +.isotope-activity { color: var(--text-dim); } + +.bg-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 12px; +} + +.bg-stat { + background: var(--bg-card); + border-radius: 8px; + padding: 12px; + text-align: center; +} + +.bg-stat-value { font-size: 1.4em; color: var(--accent-bright); } +.bg-stat-label { font-size: 0.8em; color: var(--text-dim); } + +.history-item { + background: var(--bg-card); + border-radius: 8px; + padding: 12px; + margin-bottom: 8px; + cursor: pointer; + transition: background 0.2s; +} + +.history-item:hover { background: #1e2a4a; } + +.history-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.history-date { font-weight: bold; color: var(--accent-bright); } +.history-cps { color: var(--text-dim); } +.history-isotopes { color: var(--green); font-size: 0.85em; } +.history-details { margin-top: 8px; padding-top: 8px; border-top: 1px solid #333; font-size: 0.9em; display: none; } +.history-details.open { display: block; } + +.peak-row { + display: flex; + justify-content: space-between; + padding: 4px 0; + font-size: 0.85em; +} + +@media (max-width: 600px) { + .bg-stats { grid-template-columns: repeat(2, 1fr); } + header { flex-direction: column; gap: 8px; } + nav a { font-size: 0.85em; padding: 8px; } +} \ No newline at end of file diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..ba1b376 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,70 @@ + + + + + + Radiacode 103 — Dashboard + + + + +
+

Radiacode 103

+
+ + -- CPS + -- h +
+
+ + + +
+
+
+ +
+
+ + +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+ + + + +
+
+ +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..8a1ed31 --- /dev/null +++ b/web/static/js/app.js @@ -0,0 +1,51 @@ +const API_BASE = ''; +let refreshInterval = null; +const REFRESH_MS = 30000; // 30 seconds + +// Tab navigation +document.querySelectorAll('nav a').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const tab = link.dataset.tab; + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); + link.classList.add('active'); + document.getElementById(`tab-${tab}`).classList.add('active'); + // Refresh the active tab + if (tab === 'spectrum') refreshSpectrum(); + if (tab === 'background') refreshBackground(); + if (tab === 'cps') loadCps(24); + }); +}); + +// Status bar refresh +async function refreshStatus() { + try { + const resp = await fetch(`${API_BASE}/api/status`); + if (!resp.ok) { + document.getElementById('status-connected').className = 'status-dot'; + return; + } + const data = await resp.json(); + const dot = document.getElementById('status-connected'); + dot.className = data.connected && !data.stale ? 'status-dot connected' : 'status-dot'; + document.getElementById('status-cps').textContent = `${data.cps.toFixed(1)} CPS`; + document.getElementById('status-live-time').textContent = `${data.cumulated_live_time_h.toFixed(1)} h`; + } catch { + document.getElementById('status-connected').className = 'status-dot'; + } +} + +// Auto-refresh +function startRefresh() { + refreshStatus(); + refreshSpectrum(); + refreshInterval = setInterval(() => { + refreshStatus(); + const activeTab = document.querySelector('.tab.active')?.dataset.tab; + if (activeTab === 'spectrum') refreshSpectrum(); + }, REFRESH_MS); +} + +// Initialize +startRefresh(); \ No newline at end of file diff --git a/web/static/js/background.js b/web/static/js/background.js new file mode 100644 index 0000000..8a8faec --- /dev/null +++ b/web/static/js/background.js @@ -0,0 +1,103 @@ +let bgChart = null; + +async function refreshBackground() { + try { + const [infoResp, specResp] = await Promise.all([ + fetch(`${API_BASE}/api/background`), + fetch(`${API_BASE}/api/background/spectrum`) + ]); + + if (!infoResp.ok || !specResp.ok) { + document.getElementById('bg-stats').innerHTML = '

Background non disponible

'; + return; + } + + const info = await infoResp.json(); + const spec = await specResp.json(); + + // Stats + document.getElementById('bg-stats').innerHTML = ` +
${info.elapsed_hours.toFixed(1)}h
Durée
+
${info.live_time_s.toFixed(0)}s
Live time
+
${info.total_counts.toFixed(0)}
Coups
+
${info.cps.toFixed(2)}
CPS
+ `; + + // Chart + updateBackgroundChart(spec); + + // Peaks table + updatePeaksTable(info.top_peaks || []); + } catch {} +} + +function updateBackgroundChart(spec) { + const ctx = document.getElementById('background-chart').getContext('2d'); + + const chartData = { + labels: spec.energy_kev, + datasets: [{ + label: 'Background', + data: spec.counts, + borderColor: '#ff9800', + backgroundColor: 'rgba(255, 152, 0, 0.1)', + borderWidth: 1, + pointRadius: 0, + fill: true, + }] + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { labels: { color: '#e0e0e0' } }, + tooltip: { + callbacks: { + title: (items) => `${spec.energy_kev[items[0].dataIndex]} keV`, + label: (item) => `${item.raw.toFixed(1)} counts` + } + } + }, + scales: { + x: { + type: 'linear', + title: { display: true, text: 'Énergie (keV)', color: '#888' }, + ticks: { color: '#888', maxTicksLimit: 20 }, + grid: { color: '#333' }, + }, + y: { + title: { display: true, text: 'Comptages', color: '#888' }, + ticks: { color: '#888' }, + grid: { color: '#333' }, + } + } + }; + + if (bgChart) { + bgChart.data = chartData; + bgChart.options = options; + bgChart.update(); + } else { + bgChart = new Chart(ctx, { type: 'line', data: chartData, options }); + } +} + +function updatePeaksTable(peaks) { + const container = document.getElementById('peaks-table'); + if (!peaks || peaks.length === 0) { + container.innerHTML = '

Pas assez de données pour identifier les pics

'; + return; + } + + let html = '

Pics détectés

'; + peaks.forEach(p => { + html += `
+ ${p.energy_kev.toFixed(1)} keV + ${p.counts.toFixed(0)} cts +
`; + }); + container.innerHTML = html; +} + +document.querySelector('[data-tab="background"]').addEventListener('click', refreshBackground); \ No newline at end of file diff --git a/web/static/js/cps.js b/web/static/js/cps.js new file mode 100644 index 0000000..6c332d7 --- /dev/null +++ b/web/static/js/cps.js @@ -0,0 +1,83 @@ +let cpsChart = null; + +async function loadCps(hours = 24) { + // Highlight active button + document.querySelectorAll('.controls button').forEach(b => b.style.opacity = '0.6'); + const activeBtn = [...document.querySelectorAll('.controls button')].find( + b => b.textContent.includes(hours <= 24 ? `${hours}h` : `${hours/24}j`) || (hours === 168 && b.textContent === '7j') + ); + if (activeBtn) activeBtn.style.opacity = '1'; + + try { + const resp = await fetch(`${API_BASE}/api/cps/timeline?hours=${hours}`); + if (!resp.ok) return; + const data = await resp.json(); + + if (!data.data_points || data.data_points.length === 0) { + return; + } + + const labels = data.data_points.map(d => d.ts); + const cpsValues = data.data_points.map(d => d.cps); + + updateCpsChart(labels, cpsValues); + } catch {} +} + +function updateCpsChart(labels, values) { + const ctx = document.getElementById('cps-chart').getContext('2d'); + + const chartData = { + labels: labels, + datasets: [{ + label: 'CPS', + data: values, + borderColor: '#4caf50', + backgroundColor: 'rgba(76, 175, 80, 0.1)', + borderWidth: 1.5, + pointRadius: 0, + fill: true, + tension: 0.3, + }] + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { labels: { color: '#e0e0e0' } }, + }, + scales: { + x: { + type: 'time', + time: { + tooltipFormat: 'dd/MM HH:mm', + displayFormats: { minute: 'HH:mm', hour: 'HH:mm', day: 'dd/MM' } + }, + title: { display: true, text: 'Temps', color: '#888' }, + ticks: { color: '#888', maxTicksLimit: 12 }, + grid: { color: '#333' }, + }, + y: { + title: { display: true, text: 'CPS', color: '#888' }, + ticks: { color: '#888' }, + grid: { color: '#333' }, + beginAtZero: true, + } + } + }; + + if (cpsChart) { + cpsChart.data = chartData; + cpsChart.options = options; + cpsChart.update(); + } else { + // Chart.js needs the date adapter for time axis + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js'; + script.onload = () => { + cpsChart = new Chart(ctx, { type: 'line', data: chartData, options }); + }; + document.head.appendChild(script); + } +} \ No newline at end of file diff --git a/web/static/js/history.js b/web/static/js/history.js new file mode 100644 index 0000000..24b7a17 --- /dev/null +++ b/web/static/js/history.js @@ -0,0 +1,39 @@ +async function loadHistory() { + try { + const resp = await fetch(`${API_BASE}/api/history`); + if (!resp.ok) return; + const reports = await resp.json(); + const container = document.getElementById('history-list'); + + if (reports.length === 0) { + container.innerHTML = '

Aucun rapport disponible

'; + return; + } + + let html = ''; + reports.forEach(r => { + const isoText = r.isotopes.length > 0 ? r.isotopes.join(', ') : 'background uniquement'; + html += `
+
+ ${r.date.split('T')[0]} + ${r.cps_mean.toFixed(1)} CPS + ${r.isotope_count} isotope(s) +
+
+

Live time: ${r.live_time_hours.toFixed(1)}h | Total: ${r.total_counts} coups

+

Isotopes: ${isoText}

+

Background: ${r.background_subtracted ? 'soustrait' : 'non soustrait'}

+
+
`; + }); + container.innerHTML = html; + } catch {} +} + +function toggleDetails(el) { + const details = el.querySelector('.history-details'); + details.classList.toggle('open'); +} + +// Load on tab switch +document.querySelector('[data-tab="history"]').addEventListener('click', loadHistory); \ No newline at end of file diff --git a/web/static/js/spectrum.js b/web/static/js/spectrum.js new file mode 100644 index 0000000..30df904 --- /dev/null +++ b/web/static/js/spectrum.js @@ -0,0 +1,97 @@ +let spectrumChart = null; +let currentSpectrumData = null; + +async function refreshSpectrum() { + const showDiff = document.getElementById('show-difference').checked; + const endpoint = showDiff ? '/api/spectrum/difference' : '/api/spectrum/current'; + + try { + const resp = await fetch(`${API_BASE}${endpoint}`); + if (!resp.ok) return; + const data = await resp.json(); + currentSpectrumData = data; + updateSpectrumChart(data); + updateIsotopesTable(data.isotopes_detected || []); + } catch {} +} + +function updateSpectrumChart(data) { + const logScale = document.getElementById('log-scale').checked; + const ctx = document.getElementById('spectrum-chart').getContext('2d'); + + const chartData = { + labels: data.energy_kev, + datasets: [{ + label: data.background_subtracted ? 'Spectre (background soustrait)' : 'Spectre cumulé', + data: data.counts, + borderColor: '#4fc3f7', + backgroundColor: 'rgba(79, 195, 247, 0.1)', + borderWidth: 1, + pointRadius: 0, + fill: true, + }] + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 300 }, + plugins: { + legend: { labels: { color: '#e0e0e0' } }, + tooltip: { + callbacks: { + title: (items) => { + const idx = items[0].dataIndex; + return `${data.energy_kev[idx]} keV`; + }, + label: (item) => `${item.raw.toFixed(1)} counts` + } + } + }, + scales: { + x: { + type: 'linear', + title: { display: true, text: 'Énergie (keV)', color: '#888' }, + ticks: { color: '#888', maxTicksLimit: 20 }, + grid: { color: '#333' }, + }, + y: { + type: logScale ? 'logarithmic' : 'linear', + title: { display: true, text: 'Comptages', color: '#888' }, + ticks: { color: '#888' }, + grid: { color: '#333' }, + } + } + }; + + if (spectrumChart) { + spectrumChart.data = chartData; + spectrumChart.options = options; + spectrumChart.update(); + } else { + spectrumChart = new Chart(ctx, { type: 'line', data: chartData, options }); + } +} + +function updateIsotopesTable(isotopes) { + const container = document.getElementById('isotopes-table'); + if (!isotopes || isotopes.length === 0) { + container.innerHTML = '

Aucun isotope détecté (background uniquement)

'; + return; + } + + let html = '

Isotopes détectés

'; + isotopes.forEach(iso => { + const probColor = iso.probability > 0.9 ? '#4caf50' : iso.probability > 0.7 ? '#ff9800' : '#f44336'; + html += `
+ ${iso.isotope} + ${(iso.probability * 100).toFixed(1)}% + ${iso.activity_bq.toFixed(1)} Bq +
`; + }); + container.innerHTML = html; +} + +// Event listeners +document.getElementById('show-difference').addEventListener('change', refreshSpectrum); +document.getElementById('log-scale').addEventListener('change', refreshSpectrum); \ No newline at end of file