Root cause of Am-241 misidentification: the Radiacode 103's CsI(Tl) crystal shifts low-energy peaks upward (59.5 keV → 71.6 keV for Am-241) due to non-proportional scintillation response. The model was trained on theoretical peak positions and couldn't match the shifted real peaks. Changes: - Add inverse CsI(Tl) non-linear correction to inference pipeline (radiacode_monitor.py, web/config.py, test_detection.py) E_apparent = E_true * (1 + 0.37 * exp(-E_true/100)) Corrects channel mapping so peaks appear at theoretical energies - Fix energy calibration: DetectorConfig now uses E = 0.33 + 2.97*ch with 1023 channels, matching the real detector (was energy_min=20, skip_first_channel=True, different channel width) - Add K-escape peaks for CsI(Tl) iodine X-ray escape (E - 28.5 keV) - Add asymmetric peak shapes for low-energy tails (< 200 keV) - Add log1p normalization in dataset and inference (replaces max-norm) - Add background-subtracted training mode (subtract_background flag) - Add low-signal augmentation (0.01-5 Bq activities, 30-300s durations) - Update docker-compose.yml: batch_size=32, duration=30-300s, CSI_NONLINEAR_ALPHA/BETA env vars for detect and web - Web dashboard: apply CsI correction to displayed spectra - Various UI fixes (Chart.js width, zoom/pan, isotope lines) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
191 lines
6.6 KiB
Python
191 lines
6.6 KiB
Python
import json
|
|
from fastapi import APIRouter, HTTPException
|
|
from app.config import BACKGROUND_SNAPSHOT_PATH, BACKGROUND_PATH, energy_axis, NUM_CHANNELS, ENERGY_OFFSET, ENERGY_SLOPE, clip_to_range
|
|
from app.theoretical_bg import generate_continuum_only
|
|
from app.noise import extract_continuum
|
|
import numpy as np
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _load_snapshot():
|
|
"""Load the live snapshot file, or raise 404."""
|
|
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:
|
|
return json.load(f)
|
|
except (json.JSONDecodeError, OSError):
|
|
raise HTTPException(status_code=500, detail="Background snapshot file corrupt")
|
|
|
|
|
|
def _load_reference():
|
|
"""Load the 24h reference background, or return None."""
|
|
if not BACKGROUND_PATH.exists():
|
|
return None
|
|
try:
|
|
bg_data = np.load(str(BACKGROUND_PATH), allow_pickle=True).item()
|
|
raw_counts = [round(float(c), 1) for c in bg_data["counts"][:NUM_CHANNELS]]
|
|
return {
|
|
"counts": clip_to_range(raw_counts),
|
|
"live_time_s": round(float(bg_data["duration"]), 1),
|
|
}
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
@router.get("")
|
|
async def get_background_info():
|
|
"""Background metadata: elapsed time, CPS, top peaks."""
|
|
snapshot = _load_snapshot()
|
|
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():
|
|
"""Live background spectrum (from snapshot) with energy axis."""
|
|
snapshot = _load_snapshot()
|
|
live_time = snapshot.get("live_time_s", 0)
|
|
raw_spectrum = snapshot.get("spectrum", [0] * 1024)[:NUM_CHANNELS]
|
|
|
|
return {
|
|
"channels": clip_to_range(list(range(NUM_CHANNELS))),
|
|
"energy_kev": energy_axis(),
|
|
"counts": clip_to_range(raw_spectrum),
|
|
"live_time_s": live_time,
|
|
"cps": snapshot.get("cps", 0),
|
|
"top_peaks": snapshot.get("top_peaks", []),
|
|
"reference_available": BACKGROUND_PATH.exists(),
|
|
}
|
|
|
|
|
|
@router.get("/reference")
|
|
async def get_background_reference():
|
|
"""24h reference background spectrum for overlay comparison."""
|
|
ref = _load_reference()
|
|
if ref is None:
|
|
raise HTTPException(status_code=404, detail="No 24h reference background available")
|
|
|
|
return {
|
|
"channels": clip_to_range(list(range(NUM_CHANNELS))),
|
|
"energy_kev": energy_axis(),
|
|
"counts": ref["counts"],
|
|
"live_time_s": ref["live_time_s"],
|
|
}
|
|
|
|
|
|
@router.get("/continuum")
|
|
async def get_continuum(cps: float = 6.0, live_time_s: float = 3600.0):
|
|
"""CsI(Tl) detector response continuum only (no photopeaks, no noise)."""
|
|
raw = generate_continuum_only(cps=cps, live_time_s=live_time_s)
|
|
raw["energy_kev"] = clip_to_range(raw["energy_kev"])
|
|
raw["counts"] = clip_to_range(raw["counts"])
|
|
return raw
|
|
|
|
|
|
@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.
|
|
"""
|
|
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)
|
|
|
|
e_list = [round(float(E), 2) for E in e_axis]
|
|
m_list = [round(float(c), 1) for c in measured_counts]
|
|
f_list = [round(float(c), 1) for c in fitted_counts]
|
|
|
|
return {
|
|
"energy_kev": clip_to_range(e_list),
|
|
"measured_counts": clip_to_range(m_list),
|
|
"fitted_counts": clip_to_range(f_list),
|
|
"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)
|
|
|
|
e_list = [round(float(E), 2) for E in e_axis]
|
|
c_list = [round(float(c), 1) for c in continuum]
|
|
return {
|
|
"energy_kev": clip_to_range(e_list),
|
|
"counts": clip_to_range(c_list),
|
|
} |