Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f6f451eb4 | ||
|
|
cc3e38efa3 | ||
|
|
0a2ed5ad8f | ||
|
|
459ddf1792 | ||
|
|
de697b080d | ||
|
|
f238940867 | ||
|
|
b941880c22 | ||
|
|
9d2384a639 | ||
|
|
56c6b79a5a | ||
|
|
19c38fe571 | ||
|
|
9a1af94d88 | ||
|
|
452cfdef85 | ||
|
|
96dd1f834c | ||
|
|
e66ef86729 | ||
|
|
0318ee6cc5 | ||
|
|
0ddfb1997d | ||
|
|
b96546cea3 | ||
|
|
c059f838bb | ||
|
|
a9e124a97d | ||
|
|
edd5e82d29 | ||
|
|
aeb07e14db | ||
|
|
619024f547 | ||
|
|
460d6c1b3e | ||
|
|
eb4291b10a | ||
|
|
9a72bb3a8f | ||
|
|
db81290026 | ||
|
|
d9229e5a93 | ||
|
|
e9376a98bf |
@@ -17,3 +17,24 @@ 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
|
||||
|
||||
# Airflow (webserver + scheduler, LocalExecutor). Base de métadonnées dédiée `airflow` dans le
|
||||
# même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus.
|
||||
AIRFLOW_PORT=8080
|
||||
# Chiffre les connexions/variables stockées par Airflow. Générer la vôtre :
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
AIRFLOW_FERNET_KEY=change_me
|
||||
# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la
|
||||
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
AIRFLOW_WEBSERVER_SECRET_KEY=change_me
|
||||
AIRFLOW_ADMIN_USERNAME=admin
|
||||
# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les
|
||||
# comptes `app_user` d'EnerVision.
|
||||
AIRFLOW_ADMIN_PASSWORD=change_me
|
||||
AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
|
||||
|
||||
@@ -66,6 +66,9 @@ ml/mlruns/
|
||||
ml/mlartifacts/
|
||||
ml/mlflow.db
|
||||
|
||||
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
|
||||
etl/airflow/tests/.airflow_home/
|
||||
|
||||
# IDE et OS
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
BACKEND := apps/backend
|
||||
FRONTEND := apps/frontend
|
||||
ML := ml
|
||||
AIRFLOW := etl/airflow
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \
|
||||
.PHONY: help install install-backend install-frontend install-ml install-airflow \
|
||||
dev dev-backend dev-frontend \
|
||||
lint format typecheck test test-cov test-integration check \
|
||||
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
|
||||
ml-lint ml-typecheck ml-test ml-check ml-train
|
||||
ml-lint ml-typecheck ml-test ml-check ml-train ml-score recommendations \
|
||||
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs
|
||||
|
||||
help: ## Liste les cibles disponibles
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
install: install-backend install-frontend install-ml ## Installe les dépendances backend, frontend et ML
|
||||
install: install-backend install-frontend install-ml install-airflow ## Installe les dépendances backend, frontend, ML et Airflow
|
||||
|
||||
install-backend: ## Installe les dépendances du backend
|
||||
cd $(BACKEND) && uv sync --all-groups
|
||||
@@ -22,6 +25,9 @@ install-frontend: ## Installe les dépendances du frontend
|
||||
install-ml: ## Installe les dépendances du pipeline ML
|
||||
cd $(ML) && uv sync --all-groups
|
||||
|
||||
install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
|
||||
cd $(AIRFLOW) && uv sync --all-groups
|
||||
|
||||
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
|
||||
@trap 'kill 0' EXIT INT TERM; \
|
||||
$(MAKE) --no-print-directory dev-backend & \
|
||||
@@ -74,6 +80,30 @@ ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du
|
||||
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL
|
||||
cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),)
|
||||
|
||||
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
|
||||
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),)
|
||||
|
||||
recommendations: ## Genere les recommandations depuis les alertes en base. SITE=identifiant optionnel
|
||||
cd $(BACKEND) && uv run python -m app.cli generate-recommendations $(if $(SITE),--site-id $(SITE),)
|
||||
|
||||
airflow-lint: ## Analyse statique des DAGs Airflow
|
||||
cd $(AIRFLOW) && uv run ruff check .
|
||||
|
||||
airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structure attendue
|
||||
cd $(AIRFLOW) && uv run pytest
|
||||
|
||||
airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow
|
||||
|
||||
airflow-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant.
|
||||
docker compose up -d airflow-init airflow-webserver airflow-scheduler
|
||||
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
|
||||
|
||||
airflow-down: ## Arrête le webserver et le scheduler Airflow
|
||||
docker compose stop airflow-webserver airflow-scheduler
|
||||
|
||||
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
|
||||
docker compose logs -f airflow-scheduler
|
||||
|
||||
docker-build: ## Construit l'image du backend
|
||||
docker build -t enervision-backend:local $(BACKEND)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -113,6 +113,7 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
|
||||
| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` |
|
||||
| `/api/v1/recommendations` | Liste les recommandations | `lecteur` |
|
||||
| `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `lecteur` |
|
||||
| `/api/v1/recommendations/generate` | Génère les recommandations depuis les alertes (POST) | `admin` |
|
||||
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
|
||||
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.repositories.audit_log import AuditLogRepository
|
||||
from app.repositories.login_attempt import LoginAttemptRepository
|
||||
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
|
||||
from app.repositories.password_reset_token import PasswordResetTokenRepository
|
||||
from app.repositories.prediction import PredictionRepository
|
||||
from app.repositories.reading import ReadingRepository
|
||||
from app.repositories.recommendation import RecommendationRepository
|
||||
from app.repositories.refresh_token import RefreshTokenRepository
|
||||
@@ -34,6 +35,7 @@ from app.repositories.site import SiteRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.alert import AlertService
|
||||
from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
|
||||
from app.services.prediction import PredictionService
|
||||
from app.services.reading import ReadingService
|
||||
from app.services.recommendation import RecommendationService
|
||||
from app.services.sensor import SensorService
|
||||
@@ -178,14 +180,23 @@ 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)]
|
||||
|
||||
|
||||
def get_recommendation_service(session: SessionDep) -> RecommendationService:
|
||||
return RecommendationService(recommendations=RecommendationRepository(session))
|
||||
return RecommendationService(
|
||||
recommendations=RecommendationRepository(session),
|
||||
alerts=AlertRepository(session),
|
||||
transaction=session,
|
||||
)
|
||||
|
||||
|
||||
RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)]
|
||||
@@ -212,6 +223,15 @@ def get_sensor_service(session: SessionDep) -> SensorService:
|
||||
SensorServiceDep = Annotated[SensorService, Depends(get_sensor_service)]
|
||||
|
||||
|
||||
def get_prediction_service(session: SessionDep) -> PredictionService:
|
||||
return PredictionService(
|
||||
sites=SiteRepository(session), predictions=PredictionRepository(session)
|
||||
)
|
||||
|
||||
|
||||
PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)]
|
||||
|
||||
|
||||
async def get_current_principal(
|
||||
credentials: CredentialsDep,
|
||||
session: SessionDep,
|
||||
|
||||
@@ -63,7 +63,7 @@ TAGS: Final[list[dict[str, Any]]] = [
|
||||
"name": "recommendations",
|
||||
"description": (
|
||||
"Consultation des recommandations issues des alertes. Accessible à partir du rôle "
|
||||
"`lecteur`."
|
||||
"`lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`."
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -83,6 +83,13 @@ TAGS: Final[list[dict[str, Any]]] = [
|
||||
"name": "sensors",
|
||||
"description": "État de santé des capteurs par site. Réservé au rôle `admin`.",
|
||||
},
|
||||
{
|
||||
"name": "predictions",
|
||||
"description": (
|
||||
"Dernière prévision de consommation par site, calculée hors ligne par le pipeline "
|
||||
"de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
cookie_de_rafraichissement = APIKeyCookie(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import LecteurDep, PredictionServiceDep
|
||||
from app.schemas.prediction import PredictionSummaryResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=PredictionSummaryResponse,
|
||||
summary="Dernière prédiction de consommation par site",
|
||||
)
|
||||
async def get_predictions(
|
||||
_: LecteurDep, service: PredictionServiceDep
|
||||
) -> PredictionSummaryResponse:
|
||||
resume = await service.summary()
|
||||
return PredictionSummaryResponse.model_validate(resume)
|
||||
@@ -1,13 +1,18 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import LecteurDep, RecommendationServiceDep
|
||||
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
||||
from app.api.deps import AdminDep, LecteurDep, RecommendationServiceDep
|
||||
from app.api.openapi import REPONSE_VALIDATION, REPONSES_ADMIN, Reponses
|
||||
from app.schemas.errors import ErrorResponse
|
||||
from app.schemas.recommendation import RecommendationResponse
|
||||
from app.schemas.recommendation import (
|
||||
RecommendationGenerationResponse,
|
||||
RecommendationResponse,
|
||||
)
|
||||
from app.services.recommendation import RecommendationNotFoundError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
REPONSES_GENERATION: Reponses = {**REPONSES_ADMIN, **REPONSE_VALIDATION}
|
||||
|
||||
REPONSES_INTROUVABLE: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."},
|
||||
@@ -38,3 +43,22 @@ async def get_recommendation(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable"
|
||||
) from erreur
|
||||
return RecommendationResponse.model_validate(recommendation)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate",
|
||||
response_model=RecommendationGenerationResponse,
|
||||
summary="Génère les recommandations à partir des alertes",
|
||||
responses=REPONSES_GENERATION,
|
||||
)
|
||||
async def generate_recommendations(
|
||||
_: AdminDep,
|
||||
service: RecommendationServiceDep,
|
||||
site_id: str | None = None,
|
||||
) -> RecommendationGenerationResponse:
|
||||
rapport = await service.generate(site_id=site_id)
|
||||
return RecommendationGenerationResponse(
|
||||
alerts_examined=rapport.alertes_examinees,
|
||||
recommendations_created=rapport.recommandations_creees,
|
||||
already_present=rapport.deja_presentes,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.api.v1.endpoints import (
|
||||
alerts,
|
||||
auth,
|
||||
health,
|
||||
predictions,
|
||||
readings,
|
||||
recommendations,
|
||||
sensors,
|
||||
@@ -34,3 +35,6 @@ api_router.include_router(
|
||||
api_router.include_router(
|
||||
sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN
|
||||
)
|
||||
api_router.include_router(
|
||||
predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR
|
||||
)
|
||||
|
||||
@@ -22,8 +22,11 @@ from app.core.hashing import build_hasher
|
||||
from app.core.roles import Role
|
||||
from app.db.session import get_session_factory
|
||||
from app.main import create_app
|
||||
from app.repositories.alert import AlertRepository
|
||||
from app.repositories.recommendation import RecommendationRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite
|
||||
from app.services.recommendation import RecommendationService
|
||||
|
||||
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
||||
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
|
||||
@@ -63,6 +66,22 @@ async def create_admin(
|
||||
)
|
||||
|
||||
|
||||
async def generate_recommendations(*, site_id: str | None) -> str:
|
||||
async with get_session_factory()() as session:
|
||||
service = RecommendationService(
|
||||
recommendations=RecommendationRepository(session),
|
||||
alerts=AlertRepository(session),
|
||||
transaction=session,
|
||||
)
|
||||
rapport = await service.generate(site_id=site_id)
|
||||
|
||||
return (
|
||||
f"{rapport.alertes_examinees} alerte(s) examinée(s), "
|
||||
f"{rapport.recommandations_creees} recommandation(s) créée(s), "
|
||||
f"{rapport.deja_presentes} déjà présente(s)"
|
||||
)
|
||||
|
||||
|
||||
# Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le
|
||||
# fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle
|
||||
# de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris.
|
||||
@@ -109,6 +128,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"export-openapi", help="Écrit le contrat OpenAPI sur disque"
|
||||
)
|
||||
contrat.add_argument("--output", default=str(CHEMIN_CONTRAT))
|
||||
|
||||
recommandations = sous_commandes.add_parser(
|
||||
"generate-recommendations",
|
||||
help="Applique le moteur de règles aux alertes en base",
|
||||
)
|
||||
recommandations.add_argument(
|
||||
"--site-id", default=None, help="Limite le traitement aux alertes d'un site"
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -152,6 +179,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(export_openapi(Path(arguments.output)))
|
||||
return 0
|
||||
|
||||
if arguments.commande == "generate-recommendations":
|
||||
print(asyncio.run(generate_recommendations(site_id=arguments.site_id)))
|
||||
return 0
|
||||
|
||||
mot_de_passe = read_password(generate=arguments.generate)
|
||||
|
||||
succes, message = asyncio.run(
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,416 @@
|
||||
# Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance.
|
||||
# Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie
|
||||
# n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs
|
||||
# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS et la taille
|
||||
# des tableaux est plafonnée par MAX_SITES et par --limit. Une valeur hors bornes devient NULL
|
||||
# et laisse sa trace dans null_reasons plutôt que de lever : le mock émet des anomalies par
|
||||
# construction, et raw_data conserve de toute façon la réponse d'origine intacte.
|
||||
|
||||
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"
|
||||
|
||||
MAX_SITES = 100
|
||||
|
||||
MAX_LIMIT = 1000
|
||||
|
||||
# Les quatre seules valeurs que la contrainte ck_reading_quality accepte.
|
||||
ACCEPTED_QUALITIES = frozenset({"good", "partial", "degraded", "critical"})
|
||||
|
||||
PHYSICAL_BOUNDS: dict[str, tuple[float, float]] = {
|
||||
"consumption_kw": (0.0, 100_000.0),
|
||||
"consumption_kwh": (0.0, 100_000.0),
|
||||
"voltage_v": (0.0, 1_000.0),
|
||||
"current_a": (0.0, 10_000.0),
|
||||
"power_factor": (0.0, 1.0),
|
||||
"temperature_celsius": (-90.0, 60.0),
|
||||
"humidity_percent": (0.0, 100.0),
|
||||
}
|
||||
|
||||
CAPACITY_BOUNDS = (0.0, 100_000.0)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def read_text(payload: dict[str, Any], key: str) -> str:
|
||||
value = payload.get(key)
|
||||
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"Champ {key} absent ou invalide dans la réponse de l'API Mock.")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def optional_text(value: Any) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def coerce_measure(
|
||||
value: Any,
|
||||
bounds: tuple[float, float],
|
||||
) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
return None
|
||||
|
||||
lower, upper = bounds
|
||||
|
||||
# Écarte aussi NaN et les infinis, qu'aucune comparaison de bornes ne retient.
|
||||
return float(value) if lower <= value <= upper else None
|
||||
|
||||
|
||||
def resolve_quality(
|
||||
value: Any,
|
||||
rejected: list[str],
|
||||
) -> str | None:
|
||||
quality = value if isinstance(value, str) and value in ACCEPTED_QUALITIES else None
|
||||
|
||||
if rejected:
|
||||
return "critical" if quality == "critical" else "degraded"
|
||||
|
||||
return quality
|
||||
|
||||
|
||||
def resolve_null_reasons(
|
||||
value: Any,
|
||||
rejected: list[str],
|
||||
) -> list[str]:
|
||||
reported = [str(reason) for reason in value] if isinstance(value, list) else []
|
||||
|
||||
return reported + rejected
|
||||
|
||||
|
||||
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.")
|
||||
|
||||
if len(payload) > MAX_SITES:
|
||||
raise ValueError(f"La réponse /api/v1/sites dépasse le plafond de {MAX_SITES} sites.")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def build_site_row(
|
||||
site: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"site_id": read_text(site, "site_id"),
|
||||
"site_type": read_text(site, "site_type"),
|
||||
"site_name": read_text(site, "site_name"),
|
||||
"location": optional_text(site.get("location")),
|
||||
"capacity_kw": coerce_measure(site.get("capacity_kw"), CAPACITY_BOUNDS),
|
||||
"status": optional_text(site.get("status")),
|
||||
}
|
||||
|
||||
|
||||
async def upsert_sites(
|
||||
connection: AsyncConnection,
|
||||
sites: list[dict[str, Any]],
|
||||
) -> None:
|
||||
rows = [build_site_row(site) for site in sites]
|
||||
|
||||
if not rows:
|
||||
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
|
||||
"""
|
||||
),
|
||||
rows,
|
||||
)
|
||||
|
||||
|
||||
async def fetch_readings(
|
||||
client: httpx.AsyncClient,
|
||||
site_id: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
limit: int = MAX_LIMIT,
|
||||
) -> 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.")
|
||||
|
||||
if len(payload) > limit:
|
||||
raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def build_reading_row(
|
||||
reading: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
measures: dict[str, float | None] = {}
|
||||
rejected: list[str] = []
|
||||
|
||||
for name, bounds in PHYSICAL_BOUNDS.items():
|
||||
received = reading.get(name)
|
||||
measures[name] = coerce_measure(received, bounds)
|
||||
|
||||
if received is not None and measures[name] is None:
|
||||
rejected.append(f"out_of_physical_bounds:{name}")
|
||||
|
||||
return {
|
||||
"site_id": read_text(reading, "site_id"),
|
||||
"timestamp": parse_datetime(read_text(reading, "timestamp")),
|
||||
"source": SOURCE_HISTORY,
|
||||
"dataset_id": None,
|
||||
**measures,
|
||||
"consumption_euros": None,
|
||||
"solar_irradiance_wm2": None,
|
||||
"is_working_hours": None,
|
||||
"data_quality": resolve_quality(reading.get("data_quality"), rejected),
|
||||
"null_reasons": resolve_null_reasons(reading.get("null_reasons"), rejected),
|
||||
"imputed_values": None,
|
||||
"imputation_method": None,
|
||||
"raw_data": json.dumps(
|
||||
reading,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible
|
||||
# nommée, DO NOTHING avalerait aussi une violation de clé primaire.
|
||||
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 (site_id, timestamp, source, (coalesce(dataset_id, 0)))
|
||||
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 = read_text(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=MAX_LIMIT,
|
||||
)
|
||||
|
||||
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 > MAX_LIMIT:
|
||||
raise ValueError(f"--limit doit être compris entre 1 et {MAX_LIMIT}.")
|
||||
|
||||
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()
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Prediction
|
||||
|
||||
|
||||
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
|
||||
# `ReadingRepository.latest_by_site`. Trié sur `target_at` (couvert par
|
||||
# `ix_prediction_site_target`) plutôt que `created_at` : c'est la prévision la plus
|
||||
# récente qui compte pour un tableau de bord, pas forcément le dernier run de scoring.
|
||||
requete = (
|
||||
select(Prediction)
|
||||
.distinct(Prediction.site_id)
|
||||
.order_by(
|
||||
Prediction.site_id,
|
||||
Prediction.target_at.desc(),
|
||||
Prediction.prediction_id.desc(),
|
||||
)
|
||||
)
|
||||
return (await self._session.scalars(requete)).all()
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Recommendation
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NouvelleRecommandation:
|
||||
alert_id: int
|
||||
action: str
|
||||
explanation: str
|
||||
rule_reference: str
|
||||
|
||||
|
||||
TAILLE_DE_LOT = 1000
|
||||
|
||||
|
||||
class RecommendationRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
@@ -20,3 +33,19 @@ class RecommendationRepository:
|
||||
)
|
||||
recommendation: Recommendation | None = await self._session.scalar(requete)
|
||||
return recommendation
|
||||
|
||||
# Pourquoi : l'idempotence est déléguée à `uq_recommendation_alert_rule` plutôt qu'à une
|
||||
# lecture préalable, qui laisserait une fenêtre entre le contrôle et l'insertion.
|
||||
async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int:
|
||||
creees = 0
|
||||
# Piège : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre
|
||||
# colonnes. Au-delà de ce seuil un `INSERT` d'un seul tenant échouerait.
|
||||
for debut in range(0, len(nouvelles), TAILLE_DE_LOT):
|
||||
requete = (
|
||||
insert(Recommendation)
|
||||
.values([asdict(nouvelle) for nouvelle in nouvelles[debut : debut + TAILLE_DE_LOT]])
|
||||
.on_conflict_do_nothing(constraint="uq_recommendation_alert_rule")
|
||||
.returning(Recommendation.recommendation_id)
|
||||
)
|
||||
creees += len((await self._session.scalars(requete)).all())
|
||||
return creees
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class PredictionTargetMetric(StrEnum):
|
||||
CONSUMPTION_KWH = "consumption_kwh"
|
||||
CONSUMPTION_KW = "consumption_kw"
|
||||
|
||||
|
||||
class PredictionStatus(StrEnum):
|
||||
AVAILABLE = "available"
|
||||
INSUFFICIENT_DATA = "insufficient_data"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class SitePredictionResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
target_at: datetime
|
||||
target_metric: PredictionTargetMetric
|
||||
period_minutes: int | None
|
||||
predicted_value: float | None
|
||||
status: PredictionStatus
|
||||
failure_reason: str | None
|
||||
model_reference: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SitePredictionSummaryResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
site_id: str
|
||||
site_name: str
|
||||
prediction: SitePredictionResponse | None
|
||||
|
||||
|
||||
class PredictionSummaryResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
timestamp: datetime
|
||||
sites: list[SitePredictionSummaryResponse]
|
||||
@@ -12,3 +12,9 @@ class RecommendationResponse(BaseModel):
|
||||
explanation: str
|
||||
rule_reference: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RecommendationGenerationResponse(BaseModel):
|
||||
alerts_examined: int
|
||||
recommendations_created: int
|
||||
already_present: int
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.models.energy import Prediction, Site
|
||||
from app.repositories.prediction import PredictionRepository
|
||||
from app.repositories.site import SiteRepository
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SitePrediction:
|
||||
target_at: datetime
|
||||
target_metric: str
|
||||
period_minutes: int | None
|
||||
predicted_value: float | None
|
||||
status: str
|
||||
failure_reason: str | None
|
||||
model_reference: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SitePredictionSummary:
|
||||
site_id: str
|
||||
site_name: str
|
||||
prediction: SitePrediction | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PredictionSummary:
|
||||
timestamp: datetime
|
||||
sites: list[SitePredictionSummary]
|
||||
|
||||
|
||||
class PredictionService:
|
||||
def __init__(self, sites: SiteRepository, predictions: PredictionRepository) -> None:
|
||||
self._sites = sites
|
||||
self._predictions = predictions
|
||||
|
||||
async def summary(self) -> PredictionSummary:
|
||||
sites = await self._sites.list_all()
|
||||
dernieres = {p.site_id: p for p in await self._predictions.latest_by_site()}
|
||||
|
||||
return PredictionSummary(
|
||||
timestamp=datetime.now(UTC),
|
||||
sites=[_resume_site(site, dernieres.get(site.site_id)) for site in sites],
|
||||
)
|
||||
|
||||
|
||||
def _resume_site(site: Site, derniere: Prediction | None) -> SitePredictionSummary:
|
||||
# Piège : l'absence de ligne signifie « jamais scoré », pas une valeur pseudo-statut, qui
|
||||
# n'existe pas dans la contrainte de la table. `prediction` reste `None` plutôt que de
|
||||
# fabriquer un statut absent du domaine `available`/`insufficient_data`/`error`.
|
||||
prediction = None
|
||||
if derniere is not None:
|
||||
prediction = SitePrediction(
|
||||
target_at=derniere.target_at,
|
||||
target_metric=derniere.target_metric,
|
||||
period_minutes=derniere.period_minutes,
|
||||
predicted_value=derniere.predicted_value,
|
||||
status=derniere.status,
|
||||
failure_reason=derniere.failure_reason,
|
||||
model_reference=derniere.model_reference,
|
||||
created_at=derniere.created_at,
|
||||
)
|
||||
|
||||
return SitePredictionSummary(
|
||||
site_id=site.site_id, site_name=site.site_name, prediction=prediction
|
||||
)
|
||||
@@ -1,7 +1,15 @@
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from app.models.energy import Recommendation
|
||||
from app.repositories.alert import AlertRepository
|
||||
from app.repositories.recommendation import RecommendationRepository
|
||||
from app.services.recommendation_rules import applique_les_regles
|
||||
|
||||
|
||||
class Transaction(Protocol):
|
||||
async def commit(self) -> None: ...
|
||||
|
||||
|
||||
class RecommendationError(Exception):
|
||||
@@ -12,9 +20,24 @@ class RecommendationNotFoundError(RecommendationError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RapportGeneration:
|
||||
alertes_examinees: int
|
||||
recommandations_creees: int
|
||||
deja_presentes: int
|
||||
|
||||
|
||||
class RecommendationService:
|
||||
def __init__(self, *, recommendations: RecommendationRepository) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
recommendations: RecommendationRepository,
|
||||
alerts: AlertRepository,
|
||||
transaction: Transaction,
|
||||
) -> None:
|
||||
self._recommendations = recommendations
|
||||
self._alerts = alerts
|
||||
self._transaction = transaction
|
||||
|
||||
async def list_all(self) -> Sequence[Recommendation]:
|
||||
return await self._recommendations.list_all()
|
||||
@@ -24,3 +47,16 @@ class RecommendationService:
|
||||
if recommendation is None:
|
||||
raise RecommendationNotFoundError(recommendation_id)
|
||||
return recommendation
|
||||
|
||||
async def generate(self, *, site_id: str | None = None) -> RapportGeneration:
|
||||
alertes = await self._alerts.list_all(site_id=site_id)
|
||||
nouvelles = [nouvelle for alerte in alertes for nouvelle in applique_les_regles(alerte)]
|
||||
|
||||
creees = await self._recommendations.create_missing(nouvelles)
|
||||
await self._transaction.commit()
|
||||
|
||||
return RapportGeneration(
|
||||
alertes_examinees=len(alertes),
|
||||
recommandations_creees=creees,
|
||||
deja_presentes=len(nouvelles) - creees,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Piège : `rule_reference` est la clé d'idempotence en base, portée par la contrainte
|
||||
# `uq_recommendation_alert_rule`. Renommer une référence déjà livrée ne remplace pas les
|
||||
# recommandations existantes, il en crée de nouvelles à côté. Une règle qui change de sens
|
||||
# prend donc une référence suffixée `-v2` - REGLES.
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from app.models.energy import Alert
|
||||
from app.repositories.recommendation import NouvelleRecommandation
|
||||
from app.schemas.alert import AlertSeverity, AlertType
|
||||
|
||||
FACTEUR_DEPASSEMENT_MAJEUR: Final = 1.2
|
||||
POURCENTAGE_DEPASSEMENT_MAJEUR: Final = round((FACTEUR_DEPASSEMENT_MAJEUR - 1) * 100)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Regle:
|
||||
reference: str
|
||||
action: str
|
||||
declencheur: Callable[[Alert], bool]
|
||||
motif: Callable[[Alert], str]
|
||||
|
||||
|
||||
def _du_type(attendu: AlertType) -> Callable[[Alert], bool]:
|
||||
return lambda alerte: alerte.type == attendu
|
||||
|
||||
|
||||
def _de_severite(attendue: AlertSeverity) -> Callable[[Alert], bool]:
|
||||
return lambda alerte: alerte.severity == attendue
|
||||
|
||||
|
||||
# Un seuil nul ou négatif rendrait le rapport `value / threshold` arbitraire : l'alerte ne
|
||||
# renseigne alors aucun dépassement exploitable, et la règle ne se déclenche pas.
|
||||
def _depasse_largement_le_seuil(alerte: Alert) -> bool:
|
||||
if alerte.value is None or alerte.threshold is None or alerte.threshold <= 0:
|
||||
return False
|
||||
return alerte.value >= alerte.threshold * FACTEUR_DEPASSEMENT_MAJEUR
|
||||
|
||||
|
||||
REGLES: Final[tuple[Regle, ...]] = (
|
||||
Regle(
|
||||
reference="spike-delestage-v1",
|
||||
action="Délester les équipements non prioritaires sur le créneau du pic",
|
||||
declencheur=_du_type(AlertType.SPIKE),
|
||||
motif=lambda alerte: f"Pic de consommation signalé sur le site {alerte.site_id}",
|
||||
),
|
||||
Regle(
|
||||
reference="threshold-reduction-v1",
|
||||
action="Ramener la puissance appelée sous le seuil contractuel",
|
||||
declencheur=_du_type(AlertType.THRESHOLD),
|
||||
motif=lambda alerte: f"Seuil de consommation dépassé sur le site {alerte.site_id}",
|
||||
),
|
||||
Regle(
|
||||
reference="outage-secours-v1",
|
||||
action="Basculer sur l'alimentation de secours et prévenir l'exploitant",
|
||||
declencheur=_du_type(AlertType.OUTAGE),
|
||||
motif=lambda alerte: (
|
||||
f"Risque de surcharge ou de coupure imminente sur le site {alerte.site_id}"
|
||||
),
|
||||
),
|
||||
Regle(
|
||||
reference="sensor-maintenance-v1",
|
||||
action="Planifier une intervention de maintenance sur le capteur",
|
||||
declencheur=_du_type(AlertType.SENSOR),
|
||||
motif=lambda alerte: (
|
||||
f"Capteur défaillant sur le site {alerte.site_id}, les mesures ne sont plus fiables"
|
||||
),
|
||||
),
|
||||
Regle(
|
||||
reference="anomaly-verification-v1",
|
||||
action="Confronter la mesure à la prévision et vérifier le paramétrage du site",
|
||||
declencheur=_du_type(AlertType.ANOMALY),
|
||||
motif=lambda alerte: (
|
||||
f"Écart anormal entre la mesure et le comportement attendu du site {alerte.site_id}"
|
||||
),
|
||||
),
|
||||
Regle(
|
||||
reference="escalade-astreinte-v1",
|
||||
action="Escalader à l'astreinte sous une heure",
|
||||
declencheur=_de_severite(AlertSeverity.CRITICAL),
|
||||
motif=lambda alerte: f"Alerte de sévérité critique sur le site {alerte.site_id}",
|
||||
),
|
||||
Regle(
|
||||
reference="contrat-puissance-v1",
|
||||
action="Réévaluer la puissance souscrite au contrat",
|
||||
declencheur=_depasse_largement_le_seuil,
|
||||
motif=lambda alerte: (
|
||||
f"Dépassement d'au moins {POURCENTAGE_DEPASSEMENT_MAJEUR} % du seuil "
|
||||
f"sur le site {alerte.site_id}"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def applique_les_regles(alerte: Alert) -> list[NouvelleRecommandation]:
|
||||
contexte = _contexte_de_mesure(alerte)
|
||||
return [
|
||||
NouvelleRecommandation(
|
||||
alert_id=alerte.alert_id,
|
||||
action=regle.action,
|
||||
explanation=f"{regle.motif(alerte)}{contexte}.",
|
||||
rule_reference=regle.reference,
|
||||
)
|
||||
for regle in REGLES
|
||||
if regle.declencheur(alerte)
|
||||
]
|
||||
|
||||
|
||||
def _contexte_de_mesure(alerte: Alert) -> str:
|
||||
if alerte.value is None:
|
||||
return ""
|
||||
grandeur = alerte.metric or "valeur"
|
||||
if alerte.threshold is None:
|
||||
return f" ({grandeur} mesurée à {alerte.value})"
|
||||
return f" ({grandeur} mesurée à {alerte.value}, seuil {alerte.threshold})"
|
||||
+305
-1
@@ -1453,6 +1453,90 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/recommendations/generate": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"recommendations"
|
||||
],
|
||||
"summary": "Génère les recommandations à partir des alertes",
|
||||
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "site_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Site Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RecommendationGenerationResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InternalErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/stats/summary": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1719,6 +1803,62 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/predictions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"predictions"
|
||||
],
|
||||
"summary": "Dernière prédiction de consommation par site",
|
||||
"operationId": "get_predictions_api_v1_predictions_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PredictionSummaryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InternalErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -1972,6 +2112,45 @@
|
||||
],
|
||||
"title": "PasswordChangeRequest"
|
||||
},
|
||||
"PredictionStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"available",
|
||||
"insufficient_data",
|
||||
"error"
|
||||
],
|
||||
"title": "PredictionStatus"
|
||||
},
|
||||
"PredictionSummaryResponse": {
|
||||
"properties": {
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Timestamp"
|
||||
},
|
||||
"sites": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SitePredictionSummaryResponse"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Sites"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"timestamp",
|
||||
"sites"
|
||||
],
|
||||
"title": "PredictionSummaryResponse"
|
||||
},
|
||||
"PredictionTargetMetric": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"consumption_kwh",
|
||||
"consumption_kw"
|
||||
],
|
||||
"title": "PredictionTargetMetric"
|
||||
},
|
||||
"PrincipalResponse": {
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -2249,6 +2428,29 @@
|
||||
],
|
||||
"title": "ReadingSource"
|
||||
},
|
||||
"RecommendationGenerationResponse": {
|
||||
"properties": {
|
||||
"alerts_examined": {
|
||||
"type": "integer",
|
||||
"title": "Alerts Examined"
|
||||
},
|
||||
"recommendations_created": {
|
||||
"type": "integer",
|
||||
"title": "Recommendations Created"
|
||||
},
|
||||
"already_present": {
|
||||
"type": "integer",
|
||||
"title": "Already Present"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"alerts_examined",
|
||||
"recommendations_created",
|
||||
"already_present"
|
||||
],
|
||||
"title": "RecommendationGenerationResponse"
|
||||
},
|
||||
"RecommendationResponse": {
|
||||
"properties": {
|
||||
"recommendation_id": {
|
||||
@@ -2518,6 +2720,104 @@
|
||||
],
|
||||
"title": "SiteCurrentResponse"
|
||||
},
|
||||
"SitePredictionResponse": {
|
||||
"properties": {
|
||||
"target_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Target At"
|
||||
},
|
||||
"target_metric": {
|
||||
"$ref": "#/components/schemas/PredictionTargetMetric"
|
||||
},
|
||||
"period_minutes": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Period Minutes"
|
||||
},
|
||||
"predicted_value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Predicted Value"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/PredictionStatus"
|
||||
},
|
||||
"failure_reason": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Failure Reason"
|
||||
},
|
||||
"model_reference": {
|
||||
"type": "string",
|
||||
"title": "Model Reference"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Created At"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target_at",
|
||||
"target_metric",
|
||||
"period_minutes",
|
||||
"predicted_value",
|
||||
"status",
|
||||
"failure_reason",
|
||||
"model_reference",
|
||||
"created_at"
|
||||
],
|
||||
"title": "SitePredictionResponse"
|
||||
},
|
||||
"SitePredictionSummaryResponse": {
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string",
|
||||
"title": "Site Id"
|
||||
},
|
||||
"site_name": {
|
||||
"type": "string",
|
||||
"title": "Site Name"
|
||||
},
|
||||
"prediction": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SitePredictionResponse"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"site_id",
|
||||
"site_name",
|
||||
"prediction"
|
||||
],
|
||||
"title": "SitePredictionSummaryResponse"
|
||||
},
|
||||
"SiteResponse": {
|
||||
"properties": {
|
||||
"site_id": {
|
||||
@@ -2960,7 +3260,7 @@
|
||||
},
|
||||
{
|
||||
"name": "recommendations",
|
||||
"description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`."
|
||||
"description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`."
|
||||
},
|
||||
{
|
||||
"name": "stats",
|
||||
@@ -2973,6 +3273,10 @@
|
||||
{
|
||||
"name": "sensors",
|
||||
"description": "État de santé des capteurs par site. Réservé au rôle `admin`."
|
||||
},
|
||||
{
|
||||
"name": "predictions",
|
||||
"description": "Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -51,8 +51,10 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = {
|
||||
("GET", "/api/v1/alerts"): Role.LECTEUR,
|
||||
("GET", "/api/v1/recommendations"): Role.LECTEUR,
|
||||
("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR,
|
||||
("POST", "/api/v1/recommendations/generate"): Role.ADMIN,
|
||||
("GET", "/api/v1/stats/summary"): Role.LECTEUR,
|
||||
("GET", "/api/v1/readings"): Role.LECTEUR,
|
||||
("GET", "/api/v1/predictions"): Role.LECTEUR,
|
||||
("GET", "/api/v1/sensors/status"): Role.ADMIN,
|
||||
("GET", "/api/v1/users"): Role.ADMIN,
|
||||
("POST", "/api/v1/users"): Role.ADMIN,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.api.deps import get_current_principal, get_prediction_service
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.services.prediction import PredictionSummary, SitePrediction, SitePredictionSummary
|
||||
|
||||
TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC)
|
||||
CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def lecteur() -> Principal:
|
||||
# Le garde-fou de rôle (`lecteur` minimum) est déjà couvert par l'ensemble `ROUTES_A_ROLE`
|
||||
# de `tests/api/test_openapi.py` : pas besoin ici d'un paramètre de rôle jamais appelé avec
|
||||
# autre chose que sa valeur par défaut.
|
||||
return Principal(
|
||||
id=uuid4(),
|
||||
email="lecteur@enervision.fr",
|
||||
role=Role.LECTEUR,
|
||||
kind=AccountKind.HUMAIN,
|
||||
must_change_password=False,
|
||||
)
|
||||
|
||||
|
||||
class FauxService:
|
||||
def __init__(self) -> None:
|
||||
self.resume = PredictionSummary(
|
||||
timestamp=datetime.now(UTC),
|
||||
sites=[
|
||||
SitePredictionSummary(
|
||||
site_id="SITE001",
|
||||
site_name="Bureau Paris La Défense",
|
||||
prediction=SitePrediction(
|
||||
target_at=TARGET_AT,
|
||||
target_metric="consumption_kwh",
|
||||
period_minutes=60,
|
||||
predicted_value=812.5,
|
||||
status="available",
|
||||
failure_reason=None,
|
||||
model_reference="lightgbm-abc123",
|
||||
created_at=CREATED_AT,
|
||||
),
|
||||
),
|
||||
SitePredictionSummary(site_id="SITE002", site_name="Usine Lyon", prediction=None),
|
||||
],
|
||||
)
|
||||
|
||||
async def summary(self) -> PredictionSummary:
|
||||
return self.resume
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]:
|
||||
def installe() -> FauxService:
|
||||
service = FauxService()
|
||||
app.dependency_overrides[get_prediction_service] = lambda: service
|
||||
app.dependency_overrides[get_current_principal] = lambda: lecteur()
|
||||
return service
|
||||
|
||||
yield installe
|
||||
app.dependency_overrides.pop(get_prediction_service, None)
|
||||
app.dependency_overrides.pop(get_current_principal, None)
|
||||
|
||||
|
||||
async def test_get_predictions_returns_the_service_result(
|
||||
servi: Callable[[], FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi()
|
||||
|
||||
response = await client.get("/api/v1/predictions")
|
||||
|
||||
assert response.status_code == 200
|
||||
corps = response.json()
|
||||
premier, second = corps["sites"]
|
||||
assert premier["site_id"] == "SITE001"
|
||||
assert premier["prediction"]["predicted_value"] == 812.5
|
||||
assert premier["prediction"]["status"] == "available"
|
||||
assert second["site_id"] == "SITE002"
|
||||
assert second["prediction"] is None
|
||||
@@ -10,7 +10,7 @@ from app.api.deps import get_current_principal, get_recommendation_service
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.models.energy import Recommendation
|
||||
from app.services.recommendation import RecommendationNotFoundError
|
||||
from app.services.recommendation import RapportGeneration, RecommendationNotFoundError
|
||||
|
||||
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
|
||||
@@ -40,6 +40,7 @@ class FauxService:
|
||||
def __init__(self, erreur: Exception | None = None) -> None:
|
||||
self._erreur = erreur
|
||||
self.recommendation = recommendation()
|
||||
self.site_demande: str | None = None
|
||||
|
||||
async def list_all(self) -> list[Recommendation]:
|
||||
return [self.recommendation]
|
||||
@@ -49,6 +50,10 @@ class FauxService:
|
||||
raise self._erreur
|
||||
return self.recommendation
|
||||
|
||||
async def generate(self, *, site_id: str | None = None) -> RapportGeneration:
|
||||
self.site_demande = site_id
|
||||
return RapportGeneration(alertes_examinees=2, recommandations_creees=3, deja_presentes=1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
|
||||
@@ -142,3 +147,56 @@ async def test_get_recommendation_returns_404_when_the_session_finds_nothing(
|
||||
response = await client.get("/api/v1/recommendations/404")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_connecte(app: FastAPI) -> Iterator[None]:
|
||||
app.dependency_overrides[get_current_principal] = lambda: principal(Role.ADMIN)
|
||||
yield
|
||||
app.dependency_overrides.pop(get_current_principal, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def servi_en_admin(app: FastAPI, admin_connecte: None) -> Iterator[Callable[[], FauxService]]:
|
||||
def installe() -> FauxService:
|
||||
service = FauxService()
|
||||
app.dependency_overrides[get_recommendation_service] = lambda: service
|
||||
return service
|
||||
|
||||
yield installe
|
||||
app.dependency_overrides.pop(get_recommendation_service, None)
|
||||
|
||||
|
||||
async def test_generate_recommendations_returns_the_generation_report(
|
||||
servi_en_admin: Callable[[], FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi_en_admin()
|
||||
|
||||
response = await client.post("/api/v1/recommendations/generate")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"alerts_examined": 2,
|
||||
"recommendations_created": 3,
|
||||
"already_present": 1,
|
||||
}
|
||||
|
||||
|
||||
async def test_generate_recommendations_forwards_the_requested_site(
|
||||
servi_en_admin: Callable[[], FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
service = servi_en_admin()
|
||||
|
||||
await client.post("/api/v1/recommendations/generate", params={"site_id": "SITE002"})
|
||||
|
||||
assert service.site_demande == "SITE002"
|
||||
|
||||
|
||||
async def test_generate_recommendations_refuses_a_reader(
|
||||
servi: Callable[..., FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi()
|
||||
|
||||
response = await client.post("/api/v1/recommendations/generate")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -0,0 +1,834 @@
|
||||
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 (
|
||||
MAX_SITES,
|
||||
READING_INSERT,
|
||||
SOURCE_HISTORY,
|
||||
build_reading_batch,
|
||||
build_reading_row,
|
||||
build_site_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_sites_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/sites doit être une liste",
|
||||
):
|
||||
await fetch_sites(client)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
async def test_fetch_sites_rejects_a_response_above_the_cap() -> None:
|
||||
def handler(request: Request) -> Response:
|
||||
return Response(
|
||||
status_code=200,
|
||||
json=[make_site() for _ in range(MAX_SITES + 1)],
|
||||
)
|
||||
|
||||
transport = MockTransport(handler)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://mock.test",
|
||||
) as client:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=f"dépasse le plafond de {MAX_SITES} sites",
|
||||
):
|
||||
await fetch_sites(client)
|
||||
|
||||
|
||||
async def test_fetch_readings_rejects_a_response_above_the_requested_limit() -> None:
|
||||
def handler(request: Request) -> Response:
|
||||
return Response(
|
||||
status_code=200,
|
||||
json=[make_reading(), make_reading(), make_reading()],
|
||||
)
|
||||
|
||||
transport = MockTransport(handler)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://mock.test",
|
||||
) as client:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="dépasse la limite demandée de 2",
|
||||
):
|
||||
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=2,
|
||||
)
|
||||
|
||||
|
||||
def test_build_reading_row_neutralises_values_outside_physical_bounds() -> None:
|
||||
reading = make_reading()
|
||||
|
||||
reading["power_factor"] = 42.0
|
||||
reading["temperature_celsius"] = 1e30
|
||||
reading["humidity_percent"] = -1.0
|
||||
|
||||
row = build_reading_row(reading)
|
||||
|
||||
assert row["power_factor"] is None
|
||||
assert row["temperature_celsius"] is None
|
||||
assert row["humidity_percent"] is None
|
||||
|
||||
assert row["null_reasons"] == [
|
||||
"out_of_physical_bounds:power_factor",
|
||||
"out_of_physical_bounds:temperature_celsius",
|
||||
"out_of_physical_bounds:humidity_percent",
|
||||
]
|
||||
|
||||
assert row["data_quality"] == "degraded"
|
||||
|
||||
assert json.loads(row["raw_data"])["power_factor"] == 42.0
|
||||
|
||||
|
||||
def test_build_reading_row_rejects_a_measure_that_is_not_a_number() -> None:
|
||||
reading = make_reading()
|
||||
|
||||
reading["consumption_kw"] = "87.34"
|
||||
|
||||
row = build_reading_row(reading)
|
||||
|
||||
assert row["consumption_kw"] is None
|
||||
assert "out_of_physical_bounds:consumption_kw" in row["null_reasons"]
|
||||
|
||||
|
||||
def test_build_reading_row_drops_a_quality_the_database_refuses() -> None:
|
||||
reading = make_reading()
|
||||
|
||||
reading["data_quality"] = "unknown"
|
||||
|
||||
row = build_reading_row(reading)
|
||||
|
||||
assert row["data_quality"] is None
|
||||
|
||||
|
||||
def test_build_reading_row_requires_an_identifier() -> None:
|
||||
reading = make_reading()
|
||||
|
||||
del reading["site_id"]
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Champ site_id absent ou invalide",
|
||||
):
|
||||
build_reading_row(reading)
|
||||
|
||||
|
||||
def test_build_site_row_keeps_only_the_expected_columns() -> None:
|
||||
site = make_site()
|
||||
|
||||
site["unexpected"] = "valeur hostile"
|
||||
site["capacity_kw"] = -5.0
|
||||
site["status"] = 12
|
||||
|
||||
row = build_site_row(site)
|
||||
|
||||
assert set(row) == {
|
||||
"site_id",
|
||||
"site_type",
|
||||
"site_name",
|
||||
"location",
|
||||
"capacity_kw",
|
||||
"status",
|
||||
}
|
||||
|
||||
assert row["capacity_kw"] is None
|
||||
assert row["status"] is None
|
||||
|
||||
|
||||
async def test_upsert_sites_sends_only_the_expected_columns() -> None:
|
||||
connection = AsyncMock()
|
||||
|
||||
site = make_site()
|
||||
site["unexpected"] = "valeur hostile"
|
||||
|
||||
await upsert_sites(
|
||||
connection,
|
||||
[site],
|
||||
)
|
||||
|
||||
rows = connection.execute.await_args.args[1]
|
||||
|
||||
assert "unexpected" not in rows[0]
|
||||
assert rows[0]["site_id"] == "SITE001"
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_out_of_bounds_reading_is_stored_neutralised(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
reading = make_reading()
|
||||
reading["power_factor"] = 42.0
|
||||
|
||||
row = build_reading_row(reading)
|
||||
|
||||
connection = await session.connection()
|
||||
|
||||
await upsert_sites(
|
||||
connection,
|
||||
[make_site()],
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
READING_INSERT,
|
||||
[row],
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT power_factor, data_quality, null_reasons, raw_data ->> 'power_factor'
|
||||
FROM reading
|
||||
WHERE site_id = :site_id
|
||||
AND timestamp = :timestamp
|
||||
AND source = :source
|
||||
"""
|
||||
),
|
||||
{
|
||||
"site_id": row["site_id"],
|
||||
"timestamp": row["timestamp"],
|
||||
"source": row["source"],
|
||||
},
|
||||
)
|
||||
|
||||
stored = result.one()
|
||||
|
||||
await session.rollback()
|
||||
|
||||
assert stored[0] is None
|
||||
assert stored[1] == "degraded"
|
||||
assert stored[2] == ["out_of_physical_bounds:power_factor"]
|
||||
assert stored[3] == "42.0"
|
||||
@@ -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 == []
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Prediction
|
||||
from app.repositories.prediction import PredictionRepository
|
||||
from tests.repositories.test_site import creer as creer_site
|
||||
from tests.repositories.test_site import identifiant as identifiant_site
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def creer_prediction(
|
||||
session: AsyncSession, *, site_id: str, **overrides: object
|
||||
) -> Prediction:
|
||||
prediction = Prediction(
|
||||
site_id=site_id,
|
||||
target_at=overrides.get("target_at", datetime(2026, 9, 16, tzinfo=UTC)),
|
||||
target_metric=overrides.get("target_metric", "consumption_kwh"),
|
||||
period_minutes=overrides.get("period_minutes", 60),
|
||||
predicted_value=overrides.get("predicted_value", 42.0),
|
||||
model_reference=overrides.get("model_reference", "lightgbm-test"),
|
||||
status=overrides.get("status", "available"),
|
||||
failure_reason=overrides.get("failure_reason"),
|
||||
)
|
||||
session.add(prediction)
|
||||
await session.flush()
|
||||
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)
|
||||
ancienne = await creer_prediction(
|
||||
session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC)
|
||||
)
|
||||
recente = await creer_prediction(
|
||||
session, site_id=site.site_id, target_at=datetime(2026, 9, 15, tzinfo=UTC)
|
||||
)
|
||||
|
||||
resultats = await depot.latest_by_site()
|
||||
identifiants = [
|
||||
p.prediction_id
|
||||
for p in resultats
|
||||
if p.prediction_id in (ancienne.prediction_id, recente.prediction_id)
|
||||
]
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == [recente.prediction_id]
|
||||
|
||||
|
||||
async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None:
|
||||
premier = await creer_site(session)
|
||||
second = await creer_site(session)
|
||||
depot = PredictionRepository(session)
|
||||
voulue_premier = await creer_prediction(session, site_id=premier.site_id)
|
||||
voulue_second = await creer_prediction(session, site_id=second.site_id)
|
||||
|
||||
resultats = await depot.latest_by_site()
|
||||
identifiants = {p.site_id for p in resultats if p.site_id in (premier.site_id, second.site_id)}
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == {voulue_premier.site_id, voulue_second.site_id}
|
||||
|
||||
|
||||
async def test_latest_by_site_keeps_an_insufficient_data_prediction(session: AsyncSession) -> None:
|
||||
site = await creer_site(session)
|
||||
depot = PredictionRepository(session)
|
||||
voulue = await creer_prediction(
|
||||
session,
|
||||
site_id=site.site_id,
|
||||
status="insufficient_data",
|
||||
predicted_value=None,
|
||||
failure_reason="pas assez d'historique",
|
||||
)
|
||||
|
||||
resultats = await depot.latest_by_site()
|
||||
identifiants = [p.prediction_id for p in resultats if p.site_id == site.site_id]
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == [voulue.prediction_id]
|
||||
|
||||
|
||||
async def test_latest_by_site_returns_an_empty_list_when_there_is_nothing(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
depot = PredictionRepository(session)
|
||||
|
||||
resultats = [p for p in await depot.latest_by_site() if p.site_id == identifiant_site()]
|
||||
|
||||
assert resultats == []
|
||||
@@ -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:
|
||||
|
||||
@@ -5,7 +5,8 @@ import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Alert, Recommendation, Site
|
||||
from app.repositories.recommendation import RecommendationRepository
|
||||
from app.repositories import recommendation as module_recommendation
|
||||
from app.repositories.recommendation import NouvelleRecommandation, RecommendationRepository
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -83,3 +84,59 @@ async def test_list_all_returns_the_recommendations_sorted_by_identifier(
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == sorted(identifiants)
|
||||
|
||||
|
||||
def nouvelle(alert_id: int, reference: str = "spike-delestage-v1") -> NouvelleRecommandation:
|
||||
return NouvelleRecommandation(
|
||||
alert_id=alert_id,
|
||||
action="Délester les équipements non prioritaires",
|
||||
explanation="Pic de consommation signalé.",
|
||||
rule_reference=reference,
|
||||
)
|
||||
|
||||
|
||||
async def test_create_missing_inserts_the_proposals(session: AsyncSession) -> None:
|
||||
depot = RecommendationRepository(session)
|
||||
alert_id = await creer_alerte(session)
|
||||
|
||||
creees = await depot.create_missing(
|
||||
[nouvelle(alert_id), nouvelle(alert_id, "escalade-astreinte-v1")]
|
||||
)
|
||||
await session.rollback()
|
||||
|
||||
assert creees == 2
|
||||
|
||||
|
||||
async def test_create_missing_ignores_a_rule_already_held_for_the_alert(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
depot = RecommendationRepository(session)
|
||||
alert_id = await creer_alerte(session)
|
||||
await depot.create_missing([nouvelle(alert_id)])
|
||||
|
||||
creees = await depot.create_missing([nouvelle(alert_id)])
|
||||
await session.rollback()
|
||||
|
||||
assert creees == 0
|
||||
|
||||
|
||||
async def test_create_missing_returns_zero_without_any_proposal(session: AsyncSession) -> None:
|
||||
creees = await RecommendationRepository(session).create_missing([])
|
||||
|
||||
assert creees == 0
|
||||
|
||||
|
||||
async def test_create_missing_inserts_every_proposal_across_several_batches(
|
||||
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(module_recommendation, "TAILLE_DE_LOT", 2)
|
||||
depot = RecommendationRepository(session)
|
||||
alert_id = await creer_alerte(session)
|
||||
propositions = [nouvelle(alert_id, f"regle-{index}-v1") for index in range(5)]
|
||||
|
||||
creees = await depot.create_missing(propositions)
|
||||
enregistrees = [r for r in await depot.list_all() if r.alert_id == alert_id]
|
||||
await session.rollback()
|
||||
|
||||
assert creees == 5
|
||||
assert len(enregistrees) == 5
|
||||
|
||||
@@ -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,121 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.services.prediction import PredictionService
|
||||
|
||||
TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC)
|
||||
CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FauxSite:
|
||||
site_id: str
|
||||
site_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FauxPrediction:
|
||||
site_id: str
|
||||
target_at: datetime
|
||||
target_metric: str
|
||||
period_minutes: int | None
|
||||
predicted_value: float | None
|
||||
status: str
|
||||
failure_reason: str | None
|
||||
model_reference: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FauxDepotSites:
|
||||
def __init__(self, sites: list[FauxSite]) -> None:
|
||||
self._sites = sites
|
||||
|
||||
async def list_all(self) -> list[FauxSite]:
|
||||
return self._sites
|
||||
|
||||
|
||||
class FauxDepotPredictions:
|
||||
def __init__(self, predictions: list[FauxPrediction]) -> None:
|
||||
self._predictions = predictions
|
||||
|
||||
async def latest_by_site(self) -> list[FauxPrediction]:
|
||||
return self._predictions
|
||||
|
||||
|
||||
def prediction_disponible(site_id: str = "A") -> FauxPrediction:
|
||||
return FauxPrediction(
|
||||
site_id=site_id,
|
||||
target_at=TARGET_AT,
|
||||
target_metric="consumption_kwh",
|
||||
period_minutes=60,
|
||||
predicted_value=812.5,
|
||||
status="available",
|
||||
failure_reason=None,
|
||||
model_reference="lightgbm-abc123",
|
||||
created_at=CREATED_AT,
|
||||
)
|
||||
|
||||
|
||||
async def test_summary_attaches_the_latest_prediction_to_its_site() -> None:
|
||||
service = PredictionService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
resume = await service.summary()
|
||||
|
||||
site = resume.sites[0]
|
||||
assert site.site_id == "A"
|
||||
assert site.prediction is not None
|
||||
assert site.prediction.predicted_value == 812.5
|
||||
assert site.prediction.status == "available"
|
||||
|
||||
|
||||
async def test_summary_leaves_prediction_none_for_a_site_never_scored() -> None:
|
||||
service = PredictionService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
predictions=FauxDepotPredictions([]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
resume = await service.summary()
|
||||
|
||||
assert resume.sites[0].prediction is None
|
||||
|
||||
|
||||
async def test_summary_carries_an_insufficient_data_prediction_without_a_value() -> None:
|
||||
insuffisante = FauxPrediction(
|
||||
site_id="A",
|
||||
target_at=TARGET_AT,
|
||||
target_metric="consumption_kwh",
|
||||
period_minutes=60,
|
||||
predicted_value=None,
|
||||
status="insufficient_data",
|
||||
failure_reason="pas assez d'historique",
|
||||
model_reference="lightgbm-abc123",
|
||||
created_at=CREATED_AT,
|
||||
)
|
||||
service = PredictionService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
predictions=FauxDepotPredictions([insuffisante]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
resume = await service.summary()
|
||||
|
||||
site = resume.sites[0]
|
||||
assert site.prediction is not None
|
||||
assert site.prediction.status == "insufficient_data"
|
||||
assert site.prediction.predicted_value is None
|
||||
assert site.prediction.failure_reason == "pas assez d'historique"
|
||||
|
||||
|
||||
async def test_summary_covers_every_site_even_with_a_single_prediction_in_the_repository() -> None:
|
||||
service = PredictionService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A"), FauxSite("B", "Site B")]), # type: ignore[arg-type]
|
||||
predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
resume = await service.summary()
|
||||
|
||||
par_site = {site.site_id: site for site in resume.sites}
|
||||
assert par_site["A"].prediction is not None
|
||||
assert par_site["B"].prediction is None
|
||||
@@ -1,10 +1,14 @@
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.energy import Recommendation
|
||||
from app.models.energy import Alert, Recommendation
|
||||
from app.repositories.recommendation import NouvelleRecommandation
|
||||
from app.services.recommendation import RecommendationNotFoundError, RecommendationService
|
||||
|
||||
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def recommendation(recommendation_id: int = 1) -> Recommendation:
|
||||
return Recommendation(
|
||||
@@ -13,13 +17,33 @@ def recommendation(recommendation_id: int = 1) -> Recommendation:
|
||||
action="Vérifier la consommation",
|
||||
explanation="Pic détecté",
|
||||
rule_reference="spike-v1",
|
||||
created_at=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
created_at=MOMENT,
|
||||
)
|
||||
|
||||
|
||||
def alerte(alert_id: int = 1, site_id: str = "SITE001", severity: str = "high") -> Alert:
|
||||
return Alert(
|
||||
alert_id=alert_id,
|
||||
source_alert_id=f"ALR-{alert_id}",
|
||||
site_id=site_id,
|
||||
source="api_mock",
|
||||
timestamp=MOMENT,
|
||||
type="spike",
|
||||
severity=severity,
|
||||
message="Pic de consommation",
|
||||
value=None,
|
||||
threshold=None,
|
||||
metric=None,
|
||||
prediction_id=None,
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(self, recommendations: list[Recommendation]) -> None:
|
||||
def __init__(self, recommendations: list[Recommendation], creees: int | None = None) -> None:
|
||||
self._recommendations = recommendations
|
||||
self._creees = creees
|
||||
self.recues: list[NouvelleRecommandation] = []
|
||||
|
||||
async def list_all(self) -> list[Recommendation]:
|
||||
return self._recommendations
|
||||
@@ -29,27 +53,111 @@ class FakeRepository:
|
||||
(r for r in self._recommendations if r.recommendation_id == recommendation_id), None
|
||||
)
|
||||
|
||||
async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int:
|
||||
self.recues = list(nouvelles)
|
||||
return len(self.recues) if self._creees is None else self._creees
|
||||
|
||||
async def test_list_all_returns_the_repository_recommendations() -> None:
|
||||
service = RecommendationService(
|
||||
recommendations=FakeRepository([recommendation(1), recommendation(2)])
|
||||
|
||||
class FakeAlertRepository:
|
||||
def __init__(self, alertes: list[Alert]) -> None:
|
||||
self._alertes = alertes
|
||||
self.site_demande: str | None = None
|
||||
|
||||
async def list_all(
|
||||
self, *, site_id: str | None = None, severity: str | None = None
|
||||
) -> list[Alert]:
|
||||
self.site_demande = site_id
|
||||
if site_id is None:
|
||||
return self._alertes
|
||||
return [a for a in self._alertes if a.site_id == site_id]
|
||||
|
||||
|
||||
class FakeTransaction:
|
||||
def __init__(self) -> None:
|
||||
self.commits = 0
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
|
||||
def service(
|
||||
recommendations: FakeRepository | None = None,
|
||||
alerts: FakeAlertRepository | None = None,
|
||||
transaction: FakeTransaction | None = None,
|
||||
) -> RecommendationService:
|
||||
return RecommendationService(
|
||||
recommendations=recommendations or FakeRepository([]),
|
||||
alerts=alerts or FakeAlertRepository([]),
|
||||
transaction=transaction or FakeTransaction(),
|
||||
)
|
||||
|
||||
recommendations = await service.list_all()
|
||||
|
||||
async def test_list_all_returns_the_repository_recommendations() -> None:
|
||||
depot = FakeRepository([recommendation(1), recommendation(2)])
|
||||
|
||||
recommendations = await service(recommendations=depot).list_all()
|
||||
|
||||
assert [r.recommendation_id for r in recommendations] == [1, 2]
|
||||
|
||||
|
||||
async def test_get_by_id_returns_the_matching_recommendation() -> None:
|
||||
service = RecommendationService(recommendations=FakeRepository([recommendation(1)]))
|
||||
|
||||
trouve = await service.get_by_id(1)
|
||||
trouve = await service(recommendations=FakeRepository([recommendation(1)])).get_by_id(1)
|
||||
|
||||
assert trouve.recommendation_id == 1
|
||||
|
||||
|
||||
async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None:
|
||||
service = RecommendationService(recommendations=FakeRepository([]))
|
||||
|
||||
with pytest.raises(RecommendationNotFoundError):
|
||||
await service.get_by_id(404)
|
||||
await service().get_by_id(404)
|
||||
|
||||
|
||||
async def test_generate_persists_one_proposal_per_triggered_rule() -> None:
|
||||
depot = FakeRepository([])
|
||||
|
||||
rapport = await service(
|
||||
recommendations=depot, alerts=FakeAlertRepository([alerte(severity="critical")])
|
||||
).generate()
|
||||
|
||||
assert {n.rule_reference for n in depot.recues} == {
|
||||
"spike-delestage-v1",
|
||||
"escalade-astreinte-v1",
|
||||
}
|
||||
assert rapport.recommandations_creees == 2
|
||||
|
||||
|
||||
async def test_generate_commits_once() -> None:
|
||||
transaction = FakeTransaction()
|
||||
|
||||
await service(alerts=FakeAlertRepository([alerte()]), transaction=transaction).generate()
|
||||
|
||||
assert transaction.commits == 1
|
||||
|
||||
|
||||
async def test_generate_restricts_the_alerts_to_the_requested_site() -> None:
|
||||
alertes = FakeAlertRepository([alerte(1, site_id="SITE001"), alerte(2, site_id="SITE002")])
|
||||
depot = FakeRepository([])
|
||||
|
||||
rapport = await service(recommendations=depot, alerts=alertes).generate(site_id="SITE002")
|
||||
|
||||
assert alertes.site_demande == "SITE002"
|
||||
assert rapport.alertes_examinees == 1
|
||||
assert {n.alert_id for n in depot.recues} == {2}
|
||||
|
||||
|
||||
async def test_generate_reports_nothing_when_no_alert_matches() -> None:
|
||||
rapport = await service().generate()
|
||||
|
||||
assert rapport.alertes_examinees == 0
|
||||
assert rapport.recommandations_creees == 0
|
||||
assert rapport.deja_presentes == 0
|
||||
|
||||
|
||||
async def test_generate_counts_the_proposals_the_database_already_held() -> None:
|
||||
depot = FakeRepository([], creees=0)
|
||||
|
||||
rapport = await service(
|
||||
recommendations=depot, alerts=FakeAlertRepository([alerte()])
|
||||
).generate()
|
||||
|
||||
assert rapport.recommandations_creees == 0
|
||||
assert rapport.deja_presentes == 1
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.energy import Alert
|
||||
from app.services.recommendation_rules import FACTEUR_DEPASSEMENT_MAJEUR, applique_les_regles
|
||||
|
||||
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def alerte(
|
||||
*,
|
||||
alert_id: int = 1,
|
||||
type_alerte: str = "spike",
|
||||
severity: str = "high",
|
||||
value: float | None = None,
|
||||
threshold: float | None = None,
|
||||
metric: str | None = None,
|
||||
site_id: str = "SITE001",
|
||||
) -> Alert:
|
||||
return Alert(
|
||||
alert_id=alert_id,
|
||||
source_alert_id=f"ALR-{alert_id}",
|
||||
site_id=site_id,
|
||||
source="api_mock",
|
||||
timestamp=MOMENT,
|
||||
type=type_alerte,
|
||||
severity=severity,
|
||||
message="Alerte de test",
|
||||
value=value,
|
||||
threshold=threshold,
|
||||
metric=metric,
|
||||
prediction_id=None,
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("type_alerte", "attendue"),
|
||||
[
|
||||
("spike", "spike-delestage-v1"),
|
||||
("threshold", "threshold-reduction-v1"),
|
||||
("outage", "outage-secours-v1"),
|
||||
("sensor", "sensor-maintenance-v1"),
|
||||
("anomaly", "anomaly-verification-v1"),
|
||||
],
|
||||
ids=["pic", "seuil", "coupure", "capteur", "anomalie"],
|
||||
)
|
||||
def test_each_alert_type_yields_its_own_rule(type_alerte: str, attendue: str) -> None:
|
||||
proposees = applique_les_regles(alerte(type_alerte=type_alerte))
|
||||
|
||||
assert [p.rule_reference for p in proposees] == [attendue]
|
||||
|
||||
|
||||
def test_a_critical_alert_adds_the_escalation_rule() -> None:
|
||||
proposees = applique_les_regles(alerte(severity="critical"))
|
||||
|
||||
assert "escalade-astreinte-v1" in {p.rule_reference for p in proposees}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("severity", ["low", "medium", "high"], ids=["faible", "moyenne", "haute"])
|
||||
def test_a_non_critical_alert_does_not_escalate(severity: str) -> None:
|
||||
proposees = applique_les_regles(alerte(severity=severity))
|
||||
|
||||
assert "escalade-astreinte-v1" not in {p.rule_reference for p in proposees}
|
||||
|
||||
|
||||
def test_a_large_overshoot_adds_the_contract_rule() -> None:
|
||||
proposees = applique_les_regles(
|
||||
alerte(value=720.0 * FACTEUR_DEPASSEMENT_MAJEUR, threshold=720.0)
|
||||
)
|
||||
|
||||
assert "contrat-puissance-v1" in {p.rule_reference for p in proposees}
|
||||
|
||||
|
||||
def test_an_overshoot_below_the_factor_does_not_add_the_contract_rule() -> None:
|
||||
proposees = applique_les_regles(alerte(value=800.0, threshold=720.0))
|
||||
|
||||
assert "contrat-puissance-v1" not in {p.rule_reference for p in proposees}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "threshold"),
|
||||
[(None, 720.0), (900.0, None), (900.0, 0.0), (900.0, -10.0)],
|
||||
ids=["sans mesure", "sans seuil", "seuil nul", "seuil negatif"],
|
||||
)
|
||||
def test_the_contract_rule_stays_silent_without_an_exploitable_threshold(
|
||||
value: float | None, threshold: float | None
|
||||
) -> None:
|
||||
proposees = applique_les_regles(alerte(value=value, threshold=threshold))
|
||||
|
||||
assert "contrat-puissance-v1" not in {p.rule_reference for p in proposees}
|
||||
|
||||
|
||||
def test_the_explanation_quotes_the_measure_and_the_threshold() -> None:
|
||||
proposees = applique_les_regles(alerte(value=812.5, threshold=720.0, metric="consumption_kw"))
|
||||
|
||||
assert "(consumption_kw mesurée à 812.5, seuil 720.0)" in proposees[0].explanation
|
||||
|
||||
|
||||
def test_the_explanation_quotes_the_measure_alone_when_no_threshold_is_known() -> None:
|
||||
proposees = applique_les_regles(alerte(value=812.5, metric="consumption_kw"))
|
||||
|
||||
assert "(consumption_kw mesurée à 812.5)" in proposees[0].explanation
|
||||
|
||||
|
||||
def test_the_explanation_omits_the_measure_when_the_alert_carries_none() -> None:
|
||||
proposees = applique_les_regles(alerte())
|
||||
|
||||
assert "(" not in proposees[0].explanation
|
||||
|
||||
|
||||
def test_the_explanation_names_the_site() -> None:
|
||||
proposees = applique_les_regles(alerte(site_id="SITE042"))
|
||||
|
||||
assert "SITE042" in proposees[0].explanation
|
||||
|
||||
|
||||
def test_every_proposal_carries_the_alert_identifier() -> None:
|
||||
proposees = applique_les_regles(alerte(alert_id=77, severity="critical"))
|
||||
|
||||
assert {p.alert_id for p in proposees} == {77}
|
||||
|
||||
|
||||
def test_an_alert_never_yields_the_same_rule_twice() -> None:
|
||||
proposees = applique_les_regles(
|
||||
alerte(severity="critical", value=900.0, threshold=720.0, metric="consumption_kw")
|
||||
)
|
||||
|
||||
assert len(proposees) == len({p.rule_reference for p in proposees})
|
||||
|
||||
|
||||
def test_a_critical_alert_over_the_threshold_yields_the_three_rules() -> None:
|
||||
proposees = applique_les_regles(
|
||||
alerte(severity="critical", value=900.0, threshold=720.0, metric="consumption_kw")
|
||||
)
|
||||
|
||||
assert {p.rule_reference for p in proposees} == {
|
||||
"spike-delestage-v1",
|
||||
"escalade-astreinte-v1",
|
||||
"contrat-puissance-v1",
|
||||
}
|
||||
@@ -118,3 +118,30 @@ def test_main_exports_the_contract_without_asking_for_a_password(
|
||||
assert code == 0
|
||||
assert destination.exists()
|
||||
assert str(destination) in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_build_parser_reads_the_generate_recommendations_arguments() -> None:
|
||||
arguments = cli.build_parser().parse_args(["generate-recommendations", "--site-id", "SITE002"])
|
||||
|
||||
assert arguments.commande == "generate-recommendations"
|
||||
assert arguments.site_id == "SITE002"
|
||||
|
||||
|
||||
def test_build_parser_defaults_the_generation_to_every_site() -> None:
|
||||
arguments = cli.build_parser().parse_args(["generate-recommendations"])
|
||||
|
||||
assert arguments.site_id is None
|
||||
|
||||
|
||||
def test_main_generates_the_recommendations_without_asking_for_a_password(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
async def fausse_generation(*, site_id: str | None) -> str:
|
||||
return f"génération lancée pour {site_id}"
|
||||
|
||||
monkeypatch.setattr(cli, "generate_recommendations", fausse_generation)
|
||||
|
||||
code = cli.main(["generate-recommendations", "--site-id", "SITE002"])
|
||||
|
||||
assert code == 0
|
||||
assert "SITE002" in capsys.readouterr().out
|
||||
|
||||
@@ -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()
|
||||
Generated
+2
-2
@@ -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" },
|
||||
|
||||
@@ -64,4 +64,13 @@ describe('mockApiInterceptor', () => {
|
||||
httpMock.expectNone(`${environment.apiUrl}/alerts`);
|
||||
expect((result as unknown[]).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('laisse toujours passer /predictions vers le réseau, même avec useMockFixtures activé', () => {
|
||||
environment.useMockFixtures = true;
|
||||
|
||||
http.get(`${environment.apiUrl}/predictions`).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/predictions`);
|
||||
req.flush({ timestamp: '2026-09-18T09:00:00Z', sites: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,5 +26,7 @@ export const mockApiInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (req.url.endsWith(`${environment.apiUrl}/alerts`)) {
|
||||
return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE }));
|
||||
}
|
||||
// Volontairement jamais mocké, contrairement à `stats`/`alerts` : les prévisions sont servies
|
||||
// par l'API réelle dès maintenant (au même titre que `/auth/*`, déjà toujours réel).
|
||||
return next(req);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { PredictionsService } from './predictions.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
describe('PredictionsService', () => {
|
||||
let service: PredictionsService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
service = TestBed.inject(PredictionsService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('appelle le bon endpoint et retourne un résumé de prévisions', () => {
|
||||
let result: unknown;
|
||||
service.getPredictions().subscribe((r) => (result = r));
|
||||
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/predictions`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
|
||||
req.flush({
|
||||
timestamp: '2026-09-18T09:00:00Z',
|
||||
sites: [{ site_id: 'SITE001', site_name: 'Test', prediction: null }],
|
||||
});
|
||||
|
||||
expect((result as { sites: unknown[] }).sites.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Service, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { PredictionSummary } from '../../shared/models/prediction.model';
|
||||
|
||||
@Service()
|
||||
export class PredictionsService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getPredictions() {
|
||||
return this.http.get<PredictionSummary>(`${environment.apiUrl}/predictions`);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,13 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error(); as message) {
|
||||
@if (statsError(); as message) {
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
@if (alertsError(); as message) {
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
@if (predictionsError(); as message) {
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
|
||||
@@ -72,4 +78,33 @@
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (predictions().length > 0) {
|
||||
<section class="predictions-section">
|
||||
<h2>Prévisions de consommation</h2>
|
||||
<ul class="predictions-list">
|
||||
@for (site of predictions(); track site.site_id) {
|
||||
<li class="prediction-item">
|
||||
<span class="prediction-item__site">{{ site.site_name }}</span>
|
||||
@if (site.prediction; as prediction) {
|
||||
@if (prediction.status === 'available') {
|
||||
<span class="prediction-item__value">
|
||||
{{ prediction.predicted_value | number: '1.0-1' }} kWh
|
||||
<span class="prediction-item__target"
|
||||
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
|
||||
>
|
||||
</span>
|
||||
} @else {
|
||||
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
|
||||
prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur'
|
||||
}}</ev-badge>
|
||||
}
|
||||
} @else {
|
||||
<ev-badge tone="neutral">Pas encore de prévision</ev-badge>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -121,3 +121,40 @@ h2 {
|
||||
.alert-item__message {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.predictions-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.prediction-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.prediction-item__site {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prediction-item__value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prediction-item__target {
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { of, throwError } from 'rxjs';
|
||||
import { Dashboard } from './dashboard';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
import { PredictionsService } from '../../core/services/predictions.service';
|
||||
import {AuthService} from '../../core/services/auth.service';
|
||||
import {Router, provideRouter} from '@angular/router';
|
||||
|
||||
@@ -17,18 +18,24 @@ vi.mock('chart.js', () => {
|
||||
return { Chart: ChartMock, registerables: [] };
|
||||
});
|
||||
|
||||
function predictionsMock(sites: unknown[] = []) {
|
||||
return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) };
|
||||
}
|
||||
|
||||
describe('Dashboard', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('charge les stats et les alertes au démarrage', async () => {
|
||||
it('charge les stats, les alertes et les prévisions au démarrage', async () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
|
||||
const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictions },
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
@@ -42,8 +49,12 @@ describe('Dashboard', () => {
|
||||
|
||||
expect(statsMock.getSummary).toHaveBeenCalled();
|
||||
expect(alertsMock.getAlerts).toHaveBeenCalled();
|
||||
expect(predictions.getPredictions).toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.alerts().length).toBe(1);
|
||||
expect(fixture.componentInstance.error()).toBeNull();
|
||||
expect(fixture.componentInstance.predictions().length).toBe(1);
|
||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||
expect(fixture.componentInstance.alertsError()).toBeNull();
|
||||
expect(fixture.componentInstance.predictionsError()).toBeNull();
|
||||
});
|
||||
|
||||
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
|
||||
@@ -61,6 +72,7 @@ describe('Dashboard', () => {
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
@@ -70,13 +82,13 @@ describe('Dashboard', () => {
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(statsMock.getSummary).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||
expect(fixture.componentInstance.statsError()).not.toBeNull();
|
||||
expect(fixture.componentInstance.stats()).toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(statsMock.getSummary).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.componentInstance.stats()).not.toBeNull();
|
||||
expect(fixture.componentInstance.error()).toBeNull();
|
||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||
});
|
||||
|
||||
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
|
||||
@@ -88,6 +100,7 @@ describe('Dashboard', () => {
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
@@ -96,6 +109,62 @@ describe('Dashboard', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.alerts().length).toBe(0);
|
||||
expect(fixture.componentInstance.alertsError()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
const predictions = {
|
||||
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictions },
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.predictions().length).toBe(0);
|
||||
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
|
||||
vi.useFakeTimers();
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
const predictions = {
|
||||
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictions },
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
||||
|
||||
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
|
||||
// toujours être visible : rien ne vient la rafraîchir tant que la section n'est pas rechargée.
|
||||
vi.advanceTimersByTime(30000);
|
||||
|
||||
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||
});
|
||||
|
||||
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
|
||||
@@ -108,6 +177,7 @@ describe('Dashboard', () => {
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
{ provide: AuthService, useValue: authMock },
|
||||
provideRouter([]),
|
||||
],
|
||||
@@ -137,6 +207,7 @@ describe('Dashboard', () => {
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
{ provide: AuthService, useValue: authMock },
|
||||
provideRouter([]),
|
||||
],
|
||||
@@ -164,6 +235,7 @@ describe('Dashboard', () => {
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
@@ -179,4 +251,26 @@ describe('Dashboard', () => {
|
||||
dashboard.badgeToneForSeverity('critical'),
|
||||
);
|
||||
});
|
||||
|
||||
it('distingue le ton des statuts de prévision', () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
const dashboard = fixture.componentInstance;
|
||||
|
||||
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
|
||||
expect(dashboard.badgeToneForPredictionStatus('insufficient_data')).toBe('warning');
|
||||
expect(dashboard.badgeToneForPredictionStatus('error')).toBe('danger');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, inject, signal, DestroyRef, WritableSignal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { DecimalPipe, DatePipe } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
||||
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
import { PredictionsService } from '../../core/services/predictions.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { StatsSummary } from '../../shared/models/stats.model';
|
||||
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
|
||||
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
|
||||
import { Card } from '../../shared/components/ui/card/card';
|
||||
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
||||
import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge';
|
||||
@@ -27,11 +29,21 @@ const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
||||
critical: 'critical',
|
||||
};
|
||||
|
||||
// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le
|
||||
// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que
|
||||
// tomber sur `undefined` si ce statut apparaît un jour.
|
||||
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
||||
available: 'success',
|
||||
insufficient_data: 'warning',
|
||||
error: 'danger',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
DecimalPipe,
|
||||
DatePipe,
|
||||
RouterLink,
|
||||
ConsumptionGauge,
|
||||
SiteLoadChart,
|
||||
@@ -47,31 +59,52 @@ const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
||||
export class Dashboard implements OnInit {
|
||||
private statsService = inject(StatsService);
|
||||
private alertsService = inject(AlertsService);
|
||||
private predictionsService = inject(PredictionsService);
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
stats = signal<StatsSummary | null>(null);
|
||||
alerts = signal<Alert[]>([]);
|
||||
error = signal<string | null>(null);
|
||||
predictions = signal<SitePredictionSummary[]>([]);
|
||||
|
||||
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats)
|
||||
// efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus,
|
||||
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
|
||||
statsError = signal<string | null>(null);
|
||||
alertsError = signal<string | null>(null);
|
||||
predictionsError = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.alertsService
|
||||
.getAlerts()
|
||||
.pipe(catchError(() => this.reportUnavailable()))
|
||||
.subscribe((alerts) => this.alerts.set(alerts));
|
||||
.pipe(catchError(() => this.reportUnavailable(this.alertsError)))
|
||||
.subscribe((alerts) => {
|
||||
this.alertsError.set(null);
|
||||
this.alerts.set(alerts);
|
||||
});
|
||||
|
||||
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
|
||||
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
|
||||
this.predictionsService
|
||||
.getPredictions()
|
||||
.pipe(catchError(() => this.reportUnavailable(this.predictionsError)))
|
||||
.subscribe((summary) => {
|
||||
this.predictionsError.set(null);
|
||||
this.predictions.set(summary.sites);
|
||||
});
|
||||
|
||||
// Piège : le catchError porte sur l'observable interne. Sur le flux externe il
|
||||
// terminerait le timer, et le rafraîchissement ne repartirait jamais.
|
||||
timer(0, REFRESH_INTERVAL_MS)
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())),
|
||||
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((stats) => {
|
||||
this.error.set(null);
|
||||
this.statsError.set(null);
|
||||
this.stats.set(stats);
|
||||
});
|
||||
}
|
||||
@@ -80,6 +113,10 @@ export class Dashboard implements OnInit {
|
||||
return TON_PAR_SEVERITE[severity];
|
||||
}
|
||||
|
||||
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
|
||||
return TON_PAR_STATUT_PREDICTION[status];
|
||||
}
|
||||
|
||||
onLogout(): void {
|
||||
this.auth.logout().subscribe({
|
||||
next: () => this.router.navigate(['/login']),
|
||||
@@ -91,8 +128,8 @@ export class Dashboard implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private reportUnavailable(): Observable<never> {
|
||||
this.error.set(UNAVAILABLE_MESSAGE);
|
||||
private reportUnavailable(target: WritableSignal<string | null>): Observable<never> {
|
||||
target.set(UNAVAILABLE_MESSAGE);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export type PredictionStatus = 'available' | 'insufficient_data' | 'error';
|
||||
export type PredictionTargetMetric = 'consumption_kwh' | 'consumption_kw';
|
||||
|
||||
export interface SitePrediction {
|
||||
target_at: string;
|
||||
target_metric: PredictionTargetMetric;
|
||||
period_minutes: number | null;
|
||||
predicted_value: number | null;
|
||||
status: PredictionStatus;
|
||||
failure_reason: string | null;
|
||||
model_reference: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SitePredictionSummary {
|
||||
site_id: string;
|
||||
site_name: string;
|
||||
prediction: SitePrediction | null;
|
||||
}
|
||||
|
||||
export interface PredictionSummary {
|
||||
timestamp: string;
|
||||
sites: SitePredictionSummary[];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Base de metadonnees Airflow (webserver + scheduler, LocalExecutor). Separee de la base
|
||||
-- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire
|
||||
-- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un
|
||||
-- service dedie, pour ne pas ajouter un conteneur de plus (issue #115).
|
||||
CREATE DATABASE airflow;
|
||||
@@ -5,6 +5,32 @@
|
||||
|
||||
name: enervision
|
||||
|
||||
# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du
|
||||
# webserver. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte
|
||||
# que sur `airflow-scheduler` en pratique, mais reste partage avec le webserver pour que ce
|
||||
# dernier puisse au besoin l'inspecter sans en devenir dependant.
|
||||
x-airflow-common: &airflow-common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: etl/airflow/Dockerfile
|
||||
environment: &airflow-common-env
|
||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:?}
|
||||
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:?}
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow
|
||||
# Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003/CLAUDE.md) :
|
||||
# memes identifiants que le backend en attendant.
|
||||
ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db
|
||||
volumes:
|
||||
- ./etl/airflow/dags:/opt/airflow/dags
|
||||
- ./etl/airflow/plugins:/opt/airflow/plugins
|
||||
- ./etl/airflow/include:/opt/airflow/include
|
||||
- airflow_logs:/opt/airflow/logs
|
||||
- airflow_ml_state:/opt/ml/state
|
||||
restart: unless-stopped
|
||||
|
||||
services:
|
||||
db:
|
||||
image: timescale/timescaledb-ha:pg17
|
||||
@@ -19,6 +45,7 @@ services:
|
||||
- pgdata:/home/postgres/pgdata/data
|
||||
- ./db/init/100-extensions.sql:/docker-entrypoint-initdb.d/100-extensions.sql:ro
|
||||
- ./db/init/110-test-database.sql:/docker-entrypoint-initdb.d/110-test-database.sql:ro
|
||||
- ./db/init/120-airflow-database.sql:/docker-entrypoint-initdb.d/120-airflow-database.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
@@ -50,6 +77,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"
|
||||
@@ -65,6 +98,53 @@ services:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
restart: unless-stopped
|
||||
|
||||
# Conteneur unique, jamais redemarre : migre la base de metadonnees puis cree le premier compte
|
||||
# (idempotent, `|| true` sur la creation qui echoue si le compte existe deja). `webserver` et
|
||||
# `scheduler` attendent qu'il se termine avec succes avant de demarrer.
|
||||
airflow-init:
|
||||
<<: *airflow-common
|
||||
restart: "no"
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
airflow db migrate
|
||||
airflow users create \
|
||||
--username "${AIRFLOW_ADMIN_USERNAME:-admin}" \
|
||||
--password "${AIRFLOW_ADMIN_PASSWORD:?}" \
|
||||
--firstname Admin \
|
||||
--lastname EnerVision \
|
||||
--role Admin \
|
||||
--email "${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr}" \
|
||||
|| true
|
||||
|
||||
airflow-webserver:
|
||||
<<: *airflow-common
|
||||
command: webserver
|
||||
ports:
|
||||
- "${AIRFLOW_PORT:-8080}:8080"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
airflow-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
airflow-scheduler:
|
||||
<<: *airflow-common
|
||||
command: scheduler
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
airflow-init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
airflow_logs:
|
||||
airflow_ml_state:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# 0006 - Le moteur de règles de recommandation vit dans le backend
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-18
|
||||
|
||||
## Contexte
|
||||
|
||||
L'issue #38 demande un « moteur de règles pour recommandations », portée par le label `ml`. Le
|
||||
schéma tranche déjà la forme du résultat : `recommendation(alert_id, action, explanation,
|
||||
rule_reference)`, avec `alert_id` en clé étrangère `NOT NULL` et une contrainte d'unicité
|
||||
`uq_recommendation_alert_rule` sur `(alert_id, rule_reference)`. Une recommandation est donc
|
||||
**dérivée d'une alerte**, jamais d'une mesure brute ni d'une prévision.
|
||||
|
||||
Deux emplacements se disputaient le code :
|
||||
|
||||
1. `ml/enervision_ml/`, sur le patron de `enervision_ml.score` livré par #37 : un script autonome
|
||||
qui se connecte par `ML_DATABASE_URL`, écrit une table, et que l'API se contente de lire.
|
||||
L'[ADR 0005](0005-modele-prediction-lightgbm.md) annonce d'ailleurs #38 de ce côté, en écrivant
|
||||
que le scoring, le moteur de recommandations et les tests de dérive « consommeront le même
|
||||
module `enervision_ml.features` ».
|
||||
2. `apps/backend/app/services/`, où `apps/backend/README.md` place les « regles metier ».
|
||||
|
||||
## Décision
|
||||
|
||||
**Le moteur vit dans `apps/backend/app/services/`**, sous la forme d'un module pur
|
||||
`recommendation_rules.py` (le catalogue `REGLES`) et d'une méthode `RecommendationService.generate()`
|
||||
qui l'applique, persiste et valide la transaction.
|
||||
|
||||
Trois raisons :
|
||||
|
||||
- **Il n'utilise rien du ML.** Le catalogue lit `alert.type`, `alert.severity`, `alert.value` et
|
||||
`alert.threshold`. Aucun modèle, aucune feature, aucun `enervision_ml.features` : la phrase de
|
||||
l'ADR 0005 vaut pour le scoring (#37) et les tests de dérive (#44/#45), qui manipulent bien des
|
||||
features, pas pour des règles sur alertes. Le label `ml` de #38 désigne le lot fonctionnel
|
||||
« prédiction et recommandation », pas l'emplacement du code.
|
||||
- **Il lit et écrit deux tables déjà couvertes par des repositories.** `AlertRepository` sait déjà
|
||||
filtrer par site. Le placer dans `ml/` obligerait à réécrire ces accès en SQL brut, et à
|
||||
maintenir deux représentations du même domaine.
|
||||
- **Le déclencheur HTTP n'a de sens que dans l'API.** `POST /recommendations/generate` doit passer
|
||||
par `require_role(Role.ADMIN)` et par la session injectée : cela suppose d'être dans
|
||||
l'application FastAPI.
|
||||
|
||||
Le moteur reste néanmoins **déclenchable hors HTTP**, par `python -m app.cli
|
||||
generate-recommendations` (cible `make recommendations`), sur le patron de `make ml-score` : rien
|
||||
n'oblige à exposer un port pour régénérer des recommandations.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- L'API gagne sa première route d'écriture métier. La checklist de `20-backend.md` s'applique :
|
||||
entrée dans `ROLE_MINIMUM` de `tests/api/acces.py`, et `openapi.json` régénéré dans le même
|
||||
commit.
|
||||
- `RecommendationService` n'est plus en lecture seule : il reçoit le `Transaction` Protocol déjà
|
||||
utilisé par `AuthService` et `UserService`, et commite lui-même. Les repositories continuent de
|
||||
ne pas commiter.
|
||||
- **L'idempotence est déléguée à la base.** `create_missing()` insère en `ON CONFLICT DO NOTHING`
|
||||
sur `uq_recommendation_alert_rule` plutôt que de relire avant d'écrire, ce qui supprime la
|
||||
fenêtre entre le contrôle et l'insertion. Corollaire : `rule_reference` est une clé fonctionnelle.
|
||||
Une règle dont le sens change prend une référence `-v2` ; renommer une référence livrée
|
||||
ferait réapparaître ses recommandations à côté des anciennes.
|
||||
- **Le moteur est branché sur la détection interne, et sur elle seule.** `alert` est alimentée
|
||||
par `app/detection/internal_alerts.py` (#104), lancée à la main comme `enervision_ml.score` ;
|
||||
l'ingestion de l'API Mock `/alerts` reste à faire. Le rapport de génération est donc à zéro tant
|
||||
que la détection n'a pas tourné, sans que le moteur soit à retoucher.
|
||||
- **L'insertion est découpée en lots.** `create_missing()` écrit par paquets de `TAILLE_DE_LOT`
|
||||
lignes : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre colonnes,
|
||||
et la détection interne peut alimenter `alert` au fil de l'eau.
|
||||
- Si le projet devait un jour pondérer les recommandations par un score appris, la décision serait
|
||||
à rouvrir : le moteur redeviendrait consommateur du pipeline ML.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Module et CLI dans `ml/enervision_ml/`** : cohérent avec le label `ml` et avec la lettre de
|
||||
l'ADR 0005, mais impose du SQL brut là où deux repositories existent, et laisse la génération
|
||||
hors de portée de l'API. Redeviendrait le bon choix si les règles se mettaient à consommer des
|
||||
features ou un modèle.
|
||||
- **Génération à la volée, sans persistance**, calculée à chaque `GET /recommendations` : supprime
|
||||
le besoin d'écriture, mais rend la table `recommendation` et sa contrainte d'unicité inutiles,
|
||||
et interdit toute trace de ce qui a été proposé et quand.
|
||||
- **Table de configuration des règles en base**, plutôt qu'un catalogue en Python : plus souple,
|
||||
mais déplace la logique métier hors de la revue de code et hors des tests, pour un besoin que
|
||||
rien n'exprime à ce stade.
|
||||
@@ -57,7 +57,7 @@ flowchart TB
|
||||
navigateur --> front
|
||||
front -.-> api
|
||||
api --> db
|
||||
airflow -.-> db
|
||||
airflow --> db
|
||||
prom -.-> api
|
||||
grafana -.-> db
|
||||
grafana -.-> prom
|
||||
@@ -67,6 +67,10 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API,
|
||||
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
||||
[30-frontend.md](30-frontend.md).
|
||||
|
||||
Le lien `airflow --> db` est maintenant en trait plein : deux DAGs orchestrent l'entraînement et
|
||||
le scoring du modèle ML (issue #115), cf. plus bas et [20-backend.md](20-backend.md). Le reste du
|
||||
périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit.
|
||||
|
||||
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
||||
collecteur ne vient le lire.
|
||||
|
||||
@@ -74,18 +78,20 @@ collecteur ne vient le lire.
|
||||
|
||||
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|
||||
|---|---|---|---|---|
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary` et `readings` en lecture (endpoints → services → repositories → models) |
|
||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures |
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
|
||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
||||
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement (features par lags/moyennes glissantes, baseline de persistance saisonnière, suivi MLflow local), voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Scoring, endpoint et surveillance de dérive pas encore construits |
|
||||
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
|
||||
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Deux DAGs (`ml_train` manuel, `ml_score` `@hourly`) orchestrent le pipeline ML existant en sous-processus `uv run` (issue #115). L'ingestion (issues #15/#16) n'a pas encore de DAG |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
|
||||
|
||||
## Flux bout en bout
|
||||
|
||||
Statut : `Cible`. Aucun maillon de cette chaîne n'existe aujourd'hui, à l'exception de la base.
|
||||
Statut : `Cible`. Ce flux d'ingestion (Source → Airflow → hypertable) n'existe pas encore : les
|
||||
deux DAGs livrés à ce jour (`ml_train`/`ml_score`, issue #115) orchestrent le pipeline ML, pas
|
||||
l'ingestion. Seule la base tourne réellement parmi les maillons ci-dessous.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -165,3 +171,8 @@ Elles vivent dans `../adr/`, pas ici.
|
||||
| ADR | Objet |
|
||||
|---|---|
|
||||
| [0001](../adr/0001-postgresql-timescaledb.md) | PostgreSQL 17 avec l'extension TimescaleDB, et la frontière `db/` vs `alembic/` |
|
||||
| [0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
|
||||
| [0003](../adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, avec relecture du compte à chaque requête |
|
||||
| [0004](../adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
||||
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
|
||||
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
||||
|
||||
@@ -48,6 +48,33 @@ Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devine
|
||||
l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans
|
||||
le compose. Voir [`db/README.md`](../../db/README.md).
|
||||
|
||||
### Airflow (`ml_train`/`ml_score`, issue #115)
|
||||
|
||||
Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make
|
||||
airflow-up`, pas dans `make dev`) :
|
||||
|
||||
| Service | Rôle | Points notables |
|
||||
|---|---|---|
|
||||
| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès |
|
||||
| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même |
|
||||
| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --frozen --no-dev python -m enervision_ml...`), c'est lui qui a besoin du volume `airflow_ml_state` |
|
||||
|
||||
Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) :
|
||||
l'image doit pouvoir `COPY` `ml/pyproject.toml`/`ml/uv.lock`/`ml/enervision_ml` pour se
|
||||
synchroniser un second environnement Python **3.14** (`/opt/ml/.venv`, `uv sync --locked` à la
|
||||
construction), distinct du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent vers
|
||||
ce venv plutôt que d'importer LightGBM/MLflow dans le process Airflow.
|
||||
|
||||
Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier
|
||||
`make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute
|
||||
`docker-entrypoint-initdb.d/` que sur un volume vide). Créer la base `airflow` à la main une fois :
|
||||
`docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE DATABASE airflow;"`.
|
||||
|
||||
`libgomp1` est installé explicitement dans l'image (`apt-get`, en root) : l'image Airflow de base
|
||||
est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans quoi l'erreur
|
||||
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
|
||||
construction de l'image.
|
||||
|
||||
## Cible de déploiement
|
||||
|
||||
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
||||
@@ -116,6 +143,8 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
||||
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
||||
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
|
||||
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
|
||||
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
|
||||
| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
|
||||
|
||||
## Le trou entre les deux topologies
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, sensors"]
|
||||
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, readings, sensors, predictions"]
|
||||
sc["schemas<br/>Pydantic"]
|
||||
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, SensorService"]
|
||||
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading"]
|
||||
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, ReadingService, SensorService, PredictionService"]
|
||||
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading, prediction"]
|
||||
md["models<br/>10 tables"]
|
||||
db[("PostgreSQL")]
|
||||
|
||||
@@ -146,9 +146,11 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
|
||||
| GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 |
|
||||
| GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 |
|
||||
| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 |
|
||||
| POST | `/api/v1/recommendations/generate` | Applique le moteur de règles aux alertes, filtrable par `site_id`. `admin` | 401, 403, 422, 500 |
|
||||
| GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 |
|
||||
| GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 |
|
||||
| GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 |
|
||||
| GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 |
|
||||
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
|
||||
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
|
||||
|
||||
@@ -163,7 +165,7 @@ le jeton à usage unique plutôt que dans un `Principal`.
|
||||
donc de modifier `ROUTES_PUBLIQUES` dans `tests/api/acces.py`.
|
||||
|
||||
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour
|
||||
`GET /alerts` puis pour les suivantes (`dataset`, `prediction`) : les quatre couches
|
||||
`GET /alerts` puis pour les suivantes (`dataset`) : les quatre couches
|
||||
`endpoints → services → repositories → models` y sont toutes présentes, sur des tables déjà créées
|
||||
par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, contrairement aux
|
||||
routes d'administration qui exigent `admin`. `SiteRepository` lit par `AsyncSession.scalar()` (une
|
||||
@@ -181,6 +183,33 @@ dernière `Reading` du site : un site connu sans lecture rend `200` avec tous le
|
||||
détaillé pour le frontend est dans
|
||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||
|
||||
`GET /predictions` reprend ce même sous-gabarit « dernière valeur par site » (`SiteRepository` +
|
||||
`PredictionRepository`, un `SitePredictionSummaryResponse` par site plutôt qu'une table brute).
|
||||
Différence avec `stats`/`sensors` : `prediction` est une vraie table accumulée par un processus
|
||||
externe (`enervision_ml.score`, cf. `ml/README.md`), pas une valeur recalculée à la volée depuis
|
||||
`reading` à chaque appel. `PredictionRepository.latest_by_site()` isole donc un `DISTINCT ON
|
||||
(site_id)` ordonné par `target_at DESC` (couvert par l'index `ix_prediction_site_target`), le même
|
||||
mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend `prediction: null`
|
||||
plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte
|
||||
`ck_prediction_status` n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais
|
||||
LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf.
|
||||
[ML-START.md](../../ML-START.md) section 3.
|
||||
|
||||
`POST /recommendations/generate` est la seule route d'écriture métier du contrat. Elle applique
|
||||
le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni
|
||||
feature ML : le catalogue `REGLES` associe à chaque type et à chaque gravité d'alerte une action et
|
||||
son explication, et une même alerte peut en déclencher plusieurs, comme le prévoit
|
||||
[40-data.md](40-data.md). L'idempotence est portée par la base, pas par le service :
|
||||
`RecommendationRepository.create_missing()` insère en `ON CONFLICT DO NOTHING` sur
|
||||
`uq_recommendation_alert_rule`, donc rejouer la génération sur les mêmes alertes ne crée rien et
|
||||
le rapport rendu distingue `recommendations_created` de `already_present`. Le même traitement est
|
||||
disponible hors HTTP par `python -m app.cli generate-recommendations` (cible `make
|
||||
recommendations`), sur le patron de `make ml-score`. Le choix de loger le moteur dans le backend
|
||||
plutôt que dans `ml/` est justifié par l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md).
|
||||
Les alertes traitées sont celles qu'écrit la détection interne (#104, section ci-dessous) : la
|
||||
génération ne rend donc de recommandations qu'une fois la détection passée. L'insertion est
|
||||
découpée en lots de `TAILLE_DE_LOT` lignes, asyncpg plafonnant une requête à 32 767 paramètres.
|
||||
|
||||
`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable,
|
||||
donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/
|
||||
owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni
|
||||
@@ -194,6 +223,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
|
||||
@@ -269,7 +338,7 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv
|
||||
### Ajouter une route métier
|
||||
|
||||
Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/
|
||||
`readings`/`sensors` (`dataset`, `prediction`) :
|
||||
`readings`/`sensors`/`predictions` (`dataset`) :
|
||||
|
||||
1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN`
|
||||
au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux
|
||||
|
||||
@@ -13,24 +13,29 @@ Ce qui est en place :
|
||||
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
|
||||
`provideHttpClient(withInterceptors([mockApiInterceptor]))`.
|
||||
- Une route `/dashboard` en composant différé, et une redirection depuis la racine.
|
||||
- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de
|
||||
fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le
|
||||
- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et
|
||||
`AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification
|
||||
(jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`,
|
||||
`features/dashboard` la page principale, `shared/components` la jauge de consommation et le
|
||||
graphique de charge par site, tous deux construits sur Chart.js.
|
||||
- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation,
|
||||
changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail :
|
||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||
- Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`,
|
||||
`ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit
|
||||
réutiliser plutôt que redéfinir ses propres styles. Détail :
|
||||
[32-design-systeme-frontend.md](32-design-systeme-frontend.md).
|
||||
- L'état vit dans des signaux, sans bibliothèque dédiée.
|
||||
- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test.
|
||||
- Vitest via le builder `@angular/build:unit-test`, couverture activée.
|
||||
- Prettier configuré, parser `angular` pour les gabarits HTML.
|
||||
|
||||
Ce qui n'existe pas encore :
|
||||
|
||||
- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
|
||||
sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre.
|
||||
- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que
|
||||
les routes métier de l'API en exigent un. Voir
|
||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||
- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
|
||||
sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien
|
||||
ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement.
|
||||
`GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette
|
||||
PR (voir plus bas).
|
||||
- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide.
|
||||
- Aucun lint : ESLint n'est pas installé.
|
||||
|
||||
@@ -84,19 +89,19 @@ sequenceDiagram
|
||||
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
|
||||
`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en
|
||||
production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel.
|
||||
`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il
|
||||
suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide
|
||||
et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche.
|
||||
|
||||
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
|
||||
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
|
||||
`apiUrl` relatif, `/api/v1`.
|
||||
|
||||
En production, il n'y a pas de proxy : `environment.ts` porte une URL absolue. Angular substitue
|
||||
le fichier via `fileReplacements`, et la configuration `production` est celle par défaut.
|
||||
|
||||
**Dette connue.** `src/environments/environment.ts`, qui est la configuration de production,
|
||||
pointe `http://localhost:8000/api/v1` en dur. La valeur est celle du poste de développement :
|
||||
telle quelle, un build de production ne joindra jamais l'API. À corriger avant le premier
|
||||
déploiement, en même temps que sera tranchée la question de l'ingress dans
|
||||
[10-infra.md](10-infra.md).
|
||||
En production, il n'y a pas de proxy, mais `environment.ts` porte lui aussi un `apiUrl` relatif
|
||||
(`/api/v1`) plutôt qu'une URL absolue : la dette qui pointait en dur sur
|
||||
`http://localhost:8000/api/v1` a été corrigée. Un build de production sert donc l'appel `/api/v1/...`
|
||||
sur son propre origin, ce qui suppose qu'un ingress ou un reverse proxy route `/api` vers le
|
||||
backend une fois déployé — question toujours ouverte dans [10-infra.md](10-infra.md).
|
||||
|
||||
## Exécution
|
||||
|
||||
@@ -124,9 +129,10 @@ avec un service statique, il reste à écrire.
|
||||
## Sécurité
|
||||
|
||||
- Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL.
|
||||
- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun
|
||||
intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui
|
||||
que les fixtures.
|
||||
- L'authentification existe des deux côtés désormais : `authGuard` protège `/dashboard` et
|
||||
`/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le
|
||||
rafraîchissement sur 401. Détail complet dans
|
||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
+357
-53
@@ -6,10 +6,16 @@ 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 **mesures** est implémentée pour les deux sources du MVP, le dataset CSV/JSON et
|
||||
l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir
|
||||
l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). L'orchestration Airflow, les
|
||||
agrégats continus, la compression et la rétention restent des cibles.
|
||||
|
||||
## Trois emplacements, trois rôles
|
||||
|
||||
@@ -35,8 +41,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 +61,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
|
||||
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 de l'API et de Grafana viseront 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.
|
||||
|
||||
## 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 +123,7 @@ erDiagram
|
||||
bool must_change_password
|
||||
timestamptz credentials_changed_at
|
||||
}
|
||||
|
||||
REFRESH_TOKEN {
|
||||
uuid id PK
|
||||
uuid family_id
|
||||
@@ -99,6 +135,7 @@ erDiagram
|
||||
text revoked_reason
|
||||
uuid replaced_by
|
||||
}
|
||||
|
||||
LOGIN_ATTEMPT {
|
||||
bigint id PK
|
||||
timestamptz occurred_at
|
||||
@@ -106,6 +143,7 @@ erDiagram
|
||||
inet client_ip
|
||||
text outcome
|
||||
}
|
||||
|
||||
AUDIT_LOG {
|
||||
bigint id PK
|
||||
timestamptz occurred_at
|
||||
@@ -114,9 +152,27 @@ 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 :
|
||||
Six choix de modélisation portent une intention et se défendent seuls :
|
||||
|
||||
- **`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
|
||||
@@ -129,6 +185,10 @@ Quatre choix de modélisation portent une intention et se défendent seuls :
|
||||
- **`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).
|
||||
- **`password_reset_token` ne stocke que l'empreinte du jeton**, jamais sa valeur. Une fuite de
|
||||
la table ne donne donc rien à rejouer.
|
||||
- **`password_reset_attempt` est séparée de `audit_log`** : son volume est piloté par le
|
||||
demandeur, comme celui de `login_attempt`, donc elle doit pouvoir se purger.
|
||||
|
||||
`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
|
||||
@@ -137,8 +197,9 @@ piloté par l'attaquant.
|
||||
|
||||
## 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 +243,29 @@ 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 relèvent du jalon J2, « valider le périmètre retenu ». Le schéma et l'ingestion sont
|
||||
livrés : ce qui suit porte sur leur exploitation, plus sur leur forme.
|
||||
|
||||
- **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.
|
||||
|
||||

|
||||
@@ -208,21 +274,26 @@ 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.
|
||||
|
||||
Les lignes de `recommendation` sont écrites par le moteur de règles du backend
|
||||
(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`
|
||||
ou par `make recommendations`, à partir des alertes déjà en base. Le couple
|
||||
`(alert_id, rule_reference)` est unique : rejouer le moteur sur les mêmes alertes n'ajoute aucune
|
||||
ligne.
|
||||
|
||||
### Relations entre les tables
|
||||
|
||||
@@ -234,13 +305,19 @@ et ne sont pas considérées comme des alertes actuelles.
|
||||
|
||||
## 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 +348,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 +376,232 @@ 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
|
||||
```
|
||||
|
||||
### Frontière de confiance avec l'API Mock
|
||||
|
||||
L'API Mock de l'école n'a aucune authentification et expose un endpoint mutatif à quiconque. Sa
|
||||
réponse est donc traitée comme une entrée hostile, conformément à API10 dans
|
||||
[la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte,
|
||||
c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction.
|
||||
|
||||
Quatre garde-fous, tous dans `mock_api_import.py` :
|
||||
|
||||
| Garde-fou | Mise en œuvre |
|
||||
|---|---|
|
||||
| Timeout | `APP_MOCK_API_TIMEOUT_SECONDS`, dix secondes par défaut |
|
||||
| Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `--limit` mesures par site |
|
||||
| Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur |
|
||||
| Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus |
|
||||
|
||||
Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa
|
||||
trace dans `null_reasons` sous la forme `out_of_physical_bounds:<colonne>`, et `data_quality`
|
||||
descend à `degraded`. Une `data_quality` que `ck_reading_quality` refuserait devient `NULL`
|
||||
plutôt que de faire échouer le lot entier. Dans tous les cas `raw_data` conserve la réponse
|
||||
d'origine intacte : rien n'est perdu, seule son exploitation est bornée.
|
||||
|
||||
Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP
|
||||
lui-même demanderait une lecture en flux, et reste à faire.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -41,6 +41,7 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints
|
||||
| En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 |
|
||||
| Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design |
|
||||
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
|
||||
| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs |
|
||||
| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components |
|
||||
|
||||
Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des
|
||||
@@ -53,7 +54,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu
|
||||
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
||||
| **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. |
|
||||
| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. |
|
||||
| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
|
||||
| **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
|
||||
| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. |
|
||||
| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. |
|
||||
| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. |
|
||||
|
||||
+348
-27
@@ -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
|
||||
@@ -96,14 +115,14 @@ CSV + métadonnées JSON
|
||||
PostgreSQL / TimescaleDB
|
||||
```
|
||||
|
||||
### 1. Extraction
|
||||
#### 1. Extraction
|
||||
|
||||
Le pipeline charge :
|
||||
|
||||
- `all_sites_combined.csv` avec Pandas ;
|
||||
- `dataset_metadata.json` avec le module JSON de Python.
|
||||
|
||||
### 2. Validation
|
||||
#### 2. Validation
|
||||
|
||||
Avant toute écriture en base, le pipeline contrôle notamment :
|
||||
|
||||
@@ -117,7 +136,7 @@ Avant toute écriture en base, le pipeline contrôle notamment :
|
||||
|
||||
Une incohérence détectée pendant cette étape interrompt l'import avant le chargement.
|
||||
|
||||
### 3. Dry-run
|
||||
#### 3. Dry-run
|
||||
|
||||
Un mode `--dry-run` permet d'exécuter les contrôles sans écrire de données dans PostgreSQL.
|
||||
|
||||
@@ -130,7 +149,7 @@ Il permet notamment de vérifier :
|
||||
- les valeurs NULL ;
|
||||
- l'empreinte SHA-256.
|
||||
|
||||
### 4. Traçabilité
|
||||
#### 4. Traçabilité
|
||||
|
||||
Une empreinte SHA-256 est calculée à partir du fichier CSV afin d'identifier le dataset utilisé.
|
||||
|
||||
@@ -142,7 +161,7 @@ Empreinte SHA-256 du dataset validé :
|
||||
|
||||
Cette empreinte participe à la traçabilité du dataset chargé.
|
||||
|
||||
### 5. Transformation
|
||||
#### 5. Transformation
|
||||
|
||||
Les timestamps sont normalisés avec la timezone :
|
||||
|
||||
@@ -161,7 +180,7 @@ imputed_values = NULL
|
||||
imputation_method = NULL
|
||||
```
|
||||
|
||||
### 6. Chargement
|
||||
#### 6. Chargement
|
||||
|
||||
Le chargement est réalisé avec SQLAlchemy Async dans PostgreSQL/TimescaleDB.
|
||||
|
||||
@@ -188,7 +207,7 @@ dataset_id = identifiant du dataset
|
||||
|
||||
Cette représentation respecte les contraintes définies dans le schéma de la base.
|
||||
|
||||
## Dataset validé
|
||||
### Dataset validé
|
||||
|
||||
Le dataset traité contient :
|
||||
|
||||
@@ -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,251 @@ 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
|
||||
```
|
||||
|
||||
## Import depuis l'API Mock
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
Ce sont les quatre seules valeurs que la contrainte `ck_reading_quality` accepte. Toute autre
|
||||
valeur renvoyée par l'API est remplacée par `NULL` plutôt que de faire échouer le lot entier.
|
||||
|
||||
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.
|
||||
|
||||
### Bornes physiques et frontière de confiance
|
||||
|
||||
La réponse de l'API Mock est traitée comme une entrée hostile : l'API n'a pas
|
||||
d'authentification et expose un endpoint mutatif à quiconque. Voir API10 dans
|
||||
`docs/architecture/owasp-traceabilite.md`.
|
||||
|
||||
Les plages acceptées sont déclarées dans `PHYSICAL_BOUNDS` :
|
||||
|
||||
| Grandeur | Plage acceptée |
|
||||
|---|---|
|
||||
| `consumption_kw` | 0 à 100 000 |
|
||||
| `consumption_kwh` | 0 à 100 000 |
|
||||
| `voltage_v` | 0 à 1 000 |
|
||||
| `current_a` | 0 à 10 000 |
|
||||
| `power_factor` | 0 à 1 |
|
||||
| `temperature_celsius` | -90 à 60 |
|
||||
| `humidity_percent` | 0 à 100 |
|
||||
| `capacity_kw` | 0 à 100 000 |
|
||||
|
||||
Une valeur hors plage, d'un type inattendu, `NaN` ou infinie devient `NULL` :
|
||||
|
||||
```text
|
||||
null_reasons += "out_of_physical_bounds:<colonne>"
|
||||
data_quality = "degraded"
|
||||
```
|
||||
|
||||
L'import ne s'interrompt pas pour autant : le mock émet des anomalies par construction, et
|
||||
`raw_data` conserve la réponse d'origine.
|
||||
|
||||
La taille des réponses est plafonnée : au plus `MAX_SITES` sites, et au plus `--limit` mesures
|
||||
par site. Au-delà, l'import échoue au lieu de charger.
|
||||
|
||||
Enfin, seuls les champs attendus sont recopiés vers la base. Une clé supplémentaire renvoyée par
|
||||
l'API n'atteint jamais une colonne.
|
||||
|
||||
### 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 du pipeline sont situés dans :
|
||||
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 +577,94 @@ 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
|
||||
```
|
||||
|
||||
Contrôler le typage :
|
||||
|
||||
```powershell
|
||||
uv run mypy app
|
||||
```
|
||||
|
||||
Exécuter la suite complète avec le seuil de couverture :
|
||||
|
||||
```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
|
||||
|
||||
L'import historique constitue la première brique du pipeline Data EnerVision.
|
||||
Deux sources de données sont maintenant prises en charge :
|
||||
|
||||
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.
|
||||
```text
|
||||
Dataset CSV/JSON
|
||||
|
|
||||
v
|
||||
historical_import.py
|
||||
|
|
||||
+-----------------+
|
||||
|
|
||||
v
|
||||
PostgreSQL / TimescaleDB
|
||||
^
|
||||
|
|
||||
+-----------------+
|
||||
|
|
||||
mock_api_import.py
|
||||
^
|
||||
|
|
||||
API Mock
|
||||
```
|
||||
|
||||
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.
|
||||
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
||||
|
||||
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`), mais il orchestre pour l'instant le pipeline ML (`ml_train`/`ml_score`, issue #115), pas encore ces deux imports : orchestrer `historical_import.py` et `mock_api_import.py` (normalisation et chargement micro-batch, issues #15/#16) reste à faire.
|
||||
|
||||
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py` et `ml_score.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel).
|
||||
|
||||
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Image Airflow EnerVision : ajoute le projet ml/ dans son propre environnement Python 3.14,
|
||||
# distinct du Python 3.12 qui fait tourner Airflow lui-meme, pour que les DAGs puissent lancer
|
||||
# `uv run python -m enervision_ml.train`/`.score` en sous-processus (cf. docs/architecture/
|
||||
# 20-backend.md, section Détection d'alertes internes pour le meme raisonnement applique a
|
||||
# app/detection). Airflow ne devient jamais un consommateur direct de LightGBM/MLflow.
|
||||
FROM apache/airflow:2.10.4-python3.12
|
||||
|
||||
# LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans
|
||||
# toolchain de compilation). Sans lui : `OSError: libgomp.so.1: cannot open shared object file`
|
||||
# au premier `import lightgbm`, seulement au moment ou une tache tourne reellement.
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y libgomp1 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Pre-cree, appartenant a `airflow` : docker-compose y monte un volume nomme partage entre
|
||||
# `ml_train` et `ml_score` (le modele ecrit par l'un, lu par l'autre). Un volume nomme herite des
|
||||
# permissions du repertoire qu'il recouvre a son premier montage ; sans ce chown prealable, il
|
||||
# serait cree root:root et illisible par le conteneur, qui tourne en `airflow` (uid 50000).
|
||||
RUN mkdir -p /opt/ml/state && chown -R airflow:root /opt/ml
|
||||
USER airflow
|
||||
|
||||
# L'image de base embarque deja un `uv`, mais trop ancien (0.4.29) pour le format de verrou de
|
||||
# `ml/uv.lock`. On le remplace par la version deja pinnee ailleurs dans le depot
|
||||
# (apps/backend/Dockerfile).
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /home/airflow/.local/bin/uv
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PROJECT_ENVIRONMENT=/opt/ml/.venv
|
||||
|
||||
WORKDIR /opt/ml
|
||||
|
||||
COPY --chown=airflow:root ml/pyproject.toml ml/uv.lock ./
|
||||
RUN uv sync --locked --no-install-project --no-dev
|
||||
|
||||
COPY --chown=airflow:root ml/enervision_ml ./enervision_ml
|
||||
RUN uv sync --locked --no-dev
|
||||
|
||||
WORKDIR /opt/airflow
|
||||
@@ -0,0 +1,33 @@
|
||||
"""DAG de scoring horaire du modele LightGBM (issue #115).
|
||||
|
||||
Planifie toutes les heures, au rythme documente par `enervision_ml.score` (score le prochain pas
|
||||
horaire par site). Reutilise le modele ecrit par `ml_train` (DAG separe, declenche a la main) :
|
||||
ce DAG ne reentraine jamais rien. Si aucun modele n'a encore ete entraine, la tache echoue
|
||||
(`FileNotFoundError`) plutot que de rester silencieuse.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from airflow.models.dag import DAG
|
||||
from airflow.operators.bash import BashOperator
|
||||
|
||||
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
|
||||
|
||||
with DAG(
|
||||
dag_id="ml_score",
|
||||
description="Score le prochain pas horaire par site (enervision_ml.score).",
|
||||
schedule="@hourly",
|
||||
start_date=datetime(2026, 1, 1),
|
||||
catchup=False,
|
||||
tags=["ml"],
|
||||
) as dag:
|
||||
# `--frozen --no-dev` : cf. `ml_train.py`, meme raisonnement.
|
||||
BashOperator(
|
||||
task_id="score",
|
||||
bash_command=(
|
||||
"cd /opt/ml && uv run --frozen --no-dev python -m enervision_ml.score "
|
||||
f"--model {MODEL_PATH}"
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""DAG d'entrainement du modele LightGBM (issue #115).
|
||||
|
||||
Pas de planification : reentrainer est couteux et sa cadence n'est pas une decision prise
|
||||
(cf. `docs/architecture/20-backend.md`). Declenchement manuel depuis l'UI ou la CLI Airflow en
|
||||
attendant. `ml_score` (DAG separe, planifie toutes les heures) reutilise le modele que ce DAG
|
||||
ecrit, il ne reentraine jamais rien lui-meme.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from airflow.models.dag import DAG
|
||||
from airflow.operators.bash import BashOperator
|
||||
|
||||
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
|
||||
MLFLOW_TRACKING_URI = "sqlite:////opt/ml/state/mlflow.db"
|
||||
|
||||
with DAG(
|
||||
dag_id="ml_train",
|
||||
description="Entraine le modele LightGBM de prevision de consommation (enervision_ml.train).",
|
||||
schedule=None,
|
||||
start_date=datetime(2026, 1, 1),
|
||||
catchup=False,
|
||||
tags=["ml"],
|
||||
) as dag:
|
||||
# `--frozen --no-dev` : l'environnement `/opt/ml/.venv` est fige a la construction de l'image
|
||||
# (groupe `dev` exclu). Sans `--no-dev` ici, `uv run` resynchronise ruff/mypy a chaque
|
||||
# execution : un acces reseau evitable, sur le chemin d'execution d'une tache planifiee.
|
||||
BashOperator(
|
||||
task_id="train",
|
||||
bash_command=(
|
||||
"cd /opt/ml && uv run --frozen --no-dev python -m enervision_ml.train "
|
||||
f"--model-output {MODEL_PATH} --mlflow-tracking-uri {MLFLOW_TRACKING_URI}"
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
[project]
|
||||
name = "enervision-airflow"
|
||||
version = "0.1.0"
|
||||
description = "DAGs d'orchestration EnerVision (Airflow)"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
dependencies = [
|
||||
"apache-airflow==2.10.4",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.16.7",
|
||||
"pytest>=9.1.1",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
src = ["dags", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "W", "F", "I", "N", "UP", "B", "SIM", "RUF"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Isole Airflow d'un `~/airflow` reel : `AIRFLOW_HOME` doit etre pose avant le premier `import
|
||||
airflow`, donc ici plutot que dans une fixture (les fixtures s'executent trop tard, apres que les
|
||||
modules de test aient deja importe `airflow`)."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_AIRFLOW_HOME = Path(__file__).resolve().parent / ".airflow_home"
|
||||
_AIRFLOW_HOME.mkdir(exist_ok=True)
|
||||
|
||||
os.environ.setdefault("AIRFLOW_HOME", str(_AIRFLOW_HOME))
|
||||
os.environ.setdefault("AIRFLOW__CORE__LOAD_EXAMPLES", "False")
|
||||
os.environ.setdefault("AIRFLOW__CORE__UNIT_TEST_MODE", "True")
|
||||
os.environ.setdefault(
|
||||
"AIRFLOW__DATABASE__SQL_ALCHEMY_CONN", f"sqlite:///{_AIRFLOW_HOME / 'airflow.db'}"
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests d'integrite des DAGs : s'importent sans erreur, structure attendue. Pas d'execution
|
||||
reelle des taches (ca reclamerait le conteneur avec `uv`/`enervision_ml`), juste la definition."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from airflow.models.dagbag import DagBag
|
||||
|
||||
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dagbag() -> DagBag:
|
||||
return DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
|
||||
|
||||
|
||||
def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None:
|
||||
assert dagbag.import_errors == {}
|
||||
|
||||
|
||||
def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None:
|
||||
assert set(dagbag.dag_ids) == {"ml_train", "ml_score"}
|
||||
|
||||
|
||||
def test_ml_train_has_no_schedule() -> None:
|
||||
dagbag = DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
|
||||
assert dagbag.dags["ml_train"].timetable.summary == "None"
|
||||
|
||||
|
||||
def test_ml_score_runs_every_hour() -> None:
|
||||
# `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que `.summary` le rend.
|
||||
dagbag = DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
|
||||
assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *"
|
||||
|
||||
|
||||
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
|
||||
tache = dagbag.dags["ml_train"].get_task("train")
|
||||
assert "enervision_ml.train" in tache.bash_command
|
||||
|
||||
|
||||
def test_ml_score_task_calls_the_scoring_module(dagbag: DagBag) -> None:
|
||||
tache = dagbag.dags["ml_score"].get_task("score")
|
||||
assert "enervision_ml.score" in tache.bash_command
|
||||
|
||||
|
||||
def test_ml_score_reuses_the_model_path_written_by_ml_train(dagbag: DagBag) -> None:
|
||||
entrainement = dagbag.dags["ml_train"].get_task("train").bash_command
|
||||
scoring = dagbag.dags["ml_score"].get_task("score").bash_command
|
||||
chemin_modele = "/opt/ml/state/models/lightgbm-consumption.txt"
|
||||
|
||||
assert chemin_modele in entrainement
|
||||
assert chemin_modele in scoring
|
||||
Generated
+1970
File diff suppressed because it is too large
Load Diff
+51
-5
@@ -57,6 +57,46 @@ validation. La coupure est **chronologique**, jamais un tirage aleatoire de lign
|
||||
aleatoire laisserait des lignes de validation "voir" des lignes d'entrainement via leurs
|
||||
lags/moyennes glissantes, une fuite qui masquerait un surapprentissage.
|
||||
|
||||
## Scoring
|
||||
|
||||
```bash
|
||||
uv run python -m enervision_ml.score --csv data/all_sites_combined.csv
|
||||
# ou, une fois la base peuplee et ML_DATABASE_URL positionnee :
|
||||
uv run python -m enervision_ml.score
|
||||
```
|
||||
|
||||
Calcule, pour chaque site (ou un seul avec `--site-id`), la consommation prevue de l'heure suivant
|
||||
sa derniere lecture connue, et ecrit une ligne dans `prediction`. Etapes, cf. `ML-START.md`
|
||||
section 2 :
|
||||
|
||||
1. Lit une fenetre recente de `reading`+`site` (21 jours par defaut, une marge au-dessus des 168h
|
||||
necessaires au lag hebdomadaire) plutot que tout l'historique -- le meme piege que celui deja
|
||||
corrige sur `GET /readings` (fenetre non plafonnee sur une hypertable).
|
||||
2. Ajoute une ligne "future" par site (l'heure suivante) et calcule ses features avec
|
||||
`enervision_ml.features.build_features`, **exactement** la meme fonction qu'a l'entrainement.
|
||||
3. Si le lag de 168h est absent (moins d'une semaine d'historique pour ce site) : ecrit
|
||||
`status="insufficient_data"` directement, sans jamais appeler LightGBM.
|
||||
4. Sinon : appelle `booster.predict(...)` et ecrit `status="available"` avec la valeur predite.
|
||||
|
||||
`--model` pointe vers le fichier entraine (`models/lightgbm-consumption.txt` par defaut).
|
||||
`model_reference` en base est le hache SHA-256 (tronque) du fichier modele, pas son nom de
|
||||
fichier : `train.py` reecrit toujours le meme chemin a chaque entrainement, donc le nom seul ne
|
||||
distinguerait pas deux versions du modele.
|
||||
|
||||
En mode `--csv`, rien n'est ecrit en base : c'est un instantane historique fige (l'heure "future"
|
||||
calculee a partir de la fin du CSV n'existe dans aucune base reelle), utile pour valider le
|
||||
pipeline sans base joignable.
|
||||
|
||||
**Limite assumee** : la feature `is_working_hours` de la ligne future est recopiee depuis la
|
||||
derniere lecture reelle, pas recalculee -- il n'existe aucune regle horaire ouvrable dans ce
|
||||
depot (elle vit dans le generateur du jeu de donnees d'origine). L'approximation n'est fausse
|
||||
qu'aux heures de bascule ouverture/fermeture, sur une seule feature parmi une dizaine, pour une
|
||||
prevision a un seul pas.
|
||||
|
||||
`prediction` n'a pas de contrainte d'unicite sur `(site_id, target_at)` : chaque run de scoring
|
||||
insere une nouvelle ligne plutot que d'ecraser la precedente, pour garder une trace de chaque
|
||||
prevision (utile plus tard pour comparer prevision et realise, surveillance de derive #44/#45).
|
||||
|
||||
## Commandes
|
||||
|
||||
```bash
|
||||
@@ -81,8 +121,14 @@ environnement de developpement pour le moment.
|
||||
## Piege a connaitre
|
||||
|
||||
`enervision_ml.features.build_features` est **le seul endroit** qui doit construire les features
|
||||
du modele, a l'entrainement comme au futur scoring (service #37, pas encore construit). Si les
|
||||
deux divergent meme legerement (une fenetre de moyenne glissante calculee differemment, par
|
||||
exemple), le modele recoit en production des features qui ne ressemblent plus a ce qu'il a
|
||||
appris, et ses predictions deviennent silencieusement mauvaises sans qu'aucune erreur ne se
|
||||
declenche. Ne jamais reecrire cette logique ailleurs : importer `enervision_ml.features`.
|
||||
du modele, a l'entrainement comme au scoring (`enervision_ml.score`). Si les deux divergent meme
|
||||
legerement (une fenetre de moyenne glissante calculee differemment, par exemple), le modele
|
||||
recoit en production des features qui ne ressemblent plus a ce qu'il a appris, et ses predictions
|
||||
deviennent silencieusement mauvaises sans qu'aucune erreur ne se declenche. Ne jamais reecrire
|
||||
cette logique ailleurs : importer `enervision_ml.features`.
|
||||
|
||||
## Et cote API ?
|
||||
|
||||
`GET /api/v1/predictions` (backend, `apps/backend`) lit ce que `enervision_ml.score` a ecrit dans
|
||||
`prediction` -- la derniere prevision par site, jamais un recalcul a la volee. FastAPI ne fait
|
||||
jamais tourner LightGBM lui-meme, cf. `ML-START.md` section 3.
|
||||
|
||||
@@ -16,6 +16,7 @@ Deux chemins, qui doivent produire le meme schema de sortie (colonnes `site_id`,
|
||||
colonne est renvoyee a `NaN`, que LightGBM gere nativement comme valeur manquante.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
@@ -34,6 +35,14 @@ OUTPUT_COLUMNS = [
|
||||
"capacity_kw",
|
||||
]
|
||||
|
||||
NUMERIC_COLUMNS = [
|
||||
"consumption_kwh",
|
||||
"temperature_celsius",
|
||||
"humidity_percent",
|
||||
"solar_irradiance_wm2",
|
||||
"capacity_kw",
|
||||
]
|
||||
|
||||
_READING_QUERY = text(
|
||||
"""
|
||||
SELECT
|
||||
@@ -53,10 +62,43 @@ _READING_QUERY = text(
|
||||
)
|
||||
|
||||
|
||||
_RECENT_READING_QUERY = text(
|
||||
"""
|
||||
SELECT
|
||||
r.site_id,
|
||||
r.timestamp,
|
||||
r.consumption_kwh,
|
||||
r.temperature_celsius,
|
||||
r.humidity_percent,
|
||||
r.solar_irradiance_wm2,
|
||||
r.is_working_hours,
|
||||
s.site_type,
|
||||
s.capacity_kw
|
||||
FROM reading r
|
||||
JOIN site s ON s.site_id = r.site_id
|
||||
WHERE r.timestamp >= :since
|
||||
ORDER BY r.site_id, r.timestamp
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def load_from_database(connection: Connectable) -> pd.DataFrame:
|
||||
"""Lit l'historique complet `reading` + `site` depuis PostgreSQL."""
|
||||
"""Lit l'historique complet `reading` + `site` depuis PostgreSQL. Entrainement seulement :
|
||||
le scoring n'a besoin que d'une fenetre recente, cf. `load_recent_from_database`.
|
||||
"""
|
||||
frame = pd.read_sql(_READING_QUERY, connection)
|
||||
return frame[OUTPUT_COLUMNS]
|
||||
return _typer(frame[OUTPUT_COLUMNS])
|
||||
|
||||
|
||||
def load_recent_from_database(connection: Connectable, *, since: datetime) -> pd.DataFrame:
|
||||
"""Lit `reading` + `site` depuis `since` seulement, pour le scoring.
|
||||
|
||||
Piege evite : un `SELECT` sans borne sur l'hypertable complete juste pour scorer le prochain
|
||||
pas horaire serait la meme erreur que celle corrigee sur `GET /readings` (fenetre non
|
||||
plafonnee sur une table pouvant porter des annees d'historique).
|
||||
"""
|
||||
frame = pd.read_sql(_RECENT_READING_QUERY, connection, params={"since": since})
|
||||
return _typer(frame[OUTPUT_COLUMNS])
|
||||
|
||||
|
||||
def load_from_csv(csv_path: Path) -> pd.DataFrame:
|
||||
@@ -65,4 +107,25 @@ def load_from_csv(csv_path: Path) -> pd.DataFrame:
|
||||
frame["capacity_kw"] = float("nan")
|
||||
frame["is_working_hours"] = frame["is_working_hours"].astype(bool)
|
||||
|
||||
return frame[OUTPUT_COLUMNS]
|
||||
return _typer(frame[OUTPUT_COLUMNS])
|
||||
|
||||
|
||||
def _typer(frame: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Force le typage numerique attendu par LightGBM.
|
||||
|
||||
Piege reel, pas theorique : `site.capacity_kw` n'est peuple par aucun pipeline d'ingestion
|
||||
aujourd'hui (`historical_import.py` ne pose que `site_type`/`site_name`). Une colonne
|
||||
entierement `NULL` revient de `pd.read_sql` en dtype `object` plutot que `float64`, ce que
|
||||
LightGBM refuse ("pandas dtypes must be int, float or bool"). `pd.to_numeric` corrige aussi
|
||||
n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas
|
||||
seulement `capacity_kw`.
|
||||
|
||||
Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas
|
||||
seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement
|
||||
`NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au
|
||||
scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees.
|
||||
"""
|
||||
typee = frame.copy()
|
||||
for colonne in NUMERIC_COLUMNS:
|
||||
typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce")
|
||||
return typee
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Scoring du modele LightGBM : calcule et enregistre la consommation prevue du prochain pas
|
||||
horaire, par site.
|
||||
|
||||
CLI autonome, sur le meme gabarit que `enervision_ml.train` et
|
||||
`apps/backend/app/etl/historical_import.py`. Cf. `docs/ML-START.md`, section 2.
|
||||
|
||||
uv run python -m enervision_ml.score --csv ../ml/data/all_sites_combined.csv
|
||||
uv run python -m enervision_ml.score # lit ML_DATABASE_URL, ecrit dans `prediction`
|
||||
|
||||
Reutilise `enervision_ml.features.build_features` tel quel (jamais reecrit) : c'est la garantie
|
||||
contre le train/serve skew documentee dans ce module.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import lightgbm as lgb
|
||||
import pandas as pd
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from enervision_ml import config
|
||||
from enervision_ml.data import load_from_csv, load_recent_from_database
|
||||
from enervision_ml.features import TARGET_COLUMN, WEATHER_COLUMNS, build_features, feature_columns
|
||||
|
||||
# Marge au-dessus des 168h necessaires au lag hebdomadaire, pour absorber les trous de mesure.
|
||||
LOOKBACK = timedelta(days=21)
|
||||
|
||||
# Au-dela de ce seuil, la derniere lecture d'un site est trop vieille pour que "l'heure
|
||||
# suivante" ait un sens operationnel : ce n'est plus une prevision a un pas, c'est un site dont
|
||||
# l'ingestion s'est probablement arretee. Sans cette borne, `build_scoring_frame` produirait
|
||||
# quand meme un `target_at` (derniere lecture + 1h), et rien en aval (ni l'API, ni le dashboard)
|
||||
# ne distingue une prevision fraiche d'une prevision vieille de plusieurs jours.
|
||||
MAX_STALENESS = timedelta(hours=24)
|
||||
|
||||
TARGET_METRIC = "consumption_kwh"
|
||||
PERIOD_MINUTES = 60
|
||||
LAG_168H_COLUMN = f"{TARGET_COLUMN}_lag_168h"
|
||||
INSUFFICIENT_DATA_REASON = (
|
||||
"Historique insuffisant : moins de 168h de consumption_kwh disponibles pour ce site."
|
||||
)
|
||||
|
||||
|
||||
def _stale_reason(age: pd.Timedelta) -> str:
|
||||
return (
|
||||
f"Dernière lecture vieille de {age.total_seconds() / 3600:.0f}h "
|
||||
f"(seuil {MAX_STALENESS.total_seconds() / 3600:.0f}h) : ingestion probablement "
|
||||
"arrêtée pour ce site."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScoredSite:
|
||||
site_id: str
|
||||
target_at: datetime
|
||||
status: str
|
||||
predicted_value: float | None
|
||||
failure_reason: str | None
|
||||
|
||||
|
||||
def model_reference(model_path: Path) -> str:
|
||||
"""Identifiant stable du modele utilise, insensible au fait que `train.py` reecrive
|
||||
toujours le meme nom de fichier a chaque entrainement (pas de versioning par nom, cf.
|
||||
`ml/README.md`)."""
|
||||
empreinte = hashlib.sha256(model_path.read_bytes()).hexdigest()
|
||||
return f"lightgbm-{empreinte[:12]}"
|
||||
|
||||
|
||||
def build_scoring_frame(recent: pd.DataFrame, *, site_id: str | None = None) -> pd.DataFrame:
|
||||
"""Ajoute une ligne future (l'heure suivant la derniere lecture connue) par site, et calcule
|
||||
ses features par `build_features` -- exactement comme a l'entrainement, seule la cible de
|
||||
cette ligne est inconnue.
|
||||
|
||||
Piege assume : `is_working_hours` de la ligne future est copie de la derniere lecture reelle,
|
||||
pas recalcule. Il n'existe aucune regle horaire ouvrable dans ce depot (elle vit dans le
|
||||
generateur du jeu de donnees d'origine, hors de ce code) ; l'approximation n'est fausse
|
||||
qu'aux heures de bascule (ouverture/fermeture), sur une seule feature parmi une dizaine, pour
|
||||
une prevision a un pas seulement.
|
||||
"""
|
||||
travail = recent if site_id is None else recent[recent["site_id"] == site_id]
|
||||
if travail.empty:
|
||||
return build_features(travail)
|
||||
|
||||
dernieres = (
|
||||
travail.sort_values("timestamp").groupby("site_id", as_index=False, sort=False).tail(1)
|
||||
).copy()
|
||||
dernieres["timestamp"] = dernieres["timestamp"] + pd.Timedelta(hours=1)
|
||||
dernieres[TARGET_COLUMN] = float("nan")
|
||||
# Meteo future inconnue (cf. piege documente dans `enervision_ml.features.build_features`) :
|
||||
# laisser `NaN` ici n'a aucun effet sur les features utilisees, qui ne prennent la meteo que
|
||||
# decalee.
|
||||
for colonne in WEATHER_COLUMNS:
|
||||
dernieres[colonne] = float("nan")
|
||||
|
||||
etendu = pd.concat([travail, dernieres], ignore_index=True)
|
||||
features = build_features(etendu)
|
||||
return features.groupby("site_id", as_index=False, sort=False).tail(1).reset_index(drop=True)
|
||||
|
||||
|
||||
def score(
|
||||
booster: lgb.Booster, scoring_frame: pd.DataFrame, *, instant: datetime
|
||||
) -> list[ScoredSite]:
|
||||
resultats: list[ScoredSite] = []
|
||||
|
||||
# `timestamp` de la ligne de scoring vaut derniere lecture + 1h (cf. `build_scoring_frame`) :
|
||||
# on en deduit l'age de cette derniere lecture par rapport a `instant`.
|
||||
travail = scoring_frame.copy()
|
||||
travail["_age"] = instant - (travail["timestamp"] - pd.Timedelta(hours=1))
|
||||
|
||||
perimes = travail[travail["_age"] > MAX_STALENESS]
|
||||
for enregistrement in _records(perimes):
|
||||
resultats.append(
|
||||
ScoredSite(
|
||||
site_id=enregistrement["site_id"],
|
||||
target_at=enregistrement["timestamp"].to_pydatetime(),
|
||||
status="insufficient_data",
|
||||
predicted_value=None,
|
||||
failure_reason=_stale_reason(enregistrement["_age"]),
|
||||
)
|
||||
)
|
||||
|
||||
a_jour = travail[travail["_age"] <= MAX_STALENESS]
|
||||
|
||||
insuffisants = a_jour[a_jour[LAG_168H_COLUMN].isna()]
|
||||
for enregistrement in _records(insuffisants):
|
||||
resultats.append(
|
||||
ScoredSite(
|
||||
site_id=enregistrement["site_id"],
|
||||
target_at=enregistrement["timestamp"].to_pydatetime(),
|
||||
status="insufficient_data",
|
||||
predicted_value=None,
|
||||
failure_reason=INSUFFICIENT_DATA_REASON,
|
||||
)
|
||||
)
|
||||
|
||||
suffisants = a_jour[a_jour[LAG_168H_COLUMN].notna()]
|
||||
if not suffisants.empty:
|
||||
typee = suffisants.copy()
|
||||
typee["site_type"] = typee["site_type"].astype("category")
|
||||
predictions = booster.predict(typee[feature_columns()])
|
||||
for enregistrement, valeur in zip(_records(suffisants), predictions, strict=True):
|
||||
resultats.append(
|
||||
ScoredSite(
|
||||
site_id=enregistrement["site_id"],
|
||||
target_at=enregistrement["timestamp"].to_pydatetime(),
|
||||
status="available",
|
||||
predicted_value=float(valeur),
|
||||
failure_reason=None,
|
||||
)
|
||||
)
|
||||
|
||||
return resultats
|
||||
|
||||
|
||||
def _records(frame: pd.DataFrame) -> list[dict[str, Any]]:
|
||||
return cast(list[dict[str, Any]], frame.to_dict(orient="records"))
|
||||
|
||||
|
||||
_INSERT_PREDICTION = text(
|
||||
"""
|
||||
INSERT INTO prediction (
|
||||
site_id, target_at, target_metric, period_minutes,
|
||||
predicted_value, model_reference, status, failure_reason
|
||||
) VALUES (
|
||||
:site_id, :target_at, :target_metric, :period_minutes,
|
||||
:predicted_value, :model_reference, :status, :failure_reason
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def write_predictions(
|
||||
connection: Connection, resultats: list[ScoredSite], *, reference: str
|
||||
) -> None:
|
||||
"""Ecrit une ligne par site score. Insertion seule, jamais de mise a jour : `prediction`
|
||||
n'a pas de contrainte d'unicite sur `(site_id, target_at)`, chaque run garde sa propre trace
|
||||
plutot que d'ecraser la precedente -- utile plus tard pour comparer prevision et realise
|
||||
(surveillance de derive, #44/#45)."""
|
||||
if not resultats:
|
||||
return
|
||||
|
||||
lignes = [
|
||||
{
|
||||
"site_id": r.site_id,
|
||||
"target_at": r.target_at,
|
||||
"target_metric": TARGET_METRIC,
|
||||
"period_minutes": PERIOD_MINUTES,
|
||||
"predicted_value": r.predicted_value,
|
||||
"model_reference": reference,
|
||||
"status": r.status,
|
||||
"failure_reason": r.failure_reason,
|
||||
}
|
||||
for r in resultats
|
||||
]
|
||||
connection.execute(_INSERT_PREDICTION, lignes)
|
||||
|
||||
|
||||
def _load_recent_from_csv(csv_path: Path, *, now: datetime | None) -> tuple[pd.DataFrame, datetime]:
|
||||
brute = load_from_csv(csv_path)
|
||||
instant = now or (
|
||||
brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC)
|
||||
)
|
||||
return brute[brute["timestamp"] >= instant - LOOKBACK], instant
|
||||
|
||||
|
||||
def _score_frame(
|
||||
recent: pd.DataFrame, *, model_path: Path, site_id: str | None, instant: datetime
|
||||
) -> list[ScoredSite]:
|
||||
scoring_frame = build_scoring_frame(recent, site_id=site_id)
|
||||
if scoring_frame.empty:
|
||||
return []
|
||||
|
||||
booster = lgb.Booster(model_file=str(model_path))
|
||||
return score(booster, scoring_frame, instant=instant)
|
||||
|
||||
|
||||
def run_scoring(
|
||||
*,
|
||||
model_path: Path,
|
||||
csv_path: Path | None = None,
|
||||
site_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> list[ScoredSite]:
|
||||
"""Score le prochain pas horaire par site et l'ecrit dans `prediction`.
|
||||
|
||||
En mode `--csv`, rien n'est ecrit : c'est un instantane historique fige (l'heure "future"
|
||||
calculee n'existe dans aucune base reelle), utile pour valider le pipeline sans base
|
||||
joignable, cf. `ml/README.md`. `site_id` n'est filtre qu'une fois, dans
|
||||
`build_scoring_frame` : le filtrer aussi ici serait redondant.
|
||||
"""
|
||||
if csv_path is not None:
|
||||
recent, instant = _load_recent_from_csv(csv_path, now=now)
|
||||
return _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant)
|
||||
|
||||
# Un seul engine pour la lecture et l'ecriture de ce run, plutot qu'un par etape.
|
||||
engine = create_engine(config.database_url())
|
||||
try:
|
||||
instant = now or datetime.now(UTC)
|
||||
recent = load_recent_from_database(engine, since=instant - LOOKBACK)
|
||||
resultats = _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant)
|
||||
|
||||
reference = model_reference(model_path)
|
||||
with engine.begin() as connection:
|
||||
write_predictions(connection, resultats, reference=reference)
|
||||
|
||||
return resultats
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Scoring du modele LightGBM EnerVision")
|
||||
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
type=Path,
|
||||
default=Path("models/lightgbm-consumption.txt"),
|
||||
help="Chemin du modele entraine. Defaut : models/lightgbm-consumption.txt.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--csv",
|
||||
type=Path,
|
||||
default=None,
|
||||
help=(
|
||||
"Instantane historique de demarrage/demo, rien n'est ecrit en base. Omis, lit "
|
||||
"ML_DATABASE_URL, se connecte a PostgreSQL et ecrit dans `prediction`."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--site-id",
|
||||
default=None,
|
||||
help="Ne score que ce site. Omis, tous les sites presents dans la fenetre recente.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--now",
|
||||
type=_parse_instant,
|
||||
default=None,
|
||||
help=(
|
||||
"Instant de reference (ISO 8601), pour tester ou demontrer le scoring cote base sur "
|
||||
"des donnees anciennes (ex. le jeu de donnees historique, qui s'arrete fin 2024). "
|
||||
"Omis, horloge systeme reelle."
|
||||
),
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _parse_instant(valeur: str) -> datetime:
|
||||
instant = datetime.fromisoformat(valeur)
|
||||
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
resultats = run_scoring(
|
||||
model_path=args.model, csv_path=args.csv, site_id=args.site_id, now=args.now
|
||||
)
|
||||
|
||||
if not resultats:
|
||||
print("Aucun site a scorer (aucune lecture recente dans la fenetre).")
|
||||
return
|
||||
|
||||
for r in resultats:
|
||||
if r.status == "available":
|
||||
print(f"{r.site_id} @ {r.target_at} : {r.predicted_value:.2f} kWh")
|
||||
else:
|
||||
print(f"{r.site_id} @ {r.target_at} : {r.status} ({r.failure_reason})")
|
||||
|
||||
if args.csv is not None:
|
||||
print("\nMode --csv : instantane historique, rien ecrit en base.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+3
-3
@@ -47,9 +47,9 @@ select = [
|
||||
"S",
|
||||
"PT",
|
||||
]
|
||||
# N806 : `X`/`y` (donnees/cible) est la convention scikit-learn/LightGBM, pas une variable mal
|
||||
# nommee.
|
||||
ignore = ["B008", "N806"]
|
||||
# N806/N803 : `X`/`y` (donnees/cible) est la convention scikit-learn/LightGBM, pas une variable
|
||||
# ou un argument mal nomme.
|
||||
ignore = ["B008", "N806", "N803"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**/*.py" = ["S101"]
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv
|
||||
|
||||
_CSV_HEADER = (
|
||||
"site_id,timestamp,consumption_kwh,temperature_celsius,humidity_percent,"
|
||||
"solar_irradiance_wm2,is_working_hours,site_type"
|
||||
)
|
||||
|
||||
|
||||
def write_csv(tmp_path: Path, *lignes: str) -> Path:
|
||||
csv_path = tmp_path / "recent.csv"
|
||||
csv_path.write_text("\n".join([_CSV_HEADER, *lignes]) + "\n")
|
||||
return csv_path
|
||||
|
||||
|
||||
def test_load_from_csv_types_every_numeric_column_as_float(tmp_path: Path) -> None:
|
||||
csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office")
|
||||
|
||||
frame = load_from_csv(csv_path)
|
||||
|
||||
for colonne in NUMERIC_COLUMNS:
|
||||
assert frame[colonne].dtype == "float64"
|
||||
|
||||
|
||||
def test_load_from_csv_coerces_a_corrupted_measurement_to_nan(tmp_path: Path) -> None:
|
||||
# Reproduit une valeur de capteur corrompue plutot que vraiment manquante : `pandas` type
|
||||
# alors la colonne entiere en `object`, pas en `float64` rempli de `NaN` -- le meme genre de
|
||||
# divergence de typage que celle que `pd.read_sql` produit sur une colonne SQL entierement
|
||||
# `NULL` (cf. `site.capacity_kw`, jamais peuplee par aucun pipeline d'ingestion aujourd'hui).
|
||||
csv_path = write_csv(
|
||||
tmp_path,
|
||||
"SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office",
|
||||
"SITE001,2026-01-01T01:00:00,capteur_hs,15.2,50.5,0.0,True,office",
|
||||
)
|
||||
|
||||
frame = load_from_csv(csv_path)
|
||||
|
||||
assert frame["consumption_kwh"].dtype == "float64"
|
||||
assert frame["consumption_kwh"].iloc[0] == 10.5
|
||||
assert pd.isna(frame["consumption_kwh"].iloc[1])
|
||||
|
||||
|
||||
def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None:
|
||||
# `capacity_kw` n'existe pas dans ce CSV : `load_from_csv` la pose elle-meme a `NaN`. Cette
|
||||
# affectation directe est deja un `float`, contrairement au cas `pd.read_sql` -- ce test
|
||||
# garde le contrat visible malgre tout, au cas ou l'implementation changerait.
|
||||
csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office")
|
||||
|
||||
frame = load_from_csv(csv_path)
|
||||
|
||||
assert frame["capacity_kw"].dtype == "float64"
|
||||
assert pd.isna(frame["capacity_kw"].iloc[0])
|
||||
@@ -0,0 +1,277 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from enervision_ml.features import TARGET_COLUMN
|
||||
from enervision_ml.score import (
|
||||
LAG_168H_COLUMN,
|
||||
MAX_STALENESS,
|
||||
ScoredSite,
|
||||
build_scoring_frame,
|
||||
model_reference,
|
||||
run_scoring,
|
||||
score,
|
||||
write_predictions,
|
||||
)
|
||||
|
||||
|
||||
def make_recent(
|
||||
site_id: str, *, heures: int, depart: datetime, valeur: float = 10.0
|
||||
) -> pd.DataFrame:
|
||||
instants = [depart + timedelta(hours=h) for h in range(heures)]
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"site_id": site_id,
|
||||
"timestamp": instants,
|
||||
TARGET_COLUMN: [valeur + h for h in range(heures)],
|
||||
"temperature_celsius": 15.0,
|
||||
"humidity_percent": 50.0,
|
||||
"solar_irradiance_wm2": 0.0,
|
||||
"is_working_hours": True,
|
||||
"site_type": "office",
|
||||
"capacity_kw": 100.0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FakeBooster:
|
||||
def __init__(self, valeur: float = 42.0) -> None:
|
||||
self.valeur = valeur
|
||||
self.appels: list[int] = []
|
||||
|
||||
def predict(self, X: Any) -> list[float]:
|
||||
self.appels.append(len(X))
|
||||
return [self.valeur] * len(X)
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self) -> None:
|
||||
self.appels: list[tuple[Any, Any]] = []
|
||||
|
||||
def execute(self, statement: Any, parameters: Any = None) -> None:
|
||||
self.appels.append((statement, parameters))
|
||||
|
||||
|
||||
def test_build_scoring_frame_adds_one_row_per_site_one_hour_after_the_last_reading() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
recent = pd.concat(
|
||||
[
|
||||
make_recent("site-a", heures=200, depart=depart),
|
||||
make_recent("site-b", heures=200, depart=depart),
|
||||
],
|
||||
ignore_index=True,
|
||||
)
|
||||
|
||||
scoring_frame = build_scoring_frame(recent)
|
||||
|
||||
assert set(scoring_frame["site_id"]) == {"site-a", "site-b"}
|
||||
derniere_lecture = depart + timedelta(hours=199)
|
||||
assert (scoring_frame["timestamp"] == derniere_lecture + timedelta(hours=1)).all()
|
||||
|
||||
|
||||
def test_build_scoring_frame_computes_lags_from_real_history() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
recent = make_recent("site-a", heures=200, depart=depart)
|
||||
|
||||
scoring_frame = build_scoring_frame(recent)
|
||||
|
||||
ligne = scoring_frame.iloc[0]
|
||||
# La cible future n'existe pas : le lag d'1h doit valoir la toute derniere valeur reelle.
|
||||
assert ligne[f"{TARGET_COLUMN}_lag_1h"] == recent[TARGET_COLUMN].iloc[-1]
|
||||
|
||||
|
||||
def test_build_scoring_frame_flags_insufficient_history_under_168_hours() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
recent = make_recent("site-a", heures=100, depart=depart)
|
||||
|
||||
scoring_frame = build_scoring_frame(recent)
|
||||
|
||||
assert pd.isna(scoring_frame.iloc[0][LAG_168H_COLUMN])
|
||||
|
||||
|
||||
def test_build_scoring_frame_accepts_a_full_week_of_history() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
recent = make_recent("site-a", heures=169, depart=depart)
|
||||
|
||||
scoring_frame = build_scoring_frame(recent)
|
||||
|
||||
assert not pd.isna(scoring_frame.iloc[0][LAG_168H_COLUMN])
|
||||
|
||||
|
||||
def test_build_scoring_frame_filters_to_a_single_site() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
recent = pd.concat(
|
||||
[
|
||||
make_recent("site-a", heures=200, depart=depart),
|
||||
make_recent("site-b", heures=200, depart=depart),
|
||||
],
|
||||
ignore_index=True,
|
||||
)
|
||||
|
||||
scoring_frame = build_scoring_frame(recent, site_id="site-a")
|
||||
|
||||
assert scoring_frame["site_id"].tolist() == ["site-a"]
|
||||
|
||||
|
||||
def test_build_scoring_frame_returns_empty_when_there_is_no_recent_reading() -> None:
|
||||
recent = make_recent("site-a", heures=0, depart=datetime(2026, 1, 1, tzinfo=UTC))
|
||||
|
||||
scoring_frame = build_scoring_frame(recent)
|
||||
|
||||
assert scoring_frame.empty
|
||||
|
||||
|
||||
def target_at_for(depart: datetime, heures: int) -> datetime:
|
||||
"""`target_at` que produira `build_scoring_frame` pour ce jeu synthetique (derniere lecture
|
||||
+ 1h) : l'utiliser comme `instant` donne un age d'1h, largement sous le seuil de peremption,
|
||||
pour les tests qui ne visent pas ce filtre."""
|
||||
return depart + timedelta(hours=heures)
|
||||
|
||||
|
||||
def test_score_marks_insufficient_history_without_calling_the_model() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
scoring_frame = build_scoring_frame(make_recent("site-a", heures=100, depart=depart))
|
||||
booster = FakeBooster()
|
||||
|
||||
resultats = score(
|
||||
booster, # type: ignore[arg-type]
|
||||
scoring_frame,
|
||||
instant=target_at_for(depart, 100),
|
||||
)
|
||||
|
||||
assert resultats == [
|
||||
ScoredSite(
|
||||
site_id="site-a",
|
||||
target_at=resultats[0].target_at,
|
||||
status="insufficient_data",
|
||||
predicted_value=None,
|
||||
failure_reason=resultats[0].failure_reason,
|
||||
)
|
||||
]
|
||||
assert booster.appels == []
|
||||
|
||||
|
||||
def test_score_predicts_when_history_is_sufficient() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
|
||||
booster = FakeBooster(valeur=99.5)
|
||||
|
||||
resultats = score(
|
||||
booster, # type: ignore[arg-type]
|
||||
scoring_frame,
|
||||
instant=target_at_for(depart, 200),
|
||||
)
|
||||
|
||||
assert len(resultats) == 1
|
||||
assert resultats[0].status == "available"
|
||||
assert resultats[0].predicted_value == 99.5
|
||||
assert resultats[0].failure_reason is None
|
||||
assert booster.appels == [1]
|
||||
|
||||
|
||||
def test_score_marks_a_stale_site_as_insufficient_data_without_calling_the_model() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
# Historique largement suffisant (168h+), mais l'instant de reference est loin apres la
|
||||
# derniere lecture : la fraicheur doit primer sur la disponibilite de l'historique.
|
||||
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
|
||||
instant = target_at_for(depart, 200) + MAX_STALENESS + timedelta(hours=1)
|
||||
booster = FakeBooster()
|
||||
|
||||
resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type]
|
||||
|
||||
assert len(resultats) == 1
|
||||
assert resultats[0].status == "insufficient_data"
|
||||
assert resultats[0].predicted_value is None
|
||||
assert "vieille" in (resultats[0].failure_reason or "")
|
||||
assert booster.appels == []
|
||||
|
||||
|
||||
def test_score_accepts_a_reading_exactly_at_the_staleness_threshold() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
|
||||
# `target_at_for(...)` donne deja un age d'1h (cf. sa docstring) : retrancher cette heure
|
||||
# pour retomber exactement sur le seuil, ni en dessous ni au dessus.
|
||||
instant = target_at_for(depart, 200) + MAX_STALENESS - timedelta(hours=1)
|
||||
booster = FakeBooster(valeur=12.0)
|
||||
|
||||
resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type]
|
||||
|
||||
assert resultats[0].status == "available"
|
||||
assert booster.appels == [1]
|
||||
|
||||
|
||||
def test_write_predictions_does_nothing_when_there_is_nothing_to_write() -> None:
|
||||
connection = FakeConnection()
|
||||
|
||||
write_predictions(connection, [], reference="lightgbm-test") # type: ignore[arg-type]
|
||||
|
||||
assert connection.appels == []
|
||||
|
||||
|
||||
def test_write_predictions_sends_one_row_per_result() -> None:
|
||||
connection = FakeConnection()
|
||||
resultats = [
|
||||
ScoredSite("site-a", datetime(2026, 1, 1, tzinfo=UTC), "available", 42.0, None),
|
||||
ScoredSite(
|
||||
"site-b",
|
||||
datetime(2026, 1, 1, tzinfo=UTC),
|
||||
"insufficient_data",
|
||||
None,
|
||||
"pas assez d'historique",
|
||||
),
|
||||
]
|
||||
|
||||
write_predictions(connection, resultats, reference="lightgbm-test") # type: ignore[arg-type]
|
||||
|
||||
assert len(connection.appels) == 1
|
||||
_, lignes = connection.appels[0]
|
||||
assert len(lignes) == 2
|
||||
assert lignes[0]["model_reference"] == "lightgbm-test"
|
||||
assert lignes[0]["target_metric"] == "consumption_kwh"
|
||||
assert lignes[0]["period_minutes"] == 60
|
||||
|
||||
|
||||
def test_model_reference_is_stable_for_the_same_file_content(tmp_path: Path) -> None:
|
||||
model_path = tmp_path / "model.txt"
|
||||
model_path.write_bytes(b"contenu-du-modele")
|
||||
|
||||
assert model_reference(model_path) == model_reference(model_path)
|
||||
|
||||
|
||||
def test_model_reference_changes_with_the_file_content(tmp_path: Path) -> None:
|
||||
premier = tmp_path / "model-a.txt"
|
||||
premier.write_bytes(b"version-1")
|
||||
second = tmp_path / "model-b.txt"
|
||||
second.write_bytes(b"version-2")
|
||||
|
||||
assert model_reference(premier) != model_reference(second)
|
||||
|
||||
|
||||
def test_run_scoring_in_csv_mode_scores_without_touching_a_database(tmp_path: Path) -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
frame = pd.concat(
|
||||
[
|
||||
make_recent("site-a", heures=400, depart=depart),
|
||||
make_recent("site-b", heures=400, depart=depart),
|
||||
],
|
||||
ignore_index=True,
|
||||
)
|
||||
csv_path = tmp_path / "recent.csv"
|
||||
frame.to_csv(csv_path, index=False)
|
||||
|
||||
model_path = tmp_path / "model.txt"
|
||||
model_path.write_bytes(b"peu importe le contenu pour ce test")
|
||||
|
||||
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||
monkeypatch.setattr(
|
||||
"enervision_ml.score.lgb.Booster", lambda model_file: FakeBooster(valeur=7.0)
|
||||
)
|
||||
|
||||
resultats = run_scoring(model_path=model_path, csv_path=csv_path)
|
||||
|
||||
assert {r.site_id for r in resultats} == {"site-a", "site-b"}
|
||||
assert all(r.status == "available" for r in resultats)
|
||||
assert all(r.predicted_value == 7.0 for r in resultats)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user