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>
74 lines
2.5 KiB
Python
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 |