Files
radiacode/web/app/noise.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

107 lines
3.1 KiB
Python

"""
Detector-agnostic continuum extraction for gamma-ray spectra.
Extracts the detector's intrinsic response curve (continuum) from measured
background data. Isotope photopeaks are subtracted, then the residual is
smoothed to produce a clean continuum shape that reflects only the detector's
physics — no isotope signatures.
Works with any scintillator or semiconductor detector.
"""
import numpy as np
from scipy.ndimage import gaussian_filter1d
# Common environmental isotope lines (keV) — subtracted regardless of detector.
_ENV_PEAKS = [
(241.0, 0.04),
(295.22, 0.1842),
(351.93, 0.3560),
(609.31, 0.4549),
(911.20, 0.2580),
(1120.29, 0.1492),
(1460.83, 0.1066),
(1764.49, 0.1531),
(2614.51, 0.3586),
]
_E_OFFSET = 0.33
_E_SLOPE = 2.97
def _sigma_ch(E_keV):
"""Peak sigma in channels at energy E_keV (sqrt(E) resolution scaling)."""
fwhm_keV = 0.08 * E_keV * (E_keV / 662.0) ** 0.5
sigma_keV = fwhm_keV / 2.355
return max(sigma_keV / _E_SLOPE, 2.0)
def _subtract_peaks(counts, energy_axis):
"""Remove known isotope photopeaks from spectrum."""
continuum = counts.copy()
channels = np.arange(len(counts), dtype=np.float64)
for line_energy, _ in _ENV_PEAKS:
idx = int(np.argmin(np.abs(energy_axis - line_energy)))
if idx < 0 or idx >= len(counts):
continue
sig = _sigma_ch(line_energy)
far = int(5 * sig) + 3
lo_start = max(0, idx - far - int(3 * sig))
lo_end = max(0, idx - far)
hi_start = min(len(counts), idx + far)
hi_end = min(len(counts), idx + far + int(3 * sig))
baseline_regions = []
if lo_end > lo_start:
baseline_regions.append(continuum[lo_start:lo_end])
if hi_end > hi_start:
baseline_regions.append(continuum[hi_start:hi_end])
if not baseline_regions:
continue
local_bg = float(np.median(np.concatenate(baseline_regions)))
peak_height = continuum[idx] - local_bg
if peak_height > 0:
gaussian = peak_height * np.exp(-0.5 * ((channels - idx) / sig) ** 2)
continuum -= gaussian
return np.maximum(continuum, 0)
def extract_continuum(counts, energy_axis=None):
"""Extract the detector's intrinsic response continuum.
Removes isotope photopeaks, then smooths with a wide Gaussian filter
to produce a clean curve showing only the detector's continuum shape.
Parameters
----------
counts : array
Raw accumulated counts per channel.
energy_axis : array, optional
Energy axis in keV.
Returns
-------
array — smooth continuum (peak-subtracted, Gaussian-smoothed)
"""
counts = np.asarray(counts, dtype=np.float64)
n_channels = len(counts)
if energy_axis is None:
energy_axis = _E_OFFSET + _E_SLOPE * np.arange(n_channels, dtype=np.float64)
continuum = _subtract_peaks(counts, energy_axis)
# Wide Gaussian smooth (sigma ~1.5% of channels ≈ 45 keV)
sigma = max(15, n_channels // 60)
continuum_smooth = gaussian_filter1d(continuum, sigma=sigma)
continuum_smooth = np.maximum(continuum_smooth, 0)
return continuum_smooth