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 os
import sys
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
# 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_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):
"""Apply inverse CsI(Tl) non-linear response correction to spectrum channels.
@ -146,6 +155,38 @@ class RadiacodeMonitor:
self.last_report_date = None
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):
"""Tente d'établir une connexion persistante au Radiacode."""
try:
@ -191,6 +232,8 @@ class RadiacodeMonitor:
if live_time > 0 and counts.sum() > 0:
self.cumulated_counts += counts
self.cumulated_live_time += live_time
self.hourly_counts += counts
self.hourly_live_time += live_time
self._rc.spectrum_reset()
log.info(
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)
else:
net_rate = rate[:1023]
net_rate[:MIN_CHANNEL] = 0
isotopes = self.run_inference(net_rate)
state = {
@ -300,6 +344,7 @@ class RadiacodeMonitor:
net_rate = np.clip(rate[:1023] - bg_rate, 0, None)
else:
net_rate = rate[:1023]
net_rate[:MIN_CHANNEL] = 0
results = self.run_inference(net_rate)
@ -343,6 +388,44 @@ class RadiacodeMonitor:
self.cumulated_counts = np.zeros(1024, dtype=np.float64)
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):
"""Boucle principale."""
log.info("=" * 50)
@ -370,6 +453,7 @@ class RadiacodeMonitor:
self.log_cps(self.cumulated_counts, self.cumulated_live_time)
else:
self.save_state()
self._check_hour_rollover()
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)
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
if config.normalize:
spectrum = normalize_spectrum(spectrum, config.normalization_method)