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:
@ -11,6 +11,7 @@ import json
|
||||
import os
|
||||
|
||||
SAMPLE_INTERVAL = int(os.environ.get("SAMPLE_INTERVAL", "60"))
|
||||
RESET_INTERVAL = int(os.environ.get("RESET_INTERVAL", "3600")) # Reset detector every N seconds (default: 1h)
|
||||
TARGET_DURATION = int(os.environ.get("TARGET_DURATION", str(86400))) # 24h
|
||||
OUTPUT_PATH = os.environ.get("BACKGROUND_PATH", "/data/background_24h.npy")
|
||||
SNAPSHOT_PATH = os.environ.get("SNAPSHOT_PATH", "/data/background_snapshot.json")
|
||||
@ -18,6 +19,22 @@ SNAPSHOT_PATH = os.environ.get("SNAPSHOT_PATH", "/data/background_snapshot.json"
|
||||
BG_COUNTS = np.zeros(1024, dtype=np.float64)
|
||||
BG_LIVE_TIME = 0.0
|
||||
device = None
|
||||
last_counts = None
|
||||
last_live_time = None
|
||||
last_reset_time = 0
|
||||
|
||||
# Resume from previous snapshot if it exists
|
||||
if os.path.exists(SNAPSHOT_PATH):
|
||||
try:
|
||||
with open(SNAPSHOT_PATH) as _f:
|
||||
_prev = json.load(_f)
|
||||
_prev_spectrum = _prev.get("spectrum", [])
|
||||
if len(_prev_spectrum) == 1024:
|
||||
BG_COUNTS = np.array(_prev_spectrum, dtype=np.float64)
|
||||
BG_LIVE_TIME = float(_prev.get("live_time_s", 0))
|
||||
print(f"Snapshot anterieur charge : {BG_LIVE_TIME:.0f}s live, {BG_COUNTS.sum():.0f} coups")
|
||||
except Exception as _e:
|
||||
print(f"Impossible de charger le snapshot anterieur : {_e}")
|
||||
|
||||
def save_snapshot():
|
||||
"""Save a human-readable snapshot of current background."""
|
||||
@ -46,7 +63,18 @@ print(f"Capture du bruit de fond pendant {TARGET_DURATION/3600:.0f}h...")
|
||||
print("Assurez-vous qu'aucune source radioactive n'est a proximite du detecteur.")
|
||||
print()
|
||||
|
||||
start = time.time()
|
||||
start_offset = 0
|
||||
if os.path.exists(SNAPSHOT_PATH):
|
||||
try:
|
||||
with open(SNAPSHOT_PATH) as _f:
|
||||
_prev = json.load(_f)
|
||||
start_offset = float(_prev.get("elapsed_hours", 0)) * 3600
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
start = time.time() - start_offset
|
||||
last_reset_time = time.time()
|
||||
|
||||
while (time.time() - start) < TARGET_DURATION:
|
||||
time.sleep(SAMPLE_INTERVAL)
|
||||
try:
|
||||
@ -55,12 +83,41 @@ while (time.time() - start) < TARGET_DURATION:
|
||||
|
||||
device = RadiaCode()
|
||||
device.spectrum_reset()
|
||||
last_counts = None
|
||||
last_live_time = None
|
||||
last_reset_time = time.time()
|
||||
print("Radiacode connecte.")
|
||||
|
||||
spectrum = device.spectrum()
|
||||
BG_COUNTS += np.array(spectrum.counts, dtype=np.float64)
|
||||
BG_LIVE_TIME += spectrum.duration.total_seconds()
|
||||
device.spectrum_reset()
|
||||
counts = np.array(spectrum.counts, dtype=np.float64)
|
||||
live_time = spectrum.duration.total_seconds()
|
||||
|
||||
# Compute delta since last read (avoid double-counting)
|
||||
if last_counts is not None and last_live_time is not None:
|
||||
delta_counts = counts - last_counts
|
||||
delta_live_time = live_time - last_live_time
|
||||
# If detector was reset externally, delta would be negative
|
||||
if delta_counts.sum() < 0 or delta_live_time < 0:
|
||||
delta_counts = counts
|
||||
delta_live_time = live_time
|
||||
BG_COUNTS += delta_counts
|
||||
BG_LIVE_TIME += max(delta_live_time, 0)
|
||||
else:
|
||||
BG_COUNTS += counts
|
||||
BG_LIVE_TIME += live_time
|
||||
|
||||
last_counts = counts.copy()
|
||||
last_live_time = live_time
|
||||
|
||||
# Only reset detector spectrum at RESET_INTERVAL
|
||||
now = time.time()
|
||||
if now - last_reset_time >= RESET_INTERVAL:
|
||||
device.spectrum_reset()
|
||||
last_counts = None
|
||||
last_live_time = None
|
||||
last_reset_time = now
|
||||
print(f" → Reset détecteur (intervalle {RESET_INTERVAL}s)")
|
||||
|
||||
elapsed = time.time() - start
|
||||
cps = BG_COUNTS.sum() / BG_LIVE_TIME if BG_LIVE_TIME > 0 else 0
|
||||
print(
|
||||
@ -72,6 +129,8 @@ while (time.time() - start) < TARGET_DURATION:
|
||||
except Exception as e:
|
||||
print(f"\nErreur : {e}, reconnexion...")
|
||||
device = None
|
||||
last_counts = None
|
||||
last_live_time = None
|
||||
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH) if os.path.dirname(OUTPUT_PATH) else ".", exist_ok=True)
|
||||
np.save(
|
||||
|
||||
@ -60,7 +60,7 @@ services:
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "8000:8080"
|
||||
volumes:
|
||||
- ./data:/data:ro
|
||||
- ./logs:/logs:ro
|
||||
@ -74,4 +74,4 @@ services:
|
||||
- ISOTOPE_INDEX_PATH=/models/vega_isotope_index.txt
|
||||
- ENERGY_CALIBRATION_OFFSET=0.33
|
||||
- ENERGY_CALIBRATION_SLOPE=2.97
|
||||
restart: unless-stopped
|
||||
restart: unless-stopped
|
||||
|
||||
@ -324,13 +324,16 @@ def generate_realistic_continuum(
|
||||
Generate realistic CsI(Tl) background continuum shape.
|
||||
|
||||
Calibrated against real Radiacode 103 background measurements.
|
||||
Produces the characteristic asymmetric hump at ~110 keV and
|
||||
Compton-like tail that simple exponentials miss.
|
||||
Produces the characteristic asymmetric hump at ~110 keV with
|
||||
housing absorption at low energy, Compton plateau, and proper
|
||||
high-energy falloff.
|
||||
|
||||
Shape components:
|
||||
- Asymmetric hump centered at ~110 keV (sigma_left=55, sigma_right=50 keV)
|
||||
- Compton continuum: 0.45*exp(-E/240) + 0.04*exp(-E/700)
|
||||
- Noise floor at 0.8% of peak
|
||||
- Asymmetric hump at ~110 keV (sigma_left=48, sigma_right=80 keV)
|
||||
- Housing absorption below ~40 keV: 1 - exp(-E/30)
|
||||
- Compton plateau around 200-260 keV from Pb-214/Bi-214 scatter
|
||||
- Compton tail: 0.38*exp(-E/170) + 0.06*exp(-E/500)
|
||||
- Noise floor at 0.3% of peak
|
||||
|
||||
Args:
|
||||
energy_bins: Array of energy bin centers (keV)
|
||||
@ -342,24 +345,31 @@ def generate_realistic_continuum(
|
||||
"""
|
||||
E = energy_bins
|
||||
|
||||
# Asymmetric hump at ~110 keV (low-energy scatter peak in CsI(Tl))
|
||||
# Asymmetric hump at ~110 keV
|
||||
# Left side sharper (sigma=48), right side broader with Compton shoulder (sigma=80)
|
||||
hump_center = 110.0
|
||||
sigma_left = 55.0 # Broader on the low-energy side
|
||||
sigma_right = 50.0 # Narrower on the high-energy side
|
||||
hump = np.where(
|
||||
E <= hump_center,
|
||||
np.exp(-0.5 * ((E - hump_center) / sigma_left) ** 2),
|
||||
np.exp(-0.5 * ((E - hump_center) / sigma_right) ** 2),
|
||||
np.exp(-0.5 * ((E - hump_center) / 48.0) ** 2),
|
||||
np.exp(-0.5 * ((E - hump_center) / 80.0) ** 2),
|
||||
)
|
||||
|
||||
# Housing absorption at very low energy (< ~40 keV)
|
||||
absorption = 1.0 - np.exp(-E / 30.0)
|
||||
|
||||
# Compton continuum tail
|
||||
tail = 0.45 * np.exp(-E / 240.0) + 0.04 * np.exp(-E / 700.0)
|
||||
tail = 0.38 * np.exp(-E / 170.0) + 0.06 * np.exp(-E / 500.0)
|
||||
|
||||
# Noise floor (low-level baseline)
|
||||
noise_floor = 0.008
|
||||
# Compton plateau around 200-260 keV (Pb-214/Bi-214 scatter)
|
||||
compton_edge = np.maximum(0, 1.0 - ((E - 180.0) / 150.0) ** 2)
|
||||
compton_edge[E > 330] = 0
|
||||
compton_plateau = 0.12 * compton_edge
|
||||
|
||||
# Combine shape components
|
||||
continuum = hump + tail + noise_floor
|
||||
# Noise floor
|
||||
noise_floor = 0.003
|
||||
|
||||
# Combine continuum with absorption
|
||||
continuum = (hump + tail + compton_plateau) * absorption + noise_floor
|
||||
|
||||
# Normalize to target total counts
|
||||
if continuum.sum() > 0 and total_counts > 0:
|
||||
|
||||
208
web/app/bg_calibration.py
Normal file
208
web/app/bg_calibration.py
Normal file
@ -0,0 +1,208 @@
|
||||
"""
|
||||
CsI(Tl) detector response continuum calibration for Radiacode 103.
|
||||
|
||||
Models ONLY the detector's noise continuum. Photopeaks from environmental
|
||||
isotopes depend on measurement location and are NOT part of this model.
|
||||
|
||||
Uses two approaches:
|
||||
1. Spline-based: non-parametric, automatically fits any shape
|
||||
2. Parametric: for the /fit endpoint (comparison with measured data)
|
||||
|
||||
The spline approach is preferred — it uses scipy's smoothing spline with
|
||||
Generalized Cross-Validation to automatically find the right smoothness,
|
||||
after iterative peak subtraction.
|
||||
"""
|
||||
|
||||
import json
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from scipy.interpolate import make_smoothing_spline
|
||||
from scipy.signal import savgol_filter
|
||||
from app.config import ENERGY_OFFSET, ENERGY_SLOPE, NUM_CHANNELS
|
||||
|
||||
PHOTOPEAK_LINES = [
|
||||
(295.22, 0.1842), (351.93, 0.3560), (609.31, 0.4549),
|
||||
(911.20, 0.2580), (968.97, 0.1580), (1120.29, 0.1492),
|
||||
(1460.83, 0.1066), (1764.49, 0.1531), (2614.51, 0.3586),
|
||||
]
|
||||
|
||||
|
||||
def _sigma_keV(E):
|
||||
return max(12.0, 23.6 * np.sqrt(max(E, 1.0) / 662.0))
|
||||
|
||||
|
||||
def _smooth(y):
|
||||
window = min(51, len(y) // 10 * 2 + 1)
|
||||
if window < 5:
|
||||
window = 5
|
||||
return savgol_filter(y, window_length=window, polyorder=3)
|
||||
|
||||
|
||||
def _subtract_peaks(energy_axis, smoothed_cps):
|
||||
"""Iteratively estimate and subtract photopeak contributions."""
|
||||
continuum = smoothed_cps.copy()
|
||||
peak_amplitudes = []
|
||||
|
||||
for line_energy, _ in PHOTOPEAK_LINES:
|
||||
sig = _sigma_keV(line_energy)
|
||||
idx = np.argmin(np.abs(energy_axis - line_energy))
|
||||
n_sigma = max(int(2 * sig / 2.97), 3)
|
||||
off_lo = continuum[max(0, idx - 3 * n_sigma):max(1, idx - n_sigma)]
|
||||
off_hi = continuum[min(len(continuum), idx + n_sigma):min(len(continuum), idx + 3 * n_sigma)]
|
||||
off_peak = np.concatenate([off_lo, off_hi])
|
||||
local_bg = np.median(off_peak) if len(off_peak) > 0 else 0
|
||||
peak_height = continuum[idx] - local_bg
|
||||
|
||||
if peak_height > 0:
|
||||
amplitude = peak_height * sig * np.sqrt(2 * np.pi)
|
||||
gaussian = amplitude * np.exp(-0.5 * ((energy_axis - line_energy) / sig) ** 2) / (sig * np.sqrt(2 * np.pi))
|
||||
continuum -= gaussian
|
||||
continuum = np.maximum(continuum, 0)
|
||||
peak_amplitudes.append({"energy_keV": line_energy, "amplitude": float(max(0, peak_height) * sig * np.sqrt(2 * np.pi)) if peak_height > 0 else 0.0})
|
||||
|
||||
return continuum, peak_amplitudes
|
||||
|
||||
|
||||
def calibrate_spline(measured_cps, energy_axis):
|
||||
"""
|
||||
Fit a smoothing spline to the peak-subtracted continuum.
|
||||
|
||||
Uses scipy's make_smoothing_spline with GCV (Generalized Cross-Validation)
|
||||
to automatically find the optimal smoothing parameter.
|
||||
|
||||
Returns a dict with the fitted spline evaluated at all channels.
|
||||
"""
|
||||
E = energy_axis
|
||||
y_smooth = _smooth(measured_cps)
|
||||
continuum, peak_amplitudes = _subtract_peaks(E, y_smooth)
|
||||
|
||||
# Ensure positive values for spline fitting
|
||||
continuum = np.maximum(continuum, 0)
|
||||
|
||||
# Use log-space for better fit at low-signal high-energy region
|
||||
# Add small offset to avoid log(0)
|
||||
offset = continuum[continuum > 0].min() * 0.1 if (continuum > 0).any() else 1e-6
|
||||
log_continuum = np.log(continuum + offset)
|
||||
|
||||
# Fit smoothing spline in log-space (GCV auto-selects lambda)
|
||||
try:
|
||||
spline = make_smoothing_spline(E, log_continuum)
|
||||
log_fit = spline(E)
|
||||
# Convert back from log-space
|
||||
fit_cps = np.exp(log_fit) - offset
|
||||
fit_cps = np.maximum(fit_cps, 0)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# Quality metrics
|
||||
residuals = continuum - fit_cps
|
||||
ss_res = np.sum(residuals ** 2)
|
||||
ss_tot = np.sum((continuum - continuum.mean()) ** 2)
|
||||
r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0
|
||||
|
||||
return {
|
||||
"continuum_cps": fit_cps,
|
||||
"peak_amplitudes": peak_amplitudes,
|
||||
"r_squared": float(r_squared),
|
||||
"residuals_rms": float(np.sqrt(np.mean(residuals ** 2))),
|
||||
}
|
||||
|
||||
|
||||
def calibrate_background(measured_cps, energy_axis):
|
||||
"""
|
||||
Fit the continuum model using smoothing spline.
|
||||
Returns both spline-based fit and parameters for the /fit endpoint.
|
||||
"""
|
||||
result = calibrate_spline(measured_cps, energy_axis)
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
# The spline result is the continuum CPS array
|
||||
return {
|
||||
"params": {}, # Non-parametric model, no params
|
||||
"continuum_cps": result["continuum_cps"],
|
||||
"peak_amplitudes": result["peak_amplitudes"],
|
||||
"r_squared": result["r_squared"],
|
||||
"residuals_rms": result["residuals_rms"],
|
||||
"method": "smoothing_spline_gcv",
|
||||
}
|
||||
|
||||
|
||||
def build_calibrated_continuum(energy_axis, total_counts, params):
|
||||
"""Build the continuum from calibrated parameters."""
|
||||
if "continuum_cps" in params:
|
||||
# Spline-based: already have the CPS array
|
||||
cps = np.array(params["continuum_cps"])
|
||||
if cps.sum() > 0:
|
||||
return cps * total_counts / cps.sum()
|
||||
return cps
|
||||
return np.zeros(len(energy_axis))
|
||||
|
||||
|
||||
# Cached calibration
|
||||
_cached_result = None
|
||||
_CALIBRATION_PATH = Path("/data/bg_calibration.json")
|
||||
|
||||
|
||||
def load_or_calibrate():
|
||||
"""Load cached calibration or fit from measured data."""
|
||||
global _cached_result
|
||||
|
||||
if _cached_result is not None:
|
||||
return _cached_result
|
||||
|
||||
if _CALIBRATION_PATH.exists():
|
||||
try:
|
||||
with open(_CALIBRATION_PATH) as f:
|
||||
_cached_result = json.load(f)
|
||||
return _cached_result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from app.config import BACKGROUND_PATH, BACKGROUND_SNAPSHOT_PATH
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
channels = np.arange(NUM_CHANNELS, dtype=np.float64)
|
||||
e_axis = ENERGY_OFFSET + ENERGY_SLOPE * channels
|
||||
measured_cps = measured_counts / live_time
|
||||
|
||||
result = calibrate_background(measured_cps, e_axis)
|
||||
|
||||
if "error" in result:
|
||||
return None
|
||||
|
||||
_cached_result = {
|
||||
"continuum_cps": [round(float(c), 6) for c in result["continuum_cps"]],
|
||||
"method": result["method"],
|
||||
"r_squared": result["r_squared"],
|
||||
}
|
||||
|
||||
_CALIBRATION_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _CALIBRATION_PATH.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(_cached_result, f, indent=2)
|
||||
tmp.replace(_CALIBRATION_PATH)
|
||||
|
||||
return _cached_result
|
||||
107
web/app/noise.py
Normal file
107
web/app/noise.py
Normal file
@ -0,0 +1,107 @@
|
||||
"""
|
||||
Detector-agnostic continuum extraction for gamma-ray spectra.
|
||||
|
||||
Extracts the detector's intrinsic response curve (continuum) from measured
|
||||
background data. Isotope photopeaks are subtracted, then the residual is
|
||||
smoothed to produce a clean continuum shape that reflects only the detector's
|
||||
physics — no isotope signatures.
|
||||
|
||||
Works with any scintillator or semiconductor detector.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import gaussian_filter1d
|
||||
|
||||
|
||||
# Common environmental isotope lines (keV) — subtracted regardless of detector.
|
||||
_ENV_PEAKS = [
|
||||
(241.0, 0.04),
|
||||
(295.22, 0.1842),
|
||||
(351.93, 0.3560),
|
||||
(609.31, 0.4549),
|
||||
(911.20, 0.2580),
|
||||
(1120.29, 0.1492),
|
||||
(1460.83, 0.1066),
|
||||
(1764.49, 0.1531),
|
||||
(2614.51, 0.3586),
|
||||
]
|
||||
|
||||
_E_OFFSET = 0.33
|
||||
_E_SLOPE = 2.97
|
||||
|
||||
|
||||
def _sigma_ch(E_keV):
|
||||
"""Peak sigma in channels at energy E_keV (sqrt(E) resolution scaling)."""
|
||||
fwhm_keV = 0.08 * E_keV * (E_keV / 662.0) ** 0.5
|
||||
sigma_keV = fwhm_keV / 2.355
|
||||
return max(sigma_keV / _E_SLOPE, 2.0)
|
||||
|
||||
|
||||
def _subtract_peaks(counts, energy_axis):
|
||||
"""Remove known isotope photopeaks from spectrum."""
|
||||
continuum = counts.copy()
|
||||
channels = np.arange(len(counts), dtype=np.float64)
|
||||
|
||||
for line_energy, _ in _ENV_PEAKS:
|
||||
idx = int(np.argmin(np.abs(energy_axis - line_energy)))
|
||||
if idx < 0 or idx >= len(counts):
|
||||
continue
|
||||
|
||||
sig = _sigma_ch(line_energy)
|
||||
far = int(5 * sig) + 3
|
||||
|
||||
lo_start = max(0, idx - far - int(3 * sig))
|
||||
lo_end = max(0, idx - far)
|
||||
hi_start = min(len(counts), idx + far)
|
||||
hi_end = min(len(counts), idx + far + int(3 * sig))
|
||||
|
||||
baseline_regions = []
|
||||
if lo_end > lo_start:
|
||||
baseline_regions.append(continuum[lo_start:lo_end])
|
||||
if hi_end > hi_start:
|
||||
baseline_regions.append(continuum[hi_start:hi_end])
|
||||
|
||||
if not baseline_regions:
|
||||
continue
|
||||
|
||||
local_bg = float(np.median(np.concatenate(baseline_regions)))
|
||||
peak_height = continuum[idx] - local_bg
|
||||
|
||||
if peak_height > 0:
|
||||
gaussian = peak_height * np.exp(-0.5 * ((channels - idx) / sig) ** 2)
|
||||
continuum -= gaussian
|
||||
|
||||
return np.maximum(continuum, 0)
|
||||
|
||||
|
||||
def extract_continuum(counts, energy_axis=None):
|
||||
"""Extract the detector's intrinsic response continuum.
|
||||
|
||||
Removes isotope photopeaks, then smooths with a wide Gaussian filter
|
||||
to produce a clean curve showing only the detector's continuum shape.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
counts : array
|
||||
Raw accumulated counts per channel.
|
||||
energy_axis : array, optional
|
||||
Energy axis in keV.
|
||||
|
||||
Returns
|
||||
-------
|
||||
array — smooth continuum (peak-subtracted, Gaussian-smoothed)
|
||||
"""
|
||||
counts = np.asarray(counts, dtype=np.float64)
|
||||
n_channels = len(counts)
|
||||
|
||||
if energy_axis is None:
|
||||
energy_axis = _E_OFFSET + _E_SLOPE * np.arange(n_channels, dtype=np.float64)
|
||||
|
||||
continuum = _subtract_peaks(counts, energy_axis)
|
||||
|
||||
# Wide Gaussian smooth (sigma ~1.5% of channels ≈ 45 keV)
|
||||
sigma = max(15, n_channels // 60)
|
||||
continuum_smooth = gaussian_filter1d(continuum, sigma=sigma)
|
||||
continuum_smooth = np.maximum(continuum_smooth, 0)
|
||||
|
||||
return continuum_smooth
|
||||
@ -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],
|
||||
}
|
||||
@ -1,139 +1,74 @@
|
||||
"""
|
||||
Theoretical natural background spectrum for CsI(Tl) detectors (Radiacode 103).
|
||||
CsI(Tl) detector response continuum for Radiacode 103.
|
||||
|
||||
Shape calibrated against real Radiacode 103 background measurements.
|
||||
The CsI(Tl) crystal (1 cm³, 8.4% FWHM) produces a spectrum with:
|
||||
- A dominant low-energy hump peaking around 100-120 keV
|
||||
- Exponential decay at higher energies
|
||||
- Subtle photopeaks from natural isotopes
|
||||
Models ONLY the detector's noise continuum. Photopeaks from environmental
|
||||
isotopes depend on measurement location and are NOT included.
|
||||
|
||||
Auto-calibrated from measured background using smoothing spline (GCV)
|
||||
when available. Falls back to a simple parametric model otherwise.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from app.config import ENERGY_OFFSET, ENERGY_SLOPE, NUM_CHANNELS
|
||||
|
||||
|
||||
# Photopeak lines: (energy_keV, relative_weight)
|
||||
# Weights tuned so peaks are visible above local continuum at typical CPS
|
||||
NATURAL_BG_LINES = [
|
||||
(295.22, 0.10), # Pb-214
|
||||
(351.93, 0.18), # Pb-214
|
||||
(609.31, 0.15), # Bi-214
|
||||
(911.20, 0.08), # Ac-228
|
||||
(968.97, 0.05), # Ac-228
|
||||
(1120.29, 0.06), # Bi-214
|
||||
(1460.83, 0.12), # K-40
|
||||
(1764.49, 0.08), # Bi-214
|
||||
(2614.51, 0.18), # Tl-208
|
||||
]
|
||||
|
||||
|
||||
def _gaussian(x, center, sigma, amplitude):
|
||||
return amplitude * np.exp(-0.5 * ((x - center) / sigma) ** 2)
|
||||
|
||||
|
||||
def generate_theoretical_bg(cps: float = 6.0, live_time_s: float = 3600.0):
|
||||
channels = np.arange(NUM_CHANNELS, dtype=np.float64)
|
||||
energy_axis = ENERGY_OFFSET + ENERGY_SLOPE * channels
|
||||
total_counts = cps * live_time_s
|
||||
|
||||
# ── 1. Main hump: asymmetric peak at ~105 keV ──
|
||||
# Real data: rises from ~60 at 10keV to ~280 at 100-120keV, then falls
|
||||
hump_center = 110.0
|
||||
hump = np.zeros(NUM_CHANNELS, dtype=np.float64)
|
||||
low_mask = energy_axis <= hump_center
|
||||
hump[low_mask] = _gaussian(energy_axis[low_mask], hump_center, 55.0, 1.0)
|
||||
hump[~low_mask] = _gaussian(energy_axis[~low_mask], hump_center, 50.0, 1.0)
|
||||
|
||||
# ── 2. Compton continuum tail ──
|
||||
# Real data: ~136@200, ~80@250, ~44@295, ~14@400, ~5@600
|
||||
tail = 0.45 * np.exp(-energy_axis / 240) + 0.04 * np.exp(-energy_axis / 700)
|
||||
|
||||
# ── 3. Low-energy noise floor ──
|
||||
noise_floor = 0.008
|
||||
|
||||
# ── 4. Combine continuum ──
|
||||
continuum = hump + tail + noise_floor
|
||||
|
||||
# ── 5. Photopeaks ──
|
||||
# CsI(Tl) 8.4% FWHM at 662 keV, scaling as sqrt(E)
|
||||
# sigma(E) = FWHM(E) / 2.355 = 0.084 * sqrt(E * 662) / 662 / 2.355
|
||||
# Simplified: sigma = 23.6 * sqrt(E/662) keV
|
||||
def sigma_keV(E):
|
||||
return max(12.0, 23.6 * np.sqrt(max(E, 1.0) / 662.0))
|
||||
|
||||
peak_frac = 0.08 # 8% of total counts in resolved photopeaks
|
||||
total_weight = sum(w for _, w in NATURAL_BG_LINES)
|
||||
|
||||
peaks = np.zeros(NUM_CHANNELS, dtype=np.float64)
|
||||
for line_energy, weight in NATURAL_BG_LINES:
|
||||
sig = sigma_keV(line_energy)
|
||||
peak_counts = total_counts * peak_frac * (weight / total_weight)
|
||||
amplitude = peak_counts / (sig * np.sqrt(2 * np.pi))
|
||||
peaks += _gaussian(energy_axis, line_energy, sig, amplitude)
|
||||
|
||||
# ── 6. Combine and normalize ──
|
||||
raw = continuum + peaks / total_counts # peaks normalized later
|
||||
raw *= total_counts / raw.sum()
|
||||
|
||||
# ── 7. Poisson-like noise ──
|
||||
rng = np.random.default_rng(42)
|
||||
noise = rng.normal(0, 1, NUM_CHANNELS) * np.sqrt(np.maximum(raw, 1.0)) * 0.25
|
||||
raw += noise
|
||||
|
||||
# Floor at 0.9 for log scale
|
||||
spectrum = np.clip(raw, 0.9, None)
|
||||
|
||||
key_lines = [
|
||||
(295.22, "Pb-214"), (351.93, "Pb-214"),
|
||||
(609.31, "Bi-214"), (911.20, "Ac-228"),
|
||||
(1120.29, "Bi-214"), (1460.83, "K-40"),
|
||||
(1764.49, "Bi-214"), (2614.51, "Tl-208"),
|
||||
]
|
||||
|
||||
return {
|
||||
"energy_kev": [round(float(E), 2) for E in energy_axis],
|
||||
"counts": [round(float(c), 1) for c in spectrum],
|
||||
"cps": round(cps, 2),
|
||||
"live_time_s": round(live_time_s, 1),
|
||||
"lines": [
|
||||
{"energy_keV": E, "name": name} for E, name in key_lines
|
||||
],
|
||||
}
|
||||
def _get_continuum_cps():
|
||||
"""Try to load calibrated spline continuum from measured data."""
|
||||
try:
|
||||
from app.bg_calibration import load_or_calibrate
|
||||
calibrated = load_or_calibrate()
|
||||
if calibrated and "continuum_cps" in calibrated:
|
||||
return np.array(calibrated["continuum_cps"])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def generate_continuum_only(cps: float = 6.0, live_time_s: float = 3600.0):
|
||||
"""Generate only the CsI(Tl) continuum shape (no photopeaks, no noise).
|
||||
|
||||
This matches the model used in training (generate_realistic_continuum in
|
||||
spectrum_physics.py) for direct comparison with measured backgrounds.
|
||||
"""
|
||||
"""Detector response continuum only (no photopeaks, no noise)."""
|
||||
channels = np.arange(NUM_CHANNELS, dtype=np.float64)
|
||||
energy_axis = ENERGY_OFFSET + ENERGY_SLOPE * channels
|
||||
total_counts = cps * live_time_s
|
||||
|
||||
# Asymmetric hump at ~110 keV
|
||||
hump_center = 110.0
|
||||
hump = np.where(
|
||||
energy_axis <= hump_center,
|
||||
np.exp(-0.5 * ((energy_axis - hump_center) / 55.0) ** 2),
|
||||
np.exp(-0.5 * ((energy_axis - hump_center) / 50.0) ** 2),
|
||||
)
|
||||
# Try calibrated spline first
|
||||
continuum_cps = _get_continuum_cps()
|
||||
|
||||
# Compton continuum tail
|
||||
tail = 0.45 * np.exp(-energy_axis / 240.0) + 0.04 * np.exp(-energy_axis / 700.0)
|
||||
|
||||
# Noise floor
|
||||
noise_floor = 0.008
|
||||
|
||||
continuum = hump + tail + noise_floor
|
||||
|
||||
# Normalize to target total counts
|
||||
if continuum.sum() > 0 and total_counts > 0:
|
||||
continuum *= total_counts / continuum.sum()
|
||||
if continuum_cps is not None and len(continuum_cps) == NUM_CHANNELS:
|
||||
# Scale calibrated CPS to match requested total counts
|
||||
continuum = continuum_cps.copy()
|
||||
if continuum.sum() > 0:
|
||||
continuum *= total_counts / continuum.sum()
|
||||
else:
|
||||
# Fallback: simple parametric model
|
||||
continuum = _fallback_continuum(energy_axis, total_counts)
|
||||
|
||||
return {
|
||||
"energy_kev": [round(float(E), 2) for E in energy_axis],
|
||||
"counts": [round(float(c), 1) for c in continuum],
|
||||
"cps": round(cps, 2),
|
||||
"live_time_s": round(live_time_s, 1),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _fallback_continuum(energy_axis, total_counts):
|
||||
"""Simple parametric fallback when no measured data available."""
|
||||
E = energy_axis
|
||||
|
||||
# Asymmetric hump
|
||||
hump_center, sigma_left, tail_decay_right = 110.0, 40.0, 100.0
|
||||
left = np.exp(-0.5 * ((E - hump_center) / sigma_left) ** 2)
|
||||
right = np.exp(-(E - hump_center) / tail_decay_right)
|
||||
hump = np.where(E <= hump_center, left, right)
|
||||
|
||||
# Housing absorption
|
||||
absorption = 1.0 * (1.0 - np.exp(-E / 20.0))
|
||||
|
||||
# Compton tail
|
||||
compton = 0.5 / (np.maximum(E, 1.0) + 15.0) ** 1.3
|
||||
|
||||
continuum = (hump + compton) * absorption
|
||||
|
||||
if continuum.sum() > 0 and total_counts > 0:
|
||||
continuum *= total_counts / continuum.sum()
|
||||
|
||||
return continuum
|
||||
@ -1,3 +1,4 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
numpy>=1.24.0
|
||||
numpy>=1.24.0
|
||||
scipy>=1.10.0
|
||||
@ -78,6 +78,8 @@ main { padding: 16px; }
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
height: 450px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.controls {
|
||||
@ -108,6 +110,63 @@ main { padding: 16px; }
|
||||
|
||||
.controls button:hover { background: var(--accent-bright); color: #000; }
|
||||
|
||||
.btn-small {
|
||||
background: var(--accent);
|
||||
color: var(--text);
|
||||
border: 1px solid #444;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
margin-left: auto;
|
||||
}
|
||||
.btn-small:hover { background: var(--accent-bright); color: #000; }
|
||||
|
||||
.chart-container.fullscreen {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg);
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chart-container.fullscreen canvas {
|
||||
flex: 1;
|
||||
}
|
||||
.exit-fullscreen-btn {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 14px;
|
||||
z-index: 1001;
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 1.2em;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.chart-container.fullscreen .exit-fullscreen-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.exit-fullscreen-btn:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#isotopes-table, #peaks-table {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=2">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1/dist/chartjs-plugin-annotation.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@ -38,6 +40,7 @@
|
||||
<label id="lines-detected-label" style="display:none"><input type="checkbox" id="lines-detected-only" checked> Détectés uniquement</label>
|
||||
<label><input type="checkbox" id="show-bg-overlay"> Overlay background</label>
|
||||
<button id="download-csv" class="btn-small">CSV</button>
|
||||
<button id="reset-zoom-spectrum" class="btn-small" style="display:none" title="Réinitialiser le zoom">↺</button>
|
||||
<button id="fullscreen-btn" class="btn-small" title="Plein écran">⛶</button>
|
||||
</div>
|
||||
<div id="isotopes-table"></div>
|
||||
@ -51,9 +54,10 @@
|
||||
<div class="bg-stats" id="bg-stats"></div>
|
||||
<div class="chart-header">
|
||||
<label style="font-size:0.85em;color:#888;display:flex;align-items:center;gap:4px"><input type="checkbox" id="show-bg-smooth" checked> Lissé</label>
|
||||
<label style="font-size:0.85em;color:#888;display:flex;align-items:center;gap:4px"><input type="checkbox" id="show-bg-theoretical"> Théorique</label>
|
||||
<label style="font-size:0.85em;color:#888;display:flex;align-items:center;gap:4px"><input type="checkbox" id="show-bg-continuum"> Continuum CsI</label>
|
||||
<label style="font-size:0.85em;color:#888;display:flex;align-items:center;gap:4px"><input type="checkbox" id="show-bg-continuum"> Continuum</label>
|
||||
<label style="display:none;font-size:0.85em;color:#888"><input type="checkbox" id="show-bg-reference"> Ref 24h</label>
|
||||
<label style="font-size:0.85em;color:#888;display:flex;align-items:center;gap:4px"><input type="checkbox" id="bg-scale-log" checked> Log</label>
|
||||
<button id="reset-zoom-bg" class="btn-small" style="display:none" title="Réinitialiser le zoom">↺</button>
|
||||
<button class="btn-small fullscreen-btn" title="Plein écran">⛶</button>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
@ -69,6 +73,7 @@
|
||||
<button onclick="loadCps(6)">6h</button>
|
||||
<button onclick="loadCps(24)">24h</button>
|
||||
<button onclick="loadCps(168)">7j</button>
|
||||
<button id="reset-zoom-cps" class="btn-small" style="display:none" title="Réinitialiser le zoom">↺</button>
|
||||
<button class="btn-small fullscreen-btn" title="Plein écran">⛶</button>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
@ -78,11 +83,11 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/isotope_lines.js?v=2"></script>
|
||||
<script src="/static/js/spectrum.js?v=2"></script>
|
||||
<script src="/static/js/isotope_lines.js?v=3"></script>
|
||||
<script src="/static/js/spectrum.js?v=6"></script>
|
||||
<script src="/static/js/history.js?v=2"></script>
|
||||
<script src="/static/js/background.js?v=3"></script>
|
||||
<script src="/static/js/cps.js?v=2"></script>
|
||||
<script src="/static/js/app.js?v=2"></script>
|
||||
<script src="/static/js/background.js?v=11"></script>
|
||||
<script src="/static/js/cps.js?v=5"></script>
|
||||
<script src="/static/js/app.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -28,9 +28,13 @@ async function refreshStatus() {
|
||||
}
|
||||
const data = await resp.json();
|
||||
const dot = document.getElementById('status-connected');
|
||||
dot.className = data.connected && !data.stale ? 'status-dot connected' : 'status-dot';
|
||||
const ok = data.connected && !data.stale;
|
||||
dot.className = ok ? '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`;
|
||||
document.title = ok
|
||||
? `${data.cps.toFixed(1)} CPS · ${data.cumulated_live_time_h.toFixed(1)}h`
|
||||
: 'Hors ligne';
|
||||
} catch {
|
||||
document.getElementById('status-connected').className = 'status-dot';
|
||||
}
|
||||
@ -47,5 +51,41 @@ function startRefresh() {
|
||||
}, REFRESH_MS);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
startRefresh();
|
||||
// Isotope lines toggle — show/hide "detected only" checkbox
|
||||
document.getElementById('show-isotope-lines').addEventListener('change', (e) => {
|
||||
document.getElementById('lines-detected-label').style.display = e.target.checked ? 'flex' : 'none';
|
||||
if (!e.target.checked) refreshSpectrum();
|
||||
});
|
||||
|
||||
// Fullscreen toggle for charts
|
||||
function exitFullscreen() {
|
||||
document.querySelectorAll('.chart-container.fullscreen').forEach(c => c.classList.remove('fullscreen'));
|
||||
document.querySelectorAll('.fullscreen-btn, #fullscreen-btn').forEach(btn => btn.innerHTML = '⛶');
|
||||
setTimeout(() => window.dispatchEvent(new Event('resize')), 100);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.fullscreen-btn, #fullscreen-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const container = btn.closest('section').querySelector('.chart-container');
|
||||
if (container.classList.contains('fullscreen')) {
|
||||
exitFullscreen();
|
||||
} else {
|
||||
container.classList.add('fullscreen');
|
||||
btn.innerHTML = '✕';
|
||||
setTimeout(() => window.dispatchEvent(new Event('resize')), 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Exit fullscreen buttons inside chart containers
|
||||
document.querySelectorAll('.exit-fullscreen-btn').forEach(btn => {
|
||||
btn.addEventListener('click', exitFullscreen);
|
||||
});
|
||||
|
||||
// ESC to exit fullscreen
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') exitFullscreen();
|
||||
});
|
||||
|
||||
// Initialize — called after all scripts are loaded
|
||||
window.addEventListener('load', startRefresh);
|
||||
@ -1,6 +1,5 @@
|
||||
let bgChart = null;
|
||||
let bgReferenceData = null;
|
||||
let bgTheoreticalData = null;
|
||||
let bgContinuumData = null;
|
||||
|
||||
async function loadBgReference() {
|
||||
@ -11,14 +10,6 @@ async function loadBgReference() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadBgTheoretical(cps, liveTime) {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/background/theoretical?cps=${cps}&live_time_s=${liveTime}`);
|
||||
if (!resp.ok) return;
|
||||
bgTheoreticalData = await resp.json();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadBgContinuum(cps, liveTime) {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/background/continuum?cps=${cps}&live_time_s=${liveTime}`);
|
||||
@ -30,7 +21,7 @@ async function loadBgContinuum(cps, liveTime) {
|
||||
/**
|
||||
* Gaussian kernel smoothing.
|
||||
* Convolves the data with a Gaussian kernel of given sigma (in channels).
|
||||
* Preserves peak shapes while removing statistical noise.
|
||||
* Preserves peak shapes while reducing statistical variation.
|
||||
*/
|
||||
function smoothGaussian(data, sigma) {
|
||||
if (!data || data.length === 0) return data;
|
||||
@ -79,12 +70,7 @@ async function refreshBackground() {
|
||||
<div class="bg-stat"><div class="bg-stat-value">${info.cps.toFixed(2)}</div><div class="bg-stat-label">CPS</div></div>
|
||||
`;
|
||||
|
||||
// Load theoretical curve on first load
|
||||
if (!bgTheoreticalData && spec.live_time_s > 0) {
|
||||
await loadBgTheoretical(info.cps || 6.0, spec.live_time_s);
|
||||
}
|
||||
|
||||
// Load CsI(Tl) continuum on first load
|
||||
// Load continuum on first load
|
||||
if (!bgContinuumData && spec.live_time_s > 0) {
|
||||
await loadBgContinuum(info.cps || 6.0, spec.live_time_s);
|
||||
}
|
||||
@ -102,9 +88,9 @@ async function refreshBackground() {
|
||||
}
|
||||
|
||||
function updateBackgroundChart(spec) {
|
||||
const showLog = document.getElementById('bg-scale-log')?.checked;
|
||||
const ctx = document.getElementById('background-chart').getContext('2d');
|
||||
const showRef = document.getElementById('show-bg-reference')?.checked && bgReferenceData;
|
||||
const showTheory = document.getElementById('show-bg-theoretical')?.checked && bgTheoreticalData;
|
||||
const showSmooth = document.getElementById('show-bg-smooth')?.checked;
|
||||
const showContinuum = document.getElementById('show-bg-continuum')?.checked && bgContinuumData;
|
||||
|
||||
@ -119,8 +105,6 @@ function updateBackgroundChart(spec) {
|
||||
}];
|
||||
|
||||
if (showSmooth) {
|
||||
// Smoothed version of live data — sigma=8 channels (~24 keV)
|
||||
// Wide enough to remove noise, narrow enough to preserve the 100 keV peak
|
||||
const smoothed = smoothGaussian(spec.counts, 8);
|
||||
datasets.push({
|
||||
label: 'Lissé',
|
||||
@ -133,22 +117,9 @@ function updateBackgroundChart(spec) {
|
||||
});
|
||||
}
|
||||
|
||||
if (showTheory) {
|
||||
datasets.push({
|
||||
label: 'Théorique',
|
||||
data: bgTheoreticalData.counts,
|
||||
borderColor: 'rgba(76, 175, 80, 0.7)',
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.05)',
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
fill: true,
|
||||
borderDash: [6, 3],
|
||||
});
|
||||
}
|
||||
|
||||
if (showContinuum) {
|
||||
datasets.push({
|
||||
label: 'Continuum CsI(Tl)',
|
||||
label: 'Continuum',
|
||||
data: bgContinuumData.counts,
|
||||
borderColor: 'rgba(156, 39, 176, 0.8)',
|
||||
backgroundColor: 'rgba(156, 39, 176, 0.05)',
|
||||
@ -183,13 +154,33 @@ function updateBackgroundChart(spec) {
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { labels: { color: '#e0e0e0' } },
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
filter: (item) => item.raw != null,
|
||||
callbacks: {
|
||||
title: (items) => `${spec.energy_kev[items[0].dataIndex]} keV`,
|
||||
label: (item) => `${item.dataset.label}: ${item.raw.toFixed(1)} counts`
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: 'x',
|
||||
modifierKey: null,
|
||||
},
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
drag: { enabled: false },
|
||||
mode: 'x',
|
||||
limits: { x: { min: 30, max: 3000 } },
|
||||
onZoom: () => { document.getElementById('reset-zoom-bg').style.display = 'inline-block'; }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
@ -200,21 +191,21 @@ function updateBackgroundChart(spec) {
|
||||
grid: { color: '#333' },
|
||||
},
|
||||
y: {
|
||||
type: 'logarithmic',
|
||||
title: { display: true, text: 'Comptages (log)', color: '#888' },
|
||||
min: 0.9,
|
||||
type: showLog ? 'logarithmic' : 'linear',
|
||||
title: { display: true, text: `Comptages (${showLog ? 'log' : 'lin'})`, color: '#888' },
|
||||
...(showLog ? { min: 0.9 } : {}),
|
||||
ticks: { color: '#888' },
|
||||
grid: { color: '#333' },
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (bgChart) {
|
||||
if (bgChart) {
|
||||
bgChart.data = chartData;
|
||||
bgChart.options = options;
|
||||
bgChart.update();
|
||||
} else {
|
||||
bgChart = new Chart(ctx, { type: 'line', data: chartData, options });
|
||||
bgChart = new Chart(ctx, { type: 'line', data: chartData, ...options });
|
||||
}
|
||||
}
|
||||
|
||||
@ -242,19 +233,23 @@ document.querySelector('[data-tab="background"]').addEventListener('click', () =
|
||||
|
||||
// Toggle handlers
|
||||
document.getElementById('show-bg-reference')?.addEventListener('change', () => refreshBackground());
|
||||
document.getElementById('show-bg-theoretical')?.addEventListener('change', () => {
|
||||
if (document.getElementById('show-bg-theoretical').checked && !bgTheoreticalData) {
|
||||
loadBgTheoretical(6.0, 3600).then(() => refreshBackground());
|
||||
} else {
|
||||
refreshBackground();
|
||||
}
|
||||
});
|
||||
document.getElementById('show-bg-continuum')?.addEventListener('change', () => {
|
||||
if (document.getElementById('show-bg-continuum').checked && !bgContinuumData) {
|
||||
const info = document.getElementById('bg-stats');
|
||||
loadBgContinuum(6.0, 3600).then(() => refreshBackground());
|
||||
} else {
|
||||
refreshBackground();
|
||||
}
|
||||
});
|
||||
document.getElementById('show-bg-smooth')?.addEventListener('change', () => refreshBackground());
|
||||
document.getElementById('show-bg-smooth')?.addEventListener('change', () => refreshBackground());
|
||||
document.getElementById('bg-scale-log')?.addEventListener('change', () => refreshBackground());
|
||||
|
||||
// Reset zoom button
|
||||
document.getElementById('reset-zoom-bg')?.addEventListener('click', () => {
|
||||
if (bgChart) {
|
||||
bgChart.resetZoom();
|
||||
document.getElementById('reset-zoom-bg').style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Show/hide reset button based on zoom state — wrapped via options plugin
|
||||
const _origBgOptions = options => options;
|
||||
@ -44,8 +44,36 @@ function updateCpsChart(labels, values) {
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { labels: { color: '#e0e0e0' } },
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
filter: (item) => item.raw != null,
|
||||
callbacks: {
|
||||
title: (items) => {
|
||||
const d = new Date(items[0].parsed.x / 1000);
|
||||
return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
},
|
||||
label: (item) => `${item.dataset.label}: ${item.raw.toFixed(2)}`
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: 'x',
|
||||
modifierKey: null,
|
||||
},
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
drag: { enabled: false },
|
||||
mode: 'x',
|
||||
onZoom: () => { document.getElementById('reset-zoom-cps').style.display = 'inline-block'; }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
@ -76,8 +104,16 @@ function updateCpsChart(labels, values) {
|
||||
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 });
|
||||
cpsChart = new Chart(ctx, { type: 'line', data: chartData, ...options });
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset zoom
|
||||
document.getElementById('reset-zoom-cps')?.addEventListener('click', () => {
|
||||
if (cpsChart) {
|
||||
cpsChart.resetZoom();
|
||||
document.getElementById('reset-zoom-cps').style.display = 'none';
|
||||
}
|
||||
});
|
||||
122
web/static/js/isotope_lines.js
Normal file
122
web/static/js/isotope_lines.js
Normal file
@ -0,0 +1,122 @@
|
||||
// Raies gamma principales pour les isotopes les plus courants
|
||||
// Format : { isotope, energy_keV, intensity } (intensity = % de désintégration)
|
||||
const ISOTOPE_LINES = [
|
||||
// Chaîne Uranium-238 (présent naturellement)
|
||||
{ isotope: "Bi-214", energy_keV: 609.3, intensity: 45.5 },
|
||||
{ isotope: "Bi-214", energy_keV: 1120.3, intensity: 15.0 },
|
||||
{ isotope: "Bi-214", energy_keV: 1764.5, intensity: 15.4 },
|
||||
{ isotope: "Pb-214", energy_keV: 295.2, intensity: 19.2 },
|
||||
{ isotope: "Pb-214", energy_keV: 351.9, intensity: 37.6 },
|
||||
{ isotope: "Ra-226", energy_keV: 186.2, intensity: 3.6 },
|
||||
|
||||
// Chaîne Thorium-232 (présent naturellement)
|
||||
{ isotope: "Ac-228", energy_keV: 911.2, intensity: 27.7 },
|
||||
{ isotope: "Ac-228", energy_keV: 338.3, intensity: 11.3 },
|
||||
{ isotope: "Tl-208", energy_keV: 583.2, intensity: 84.5 },
|
||||
{ isotope: "Tl-208", energy_keV: 2614.5, intensity: 99.0 },
|
||||
{ isotope: "Pb-212", energy_keV: 238.6, intensity: 43.6 },
|
||||
|
||||
// Potassium-40 (naturel, ubiquitaire)
|
||||
{ isotope: "K-40", energy_keV: 1460.8, intensity: 10.7 },
|
||||
|
||||
// Isotopes artificiels courants
|
||||
{ isotope: "Cs-137", energy_keV: 661.7, intensity: 85.1 },
|
||||
{ isotope: "Cs-134", energy_keV: 604.7, intensity: 97.6 },
|
||||
{ isotope: "Cs-134", energy_keV: 795.8, intensity: 85.5 },
|
||||
{ isotope: "Co-60", energy_keV: 1173.2, intensity: 99.9 },
|
||||
{ isotope: "Co-60", energy_keV: 1332.5, intensity: 100.0 },
|
||||
{ isotope: "Co-58", energy_keV: 810.8, intensity: 99.4 },
|
||||
{ isotope: "I-131", energy_keV: 364.5, intensity: 81.7 },
|
||||
{ isotope: "I-131", energy_keV: 637.0, intensity: 7.3 },
|
||||
{ isotope: "I-131", energy_keV: 284.3, intensity: 6.1 },
|
||||
{ isotope: "Ba-133", energy_keV: 356.0, intensity: 62.0 },
|
||||
{ isotope: "Ir-192", energy_keV: 316.5, intensity: 82.8 },
|
||||
{ isotope: "Ir-192", energy_keV: 468.1, intensity: 47.8 },
|
||||
{ isotope: "Ir-192", energy_keV: 604.4, intensity: 8.2 },
|
||||
{ isotope: "Am-241", energy_keV: 59.5, intensity: 35.9 },
|
||||
|
||||
// Autres courants
|
||||
{ isotope: "Na-22", energy_keV: 511.0, intensity: 90.0 },
|
||||
{ isotope: "Na-22", energy_keV: 1274.5, intensity: 99.9 },
|
||||
{ isotope: "Eu-152", energy_keV: 121.8, intensity: 28.6 },
|
||||
{ isotope: "Eu-152", energy_keV: 344.3, intensity: 26.6 },
|
||||
{ isotope: "Eu-152", energy_keV: 1408.0, intensity: 20.9 },
|
||||
{ isotope: "Mn-54", energy_keV: 834.8, intensity: 99.9 },
|
||||
{ isotope: "Zn-65", energy_keV: 1115.5, intensity: 50.0 },
|
||||
{ isotope: "Zr-95/Nb-95", energy_keV: 765.8, intensity: 99.8 },
|
||||
{ isotope: "Ru-106/Rh-106", energy_keV: 512.0, intensity: 20.5 },
|
||||
];
|
||||
|
||||
// Filtrer les lignes dans la plage visible du détecteur (30-3050 keV pour Radiacode 103)
|
||||
const VISIBLE_LINES = ISOTOPE_LINES.filter(l => l.energy_keV >= 30 && l.energy_keV <= 3050);
|
||||
|
||||
// Global crosshair plugin — vertical dashed line on hover for all charts
|
||||
const CrosshairPlugin = {
|
||||
id: 'crosshair',
|
||||
afterDraw(chart) {
|
||||
const tooltip = chart.tooltip;
|
||||
if (!tooltip || !tooltip._active || tooltip._active.length === 0) return;
|
||||
const x = tooltip._active[0].element.x;
|
||||
const { top, bottom } = chart.chartArea;
|
||||
const ctx = chart.ctx;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, top);
|
||||
ctx.lineTo(x, bottom);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
Chart.register(CrosshairPlugin);
|
||||
|
||||
// Couleurs par catégorie d'isotope
|
||||
function isotopeLineColor(isotope) {
|
||||
if (["K-40", "Bi-214", "Pb-214", "Ra-226"].includes(isotope)) return "rgba(255,152,0,0.5)"; // Uranium chain - orange
|
||||
if (["Ac-228", "Tl-208", "Pb-212"].includes(isotope)) return "rgba(156,39,176,0.5)"; // Thorium chain - purple
|
||||
if (isotope === "K-40") return "rgba(255,193,7,0.5)"; // K-40 - yellow
|
||||
if (isotope.startsWith("Cs") || isotope.startsWith("I-131")) return "rgba(244,67,54,0.5)"; // Fission products - red
|
||||
if (isotope.startsWith("Co")) return "rgba(76,175,80,0.5)"; // Activation - green
|
||||
return "rgba(100,181,246,0.35)"; // Others - light blue
|
||||
}
|
||||
|
||||
function isotopeLabelColor(isotope) {
|
||||
if (["K-40", "Bi-214", "Pb-214", "Ra-226"].includes(isotope)) return "#ff9800";
|
||||
if (["Ac-228", "Tl-208", "Pb-212"].includes(isotope)) return "#9c27b0";
|
||||
if (isotope === "K-40") return "#ffc107";
|
||||
if (isotope.startsWith("Cs") || isotope.startsWith("I-131")) return "#f44336";
|
||||
if (isotope.startsWith("Co")) return "#4caf50";
|
||||
return "#64b5f6";
|
||||
}
|
||||
|
||||
// Créer les annotations Chart.js pour les raies isotopiques
|
||||
function buildIsotopeAnnotations(showDetectedOnly, detectedIsotopes) {
|
||||
const annotations = {};
|
||||
const detected = new Set(detectedIsotopes || []);
|
||||
|
||||
VISIBLE_LINES.forEach((line, i) => {
|
||||
if (showDetectedOnly && !detected.has(line.isotope)) return;
|
||||
// Regrouper les labels pour éviter les chevauchements
|
||||
annotations[`line_${i}`] = {
|
||||
type: 'line',
|
||||
xMin: line.energy_keV,
|
||||
xMax: line.energy_keV,
|
||||
borderColor: isotopeLineColor(line.isotope),
|
||||
borderWidth: 1,
|
||||
label: {
|
||||
display: true,
|
||||
content: `${line.isotope} ${line.energy_keV}`,
|
||||
position: 'start',
|
||||
backgroundColor: 'rgba(26,26,46,0.85)',
|
||||
color: isotopeLabelColor(line.isotope),
|
||||
font: { size: 9 },
|
||||
padding: 2,
|
||||
rotation: -90,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return annotations;
|
||||
}
|
||||
@ -17,34 +17,83 @@ async function refreshSpectrum() {
|
||||
|
||||
function updateSpectrumChart(data) {
|
||||
const logScale = document.getElementById('log-scale').checked;
|
||||
const showLines = document.getElementById('show-isotope-lines').checked;
|
||||
const detectedOnly = document.getElementById('lines-detected-only').checked;
|
||||
const showBgOverlay = document.getElementById('show-bg-overlay').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)',
|
||||
const 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,
|
||||
tension: 0.1,
|
||||
}];
|
||||
|
||||
// Overlay background if requested and available
|
||||
if (showBgOverlay && bgOverlayData) {
|
||||
datasets.push({
|
||||
label: 'Background',
|
||||
data: bgOverlayData.counts,
|
||||
borderColor: 'rgba(255, 152, 0, 0.6)',
|
||||
backgroundColor: 'rgba(255, 152, 0, 0.05)',
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
fill: true,
|
||||
}]
|
||||
tension: 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
labels: data.energy_kev,
|
||||
datasets: datasets,
|
||||
};
|
||||
|
||||
// Annotations
|
||||
let annotations = {};
|
||||
if (showLines) {
|
||||
annotations = buildIsotopeAnnotations(detectedOnly, (data.isotopes_detected || []).map(i => i.isotope));
|
||||
}
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 300 },
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { labels: { color: '#e0e0e0' } },
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
filter: (item) => item.raw != null,
|
||||
callbacks: {
|
||||
title: (items) => {
|
||||
const idx = items[0].dataIndex;
|
||||
return `${data.energy_kev[idx]} keV`;
|
||||
},
|
||||
label: (item) => `${item.raw.toFixed(1)} counts`
|
||||
label: (item) => `${item.dataset.label}: ${item.raw.toFixed(1)} counts`
|
||||
}
|
||||
},
|
||||
annotation: {
|
||||
annotations: annotations
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: 'x',
|
||||
modifierKey: null,
|
||||
},
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
drag: { enabled: false },
|
||||
mode: 'x',
|
||||
limits: { x: { min: 30, max: 3000 } },
|
||||
onZoom: () => { document.getElementById('reset-zoom-spectrum').style.display = 'inline-block'; }
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -57,7 +106,8 @@ function updateSpectrumChart(data) {
|
||||
},
|
||||
y: {
|
||||
type: logScale ? 'logarithmic' : 'linear',
|
||||
title: { display: true, text: 'Comptages', color: '#888' },
|
||||
title: { display: true, text: logScale ? 'Comptages (log)' : 'Comptages', color: '#888' },
|
||||
min: logScale ? 0.9 : undefined,
|
||||
ticks: { color: '#888' },
|
||||
grid: { color: '#333' },
|
||||
}
|
||||
@ -69,7 +119,7 @@ function updateSpectrumChart(data) {
|
||||
spectrumChart.options = options;
|
||||
spectrumChart.update();
|
||||
} else {
|
||||
spectrumChart = new Chart(ctx, { type: 'line', data: chartData, options });
|
||||
spectrumChart = new Chart(ctx, { type: 'line', data: chartData, ...options });
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,6 +142,48 @@ function updateIsotopesTable(isotopes) {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
let bgOverlayData = null;
|
||||
|
||||
async function loadBgOverlay() {
|
||||
if (bgOverlayData) return;
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/background/spectrum`);
|
||||
if (!resp.ok) return;
|
||||
bgOverlayData = await resp.json();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('show-difference').addEventListener('change', refreshSpectrum);
|
||||
document.getElementById('log-scale').addEventListener('change', refreshSpectrum);
|
||||
document.getElementById('log-scale').addEventListener('change', refreshSpectrum);
|
||||
document.getElementById('show-isotope-lines').addEventListener('change', refreshSpectrum);
|
||||
document.getElementById('lines-detected-only').addEventListener('change', refreshSpectrum);
|
||||
document.getElementById('show-bg-overlay').addEventListener('change', async (e) => {
|
||||
if (e.target.checked) await loadBgOverlay();
|
||||
refreshSpectrum();
|
||||
});
|
||||
|
||||
// Reset zoom
|
||||
document.getElementById('reset-zoom-spectrum')?.addEventListener('click', () => {
|
||||
if (spectrumChart) {
|
||||
spectrumChart.resetZoom();
|
||||
document.getElementById('reset-zoom-spectrum').style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Download CSV
|
||||
document.getElementById('download-csv').addEventListener('click', () => {
|
||||
if (!currentSpectrumData) return;
|
||||
const header = 'energy_keV,counts';
|
||||
const rows = currentSpectrumData.energy_kev.map((e, i) =>
|
||||
`${e},${currentSpectrumData.counts[i]}`
|
||||
);
|
||||
const csv = [header, ...rows].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `spectrum_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
Reference in New Issue
Block a user