Dash web: crosshair, zoom/pan X, scale log/lin, continuum extraction, background resume

- Tooltip entier (intersect:false) + ligne verticale crosshair sur tous les graphes
- Zoom molette/pinch sur l'axe X, pan souris, limites clamped 30-3000 keV
- Toggle échelle log/linéaire onglet Background
- Extraction continuum détecteur (isotope peaks subtracted + Gaussian smoothing)
- Reprise snapshot précédent au démarrage capture_background.py
- Suppression refs "Théorique" et "Bruit capteur" de l'interface
- Plugin chartjs-plugin-zoom + hammerjs via CDN
- Fix Chart constructor spread operator

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-05-19 23:26:28 +02:00
parent 0f2417bf88
commit c764a5c264
15 changed files with 975 additions and 221 deletions

View File

@ -1,7 +1,8 @@
import json
from fastapi import APIRouter, HTTPException
from app.config import BACKGROUND_SNAPSHOT_PATH, BACKGROUND_PATH, energy_axis, NUM_CHANNELS
from app.theoretical_bg import generate_theoretical_bg, generate_continuum_only
from app.config import BACKGROUND_SNAPSHOT_PATH, BACKGROUND_PATH, energy_axis, NUM_CHANNELS, ENERGY_OFFSET, ENERGY_SLOPE
from app.theoretical_bg import generate_continuum_only
from app.noise import extract_continuum
import numpy as np
router = APIRouter()
@ -80,16 +81,100 @@ async def get_background_reference():
}
@router.get("/theoretical")
async def get_theoretical_bg(cps: float = 6.0, live_time_s: float = 3600.0):
"""Theoretical natural background spectrum (K-40, U-238 chain, Th-232 chain)."""
return generate_theoretical_bg(cps=cps, live_time_s=live_time_s)
@router.get("/continuum")
async def get_continuum(cps: float = 6.0, live_time_s: float = 3600.0):
"""CsI(Tl) continuum shape only (hump + Compton tail, no photopeaks, no noise).
"""CsI(Tl) detector response continuum only (no photopeaks, no noise)."""
return generate_continuum_only(cps=cps, live_time_s=live_time_s)
Matches the model used in training (generate_realistic_continuum).
@router.get("/fit")
async def fit_background():
"""Fit the parametric CsI(Tl) detector response model to measured background data.
Returns the fitted curve, parameters, and quality metrics.
"""
return generate_continuum_only(cps=cps, live_time_s=live_time_s)
from app.bg_calibration import calibrate_background, build_calibrated_continuum
# Load measured data
measured_counts = None
live_time = 0
if BACKGROUND_PATH.exists():
try:
bg_data = np.load(str(BACKGROUND_PATH), allow_pickle=True).item()
measured_counts = bg_data["counts"].astype(np.float64)[:NUM_CHANNELS]
live_time = float(bg_data["duration"])
except Exception:
pass
if measured_counts is None and BACKGROUND_SNAPSHOT_PATH.exists():
try:
with open(BACKGROUND_SNAPSHOT_PATH) as f:
snapshot = json.load(f)
measured_counts = np.array(snapshot.get("spectrum", [])[:NUM_CHANNELS], dtype=np.float64)
live_time = float(snapshot.get("live_time_s", 0))
except Exception:
pass
if measured_counts is None or live_time < 600:
raise HTTPException(status_code=404, detail="No measured background available for fitting")
channels = np.arange(NUM_CHANNELS, dtype=np.float64)
e_axis = ENERGY_OFFSET + ENERGY_SLOPE * channels
# Run calibration
measured_cps = measured_counts / live_time
result = calibrate_background(measured_cps, e_axis)
if "error" in result:
raise HTTPException(status_code=500, detail=f"Fitting failed: {result['error']}")
# Build fitted curve at same scale as measured
fitted_counts = build_calibrated_continuum(e_axis, measured_counts.sum(), result)
return {
"energy_kev": [round(float(E), 2) for E in e_axis],
"measured_counts": [round(float(c), 1) for c in measured_counts],
"fitted_counts": [round(float(c), 1) for c in fitted_counts],
"method": result.get("method", "spline"),
"r_squared": result["r_squared"],
"residuals_rms": result["residuals_rms"],
"live_time_s": round(live_time, 1),
}
@router.get("/noise")
async def get_background_noise():
"""Detector's intrinsic continuum curve (isotope peaks subtracted).
Returns the smooth detector response shape without any isotope
photopeak signatures. Works with any detector type.
"""
counts = None
if BACKGROUND_PATH.exists():
try:
bg_data = np.load(str(BACKGROUND_PATH), allow_pickle=True).item()
counts = bg_data["counts"].astype(np.float64)[:NUM_CHANNELS]
except Exception:
pass
if counts is None and BACKGROUND_SNAPSHOT_PATH.exists():
try:
with open(BACKGROUND_SNAPSHOT_PATH) as f:
snapshot = json.load(f)
counts = np.array(snapshot.get("spectrum", [])[:NUM_CHANNELS], dtype=np.float64)
except Exception:
pass
if counts is None:
raise HTTPException(status_code=404, detail="No background data available")
channels = np.arange(NUM_CHANNELS, dtype=np.float64)
e_axis = ENERGY_OFFSET + ENERGY_SLOPE * channels
continuum = extract_continuum(counts, energy_axis=e_axis)
return {
"energy_kev": [round(float(E), 2) for E in e_axis],
"counts": [round(float(c), 1) for c in continuum],
}