Fix: CsI(Tl) non-linear response correction + detector calibration overhaul

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>
This commit is contained in:
Jacquin Antoine
2026-05-21 17:35:22 +02:00
parent 3b4446b181
commit 0847a3fc80
21 changed files with 913 additions and 278 deletions

View File

@ -1,6 +1,6 @@
import json
from fastapi import APIRouter, HTTPException
from app.config import BACKGROUND_SNAPSHOT_PATH, BACKGROUND_PATH, energy_axis, NUM_CHANNELS, ENERGY_OFFSET, ENERGY_SLOPE
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
@ -25,8 +25,9 @@ def _load_reference():
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": [round(float(c), 1) for c in bg_data["counts"][:NUM_CHANNELS]],
"counts": clip_to_range(raw_counts),
"live_time_s": round(float(bg_data["duration"]), 1),
}
except Exception:
@ -54,11 +55,12 @@ 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": list(range(NUM_CHANNELS)),
"channels": clip_to_range(list(range(NUM_CHANNELS))),
"energy_kev": energy_axis(),
"counts": snapshot.get("spectrum", [0] * 1024)[:NUM_CHANNELS],
"counts": clip_to_range(raw_spectrum),
"live_time_s": live_time,
"cps": snapshot.get("cps", 0),
"top_peaks": snapshot.get("top_peaks", []),
@ -74,7 +76,7 @@ async def get_background_reference():
raise HTTPException(status_code=404, detail="No 24h reference background available")
return {
"channels": list(range(NUM_CHANNELS)),
"channels": clip_to_range(list(range(NUM_CHANNELS))),
"energy_kev": energy_axis(),
"counts": ref["counts"],
"live_time_s": ref["live_time_s"],
@ -84,7 +86,10 @@ async def get_background_reference():
@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)."""
return generate_continuum_only(cps=cps, live_time_s=live_time_s)
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")
@ -132,10 +137,14 @@ async def fit_background():
# 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": [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],
"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"],
@ -174,7 +183,9 @@ async def get_background_noise():
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": [round(float(E), 2) for E in e_axis],
"counts": [round(float(c), 1) for c in continuum],
"energy_kev": clip_to_range(e_list),
"counts": clip_to_range(c_list),
}