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

@ -49,14 +49,14 @@ class IsotopeSource:
@dataclass
class SpectrumConfig:
"""Configuration for a single spectrum generation."""
# Time parameters
duration_seconds: float = 60.0
time_interval_seconds: float = 1.0 # Each row in the spectrogram
# Sources to include
sources: List[IsotopeSource] = field(default_factory=list)
# Background options
include_background: bool = True
background_cps: float = 5.0
@ -64,18 +64,25 @@ class SpectrumConfig:
include_radon: bool = True
include_thorium: bool = True
measured_background_path: Optional[str] = None
# Background subtraction simulation
# When True, generates a second independent background realization
# and subtracts it from the spectrum, then clips negatives to 0.
# This simulates what happens at inference time (measured bg subtraction).
subtract_background: bool = False
# Detector configuration
detector_name: str = "radiacode_103"
# Noise options
apply_poisson: bool = True
apply_electronic: bool = False
electronic_noise_sigma: float = 0.5
# Normalization
# Normalization — "log1p" preserves relative signal levels,
# works well after background subtraction where many channels are ~0.
normalize: bool = True
normalization_method: str = "max" # max, sum, log, sqrt
normalization_method: str = "log1p" # max, sum, log, sqrt, log1p
@dataclass
@ -272,7 +279,7 @@ class SpectrumGenerator:
all_source_isotopes.extend(src_iso)
all_background_isotopes.extend(bg_iso)
# Apply noise
# Apply noise before any subtraction (Poisson noise on raw counts)
if config.apply_poisson:
spectrum = apply_poisson_noise(spectrum)
@ -282,6 +289,24 @@ class SpectrumGenerator:
config.electronic_noise_sigma
)
# Simulate background subtraction (matches inference pipeline)
if config.subtract_background and config.include_background:
# Generate an independent background realization
bg_spectrum2, _ = generate_environmental_background(
self.energy_bins,
config.duration_seconds,
background_cps=config.background_cps,
include_k40=config.include_k40,
include_radon=config.include_radon,
include_thorium=config.include_thorium,
detector_config=self.detector_config,
measured_background_path=config.measured_background_path,
)
if config.apply_poisson:
bg_spectrum2 = apply_poisson_noise(bg_spectrum2)
# Subtract and clip — same as inference: net = clip(rate - bg_rate, 0, inf)
spectrum = np.maximum(spectrum - bg_spectrum2, 0)
# Normalize if requested
if config.normalize:
spectrum = normalize_spectrum(spectrum, config.normalization_method)