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

@ -128,19 +128,21 @@ def generate_training_batch(
num_samples: int,
output_dir: Path,
detector_name: str = "radiacode_103",
duration_range: tuple = (60, 300),
duration_range: tuple = (30, 300),
activity_range: tuple = (1.0, 100.0),
single_isotope_fraction: float = 0.4,
dual_isotope_fraction: float = 0.3,
multi_isotope_fraction: float = 0.2,
single_isotope_fraction: float = 0.3,
dual_isotope_fraction: float = 0.2,
multi_isotope_fraction: float = 0.15,
background_only_fraction: float = 0.1,
low_signal_fraction: float = 0.15,
subtracted_fraction: float = 0.1,
save_png: bool = False,
random_seed: int = None,
measured_background_path: str = None,
) -> list:
"""
Generate a batch of training samples with various configurations.
Args:
num_samples: Total number of samples to generate
output_dir: Output directory for spectra and labels
@ -151,9 +153,11 @@ def generate_training_batch(
dual_isotope_fraction: Fraction of two-isotope samples
multi_isotope_fraction: Fraction of 3+ isotope samples
background_only_fraction: Fraction of background-only samples
low_signal_fraction: Fraction of low-activity samples (0.01-5 Bq)
subtracted_fraction: Fraction of background-subtracted samples
save_png: Whether to also save PNG images
random_seed: Random seed for reproducibility
Returns:
List of generated spectra
"""
@ -181,11 +185,13 @@ def generate_training_batch(
n_dual = int(num_samples * dual_isotope_fraction)
n_multi = int(num_samples * multi_isotope_fraction)
n_background = int(num_samples * background_only_fraction)
n_low_signal = int(num_samples * low_signal_fraction)
n_subtracted = int(num_samples * subtracted_fraction)
# Adjust to ensure we hit exactly num_samples
remaining = num_samples - (n_single + n_dual + n_multi + n_background)
remaining = num_samples - (n_single + n_dual + n_multi + n_background + n_low_signal + n_subtracted)
n_single += remaining
total_generated = 0
print(f"\nGenerating {num_samples} synthetic spectra:")
@ -193,6 +199,8 @@ def generate_training_batch(
print(f" - Dual isotope: {n_dual}")
print(f" - Multi isotope (3+): {n_multi}")
print(f" - Background only: {n_background}")
print(f" - Low signal (0.01-5 Bq): {n_low_signal}")
print(f" - Background-subtracted: {n_subtracted}")
print()
sample_num = 0
@ -314,6 +322,77 @@ def generate_training_batch(
sample_num += 1
# Generate low-signal samples (weak sources, 0.01-5 Bq)
print("Generating low-signal samples...")
for i in range(n_low_signal):
isotope = np.random.choice(isotope_pool)
activity = np.random.uniform(0.01, 5.0)
duration = np.random.uniform(*duration_range)
spectrum = generate_single_isotope_sample(
generator,
isotope,
activity,
duration,
detector_name=detector_name,
include_background=True,
measured_background_path=measured_background_path,
)
save_spectrum(
spectrum,
spectra_dir,
save_image=True,
image_format='npy'
)
del spectrum
sample_num += 1
if sample_num % 100 == 0:
print(f" Generated {sample_num}/{num_samples} samples...")
# Generate background-subtracted samples (simulates inference pipeline)
print("Generating background-subtracted samples...")
for i in range(n_subtracted):
num_iso = np.random.choice([1, 2, 3], p=[0.5, 0.3, 0.2])
isotopes = np.random.choice(isotope_pool, size=num_iso, replace=False)
activities = [np.random.uniform(0.1, 50.0) for _ in range(num_iso)]
duration = np.random.uniform(*duration_range)
sources = [
IsotopeSource(
isotope_name=name,
activity_bq=activity,
include_daughters=True
)
for name, activity in zip(isotopes, activities)
]
config = SpectrumConfig(
duration_seconds=duration,
sources=sources,
include_background=True,
subtract_background=True,
detector_name=detector_name,
measured_background_path=measured_background_path,
)
spectrum = generator.generate_spectrum(config)
save_spectrum(
spectrum,
spectra_dir,
save_image=True,
image_format='npy'
)
del spectrum
sample_num += 1
if sample_num % 100 == 0:
print(f" Generated {sample_num}/{num_samples} samples...")
total_generated = sample_num
print(f"\nGenerated {total_generated} samples total")