diff --git a/detect/radiacode_monitor.py b/detect/radiacode_monitor.py index 1ffbdb8..55df828 100644 --- a/detect/radiacode_monitor.py +++ b/detect/radiacode_monitor.py @@ -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) diff --git a/train/vega_ml/synthetic_spectra/generator.py b/train/vega_ml/synthetic_spectra/generator.py index 1f54300..75759e7 100644 --- a/train/vega_ml/synthetic_spectra/generator.py +++ b/train/vega_ml/synthetic_spectra/generator.py @@ -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)