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
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user