Files
radiacode/train/vega_ml/synthetic_spectra/physics/spectrum_physics.py
Jacquin Antoine c764a5c264 Dash web: crosshair, zoom/pan X, scale log/lin, continuum extraction, background resume
- Tooltip entier (intersect:false) + ligne verticale crosshair sur tous les graphes
- Zoom molette/pinch sur l'axe X, pan souris, limites clamped 30-3000 keV
- Toggle échelle log/linéaire onglet Background
- Extraction continuum détecteur (isotope peaks subtracted + Gaussian smoothing)
- Reprise snapshot précédent au démarrage capture_background.py
- Suppression refs "Théorique" et "Bruit capteur" de l'interface
- Plugin chartjs-plugin-zoom + hammerjs via CDN
- Fix Chart constructor spread operator

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:26:28 +02:00

684 lines
21 KiB
Python

"""
Spectrum Physics Module
Implements the physics of gamma spectrum generation including:
- Peak shape modeling (Gaussian with detector response)
- Background continuum generation
- Counting statistics (Poisson sampling)
- Detector efficiency modeling
"""
import numpy as np
from pathlib import Path
from scipy import special
from typing import Optional, Tuple, List
from dataclasses import dataclass
from ..config import DetectorConfig, get_default_config
@dataclass
class PeakParameters:
"""Parameters for a single gamma peak."""
energy_kev: float
intensity: float # Emission probability (photons/decay)
activity_bq: float # Source activity in Becquerels
live_time_s: float # Acquisition time in seconds
def gaussian_peak(
energy_bins: np.ndarray,
peak_energy: float,
sigma: float,
amplitude: float
) -> np.ndarray:
"""
Generate a Gaussian peak.
Args:
energy_bins: Array of energy bin centers (keV)
peak_energy: Center energy of peak (keV)
sigma: Standard deviation (keV)
amplitude: Peak area (total counts)
Returns:
Array of counts in each bin
"""
# Gaussian probability density
prob = np.exp(-0.5 * ((energy_bins - peak_energy) / sigma) ** 2)
prob /= (sigma * np.sqrt(2 * np.pi))
# Scale by amplitude and bin width
bin_width = energy_bins[1] - energy_bins[0] if len(energy_bins) > 1 else 1.0
return amplitude * prob * bin_width
def calculate_fwhm(energy_kev: float, fwhm_at_662: float = 0.084) -> float:
"""
Calculate FWHM at a given energy for scintillator detectors.
FWHM scales as sqrt(E) for scintillators due to statistical fluctuations
in light collection.
FWHM(E) = FWHM_662 * sqrt(E/662) * 662 / E * E = FWHM_662 * sqrt(662/E) * E
Actually: FWHM(E) / E = FWHM_662 / 662 * sqrt(662/E)
So: FWHM(E) = E * FWHM_662 / 662 * sqrt(662/E) = FWHM_662 * sqrt(662 * E) / 662
= FWHM_662 * sqrt(E / 662)
Wait, let me recalculate:
For scintillators, the relative resolution (FWHM/E) scales as 1/sqrt(E)
FWHM(E)/E = (FWHM_662/662) * sqrt(662/E)
FWHM(E) = FWHM_662 * sqrt(662 * E) / 662 = FWHM_662 * sqrt(E/662)
At 662 keV: FWHM = FWHM_662 * sqrt(1) = FWHM_662 ✓
At lower E: larger relative FWHM (worse resolution)
At higher E: smaller relative FWHM (better resolution)
Args:
energy_kev: Energy in keV
fwhm_at_662: FWHM at 662 keV as fraction (e.g., 0.084 for 8.4%)
Returns:
FWHM in keV at the given energy
"""
# FWHM_662 is given as fraction, so at 662 keV, FWHM = 0.084 * 662 = ~55.6 keV
fwhm_662_kev = fwhm_at_662 * 662.0
# Scale by sqrt(E/662)
fwhm_kev = fwhm_662_kev * np.sqrt(energy_kev / 662.0)
return fwhm_kev
def fwhm_to_sigma(fwhm: float) -> float:
"""Convert FWHM to Gaussian sigma."""
return fwhm / (2.0 * np.sqrt(2.0 * np.log(2.0))) # ≈ FWHM / 2.355
def detector_efficiency(
energy_kev: float,
detector_config: Optional[DetectorConfig] = None
) -> float:
"""
Calculate detector full-energy peak efficiency.
For CsI and GAGG scintillators, efficiency varies with energy.
This is a simplified model - real efficiency curves should be
measured for each detector.
Args:
energy_kev: Gamma energy in keV
detector_config: Detector configuration
Returns:
Efficiency as fraction (0-1)
"""
if detector_config is None:
detector_config = get_default_config()
# Simplified efficiency model for ~1 cm³ scintillator
# Low energy: efficiency increases (more stopping power)
# High energy: efficiency decreases (photons pass through)
# Peak around 100-300 keV for small scintillators
# This is a phenomenological model
# Real efficiency should be calibrated
if energy_kev < 20:
return 0.0
# Simple model: efficiency peaks around 100-200 keV
# Falls off at low energy (absorption in housing)
# Falls off at high energy (less stopping power)
# Low energy cutoff (absorption)
low_eff = 1.0 - np.exp(-energy_kev / 50.0)
# High energy falloff (escape)
# For 1 cm³ CsI, efficiency drops significantly above ~500 keV
high_eff = np.exp(-energy_kev / 2000.0)
# Combine effects
eff = 0.8 * low_eff * high_eff
# Scale by detector volume
volume_factor = (detector_config.detector_volume_cm3 / 1.0) ** (1/3)
eff *= min(1.0, volume_factor)
return max(0.0, min(1.0, eff))
def calculate_expected_counts(
peak_params: PeakParameters,
detector_config: Optional[DetectorConfig] = None
) -> float:
"""
Calculate expected counts in a photopeak.
λ = A * t * I * ε * T
Where:
A = activity (decays/s)
t = live time (s)
I = emission probability (photons/decay)
ε = detector efficiency
T = transmission factor (assumed 1 for now)
Args:
peak_params: Peak parameters
detector_config: Detector configuration
Returns:
Expected number of counts in the photopeak
"""
if detector_config is None:
detector_config = get_default_config()
efficiency = detector_efficiency(peak_params.energy_kev, detector_config)
expected = (
peak_params.activity_bq *
peak_params.live_time_s *
peak_params.intensity *
efficiency
)
return expected
def generate_peak_spectrum(
energy_bins: np.ndarray,
peak_params: PeakParameters,
detector_config: Optional[DetectorConfig] = None
) -> np.ndarray:
"""
Generate a single gamma peak with detector response.
Args:
energy_bins: Array of energy bin centers (keV)
peak_params: Peak parameters
detector_config: Detector configuration
Returns:
Array of expected counts in each bin (not yet Poisson sampled)
"""
if detector_config is None:
detector_config = get_default_config()
# Calculate expected counts
amplitude = calculate_expected_counts(peak_params, detector_config)
if amplitude <= 0:
return np.zeros_like(energy_bins)
# Calculate peak width
fwhm_kev = calculate_fwhm(peak_params.energy_kev, detector_config.fwhm_at_662)
sigma = fwhm_to_sigma(fwhm_kev)
# Generate Gaussian peak
peak = gaussian_peak(energy_bins, peak_params.energy_kev, sigma, amplitude)
return peak
def generate_compton_continuum(
energy_bins: np.ndarray,
peak_energy: float,
peak_counts: float,
compton_to_peak_ratio: float = 0.5
) -> np.ndarray:
"""
Generate simplified Compton continuum for a gamma line.
The Compton continuum extends from 0 to the Compton edge.
Compton edge energy = E * (1 - 1/(1 + 2*E/(511)))
Args:
energy_bins: Array of energy bin centers (keV)
peak_energy: Energy of the gamma line (keV)
peak_counts: Total counts in the photopeak
compton_to_peak_ratio: Ratio of Compton counts to peak counts
Returns:
Array of Compton continuum counts
"""
# Compton edge energy
alpha = peak_energy / 511.0 # E / m_e c²
compton_edge = peak_energy * (2 * alpha) / (1 + 2 * alpha)
# Create continuum (simplified flat + edge shape)
continuum = np.zeros_like(energy_bins)
# Mask for energies below Compton edge
mask = energy_bins < compton_edge
if np.any(mask):
# Simple model: roughly flat with enhancement near edge
base_level = peak_counts * compton_to_peak_ratio / np.sum(mask)
continuum[mask] = base_level
# Add edge enhancement (Klein-Nishina-like shape)
edge_region = (energy_bins > 0.8 * compton_edge) & (energy_bins < compton_edge)
if np.any(edge_region):
enhancement = 1.5 * np.exp(-((energy_bins[edge_region] - compton_edge) / (0.05 * compton_edge)) ** 2)
continuum[edge_region] *= (1 + enhancement)
return continuum
# =============================================================================
# BACKGROUND GENERATION
# =============================================================================
def generate_exponential_background(
energy_bins: np.ndarray,
amplitude: float = 100.0,
decay_constant: float = 0.003
) -> 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
"""
return amplitude * np.exp(-decay_constant * energy_bins)
def generate_polynomial_background(
energy_bins: np.ndarray,
coefficients: List[float] = None
) -> 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 with
housing absorption at low energy, Compton plateau, and proper
high-energy falloff.
Shape components:
- Asymmetric hump at ~110 keV (sigma_left=48, sigma_right=80 keV)
- Housing absorption below ~40 keV: 1 - exp(-E/30)
- Compton plateau around 200-260 keV from Pb-214/Bi-214 scatter
- Compton tail: 0.38*exp(-E/170) + 0.06*exp(-E/500)
- Noise floor at 0.3% 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
# Left side sharper (sigma=48), right side broader with Compton shoulder (sigma=80)
hump_center = 110.0
hump = np.where(
E <= hump_center,
np.exp(-0.5 * ((E - hump_center) / 48.0) ** 2),
np.exp(-0.5 * ((E - hump_center) / 80.0) ** 2),
)
# Housing absorption at very low energy (< ~40 keV)
absorption = 1.0 - np.exp(-E / 30.0)
# Compton continuum tail
tail = 0.38 * np.exp(-E / 170.0) + 0.06 * np.exp(-E / 500.0)
# Compton plateau around 200-260 keV (Pb-214/Bi-214 scatter)
compton_edge = np.maximum(0, 1.0 - ((E - 180.0) / 150.0) ** 2)
compton_edge[E > 330] = 0
compton_plateau = 0.12 * compton_edge
# Noise floor
noise_floor = 0.003
# Combine continuum with absorption
continuum = (hump + tail + compton_plateau) * absorption + 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,
background_cps: float = 5.0,
include_k40: bool = True,
include_radon: bool = True,
include_thorium: bool = True,
detector_config: Optional[DetectorConfig] = None,
measured_background_path: Optional[str] = None
) -> Tuple[np.ndarray, List[str]]:
"""
Generate realistic environmental background spectrum.
Includes:
- 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
background_cps: Average background count rate (cps)
include_k40: Include potassium-40 peak
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 = []
# Use measured background if available, otherwise synthetic continuum
total_continuum_counts = background_cps * duration_seconds * 0.7
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
peak = generate_peak_spectrum(
energy_bins,
PeakParameters(
energy_kev=1460.83,
intensity=0.1066,
activity_bq=k40_activity,
live_time_s=duration_seconds
),
detector_config
)
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(
energy_bins,
PeakParameters(
energy_kev=energy,
intensity=intensity,
activity_bq=radon_activity,
live_time_s=duration_seconds
),
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(
energy_bins,
PeakParameters(
energy_kev=energy,
intensity=intensity,
activity_bq=radon_activity,
live_time_s=duration_seconds
),
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,
PeakParameters(
energy_kev=911.20,
intensity=0.258,
activity_bq=thorium_activity,
live_time_s=duration_seconds
),
detector_config
)
background += peak
# Pb-212 line
peak = generate_peak_spectrum(
energy_bins,
PeakParameters(
energy_kev=238.63,
intensity=0.436,
activity_bq=thorium_activity,
live_time_s=duration_seconds
),
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
peak = generate_peak_spectrum(
energy_bins,
PeakParameters(
energy_kev=energy,
intensity=intensity,
activity_bq=thorium_activity,
live_time_s=duration_seconds
),
detector_config
)
background += peak
background_isotopes.extend(["Ac-228", "Pb-212", "Tl-208"])
return background, background_isotopes
def apply_poisson_noise(spectrum: np.ndarray) -> np.ndarray:
"""
Apply Poisson counting statistics to a spectrum.
Each bin is sampled from a Poisson distribution with
lambda = expected counts in that bin.
Args:
spectrum: Array of expected counts (can be float)
Returns:
Array of actual counts (integers)
"""
# Handle negative values (shouldn't happen but be safe)
spectrum = np.maximum(0, spectrum)
# Sample from Poisson distribution
return np.random.poisson(spectrum).astype(np.float64)
def apply_electronic_noise(
spectrum: np.ndarray,
sigma: float = 0.5
) -> np.ndarray:
"""
Apply small Gaussian electronic noise.
Args:
spectrum: Count spectrum
sigma: Standard deviation of electronic noise (counts)
Returns:
Spectrum with added electronic noise
"""
noise = np.random.normal(0, sigma, spectrum.shape)
result = spectrum + noise
return np.maximum(0, result)
# =============================================================================
# NORMALIZATION
# =============================================================================
def normalize_spectrum(
spectrum: np.ndarray,
method: str = "max"
) -> np.ndarray:
"""
Normalize a spectrum for ML training.
Args:
spectrum: Raw count spectrum
method: Normalization method
- "max": Divide by maximum value (range 0-1)
- "sum": Divide by total counts (probability distribution)
- "log": Log transform then max normalize
- "sqrt": Square root transform then max normalize
Returns:
Normalized spectrum
"""
if method == "max":
max_val = spectrum.max()
if max_val > 0:
return spectrum / max_val
return spectrum
elif method == "sum":
total = spectrum.sum()
if total > 0:
return spectrum / total
return spectrum
elif method == "log":
# Log transform (add 1 to handle zeros)
log_spec = np.log1p(spectrum)
max_val = log_spec.max()
if max_val > 0:
return log_spec / max_val
return log_spec
elif method == "sqrt":
sqrt_spec = np.sqrt(spectrum)
max_val = sqrt_spec.max()
if max_val > 0:
return sqrt_spec / max_val
return sqrt_spec
else:
raise ValueError(f"Unknown normalization method: {method}")