fix(backend): fiabilise le tri des lectures/predictions et la detection de redemarrage a zero
Backend / Tests exigeant une base (push) Failing after 38s
Backend / Lint, typage et tests (push) Successful in 1m29s
Backend / Audit des dépendances (push) Successful in 1m3s
SonarQube / build-back (push) Successful in 1m8s
SonarQube / build-front (push) Successful in 9m36s
SonarQube / test-back (push) Failing after 1m5s
SonarQube / test-front (push) Failing after 5m15s
SonarQube / SonarQube (push) Skipped

This commit is contained in:
Dorian
2026-09-18 16:58:03 +02:00
parent a9e124a97d
commit c059f838bb
8 changed files with 208 additions and 30 deletions
+7 -1
View File
@@ -16,10 +16,16 @@ class PredictionRepository:
) -> Sequence[Prediction]:
# Restreint à `available` : une prévision `insufficient_data`/`error` n'a pas de
# `predicted_value` à comparer à une lecture réelle (détection d'anomalie).
# Piège : `prediction` n'a pas d'unicité sur `(site_id, target_at)` (cf.
# `enervision_ml.score`, qui insère toujours une nouvelle ligne plutôt que d'écraser la
# précédente). `prediction_id` en dernier départage donc les égalités de `target_at` par
# ordre croissant : `_detect_anomaly` construit un dict qui garde le dernier rencontré,
# c'est-à-dire le run le plus récent plutôt qu'une ligne choisie au hasard par le plan
# d'exécution.
requete = (
select(Prediction)
.where(Prediction.target_at >= since, Prediction.status == "available")
.order_by(Prediction.site_id, Prediction.target_at)
.order_by(Prediction.site_id, Prediction.target_at, Prediction.prediction_id)
)
if site_id is not None:
requete = requete.where(Prediction.site_id == site_id)
+4 -1
View File
@@ -37,10 +37,13 @@ class ReadingRepository:
async def list_since(self, *, since: datetime, site_id: str | None = None) -> Sequence[Reading]:
# Trié par site puis par heure croissante : la détection d'alertes (spike) a besoin de
# comparer chaque lecture à celle qui la précède immédiatement pour le même site.
# `reading_id` en dernier départage : `uq_reading_source` autorise deux lignes au même
# `site_id`+`timestamp` quand la `source` diffère (même piège que `latest_for_site`), sans
# quoi l'ordre entre elles ne serait pas garanti d'un appel à l'autre.
requete = (
select(Reading)
.where(Reading.timestamp >= since)
.order_by(Reading.site_id, Reading.timestamp)
.order_by(Reading.site_id, Reading.timestamp, Reading.reading_id)
)
if site_id is not None:
requete = requete.where(Reading.site_id == site_id)
+41 -19
View File
@@ -142,43 +142,65 @@ def _detect_threshold(lectures: Sequence[Reading], sites_par_id: dict[str, Site]
def _detect_spike(lectures: Sequence[Reading]) -> list[Alert]:
# `lectures` est triée par site puis par heure (cf. `ReadingRepository.list_since`) : deux
# lignes consécutives du même site sont donc deux mesures consécutives dans le temps.
# `lectures` est triée par site, heure puis `reading_id` (cf. `ReadingRepository.list_since`) :
# deux lignes consécutives du même site sont donc deux mesures consécutives dans le temps,
# sauf lorsqu'elles partagent le même horodatage (deux `source` différentes pour le même
# instant, permises par `uq_reading_source`) : ce n'est alors pas une variation réelle, on
# l'ignore plutôt que de générer une fausse alerte figée par son `source_alert_id`.
alertes = []
precedente: Reading | None = None
for lecture in lectures:
if precedente is None or precedente.site_id != lecture.site_id:
if (
precedente is None
or precedente.site_id != lecture.site_id
or precedente.timestamp == lecture.timestamp
):
precedente = lecture
continue
avant, apres = precedente.consumption_kw, lecture.consumption_kw
precedente = lecture
if avant is None or apres is None or avant == 0:
if avant is None or apres is None:
continue
if avant == 0:
# Une variation relative n'a pas de sens depuis zéro, mais un redémarrage direct à
# une consommation positive reste le signal le plus alarmant du lot : `critical`
# plutôt qu'un ratio indéfini.
if apres > 0:
alertes.append(_spike_alert(lecture, avant, apres, severity="critical"))
continue
variation = abs(apres - avant) / abs(avant)
if variation < SPIKE_RELATIVE_THRESHOLD:
continue
alertes.append(
Alert(
source_alert_id=f"spike:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}",
site_id=lecture.site_id,
source="enervision",
timestamp=lecture.timestamp,
type="spike",
_spike_alert(
lecture,
avant,
apres,
severity=_severity_from_ratio(variation / SPIKE_RELATIVE_THRESHOLD),
message=(
f"Variation brutale de {variation * 100:.0f}% entre deux lectures "
f"consécutives ({avant:.1f} kW -> {apres:.1f} kW)"
),
value=apres,
threshold=avant,
metric=THRESHOLD_METRIC,
prediction_id=None,
raw_data={},
)
)
return alertes
def _spike_alert(lecture: Reading, avant: float, apres: float, *, severity: str) -> Alert:
return Alert(
source_alert_id=f"spike:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}",
site_id=lecture.site_id,
source="enervision",
timestamp=lecture.timestamp,
type="spike",
severity=severity,
message=(
f"Variation brutale entre deux lectures consécutives ({avant:.1f} kW -> {apres:.1f} kW)"
),
value=apres,
threshold=avant,
metric=THRESHOLD_METRIC,
prediction_id=None,
raw_data={},
)
def _detect_anomaly(lectures: Sequence[Reading], predictions: Sequence[Prediction]) -> list[Alert]:
# Alignement strict (site_id, target_at == timestamp) : `enervision_ml.score` produit une
# cible à l'heure pile suivant la dernière lecture, sur la même grille horaire que `reading`.