Background réaliste CsI(Tl) + hybridation mesuré/synthétique + dashboard continuum

- Remplace le continuum exponentiel par un modèle réaliste CsI(Tl) dans
  l'entraînement (bosse asymétrique ~110 keV + queue Compton)
- Ajoute l'injection de background mesuré (70% mesuré / 30% synthétique)
  via --measured_background et MEASURED_BACKGROUND_PATH
- Ajoute l'endpoint /api/background/continuum et le toggle "Continuum CsI"
  sur le dashboard background
- Exclut le canal 1023 (overflow bin) de l'affichage web (NUM_CHANNELS=1023)
- Corrige le lissage Gaussien du background (normalisation locale aux bords)
- Met à jour README.md, CLAUDE.md, TUTORIEL.md, TOTO.md, vega_ml/README.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-05-19 18:14:00 +02:00
parent 1e0c1a5ea5
commit 75d271c696
17 changed files with 917 additions and 224 deletions

View File

@ -9,6 +9,7 @@ Implements the physics of gamma spectrum generation including:
"""
import numpy as np
from pathlib import Path
from scipy import special
from typing import Optional, Tuple, List
from dataclasses import dataclass
@ -274,14 +275,14 @@ def generate_exponential_background(
) -> np.ndarray:
"""
Generate exponential background continuum.
B(E) = A * exp(-b * E)
Args:
energy_bins: Array of energy bin centers (keV)
amplitude: Background amplitude at E=0
decay_constant: Exponential decay constant (1/keV)
Returns:
Array of background counts
"""
@ -294,26 +295,123 @@ def generate_polynomial_background(
) -> np.ndarray:
"""
Generate polynomial background.
B(E) = Σ c_m * E^m
Args:
energy_bins: Array of energy bin centers (keV)
coefficients: Polynomial coefficients [c0, c1, c2, ...]
Returns:
Array of background counts
"""
if coefficients is None:
coefficients = [10.0, -0.005, 1e-6] # Default quadratic
background = np.zeros_like(energy_bins)
for m, c in enumerate(coefficients):
background += c * (energy_bins ** m)
return np.maximum(0, background)
def generate_realistic_continuum(
energy_bins: np.ndarray,
total_counts: float,
detector_config: Optional[DetectorConfig] = None
) -> np.ndarray:
"""
Generate realistic CsI(Tl) background continuum shape.
Calibrated against real Radiacode 103 background measurements.
Produces the characteristic asymmetric hump at ~110 keV and
Compton-like tail that simple exponentials miss.
Shape components:
- Asymmetric hump centered at ~110 keV (sigma_left=55, sigma_right=50 keV)
- Compton continuum: 0.45*exp(-E/240) + 0.04*exp(-E/700)
- Noise floor at 0.8% of peak
Args:
energy_bins: Array of energy bin centers (keV)
total_counts: Target total counts in the continuum
detector_config: Detector configuration (unused, kept for API consistency)
Returns:
Array of background counts matching real CsI(Tl) continuum shape
"""
E = energy_bins
# Asymmetric hump at ~110 keV (low-energy scatter peak in CsI(Tl))
hump_center = 110.0
sigma_left = 55.0 # Broader on the low-energy side
sigma_right = 50.0 # Narrower on the high-energy side
hump = np.where(
E <= hump_center,
np.exp(-0.5 * ((E - hump_center) / sigma_left) ** 2),
np.exp(-0.5 * ((E - hump_center) / sigma_right) ** 2),
)
# Compton continuum tail
tail = 0.45 * np.exp(-E / 240.0) + 0.04 * np.exp(-E / 700.0)
# Noise floor (low-level baseline)
noise_floor = 0.008
# Combine shape components
continuum = hump + tail + noise_floor
# Normalize to target total counts
if continuum.sum() > 0 and total_counts > 0:
continuum *= total_counts / continuum.sum()
return continuum
def load_measured_background(
path: str,
energy_bins: np.ndarray,
duration_seconds: float
) -> Optional[np.ndarray]:
"""
Load a measured background spectrum from a .npy file and rescale it
to match the target duration.
The .npy file should contain a dict with keys 'counts' and 'duration'.
Args:
path: Path to the .npy background file
energy_bins: Array of energy bin centers (keV) for alignment
duration_seconds: Target duration to scale the spectrum to
Returns:
Background spectrum scaled to target duration, or None if file not found
"""
bg_path = Path(path)
if not bg_path.exists():
return None
try:
bg_data = np.load(str(bg_path), allow_pickle=True).item()
bg_counts = bg_data["counts"].astype(np.float64)
bg_duration = float(bg_data["duration"])
# Truncate or pad to match energy_bins length
num_channels = len(energy_bins)
if len(bg_counts) > num_channels:
bg_counts = bg_counts[:num_channels]
elif len(bg_counts) < num_channels:
bg_counts = np.pad(bg_counts, (0, num_channels - len(bg_counts)))
# Scale to target duration (cps * target_duration)
if bg_duration > 0:
scale = duration_seconds / bg_duration
return bg_counts * scale
return None
except Exception:
return None
def generate_environmental_background(
energy_bins: np.ndarray,
duration_seconds: float,
@ -321,17 +419,19 @@ def generate_environmental_background(
include_k40: bool = True,
include_radon: bool = True,
include_thorium: bool = True,
detector_config: Optional[DetectorConfig] = None
detector_config: Optional[DetectorConfig] = None,
measured_background_path: Optional[str] = None
) -> Tuple[np.ndarray, List[str]]:
"""
Generate realistic environmental background spectrum.
Includes:
- Exponential continuum (cosmic rays, scattered gammas)
- Realistic CsI(Tl) continuum shape (asymmetric hump + Compton tail)
- Or measured background if path provided and file exists
- K-40 peak (1460 keV) - ubiquitous in environment
- Radon daughters (Pb-214, Bi-214) - indoor air
- Thorium daughters (Pb-212, Tl-208) - building materials
Args:
energy_bins: Array of energy bin centers (keV)
duration_seconds: Acquisition time
@ -340,27 +440,47 @@ def generate_environmental_background(
include_radon: Include radon daughter peaks
include_thorium: Include thorium daughter peaks
detector_config: Detector configuration
measured_background_path: Path to .npy file with measured background.
If provided and file exists, used as the continuum base instead
of the synthetic continuum. Isotope peaks are still added on top
with stochastic variation for training diversity.
Returns:
Tuple of (background_spectrum, list_of_background_isotopes)
"""
if detector_config is None:
detector_config = get_default_config()
background_isotopes = []
# Start with exponential continuum
# Use measured background if available, otherwise synthetic continuum
total_continuum_counts = background_cps * duration_seconds * 0.7
background = generate_exponential_background(
energy_bins,
amplitude=total_continuum_counts / 500,
decay_constant=0.002
)
# Normalize continuum to target count rate
if background.sum() > 0:
background *= (total_continuum_counts / background.sum())
measured = None
if measured_background_path:
measured = load_measured_background(
measured_background_path, energy_bins, duration_seconds
)
if measured is not None:
# Scale measured background to match target CPS
measured_total = measured.sum()
if measured_total > 0 and total_continuum_counts > 0:
# Blend: 70% measured shape, 30% synthetic for robustness
synthetic = generate_realistic_continuum(
energy_bins, total_counts=total_continuum_counts * 0.3,
detector_config=detector_config
)
measured_scaled = measured * (total_continuum_counts * 0.7 / measured_total)
background = measured_scaled + synthetic
else:
background = measured
else:
background = generate_realistic_continuum(
energy_bins, total_counts=total_continuum_counts,
detector_config=detector_config
)
# Add K-40 peak (very common)
if include_k40:
k40_activity = np.random.uniform(0.5, 5.0) # Bq
@ -376,11 +496,11 @@ def generate_environmental_background(
)
background += peak
background_isotopes.append("K-40")
# Add radon daughters
if include_radon:
radon_activity = np.random.uniform(0.1, 2.0) # Bq
# Pb-214 lines
for energy, intensity in [(295.22, 0.1842), (351.93, 0.356)]:
peak = generate_peak_spectrum(
@ -394,7 +514,7 @@ def generate_environmental_background(
detector_config
)
background += peak
# Bi-214 lines
for energy, intensity in [(609.31, 0.4549), (1120.29, 0.1492), (1764.49, 0.1531)]:
peak = generate_peak_spectrum(
@ -408,13 +528,13 @@ def generate_environmental_background(
detector_config
)
background += peak
background_isotopes.extend(["Pb-214", "Bi-214"])
# Add thorium daughters
if include_thorium:
thorium_activity = np.random.uniform(0.05, 1.0) # Bq
# Ac-228 line
peak = generate_peak_spectrum(
energy_bins,
@ -427,7 +547,7 @@ def generate_environmental_background(
detector_config
)
background += peak
# Pb-212 line
peak = generate_peak_spectrum(
energy_bins,
@ -440,7 +560,7 @@ def generate_environmental_background(
detector_config
)
background += peak
# Tl-208 lines
for energy, intensity in [(583.19, 0.845 * 0.36), (2614.51, 0.998 * 0.36)]:
# Branching ratio of 36% for Tl-208 path
@ -455,9 +575,9 @@ def generate_environmental_background(
detector_config
)
background += peak
background_isotopes.extend(["Ac-228", "Pb-212", "Tl-208"])
return background, background_isotopes