302 lines
11 KiB
Python
302 lines
11 KiB
Python
from collections.abc import Sequence
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from app.models.energy import Alert, Prediction, Reading, Site
|
|
from app.repositories.alert import AlertRepository
|
|
from app.repositories.prediction import PredictionRepository
|
|
from app.repositories.reading import ReadingRepository
|
|
from app.repositories.site import SiteRepository
|
|
|
|
# Fenêtre de lectures/prédictions analysée à chaque exécution : assez large pour couvrir une paire
|
|
# de lectures consécutives (spike) et une coupure prolongée (outage), sans réanalyser tout
|
|
# l'historique à chaque lancement manuel du script de détection.
|
|
LOOKBACK = timedelta(hours=48)
|
|
|
|
# Cadence nominale d'une lecture : le CSV historique comme l'API Mock livrent un pas horaire.
|
|
EXPECTED_INTERVAL = timedelta(hours=1)
|
|
# Au-delà de trois pas manqués, on parle de coupure plutôt que d'un simple retard d'ingestion.
|
|
OUTAGE_THRESHOLD = EXPECTED_INTERVAL * 3
|
|
|
|
# +/-50% entre deux lectures consécutives du même site.
|
|
SPIKE_RELATIVE_THRESHOLD = 0.5
|
|
# 30% d'écart entre la consommation réelle et la prévision du même site/instant.
|
|
ANOMALY_RELATIVE_THRESHOLD = 0.3
|
|
# Une prévision quasi nulle rend l'écart relatif ininterprétable ; on l'ignore plutôt.
|
|
ANOMALY_MINIMUM_PREDICTED_VALUE = 1e-6
|
|
|
|
THRESHOLD_METRIC = "consumption_kw"
|
|
ANOMALY_METRIC = "consumption_kwh"
|
|
# `data_quality` -> sévérité du capteur défaillant. `good` est volontairement absent : il ne
|
|
# déclenche jamais d'alerte.
|
|
QUALITE_VERS_SEVERITE: dict[str, str] = {
|
|
"partial": "low",
|
|
"degraded": "medium",
|
|
"critical": "critical",
|
|
}
|
|
|
|
|
|
class AlertService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
alerts: AlertRepository,
|
|
readings: ReadingRepository,
|
|
predictions: PredictionRepository,
|
|
sites: SiteRepository,
|
|
) -> None:
|
|
self._alerts = alerts
|
|
self._readings = readings
|
|
self._predictions = predictions
|
|
self._sites = sites
|
|
|
|
async def list_all(
|
|
self, *, site_id: str | None = None, severity: str | None = None
|
|
) -> Sequence[Alert]:
|
|
return await self._alerts.list_all(site_id=site_id, severity=severity)
|
|
|
|
async def detect(
|
|
self, *, now: datetime | None = None, site_id: str | None = None
|
|
) -> Sequence[Alert]:
|
|
"""Compare les lectures/prévisions récentes aux cinq règles internes et enregistre les
|
|
alertes déclenchées (`source='enervision'`). Idempotent grâce à `source_alert_id` :
|
|
rejouer sur une fenêtre déjà analysée ne recrée pas les mêmes lignes."""
|
|
instant = now or datetime.now(UTC)
|
|
depuis = instant - LOOKBACK
|
|
|
|
sites = await self._sites.list_all()
|
|
if site_id is not None:
|
|
sites = [site for site in sites if site.site_id == site_id]
|
|
sites_par_id = {site.site_id: site for site in sites}
|
|
if not sites_par_id:
|
|
return []
|
|
|
|
lectures = [
|
|
lecture
|
|
for lecture in await self._readings.list_since(since=depuis, site_id=site_id)
|
|
if lecture.site_id in sites_par_id
|
|
]
|
|
predictions = [
|
|
prediction
|
|
for prediction in await self._predictions.list_since(since=depuis, site_id=site_id)
|
|
if prediction.site_id in sites_par_id
|
|
]
|
|
dernieres_lectures = {
|
|
lecture.site_id: lecture
|
|
for lecture in await self._readings.latest_by_site()
|
|
if lecture.site_id in sites_par_id
|
|
}
|
|
|
|
candidates = [
|
|
*_detect_threshold(lectures, sites_par_id),
|
|
*_detect_spike(lectures),
|
|
*_detect_anomaly(lectures, predictions),
|
|
*_detect_outage(sites, dernieres_lectures, instant),
|
|
*_detect_sensor(lectures),
|
|
]
|
|
if not candidates:
|
|
return []
|
|
return await self._alerts.create_many(candidates)
|
|
|
|
|
|
def _severity_from_ratio(ratio: float) -> str:
|
|
if ratio >= 2.0:
|
|
return "critical"
|
|
if ratio >= 1.5:
|
|
return "high"
|
|
if ratio >= 1.2:
|
|
return "medium"
|
|
return "low"
|
|
|
|
|
|
def _detect_threshold(lectures: Sequence[Reading], sites_par_id: dict[str, Site]) -> list[Alert]:
|
|
# Seuil fixe = la capacité déclarée du site : dépasser `capacity_kw` est un dépassement
|
|
# matériel, pas une simple variation, et évite un seuil arbitraire non fourni par le domaine.
|
|
alertes = []
|
|
for lecture in lectures:
|
|
site = sites_par_id[lecture.site_id]
|
|
valeur = lecture.consumption_kw
|
|
if site.capacity_kw is None or site.capacity_kw <= 0 or valeur is None:
|
|
continue
|
|
if valeur <= site.capacity_kw:
|
|
continue
|
|
alertes.append(
|
|
Alert(
|
|
source_alert_id=f"threshold:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}",
|
|
site_id=lecture.site_id,
|
|
source="enervision",
|
|
timestamp=lecture.timestamp,
|
|
type="threshold",
|
|
severity=_severity_from_ratio(valeur / site.capacity_kw),
|
|
message=(
|
|
f"Puissance appelée {valeur:.1f} kW au-dessus de la capacité du site "
|
|
f"({site.capacity_kw:.1f} kW)"
|
|
),
|
|
value=valeur,
|
|
threshold=site.capacity_kw,
|
|
metric=THRESHOLD_METRIC,
|
|
prediction_id=None,
|
|
raw_data={},
|
|
)
|
|
)
|
|
return alertes
|
|
|
|
|
|
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.
|
|
alertes = []
|
|
precedente: Reading | None = None
|
|
for lecture in lectures:
|
|
if precedente is None or precedente.site_id != lecture.site_id:
|
|
precedente = lecture
|
|
continue
|
|
avant, apres = precedente.consumption_kw, lecture.consumption_kw
|
|
precedente = lecture
|
|
if avant is None or apres is None or avant == 0:
|
|
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",
|
|
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 _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`.
|
|
predictions_par_cle = {
|
|
(prediction.site_id, prediction.target_at): prediction
|
|
for prediction in predictions
|
|
if prediction.target_metric == ANOMALY_METRIC
|
|
}
|
|
alertes = []
|
|
for lecture in lectures:
|
|
prediction = predictions_par_cle.get((lecture.site_id, lecture.timestamp))
|
|
reel = lecture.consumption_kwh
|
|
if prediction is None or reel is None or prediction.predicted_value is None:
|
|
continue
|
|
predite = prediction.predicted_value
|
|
if abs(predite) < ANOMALY_MINIMUM_PREDICTED_VALUE:
|
|
continue
|
|
ecart = abs(reel - predite) / abs(predite)
|
|
if ecart < ANOMALY_RELATIVE_THRESHOLD:
|
|
continue
|
|
alertes.append(
|
|
Alert(
|
|
source_alert_id=f"anomaly:{ANOMALY_METRIC}:{lecture.timestamp.isoformat()}",
|
|
site_id=lecture.site_id,
|
|
source="enervision",
|
|
timestamp=lecture.timestamp,
|
|
type="anomaly",
|
|
severity=_severity_from_ratio(ecart / ANOMALY_RELATIVE_THRESHOLD),
|
|
message=(
|
|
f"Écart de {ecart * 100:.0f}% entre la consommation mesurée ({reel:.1f} kWh) "
|
|
f"et la prévision ({predite:.1f} kWh)"
|
|
),
|
|
value=reel,
|
|
threshold=predite,
|
|
metric=ANOMALY_METRIC,
|
|
prediction_id=prediction.prediction_id,
|
|
raw_data={},
|
|
)
|
|
)
|
|
return alertes
|
|
|
|
|
|
def _detect_outage(
|
|
sites: Sequence[Site], dernieres_lectures: dict[str, Reading], now: datetime
|
|
) -> list[Alert]:
|
|
alertes = []
|
|
for site in sites:
|
|
derniere = dernieres_lectures.get(site.site_id)
|
|
if derniere is None:
|
|
alertes.append(
|
|
_outage_alert(
|
|
site.site_id,
|
|
now,
|
|
reference=None,
|
|
message="Aucune lecture n'a jamais été reçue pour ce site",
|
|
severity="critical",
|
|
)
|
|
)
|
|
continue
|
|
absence = now - derniere.timestamp
|
|
if absence < OUTAGE_THRESHOLD:
|
|
continue
|
|
alertes.append(
|
|
_outage_alert(
|
|
site.site_id,
|
|
now,
|
|
reference=derniere.timestamp,
|
|
message=(
|
|
f"Aucune lecture depuis {absence} (dernière lecture : "
|
|
f"{derniere.timestamp.isoformat()})"
|
|
),
|
|
severity=_severity_from_ratio(absence / OUTAGE_THRESHOLD),
|
|
)
|
|
)
|
|
return alertes
|
|
|
|
|
|
def _outage_alert(
|
|
site_id: str, now: datetime, *, reference: datetime | None, message: str, severity: str
|
|
) -> Alert:
|
|
return Alert(
|
|
source_alert_id=f"outage:{reference.isoformat() if reference is not None else 'jamais'}",
|
|
site_id=site_id,
|
|
source="enervision",
|
|
timestamp=now,
|
|
type="outage",
|
|
severity=severity,
|
|
message=message,
|
|
value=None,
|
|
threshold=None,
|
|
metric=None,
|
|
prediction_id=None,
|
|
raw_data={},
|
|
)
|
|
|
|
|
|
def _detect_sensor(lectures: Sequence[Reading]) -> list[Alert]:
|
|
alertes = []
|
|
for lecture in lectures:
|
|
severite = QUALITE_VERS_SEVERITE.get(lecture.data_quality or "")
|
|
if severite is None:
|
|
continue
|
|
raisons = ", ".join(lecture.null_reasons or []) or "raison non précisée"
|
|
alertes.append(
|
|
Alert(
|
|
source_alert_id=f"sensor:{lecture.timestamp.isoformat()}",
|
|
site_id=lecture.site_id,
|
|
source="enervision",
|
|
timestamp=lecture.timestamp,
|
|
type="sensor",
|
|
severity=severite,
|
|
message=f"Qualité de mesure {lecture.data_quality} ({raisons})",
|
|
value=None,
|
|
threshold=None,
|
|
metric=None,
|
|
prediction_id=None,
|
|
raw_data={},
|
|
)
|
|
)
|
|
return alertes
|