Files
radiacode/web/app/theoretical_bg.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

74 lines
2.5 KiB
Python

"""
CsI(Tl) detector response continuum for Radiacode 103.
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
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):
"""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
# Try calibrated spline first (fits measured background)
continuum_cps = _get_continuum_cps()
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: 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