Files
radiacode/web/app/bg_calibration.py
Jacquin Antoine 0847a3fc80 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>
2026-05-21 17:35:22 +02:00

209 lines
6.9 KiB
Python

"""
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 iterative peak subtraction followed by Gaussian smoothing to produce
a clean continuum shape. This approach tracks the measured background closely
at all energies, unlike log-space splines which collapse in low-signal regions.
"""
import json
import numpy as np
from pathlib import Path
from scipy.ndimage import gaussian_filter1d
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 _sigma_ch(E_keV):
fwhm_keV = 0.08 * E_keV * (E_keV / 662.0) ** 0.5
sigma_keV = fwhm_keV / 2.355
return max(sigma_keV / ENERGY_SLOPE, 2.0)
def _subtract_peaks(energy_axis, spectrum):
"""Remove known isotope photopeaks from spectrum."""
continuum = spectrum.copy()
channels = np.arange(len(spectrum), dtype=np.float64)
for line_energy, _ in PHOTOPEAK_LINES:
idx = int(np.argmin(np.abs(energy_axis - line_energy)))
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(spectrum), idx + far)
hi_end = min(len(spectrum), 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), [{"energy_keV": e, "amplitude": 0.0} for e, _ in PHOTOPEAK_LINES]
def calibrate_spline(measured_cps, energy_axis):
"""
Fit continuum using peak subtraction + Gaussian smoothing.
Uses scipy's gaussian_filter1d after iterative peak subtraction,
producing a smooth continuum that tracks the measured background
closely at all energies including the high-energy tail.
"""
# Step 1: Smooth to reduce statistical noise
window = min(51, len(measured_cps) // 10 * 2 + 1)
if window < 5:
window = 5
y_smooth = savgol_filter(measured_cps, window_length=window, polyorder=3)
# Step 2: Subtract known photopeaks
continuum, peak_amplitudes = _subtract_peaks(energy_axis, y_smooth)
# Step 3: Gaussian smooth for final continuum shape
sigma = max(15, len(continuum) // 60)
continuum_smooth = gaussian_filter1d(continuum, sigma=sigma)
continuum_smooth = np.maximum(continuum_smooth, 0)
# Quality metrics
residuals = continuum - continuum_smooth
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": continuum_smooth,
"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 peak subtraction + Gaussian smoothing."""
result = calibrate_spline(measured_cps, energy_axis)
if "error" in result:
return result
return {
"params": {},
"continuum_cps": result["continuum_cps"],
"peak_amplitudes": result["peak_amplitudes"],
"r_squared": result["r_squared"],
"residuals_rms": result["residuals_rms"],
"method": "peak_subtract_gaussian",
}
def build_calibrated_continuum(energy_axis, total_counts, params):
"""Build the continuum from calibrated parameters."""
if "continuum_cps" in params:
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
# Try loading from cache file first (read-only volume is fine for reads)
if _CALIBRATION_PATH.exists():
try:
with open(_CALIBRATION_PATH) as f:
cached = json.load(f)
# Invalidate if method changed
if cached.get("method") != "peak_subtract_gaussian":
cached = None
except Exception:
cached = None
if cached and "continuum_cps" in cached:
_cached_result = cached
return _cached_result
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"],
}
# Write cache if volume is writable (may fail on read-only mounts)
try:
_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)
except OSError:
pass # Read-only volume — in-memory cache is sufficient
return _cached_result