Compare commits

..
Author SHA1 Message Date
Meryemel-gham 56c6b79a5a style(etl): applique le formatage Ruff aux tests API Mock
Backend / Lint, typage et tests (push) Successful in 1m57s
Backend / Tests exigeant une base (push) Failing after 36s
Backend / Audit des dépendances (push) Successful in 57s
SonarQube / build-back (push) Successful in 1m30s
SonarQube / build-front (push) Successful in 9m37s
SonarQube / test-back (push) Failing after 53s
SonarQube / test-front (push) Failing after 5m5s
SonarQube / SonarQube (push) Skipped
2026-09-21 09:47:07 +02:00
Meryemel-gham 452cfdef85 test(etl): corrige les points bloquants de la revue API Mock 2026-09-21 09:36:10 +02:00
Meryemel-gham 96dd1f834c docs: documente l'ingestion depuis l'API Mock 2026-09-21 09:11:44 +02:00
Meryemel-gham e66ef86729 test(apps): couvre l'import depuis l'API Mock 2026-09-21 09:11:43 +02:00
Meryemel-gham 0318ee6cc5 feat(apps): ajoute l'import depuis l'API Mock 2026-09-21 09:11:43 +02:00
Meryemel-gham 0ddfb1997d feat(apps): configure la connexion à l'API Mock 2026-09-21 09:11:43 +02:00
PhyriosandGitHub b96546cea3 Merge pull request #113 from ineszang/feat/detection-alertes-internes
feat(backend): detecte les alertes internes a partir des lectures et …
2026-09-18 17:00:48 +02:00
Dorian c059f838bb 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
2026-09-18 16:58:03 +02:00
Dorian a9e124a97d feat(backend): detecte les alertes internes a partir des lectures et previsions 2026-09-18 16:10:06 +02:00
Johan LEROY edd5e82d29 fix 2026-09-18 16:04:10 +02:00
Johan LEROYandGitHub 619024f547 Merge pull request #105 from ineszang/feat/service-de-scoring
feat(ml,backend): implemente le service de scoring et GET /predictions
2026-09-18 15:33:12 +02:00
24 changed files with 2800 additions and 104 deletions
+6
View File
@@ -17,3 +17,9 @@ APP_LOG_LEVEL=INFO
APP_SECRET_KEY=change_me
APP_CORS_ORIGINS=http://localhost:4200
BACKEND_PORT=8000
# API Mock EnerVision
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
APP_MOCK_API_USERNAME=change_me
APP_MOCK_API_PASSWORD=change_me
APP_MOCK_API_TIMEOUT_SECONDS=10
+4
View File
@@ -18,3 +18,7 @@ APP_SMTP_HOST=localhost
APP_SMTP_PORT=1025
APP_SMTP_USE_TLS=false
APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
APP_MOCK_API_USERNAME=change_me
APP_MOCK_API_PASSWORD=change_me
APP_MOCK_API_TIMEOUT_SECONDS=10
+6 -1
View File
@@ -180,7 +180,12 @@ SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
def get_alert_service(session: SessionDep) -> AlertService:
return AlertService(alerts=AlertRepository(session))
return AlertService(
alerts=AlertRepository(session),
readings=ReadingRepository(session),
predictions=PredictionRepository(session),
sites=SiteRepository(session),
)
AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)]
+5
View File
@@ -34,6 +34,11 @@ class Settings(BaseSettings):
database_pool_size: int = 5
database_max_overflow: int = 10
mock_api_base_url: str = "https://api-mock.charlieandre.fr"
mock_api_username: str | None = None
mock_api_password: SecretStr | None = None
mock_api_timeout_seconds: float = Field(default=10.0, gt=0)
jwt_issuer: str = "enervision-api"
jwt_audience: str = "enervision-web"
access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
@@ -0,0 +1,68 @@
# Détection d'alertes internes EnerVision (issue #104) : script lancé à la main pour l'instant,
# comme `enervision_ml.score` côté ML, sans automatisation Airflow pour l'ordonnancer.
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, datetime
from app.core.config import get_settings
from app.db.session import get_session_factory
from app.repositories.alert import AlertRepository
from app.repositories.prediction import PredictionRepository
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.alert import AlertService
async def run_detection(*, now: datetime | None = None, site_id: str | None = None) -> int:
"""Exécute les cinq règles de détection et enregistre les nouvelles alertes. Rend le nombre de
lignes effectivement insérées (les doublons de `source_alert_id` sont silencieusement
ignorés)."""
async with get_session_factory()() as session:
service = AlertService(
alerts=AlertRepository(session),
readings=ReadingRepository(session),
predictions=PredictionRepository(session),
sites=SiteRepository(session),
)
nouvelles = await service.detect(now=now, site_id=site_id)
await session.commit()
return len(nouvelles)
def _parse_instant(valeur: str) -> datetime:
instant = datetime.fromisoformat(valeur)
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="python -m app.detection.internal_alerts",
description="Détection d'alertes internes EnerVision",
)
parser.add_argument("--site-id", default=None, help="Limite la détection à un seul site.")
parser.add_argument(
"--now",
type=_parse_instant,
default=None,
help=(
"Instant de référence (ISO 8601, UTC si le fuseau est omis). Défaut : l'heure courante."
),
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
# Échoue tôt si `APP_SECRET_KEY`/`DATABASE_URL` manquent, avant toute requête à la base.
get_settings()
nombre = asyncio.run(run_detection(now=args.now, site_id=args.site_id))
print(f"{nombre} nouvelle(s) alerte(s) enregistrée(s).")
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+315
View File
@@ -0,0 +1,315 @@
from __future__ import annotations
import argparse
import asyncio
import json
from datetime import datetime
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import get_settings
SOURCE_HISTORY = "api_history"
def create_mock_api_client() -> httpx.AsyncClient:
settings = get_settings()
if settings.mock_api_username is None or settings.mock_api_password is None:
raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.")
return httpx.AsyncClient(
base_url=settings.mock_api_base_url.rstrip("/"),
auth=(
settings.mock_api_username,
settings.mock_api_password.get_secret_value(),
),
timeout=settings.mock_api_timeout_seconds,
)
async def fetch_sites(
client: httpx.AsyncClient,
) -> list[dict[str, Any]]:
response = await client.get("/api/v1/sites")
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("La réponse /api/v1/sites doit être une liste.")
return payload
async def upsert_sites(
connection: AsyncConnection,
sites: list[dict[str, Any]],
) -> None:
if not sites:
return
await connection.execute(
text(
"""
INSERT INTO site (
site_id,
site_type,
site_name,
location,
capacity_kw,
status
)
VALUES (
:site_id,
:site_type,
:site_name,
:location,
:capacity_kw,
:status
)
ON CONFLICT (site_id)
DO UPDATE SET
site_type = EXCLUDED.site_type,
site_name = EXCLUDED.site_name,
location = EXCLUDED.location,
capacity_kw = EXCLUDED.capacity_kw,
status = EXCLUDED.status
"""
),
sites,
)
async def fetch_readings(
client: httpx.AsyncClient,
site_id: str,
start_time: datetime,
end_time: datetime,
limit: int = 1000,
) -> list[dict[str, Any]]:
response = await client.get(
"/api/v1/readings",
params={
"site_id": site_id,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"limit": limit,
},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("La réponse /api/v1/readings doit être une liste.")
return payload
def build_reading_row(
reading: dict[str, Any],
) -> dict[str, Any]:
timestamp = datetime.fromisoformat(reading["timestamp"].replace("Z", "+00:00"))
return {
"site_id": reading["site_id"],
"timestamp": timestamp,
"source": SOURCE_HISTORY,
"dataset_id": None,
"consumption_kw": reading.get("consumption_kw"),
"consumption_kwh": reading.get("consumption_kwh"),
"consumption_euros": None,
"voltage_v": reading.get("voltage_v"),
"current_a": reading.get("current_a"),
"power_factor": reading.get("power_factor"),
"temperature_celsius": reading.get("temperature_celsius"),
"humidity_percent": reading.get("humidity_percent"),
"solar_irradiance_wm2": None,
"is_working_hours": None,
"data_quality": reading.get("data_quality"),
"null_reasons": reading.get("null_reasons"),
"imputed_values": None,
"imputation_method": None,
"raw_data": json.dumps(
reading,
ensure_ascii=False,
),
}
READING_INSERT = text(
"""
INSERT INTO reading (
site_id,
timestamp,
source,
dataset_id,
consumption_kw,
consumption_kwh,
consumption_euros,
voltage_v,
current_a,
power_factor,
temperature_celsius,
humidity_percent,
solar_irradiance_wm2,
is_working_hours,
data_quality,
null_reasons,
imputed_values,
imputation_method,
raw_data
)
VALUES (
:site_id,
:timestamp,
:source,
:dataset_id,
:consumption_kw,
:consumption_kwh,
:consumption_euros,
:voltage_v,
:current_a,
:power_factor,
:temperature_celsius,
:humidity_percent,
:solar_irradiance_wm2,
:is_working_hours,
:data_quality,
:null_reasons,
CAST(:imputed_values AS jsonb),
:imputation_method,
CAST(:raw_data AS jsonb)
)
ON CONFLICT DO NOTHING
"""
)
def build_reading_batch(
readings: list[dict[str, Any]],
) -> list[dict[str, Any]]:
return [build_reading_row(reading) for reading in readings]
async def import_mock_api_history(
start_time: datetime,
end_time: datetime,
limit: int,
dry_run: bool,
) -> None:
settings = get_settings()
async with create_mock_api_client() as client:
sites = await fetch_sites(client)
print(f"Sites récupérés : {len(sites)}")
all_readings: list[dict[str, Any]] = []
for site in sites:
site_id = site["site_id"]
readings = await fetch_readings(
client=client,
site_id=site_id,
start_time=start_time,
end_time=end_time,
limit=limit,
)
print(f"{site_id}: {len(readings)} lectures")
all_readings.extend(readings)
print(f"Lectures récupérées : {len(all_readings)}")
if dry_run:
print("Dry-run terminé : aucune donnée écrite.")
return
engine = create_async_engine(
str(settings.database_url),
pool_pre_ping=True,
)
try:
async with engine.begin() as connection:
await upsert_sites(
connection,
sites,
)
rows = build_reading_batch(all_readings)
if rows:
await connection.execute(
READING_INSERT,
rows,
)
finally:
await engine.dispose()
print("Import API Mock terminé.")
def parse_datetime(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=("Import historique depuis l'API Mock EnerVision"))
parser.add_argument(
"--start-time",
required=True,
type=parse_datetime,
)
parser.add_argument(
"--end-time",
required=True,
type=parse_datetime,
)
parser.add_argument(
"--limit",
type=int,
default=1000,
)
parser.add_argument(
"--dry-run",
action="store_true",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
if args.limit < 1 or args.limit > 1000:
raise ValueError("--limit doit être compris entre 1 et 1000.")
if args.start_time >= args.end_time:
raise ValueError("--start-time doit être antérieur à --end-time.")
asyncio.run(
import_mock_api_history(
start_time=args.start_time,
end_time=args.end_time,
limit=args.limit,
dry_run=args.dry_run,
)
)
if __name__ == "__main__":
main()
+34
View File
@@ -1,6 +1,7 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert
@@ -19,3 +20,36 @@ class AlertRepository:
if severity is not None:
requete = requete.where(Alert.severity == severity)
return (await self._session.scalars(requete)).all()
async def create_many(self, alerts: Sequence[Alert]) -> Sequence[Alert]:
# `ON CONFLICT DO NOTHING` sur `uq_alert_source_reference` : rejouer la détection sur une
# fenêtre qui recouvre une exécution précédente ne doit pas dupliquer une alerte déjà
# enregistrée. `RETURNING` ne renvoie donc que les lignes effectivement insérées.
if not alerts:
return []
valeurs = [
{
"source_alert_id": alerte.source_alert_id,
"site_id": alerte.site_id,
"source": alerte.source,
"timestamp": alerte.timestamp,
"type": alerte.type,
"severity": alerte.severity,
"message": alerte.message,
"value": alerte.value,
"threshold": alerte.threshold,
"metric": alerte.metric,
"prediction_id": alerte.prediction_id,
"raw_data": alerte.raw_data,
}
for alerte in alerts
]
requete = (
insert(Alert)
.values(valeurs)
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
.returning(Alert)
)
resultat = await self._session.execute(requete)
await self._session.flush()
return resultat.scalars().all()
@@ -1,4 +1,5 @@
from collections.abc import Sequence
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,6 +11,26 @@ class PredictionRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_since(
self, *, since: datetime, site_id: str | None = None
) -> 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, Prediction.prediction_id)
)
if site_id is not None:
requete = requete.where(Prediction.site_id == site_id)
return (await self._session.scalars(requete)).all()
async def latest_by_site(self) -> Sequence[Prediction]:
# `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule
# ligne par site, la plus récente grâce à l'ordre composite qui suit. Même mécanisme que
+15
View File
@@ -34,6 +34,21 @@ class ReadingRepository:
lecture: Reading | None = await self._session.scalar(requete)
return lecture
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, Reading.reading_id)
)
if site_id is not None:
requete = requete.where(Reading.site_id == site_id)
return (await self._session.scalars(requete)).all()
async def list_history(
self,
*,
+311 -2
View File
@@ -1,14 +1,323 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from app.models.energy import Alert
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) -> None:
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, 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
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:
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(
_spike_alert(
lecture,
avant,
apres,
severity=_severity_from_ratio(variation / SPIKE_RELATIVE_THRESHOLD),
)
)
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`.
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
+1 -1
View File
@@ -17,6 +17,7 @@ dependencies = [
"argon2-cffi>=23.1",
"anyio>=4.0",
"aiosmtplib>=5.1.3",
"httpx>=0.28.1",
"pandas>=3.0.5",
]
@@ -27,7 +28,6 @@ dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
"httpx>=0.28.1",
"pandas-stubs>=3.0.5.260914",
]
@@ -0,0 +1,622 @@
import json
import sys
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from httpx import AsyncClient, MockTransport, Request, Response
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
import app.etl.mock_api_import as mock_api_import
from app.etl.mock_api_import import (
READING_INSERT,
SOURCE_HISTORY,
build_reading_batch,
build_reading_row,
fetch_readings,
fetch_sites,
upsert_sites,
)
def make_site() -> dict[str, Any]:
return {
"site_id": "SITE001",
"site_type": "office",
"site_name": "Bureau Paris La Défense",
"location": "Paris, France",
"capacity_kw": 200,
"status": "active",
}
def make_reading() -> dict[str, Any]:
return {
"timestamp": "2024-06-15T12:00:00Z",
"site_id": "SITE001",
"site_type": "office",
"consumption_kw": 87.34,
"consumption_kwh": 87.34,
"voltage_v": 401.2,
"current_a": 132.5,
"power_factor": 0.923,
"temperature_celsius": 22.1,
"humidity_percent": 58.4,
"null_reasons": [],
"data_quality": "good",
}
async def test_fetch_sites_returns_sites() -> None:
def handler(request: Request) -> Response:
assert request.url.path == "/api/v1/sites"
return Response(
status_code=200,
json=[make_site()],
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
sites = await fetch_sites(client)
assert len(sites) == 1
assert sites[0]["site_id"] == "SITE001"
assert sites[0]["site_type"] == "office"
async def test_fetch_readings_sends_expected_query_parameters() -> None:
captured_params: dict[str, str] = {}
def handler(request: Request) -> Response:
nonlocal captured_params
captured_params = dict(request.url.params)
return Response(
status_code=200,
json=[make_reading()],
)
transport = MockTransport(handler)
start_time = datetime.fromisoformat("2024-06-15T12:00:00")
end_time = datetime.fromisoformat("2024-06-15T13:00:00")
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
readings = await fetch_readings(
client=client,
site_id="SITE001",
start_time=start_time,
end_time=end_time,
limit=60,
)
assert len(readings) == 1
assert captured_params["site_id"] == "SITE001"
assert captured_params["start_time"] == "2024-06-15T12:00:00"
assert captured_params["end_time"] == "2024-06-15T13:00:00"
assert captured_params["limit"] == "60"
async def test_fetch_readings_rejects_non_list_response() -> None:
def handler(request: Request) -> Response:
return Response(
status_code=200,
json={"unexpected": "payload"},
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
with pytest.raises(
ValueError,
match="La réponse /api/v1/readings doit être une liste",
):
await fetch_readings(
client=client,
site_id="SITE001",
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=60,
)
async def test_fetch_readings_raises_on_http_error() -> None:
def handler(request: Request) -> Response:
return Response(
status_code=404,
json={"detail": "Site non trouvé"},
)
transport = MockTransport(handler)
async with AsyncClient(
transport=transport,
base_url="https://mock.test",
) as client:
with pytest.raises(httpx.HTTPStatusError):
await fetch_readings(
client=client,
site_id="SITE999",
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=60,
)
def test_build_reading_row_respects_database_contract() -> None:
reading = make_reading()
row = build_reading_row(reading)
assert row["site_id"] == "SITE001"
assert row["source"] == SOURCE_HISTORY
assert row["source"] == "api_history"
assert row["dataset_id"] is None
assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00")
assert row["consumption_kw"] == 87.34
assert row["consumption_kwh"] == 87.34
assert row["data_quality"] == "good"
assert row["null_reasons"] == []
assert row["imputed_values"] is None
assert row["imputation_method"] is None
def test_build_reading_row_keeps_null_values_and_quality() -> None:
reading = make_reading()
reading["consumption_kw"] = None
reading["consumption_kwh"] = None
reading["voltage_v"] = None
reading["current_a"] = None
reading["power_factor"] = None
reading["data_quality"] = "degraded"
reading["null_reasons"] = [
"consumption_sensor_failure",
"electrical_sensor_failure",
]
row = build_reading_row(reading)
assert row["consumption_kw"] is None
assert row["consumption_kwh"] is None
assert row["voltage_v"] is None
assert row["current_a"] is None
assert row["power_factor"] is None
assert row["data_quality"] == "degraded"
assert row["null_reasons"] == [
"consumption_sensor_failure",
"electrical_sensor_failure",
]
assert row["imputed_values"] is None
assert row["imputation_method"] is None
def test_build_reading_row_keeps_raw_source_data() -> None:
reading = make_reading()
row = build_reading_row(reading)
raw_data = json.loads(row["raw_data"])
assert raw_data == reading
def test_build_reading_batch_transforms_all_readings() -> None:
first = make_reading()
second = make_reading()
second["timestamp"] = "2024-06-15T12:01:00Z"
second["consumption_kw"] = 90.5
rows = build_reading_batch([first, second])
assert len(rows) == 2
assert rows[0]["site_id"] == "SITE001"
assert rows[0]["consumption_kw"] == 87.34
assert rows[1]["site_id"] == "SITE001"
assert rows[1]["consumption_kw"] == 90.5
def test_create_mock_api_client_requires_credentials(
monkeypatch: pytest.MonkeyPatch,
) -> None:
settings = SimpleNamespace(
mock_api_username=None,
mock_api_password=None,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: settings,
)
with pytest.raises(
ValueError,
match="Les identifiants de l'API Mock ne sont pas configurés",
):
mock_api_import.create_mock_api_client()
async def test_create_mock_api_client_uses_configuration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
password = MagicMock()
password.get_secret_value.return_value = "test-password"
settings = SimpleNamespace(
mock_api_base_url="https://mock.test/",
mock_api_username="test-user",
mock_api_password=password,
mock_api_timeout_seconds=10.0,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: settings,
)
client = mock_api_import.create_mock_api_client()
try:
assert str(client.base_url) == "https://mock.test"
assert client.timeout.connect == 10.0
finally:
await client.aclose()
async def test_upsert_sites_with_empty_list_does_nothing() -> None:
connection = AsyncMock()
await upsert_sites(
connection,
[],
)
connection.execute.assert_not_awaited()
async def test_import_mock_api_history_dry_run_does_not_write(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def handler(request: Request) -> Response:
if request.url.path == "/api/v1/sites":
return Response(
status_code=200,
json=[make_site()],
)
if request.url.path == "/api/v1/readings":
return Response(
status_code=200,
json=[make_reading()],
)
return Response(status_code=404)
transport = MockTransport(handler)
client = AsyncClient(
transport=transport,
base_url="https://mock.test",
)
monkeypatch.setattr(
mock_api_import,
"create_mock_api_client",
lambda: client,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: SimpleNamespace(
database_url="postgresql+asyncpg://unused",
),
)
create_engine_mock = MagicMock()
monkeypatch.setattr(
mock_api_import,
"create_async_engine",
create_engine_mock,
)
await mock_api_import.import_mock_api_history(
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=60,
dry_run=True,
)
create_engine_mock.assert_not_called()
async def test_import_mock_api_history_loads_data(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def handler(request: Request) -> Response:
if request.url.path == "/api/v1/sites":
return Response(
status_code=200,
json=[make_site()],
)
if request.url.path == "/api/v1/readings":
return Response(
status_code=200,
json=[make_reading()],
)
return Response(status_code=404)
transport = MockTransport(handler)
client = AsyncClient(
transport=transport,
base_url="https://mock.test",
)
monkeypatch.setattr(
mock_api_import,
"create_mock_api_client",
lambda: client,
)
monkeypatch.setattr(
mock_api_import,
"get_settings",
lambda: SimpleNamespace(
database_url="postgresql+asyncpg://test:test@localhost/test",
),
)
connection = AsyncMock()
transaction_context = MagicMock()
transaction_context.__aenter__ = AsyncMock(
return_value=connection,
)
transaction_context.__aexit__ = AsyncMock(
return_value=None,
)
engine = MagicMock()
engine.begin.return_value = transaction_context
engine.dispose = AsyncMock()
create_engine_mock = MagicMock(
return_value=engine,
)
upsert_sites_mock = AsyncMock()
monkeypatch.setattr(
mock_api_import,
"create_async_engine",
create_engine_mock,
)
monkeypatch.setattr(
mock_api_import,
"upsert_sites",
upsert_sites_mock,
)
await mock_api_import.import_mock_api_history(
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
limit=60,
dry_run=False,
)
create_engine_mock.assert_called_once_with(
"postgresql+asyncpg://test:test@localhost/test",
pool_pre_ping=True,
)
upsert_sites_mock.assert_awaited_once_with(
connection,
[make_site()],
)
connection.execute.assert_awaited_once()
engine.dispose.assert_awaited_once()
def test_parse_datetime_accepts_z_suffix() -> None:
result = mock_api_import.parse_datetime(
"2024-06-15T12:00:00Z",
)
assert result == datetime.fromisoformat(
"2024-06-15T12:00:00+00:00",
)
def test_parse_args_reads_cli_parameters(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sys,
"argv",
[
"mock_api_import",
"--start-time",
"2024-06-15T12:00:00Z",
"--end-time",
"2024-06-15T13:00:00Z",
"--limit",
"60",
"--dry-run",
],
)
args = mock_api_import.parse_args()
assert args.start_time == datetime.fromisoformat(
"2024-06-15T12:00:00+00:00",
)
assert args.end_time == datetime.fromisoformat(
"2024-06-15T13:00:00+00:00",
)
assert args.limit == 60
assert args.dry_run is True
def test_main_rejects_limit_out_of_bounds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sys,
"argv",
[
"mock_api_import",
"--start-time",
"2024-06-15T12:00:00Z",
"--end-time",
"2024-06-15T13:00:00Z",
"--limit",
"0",
],
)
with pytest.raises(
ValueError,
match="--limit doit être compris entre 1 et 1000",
):
mock_api_import.main()
def test_main_rejects_invalid_period(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sys,
"argv",
[
"mock_api_import",
"--start-time",
"2024-06-15T14:00:00Z",
"--end-time",
"2024-06-15T13:00:00Z",
"--limit",
"60",
],
)
with pytest.raises(
ValueError,
match="--start-time doit être antérieur à --end-time",
):
mock_api_import.main()
def test_main_runs_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
start_time = datetime.fromisoformat(
"2024-06-15T12:00:00+00:00",
)
end_time = datetime.fromisoformat(
"2024-06-15T13:00:00+00:00",
)
import_mock = AsyncMock()
monkeypatch.setattr(
mock_api_import,
"parse_args",
lambda: SimpleNamespace(
start_time=start_time,
end_time=end_time,
limit=60,
dry_run=True,
),
)
monkeypatch.setattr(
mock_api_import,
"import_mock_api_history",
import_mock,
)
mock_api_import.main()
import_mock.assert_awaited_once_with(
start_time=start_time,
end_time=end_time,
limit=60,
dry_run=True,
)
@pytest.mark.integration
async def test_reading_insert_is_idempotent(
session: AsyncSession,
) -> None:
reading = make_reading()
row = build_reading_row(reading)
connection = await session.connection()
await upsert_sites(
connection,
[make_site()],
)
await session.execute(
READING_INSERT,
[row],
)
await session.execute(
READING_INSERT,
[row],
)
result = await session.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE site_id = :site_id
AND timestamp = :timestamp
AND source = :source
"""
),
{
"site_id": row["site_id"],
"timestamp": row["timestamp"],
"source": row["source"],
},
)
assert result.scalar_one() == 1
await session.rollback()
@@ -89,3 +89,60 @@ async def test_list_all_returns_an_empty_list_when_there_is_nothing(
alertes = await depot.list_all(site_id=identifiant_site())
assert list(alertes) == []
def _alerte_a_inserer(*, site_id: str, source_alert_id: str) -> Alert:
return Alert(
source_alert_id=source_alert_id,
site_id=site_id,
source="enervision",
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
type="threshold",
severity="high",
message="Dépassement du seuil configuré",
value=812.5,
threshold=720.0,
metric="consumption_kw",
prediction_id=None,
raw_data={},
)
async def test_create_many_inserts_every_alert(session: AsyncSession) -> None:
site = await creer_site(session)
depot = AlertRepository(session)
creees = await depot.create_many(
[
_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:a"),
_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:b"),
]
)
identifiants = [a.alert_id for a in creees]
await session.rollback()
assert len(identifiants) == 2
assert all(identifiant is not None for identifiant in identifiants)
async def test_create_many_skips_a_duplicate_source_alert_id(session: AsyncSession) -> None:
site = await creer_site(session)
depot = AlertRepository(session)
await depot.create_many(
[_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:rejouee")]
)
rejouees = await depot.create_many(
[_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:rejouee")]
)
await session.rollback()
assert rejouees == []
async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession) -> None:
depot = AlertRepository(session)
creees = await depot.create_many([])
assert creees == []
@@ -29,6 +29,85 @@ async def creer_prediction(
return prediction
async def test_list_since_excludes_predictions_before_the_cutoff(session: AsyncSession) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
dedans = await creer_prediction(
session, site_id=site.site_id, target_at=datetime(2026, 9, 16, tzinfo=UTC)
)
await creer_prediction(
session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC)
)
resultats = await depot.list_since(
since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id
)
identifiants = [p.prediction_id for p in resultats]
await session.rollback()
assert identifiants == [dedans.prediction_id]
async def test_list_since_excludes_predictions_that_are_not_available(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
await creer_prediction(
session,
site_id=site.site_id,
target_at=datetime(2026, 9, 16, tzinfo=UTC),
status="insufficient_data",
predicted_value=None,
failure_reason="pas assez d'historique",
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
await session.rollback()
assert list(resultats) == []
async def test_list_since_breaks_a_target_at_tie_by_ascending_prediction_id(
session: AsyncSession,
) -> None:
# `prediction` n'a pas d'unicité sur `(site_id, target_at)` : deux runs de scoring sans
# nouvelle lecture entre-temps produisent deux lignes `available` à la même cible. Sans ce
# départage, `_detect_anomaly` retiendrait une ligne au hasard plutôt que le run le plus
# récent.
site = await creer_site(session)
depot = PredictionRepository(session)
cible = datetime(2026, 9, 16, tzinfo=UTC)
premier_run = await creer_prediction(
session, site_id=site.site_id, target_at=cible, predicted_value=10.0
)
second_run = await creer_prediction(
session, site_id=site.site_id, target_at=cible, predicted_value=20.0
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
identifiants = [p.prediction_id for p in resultats]
await session.rollback()
assert identifiants == [premier_run.prediction_id, second_run.prediction_id]
async def test_list_since_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = PredictionRepository(session)
voulue = await creer_prediction(session, site_id=premier.site_id)
await creer_prediction(session, site_id=second.site_id)
resultats = await depot.list_since(
since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id
)
identifiants = [p.prediction_id for p in resultats]
await session.rollback()
assert identifiants == [voulue.prediction_id]
async def test_latest_by_site_keeps_only_the_most_recent_target(session: AsyncSession) -> None:
site = await creer_site(session)
depot = PredictionRepository(session)
@@ -155,6 +155,79 @@ async def test_latest_for_site_ignores_the_readings_of_the_other_sites(
assert trouvee is None
async def test_list_since_orders_by_site_then_by_time_ascending(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
plus_recente = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC)
)
plus_ancienne = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [plus_ancienne.reading_id, plus_recente.reading_id]
async def test_list_since_excludes_readings_before_the_cutoff(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
dedans = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC)
)
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC))
resultats = await depot.list_since(
since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [dedans.reading_id]
async def test_list_since_breaks_a_timestamp_tie_by_ascending_reading_id(
session: AsyncSession,
) -> None:
# `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 ce départage, `_detect_spike` traiterait
# cette paire comme une variation réelle selon un ordre non garanti par le plan d'exécution.
site = await creer_site(session)
depot = ReadingRepository(session)
horodatage = datetime(2026, 9, 16, tzinfo=UTC)
premiere = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10
)
seconde = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [premiere.reading_id, seconde.reading_id]
async def test_list_since_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = ReadingRepository(session)
voulue = await creer_lecture(session, site_id=premier.site_id)
await creer_lecture(session, site_id=second.site_id)
resultats = await depot.list_since(
since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [voulue.reading_id]
async def test_list_history_orders_the_readings_by_timestamp_descending(
session: AsyncSession,
) -> None:
+408 -6
View File
@@ -1,7 +1,10 @@
from datetime import UTC, datetime
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from app.models.energy import Alert
from app.services.alert import AlertService
from app.services.alert import OUTAGE_THRESHOLD, AlertService, _severity_from_ratio
NOW = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def alert(
@@ -26,10 +29,36 @@ def alert(
)
@dataclass
class FauxSite:
site_id: str
capacity_kw: float | None = None
@dataclass
class FauxLecture:
site_id: str
timestamp: datetime
consumption_kw: float | None = None
consumption_kwh: float | None = None
data_quality: str | None = None
null_reasons: list[str] | None = None
@dataclass
class FauxPrediction:
site_id: str
target_at: datetime
predicted_value: float | None
target_metric: str = "consumption_kwh"
prediction_id: int = 1
class FakeRepository:
def __init__(self, alerts: list[Alert]) -> None:
self._alerts = alerts
self.appels: list[tuple[str | None, str | None]] = []
self.crees: list[Alert] = []
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
@@ -37,19 +66,392 @@ class FakeRepository:
self.appels.append((site_id, severity))
return self._alerts
async def create_many(self, alerts: list[Alert]) -> list[Alert]:
self.crees = list(alerts)
return self.crees
@dataclass
class FauxDepotLectures:
depuis: list[FauxLecture] = field(default_factory=list)
dernieres: list[FauxLecture] = field(default_factory=list)
async def list_since(self, *, since: datetime, site_id: str | None = None) -> list[FauxLecture]:
return [lecture for lecture in self.depuis if site_id is None or lecture.site_id == site_id]
async def latest_by_site(self) -> list[FauxLecture]:
return self.dernieres
@dataclass
class FauxDepotPredictions:
predictions: list[FauxPrediction] = field(default_factory=list)
async def list_since(
self, *, since: datetime, site_id: str | None = None
) -> list[FauxPrediction]:
return [p for p in self.predictions if site_id is None or p.site_id == site_id]
@dataclass
class FauxDepotSites:
sites: list[FauxSite]
async def list_all(self) -> list[FauxSite]:
return self.sites
def service(
*,
sites: list[FauxSite],
lectures: list[FauxLecture] | None = None,
dernieres: list[FauxLecture] | None = None,
predictions: list[FauxPrediction] | None = None,
alerts: FakeRepository | None = None,
) -> tuple[AlertService, FakeRepository]:
depot_alertes = alerts or FakeRepository([])
dernieres_lectures = dernieres if dernieres is not None else (lectures or [])
return (
AlertService(
alerts=depot_alertes, # type: ignore[arg-type]
readings=FauxDepotLectures(depuis=lectures or [], dernieres=dernieres_lectures), # type: ignore[arg-type]
predictions=FauxDepotPredictions(predictions or []), # type: ignore[arg-type]
sites=FauxDepotSites(sites), # type: ignore[arg-type]
),
depot_alertes,
)
async def test_list_all_returns_the_repository_alerts() -> None:
service = AlertService(alerts=FakeRepository([alert(1), alert(2)]))
svc, _ = service(sites=[], alerts=FakeRepository([alert(1), alert(2)]))
alertes = await service.list_all()
alertes = await svc.list_all()
assert [a.alert_id for a in alertes] == [1, 2]
async def test_list_all_relays_the_filters_to_the_repository() -> None:
depot = FakeRepository([])
service = AlertService(alerts=depot)
svc, _ = service(sites=[], alerts=depot)
await service.list_all(site_id="site-1", severity="critical")
await svc.list_all(site_id="site-1", severity="critical")
assert depot.appels == [("site-1", "critical")]
async def test_detect_raises_a_threshold_alert_above_site_capacity() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=100.0)],
lectures=[FauxLecture("A", NOW, consumption_kw=150.0)],
)
await svc.detect(now=NOW)
(candidate,) = depot.crees
assert candidate.type == "threshold"
assert candidate.severity == "high"
assert candidate.value == 150.0
assert candidate.threshold == 100.0
assert candidate.metric == "consumption_kw"
async def test_detect_ignores_a_reading_within_capacity() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=100.0)],
lectures=[FauxLecture("A", NOW, consumption_kw=80.0)],
)
await svc.detect(now=NOW)
assert depot.crees == []
async def test_detect_ignores_threshold_when_the_site_has_no_declared_capacity() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=None)],
lectures=[FauxLecture("A", NOW, consumption_kw=9999.0)],
)
await svc.detect(now=NOW)
assert depot.crees == []
async def test_detect_raises_a_spike_alert_on_a_brutal_consecutive_variation() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=100.0),
FauxLecture("A", NOW, consumption_kw=160.0),
],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "spike"]
assert candidate.value == 160.0
assert candidate.threshold == 100.0
assert candidate.timestamp == NOW
async def test_detect_ignores_a_moderate_consecutive_variation() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=100.0),
FauxLecture("A", NOW, consumption_kw=110.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_never_compares_consecutive_readings_across_two_sites() -> None:
svc, depot = service(
sites=[FauxSite("A"), FauxSite("B")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=10.0),
FauxLecture("B", NOW, consumption_kw=1000.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_raises_an_anomaly_alert_far_from_the_matching_prediction() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[FauxPrediction("A", target_at=NOW, predicted_value=70.0)],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "anomaly"]
assert candidate.value == 100.0
assert candidate.threshold == 70.0
assert candidate.metric == "consumption_kwh"
assert candidate.prediction_id == 1
async def test_detect_ignores_a_reading_close_to_its_prediction() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[FauxPrediction("A", target_at=NOW, predicted_value=95.0)],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "anomaly"] == []
async def test_detect_ignores_a_prediction_whose_target_at_does_not_match_the_reading() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[FauxPrediction("A", target_at=NOW - timedelta(hours=1), predicted_value=1.0)],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "anomaly"] == []
async def test_detect_keeps_the_most_recent_run_when_two_predictions_share_the_same_target() -> (
None
):
# `PredictionRepository.list_since` départage les égalités de `target_at` par `prediction_id`
# croissant : le repository fait donc déjà passer le run le plus récent en dernier dans la
# liste, et c'est ce dernier que le dict de `_detect_anomaly` doit retenir.
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[
FauxPrediction("A", target_at=NOW, predicted_value=100.0, prediction_id=1),
FauxPrediction("A", target_at=NOW, predicted_value=70.0, prediction_id=2),
],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "anomaly"]
assert candidate.threshold == 70.0
assert candidate.prediction_id == 2
async def test_detect_raises_an_outage_alert_past_the_threshold() -> None:
derniere = NOW - OUTAGE_THRESHOLD - timedelta(minutes=1)
svc, depot = service(
sites=[FauxSite("A")],
lectures=[],
dernieres=[FauxLecture("A", derniere)],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "outage"]
assert candidate.severity in {"low", "medium", "high", "critical"}
async def test_detect_ignores_a_site_still_within_the_outage_threshold() -> None:
derniere = NOW - OUTAGE_THRESHOLD + timedelta(minutes=1)
svc, depot = service(
sites=[FauxSite("A")],
lectures=[],
dernieres=[FauxLecture("A", derniere)],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "outage"] == []
async def test_detect_raises_a_critical_outage_alert_for_a_site_never_read() -> None:
svc, depot = service(sites=[FauxSite("A")], lectures=[], dernieres=[])
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "outage"]
assert candidate.severity == "critical"
assert candidate.source_alert_id == "outage:jamais"
async def test_detect_raises_a_sensor_alert_on_a_degraded_reading() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, data_quality="critical", null_reasons=["missing:x"])],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "sensor"]
assert candidate.severity == "critical"
async def test_detect_ignores_a_good_quality_reading_for_the_sensor_rule() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, data_quality="good")],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "sensor"] == []
async def test_detect_scopes_to_a_single_site_when_asked() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=100.0), FauxSite("B", capacity_kw=100.0)],
lectures=[
FauxLecture("A", NOW, consumption_kw=150.0),
FauxLecture("B", NOW, consumption_kw=150.0),
],
)
await svc.detect(now=NOW, site_id="A")
assert {a.site_id for a in depot.crees} == {"A"}
async def test_detect_returns_early_when_there_is_no_site() -> None:
svc, depot = service(sites=[])
resultat = await svc.detect(now=NOW)
assert resultat == []
assert depot.crees == []
async def test_detect_ignores_a_spike_pair_with_a_missing_measurement() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=None),
FauxLecture("A", NOW, consumption_kw=160.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_ignores_a_reading_still_at_zero_after_a_previous_zero() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0),
FauxLecture("A", NOW, consumption_kw=0.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_raises_a_critical_spike_when_a_site_restarts_from_zero() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0),
FauxLecture("A", NOW, consumption_kw=50.0),
],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "spike"]
assert candidate.severity == "critical"
assert candidate.value == 50.0
assert candidate.threshold == 0.0
async def test_detect_ignores_a_spike_pair_sharing_the_same_timestamp() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW, consumption_kw=100.0),
FauxLecture("A", NOW, consumption_kw=160.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_ignores_an_anomaly_when_the_prediction_is_near_zero() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=5.0)],
predictions=[FauxPrediction("A", target_at=NOW, predicted_value=0.0)],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "anomaly"] == []
def test_severity_from_ratio_covers_every_band() -> None:
assert _severity_from_ratio(1.0) == "low"
assert _severity_from_ratio(1.2) == "medium"
assert _severity_from_ratio(1.5) == "high"
assert _severity_from_ratio(2.0) == "critical"
async def test_detect_does_not_call_create_many_when_nothing_triggers() -> None:
svc, depot = service(
sites=[FauxSite("A", capacity_kw=100.0)],
lectures=[FauxLecture("A", NOW, consumption_kw=10.0, data_quality="good")],
)
resultat = await svc.detect(now=NOW)
assert resultat == []
assert depot.crees == []
@@ -0,0 +1,87 @@
from datetime import UTC, datetime
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_session_factory
from app.detection import internal_alerts
from app.repositories.alert import AlertRepository
from tests.repositories.test_reading import creer_lecture
from tests.repositories.test_site import creer as creer_site
def test_parse_args_defaults_to_no_site_and_no_instant() -> None:
arguments = internal_alerts.parse_args([])
assert arguments.site_id is None
assert arguments.now is None
def test_parse_args_reads_the_site_id() -> None:
arguments = internal_alerts.parse_args(["--site-id", "site-1"])
assert arguments.site_id == "site-1"
def test_parse_args_parses_the_instant_option() -> None:
arguments = internal_alerts.parse_args(["--now", "2026-09-16T12:00:00+00:00"])
assert arguments.now == datetime(2026, 9, 16, 12, tzinfo=UTC)
def test_parse_instant_treats_a_naive_datetime_as_utc() -> None:
assert internal_alerts._parse_instant("2026-09-16T12:00:00") == datetime(
2026, 9, 16, 12, tzinfo=UTC
)
def test_main_prints_how_many_alerts_were_recorded(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
async def fausse_execution(*, now: datetime | None, site_id: str | None) -> int:
return 3
monkeypatch.setattr(internal_alerts, "run_detection", fausse_execution)
code = internal_alerts.main([])
assert code == 0
assert "3 nouvelle" in capsys.readouterr().out
@pytest.mark.integration
async def test_run_detection_writes_a_threshold_alert_end_to_end(session: AsyncSession) -> None:
# `run_detection` ouvre sa propre session et commite : `session.rollback()` seul ne défait
# rien ici (contrairement au reste de la suite), d'où le nettoyage explicite ci-dessous, sur
# le modèle de `tests/api/test_matrice_acces.py`.
site = await creer_site(session, capacity_kw=100.0)
site_id = site.site_id
instant = datetime(2026, 9, 16, 12, tzinfo=UTC)
await creer_lecture(session, site_id=site_id, timestamp=instant, consumption_kw=150.0)
await session.commit()
try:
nombre = await internal_alerts.run_detection(now=instant, site_id=site_id)
alertes = await AlertRepository(session).list_all(site_id=site_id)
types = [a.type for a in alertes]
await session.rollback()
assert nombre == 1
assert types == ["threshold"]
finally:
# `site.site_id` n'est plus sûr après `session.rollback()` : le rollback expire tous les
# objets de la session (indépendamment d'`expire_on_commit`), et y accéder ici relance une
# requête hors contexte async. D'où `site_id`, capturé avant.
async with get_session_factory()() as nettoyage:
await nettoyage.execute(
text("delete from alert where site_id = :site_id"), {"site_id": site_id}
)
await nettoyage.execute(
text("delete from reading where site_id = :site_id"), {"site_id": site_id}
)
await nettoyage.execute(
text("delete from site where site_id = :site_id"), {"site_id": site_id}
)
await nettoyage.commit()
+2 -2
View File
@@ -326,6 +326,7 @@ dependencies = [
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pandas" },
{ name = "prometheus-fastapi-instrumentator" },
{ name = "pydantic", extra = ["email"] },
@@ -338,7 +339,6 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pandas-stubs" },
{ name = "pytest" },
@@ -355,6 +355,7 @@ requires-dist = [
{ name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pandas", specifier = ">=3.0.5" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.13.5" },
@@ -367,7 +368,6 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", specifier = ">=2.3.1" },
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
{ name = "pytest", specifier = ">=9.1.1" },
+6
View File
@@ -50,6 +50,12 @@ services:
APP_SECRET_KEY: ${APP_SECRET_KEY:?}
APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:-https://api-mock.charlieandre.fr}
APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-}
APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-}
APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10}
APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password}
APP_SMTP_HOST: mailpit
APP_SMTP_PORT: "1025"
+40
View File
@@ -207,6 +207,46 @@ par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est
l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait
côté pilote, en `500` plutôt qu'un refus propre.
### Détection d'alertes internes
`AlertService` n'est plus lecture seule : `AlertService.detect()` compare les `reading` (et, pour
le type `anomaly`, les `prediction`) des dernières 48h (`LOOKBACK`) à cinq règles et enregistre une
ligne `alert` par déclenchement, avec `source="enervision"`. `metric`/`value`/`threshold` gardent
leur sens dans chaque règle plutôt que d'être laissés à `null` par commodité :
| `type` | Règle | `value` / `threshold` |
|---|---|---|
| `threshold` | `reading.consumption_kw` dépasse `site.capacity_kw` (site sans capacité déclarée : ignoré) | mesure / capacité du site |
| `spike` | Variation relative ≥ 50% (`SPIKE_RELATIVE_THRESHOLD`) entre deux lectures consécutives du même site, ou redémarrage direct à une valeur positive depuis zéro (`critical`) | mesure actuelle / mesure précédente |
| `anomaly` | Écart relatif ≥ 30% (`ANOMALY_RELATIVE_THRESHOLD`) entre `reading.consumption_kwh` et la `prediction` du même site dont `target_at == timestamp` | mesure réelle / valeur prédite |
| `outage` | Aucune lecture depuis plus de 3h (`OUTAGE_THRESHOLD`, 3x la cadence horaire nominale), ou site jamais lu | `null` / `null` |
| `sensor` | `reading.data_quality` ∈ `partial`/`degraded`/`critical` | `null` / `null` |
La sévérité de chaque alerte (hors `sensor`, dérivée directement de `data_quality`) suit le même
barème par ratio observé/seuil : `low` sous 1.2, `medium` sous 1.5, `high` sous 2.0, `critical`
au-delà. `AlertRepository.create_many()` insère par lot avec `ON CONFLICT DO NOTHING` sur
`uq_alert_source_reference`, et `source_alert_id` est construit de façon déterministe (règle +
horodatage) : rejouer la détection sur une fenêtre déjà analysée ne duplique donc jamais une
alerte.
**Pièges de tri corrigés en revue** : `reading`/`prediction` n'ont pas d'unicité sur leur couple
métier (`uq_reading_source` autorise deux `source` différentes au même `site_id`+`timestamp`,
`prediction` n'a aucune contrainte sur `(site_id, target_at)`, chaque run de scoring gardant sa
propre ligne). `ReadingRepository.list_since()`/`PredictionRepository.list_since()` départagent
donc les égalités par `reading_id`/`prediction_id` croissant, comme le font déjà
`latest_by_site()`/`latest_for_site()` sur les mêmes tables ; sans ce départage, l'ordre entre
lignes à égalité n'est pas garanti d'un appel à l'autre, et `_detect_spike`/`_detect_anomaly`
auraient pu comparer des lectures/choisir une prévision au hasard. `_detect_spike` ignore en plus
explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul
instant réel, pas une variation).
Comme `enervision_ml.score`, la détection est un script lancé à la main, pas encore ordonnancé par
Airflow : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans
`apps/backend` puisque les règles s'appuient sur les repositories ORM de l'API plutôt que sur une
connexion SQL directe (contrairement à `app/etl/historical_import.py`). Cette issue (#104)
débloquait #38 (moteur de règles pour recommandations), dont la FK `alert_id` `NOT NULL` n'avait
jusqu'ici rien à référencer côté `source="enervision"`.
### `/health/ready`
Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un
+332 -69
View File
@@ -6,10 +6,14 @@ système qui en découle.
## Ce que couvre ce document
**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données
d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles
marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne
d'ingestion, les agrégats continus, la compression et la rétention.
**Douze tables applicatives existent** : six pour l'authentification et six pour les données
d'énergie, dont l'hypertable `reading`.
Les sections marquées `Fait` relèvent du code déjà implémenté. Les sections marquées `Cible`
décrivent les éléments prévus mais pas encore réalisés.
L'ingestion des deux sources de données du MVP est maintenant implémentée. L'orchestration
Airflow, les agrégats continus, la compression et la rétention restent des cibles.
## Trois emplacements, trois rôles
@@ -35,8 +39,16 @@ Statut : `Fait`.
- `db/init/100-extensions.sql` crée l'extension `timescaledb`.
- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par
`apps/backend/tests/conftest.py`.
- Cinq révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
établit `alembic_version` et refuse de s'appliquer si l'extension manque :
- Six révisions Alembic sont actuellement appliquées.
- La première, `5353c0e4f094`, **ne crée aucune table** : elle établit `alembic_version`
et refuse de s'appliquer si l'extension TimescaleDB manque.
- Les révisions suivantes créent les tables liées à l'authentification :
`app_user`, `login_attempt`, `audit_log` et `refresh_token`.
- La révision `e6d2026091501` crée les six tables Data et déclare l'hypertable `reading`.
- La révision `c0adab96238c` ajoute les tables `password_reset_attempt`
et `password_reset_token`.
La garde de la première migration est :
```sql
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
@@ -47,37 +59,58 @@ END IF;
Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap sauté ne se voit pas
au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés.
Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`,
puis `login_attempt` et `audit_log`, puis `refresh_token`. La cinquième, `e6d2026091501`, crée
les six tables de données décrites en fin de document et déclare l'hypertable `reading`.
## Cycle de vie d'une mesure
Statut : `Cible`, sauf l'hypertable `reading` qui existe. Ni l'ingestion, ni les agrégats
continus, ni la compression, ni la rétention ne sont écrits.
Statut : `Partiellement fait`.
Les mécanismes d'ingestion sont maintenant implémentés pour les deux sources de données du MVP :
- le dataset historique CSV/JSON avec `historical_import.py` ;
- l'API Mock avec `mock_api_import.py`.
Les traitements sont actuellement exécutables directement depuis le backend.
L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus,
la compression et les politiques de rétention.
```mermaid
flowchart LR
src["Source de mesures"] -.-> ing["Ingestion Airflow"]
ing -.-> hy[("Hypertable reading")]
csv["CSV + JSON"] --> hist["historical_import.py"]
mock["API Mock"] --> api["mock_api_import.py"]
hist --> hy[("Hypertable reading")]
api --> hy
airflow["Airflow"] -.-> hist
airflow -.-> api
hy -.-> agg[("Agrégat continu")]
hy -.-> comp["Compression"]
hy -.-> ret["Rétention"]
agg -.-> api["API FastAPI"]
agg -.-> backend["API FastAPI"]
agg -.-> graf["Grafana"]
```
Les lectures de l'API et de Grafana visent l'agrégat continu, pas la table brute : c'est tout
l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront.
Les flèches pleines représentent les traitements actuellement implémentés.
Les flèches pointillées représentent les éléments encore prévus comme cibles.
Les lectures futures de l'API et de Grafana visent l'agrégat continu plutôt que la table brute
lorsque cette partie TimescaleDB sera mise en place.
## Tables d'authentification
Statut : `Fait`. Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les
hypertables ; elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API.
Statut : `Fait`.
Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les hypertables ;
elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API.
```mermaid
erDiagram
APP_USER ||--o{ REFRESH_TOKEN : ouvre
APP_USER ||--o{ PASSWORD_RESET_TOKEN : recoit
APP_USER {
uuid id PK
string email UK
@@ -88,6 +121,7 @@ erDiagram
bool must_change_password
timestamptz credentials_changed_at
}
REFRESH_TOKEN {
uuid id PK
uuid family_id
@@ -99,6 +133,7 @@ erDiagram
text revoked_reason
uuid replaced_by
}
LOGIN_ATTEMPT {
bigint id PK
timestamptz occurred_at
@@ -106,6 +141,7 @@ erDiagram
inet client_ip
text outcome
}
AUDIT_LOG {
bigint id PK
timestamptz occurred_at
@@ -114,31 +150,47 @@ erDiagram
text action
jsonb detail
}
PASSWORD_RESET_ATTEMPT {
bigint id PK
timestamptz occurred_at
string email_tried
inet client_ip
}
PASSWORD_RESET_TOKEN {
uuid id PK
uuid user_id FK
bytea token_hash UK
timestamptz issued_at
timestamptz expires_at
timestamptz consumed_at
inet client_ip
text user_agent
}
```
Quatre choix de modélisation portent une intention et se défendent seuls :
Plusieurs choix de modélisation portent une intention précise :
- **`app_user` et non `user`** : `user` est un mot réservé PostgreSQL, raccourci de
`CURRENT_USER`. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition
au rôle PostgreSQL qui portera le cantonnement de l'ETL.
- **`credentials_changed_at`, une seule colonne**, couvre le changement de mot de passe, le
changement de rôle et la désactivation. Un compteur de version ne dirait rien à un humain qui
lit un audit.
- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. S'il
glissait, la promesse de sept jours serait fictive et une session active ne finirait jamais.
- **`audit_log.actor_id` n'a aucune clé étrangère**, et `actor_email` comme `actor_role` sont
dénormalisés. Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur
d'ajout seul refuserait. Voir l'[ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md).
`CURRENT_USER`. Le nom rappelle aussi qu'il s'agit d'un compte applicatif.
- **`credentials_changed_at`, une seule colonne**, couvre notamment le changement de mot de passe,
le changement de rôle et la désactivation.
- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation.
- **`audit_log.actor_id` n'a aucune clé étrangère** afin de conserver les informations d'audit
même si l'entité d'origine évolue.
- `password_reset_token` ne stocke que l'empreinte du jeton et jamais sa valeur directement.
- `password_reset_attempt` est séparée de `audit_log`, car son volume peut être piloté
par des demandes externes répétées.
`audit_log` porte deux déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. Elle n'est
donc **pas** une hypertable : une politique de rétention émettrait des `DELETE` qu'ils
refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est
piloté par l'attaquant.
`audit_log` porte des déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`.
Elle n'est donc **pas** une hypertable.
## Gabarit de révision créant une hypertable
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. La révision
`e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel.
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision.
La révision `e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel.
```python
def upgrade() -> None:
@@ -182,24 +234,28 @@ colonne de temps : les index déclarés dans la révision le couvrent déjà.
## Questions ouvertes
Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma est livré : ce qui suit
porte sur son exploitation, plus sur sa forme.
Elles portent maintenant principalement sur l'exploitation du schéma :
- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure.
- **Quels agrégats continus**, et sur quelles fenêtres.
- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse.
- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures.
- **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure.
- **Quels agrégats continus** créer et sur quelles fenêtres.
- **Quelle profondeur de rétention** conserver en données brutes et à partir de quand compresser.
- **Multi-tenant ou non** : un site appartient-il à un client et faut-il cloisonner les lectures.
## Modélisation détaillée des données
Cette modélisation prend en compte les fichiers CSV historiques,
leurs métadonnées JSON et les données de l’API Mock.
Elle comprend six tables, depuis le stockage des mesures
jusqu’aux recommandations proposées à l’utilisateur.
Cette modélisation prend en compte :
- les fichiers CSV historiques ;
- leurs métadonnées JSON ;
- les données de l'API Mock.
Elle comprend six tables Data, depuis le stockage des mesures jusqu'aux recommandations proposées
à l'utilisateur.
### Schéma de données
Le diagramme ci-dessous présente les tables et leurs relations.
La révision `e6d2026091501` les crée.
![Schéma de données EnerVision](images/EnerVision-schema-donnees.png)
@@ -208,21 +264,20 @@ La révision `e6d2026091501` les crée.
### Description des tables
Chaque table remplit un rôle précis dans le traitement et l’exploitation
des données.
Chaque table remplit un rôle précis dans le traitement et l'exploitation des données.
| Table | Rôle | Origine des informations |
|---|---|---|
| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l'import |
| `site` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
| `reading` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d'EnerVision |
| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision |
Les anomalies historiques décrites dans les JSON sont conservées
dans `dataset.metadata`. Elles servent à l’analyse des données
et ne sont pas considérées comme des alertes actuelles.
Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`.
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
### Relations entre les tables
@@ -232,15 +287,21 @@ et ne sont pas considérées comme des alertes actuelles.
- Une alerte peut être associée à une prévision du même site.
- Une alerte peut donner lieu à plusieurs recommandations.
## Ingestion des données historiques
# Ingestion des données historiques
Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre du projet.
Statut : `Fait`.
Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période du 1er janvier 2023 au 31 décembre 2024.
Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre
du projet.
Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données. Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`.
Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période
du 1er janvier 2023 au 31 décembre 2024.
### Architecture du flux
Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données.
Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`.
## Architecture du flux historique
```text
Dataset CSV + métadonnées JSON
@@ -271,17 +332,26 @@ Dataset CSV + métadonnées JSON
Le pipeline est développé en Python.
Pandas est utilisé pour l'extraction, la validation et la préparation des données. SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB.
Pandas est utilisé pour l'extraction, la validation et la préparation des données.
SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB.
Une empreinte SHA-256 permet d'identifier le dataset utilisé et d'assurer sa traçabilité.
Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources. Aucune imputation n'est réalisée à cette étape.
Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources.
Aucune imputation n'est réalisée à cette étape.
Le chargement des mesures est effectué par batches de 1 000 lignes.
Les données provenant du dataset CSV sont identifiées par `source = "csv"` et associées à leur `dataset_id`.
Les données provenant du dataset CSV sont identifiées par :
### Résultats validés
```text
source = "csv"
dataset_id = identifiant du dataset
```
## Résultats validés pour l'historique
Le chargement de référence a permis d'obtenir :
@@ -290,14 +360,207 @@ Le chargement de référence a permis d'obtenir :
- 122 647 mesures ;
- 0 doublon détecté dans le dataset source.
L'idempotence a également été vérifiée par une deuxième exécution du pipeline : aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647.
L'idempotence a également été vérifiée par une deuxième exécution du pipeline :
aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647.
La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline est disponible dans `etl/README.md`.
La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline
est disponible dans `etl/README.md`.
### Évolution prévue
# Ingestion depuis l'API Mock
L'étape suivante consiste à orchestrer les traitements Data avec Apache Airflow.
Statut : `Fait`.
L'orchestration réutilisera la logique ETL existante afin de séparer la logique de traitement de la planification, du suivi des exécutions et de la gestion des erreurs.
La deuxième source du pipeline Data est l'API Mock EnerVision.
Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle de Machine Learning.
Le traitement est implémenté dans :
```text
apps/backend/app/etl/mock_api_import.py
```
## Endpoints utilisés
Le pipeline récupère les informations des sites depuis :
```text
GET /api/v1/sites
```
puis les mesures historiques simulées depuis :
```text
GET /api/v1/readings
```
Pour `/api/v1/readings`, les informations suivantes sont envoyées :
```text
site_id
start_time
end_time
limit
```
Les paramètres de ligne de commande disponibles pour l'import sont :
```text
--start-time
--end-time
--limit
--dry-run
```
## Flux d'ingestion API Mock
```text
API Mock
|
+-----+------+
| |
v v
/sites /readings
| |
+-----+------+
|
v
mock_api_import.py
|
v
Transformation
+ qualité data
|
v
PostgreSQL / TimescaleDB
| |
v v
site reading
```
Les informations des sites sont insérées ou mises à jour dans `site`.
Les mesures sont enregistrées dans l'hypertable `reading` avec :
```text
source = "api_history"
dataset_id = NULL
```
Les données provenant de l'API Mock ne sont donc pas associées à un enregistrement de la table
`dataset`.
La réponse source reçue depuis l'API est conservée dans :
```text
raw_data
```
## Qualité des données de l'API Mock
Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion.
Les informations suivantes fournies par l'API sont conservées :
```text
data_quality
null_reasons
```
Cette conservation permet de distinguer une valeur manquante d'une valeur réelle égale à zéro
et de garder les informations liées aux éventuelles défaillances de capteurs.
Aucune imputation n'est réalisée pendant cette phase :
```text
imputed_values = NULL
imputation_method = NULL
```
## Validation de l'import API Mock
Un scénario de validation a été exécuté pour les 7 sites sur la période :
```text
15/06/2024 12:00 UTC
à
15/06/2024 13:00 UTC
```
avec :
```text
limit = 60
```
Résultat :
```text
7 sites
60 lectures par site
420 lectures récupérées
```
Les données ont été chargées dans PostgreSQL/TimescaleDB puis contrôlées directement en base.
Les contrôles ont confirmé :
- `source = "api_history"` ;
- `dataset_id = NULL` ;
- la conservation des valeurs `NULL` ;
- la conservation de `data_quality` ;
- la conservation de `null_reasons` ;
- la conservation de `raw_data`.
L'idempotence a été vérifiée en rejouant le même import.
Une mesure déjà présente n'est pas ajoutée une seconde fois.
Les tests automatisés couvrent également :
- la récupération des sites ;
- les paramètres envoyés à `/api/v1/readings` ;
- les réponses HTTP en erreur ;
- le format de la réponse ;
- la transformation des mesures ;
- les valeurs manquantes ;
- la qualité des données ;
- la conservation des données sources ;
- l'idempotence en base.
# Évolution prévue
La prochaine étape consiste à orchestrer les deux mécanismes d'ingestion avec Apache Airflow.
```text
CSV / JSON ----------------+
|
v
+------------------+
| Airflow |
+------------------+
|
+----------------+----------------+
| |
v v
historical_import.py mock_api_import.py
| |
+----------------+----------------+
|
v
PostgreSQL / TimescaleDB
```
Airflow servira à :
- planifier les traitements ;
- définir leur ordre d'exécution ;
- suivre leur état ;
- gérer et remonter les erreurs ;
- faciliter les exécutions récurrentes.
Airflow ne remplacera pas la logique ETL déjà implémentée.
Les scripts Python resteront responsables de l'extraction, de la validation, de la transformation
et du chargement des données.
Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle
de Machine Learning.
+307 -22
View File
@@ -2,9 +2,10 @@
## Objectif
Le pipeline ETL EnerVision permet d'intégrer les données énergétiques historiques dans PostgreSQL/TimescaleDB.
Le pipeline ETL EnerVision permet d'intégrer les données énergétiques dans PostgreSQL/TimescaleDB à partir de deux sources :
Cette première étape du pipeline Data permet de charger le dataset fourni dans le cadre du projet, contenant les mesures énergétiques de 7 sites sur la période du 1er janvier 2023 au 31 décembre 2024.
- le dataset historique CSV/JSON fourni dans le cadre du projet ;
- l'API Mock EnerVision.
Le pipeline assure :
@@ -12,12 +13,15 @@ Le pipeline assure :
- la validation de leur structure et de leur cohérence ;
- la normalisation des données nécessaires au stockage ;
- le suivi de la qualité des données ;
- la traçabilité du dataset importé ;
- la traçabilité des données importées ;
- le chargement des données dans PostgreSQL/TimescaleDB ;
- la conservation des valeurs manquantes et des informations de qualité ;
- l'idempotence du chargement afin d'éviter la création de doublons.
## Données sources
### Dataset historique
Le dataset est fourni par le formateur dans le cadre du projet EnerVision.
Il contient les deux fichiers suivants :
@@ -29,7 +33,7 @@ dataset_metadata.json
Ces fichiers sont nécessaires une seule fois pour initialiser les données historiques de l'environnement.
Ils ne sont pas versionnés dans Git. Chaque membre de l'équipe récupère manuellement une fois les fichiers fournis par le formateur et les place dans :
Ils ne sont pas versionnés dans Git. Chaque membre de l'équipe récupère manuellement les fichiers fournis par le formateur et les place dans :
```text
data/raw/
@@ -47,14 +51,26 @@ data/
Le fichier `.gitkeep` est versionné afin de conserver le répertoire `data/raw/` dans Git. Les fichiers CSV et JSON sont ignorés par Git.
### API Mock
La deuxième source est l'API Mock EnerVision.
Elle permet de récupérer :
- les informations des sites avec `GET /api/v1/sites` ;
- les mesures simulées avec `GET /api/v1/readings`.
L'API Mock est utilisée pour compléter les données historiques avec des mesures simulées récupérées sur une période donnée.
## Technologies utilisées
| Technologie | Utilisation |
|---|---|
| Python | Développement du pipeline ETL |
| Pandas | Lecture, validation et transformation des données |
| JSON | Lecture des métadonnées du dataset |
| hashlib / SHA-256 | Identification, intégrité et traçabilité du dataset |
| Pandas | Lecture, validation et transformation du dataset historique |
| JSON | Lecture des métadonnées et conservation des données sources |
| HTTPX | Appels HTTP asynchrones vers l'API Mock |
| hashlib / SHA-256 | Identification, intégrité et traçabilité du dataset historique |
| SQLAlchemy Async | Connexion et chargement asynchrone en base |
| PostgreSQL | Stockage relationnel |
| TimescaleDB | Stockage des séries temporelles énergétiques |
@@ -62,11 +78,14 @@ Le fichier `.gitkeep` est versionné afin de conserver le répertoire `data/raw/
| Alembic | Gestion des migrations du schéma |
| uv | Gestion et exécution de l'environnement Python |
| Ruff | Contrôle de la qualité du code |
| mypy | Vérification du typage |
| Pytest | Tests automatisés |
## Fonctionnement du pipeline
# Import du dataset historique
Le script principal d'import se trouve dans :
## Fonctionnement du pipeline historique
Le script d'import se trouve dans :
```text
apps/backend/app/etl/historical_import.py
@@ -207,7 +226,7 @@ Valeurs manquantes identifiées :
| `humidity_percent` | 3 423 |
| `solar_irradiance_wm2` | 3 964 |
## Exécution en dry-run
## Exécution historique en dry-run
Depuis le dossier :
@@ -227,7 +246,7 @@ uv run python -m app.etl.historical_import `
Aucune donnée n'est écrite dans la base pendant cette exécution.
## Chargement réel
## Chargement historique réel
Depuis `apps/backend/` :
@@ -249,7 +268,7 @@ Chargement : 2000/122647
Chargement : 122647/122647
```
## Résultats obtenus
## Résultats obtenus pour le dataset historique
Après le chargement initial, les contrôles en base ont confirmé :
@@ -266,7 +285,7 @@ Le premier import a créé :
nouvelles lectures : 122647
```
## Idempotence
## Idempotence du dataset historique
Le pipeline a été exécuté une deuxième fois avec exactement le même dataset afin de vérifier son idempotence.
@@ -280,7 +299,7 @@ nouvelles lectures : 0
Une nouvelle exécution du même import ne crée donc pas de mesures supplémentaires pour le dataset testé.
## Vérifications SQL
## Vérifications SQL du dataset historique
Depuis la racine du projet, vérifier le nombre d'enregistrements avec :
@@ -302,21 +321,213 @@ Vérifier la source des mesures avec :
docker compose exec db psql -U enervision -d enervision -c "SELECT source, COUNT(*) FROM reading GROUP BY source ORDER BY source;"
```
Résultat attendu :
Résultat attendu pour le dataset historique :
```text
csv | 122647
```
## Tests et qualité
# Import depuis l'API Mock
Les tests automatisés du pipeline sont situés dans :
## Fonctionnement
Le script d'import de l'API Mock se trouve dans :
```text
apps/backend/app/etl/mock_api_import.py
```
Le flux est le suivant :
```text
API Mock
|
+-----+------+
| |
v v
/sites /readings
| |
+-----+------+
|
v
mock_api_import.py
|
v
Transformation
+ qualité data
|
v
PostgreSQL / TimescaleDB
| |
v v
site reading
```
Le pipeline commence par récupérer les sites avec :
```text
GET /api/v1/sites
```
Il récupère ensuite les mesures de chaque site avec :
```text
GET /api/v1/readings
```
Les paramètres envoyés à `/api/v1/readings` sont :
```text
site_id
start_time
end_time
limit
```
Le paramètre `limit` doit être compris entre 1 et 1000.
## Configuration de l'API Mock
La connexion à l'API Mock est configurée avec les variables d'environnement suivantes :
```text
APP_MOCK_API_BASE_URL
APP_MOCK_API_USERNAME
APP_MOCK_API_PASSWORD
APP_MOCK_API_TIMEOUT_SECONDS
```
Les identifiants réels ne sont pas versionnés dans Git.
Les fichiers `.env.example` indiquent uniquement les variables nécessaires à l'exécution.
## Transformation des mesures API
Les mesures provenant de l'API Mock sont enregistrées dans `reading` avec :
```text
source = "api_history"
dataset_id = NULL
```
Les mesures provenant de l'API ne sont donc pas rattachées à un dataset historique.
Le timestamp reçu depuis l'API est converti en `datetime` avec timezone avant le chargement.
La réponse source est conservée dans :
```text
raw_data
```
afin de préserver la donnée reçue et faciliter la traçabilité.
## Qualité des données API
Les valeurs `NULL` fournies par l'API sont conservées telles quelles.
Une valeur manquante n'est pas transformée en zéro et la mesure n'est pas supprimée.
Le pipeline conserve également :
```text
data_quality
null_reasons
```
Les niveaux de qualité possibles sont :
```text
good
partial
degraded
critical
```
Aucune imputation n'est réalisée pendant l'ingestion :
```text
imputed_values = NULL
imputation_method = NULL
```
Cette stratégie permet de distinguer une véritable valeur nulle ou manquante d'une consommation égale à zéro et de conserver les informations liées aux défaillances de capteurs.
## Dry-run de l'API Mock
Le mode `--dry-run` permet de tester la connexion, la récupération des sites et la récupération des mesures sans écrire dans PostgreSQL.
Depuis `apps/backend/` :
```powershell
uv run python -m app.etl.mock_api_import `
--start-time "2024-06-15T12:00:00" `
--end-time "2024-06-15T13:00:00" `
--limit 60 `
--dry-run
```
## Chargement réel depuis l'API Mock
Depuis `apps/backend/` :
```powershell
uv run python -m app.etl.mock_api_import `
--start-time "2024-06-15T12:00:00" `
--end-time "2024-06-15T13:00:00" `
--limit 60
```
## Résultat validé pour l'API Mock
Le scénario de validation utilisé couvre la période :
```text
15/06/2024 12:00 UTC
à
15/06/2024 13:00 UTC
```
avec une limite de 60 lectures par site.
Résultat obtenu :
```text
sites récupérés : 7
lectures par site : 60
lectures récupérées : 420
source : api_history
dataset_id : NULL
```
Les contrôles effectués directement dans PostgreSQL/TimescaleDB ont confirmé :
- l'enregistrement des mesures dans `reading` ;
- la présence des 7 sites ;
- `source = "api_history"` ;
- `dataset_id = NULL` ;
- la conservation des valeurs `NULL` ;
- la conservation de `data_quality` ;
- la conservation de `null_reasons` ;
- la conservation de la donnée source dans `raw_data`.
## Idempotence de l'import API Mock
Le même import a été exécuté plusieurs fois afin de vérifier qu'une mesure déjà présente n'est pas créée une seconde fois.
L'idempotence repose sur la contrainte d'unicité de la table `reading` et sur la gestion des conflits lors de l'insertion.
Un test d'intégration automatisé vérifie également ce comportement.
# Tests et qualité
Les tests automatisés des pipelines ETL sont situés dans :
```text
apps/backend/tests/etl/
```
Ils couvrent notamment :
Les tests de l'import historique couvrent notamment :
- la validation du dataset ;
- les colonnes obligatoires ;
@@ -328,22 +539,96 @@ Ils couvrent notamment :
- la construction des mesures destinées à la BDD ;
- le respect des contraintes du modèle de données.
Les tests de l'import API Mock couvrent notamment :
- la récupération des sites ;
- l'appel à `/api/v1/readings` ;
- les paramètres `site_id`, `start_time`, `end_time` et `limit` ;
- la gestion des erreurs HTTP ;
- la validation du format de la réponse ;
- la transformation des mesures ;
- la conservation des valeurs `NULL` ;
- la conservation de `data_quality` et `null_reasons` ;
- `source = "api_history"` ;
- `dataset_id = NULL` ;
- la conservation de `raw_data` ;
- l'idempotence du chargement.
Exécuter les tests ETL :
```powershell
uv run pytest tests\etl -v
```
Exécuter les tests unitaires de l'import API Mock :
```powershell
uv run pytest tests\etl\test_mock_api_import.py -v
```
Exécuter le test d'intégration de l'import API Mock :
```powershell
uv run pytest tests\etl\test_mock_api_import.py -m integration -v
```
Contrôler la qualité du code :
```powershell
uv run ruff check app\etl tests\etl
```
## Suite du pipeline Data
Contrôler le typage :
L'import historique constitue la première brique du pipeline Data EnerVision.
```powershell
uv run mypy app
```
La prochaine étape consiste à orchestrer les traitements ETL avec Apache Airflow, puis à préparer les données nécessaires à l'entraînement du modèle de Machine Learning.
Exécuter la suite complète avec le seuil de couverture :
Airflow sera utilisé comme orchestrateur des traitements existants et ne remplacera pas la logique métier déjà implémentée dans le pipeline ETL.
```powershell
uv run pytest --cov-fail-under=85
```
Lors de la validation de l'import API Mock :
```text
8 tests unitaires passés
1 test d'intégration passé
```
La suite backend complète a également été validée avec une couverture supérieure au seuil de 85 %.
# Suite du pipeline Data
Deux sources de données sont maintenant prises en charge :
```text
Dataset CSV/JSON
|
v
historical_import.py
|
+-----------------+
|
v
PostgreSQL / TimescaleDB
^
|
+-----------------+
|
mock_api_import.py
^
|
API Mock
```
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
La prochaine étape consiste à orchestrer ces traitements avec Apache Airflow.
Airflow permettra de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs.
Airflow ne remplacera pas la logique ETL Python existante. Les scripts actuels resteront responsables de l'extraction, de la validation, de la transformation et du chargement.
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
+1 -1
View File
@@ -9,7 +9,7 @@ sonar.tests=apps/frontend/src,apps/backend/tests
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
# Liste des fichiers et dossiers à exclure de l'analyse
sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
# Chemin vers le rapport de couverture de code
# Fichier généré par Pytest