Merge branch 'dev' into feat/deploy
This commit is contained in:
@@ -16,6 +16,10 @@ WORKDIR /app
|
||||
COPY uv.lock pyproject.toml /app/
|
||||
RUN uv sync --locked --no-install-project --no-dev
|
||||
|
||||
# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le
|
||||
# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire
|
||||
# (backend hatchling), donc de retirer `--no-build` de l'etape ci-dessus, qui garantit que
|
||||
# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541).
|
||||
COPY . /app
|
||||
|
||||
RUN uv sync --locked --no-dev
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""rapports de derive du modele de prevision
|
||||
|
||||
Revision ID: d3f1a2b7c904
|
||||
Revises: c0adab96238c
|
||||
Create Date: 2026-09-22 14:40:00.000000
|
||||
|
||||
`site_id` est nullable, et c'est le coeur du schema : une ligne par site, plus une ligne
|
||||
globale tous sites confondus, que `NULL` designe. Un seul site qui derive est invisible dans
|
||||
une moyenne d'ensemble, et une derive d'ensemble sans rupture par site signale un changement
|
||||
de modele ou de saison, pas une panne.
|
||||
|
||||
L'unicite passe par un index a `coalesce` et non par une `UniqueConstraint` : deux lignes
|
||||
globales successives ont toutes deux `site_id` a NULL, et NULL n'est egal a aucune valeur, pas
|
||||
meme a lui-meme. Meme forme que `uq_reading_source`.
|
||||
|
||||
Les trois `CHECK` sont portees par la base, comme `ck_prediction_status` : un verdict sans
|
||||
motif, ou un statut inconnu, ne doit pas dependre de la vigilance de l'appelant.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "d3f1a2b7c904"
|
||||
down_revision: str | Sequence[str] | None = "c0adab96238c"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"drift_report",
|
||||
sa.Column("drift_report_id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column(
|
||||
"computed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("site_id", sa.Text(), nullable=True),
|
||||
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("reference_start", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("reference_end", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("n_observations", sa.Integer(), nullable=False),
|
||||
sa.Column("mae", sa.Double(), nullable=True),
|
||||
sa.Column("mape", sa.Double(), nullable=True),
|
||||
sa.Column("bias", sa.Double(), nullable=True),
|
||||
sa.Column("reference_mae", sa.Double(), nullable=True),
|
||||
sa.Column("coverage_ratio", sa.Double(), nullable=True),
|
||||
sa.Column("insufficient_data_ratio", sa.Double(), nullable=True),
|
||||
sa.Column("model_references", postgresql.ARRAY(sa.Text()), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('stable', 'derive', 'indetermine')", name="ck_drift_report_status"
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status = 'stable' OR reason IS NOT NULL", name="ck_drift_report_reason"
|
||||
),
|
||||
sa.CheckConstraint("n_observations >= 0", name="ck_drift_report_observations"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["site_id"], ["site.site_id"], name="fk_drift_report_site", ondelete="RESTRICT"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("drift_report_id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_drift_report_site_computed", "drift_report", ["site_id", "computed_at"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"uq_drift_report_window",
|
||||
"drift_report",
|
||||
["window_end", sa.literal_column("coalesce(site_id, '')")],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("drift_report")
|
||||
@@ -24,6 +24,7 @@ from app.core.security import decode_access_token as decode_token
|
||||
from app.db.session import get_session
|
||||
from app.repositories.alert import AlertRepository
|
||||
from app.repositories.audit_log import AuditLogRepository
|
||||
from app.repositories.drift import DriftRepository
|
||||
from app.repositories.login_attempt import LoginAttemptRepository
|
||||
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
|
||||
from app.repositories.password_reset_token import PasswordResetTokenRepository
|
||||
@@ -35,6 +36,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.drift import DriftService
|
||||
from app.services.prediction import PredictionService
|
||||
from app.services.reading import ReadingService
|
||||
from app.services.recommendation import RecommendationService
|
||||
@@ -48,7 +50,9 @@ SettingsDep = Annotated[Settings, Depends(get_settings)]
|
||||
|
||||
CODE_CHANGEMENT_REQUIS = "password_change_required"
|
||||
|
||||
_porteur = HTTPBearer(auto_error=False, scheme_name="Jeton d'accès")
|
||||
# Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un nom
|
||||
# de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
|
||||
_porteur = HTTPBearer(auto_error=False, scheme_name="JetonAcces")
|
||||
CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)]
|
||||
|
||||
|
||||
@@ -232,6 +236,13 @@ def get_prediction_service(session: SessionDep) -> PredictionService:
|
||||
PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)]
|
||||
|
||||
|
||||
def get_drift_service(session: SessionDep) -> DriftService:
|
||||
return DriftService(DriftRepository(session))
|
||||
|
||||
|
||||
DriftServiceDep = Annotated[DriftService, Depends(get_drift_service)]
|
||||
|
||||
|
||||
async def get_current_principal(
|
||||
credentials: CredentialsDep,
|
||||
session: SessionDep,
|
||||
|
||||
@@ -90,11 +90,21 @@ TAGS: Final[list[dict[str, Any]]] = [
|
||||
"de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "monitoring",
|
||||
"description": (
|
||||
"Surveillance de la dérive du modèle : écart entre les prévisions déjà écrites et "
|
||||
"les lectures réellement arrivées, par site et tous sites confondus. Réservé à "
|
||||
"partir du rôle `operateur`, qui agit sur un pipeline dégradé."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
cookie_de_rafraichissement = APIKeyCookie(
|
||||
name=REFRESH_COOKIE_DEFAUT,
|
||||
scheme_name="Cookie de rafraîchissement",
|
||||
# Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un
|
||||
# nom de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
|
||||
scheme_name="CookieRafraichissement",
|
||||
description=(
|
||||
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
|
||||
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
|
||||
@@ -153,6 +163,17 @@ REPONSES_ADMIN: Final[Reponses] = {
|
||||
},
|
||||
}
|
||||
|
||||
REPONSES_OPERATEUR: Final[Reponses] = {
|
||||
**REPONSES_AUTHENTIFIEES,
|
||||
403: {
|
||||
"model": ErrorResponse,
|
||||
"description": (
|
||||
"Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut "
|
||||
"`password_change_required`."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits
|
||||
# insuffisants, seulement pour le mot de passe provisoire.
|
||||
REPONSES_LECTEUR: Final[Reponses] = {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import DriftServiceDep, OperateurDep
|
||||
from app.api.openapi import REPONSE_VALIDATION
|
||||
from app.schemas.drift import DriftReportResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/drift",
|
||||
response_model=list[DriftReportResponse],
|
||||
summary="Dernier rapport de dérive par site, plus la ligne globale",
|
||||
responses=REPONSE_VALIDATION,
|
||||
)
|
||||
async def get_drift(
|
||||
_: OperateurDep, service: DriftServiceDep, site_id: str | None = None
|
||||
) -> list[DriftReportResponse]:
|
||||
rapports = await service.derniers(site_id=site_id)
|
||||
return [DriftReportResponse.model_validate(rapport) for rapport in rapports]
|
||||
@@ -1,10 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
||||
from app.api.openapi import (
|
||||
REPONSE_SERVEUR,
|
||||
REPONSES_ADMIN,
|
||||
REPONSES_LECTEUR,
|
||||
REPONSES_OPERATEUR,
|
||||
)
|
||||
from app.api.v1.endpoints import (
|
||||
alerts,
|
||||
auth,
|
||||
health,
|
||||
monitoring,
|
||||
predictions,
|
||||
readings,
|
||||
recommendations,
|
||||
@@ -38,3 +44,6 @@ api_router.include_router(
|
||||
api_router.include_router(
|
||||
predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR
|
||||
)
|
||||
api_router.include_router(
|
||||
monitoring.router, prefix="/monitoring", tags=["monitoring"], responses=REPONSES_OPERATEUR
|
||||
)
|
||||
|
||||
@@ -45,15 +45,19 @@ CAPACITY_BOUNDS = (0.0, 100_000.0)
|
||||
def create_mock_api_client() -> httpx.AsyncClient:
|
||||
settings = get_settings()
|
||||
|
||||
if settings.mock_api_username is None or settings.mock_api_password is None:
|
||||
username = settings.mock_api_username
|
||||
password = (
|
||||
settings.mock_api_password.get_secret_value()
|
||||
if settings.mock_api_password is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if not username or not username.strip() or not password or not password.strip():
|
||||
raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.")
|
||||
|
||||
return httpx.AsyncClient(
|
||||
base_url=settings.mock_api_base_url.rstrip("/"),
|
||||
auth=(
|
||||
settings.mock_api_username,
|
||||
settings.mock_api_password.get_secret_value(),
|
||||
),
|
||||
auth=(username, password),
|
||||
timeout=settings.mock_api_timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
# --autogenerate`, qui générerait alors un drop de sa table.
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
|
||||
from app.models.energy import (
|
||||
Alert,
|
||||
Dataset,
|
||||
DriftReport,
|
||||
Prediction,
|
||||
Reading,
|
||||
Recommendation,
|
||||
Site,
|
||||
)
|
||||
from app.models.login_attempt import LoginAttempt
|
||||
from app.models.password_reset_attempt import PasswordResetAttempt
|
||||
from app.models.password_reset_token import PasswordResetToken
|
||||
@@ -14,6 +22,7 @@ __all__ = [
|
||||
"AppUser",
|
||||
"AuditLog",
|
||||
"Dataset",
|
||||
"DriftReport",
|
||||
"LoginAttempt",
|
||||
"PasswordResetAttempt",
|
||||
"PasswordResetToken",
|
||||
|
||||
@@ -208,3 +208,49 @@ class Recommendation(Base):
|
||||
explanation: Mapped[str] = mapped_column(Text)
|
||||
rule_reference: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class DriftReport(Base):
|
||||
__tablename__ = "drift_report"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('stable', 'derive', 'indetermine')", name="ck_drift_report_status"
|
||||
),
|
||||
CheckConstraint("status = 'stable' OR reason IS NOT NULL", name="ck_drift_report_reason"),
|
||||
CheckConstraint("n_observations >= 0", name="ck_drift_report_observations"),
|
||||
Index("ix_drift_report_site_computed", "site_id", "computed_at"),
|
||||
)
|
||||
|
||||
drift_report_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
computed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
# `NULL` porte la ligne globale, tous sites confondus : une derive d'ensemble et la derive
|
||||
# d'un seul site ne se lisent pas dans le meme chiffre.
|
||||
site_id: Mapped[str | None] = mapped_column(
|
||||
Text, ForeignKey("site.site_id", name="fk_drift_report_site", ondelete="RESTRICT")
|
||||
)
|
||||
window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
window_end: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
reference_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
reference_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
n_observations: Mapped[int] = mapped_column(Integer)
|
||||
mae: Mapped[float | None] = mapped_column(Double)
|
||||
mape: Mapped[float | None] = mapped_column(Double)
|
||||
bias: Mapped[float | None] = mapped_column(Double)
|
||||
reference_mae: Mapped[float | None] = mapped_column(Double)
|
||||
coverage_ratio: Mapped[float | None] = mapped_column(Double)
|
||||
insufficient_data_ratio: Mapped[float | None] = mapped_column(Double)
|
||||
model_references: Mapped[list[str]] = mapped_column(ARRAY(Text))
|
||||
status: Mapped[str] = mapped_column(Text)
|
||||
reason: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
# Piège : une `UniqueConstraint` ne dédoublonnerait pas les lignes globales, dont `site_id` est
|
||||
# NULL et qu'aucune n'est égale à une autre. Même forme que `uq_reading_source`.
|
||||
Index(
|
||||
"uq_drift_report_window",
|
||||
DriftReport.window_end,
|
||||
func.coalesce(DriftReport.site_id, text("''")),
|
||||
unique=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Surveillance de dérive du modèle de prévision (EC06, issue #45) : même gabarit que
|
||||
# `app.detection.internal_alerts`, ordonnancé par le DAG `derive`.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import get_session_factory
|
||||
from app.repositories.drift import DriftRepository, NouveauRapportDerive
|
||||
from app.services.drift import STATUT_DERIVE, DriftService, Seuils
|
||||
|
||||
|
||||
async def run_drift(
|
||||
*, now: datetime | None = None, site_id: str | None = None, seuils: Seuils | None = None
|
||||
) -> list[NouveauRapportDerive]:
|
||||
"""Calcule les rapports de la fenêtre et les enregistre. Rend ce qui a été calculé, que la
|
||||
ligne ait été écrite ou ignorée par l'index d'idempotence."""
|
||||
async with get_session_factory()() as session:
|
||||
depot = DriftRepository(session)
|
||||
rapports = await DriftService(depot, seuils=seuils).evaluate(now=now, site_id=site_id)
|
||||
await depot.enregistre(rapports)
|
||||
await session.commit()
|
||||
return rapports
|
||||
|
||||
|
||||
def _parse_instant(valeur: str) -> datetime:
|
||||
instant = datetime.fromisoformat(valeur)
|
||||
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
defauts = Seuils()
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m app.monitoring.drift",
|
||||
description="Surveillance de dérive du modèle de prévision EnerVision",
|
||||
)
|
||||
parser.add_argument("--site-id", default=None, help="Limite le calcul à un seul site.")
|
||||
parser.add_argument(
|
||||
"--now",
|
||||
type=_parse_instant,
|
||||
default=None,
|
||||
help=(
|
||||
"Instant de référence (ISO 8601, UTC si le fuseau est omis). Défaut : l'heure courante."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--window-hours",
|
||||
type=int,
|
||||
default=int(defauts.fenetre.total_seconds() // 3600),
|
||||
help="Durée de la fenêtre récente, et de la fenêtre de référence qui la précède.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--grace-hours",
|
||||
type=int,
|
||||
default=int(defauts.grace.total_seconds() // 3600),
|
||||
help="Délai laissé à l'ingestion avant qu'une prévision soit jugée vérifiable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-observations",
|
||||
type=int,
|
||||
default=defauts.min_observations,
|
||||
help="En deçà, le verdict est `indetermine` plutôt qu'un chiffre trompeur.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bias-threshold",
|
||||
type=float,
|
||||
default=defauts.seuil_biais,
|
||||
help=(
|
||||
"Biais absolu en kWh au-delà duquel le verdict bascule en dérive. "
|
||||
"Zéro, le défaut, laisse le biais informatif : voir l'ADR 0013."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fail-on-drift",
|
||||
action="store_true",
|
||||
help="Sort en code non nul si une dérive est constatée, pour que la tâche rougisse.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def seuils_depuis(args: argparse.Namespace) -> Seuils:
|
||||
return Seuils(
|
||||
fenetre=timedelta(hours=args.window_hours),
|
||||
grace=timedelta(hours=args.grace_hours),
|
||||
min_observations=args.min_observations,
|
||||
seuil_biais=args.bias_threshold,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
# Échoue tôt si `APP_SECRET_KEY`/`DATABASE_URL` manquent, avant toute requête à la base.
|
||||
get_settings()
|
||||
rapports = asyncio.run(
|
||||
run_drift(now=args.now, site_id=args.site_id, seuils=seuils_depuis(args))
|
||||
)
|
||||
|
||||
for rapport in rapports:
|
||||
cible = rapport.site_id or "TOUS SITES"
|
||||
mae = f"{rapport.mae:.2f}" if rapport.mae is not None else "-"
|
||||
print(
|
||||
f"{cible} : {rapport.status}, MAE {mae} kWh sur {rapport.n_observations} prévision(s)"
|
||||
f"{' : ' + rapport.reason if rapport.reason else ''}"
|
||||
)
|
||||
|
||||
derive = any(rapport.status == STATUT_DERIVE for rapport in rapports)
|
||||
return 1 if derive and args.fail_on_drift else 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
@@ -6,6 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Alert
|
||||
|
||||
# Douze colonnes par alerte, contre quatre pour une recommandation : le plafond asyncpg de
|
||||
# 32 767 parametres tombe a 2 730 lignes, d'ou un lot plus petit que `recommendation.py`.
|
||||
TAILLE_DE_LOT = 1000
|
||||
|
||||
|
||||
class AlertRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
@@ -44,12 +48,17 @@ class AlertRepository:
|
||||
}
|
||||
for alerte in alerts
|
||||
]
|
||||
requete = (
|
||||
insert(Alert)
|
||||
.values(valeurs)
|
||||
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
|
||||
.returning(Alert)
|
||||
)
|
||||
resultat = await self._session.execute(requete)
|
||||
creees: list[Alert] = []
|
||||
# Piège : asyncpg plafonne une requête à 32 767 paramètres. Une détection sur une fenêtre
|
||||
# chargée dépasse ce seuil, et l'`INSERT` d'un seul tenant échouerait.
|
||||
for debut in range(0, len(valeurs), TAILLE_DE_LOT):
|
||||
requete = (
|
||||
insert(Alert)
|
||||
.values(valeurs[debut : debut + TAILLE_DE_LOT])
|
||||
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
|
||||
.returning(Alert)
|
||||
)
|
||||
resultat = await self._session.execute(requete)
|
||||
creees.extend(resultat.scalars().all())
|
||||
await self._session.flush()
|
||||
return resultat.scalars().all()
|
||||
return creees
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Piège : deux dédoublonnages, pas un - DriftRepository.paires()
|
||||
|
||||
`prediction` n'a pas d'unicité sur `(site_id, target_at)` : chaque run de scoring empile une
|
||||
ligne de plus. `uq_reading_source` autorise de son côté deux lectures au même instant quand la
|
||||
`source` diffère. Joindre les deux tables sans `DISTINCT ON` des deux côtés compterait donc la
|
||||
même heure plusieurs fois, et la moyenne d'erreur pèserait ces sites en double.
|
||||
|
||||
On retient la prédiction du run le plus récent, celle que sert `GET /api/v1/predictions`, avec
|
||||
`prediction_id` en départage : `created_at` vaut l'heure de début de transaction et ne
|
||||
distingue pas deux lignes du même run.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Subquery, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import DriftReport, Prediction, Reading
|
||||
|
||||
TARGET_METRIC = "consumption_kwh"
|
||||
STATUT_DISPONIBLE = "available"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PaireDerive:
|
||||
site_id: str
|
||||
target_at: datetime
|
||||
predicted_value: float
|
||||
actual_value: float
|
||||
model_reference: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NouveauRapportDerive:
|
||||
site_id: str | None
|
||||
window_start: datetime
|
||||
window_end: datetime
|
||||
reference_start: datetime | None
|
||||
reference_end: datetime | None
|
||||
n_observations: int
|
||||
mae: float | None
|
||||
mape: float | None
|
||||
bias: float | None
|
||||
reference_mae: float | None
|
||||
coverage_ratio: float | None
|
||||
insufficient_data_ratio: float | None
|
||||
model_references: list[str]
|
||||
status: str
|
||||
reason: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ComptageStatut:
|
||||
site_id: str
|
||||
status: str
|
||||
nombre: int
|
||||
|
||||
|
||||
def _predictions_retenues(*, debut: datetime, fin: datetime, site_id: str | None) -> Subquery:
|
||||
requete = (
|
||||
select(
|
||||
Prediction.site_id,
|
||||
Prediction.target_at,
|
||||
Prediction.predicted_value,
|
||||
Prediction.model_reference,
|
||||
Prediction.status,
|
||||
)
|
||||
.distinct(Prediction.site_id, Prediction.target_at)
|
||||
.where(
|
||||
Prediction.target_metric == TARGET_METRIC,
|
||||
Prediction.target_at >= debut,
|
||||
Prediction.target_at < fin,
|
||||
)
|
||||
.order_by(Prediction.site_id, Prediction.target_at, Prediction.prediction_id.desc())
|
||||
)
|
||||
if site_id is not None:
|
||||
requete = requete.where(Prediction.site_id == site_id)
|
||||
return requete.subquery()
|
||||
|
||||
|
||||
def _lectures_retenues(*, debut: datetime, fin: datetime, site_id: str | None) -> Subquery:
|
||||
requete = (
|
||||
select(Reading.site_id, Reading.timestamp, Reading.consumption_kwh)
|
||||
.distinct(Reading.site_id, Reading.timestamp)
|
||||
.where(
|
||||
Reading.timestamp >= debut,
|
||||
Reading.timestamp < fin,
|
||||
Reading.consumption_kwh.is_not(None),
|
||||
)
|
||||
.order_by(Reading.site_id, Reading.timestamp, Reading.reading_id.desc())
|
||||
)
|
||||
if site_id is not None:
|
||||
requete = requete.where(Reading.site_id == site_id)
|
||||
return requete.subquery()
|
||||
|
||||
|
||||
class DriftRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def paires(
|
||||
self, *, debut: datetime, fin: datetime, site_id: str | None = None
|
||||
) -> Sequence[PaireDerive]:
|
||||
predictions = _predictions_retenues(debut=debut, fin=fin, site_id=site_id)
|
||||
lectures = _lectures_retenues(debut=debut, fin=fin, site_id=site_id)
|
||||
requete = (
|
||||
select(
|
||||
predictions.c.site_id,
|
||||
predictions.c.target_at,
|
||||
predictions.c.predicted_value,
|
||||
lectures.c.consumption_kwh,
|
||||
predictions.c.model_reference,
|
||||
)
|
||||
.select_from(predictions)
|
||||
.join(
|
||||
lectures,
|
||||
(lectures.c.site_id == predictions.c.site_id)
|
||||
& (lectures.c.timestamp == predictions.c.target_at),
|
||||
)
|
||||
.where(predictions.c.status == STATUT_DISPONIBLE)
|
||||
.order_by(predictions.c.site_id, predictions.c.target_at)
|
||||
)
|
||||
|
||||
lignes = await self._session.execute(requete)
|
||||
return [
|
||||
PaireDerive(
|
||||
site_id=ligne[0],
|
||||
target_at=ligne[1],
|
||||
predicted_value=ligne[2],
|
||||
actual_value=ligne[3],
|
||||
model_reference=ligne[4],
|
||||
)
|
||||
for ligne in lignes
|
||||
]
|
||||
|
||||
async def comptages(
|
||||
self, *, debut: datetime, fin: datetime, site_id: str | None = None
|
||||
) -> Sequence[ComptageStatut]:
|
||||
predictions = _predictions_retenues(debut=debut, fin=fin, site_id=site_id)
|
||||
requete = (
|
||||
select(predictions.c.site_id, predictions.c.status, func.count())
|
||||
.select_from(predictions)
|
||||
.group_by(predictions.c.site_id, predictions.c.status)
|
||||
)
|
||||
|
||||
lignes = await self._session.execute(requete)
|
||||
return [
|
||||
ComptageStatut(site_id=ligne[0], status=ligne[1], nombre=ligne[2]) for ligne in lignes
|
||||
]
|
||||
|
||||
# Pourquoi : l'idempotence est déléguée à `uq_drift_report_window` plutôt qu'à une lecture
|
||||
# préalable, comme pour les recommandations. Rejouer la commande sur la même fenêtre ne
|
||||
# duplique donc rien.
|
||||
async def enregistre(self, rapports: Sequence[NouveauRapportDerive]) -> int:
|
||||
if not rapports:
|
||||
return 0
|
||||
|
||||
valeurs = [asdict(rapport) for rapport in rapports]
|
||||
requete = (
|
||||
insert(DriftReport)
|
||||
.values(valeurs)
|
||||
.on_conflict_do_nothing(
|
||||
index_elements=[DriftReport.window_end, func.coalesce(DriftReport.site_id, "")]
|
||||
)
|
||||
.returning(DriftReport.drift_report_id)
|
||||
)
|
||||
return len((await self._session.scalars(requete)).all())
|
||||
|
||||
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
|
||||
requete = (
|
||||
select(DriftReport)
|
||||
.distinct(DriftReport.site_id)
|
||||
.order_by(
|
||||
DriftReport.site_id,
|
||||
DriftReport.computed_at.desc(),
|
||||
DriftReport.drift_report_id.desc(),
|
||||
)
|
||||
)
|
||||
if site_id is not None:
|
||||
requete = requete.where(DriftReport.site_id == site_id)
|
||||
return (await self._session.scalars(requete)).all()
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class DriftStatus(StrEnum):
|
||||
STABLE = "stable"
|
||||
DERIVE = "derive"
|
||||
INDETERMINE = "indetermine"
|
||||
|
||||
|
||||
class DriftReportResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
site_id: str | None
|
||||
computed_at: datetime
|
||||
window_start: datetime
|
||||
window_end: datetime
|
||||
reference_start: datetime | None
|
||||
reference_end: datetime | None
|
||||
n_observations: int
|
||||
mae: float | None
|
||||
mape: float | None
|
||||
bias: float | None
|
||||
reference_mae: float | None
|
||||
coverage_ratio: float | None
|
||||
insufficient_data_ratio: float | None
|
||||
model_references: list[str]
|
||||
status: DriftStatus
|
||||
reason: str | None
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Contrainte : la dérive se mesure sur ce qui a déjà eu lieu - DriftService.evaluate()
|
||||
|
||||
Une prévision ne devient vérifiable que quand la lecture de son instant cible est ingérée. La
|
||||
fenêtre est donc fermée à droite par un délai de grâce : sans lui, la dernière heure ferait
|
||||
chuter le taux de couverture à chaque exécution, et le verdict dirait « dérive » alors que
|
||||
seule l'ingestion n'avait pas fini son tour.
|
||||
|
||||
La comparaison se fait entre deux fenêtres vives de même durée, pas contre la métrique de
|
||||
référence du modèle journalisée à l'entraînement. Ce ne sont pas les mêmes grandeurs :
|
||||
l'entraînement mesure un backtest où la météo de l'heure cible est connue, le scoring prévoit
|
||||
une heure future dont la météo ne l'est pas. Les comparer classerait le modèle « en dérive »
|
||||
dès le premier jour, ce qui ne prouverait rien.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.models.energy import DriftReport
|
||||
from app.repositories.drift import (
|
||||
ComptageStatut,
|
||||
DriftRepository,
|
||||
NouveauRapportDerive,
|
||||
PaireDerive,
|
||||
)
|
||||
|
||||
STATUT_STABLE = "stable"
|
||||
STATUT_DERIVE = "derive"
|
||||
STATUT_INDETERMINE = "indetermine"
|
||||
|
||||
STATUT_INSUFFISANT = "insufficient_data"
|
||||
STATUT_DISPONIBLE = "available"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Seuils:
|
||||
# 168 h, la saisonnalité hebdomadaire que le modèle apprend par son lag principal : une
|
||||
# fenêtre plus courte comparerait un week-end à une semaine ouvrée.
|
||||
fenetre: timedelta = timedelta(hours=168)
|
||||
grace: timedelta = timedelta(hours=2)
|
||||
min_observations: int = 24
|
||||
ratio_derive: float = 1.25
|
||||
mae_plancher: float = 0.0
|
||||
# Un biais se compte en kWh, donc ne se transpose pas d'un site à l'autre : zéro le désactive,
|
||||
# sans cesser de le mesurer. Réglé par `--bias-threshold`, arbitrage dans l'ADR 0013.
|
||||
seuil_biais: float = 0.0
|
||||
seuil_couverture: float = 0.8
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Metriques:
|
||||
n_observations: int
|
||||
mae: float | None
|
||||
mape: float | None
|
||||
bias: float | None
|
||||
model_references: list[str]
|
||||
|
||||
|
||||
def mesure(paires: Sequence[PaireDerive]) -> Metriques:
|
||||
if not paires:
|
||||
return Metriques(n_observations=0, mae=None, mape=None, bias=None, model_references=[])
|
||||
|
||||
ecarts = [paire.predicted_value - paire.actual_value for paire in paires]
|
||||
# Le MAPE diverge sur une consommation nulle : les sites à l'arrêt sortent de ce seul
|
||||
# rapport, jamais des autres métriques.
|
||||
ratios = [
|
||||
abs(ecart / paire.actual_value)
|
||||
for ecart, paire in zip(ecarts, paires, strict=True)
|
||||
if paire.actual_value != 0
|
||||
]
|
||||
|
||||
return Metriques(
|
||||
n_observations=len(paires),
|
||||
mae=sum(abs(ecart) for ecart in ecarts) / len(ecarts),
|
||||
mape=(sum(ratios) / len(ratios) * 100) if ratios else None,
|
||||
bias=sum(ecarts) / len(ecarts),
|
||||
model_references=sorted({paire.model_reference for paire in paires}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Verdict:
|
||||
status: str
|
||||
reason: str | None
|
||||
|
||||
|
||||
class DriftService:
|
||||
def __init__(self, depot: DriftRepository, *, seuils: Seuils | None = None) -> None:
|
||||
self._depot = depot
|
||||
self._seuils = seuils or Seuils()
|
||||
|
||||
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
|
||||
"""Ce que sert l'API : le dernier rapport de chaque site, plus la ligne globale."""
|
||||
return await self._depot.derniers(site_id=site_id)
|
||||
|
||||
async def evaluate(
|
||||
self, *, now: datetime | None = None, site_id: str | None = None
|
||||
) -> list[NouveauRapportDerive]:
|
||||
"""Une ligne par site, plus une ligne globale dont le `site_id` est nul."""
|
||||
fin = (now or datetime.now(UTC)) - self._seuils.grace
|
||||
debut = fin - self._seuils.fenetre
|
||||
reference_fin = debut
|
||||
reference_debut = reference_fin - self._seuils.fenetre
|
||||
|
||||
recentes = await self._depot.paires(debut=debut, fin=fin, site_id=site_id)
|
||||
anciennes = await self._depot.paires(
|
||||
debut=reference_debut, fin=reference_fin, site_id=site_id
|
||||
)
|
||||
comptages = await self._depot.comptages(debut=debut, fin=fin, site_id=site_id)
|
||||
|
||||
gabarit = NouveauRapportDerive(
|
||||
site_id=None,
|
||||
window_start=debut,
|
||||
window_end=fin,
|
||||
reference_start=reference_debut,
|
||||
reference_end=reference_fin,
|
||||
n_observations=0,
|
||||
mae=None,
|
||||
mape=None,
|
||||
bias=None,
|
||||
reference_mae=None,
|
||||
coverage_ratio=None,
|
||||
insufficient_data_ratio=None,
|
||||
model_references=[],
|
||||
status=STATUT_INDETERMINE,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
rapports = [
|
||||
self._rapport(
|
||||
gabarit,
|
||||
site=site,
|
||||
recentes=[p for p in recentes if p.site_id == site],
|
||||
anciennes=[p for p in anciennes if p.site_id == site],
|
||||
comptages=[c for c in comptages if c.site_id == site],
|
||||
)
|
||||
for site in sorted(
|
||||
{paire.site_id for paire in recentes} | {c.site_id for c in comptages}
|
||||
)
|
||||
]
|
||||
rapports.append(
|
||||
self._rapport(
|
||||
gabarit, site=None, recentes=recentes, anciennes=anciennes, comptages=comptages
|
||||
)
|
||||
)
|
||||
return rapports
|
||||
|
||||
def _rapport(
|
||||
self,
|
||||
gabarit: NouveauRapportDerive,
|
||||
*,
|
||||
site: str | None,
|
||||
recentes: Sequence[PaireDerive],
|
||||
anciennes: Sequence[PaireDerive],
|
||||
comptages: Sequence[ComptageStatut],
|
||||
) -> NouveauRapportDerive:
|
||||
metriques = mesure(recentes)
|
||||
reference = mesure(anciennes)
|
||||
couverture = _couverture(len(recentes), comptages)
|
||||
verdict = self._verdict(metriques, reference_mae=reference.mae, couverture=couverture)
|
||||
|
||||
return replace(
|
||||
gabarit,
|
||||
site_id=site,
|
||||
n_observations=metriques.n_observations,
|
||||
mae=metriques.mae,
|
||||
mape=metriques.mape,
|
||||
bias=metriques.bias,
|
||||
reference_mae=reference.mae,
|
||||
coverage_ratio=couverture,
|
||||
insufficient_data_ratio=_part_insuffisante(comptages),
|
||||
model_references=metriques.model_references,
|
||||
status=verdict.status,
|
||||
reason=verdict.reason,
|
||||
)
|
||||
|
||||
def _verdict(
|
||||
self, metriques: Metriques, *, reference_mae: float | None, couverture: float | None
|
||||
) -> Verdict:
|
||||
seuils = self._seuils
|
||||
if metriques.n_observations < seuils.min_observations:
|
||||
return Verdict(
|
||||
STATUT_INDETERMINE,
|
||||
f"{metriques.n_observations} prévision(s) vérifiée(s) sur la fenêtre, "
|
||||
f"minimum {seuils.min_observations}.",
|
||||
)
|
||||
|
||||
if couverture is not None and couverture < seuils.seuil_couverture:
|
||||
return Verdict(
|
||||
STATUT_DERIVE,
|
||||
f"Couverture de {couverture:.0%}, sous le seuil de {seuils.seuil_couverture:.0%} : "
|
||||
"le pipeline, pas le modèle.",
|
||||
)
|
||||
|
||||
plafond = _plafond(reference_mae, ratio=seuils.ratio_derive, plancher=seuils.mae_plancher)
|
||||
if metriques.mae is not None and plafond is not None and metriques.mae > plafond:
|
||||
return Verdict(
|
||||
STATUT_DERIVE,
|
||||
f"MAE de {metriques.mae:.2f} kWh au-delà de {plafond:.2f} kWh, "
|
||||
"seuil dérivé de la fenêtre de référence.",
|
||||
)
|
||||
|
||||
if (
|
||||
seuils.seuil_biais > 0
|
||||
and metriques.bias is not None
|
||||
and abs(metriques.bias) > seuils.seuil_biais
|
||||
):
|
||||
return Verdict(
|
||||
STATUT_DERIVE,
|
||||
f"Biais de {metriques.bias:+.2f} kWh : le modèle se trompe toujours du même côté.",
|
||||
)
|
||||
|
||||
return Verdict(STATUT_STABLE, None)
|
||||
|
||||
|
||||
def _plafond(reference_mae: float | None, *, ratio: float, plancher: float) -> float | None:
|
||||
if reference_mae is None:
|
||||
return plancher or None
|
||||
return max(plancher, reference_mae * ratio)
|
||||
|
||||
|
||||
def _couverture(apparie: int, comptages: Sequence[ComptageStatut]) -> float | None:
|
||||
"""Part des prévisions disponibles qui ont trouvé leur réalisé. Mesure l'ingestion et
|
||||
l'ordonnancement, pas la qualité du modèle."""
|
||||
disponibles = sum(c.nombre for c in comptages if c.status == STATUT_DISPONIBLE)
|
||||
return apparie / disponibles if disponibles else None
|
||||
|
||||
|
||||
def _part_insuffisante(comptages: Sequence[ComptageStatut]) -> float | None:
|
||||
total = sum(c.nombre for c in comptages)
|
||||
if not total:
|
||||
return None
|
||||
return sum(c.nombre for c in comptages if c.status == STATUT_INSUFFISANT) / total
|
||||
+288
-22
@@ -213,7 +213,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Cookie de rafraîchissement": []
|
||||
"CookieRafraichissement": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Cookie de rafraîchissement": []
|
||||
"CookieRafraichissement": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -301,7 +301,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -347,7 +347,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -423,7 +423,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -673,7 +673,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -757,7 +757,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -771,7 +771,7 @@
|
||||
"operationId": "update_user_api_v1_users__user_id__patch",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -889,7 +889,7 @@
|
||||
"operationId": "reset_password_api_v1_users__user_id__password_reset_post",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1023,7 +1023,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1037,7 +1037,7 @@
|
||||
"operationId": "get_site_api_v1_sites__site_id__get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1124,7 +1124,7 @@
|
||||
"operationId": "get_current_api_v1_sites__site_id__current_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1211,7 +1211,7 @@
|
||||
"operationId": "list_alerts_api_v1_alerts_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1361,7 +1361,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1375,7 +1375,7 @@
|
||||
"operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1462,7 +1462,7 @@
|
||||
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1588,7 +1588,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1602,7 +1602,7 @@
|
||||
"operationId": "list_readings_api_v1_readings_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
@@ -1799,7 +1799,7 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1855,10 +1855,98 @@
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
"JetonAcces": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/monitoring/drift": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"monitoring"
|
||||
],
|
||||
"summary": "Dernier rapport de dérive par site, plus la ligne globale",
|
||||
"operationId": "get_drift_api_v1_monitoring_drift_get",
|
||||
"security": [
|
||||
{
|
||||
"JetonAcces": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "site_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Site Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DriftReportResponse"
|
||||
},
|
||||
"title": "Response Get Drift Api V1 Monitoring Drift Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InternalErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -1977,6 +2065,180 @@
|
||||
],
|
||||
"title": "AlertType"
|
||||
},
|
||||
"DriftReportResponse": {
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Site Id"
|
||||
},
|
||||
"computed_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Computed At"
|
||||
},
|
||||
"window_start": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Window Start"
|
||||
},
|
||||
"window_end": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Window End"
|
||||
},
|
||||
"reference_start": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Reference Start"
|
||||
},
|
||||
"reference_end": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Reference End"
|
||||
},
|
||||
"n_observations": {
|
||||
"type": "integer",
|
||||
"title": "N Observations"
|
||||
},
|
||||
"mae": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Mae"
|
||||
},
|
||||
"mape": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Mape"
|
||||
},
|
||||
"bias": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Bias"
|
||||
},
|
||||
"reference_mae": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Reference Mae"
|
||||
},
|
||||
"coverage_ratio": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Coverage Ratio"
|
||||
},
|
||||
"insufficient_data_ratio": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Insufficient Data Ratio"
|
||||
},
|
||||
"model_references": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Model References"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/DriftStatus"
|
||||
},
|
||||
"reason": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Reason"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"site_id",
|
||||
"computed_at",
|
||||
"window_start",
|
||||
"window_end",
|
||||
"reference_start",
|
||||
"reference_end",
|
||||
"n_observations",
|
||||
"mae",
|
||||
"mape",
|
||||
"bias",
|
||||
"reference_mae",
|
||||
"coverage_ratio",
|
||||
"insufficient_data_ratio",
|
||||
"model_references",
|
||||
"status",
|
||||
"reason"
|
||||
],
|
||||
"title": "DriftReportResponse"
|
||||
},
|
||||
"DriftStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stable",
|
||||
"derive",
|
||||
"indetermine"
|
||||
],
|
||||
"title": "DriftStatus"
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
@@ -3225,13 +3487,13 @@
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"Cookie de rafraîchissement": {
|
||||
"CookieRafraichissement": {
|
||||
"type": "apiKey",
|
||||
"description": "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers `/api/v1/auth`.",
|
||||
"in": "cookie",
|
||||
"name": "ev_refresh"
|
||||
},
|
||||
"Jeton d'accès": {
|
||||
"JetonAcces": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
@@ -3277,6 +3539,10 @@
|
||||
{
|
||||
"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`."
|
||||
},
|
||||
{
|
||||
"name": "monitoring",
|
||||
"description": "Surveillance de la dérive du modèle : écart entre les prévisions déjà écrites et les lectures réellement arrivées, par site et tous sites confondus. Réservé à partir du rôle `operateur`, qui agit sur un pipeline dégradé."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -87,8 +87,11 @@ disallow_untyped_defs = false
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
addopts = "-q --strict-markers -m 'not integration' --cov=app --cov-report=term-missing"
|
||||
markers = ["integration: requiert une base PostgreSQL joignable, hors `make test`"]
|
||||
addopts = "-q --strict-markers -m 'not integration and not chaine' --cov=app --cov-report=term-missing"
|
||||
markers = [
|
||||
"integration: requiert une base PostgreSQL joignable, hors `make test`",
|
||||
"chaine: requiert en plus l'environnement uv de ml/, hors `make test` et hors `-m integration`",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
|
||||
@@ -56,6 +56,7 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = {
|
||||
("GET", "/api/v1/readings"): Role.LECTEUR,
|
||||
("GET", "/api/v1/predictions"): Role.LECTEUR,
|
||||
("GET", "/api/v1/sensors/status"): Role.ADMIN,
|
||||
("GET", "/api/v1/monitoring/drift"): Role.OPERATEUR,
|
||||
("GET", "/api/v1/users"): Role.ADMIN,
|
||||
("POST", "/api/v1/users"): Role.ADMIN,
|
||||
("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Piège : ces fixtures valident leurs écritures, contrairement à celles de tests/repositories.
|
||||
|
||||
Un endpoint ouvre sa propre session par `get_session` : il ne verrait pas une ligne semée dans
|
||||
une transaction en cours. Lui passer la session de la fixture par `dependency_overrides`
|
||||
supprimerait justement ce que ces tests prouvent, et `RecommendationService.generate` valide de
|
||||
toute façon lui-même. L'isolation vient donc de la marque portée par chaque `site_id`, et le
|
||||
nettoyage est explicite, dans l'ordre imposé par les clés étrangères `RESTRICT`.
|
||||
|
||||
Contrainte : toutes ces fixtures sont à portée fonction. `engine_per_test` vide le cache du
|
||||
moteur après chaque test ; une fixture de module verrait un moteur déjà fermé à son démontage,
|
||||
et ses lignes resteraient en base.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_principal
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.db.session import get_session_factory
|
||||
from app.models.energy import Alert, Prediction, Reading, Recommendation, Site
|
||||
from tests.repositories.test_alert import creer_alerte
|
||||
from tests.repositories.test_prediction import creer_prediction
|
||||
from tests.repositories.test_reading import creer_lecture
|
||||
from tests.repositories.test_site import creer as creer_site
|
||||
|
||||
INSTANT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JeuMetier:
|
||||
"""Identifiants seuls, jamais d'instance ORM : un attribut relu sur une session fermée
|
||||
déclenche un `MissingGreenlet`."""
|
||||
|
||||
site_id: str
|
||||
site_voisin: str
|
||||
alert_id: int
|
||||
prediction_id: int
|
||||
instant: datetime
|
||||
|
||||
|
||||
async def _supprime(session: AsyncSession, sites: list[str]) -> None:
|
||||
# La suppression des recommandations est inconditionnelle : `POST /generate` en cree hors du
|
||||
# controle de la fixture, et `alert` les retient par une cle etrangere `RESTRICT`.
|
||||
alertes = select(Alert.alert_id).where(Alert.site_id.in_(sites))
|
||||
await session.execute(delete(Recommendation).where(Recommendation.alert_id.in_(alertes)))
|
||||
await session.execute(delete(Alert).where(Alert.site_id.in_(sites)))
|
||||
await session.execute(delete(Prediction).where(Prediction.site_id.in_(sites)))
|
||||
await session.execute(delete(Reading).where(Reading.site_id.in_(sites)))
|
||||
await session.execute(delete(Site).where(Site.site_id.in_(sites)))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def marque() -> str:
|
||||
return uuid4().hex[:12]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def jeu_metier(marque: str) -> AsyncIterator[JeuMetier]:
|
||||
"""Un site instrumenté, un site voisin, trois lectures horaires, une prédiction, une alerte.
|
||||
|
||||
Le voisin existe pour que les tests de filtre prouvent qu'ils écartent quelque chose.
|
||||
"""
|
||||
site_id = f"SITE-{marque}"
|
||||
voisin = f"SITE-{marque}-VOISIN"
|
||||
|
||||
async with get_session_factory()() as session:
|
||||
await creer_site(session, site_id=site_id, capacity_kw=100.0)
|
||||
await creer_site(session, site_id=voisin, capacity_kw=100.0)
|
||||
for decalage in range(3):
|
||||
await creer_lecture(
|
||||
session,
|
||||
site_id=site_id,
|
||||
timestamp=INSTANT - timedelta(hours=decalage),
|
||||
consumption_kw=10.0 + decalage,
|
||||
)
|
||||
prediction = await creer_prediction(session, site_id=site_id, target_at=INSTANT)
|
||||
alerte = await creer_alerte(session, site_id=site_id, timestamp=INSTANT)
|
||||
jeu = JeuMetier(
|
||||
site_id=site_id,
|
||||
site_voisin=voisin,
|
||||
alert_id=alerte.alert_id,
|
||||
prediction_id=prediction.prediction_id,
|
||||
instant=INSTANT,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
yield jeu
|
||||
finally:
|
||||
async with get_session_factory()() as session:
|
||||
await _supprime(session, [site_id, voisin])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def site_nu(marque: str) -> AsyncIterator[str]:
|
||||
"""Un site sans lecture ni prédiction : le cas que seul un vrai `LEFT JOIN` distingue."""
|
||||
site_id = f"SITE-{marque}-NU"
|
||||
|
||||
async with get_session_factory()() as session:
|
||||
await creer_site(session, site_id=site_id, capacity_kw=100.0)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
yield site_id
|
||||
finally:
|
||||
async with get_session_factory()() as session:
|
||||
await _supprime(session, [site_id])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def principal_injecte(app: FastAPI) -> Iterator[Callable[[Role], None]]:
|
||||
def installe(role: Role = Role.LECTEUR) -> None:
|
||||
app.dependency_overrides[get_current_principal] = lambda: Principal(
|
||||
id=uuid4(),
|
||||
email="parcours@enervision.fr",
|
||||
role=role,
|
||||
kind=AccountKind.HUMAIN,
|
||||
must_change_password=False,
|
||||
)
|
||||
|
||||
yield installe
|
||||
app.dependency_overrides.pop(get_current_principal, None)
|
||||
@@ -227,24 +227,26 @@ async def test_a_real_token_reaches_exactly_the_routes_of_its_rank(
|
||||
assert ecarts == []
|
||||
|
||||
|
||||
# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture
|
||||
# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur
|
||||
# arrive sans que `ROLE_MINIMUM` soit mis à jour.
|
||||
# Contrainte : les deux rangs ne se séparent que sur les routes que `ROLE_MINIMUM` réserve à
|
||||
# `operateur`. Une route d'opérateur ajoutée sans être classée fait diverger les statuts sans
|
||||
# qu'aucune entrée ne l'annonce, et une garde d'opérateur posée par erreur sur une route de
|
||||
# lecture fait diverger ce qui devait rester identique.
|
||||
@pytest.mark.integration
|
||||
async def test_the_operator_rank_opens_nothing_more_than_the_reader_rank(
|
||||
async def test_the_operator_rank_diverges_from_the_reader_rank_only_where_declared(
|
||||
comptes_par_role: dict[Role, str], client: AsyncClient
|
||||
) -> None:
|
||||
lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR])
|
||||
operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR])
|
||||
divergences: list[tuple[str, str]] = []
|
||||
ecarts: list[tuple[str, str]] = []
|
||||
|
||||
for methode, chemin in ROLE_MINIMUM:
|
||||
for (methode, chemin), minimum in ROLE_MINIMUM.items():
|
||||
cote_lecteur = await appelle(client, methode, chemin, headers=lecteur)
|
||||
cote_operateur = await appelle(client, methode, chemin, headers=operateur)
|
||||
if cote_lecteur.status_code != cote_operateur.status_code:
|
||||
divergences.append((methode, chemin))
|
||||
diverge = cote_lecteur.status_code != cote_operateur.status_code
|
||||
if diverge is not (minimum is Role.OPERATEUR):
|
||||
ecarts.append((methode, chemin))
|
||||
|
||||
assert divergences == []
|
||||
assert ecarts == []
|
||||
|
||||
|
||||
# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from collections.abc import Iterator, Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.api.deps import get_current_principal, get_drift_service
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.models.energy import DriftReport
|
||||
|
||||
INSTANT = datetime(2026, 9, 22, 12, tzinfo=UTC)
|
||||
|
||||
|
||||
def operateur() -> Principal:
|
||||
return Principal(
|
||||
id=uuid4(),
|
||||
email="operateur@enervision.fr",
|
||||
role=Role.OPERATEUR,
|
||||
kind=AccountKind.HUMAIN,
|
||||
must_change_password=False,
|
||||
)
|
||||
|
||||
|
||||
def rapport(*, site_id: str | None) -> DriftReport:
|
||||
return DriftReport(
|
||||
drift_report_id=1,
|
||||
computed_at=INSTANT,
|
||||
site_id=site_id,
|
||||
window_start=INSTANT - timedelta(hours=168),
|
||||
window_end=INSTANT,
|
||||
reference_start=None,
|
||||
reference_end=None,
|
||||
n_observations=48,
|
||||
mae=1.5,
|
||||
mape=12.0,
|
||||
bias=0.3,
|
||||
reference_mae=1.2,
|
||||
coverage_ratio=0.95,
|
||||
insufficient_data_ratio=0.0,
|
||||
model_references=["lightgbm-aaa"],
|
||||
status="stable",
|
||||
reason=None,
|
||||
)
|
||||
|
||||
|
||||
class FauxService:
|
||||
def __init__(self, rapports: Sequence[DriftReport]) -> None:
|
||||
self.rapports = list(rapports)
|
||||
self.site_demande: str | None = None
|
||||
|
||||
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
|
||||
self.site_demande = site_id
|
||||
return self.rapports
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def servi(app: FastAPI) -> Iterator[list[DriftReport]]:
|
||||
rapports = [rapport(site_id="SITE001"), rapport(site_id=None)]
|
||||
service = FauxService(rapports)
|
||||
app.dependency_overrides[get_current_principal] = operateur
|
||||
app.dependency_overrides[get_drift_service] = lambda: service
|
||||
yield rapports
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def test_drift_returns_the_latest_report_of_every_site(
|
||||
servi: list[DriftReport], client: AsyncClient
|
||||
) -> None:
|
||||
reponse = await client.get("/api/v1/monitoring/drift")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
assert [ligne["site_id"] for ligne in reponse.json()] == ["SITE001", None]
|
||||
|
||||
|
||||
async def test_drift_exposes_the_metrics_of_the_stored_report(
|
||||
servi: list[DriftReport], client: AsyncClient
|
||||
) -> None:
|
||||
reponse = await client.get("/api/v1/monitoring/drift")
|
||||
|
||||
premier = reponse.json()[0]
|
||||
assert premier["status"] == "stable"
|
||||
assert premier["mae"] == 1.5
|
||||
assert premier["model_references"] == ["lightgbm-aaa"]
|
||||
|
||||
|
||||
async def test_drift_returns_an_empty_list_when_no_report_exists(
|
||||
app: FastAPI, client: AsyncClient
|
||||
) -> None:
|
||||
app.dependency_overrides[get_current_principal] = operateur
|
||||
app.dependency_overrides[get_drift_service] = lambda: FauxService([])
|
||||
|
||||
reponse = await client.get("/api/v1/monitoring/drift")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
assert reponse.json() == []
|
||||
app.dependency_overrides.clear()
|
||||
@@ -104,8 +104,8 @@ def test_the_rate_limit_documents_the_delay_header(schema: dict[str, Any]) -> No
|
||||
def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None:
|
||||
schemes = schema["components"]["securitySchemes"]
|
||||
|
||||
assert schemes["Cookie de rafraîchissement"]["in"] == "cookie"
|
||||
assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh"
|
||||
assert schemes["CookieRafraichissement"]["in"] == "cookie"
|
||||
assert schemes["CookieRafraichissement"]["name"] == "ev_refresh"
|
||||
|
||||
|
||||
def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None:
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.core.roles import Role
|
||||
from tests.api.conftest import JeuMetier
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def genere(client: AsyncClient, site_id: str) -> dict[str, int]:
|
||||
# Toujours borne a un site : sans `site_id`, le service examine toutes les alertes de la
|
||||
# base, y compris celles d'un autre test, et le rapport cesse d'etre deterministe.
|
||||
reponse = await client.post(f"/api/v1/recommendations/generate?site_id={site_id}")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
return dict(reponse.json())
|
||||
|
||||
|
||||
async def test_generate_creates_a_recommendation_for_the_alert_of_the_requested_site(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.ADMIN)
|
||||
|
||||
rapport = await genere(client, jeu_metier.site_id)
|
||||
|
||||
assert rapport["alerts_examined"] == 1
|
||||
assert rapport["recommendations_created"] >= 1
|
||||
assert rapport["already_present"] == 0
|
||||
|
||||
|
||||
async def test_generate_creates_nothing_more_when_it_runs_twice_on_the_same_alerts(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.ADMIN)
|
||||
premier = await genere(client, jeu_metier.site_id)
|
||||
|
||||
second = await genere(client, jeu_metier.site_id)
|
||||
|
||||
assert second["recommendations_created"] == 0
|
||||
assert second["already_present"] == premier["recommendations_created"]
|
||||
|
||||
|
||||
async def test_generate_examines_no_alert_when_the_requested_site_has_none(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.ADMIN)
|
||||
|
||||
rapport = await genere(client, jeu_metier.site_voisin)
|
||||
|
||||
assert rapport["alerts_examined"] == 0
|
||||
assert rapport["recommendations_created"] == 0
|
||||
|
||||
|
||||
async def test_list_recommendations_returns_what_generate_persisted_in_another_session(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.ADMIN)
|
||||
await genere(client, jeu_metier.site_id)
|
||||
|
||||
reponse = await client.get("/api/v1/recommendations")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
miennes = [r for r in reponse.json() if r["alert_id"] == jeu_metier.alert_id]
|
||||
assert miennes != []
|
||||
assert all(r["rule_reference"] for r in miennes)
|
||||
|
||||
|
||||
async def test_get_recommendation_returns_the_row_created_by_generate(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.ADMIN)
|
||||
await genere(client, jeu_metier.site_id)
|
||||
liste = await client.get("/api/v1/recommendations")
|
||||
creee = next(r for r in liste.json() if r["alert_id"] == jeu_metier.alert_id)
|
||||
|
||||
reponse = await client.get(f"/api/v1/recommendations/{creee['recommendation_id']}")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
assert reponse.json() == creee
|
||||
|
||||
|
||||
async def test_get_recommendation_returns_404_when_the_identifier_is_unknown(
|
||||
principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.LECTEUR)
|
||||
|
||||
reponse = await client.get("/api/v1/recommendations/9999999")
|
||||
|
||||
assert reponse.status_code == 404
|
||||
assert reponse.json()["detail"] == "Recommandation introuvable"
|
||||
@@ -0,0 +1,80 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.core.roles import Role
|
||||
from app.db.session import get_session_factory
|
||||
from tests.api.conftest import JeuMetier
|
||||
from tests.repositories.test_reading import creer_lecture
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_list_sites_returns_the_seeded_site_with_its_stored_attributes(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.LECTEUR)
|
||||
|
||||
reponse = await client.get("/api/v1/sites")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
mien = next(site for site in reponse.json() if site["site_id"] == jeu_metier.site_id)
|
||||
assert mien["capacity_kw"] == 100.0
|
||||
assert mien["site_name"] == "Site de test"
|
||||
|
||||
|
||||
async def test_get_site_returns_404_when_the_identifier_is_absent_from_the_database(
|
||||
principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.LECTEUR)
|
||||
|
||||
reponse = await client.get("/api/v1/sites/SITE-JAMAIS-INSERE")
|
||||
|
||||
assert reponse.status_code == 404
|
||||
assert reponse.json()["detail"] == "Site introuvable"
|
||||
|
||||
|
||||
async def test_get_current_returns_the_most_recent_reading_when_several_hours_are_stored(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.LECTEUR)
|
||||
|
||||
reponse = await client.get(f"/api/v1/sites/{jeu_metier.site_id}/current")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
corps = reponse.json()
|
||||
assert corps["consumption_kw"] == 10.0
|
||||
assert corps["timestamp"].startswith("2026-09-16T12:00")
|
||||
|
||||
|
||||
async def test_get_current_keeps_the_highest_reading_id_when_two_sources_share_the_timestamp(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.LECTEUR)
|
||||
async with get_session_factory()() as session:
|
||||
await creer_lecture(
|
||||
session,
|
||||
site_id=jeu_metier.site_id,
|
||||
timestamp=jeu_metier.instant,
|
||||
source="api_history",
|
||||
consumption_kw=999.0,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
reponse = await client.get(f"/api/v1/sites/{jeu_metier.site_id}/current")
|
||||
|
||||
assert reponse.json()["consumption_kw"] == 999.0
|
||||
|
||||
|
||||
async def test_get_current_reports_a_critical_quality_when_the_site_has_no_reading(
|
||||
site_nu: str, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.LECTEUR)
|
||||
|
||||
reponse = await client.get(f"/api/v1/sites/{site_nu}/current")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
corps = reponse.json()
|
||||
assert corps["timestamp"] is None
|
||||
assert corps["data_quality"] == "critical"
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -9,7 +9,15 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
|
||||
from app.models.energy import (
|
||||
Alert,
|
||||
Dataset,
|
||||
DriftReport,
|
||||
Prediction,
|
||||
Reading,
|
||||
Recommendation,
|
||||
Site,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
@@ -112,8 +120,10 @@ async def test_duplicate_reading_is_rejected_when_key_matches(
|
||||
)
|
||||
await data_connection.execute(statement)
|
||||
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
@@ -147,9 +157,12 @@ async def test_invalid_reading_is_rejected_when_constraints_fail(
|
||||
}
|
||||
values.update(changes)
|
||||
|
||||
statement = insert(Reading).values(**values)
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
await data_connection.execute(insert(Reading).values(**values))
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
async def test_prediction_requires_period_when_energy_is_predicted(
|
||||
@@ -164,8 +177,10 @@ async def test_prediction_requires_period_when_energy_is_predicted(
|
||||
model_reference="test-model/1",
|
||||
)
|
||||
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
@@ -212,21 +227,22 @@ async def test_alert_rejects_prediction_when_site_differs(
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
statement = insert(Alert).values(
|
||||
source_alert_id=str(uuid4()),
|
||||
site_id=other_site,
|
||||
source="enervision",
|
||||
timestamp=MOMENT,
|
||||
type="spike",
|
||||
severity="high",
|
||||
message="Test",
|
||||
prediction_id=prediction_id,
|
||||
raw_data={},
|
||||
)
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
await data_connection.execute(
|
||||
insert(Alert).values(
|
||||
source_alert_id=str(uuid4()),
|
||||
site_id=other_site,
|
||||
source="enervision",
|
||||
timestamp=MOMENT,
|
||||
type="spike",
|
||||
severity="high",
|
||||
message="Test",
|
||||
prediction_id=prediction_id,
|
||||
raw_data={},
|
||||
)
|
||||
)
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
async def test_recommendation_is_unique_when_alert_and_rule_match(
|
||||
@@ -256,6 +272,67 @@ async def test_recommendation_is_unique_when_alert_and_rule_match(
|
||||
)
|
||||
await data_connection.execute(statement)
|
||||
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
def _rapport(**remplacements: object) -> dict[str, object]:
|
||||
defauts: dict[str, object] = {
|
||||
"site_id": None,
|
||||
"window_start": MOMENT,
|
||||
"window_end": MOMENT,
|
||||
"n_observations": 12,
|
||||
"model_references": ["lightgbm-aaa"],
|
||||
"status": "stable",
|
||||
"reason": None,
|
||||
}
|
||||
return {**defauts, **remplacements}
|
||||
|
||||
|
||||
async def test_drift_report_rejects_an_unknown_status(data_connection: AsyncConnection) -> None:
|
||||
statement = insert(DriftReport).values(**_rapport(status="douteux", reason="x"))
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
async def test_drift_report_rejects_a_drift_without_a_reason(
|
||||
data_connection: AsyncConnection,
|
||||
) -> None:
|
||||
statement = insert(DriftReport).values(**_rapport(status="derive"))
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
async def test_drift_report_accepts_one_global_row_without_a_site(
|
||||
data_connection: AsyncConnection,
|
||||
) -> None:
|
||||
identifiant = (
|
||||
await data_connection.execute(
|
||||
insert(DriftReport).values(**_rapport()).returning(DriftReport.drift_report_id)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
assert identifiant is not None
|
||||
|
||||
|
||||
async def test_drift_report_is_unique_when_window_and_site_match(
|
||||
data_connection: AsyncConnection,
|
||||
) -> None:
|
||||
fenetre = MOMENT + timedelta(days=1)
|
||||
statement = insert(DriftReport).values(**_rapport(window_end=fenetre))
|
||||
await data_connection.execute(statement)
|
||||
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
@@ -1,239 +1,245 @@
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from app.etl.historical_import import (
|
||||
SOURCE_NAME,
|
||||
build_reading_batch,
|
||||
classify_quality,
|
||||
compute_sha256,
|
||||
load_metadata,
|
||||
normalize_timestamps,
|
||||
validate_source,
|
||||
)
|
||||
|
||||
|
||||
def make_metadata() -> dict:
|
||||
return {
|
||||
"total_records": 2,
|
||||
"sites": {
|
||||
"SITE001": {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def make_dataframe() -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"timestamp": "2023-01-01 00:00:00",
|
||||
"site_id": "SITE001",
|
||||
"site_type": "office",
|
||||
"site_name": "Site 1",
|
||||
"consumption_kwh": 10.5,
|
||||
"consumption_euros": 2.5,
|
||||
"temperature_celsius": 20.0,
|
||||
"humidity_percent": 50.0,
|
||||
"solar_irradiance_wm2": 0.0,
|
||||
"hour": 0,
|
||||
"day_of_week": 6,
|
||||
"day_name": "Sunday",
|
||||
"month": 1,
|
||||
"is_weekend": True,
|
||||
"is_working_hours": False,
|
||||
},
|
||||
{
|
||||
"timestamp": "2023-01-01 01:00:00",
|
||||
"site_id": "SITE001",
|
||||
"site_type": "office",
|
||||
"site_name": "Site 1",
|
||||
"consumption_kwh": 11.0,
|
||||
"consumption_euros": 2.7,
|
||||
"temperature_celsius": 19.5,
|
||||
"humidity_percent": 52.0,
|
||||
"solar_irradiance_wm2": 0.0,
|
||||
"hour": 1,
|
||||
"day_of_week": 6,
|
||||
"day_name": "Sunday",
|
||||
"month": 1,
|
||||
"is_weekend": True,
|
||||
"is_working_hours": False,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_compute_sha256(tmp_path):
|
||||
file_path = tmp_path / "dataset.csv"
|
||||
content = b"hello-enervision"
|
||||
|
||||
file_path.write_bytes(content)
|
||||
|
||||
expected = hashlib.sha256(content).hexdigest()
|
||||
|
||||
assert compute_sha256(file_path) == expected
|
||||
|
||||
|
||||
def test_load_metadata(tmp_path):
|
||||
metadata_path = tmp_path / "metadata.json"
|
||||
|
||||
metadata = {
|
||||
"total_records": 2,
|
||||
"sites": {
|
||||
"SITE001": {},
|
||||
},
|
||||
}
|
||||
|
||||
metadata_path.write_text(
|
||||
json.dumps(metadata),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert load_metadata(metadata_path) == metadata
|
||||
|
||||
|
||||
def test_validate_source_accepts_valid_dataset():
|
||||
frame = make_dataframe()
|
||||
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
)
|
||||
|
||||
|
||||
def test_validate_source_rejects_missing_column():
|
||||
frame = make_dataframe().drop(columns=["consumption_kwh"])
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Colonnes obligatoires absentes",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
)
|
||||
|
||||
|
||||
def test_validate_source_rejects_duplicates():
|
||||
frame = make_dataframe()
|
||||
|
||||
frame.loc[1, "timestamp"] = frame.loc[
|
||||
0,
|
||||
"timestamp",
|
||||
]
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="doublons",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
)
|
||||
|
||||
|
||||
def test_validate_source_rejects_unknown_site():
|
||||
frame = make_dataframe()
|
||||
|
||||
frame.loc[1, "site_id"] = "SITE999"
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Sites incohérents",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_timestamps_adds_timezone():
|
||||
frame = make_dataframe()
|
||||
|
||||
normalized = normalize_timestamps(
|
||||
frame,
|
||||
"UTC",
|
||||
)
|
||||
|
||||
assert normalized["timestamp"].dt.tz is not None
|
||||
|
||||
assert "_source_timestamp" in normalized.columns
|
||||
|
||||
|
||||
def test_classify_quality_good():
|
||||
row = make_dataframe().iloc[0].to_dict()
|
||||
|
||||
quality, reasons = classify_quality(row)
|
||||
|
||||
assert quality == "good"
|
||||
assert reasons == []
|
||||
|
||||
|
||||
def test_classify_quality_degraded_when_consumption_missing():
|
||||
row = make_dataframe().iloc[0].to_dict()
|
||||
row["consumption_kwh"] = None
|
||||
|
||||
quality, reasons = classify_quality(row)
|
||||
|
||||
assert quality == "degraded"
|
||||
|
||||
assert "missing:consumption_kwh" in reasons
|
||||
|
||||
|
||||
def test_build_reading_batch_respects_database_contract():
|
||||
frame = normalize_timestamps(
|
||||
make_dataframe(),
|
||||
"UTC",
|
||||
)
|
||||
|
||||
rows = build_reading_batch(
|
||||
frame.iloc[:1],
|
||||
dataset_id=3,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
|
||||
row = rows[0]
|
||||
|
||||
assert row["dataset_id"] == 3
|
||||
|
||||
# Important :
|
||||
# contrainte ck_reading_dataset_source.
|
||||
assert row["source"] == "csv"
|
||||
assert SOURCE_NAME == "csv"
|
||||
|
||||
# Important :
|
||||
# contrainte ck_reading_imputation.
|
||||
assert row["imputed_values"] is None
|
||||
assert row["imputation_method"] is None
|
||||
|
||||
assert row["data_quality"] == "good"
|
||||
assert row["null_reasons"] == []
|
||||
|
||||
|
||||
def test_build_reading_batch_keeps_missing_values():
|
||||
frame = make_dataframe()
|
||||
|
||||
frame.loc[0, "temperature_celsius"] = None
|
||||
|
||||
frame = normalize_timestamps(
|
||||
frame,
|
||||
"UTC",
|
||||
)
|
||||
|
||||
rows = build_reading_batch(
|
||||
frame.iloc[:1],
|
||||
dataset_id=3,
|
||||
)
|
||||
|
||||
row = rows[0]
|
||||
|
||||
assert row["temperature_celsius"] is None
|
||||
|
||||
assert "missing:temperature_celsius" in row["null_reasons"]
|
||||
|
||||
# RAW ingestion : aucune imputation.
|
||||
assert row["imputed_values"] is None
|
||||
assert row["imputation_method"] is None
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from app.etl.historical_import import (
|
||||
SOURCE_NAME,
|
||||
build_reading_batch,
|
||||
classify_quality,
|
||||
compute_sha256,
|
||||
load_metadata,
|
||||
normalize_timestamps,
|
||||
validate_source,
|
||||
)
|
||||
|
||||
|
||||
def make_metadata() -> dict:
|
||||
return {
|
||||
"total_records": 2,
|
||||
"sites": {
|
||||
"SITE001": {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def make_dataframe() -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"timestamp": "2023-01-01 00:00:00",
|
||||
"site_id": "SITE001",
|
||||
"site_type": "office",
|
||||
"site_name": "Site 1",
|
||||
"consumption_kwh": 10.5,
|
||||
"consumption_euros": 2.5,
|
||||
"temperature_celsius": 20.0,
|
||||
"humidity_percent": 50.0,
|
||||
"solar_irradiance_wm2": 0.0,
|
||||
"hour": 0,
|
||||
"day_of_week": 6,
|
||||
"day_name": "Sunday",
|
||||
"month": 1,
|
||||
"is_weekend": True,
|
||||
"is_working_hours": False,
|
||||
},
|
||||
{
|
||||
"timestamp": "2023-01-01 01:00:00",
|
||||
"site_id": "SITE001",
|
||||
"site_type": "office",
|
||||
"site_name": "Site 1",
|
||||
"consumption_kwh": 11.0,
|
||||
"consumption_euros": 2.7,
|
||||
"temperature_celsius": 19.5,
|
||||
"humidity_percent": 52.0,
|
||||
"solar_irradiance_wm2": 0.0,
|
||||
"hour": 1,
|
||||
"day_of_week": 6,
|
||||
"day_name": "Sunday",
|
||||
"month": 1,
|
||||
"is_weekend": True,
|
||||
"is_working_hours": False,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_compute_sha256(tmp_path):
|
||||
file_path = tmp_path / "dataset.csv"
|
||||
content = b"hello-enervision"
|
||||
|
||||
file_path.write_bytes(content)
|
||||
|
||||
expected = hashlib.sha256(content).hexdigest()
|
||||
|
||||
assert compute_sha256(file_path) == expected
|
||||
|
||||
|
||||
def test_load_metadata(tmp_path):
|
||||
metadata_path = tmp_path / "metadata.json"
|
||||
|
||||
metadata = {
|
||||
"total_records": 2,
|
||||
"sites": {
|
||||
"SITE001": {},
|
||||
},
|
||||
}
|
||||
|
||||
metadata_path.write_text(
|
||||
json.dumps(metadata),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert load_metadata(metadata_path) == metadata
|
||||
|
||||
|
||||
def test_validate_source_accepts_valid_dataset():
|
||||
frame = make_dataframe()
|
||||
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
)
|
||||
|
||||
|
||||
def test_validate_source_rejects_missing_column():
|
||||
frame = make_dataframe().drop(columns=["consumption_kwh"])
|
||||
|
||||
metadata = make_metadata()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Colonnes obligatoires absentes",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_source_rejects_duplicates():
|
||||
frame = make_dataframe()
|
||||
|
||||
frame.loc[1, "timestamp"] = frame.loc[
|
||||
0,
|
||||
"timestamp",
|
||||
]
|
||||
|
||||
metadata = make_metadata()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="doublons",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_source_rejects_unknown_site():
|
||||
frame = make_dataframe()
|
||||
|
||||
frame.loc[1, "site_id"] = "SITE999"
|
||||
|
||||
metadata = make_metadata()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Sites incohérents",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_timestamps_adds_timezone():
|
||||
frame = make_dataframe()
|
||||
|
||||
normalized = normalize_timestamps(
|
||||
frame,
|
||||
"UTC",
|
||||
)
|
||||
|
||||
assert normalized["timestamp"].dt.tz is not None
|
||||
|
||||
assert "_source_timestamp" in normalized.columns
|
||||
|
||||
|
||||
def test_classify_quality_good():
|
||||
row = make_dataframe().iloc[0].to_dict()
|
||||
|
||||
quality, reasons = classify_quality(row)
|
||||
|
||||
assert quality == "good"
|
||||
assert reasons == []
|
||||
|
||||
|
||||
def test_classify_quality_degraded_when_consumption_missing():
|
||||
row = make_dataframe().iloc[0].to_dict()
|
||||
row["consumption_kwh"] = None
|
||||
|
||||
quality, reasons = classify_quality(row)
|
||||
|
||||
assert quality == "degraded"
|
||||
|
||||
assert "missing:consumption_kwh" in reasons
|
||||
|
||||
|
||||
def test_build_reading_batch_respects_database_contract():
|
||||
frame = normalize_timestamps(
|
||||
make_dataframe(),
|
||||
"UTC",
|
||||
)
|
||||
|
||||
rows = build_reading_batch(
|
||||
frame.iloc[:1],
|
||||
dataset_id=3,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
|
||||
row = rows[0]
|
||||
|
||||
assert row["dataset_id"] == 3
|
||||
|
||||
# Important :
|
||||
# contrainte ck_reading_dataset_source.
|
||||
assert row["source"] == "csv"
|
||||
assert SOURCE_NAME == "csv"
|
||||
|
||||
# Important :
|
||||
# contrainte ck_reading_imputation.
|
||||
assert row["imputed_values"] is None
|
||||
assert row["imputation_method"] is None
|
||||
|
||||
assert row["data_quality"] == "good"
|
||||
assert row["null_reasons"] == []
|
||||
|
||||
|
||||
def test_build_reading_batch_keeps_missing_values():
|
||||
frame = make_dataframe()
|
||||
|
||||
frame.loc[0, "temperature_celsius"] = None
|
||||
|
||||
frame = normalize_timestamps(
|
||||
frame,
|
||||
"UTC",
|
||||
)
|
||||
|
||||
rows = build_reading_batch(
|
||||
frame.iloc[:1],
|
||||
dataset_id=3,
|
||||
)
|
||||
|
||||
row = rows[0]
|
||||
|
||||
assert row["temperature_celsius"] is None
|
||||
|
||||
assert "missing:temperature_celsius" in row["null_reasons"]
|
||||
|
||||
# RAW ingestion : aucune imputation.
|
||||
assert row["imputed_values"] is None
|
||||
assert row["imputation_method"] is None
|
||||
|
||||
@@ -283,6 +283,41 @@ def test_create_mock_api_client_requires_credentials(
|
||||
mock_api_import.create_mock_api_client()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("username", "password_value"),
|
||||
[
|
||||
("", "test-password"),
|
||||
("test-user", ""),
|
||||
(" ", "test-password"),
|
||||
("test-user", " "),
|
||||
],
|
||||
)
|
||||
def test_create_mock_api_client_rejects_empty_credentials(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
username: str,
|
||||
password_value: str,
|
||||
) -> None:
|
||||
password = MagicMock()
|
||||
password.get_secret_value.return_value = password_value
|
||||
|
||||
settings = SimpleNamespace(
|
||||
mock_api_username=username,
|
||||
mock_api_password=password,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_api_import,
|
||||
"get_settings",
|
||||
lambda: settings,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Les identifiants de l'API Mock ne sont pas configurés",
|
||||
):
|
||||
mock_api_import.create_mock_api_client()
|
||||
|
||||
|
||||
async def test_create_mock_api_client_uses_configuration(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Alert
|
||||
from app.repositories import alert as module_alert
|
||||
from app.repositories.alert import AlertRepository
|
||||
from app.schemas.alert import AlertSeverity
|
||||
from tests.repositories.test_site import creer as creer_site
|
||||
@@ -146,3 +147,20 @@ async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession)
|
||||
creees = await depot.create_many([])
|
||||
|
||||
assert creees == []
|
||||
|
||||
|
||||
async def test_create_many_inserts_every_alert_across_several_batches(
|
||||
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(module_alert, "TAILLE_DE_LOT", 2)
|
||||
site = await creer_site(session)
|
||||
depot = AlertRepository(session)
|
||||
a_inserer = [
|
||||
_alerte_a_inserer(site_id=site.site_id, source_alert_id=f"threshold:lot-{index}")
|
||||
for index in range(5)
|
||||
]
|
||||
|
||||
creees = await depot.create_many(a_inserer)
|
||||
await session.rollback()
|
||||
|
||||
assert len(creees) == 5
|
||||
|
||||
@@ -49,8 +49,10 @@ async def test_the_database_refuses_to_mutate_the_audit_log(
|
||||
) -> None:
|
||||
await une_ligne(session)
|
||||
|
||||
requete = text(instruction)
|
||||
|
||||
with pytest.raises(DBAPIError, match="ajout seul"):
|
||||
await session.execute(text(instruction))
|
||||
await session.execute(requete)
|
||||
await session.rollback()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql import ClauseElement
|
||||
|
||||
from app.repositories.drift import (
|
||||
DriftRepository,
|
||||
NouveauRapportDerive,
|
||||
_lectures_retenues,
|
||||
_predictions_retenues,
|
||||
)
|
||||
from tests.repositories.test_prediction import creer_prediction
|
||||
from tests.repositories.test_reading import creer_lecture
|
||||
from tests.repositories.test_site import creer as creer_site
|
||||
|
||||
DEBUT = datetime(2026, 9, 15, tzinfo=UTC)
|
||||
FIN = datetime(2026, 9, 22, tzinfo=UTC)
|
||||
CIBLE = datetime(2026, 9, 16, 12, tzinfo=UTC)
|
||||
|
||||
|
||||
def sql(requete: ClauseElement) -> str:
|
||||
return str(requete.compile(dialect=postgresql.dialect())) # type: ignore[no-untyped-call]
|
||||
|
||||
|
||||
def rapport(**remplacements: object) -> NouveauRapportDerive:
|
||||
defauts: dict[str, object] = {
|
||||
"site_id": None,
|
||||
"window_start": DEBUT,
|
||||
"window_end": FIN,
|
||||
"reference_start": None,
|
||||
"reference_end": None,
|
||||
"n_observations": 10,
|
||||
"mae": 1.0,
|
||||
"mape": 5.0,
|
||||
"bias": 0.1,
|
||||
"reference_mae": None,
|
||||
"coverage_ratio": 1.0,
|
||||
"insufficient_data_ratio": 0.0,
|
||||
"model_references": ["lightgbm-aaa"],
|
||||
"status": "stable",
|
||||
"reason": None,
|
||||
}
|
||||
return NouveauRapportDerive(**{**defauts, **remplacements}) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_predictions_keep_one_row_per_site_and_target_in_sql() -> None:
|
||||
requete = sql(_predictions_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
|
||||
|
||||
assert "DISTINCT ON (prediction.site_id, prediction.target_at)" in requete
|
||||
assert "prediction.prediction_id DESC" in requete
|
||||
|
||||
|
||||
def test_readings_keep_one_row_per_site_and_instant_in_sql() -> None:
|
||||
requete = sql(_lectures_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
|
||||
|
||||
assert "DISTINCT ON (reading.site_id, reading.timestamp)" in requete
|
||||
assert "reading.reading_id DESC" in requete
|
||||
|
||||
|
||||
def test_predictions_restrict_themselves_to_the_requested_site_in_sql() -> None:
|
||||
requete = sql(_predictions_retenues(debut=DEBUT, fin=FIN, site_id="SITE001").element)
|
||||
|
||||
assert requete.count("prediction.site_id = ") == 1
|
||||
|
||||
|
||||
def test_readings_ignore_a_missing_consumption_in_sql() -> None:
|
||||
requete = sql(_lectures_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
|
||||
|
||||
assert "reading.consumption_kwh IS NOT NULL" in requete
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_repository_pairs_a_prediction_with_the_reading_of_the_same_instant(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
site = await creer_site(session)
|
||||
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
|
||||
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
|
||||
|
||||
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
|
||||
await session.rollback()
|
||||
|
||||
assert [(p.predicted_value, p.actual_value) for p in paires] == [(12.0, 10.0)]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_repository_keeps_the_latest_run_when_several_predictions_share_a_target(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
site = await creer_site(session)
|
||||
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
|
||||
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=99.0)
|
||||
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
|
||||
|
||||
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
|
||||
await session.rollback()
|
||||
|
||||
assert [p.predicted_value for p in paires] == [99.0]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_repository_keeps_one_reading_per_instant_when_two_sources_wrote_the_same_hour(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
site = await creer_site(session)
|
||||
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
|
||||
await creer_lecture(
|
||||
session, site_id=site.site_id, timestamp=CIBLE, source="api_current", consumption_kwh=10.0
|
||||
)
|
||||
await creer_lecture(
|
||||
session, site_id=site.site_id, timestamp=CIBLE, source="api_history", consumption_kwh=20.0
|
||||
)
|
||||
|
||||
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
|
||||
await session.rollback()
|
||||
|
||||
assert [p.actual_value for p in paires] == [20.0]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_repository_excludes_an_insufficient_data_prediction_from_the_pairs(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
site = await creer_site(session)
|
||||
await creer_prediction(
|
||||
session,
|
||||
site_id=site.site_id,
|
||||
target_at=CIBLE,
|
||||
predicted_value=None,
|
||||
status="insufficient_data",
|
||||
failure_reason="historique trop court",
|
||||
)
|
||||
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
|
||||
|
||||
depot = DriftRepository(session)
|
||||
paires = await depot.paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
|
||||
comptages = await depot.comptages(debut=DEBUT, fin=FIN, site_id=site.site_id)
|
||||
await session.rollback()
|
||||
|
||||
assert paires == []
|
||||
assert [(c.status, c.nombre) for c in comptages] == [("insufficient_data", 1)]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_repository_excludes_a_target_outside_the_window(session: AsyncSession) -> None:
|
||||
site = await creer_site(session)
|
||||
hors_fenetre = FIN + timedelta(hours=1)
|
||||
await creer_prediction(
|
||||
session, site_id=site.site_id, target_at=hors_fenetre, predicted_value=12.0
|
||||
)
|
||||
await creer_lecture(session, site_id=site.site_id, timestamp=hors_fenetre, consumption_kwh=10.0)
|
||||
|
||||
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
|
||||
await session.rollback()
|
||||
|
||||
assert paires == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_repository_reads_back_the_global_report_it_wrote(session: AsyncSession) -> None:
|
||||
depot = DriftRepository(session)
|
||||
fenetre = datetime(2035, 3, 1, tzinfo=UTC)
|
||||
|
||||
ecrites = await depot.enregistre([rapport(window_end=fenetre)])
|
||||
derniers = await depot.derniers()
|
||||
globaux = [r for r in derniers if r.site_id is None and r.window_end == fenetre]
|
||||
await session.rollback()
|
||||
|
||||
assert ecrites == 1
|
||||
assert len(globaux) == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_repository_ignores_a_second_report_for_the_same_window_and_site(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
depot = DriftRepository(session)
|
||||
fenetre = datetime(2035, 4, 1, tzinfo=UTC)
|
||||
|
||||
premiere = await depot.enregistre([rapport(window_end=fenetre)])
|
||||
seconde = await depot.enregistre([rapport(window_end=fenetre, status="derive", reason="x")])
|
||||
await session.rollback()
|
||||
|
||||
assert premiere == 1
|
||||
assert seconde == 0
|
||||
@@ -131,11 +131,14 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
empreinte = fingerprint_refresh(secret)
|
||||
expiration = datetime.now(UTC) + DUREE
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await depot.create(
|
||||
user_id=compte,
|
||||
token_hash=fingerprint_refresh(secret),
|
||||
expires_at=datetime.now(UTC) + DUREE,
|
||||
token_hash=empreinte,
|
||||
expires_at=expiration,
|
||||
client_ip=None,
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
@@ -33,6 +33,9 @@ async def creer_lecture(session: AsyncSession, *, site_id: str, **overrides: obj
|
||||
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
|
||||
source=overrides.get("source", "api_current"),
|
||||
consumption_kw=overrides.get("consumption_kw", 10.0),
|
||||
# Nul par defaut : seules les mesures en kWh alimentent la comparaison prevu/realise, et
|
||||
# un override silencieusement ignore laissait la colonne vide sans que rien ne le dise.
|
||||
consumption_kwh=overrides.get("consumption_kwh"),
|
||||
data_quality=overrides.get("data_quality", "good"),
|
||||
raw_data=overrides.get("raw_data", {}),
|
||||
)
|
||||
|
||||
@@ -178,12 +178,16 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
famille = uuid.uuid4()
|
||||
empreinte = fingerprint_refresh(secret)
|
||||
expiration = datetime.now(UTC) + DUREE
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await depot.create(
|
||||
user_id=compte,
|
||||
family_id=uuid.uuid4(),
|
||||
token_hash=fingerprint_refresh(secret),
|
||||
expires_at=datetime.now(UTC) + DUREE,
|
||||
family_id=famille,
|
||||
token_hash=empreinte,
|
||||
expires_at=expiration,
|
||||
client_ip=None,
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
@@ -31,14 +31,12 @@ async def test_the_database_refuses_an_email_written_in_upper_case(
|
||||
) -> None:
|
||||
saisie = adresse().upper()
|
||||
|
||||
requete = text(
|
||||
"insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')"
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.execute(
|
||||
text(
|
||||
"insert into app_user (email, password_hash, role) "
|
||||
"values (:e, '$argon2id$x', 'lecteur')"
|
||||
),
|
||||
{"e": saisie},
|
||||
)
|
||||
await session.execute(requete, {"e": saisie})
|
||||
await session.rollback()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.repositories.drift import ComptageStatut, PaireDerive
|
||||
from app.services.drift import (
|
||||
STATUT_DERIVE,
|
||||
STATUT_INDETERMINE,
|
||||
STATUT_STABLE,
|
||||
DriftService,
|
||||
Seuils,
|
||||
mesure,
|
||||
)
|
||||
|
||||
INSTANT = datetime(2026, 9, 22, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def paire(
|
||||
*, site_id: str = "SITE001", prevu: float, reel: float, reference: str = "lightgbm-aaa"
|
||||
) -> PaireDerive:
|
||||
return PaireDerive(
|
||||
site_id=site_id,
|
||||
target_at=INSTANT,
|
||||
predicted_value=prevu,
|
||||
actual_value=reel,
|
||||
model_reference=reference,
|
||||
)
|
||||
|
||||
|
||||
def paires(
|
||||
*, site_id: str = "SITE001", nombre: int, prevu: float, reel: float
|
||||
) -> list[PaireDerive]:
|
||||
return [paire(site_id=site_id, prevu=prevu, reel=reel) for _ in range(nombre)]
|
||||
|
||||
|
||||
class FauxDepot:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
recentes: Sequence[PaireDerive] = (),
|
||||
anciennes: Sequence[PaireDerive] = (),
|
||||
comptages: Sequence[ComptageStatut] = (),
|
||||
) -> None:
|
||||
self.recentes = list(recentes)
|
||||
self.anciennes = list(anciennes)
|
||||
self._comptages = list(comptages)
|
||||
self.fenetres: list[tuple[datetime, datetime]] = []
|
||||
|
||||
async def paires(
|
||||
self, *, debut: datetime, fin: datetime, site_id: str | None = None
|
||||
) -> Sequence[PaireDerive]:
|
||||
self.fenetres.append((debut, fin))
|
||||
return self.recentes if len(self.fenetres) == 1 else self.anciennes
|
||||
|
||||
async def comptages(
|
||||
self, *, debut: datetime, fin: datetime, site_id: str | None = None
|
||||
) -> Sequence[ComptageStatut]:
|
||||
return self._comptages
|
||||
|
||||
|
||||
def service(depot: FauxDepot, **surcharges: object) -> DriftService:
|
||||
return DriftService(depot, seuils=Seuils(**surcharges)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_drift_averages_the_absolute_gap_between_forecast_and_actual() -> None:
|
||||
metriques = mesure([paire(prevu=12.0, reel=10.0), paire(prevu=8.0, reel=10.0)])
|
||||
|
||||
assert metriques.mae == 2.0
|
||||
assert metriques.n_observations == 2
|
||||
|
||||
|
||||
def test_drift_computes_a_signed_bias_when_the_model_overforecasts() -> None:
|
||||
metriques = mesure([paire(prevu=12.0, reel=10.0), paire(prevu=14.0, reel=10.0)])
|
||||
|
||||
assert metriques.bias == 3.0
|
||||
|
||||
|
||||
def test_drift_computes_a_negative_bias_when_the_model_underforecasts() -> None:
|
||||
metriques = mesure([paire(prevu=8.0, reel=10.0), paire(prevu=6.0, reel=10.0)])
|
||||
|
||||
assert metriques.bias == -3.0
|
||||
|
||||
|
||||
def test_drift_excludes_a_zero_actual_from_the_mape_only() -> None:
|
||||
metriques = mesure([paire(prevu=11.0, reel=10.0), paire(prevu=5.0, reel=0.0)])
|
||||
|
||||
assert metriques.mape == 10.0
|
||||
assert metriques.n_observations == 2
|
||||
assert metriques.mae == 3.0
|
||||
|
||||
|
||||
def test_drift_reports_no_mape_when_every_actual_is_zero() -> None:
|
||||
metriques = mesure([paire(prevu=1.0, reel=0.0)])
|
||||
|
||||
assert metriques.mape is None
|
||||
|
||||
|
||||
def test_drift_lists_every_model_reference_seen_in_the_window() -> None:
|
||||
metriques = mesure(
|
||||
[paire(prevu=10.0, reel=10.0, reference="lightgbm-bbb"), paire(prevu=10.0, reel=10.0)]
|
||||
)
|
||||
|
||||
assert metriques.model_references == ["lightgbm-aaa", "lightgbm-bbb"]
|
||||
|
||||
|
||||
async def test_drift_reports_indetermine_when_the_window_holds_too_few_observations() -> None:
|
||||
depot = FauxDepot(recentes=paires(nombre=3, prevu=10.0, reel=10.0))
|
||||
|
||||
rapports = await service(depot, min_observations=24).evaluate(now=INSTANT)
|
||||
|
||||
assert {rapport.status for rapport in rapports} == {STATUT_INDETERMINE}
|
||||
assert all(rapport.reason for rapport in rapports)
|
||||
|
||||
|
||||
async def test_drift_reports_derive_when_the_recent_mae_exceeds_the_reference_ratio() -> None:
|
||||
depot = FauxDepot(
|
||||
recentes=paires(nombre=30, prevu=14.0, reel=10.0),
|
||||
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
|
||||
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||
)
|
||||
|
||||
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||
|
||||
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||
assert global_.status == STATUT_DERIVE
|
||||
assert global_.mae == 4.0
|
||||
assert global_.reference_mae == 1.0
|
||||
|
||||
|
||||
async def test_drift_reports_stable_when_the_recent_mae_stays_close_to_the_reference() -> None:
|
||||
depot = FauxDepot(
|
||||
recentes=paires(nombre=30, prevu=11.0, reel=10.0),
|
||||
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
|
||||
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||
)
|
||||
|
||||
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||
|
||||
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||
assert global_.status == STATUT_STABLE
|
||||
assert global_.reason is None
|
||||
|
||||
|
||||
async def test_drift_reports_derive_when_the_coverage_ratio_falls_under_the_threshold() -> None:
|
||||
depot = FauxDepot(
|
||||
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
|
||||
anciennes=paires(nombre=30, prevu=10.0, reel=10.0),
|
||||
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=100)],
|
||||
)
|
||||
|
||||
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||
|
||||
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||
assert global_.status == STATUT_DERIVE
|
||||
assert global_.coverage_ratio == 0.3
|
||||
|
||||
|
||||
async def test_drift_reports_one_line_per_site_and_one_global_line() -> None:
|
||||
depot = FauxDepot(
|
||||
recentes=[
|
||||
*paires(site_id="SITE001", nombre=12, prevu=10.0, reel=10.0),
|
||||
*paires(site_id="SITE002", nombre=12, prevu=10.0, reel=10.0),
|
||||
],
|
||||
comptages=[
|
||||
ComptageStatut(site_id="SITE001", status="available", nombre=12),
|
||||
ComptageStatut(site_id="SITE002", status="available", nombre=12),
|
||||
],
|
||||
)
|
||||
|
||||
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||
|
||||
assert [rapport.site_id for rapport in rapports] == ["SITE001", "SITE002", None]
|
||||
assert next(r for r in rapports if r.site_id is None).n_observations == 24
|
||||
|
||||
|
||||
async def test_drift_measures_the_share_of_sites_left_without_enough_history() -> None:
|
||||
depot = FauxDepot(
|
||||
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
|
||||
comptages=[
|
||||
ComptageStatut(site_id="SITE001", status="available", nombre=30),
|
||||
ComptageStatut(site_id="SITE001", status="insufficient_data", nombre=10),
|
||||
],
|
||||
)
|
||||
|
||||
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||
|
||||
assert next(r for r in rapports if r.site_id is None).insufficient_data_ratio == 0.25
|
||||
|
||||
|
||||
async def test_drift_closes_the_window_before_the_grace_delay() -> None:
|
||||
depot = FauxDepot()
|
||||
|
||||
await service(depot, grace=timedelta(hours=2), fenetre=timedelta(hours=168)).evaluate(
|
||||
now=INSTANT
|
||||
)
|
||||
|
||||
recente, reference = depot.fenetres
|
||||
assert recente[1] == INSTANT - timedelta(hours=2)
|
||||
assert recente[0] == INSTANT - timedelta(hours=170)
|
||||
assert reference[1] == recente[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prevu", "attendu"),
|
||||
[(10.0, STATUT_STABLE), (30.0, STATUT_DERIVE)],
|
||||
ids=["mae_stable", "mae_triplee"],
|
||||
)
|
||||
async def test_drift_compares_the_recent_window_to_the_reference_one(
|
||||
prevu: float, attendu: str
|
||||
) -> None:
|
||||
depot = FauxDepot(
|
||||
recentes=paires(nombre=30, prevu=prevu, reel=10.0),
|
||||
anciennes=paires(nombre=30, prevu=10.0, reel=10.0),
|
||||
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||
)
|
||||
|
||||
rapports = await service(depot, min_observations=10, mae_plancher=1.0).evaluate(now=INSTANT)
|
||||
|
||||
assert next(r for r in rapports if r.site_id is None).status == attendu
|
||||
|
||||
|
||||
async def test_drift_leaves_the_bias_out_of_the_verdict_by_default() -> None:
|
||||
# Le modèle surestime de 3 kWh à chaque heure, et le verdict reste `stable` : le biais est
|
||||
# mesuré et servi, il ne juge pas tant que `--bias-threshold` n'a pas été réglé (ADR 0013).
|
||||
depot = FauxDepot(
|
||||
recentes=paires(nombre=30, prevu=13.0, reel=10.0),
|
||||
anciennes=paires(nombre=30, prevu=13.0, reel=10.0),
|
||||
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||
)
|
||||
|
||||
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
||||
|
||||
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||
assert global_.status == STATUT_STABLE
|
||||
assert global_.bias == 3.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prevu", "attendu"),
|
||||
[(13.0, STATUT_DERIVE), (11.0, STATUT_STABLE)],
|
||||
ids=["biais_au_dela", "biais_sous_le_seuil"],
|
||||
)
|
||||
async def test_drift_reports_derive_on_the_bias_once_a_threshold_is_set(
|
||||
prevu: float, attendu: str
|
||||
) -> None:
|
||||
# MAE récente et MAE de référence sont égales : seul le biais peut faire basculer le verdict.
|
||||
depot = FauxDepot(
|
||||
recentes=paires(nombre=30, prevu=prevu, reel=10.0),
|
||||
anciennes=paires(nombre=30, prevu=prevu, reel=10.0),
|
||||
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||
)
|
||||
|
||||
rapports = await service(depot, min_observations=10, seuil_biais=2.0).evaluate(now=INSTANT)
|
||||
|
||||
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||
assert global_.status == attendu
|
||||
|
||||
|
||||
async def test_drift_prefers_the_mae_reason_when_both_the_mae_and_the_bias_exceed() -> None:
|
||||
depot = FauxDepot(
|
||||
recentes=paires(nombre=30, prevu=20.0, reel=10.0),
|
||||
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
|
||||
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
||||
)
|
||||
|
||||
rapports = await service(depot, min_observations=10, seuil_biais=2.0).evaluate(now=INSTANT)
|
||||
|
||||
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
||||
assert global_.status == STATUT_DERIVE
|
||||
assert "MAE" in (global_.reason or "")
|
||||
@@ -116,13 +116,11 @@ async def test_list_history_normalizes_naive_datetimes_to_utc() -> None:
|
||||
async def test_list_history_raises_when_start_is_after_end() -> None:
|
||||
service = ReadingService(readings=FakeRepository([]))
|
||||
|
||||
debut = datetime(2026, 9, 2, tzinfo=UTC)
|
||||
fin = datetime(2026, 9, 1, tzinfo=UTC)
|
||||
|
||||
with pytest.raises(FenetreInverseeError):
|
||||
await service.list_history(
|
||||
start=datetime(2026, 9, 2, tzinfo=UTC),
|
||||
end=datetime(2026, 9, 1, tzinfo=UTC),
|
||||
limit=500,
|
||||
offset=0,
|
||||
)
|
||||
await service.list_history(start=debut, end=fin, limit=500, offset=0)
|
||||
|
||||
|
||||
async def test_list_history_raises_when_start_equals_end() -> None:
|
||||
|
||||
@@ -235,5 +235,7 @@ async def test_every_operation_refuses_an_unknown_account(action: str) -> None:
|
||||
if action == "set_active":
|
||||
arguments["is_active"] = False
|
||||
|
||||
methode = getattr(attirail.service, action)
|
||||
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await getattr(attirail.service, action)(**arguments)
|
||||
await methode(**arguments)
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Piege : ce fichier porte le marqueur `chaine`, pas `integration` - test_the_ml_binaries...()
|
||||
|
||||
Il lance les vrais binaires `enervision_ml.train` et `enervision_ml.score` dans l'environnement
|
||||
uv de `ml/`, que le job `integration` de `backend.yml` n'installe pas. Un marqueur distinct evite
|
||||
que ce job, et `make test`, ne le selectionnent et n'echouent faute de `ml/.venv`.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import delete, insert, make_url
|
||||
|
||||
from app.api.deps import get_current_principal
|
||||
from app.core.config import get_settings
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.db.session import get_session_factory
|
||||
from app.models.energy import Prediction, Reading, Site
|
||||
|
||||
pytestmark = pytest.mark.chaine
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[3]
|
||||
ML = RACINE / "ml"
|
||||
PYTHON_ML = Path(os.environ.get("ML_PYTHON", ML / ".venv" / "bin" / "python"))
|
||||
|
||||
HEURES_COMPLETES = 400
|
||||
HEURES_INSUFFISANTES = 100
|
||||
|
||||
|
||||
def lecteur() -> Principal:
|
||||
return Principal(
|
||||
id=uuid4(),
|
||||
email="lecteur@enervision.fr",
|
||||
role=Role.LECTEUR,
|
||||
kind=AccountKind.HUMAIN,
|
||||
must_change_password=False,
|
||||
)
|
||||
|
||||
|
||||
def url_ml() -> str:
|
||||
"""Derive la chaine du pipeline de celle du backend plutot que de la recopier : les deux
|
||||
cotes visent ainsi la meme base, dans leur dialecte respectif."""
|
||||
return (
|
||||
make_url(get_settings().database_url)
|
||||
.set(drivername="postgresql+psycopg")
|
||||
.render_as_string(hide_password=False)
|
||||
)
|
||||
|
||||
|
||||
def lance_ml(module: str, *arguments: str, journal: Path) -> subprocess.CompletedProcess[str]:
|
||||
if not PYTHON_ML.exists():
|
||||
pytest.fail(
|
||||
f"Environnement ml/ absent ({PYTHON_ML}). Lancer `cd ml && uv sync --all-groups`."
|
||||
)
|
||||
|
||||
return subprocess.run( # noqa: S603 -- argv en liste, sans shell, binaire resolu dans le depot
|
||||
[str(PYTHON_ML), "-m", module, *arguments],
|
||||
cwd=ML,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=600,
|
||||
check=False,
|
||||
env={
|
||||
**os.environ,
|
||||
"ML_DATABASE_URL": url_ml(),
|
||||
"MLFLOW_TRACKING_URI": f"sqlite:///{journal}/mlflow.db",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def executer(module: str, *arguments: str, journal: Path) -> subprocess.CompletedProcess[str]:
|
||||
resultat = await anyio.to_thread.run_sync(
|
||||
partial(lance_ml, module, *arguments, journal=journal)
|
||||
)
|
||||
assert resultat.returncode == 0, resultat.stderr
|
||||
return resultat
|
||||
|
||||
|
||||
@dataclass
|
||||
class Parc:
|
||||
sites: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def lignes_horaires(site_id: str, *, heures: int, fin: datetime) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"site_id": site_id,
|
||||
"timestamp": fin - timedelta(hours=decalage),
|
||||
"source": "api_history",
|
||||
"consumption_kwh": 50.0 + math.sin(decalage / 12.0) * 10.0,
|
||||
"temperature_celsius": 15.0,
|
||||
"humidity_percent": 50.0,
|
||||
"solar_irradiance_wm2": 0.0,
|
||||
"is_working_hours": True,
|
||||
"raw_data": {},
|
||||
}
|
||||
for decalage in reversed(range(heures))
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def parc() -> AsyncIterator[Parc]:
|
||||
"""Deux sites dotes d'un historique complet, un troisieme qui n'atteint pas le lag de 168 h.
|
||||
|
||||
Les ecritures sont validees : les binaires ML ouvrent leur propre connexion et ne verraient
|
||||
pas une transaction en cours.
|
||||
"""
|
||||
fin = datetime.now(UTC).replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||
marque = uuid4().hex[:12]
|
||||
complets = [f"TEST-{marque}-A", f"TEST-{marque}-B"]
|
||||
partiel = f"TEST-{marque}-C"
|
||||
parc = Parc(sites=[*complets, partiel])
|
||||
|
||||
async with get_session_factory()() as session:
|
||||
await session.execute(
|
||||
insert(Site),
|
||||
[
|
||||
{
|
||||
"site_id": site_id,
|
||||
"site_name": f"Site {site_id}",
|
||||
"site_type": "office",
|
||||
"capacity_kw": 100.0,
|
||||
}
|
||||
for site_id in parc.sites
|
||||
],
|
||||
)
|
||||
for site_id in complets:
|
||||
await session.execute(
|
||||
insert(Reading), lignes_horaires(site_id, heures=HEURES_COMPLETES, fin=fin)
|
||||
)
|
||||
await session.execute(
|
||||
insert(Reading), lignes_horaires(partiel, heures=HEURES_INSUFFISANTES, fin=fin)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
yield parc
|
||||
finally:
|
||||
async with get_session_factory()() as session:
|
||||
await session.execute(delete(Prediction).where(Prediction.site_id.in_(parc.sites)))
|
||||
await session.execute(delete(Reading).where(Reading.site_id.in_(parc.sites)))
|
||||
await session.execute(delete(Site).where(Site.site_id.in_(parc.sites)))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def principal_lecteur(app: FastAPI) -> Iterator[None]:
|
||||
app.dependency_overrides[get_current_principal] = lecteur
|
||||
yield
|
||||
app.dependency_overrides.pop(get_current_principal, None)
|
||||
|
||||
|
||||
async def resume_du_site(client: AsyncClient, site_id: str) -> dict[str, Any]:
|
||||
reponse = await client.get("/api/v1/predictions")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
sites = reponse.json()["sites"]
|
||||
return next(site for site in sites if site["site_id"] == site_id)
|
||||
|
||||
|
||||
async def entraine_et_score(parc: Parc, tmp_path: Path, *arguments: str) -> Path:
|
||||
modele = tmp_path / "lightgbm-consumption.txt"
|
||||
|
||||
await executer(
|
||||
"enervision_ml.train",
|
||||
"--model-output",
|
||||
str(modele),
|
||||
"--mlflow-tracking-uri",
|
||||
f"sqlite:///{tmp_path}/mlflow.db",
|
||||
journal=tmp_path,
|
||||
)
|
||||
await executer("enervision_ml.score", "--model", str(modele), *arguments, journal=tmp_path)
|
||||
|
||||
return modele
|
||||
|
||||
|
||||
async def test_the_ml_binaries_produce_a_prediction_that_the_api_serves(
|
||||
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
|
||||
) -> None:
|
||||
await entraine_et_score(parc, tmp_path)
|
||||
|
||||
servi = await resume_du_site(client, parc.sites[0])
|
||||
|
||||
assert servi["prediction"]["status"] == "available"
|
||||
assert servi["prediction"]["predicted_value"] is not None
|
||||
assert servi["prediction"]["target_metric"] == "consumption_kwh"
|
||||
|
||||
|
||||
async def test_the_api_exposes_the_failure_reason_of_a_site_without_enough_history(
|
||||
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
|
||||
) -> None:
|
||||
await entraine_et_score(parc, tmp_path)
|
||||
|
||||
servi = await resume_du_site(client, parc.sites[-1])
|
||||
|
||||
assert servi["prediction"]["status"] == "insufficient_data"
|
||||
assert servi["prediction"]["predicted_value"] is None
|
||||
assert servi["prediction"]["failure_reason"] is not None
|
||||
|
||||
|
||||
async def test_the_api_serves_the_latest_run_when_the_score_cli_runs_twice(
|
||||
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
|
||||
) -> None:
|
||||
modele = await entraine_et_score(parc, tmp_path)
|
||||
premier = await resume_du_site(client, parc.sites[0])
|
||||
|
||||
await executer("enervision_ml.score", "--model", str(modele), journal=tmp_path)
|
||||
|
||||
second = await resume_du_site(client, parc.sites[0])
|
||||
assert second["prediction"]["created_at"] >= premier["prediction"]["created_at"]
|
||||
assert second["prediction"]["model_reference"] == premier["prediction"]["model_reference"]
|
||||
@@ -19,13 +19,17 @@ def test_build_parser_reads_the_create_admin_arguments() -> None:
|
||||
|
||||
|
||||
def test_build_parser_requires_a_subcommand() -> None:
|
||||
parser = cli.build_parser()
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.build_parser().parse_args([])
|
||||
parser.parse_args([])
|
||||
|
||||
|
||||
def test_build_parser_requires_an_email() -> None:
|
||||
parser = cli.build_parser()
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.build_parser().parse_args(["create-admin"])
|
||||
parser.parse_args(["create-admin"])
|
||||
|
||||
|
||||
def test_read_password_generates_a_long_secret_when_asked(
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.monitoring import drift as cli
|
||||
from app.repositories.drift import NouveauRapportDerive
|
||||
from app.services.drift import STATUT_DERIVE, STATUT_STABLE, Seuils
|
||||
|
||||
INSTANT = datetime(2026, 9, 22, 12, tzinfo=UTC)
|
||||
|
||||
|
||||
def rapport(*, site_id: str | None, status: str, reason: str | None = None) -> NouveauRapportDerive:
|
||||
return NouveauRapportDerive(
|
||||
site_id=site_id,
|
||||
window_start=INSTANT - timedelta(hours=168),
|
||||
window_end=INSTANT,
|
||||
reference_start=None,
|
||||
reference_end=None,
|
||||
n_observations=48,
|
||||
mae=1.5,
|
||||
mape=12.0,
|
||||
bias=0.3,
|
||||
reference_mae=1.2,
|
||||
coverage_ratio=1.0,
|
||||
insufficient_data_ratio=0.0,
|
||||
model_references=["lightgbm-aaa"],
|
||||
status=status,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def installe(monkeypatch: pytest.MonkeyPatch, rapports: list[NouveauRapportDerive]) -> None:
|
||||
async def fausse_execution(
|
||||
*, now: datetime | None, site_id: str | None, seuils: Seuils | None
|
||||
) -> list[NouveauRapportDerive]:
|
||||
return rapports
|
||||
|
||||
monkeypatch.setattr(cli, "run_drift", fausse_execution)
|
||||
|
||||
|
||||
def test_parse_args_defaults_to_the_standard_window() -> None:
|
||||
arguments = cli.parse_args([])
|
||||
|
||||
assert arguments.window_hours == 168
|
||||
assert arguments.grace_hours == 2
|
||||
assert arguments.fail_on_drift is False
|
||||
|
||||
|
||||
def test_parse_args_reads_the_site_id() -> None:
|
||||
assert cli.parse_args(["--site-id", "SITE001"]).site_id == "SITE001"
|
||||
|
||||
|
||||
def test_parse_args_parses_the_instant_option() -> None:
|
||||
arguments = cli.parse_args(["--now", "2026-09-22T12:00:00+00:00"])
|
||||
|
||||
assert arguments.now == INSTANT
|
||||
|
||||
|
||||
def test_parse_instant_treats_a_naive_datetime_as_utc() -> None:
|
||||
assert cli._parse_instant("2026-09-22T12:00:00") == INSTANT
|
||||
|
||||
|
||||
def test_seuils_depuis_translates_the_hour_options_into_durations() -> None:
|
||||
seuils = cli.seuils_depuis(cli.parse_args(["--window-hours", "24", "--grace-hours", "1"]))
|
||||
|
||||
assert seuils.fenetre == timedelta(hours=24)
|
||||
assert seuils.grace == timedelta(hours=1)
|
||||
|
||||
|
||||
def test_main_prints_the_verdict_of_every_line(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
installe(
|
||||
monkeypatch,
|
||||
[
|
||||
rapport(site_id="SITE001", status=STATUT_STABLE),
|
||||
rapport(site_id=None, status=STATUT_STABLE),
|
||||
],
|
||||
)
|
||||
|
||||
code = cli.main([])
|
||||
|
||||
sortie = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert "SITE001" in sortie
|
||||
assert "TOUS SITES" in sortie
|
||||
|
||||
|
||||
def test_main_exits_non_zero_when_drift_is_detected_and_the_flag_is_set(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
installe(monkeypatch, [rapport(site_id=None, status=STATUT_DERIVE, reason="MAE doublée")])
|
||||
|
||||
code = cli.main(["--fail-on-drift"])
|
||||
|
||||
assert code == 1
|
||||
assert "MAE doublée" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_main_exits_zero_when_drift_is_detected_without_the_flag(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
installe(monkeypatch, [rapport(site_id=None, status=STATUT_DERIVE, reason="MAE doublée")])
|
||||
|
||||
code = cli.main([])
|
||||
|
||||
assert code == 0
|
||||
assert capsys.readouterr().out != ""
|
||||
|
||||
|
||||
def test_parse_args_leaves_the_bias_threshold_disabled_by_default() -> None:
|
||||
assert cli.parse_args([]).bias_threshold == 0.0
|
||||
|
||||
|
||||
def test_seuils_depuis_carries_the_bias_threshold() -> None:
|
||||
assert cli.seuils_depuis(cli.parse_args(["--bias-threshold", "2.5"])).seuil_biais == 2.5
|
||||
Generated
+37
-37
@@ -655,31 +655,31 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pandas"
|
||||
version = "3.0.5"
|
||||
version = "3.0.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -936,27 +936,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.7"
|
||||
version = "0.16.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user