Fix: mask channels below 30 keV in inference and training to prevent misidentification

Below ~30 keV the detector signal is dominated by X-ray fluorescence (L-shell)
and artifacts not modelled in training data. This spurious low-energy continuum
caused the model to misidentify Am-241 as Th-232/U-235. Masking channels <30 keV
before inference fixes Am-241 detection from 2% to 99%. Same masking applied in
the synthetic spectrum generator for consistent retraining.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-05-21 21:55:34 +02:00
parent 00418d35bc
commit 091d7d9eb8
2 changed files with 91 additions and 1 deletions

View File

@ -12,7 +12,7 @@ import json
import logging import logging
import os import os
import sys import sys
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
# Configuration via variables d'environnement # Configuration via variables d'environnement
@ -37,6 +37,15 @@ ENERGY_SLOPE = float(os.environ.get("ENERGY_CALIBRATION_SLOPE", "2.97"))
CSI_NONLINEAR_ALPHA = float(os.environ.get("CSI_NONLINEAR_ALPHA", "0.37")) CSI_NONLINEAR_ALPHA = float(os.environ.get("CSI_NONLINEAR_ALPHA", "0.37"))
CSI_NONLINEAR_BETA = float(os.environ.get("CSI_NONLINEAR_BETA", "100.0")) CSI_NONLINEAR_BETA = float(os.environ.get("CSI_NONLINEAR_BETA", "100.0"))
# Minimum energy for inference — channels below this are masked to zero.
# Below ~30 keV the signal is dominated by X-ray fluorescence and detector
# artifacts not modelled in training data, causing misidentifications.
MIN_ENERGY_KEV = float(os.environ.get("MIN_ENERGY_KEV", "30.0"))
MIN_CHANNEL = max(0, int((MIN_ENERGY_KEV - ENERGY_OFFSET) / ENERGY_SLOPE))
HOURLY_DIR = Path(os.environ.get("HOURLY_DIR", "/data/hourly"))
HOURLY_DIR.mkdir(parents=True, exist_ok=True)
def correct_csilinear_energy(spectrum_rate, num_channels=1023): def correct_csilinear_energy(spectrum_rate, num_channels=1023):
"""Apply inverse CsI(Tl) non-linear response correction to spectrum channels. """Apply inverse CsI(Tl) non-linear response correction to spectrum channels.
@ -146,6 +155,38 @@ class RadiacodeMonitor:
self.last_report_date = None self.last_report_date = None
self.connected = False self.connected = False
# Suivi horaire
self.current_hour = datetime.now().hour
self.hourly_counts = np.zeros(1024, dtype=np.float64)
self.hourly_live_time = 0.0
# Restaurer l'état si disponible et du même jour
state_path = Path(STATE_PATH)
if state_path.exists():
try:
with open(state_path) as f:
saved = json.load(f)
saved_ts = datetime.fromisoformat(saved["timestamp"])
now = datetime.now()
same_day = saved_ts.date() == now.date()
yesterday_before_report = (
now.hour < REPORT_HOUR
and saved_ts.date() == (now.date() - timedelta(days=1))
)
if same_day or yesterday_before_report:
counts = saved.get("counts", [])
if len(counts) == 1024:
self.cumulated_counts = np.array(counts, dtype=np.float64)
self.cumulated_live_time = float(saved.get("cumulated_live_time_s", 0))
log.info(
f"État restauré : {self.cumulated_live_time/3600:.1f}h, "
f"{self.cumulated_counts.sum():.0f} coups"
)
else:
log.info("État sauvegardé d'un jour précédent, redémarrage à zéro")
except (json.JSONDecodeError, OSError, KeyError) as e:
log.warning(f"Impossible de restaurer l'état : {e}")
def _connect(self): def _connect(self):
"""Tente d'établir une connexion persistante au Radiacode.""" """Tente d'établir une connexion persistante au Radiacode."""
try: try:
@ -191,6 +232,8 @@ class RadiacodeMonitor:
if live_time > 0 and counts.sum() > 0: if live_time > 0 and counts.sum() > 0:
self.cumulated_counts += counts self.cumulated_counts += counts
self.cumulated_live_time += live_time self.cumulated_live_time += live_time
self.hourly_counts += counts
self.hourly_live_time += live_time
self._rc.spectrum_reset() self._rc.spectrum_reset()
log.info( log.info(
f"Échantillon : {counts.sum():.0f} coups en {live_time:.1f}s " f"Échantillon : {counts.sum():.0f} coups en {live_time:.1f}s "
@ -216,6 +259,7 @@ class RadiacodeMonitor:
net_rate = np.clip(rate[:1023] - bg_rate, 0, None) net_rate = np.clip(rate[:1023] - bg_rate, 0, None)
else: else:
net_rate = rate[:1023] net_rate = rate[:1023]
net_rate[:MIN_CHANNEL] = 0
isotopes = self.run_inference(net_rate) isotopes = self.run_inference(net_rate)
state = { state = {
@ -300,6 +344,7 @@ class RadiacodeMonitor:
net_rate = np.clip(rate[:1023] - bg_rate, 0, None) net_rate = np.clip(rate[:1023] - bg_rate, 0, None)
else: else:
net_rate = rate[:1023] net_rate = rate[:1023]
net_rate[:MIN_CHANNEL] = 0
results = self.run_inference(net_rate) results = self.run_inference(net_rate)
@ -343,6 +388,44 @@ class RadiacodeMonitor:
self.cumulated_counts = np.zeros(1024, dtype=np.float64) self.cumulated_counts = np.zeros(1024, dtype=np.float64)
self.cumulated_live_time = 0.0 self.cumulated_live_time = 0.0
def save_hourly_snapshot(self):
"""Sauvegarde le spectre accumulé pendant l'heure écoulée."""
if self.hourly_live_time < 1.0:
return
now = datetime.now()
cps = float(self.hourly_counts.sum() / self.hourly_live_time) if self.hourly_live_time > 0 else 0.0
energy_kev = [round(ENERGY_OFFSET + ENERGY_SLOPE * i, 2) for i in range(1024)]
snapshot = {
"timestamp": now.replace(minute=0, second=0, microsecond=0).isoformat(),
"date": now.strftime("%Y-%m-%d"),
"hour": self.current_hour,
"live_time_s": round(self.hourly_live_time, 1),
"total_counts": int(self.hourly_counts.sum()),
"cps": round(cps, 2),
"energy_kev": energy_kev,
"counts": [round(float(c), 1) for c in self.hourly_counts],
}
filename = f"{now.strftime('%Y-%m-%d')}_{self.current_hour:02d}.json"
filepath = HOURLY_DIR / filename
tmp_path = filepath.with_suffix(".tmp")
with open(tmp_path, "w") as f:
json.dump(snapshot, f)
os.replace(tmp_path, filepath)
log.info(f"Snapshot horaire sauvegardé : {filename} ({self.hourly_live_time/3600:.2f}h)")
def _check_hour_rollover(self):
"""Vérifie le changement d'heure, sauvegarde et réinitialise les compteurs horaires."""
now = datetime.now()
current_hour = now.hour
if current_hour != self.current_hour:
self.save_hourly_snapshot()
self.hourly_counts = np.zeros(1024, dtype=np.float64)
self.hourly_live_time = 0.0
self.current_hour = current_hour
def run(self): def run(self):
"""Boucle principale.""" """Boucle principale."""
log.info("=" * 50) log.info("=" * 50)
@ -370,6 +453,7 @@ class RadiacodeMonitor:
self.log_cps(self.cumulated_counts, self.cumulated_live_time) self.log_cps(self.cumulated_counts, self.cumulated_live_time)
else: else:
self.save_state() self.save_state()
self._check_hour_rollover()
time.sleep(SAMPLE_INTERVAL) time.sleep(SAMPLE_INTERVAL)

View File

@ -307,6 +307,12 @@ class SpectrumGenerator:
# Subtract and clip — same as inference: net = clip(rate - bg_rate, 0, inf) # Subtract and clip — same as inference: net = clip(rate - bg_rate, 0, inf)
spectrum = np.maximum(spectrum - bg_spectrum2, 0) spectrum = np.maximum(spectrum - bg_spectrum2, 0)
# Mask channels below ~30 keV — below this energy the detector signal is
# dominated by X-ray fluorescence and artefacts not modelled in training.
min_channel = max(0, int((30.0 - self.detector_config.calibration_offset_kev)
/ self.detector_config.calibration_slope_kev))
spectrum[:min_channel] = 0
# Normalize if requested # Normalize if requested
if config.normalize: if config.normalize:
spectrum = normalize_spectrum(spectrum, config.normalization_method) spectrum = normalize_spectrum(spectrum, config.normalization_method)