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:
@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user