diff --git a/Makefile b/Makefile index 0bb1dcb..2a4b3d2 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ ML := ml .PHONY: help install install-backend install-frontend install-ml 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 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}' @@ -74,6 +74,9 @@ 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),) + docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index c544dae..c247f4c 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -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 @@ -212,6 +214,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, diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 9937467..11b2604 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -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( diff --git a/apps/backend/app/api/v1/endpoints/predictions.py b/apps/backend/app/api/v1/endpoints/predictions.py new file mode 100644 index 0000000..61a534a --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/predictions.py @@ -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) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 8c0bd9e..6079acf 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -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 +) diff --git a/apps/backend/app/repositories/prediction.py b/apps/backend/app/repositories/prediction.py new file mode 100644 index 0000000..f79311a --- /dev/null +++ b/apps/backend/app/repositories/prediction.py @@ -0,0 +1,28 @@ +from collections.abc import Sequence + +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 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() diff --git a/apps/backend/app/schemas/prediction.py b/apps/backend/app/schemas/prediction.py new file mode 100644 index 0000000..b7eeea2 --- /dev/null +++ b/apps/backend/app/schemas/prediction.py @@ -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] diff --git a/apps/backend/app/services/prediction.py b/apps/backend/app/services/prediction.py new file mode 100644 index 0000000..6235bf2 --- /dev/null +++ b/apps/backend/app/services/prediction.py @@ -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 + ) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 114215b..f7c445c 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1719,6 +1719,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 +2028,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": { @@ -2518,6 +2613,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": { @@ -2973,6 +3166,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`." } ] } diff --git a/apps/backend/tests/api/acces.py b/apps/backend/tests/api/acces.py index 0b2864f..e3b641b 100644 --- a/apps/backend/tests/api/acces.py +++ b/apps/backend/tests/api/acces.py @@ -53,6 +53,7 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = { ("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR, ("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, diff --git a/apps/backend/tests/api/test_predictions.py b/apps/backend/tests/api/test_predictions.py new file mode 100644 index 0000000..184afc6 --- /dev/null +++ b/apps/backend/tests/api/test_predictions.py @@ -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 diff --git a/apps/backend/tests/repositories/test_prediction.py b/apps/backend/tests/repositories/test_prediction.py new file mode 100644 index 0000000..2be7ddd --- /dev/null +++ b/apps/backend/tests/repositories/test_prediction.py @@ -0,0 +1,92 @@ +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_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 == [] diff --git a/apps/backend/tests/services/test_prediction.py b/apps/backend/tests/services/test_prediction.py new file mode 100644 index 0000000..a402b24 --- /dev/null +++ b/apps/backend/tests/services/test_prediction.py @@ -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 diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts index 5d6e343..4483313 100644 --- a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts @@ -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: [] }); + }); }); diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts index 58287ff..7c48936 100644 --- a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts @@ -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); }; diff --git a/apps/frontend/src/app/core/services/predictions.service.spec.ts b/apps/frontend/src/app/core/services/predictions.service.spec.ts new file mode 100644 index 0000000..b3aaf7e --- /dev/null +++ b/apps/frontend/src/app/core/services/predictions.service.spec.ts @@ -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); + }); +}); diff --git a/apps/frontend/src/app/core/services/predictions.service.ts b/apps/frontend/src/app/core/services/predictions.service.ts new file mode 100644 index 0000000..4245f7e --- /dev/null +++ b/apps/frontend/src/app/core/services/predictions.service.ts @@ -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(`${environment.apiUrl}/predictions`); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 08b015e..bd5f927 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -21,7 +21,13 @@ - @if (error(); as message) { + @if (statsError(); as message) { + + } + @if (alertsError(); as message) { + + } + @if (predictionsError(); as message) { } @@ -72,4 +78,33 @@ } + + @if (predictions().length > 0) { +
+

Prévisions de consommation

+ +
+ } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 06adf01..8112b51 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -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); +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 1f285b3..5d716ae 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -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'); + }); }); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index e9f9792..3671919 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -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 = { 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 = { + 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 = { 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(null); alerts = signal([]); - error = signal(null); + predictions = signal([]); + + // 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(null); + alertsError = signal(null); + predictionsError = signal(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 { - this.error.set(UNAVAILABLE_MESSAGE); + private reportUnavailable(target: WritableSignal): Observable { + target.set(UNAVAILABLE_MESSAGE); return EMPTY; } } diff --git a/apps/frontend/src/app/shared/models/prediction.model.ts b/apps/frontend/src/app/shared/models/prediction.model.ts new file mode 100644 index 0000000..50d726a --- /dev/null +++ b/apps/frontend/src/app/shared/models/prediction.model.ts @@ -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[]; +} diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index e2b68b1..a650a1e 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -74,10 +74,10 @@ 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`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | | 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 | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index a224918..09d3b3d 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, sensors"] + ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, readings, sensors, predictions"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, SensorService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading"] + sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, ReadingService, SensorService, PredictionService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading, prediction"] md["models
10 tables"] db[("PostgreSQL")] @@ -149,6 +149,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | 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 +164,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 +182,18 @@ 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. + `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 @@ -269,7 +282,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 diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index de73599..fb10f92 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -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 diff --git a/ml/README.md b/ml/README.md index 4d69363..c7814fe 100644 --- a/ml/README.md +++ b/ml/README.md @@ -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. diff --git a/ml/enervision_ml/data.py b/ml/enervision_ml/data.py index e7b5853..f8e7bf9 100644 --- a/ml/enervision_ml/data.py +++ b/ml/enervision_ml/data.py @@ -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 diff --git a/ml/enervision_ml/score.py b/ml/enervision_ml/score.py new file mode 100644 index 0000000..c0b7e3f --- /dev/null +++ b/ml/enervision_ml/score.py @@ -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() diff --git a/ml/pyproject.toml b/ml/pyproject.toml index b3c368b..1589c92 100644 --- a/ml/pyproject.toml +++ b/ml/pyproject.toml @@ -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"] diff --git a/ml/tests/test_data.py b/ml/tests/test_data.py new file mode 100644 index 0000000..0aa42a1 --- /dev/null +++ b/ml/tests/test_data.py @@ -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]) diff --git a/ml/tests/test_score.py b/ml/tests/test_score.py new file mode 100644 index 0000000..fb3e015 --- /dev/null +++ b/ml/tests/test_score.py @@ -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)