feat(ml,backend): implemente le service de scoring et GET /predictions (#37)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user