Compare commits

..
Author SHA1 Message Date
Dorian 4f69199734 feat(ml): initialise le pipeline d'entrainement LightGBM (ADR 0005)
ML / Lint, typage et tests (push) Successful in 2m2s
2026-09-17 14:12:26 +02:00
ValentinDeFariaandGitHub 016f226fdb Merge pull request #98 from ineszang/feat/scan-dépendances-dependabot
feat: ajout dependances dependabot
2026-09-17 13:43:25 +02:00
ValentinDeFariaandGitHub 88f4f9a601 chore(ci): ajoute la surveillance docker du frontend a dependabot 2026-09-17 13:42:02 +02:00
Johan LEROYandGitHub 7913518c4b Merge pull request #94 from ineszang/feat/get-readings
feat(backend): expose GET /api/v1/readings avec fenetre bornee et pag…
2026-09-17 12:12:55 +02:00
Valentin 2ad7692f1c Ajoute la configuration Dependabot (npm, uv, github-actions, docker) 2026-09-17 12:12:48 +02:00
Dorian a158d6f84c Merge remote-tracking branch 'origin/dev' into feat/get-readings 2026-09-17 11:58:27 +02:00
ineszangandGitHub 7dfd7a7e74 Merge pull request #88 from ineszang/feat/data-import
Ajout du pipeline d'import des données historiques
2026-09-17 11:57:58 +02:00
Dorian 8d28113f03 feat(backend): expose GET /api/v1/readings avec fenetre bornee et pagination 2026-09-17 11:50:20 +02:00
Meryemel-gham 6798d35572 fix(data): corrige le typage de l'import historique
Backend / Lint, typage et tests (push) Successful in 1m16s
2026-09-17 10:41:18 +02:00
Meryemel-gham f03dce5fe3 style(data): applique le formatage Ruff 2026-09-17 10:06:54 +02:00
Meryemel-gham 74ac1b4577 docs(data): documente le pipeline d'import historique 2026-09-17 09:55:27 +02:00
Meryemel-gham ebb72fb399 test(data): couvre l'import historique 2026-09-17 09:34:30 +02:00
Meryemel-gham b2d52823ba fix(data): aligne l'import historique avec les contraintes BDD 2026-09-17 09:34:30 +02:00
Meryemel-gham fcbfcc8eb2 feat(data): ajoute l'import historique des donnees 2026-09-17 09:34:30 +02:00
Johan LEROYandGitHub 3692d486c6 Merge pull request #83 from ineszang/feat/endpoint-sensors-status
feat(backend): expose GET /api/v1/sensors/status
2026-09-17 09:16:24 +02:00
ValentinDeFariaandGitHub 24bf8bf4b9 Update apps/backend/tests/services/test_sensor.py
Backend / Lint, typage et tests (push) Successful in 1m11s
2026-09-17 09:10:04 +02:00
Johan LEROYandGitHub 9f465538bf Merge pull request #85 from ineszang/feat/auth-front
feat(frontend): authentification frontend
2026-09-17 09:09:59 +02:00
Johan LEROY 515a92b395 fix(frontend): isole les fichiers de tests vitest pour eviter la pollution de mocks
Le test site-load-chart.spec.ts echouait de facon intermittente en CI : sans
isolation, vitest partage le registre de modules entre fichiers de spec, donc
le mock chart.js d'un fichier pouvait ecraser celui d'un autre selon l'ordre
d'execution.
2026-09-17 09:02:53 +02:00
ValentinDeFariaandGitHub 0174272bdd Update dashboard.html 2026-09-16 17:00:52 +02:00
ineszangandGitHub c740b61b24 Merge pull request #80 from ineszang/feat/pipeline-ci
Feat/pipeline ci
2026-09-16 16:41:55 +02:00
Valentin 5669cd63ec feat(frontend): authentification frontend
ajout de la page login, changement de mot de passe forcé, rafraîchissement de session en mémoire, intercepteur, déconnexion, bouton logout sur le dashboard
2026-09-16 16:07:28 +02:00
ineszang44 06cb60463c fix(frontend): droit d'accès au fichier de config de nginx, réduction de code smells 2026-09-16 15:12:04 +02:00
ineszang44 1c6b6105bd chore(frontend): ajout de la configuration nginx 2026-09-16 14:54:12 +02:00
Johan LEROY 77440281f8 feat(backend): expose GET /api/v1/sensors/status pour l'issue #32
Dérive l'état de santé de 5 capteurs par site et un statut overall depuis
la dernière lecture (data_quality, null_reasons, nullité des colonnes),
sur le gabarit d'agrégation de StatsService. Route réservée au rôle admin.
2026-09-16 14:53:54 +02:00
Johan LEROYandGitHub 63ee79cf32 Merge pull request #81 from ineszang/feat/endpoint-alertes-predictives
feat(backend): expose GET /api/v1/alerts
2026-09-16 14:44:27 +02:00
Johan LEROY 76fa90dfcb test(backend): exerce AlertSeverity comme enum plutot qu'une chaine dans les tests alerts
Backend / Lint, typage et tests (push) Successful in 1m8s
Le filtre severity passait par une chaine brute dans les tests, sans jamais
exercer le trajet reel AlertSeverity (enum) -> SQLAlchemy -> PostgreSQL.
2026-09-16 14:43:27 +02:00
ineszang44 6ecec1afef chore(frontend): ajout du dockerignore et du dockerfile 2026-09-16 14:40:55 +02:00
Johan LEROY e13096c62a Fusionne dev dans feat/endpoint-alertes-predictives
Resout les conflits additifs entre les routes alerts, recommendations
et stats mergees sur dev (PR #79, PR #82) pendant le developpement de
cette branche : deps.py, router.py, openapi.py, openapi.json,
test_openapi.py et 20-backend.md conservent desormais les trois routes.
2026-09-16 14:25:49 +02:00
ineszang44 970a4a50b8 fix(frontend): suppression de dépendance dans le service frontend 2026-09-16 14:22:50 +02:00
Johan LEROYandGitHub 3fb907d6f6 Merge pull request #82 from ineszang/feat/endpoint-recommandations
feat(backend): ajoute GET /recommendations et GET /recommendations/{r…
2026-09-16 14:21:08 +02:00
Johan LEROY 1654e4dd81 docs(backend): documente la checklist d'ajout d'une route metier
La generalisation de ROUTES_A_ROLE (commit precedent) avait deja ete
approuvee sur feat/openapi-contrat mais poussee apres la fermeture de
la PR #76 : elle n'a donc jamais atteint dev, et sa documentation non
plus. Complete ce qui manquait pour que le passage a l'echelle du
contrat OpenAPI soit reellement utilisable par la prochaine route.
2026-09-16 13:27:41 +02:00
Johan LEROY e50921c907 feat(backend): expose GET /api/v1/alerts
Consultation des alertes de consommation, filtrable par site_id et
severity a l'identique du contrat GET /alerts de l'API Mock. Reprend
le gabarit endpoints -> services -> repositories -> models pose par
sites, sur la table alert deja creee par la revision Alembic
e6d2026091501.

Generalise aussi le garde-fou OpenAPI du 403 (ROUTES_A_ROLE) au-dela
du seul tag users, pour que l'ajout d'alerts a la liste des routes
protegees par role soit reellement verifie.

Closes #59
2026-09-16 13:06:37 +02:00
ineszang44 730adb69b1 chore(frontend): faux positifs cwe 2026-09-16 12:32:28 +02:00
ineszang44 f43c9f76a0 test(frontend): workflow 2026-09-16 12:29:54 +02:00
ineszang44 04e4913952 fix(frontend): code smells 2026-09-16 12:20:15 +02:00
ineszang44 d1e4d8cfa0 test(frontend): lancement automatique du workflow après un push ou avec une pull request 2026-09-16 11:31:10 +02:00
ineszang44 1f0eb410eb test(frontend): suppression des conditions if 2026-09-16 11:28:49 +02:00
ineszang44 078983a41d test(frontend): suppression de la propriété 'pull-request' 2026-09-16 11:25:55 +02:00
ineszang44 596cf43eda test(frontend): lancement manuel du workflow 2026-09-16 11:22:58 +02:00
ineszang44 11baea7117 changement d'ordre des jobs + ajout des dépendances entre les jobs 2026-09-16 10:29:47 +02:00
ineszang44 61b3494d12 correction nom du workflow pour le frontend 2026-09-16 10:03:59 +02:00
ineszang b5cffbf56f fix(frontend): changement de version des actions pour raisons de compatibilité 2026-09-15 16:59:34 +02:00
ineszang 3ef7de5baa feat(frontend): ajout chemins dans le pipeline CI 2026-09-15 16:46:16 +02:00
ineszang ff6e3c288c feat(frontend): ajout du job de build 2026-09-15 16:35:50 +02:00
ineszang 2390e58f78 chore: sonarqube 2026-09-15 16:10:39 +02:00
ineszang b300be5186 chore: init de la config du frontend sur docker compose 2026-09-15 16:10:18 +02:00
ineszang b8f806518f feat(frontend): ajout sonarqube dans le pipeline 2026-09-15 16:08:31 +02:00
ineszangandGitHub 83392c7ff4 chore: init pipeline frontend 2026-09-15 14:56:33 +02:00
86 changed files with 8287 additions and 41 deletions
+27
View File
@@ -0,0 +1,27 @@
# Dépendances (réinstallées dans l'image)
node_modules/
vendor/
__pycache__/
*.pyc
# Git et IDE
.git/
.gitignore
.vscode/
.idea/
*.swp
# Fichiers de build locaux
dist/
build/
*.log
# Secrets et config locale (CRITIQUE : risque d'exfiltration)
.env
.env.local
*.pem
*.key
secrets/
.npmrc
.pypirc
kubeconfig
+40
View File
@@ -0,0 +1,40 @@
version: 2
updates:
# Frontend — npm
- package-ecosystem: "npm"
directory: "/apps/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
frontend-dependencies:
patterns:
- "*"
# Backend — uv (lit pyproject.toml / uv.lock)
- package-ecosystem: "uv"
directory: "/apps/backend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
backend-dependencies:
patterns:
- "*"
# Les workflows GitHub Actions eux-mêmes ont aussi des dépendances à jour
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# Si un Dockerfile existe pour le backend
- package-ecosystem: "docker"
directory: "/apps/backend"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/apps/frontend"
schedule:
interval: "weekly"
+77
View File
@@ -0,0 +1,77 @@
name: Frontend
# Pipeline à choix multiple
on:
# workflow_dispatch -> lancement manuel des jobs
workflow_dispatch:
inputs:
job_choice:
required: true
description: "Choix du job"
type: choice
default: all
options:
- build
- sonarqube
- test
- all # lancer tous les jobs
push:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
pull_request:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
# Ordre de lancement des jobs
# build -> test -> sonarqube -> deploy
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm run build
working-directory: apps/frontend
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm test -- --watch=false
working-directory: apps/frontend
sonarqube:
needs: [build, test]
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# deploy:
# runs-on: ubuntu-latest
# steps:
# - run: echo "DEPLOY job is running"
+59
View File
@@ -0,0 +1,59 @@
name: ML
# Piège : la version de Python vient de ml/.python-version, et doit rester en 3.14 (cf.
# .github/workflows/backend.yml, même contrainte).
on:
push:
paths:
- "ml/**"
- ".github/workflows/ml.yml"
pull_request:
paths:
- "ml/**"
- ".github/workflows/ml.yml"
permissions:
contents: read
concurrency:
group: ml-${{ github.ref }}
cancel-in-progress: true
jobs:
verification:
name: Lint, typage et tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: ml
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
- name: Vérifie le formatage
run: uv run ruff format --check .
- name: Analyse statique
run: uv run ruff check --output-format=github .
- name: Typage
run: uv run mypy enervision_ml tests
# Aucun test ne touche PostgreSQL ni MLflow distant : tout tourne sur donnees
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py).
- name: Tests
run: uv run pytest
+10 -1
View File
@@ -52,11 +52,20 @@ standalone_admin_password.txt
secrets/
# Donnees locales
data/
data/raw/*
!data/raw/.gitkeep
*.sqlite3
monitoring/grafana/data/
monitoring/prometheus/data/
# ML : jeu de donnees, modeles entraines et suivi MLflow local, tous generes/volumineux
ml/data/
ml/models/*
!ml/models/.gitkeep
ml/mlruns/
ml/mlartifacts/
ml/mlflow.db
# IDE et OS
.idea/
.vscode/
+22 -3
View File
@@ -1,15 +1,17 @@
BACKEND := apps/backend
FRONTEND := apps/frontend
ML := ml
.DEFAULT_GOAL := help
.PHONY: help install install-backend install-frontend dev dev-backend dev-frontend \
.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \
lint format typecheck test test-cov test-integration check \
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
ml-lint ml-typecheck ml-test ml-check ml-train
help: ## Liste les cibles disponibles
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
install: install-backend install-frontend ## Installe les dépendances backend et frontend
install: install-backend install-frontend install-ml ## Installe les dépendances backend, frontend et ML
install-backend: ## Installe les dépendances du backend
cd $(BACKEND) && uv sync --all-groups
@@ -17,6 +19,9 @@ install-backend: ## Installe les dépendances du backend
install-frontend: ## Installe les dépendances du frontend
cd $(FRONTEND) && npm ci
install-ml: ## Installe les dépendances du pipeline ML
cd $(ML) && uv sync --all-groups
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
@trap 'kill 0' EXIT INT TERM; \
$(MAKE) --no-print-directory dev-backend & \
@@ -55,6 +60,20 @@ check: lint typecheck test ## Chaîne de vérification complète
openapi: ## Régénère apps/backend/openapi.json depuis les routes déclarées
cd $(BACKEND) && uv run python -m app.cli export-openapi
ml-lint: ## Analyse statique du pipeline ML
cd $(ML) && uv run ruff check .
ml-typecheck: ## Vérifie le typage du pipeline ML
cd $(ML) && uv run mypy enervision_ml tests
ml-test: ## Exécute les tests du pipeline ML (donnees synthetiques, sans base ni serveur MLflow)
cd $(ML) && uv run pytest
ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL
cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),)
docker-build: ## Construit l'image du backend
docker build -t enervision-backend:local $(BACKEND)
+2
View File
@@ -25,6 +25,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
@@ -53,6 +54,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
├── infra/terraform/
│ ├── modules/ Modules reutilisables
│ └── environments/ Racines Terraform, une par environnement
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
├── monitoring/
│ ├── prometheus/ Collecte et regles d'alerte
│ ├── grafana/ Provisioning et dashboards
+25
View File
@@ -21,6 +21,7 @@ from app.core.roles import AccountKind, Role, has_at_least
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy
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.login_attempt import LoginAttemptRepository
from app.repositories.reading import ReadingRepository
@@ -28,8 +29,11 @@ from app.repositories.recommendation import RecommendationRepository
from app.repositories.refresh_token import RefreshTokenRepository
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
from app.services.reading import ReadingService
from app.services.recommendation import RecommendationService
from app.services.sensor import SensorService
from app.services.site import SiteService
from app.services.stats import StatsService
from app.services.user import UserService
@@ -144,6 +148,13 @@ def get_site_service(session: SessionDep) -> SiteService:
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
def get_alert_service(session: SessionDep) -> AlertService:
return AlertService(alerts=AlertRepository(session))
AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)]
def get_recommendation_service(session: SessionDep) -> RecommendationService:
return RecommendationService(recommendations=RecommendationRepository(session))
@@ -158,6 +169,20 @@ def get_stats_service(session: SessionDep) -> StatsService:
StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)]
def get_reading_service(session: SessionDep) -> ReadingService:
return ReadingService(readings=ReadingRepository(session))
ReadingServiceDep = Annotated[ReadingService, Depends(get_reading_service)]
def get_sensor_service(session: SessionDep) -> SensorService:
return SensorService(sites=SiteRepository(session), readings=ReadingRepository(session))
SensorServiceDep = Annotated[SensorService, Depends(get_sensor_service)]
async def get_current_principal(
credentials: CredentialsDep,
session: SessionDep,
+17
View File
@@ -54,6 +54,11 @@ TAGS: Final[list[dict[str, Any]]] = [
"name": "sites",
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.",
},
{
"name": "alerts",
"description": "Consultation des alertes de consommation. Accessible à partir du rôle "
"`lecteur`.",
},
{
"name": "recommendations",
"description": (
@@ -66,6 +71,18 @@ TAGS: Final[list[dict[str, Any]]] = [
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle "
"`lecteur`.",
},
{
"name": "readings",
"description": (
"Historique des lectures de consommation. Fenêtre temporelle plafonnée à 90 jours, "
"24 dernières heures par défaut si `start`/`end` sont omis. Accessible à partir du "
"rôle `lecteur`."
),
},
{
"name": "sensors",
"description": "État de santé des capteurs par site. Réservé au rôle `admin`.",
},
]
cookie_de_rafraichissement = APIKeyCookie(
@@ -0,0 +1,23 @@
from fastapi import APIRouter
from app.api.deps import AlertServiceDep, LecteurDep
from app.api.openapi import REPONSE_VALIDATION
from app.schemas.alert import AlertResponse, AlertSeverity
router = APIRouter()
@router.get(
"",
response_model=list[AlertResponse],
summary="Liste les alertes",
responses=REPONSE_VALIDATION,
)
async def list_alerts(
_: LecteurDep,
service: AlertServiceDep,
site_id: str | None = None,
severity: AlertSeverity | None = None,
) -> list[AlertResponse]:
alertes = await service.list_all(site_id=site_id, severity=severity)
return [AlertResponse.model_validate(alerte) for alerte in alertes]
@@ -0,0 +1,54 @@
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, status
from app.api.deps import LecteurDep, ReadingServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.reading import ReadingResponse
from app.services.reading import FenetreInverseeError, FenetreTropLargeError
router = APIRouter()
REPONSES_FENETRE: Reponses = {
**REPONSE_VALIDATION,
400: {
"model": ErrorResponse,
"description": (
"Fenêtre temporelle invalide : `start` postérieur ou égal à `end`, ou écart entre "
"les deux supérieur à 90 jours."
),
},
}
@router.get(
"",
response_model=list[ReadingResponse],
summary="Liste l'historique des lectures",
responses=REPONSES_FENETRE,
)
async def list_readings(
_: LecteurDep,
service: ReadingServiceDep,
site_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int = Query(500, ge=1, le=2000),
offset: int = Query(0, ge=0),
) -> list[ReadingResponse]:
try:
lectures = await service.list_history(
site_id=site_id, start=start, end=end, limit=limit, offset=offset
)
except FenetreInverseeError as erreur:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="`start` doit être strictement antérieur à `end`",
) from erreur
except FenetreTropLargeError as erreur:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="L'écart entre `start` et `end` ne peut pas dépasser 90 jours",
) from erreur
return [ReadingResponse.model_validate(lecture) for lecture in lectures]
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from app.api.deps import AdminDep, SensorServiceDep
from app.schemas.sensor import SensorStatusResponse
router = APIRouter()
@router.get(
"/status",
response_model=SensorStatusResponse,
summary="État de santé des capteurs par site",
)
async def get_status(_: AdminDep, service: SensorServiceDep) -> SensorStatusResponse:
etat = await service.status()
return SensorStatusResponse.model_validate(etat)
+20 -1
View File
@@ -1,13 +1,26 @@
from fastapi import APIRouter
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
from app.api.v1.endpoints import auth, health, recommendations, sites, stats, users
from app.api.v1.endpoints import (
alerts,
auth,
health,
readings,
recommendations,
sensors,
sites,
stats,
users,
)
api_router = APIRouter(responses=REPONSE_SERVEUR)
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR)
api_router.include_router(
alerts.router, prefix="/alerts", tags=["alerts"], responses=REPONSES_LECTEUR
)
api_router.include_router(
recommendations.router,
prefix="/recommendations",
@@ -15,3 +28,9 @@ api_router.include_router(
responses=REPONSES_LECTEUR,
)
api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR)
api_router.include_router(
readings.router, prefix="/readings", tags=["readings"], responses=REPONSES_LECTEUR
)
api_router.include_router(
sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN
)
View File
+621
View File
@@ -0,0 +1,621 @@
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
from pathlib import Path
from typing import Any, cast
import pandas as pd
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import get_settings
REQUIRED_COLUMNS = {
"timestamp",
"site_id",
"site_type",
"site_name",
"consumption_kwh",
"consumption_euros",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
"hour",
"day_of_week",
"day_name",
"month",
"is_weekend",
"is_working_hours",
}
MEASURE_COLUMNS = [
"consumption_kwh",
"consumption_euros",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
]
SOURCE_NAME = "csv"
def compute_sha256(path: Path) -> str:
"""Calcule l'empreinte SHA-256 du fichier source."""
sha256 = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
sha256.update(block)
return sha256.hexdigest()
def load_metadata(path: Path) -> dict[str, Any]:
"""Charge les métadonnées fournies avec le dataset."""
with path.open("r", encoding="utf-8") as source:
metadata = json.load(source)
if not isinstance(metadata, dict):
raise ValueError("Le fichier de métadonnées doit contenir un objet JSON.")
return cast(dict[str, Any], metadata)
def classify_quality(
row: dict[str, Any],
) -> tuple[str, list[str]]:
"""
Déduit une qualité technique à partir des champs manquants.
Les valeurs NULL sont conservées. On ne cherche pas ici à
déterminer la cause physique exacte de leur absence.
"""
missing = [column for column in MEASURE_COLUMNS if pd.isna(row.get(column))]
if not missing:
quality = "good"
elif len(missing) == len(MEASURE_COLUMNS):
quality = "critical"
elif "consumption_kwh" in missing:
quality = "degraded"
else:
quality = "partial"
reasons = [f"missing:{column}" for column in missing]
return quality, reasons
def validate_source(
frame: pd.DataFrame,
metadata: dict[str, Any],
) -> None:
"""Valide le dataset avant tout chargement en base."""
missing_columns = REQUIRED_COLUMNS.difference(frame.columns)
if missing_columns:
raise ValueError(f"Colonnes obligatoires absentes : {sorted(missing_columns)}")
expected_records = int(metadata["total_records"])
if len(frame) != expected_records:
raise ValueError(f"Nombre de lignes inattendu : {len(frame)} au lieu de {expected_records}")
expected_sites = set(metadata["sites"].keys())
actual_sites = set(frame["site_id"].unique())
if actual_sites != expected_sites:
raise ValueError(
f"Sites incohérents. Attendus={sorted(expected_sites)}, trouvés={sorted(actual_sites)}"
)
duplicated = frame.duplicated(subset=["site_id", "timestamp"]).sum()
if duplicated:
raise ValueError(f"{duplicated} doublons (site_id, timestamp) détectés")
static_variants = frame.groupby("site_id")[["site_type", "site_name"]].nunique()
if (static_variants > 1).any().any():
raise ValueError("Un site possède plusieurs valeurs de site_type ou site_name.")
# Vérifie également que tous les timestamps
# peuvent être interprétés correctement.
pd.to_datetime(
frame["timestamp"],
errors="raise",
)
def normalize_timestamps(
frame: pd.DataFrame,
source_timezone: str,
) -> pd.DataFrame:
"""
Normalise les timestamps et leur associe une timezone.
Les timestamps originaux sont conservés dans une colonne
temporaire afin de pouvoir les stocker dans raw_data.
"""
normalized = frame.copy()
normalized["_source_timestamp"] = normalized["timestamp"]
timestamps = pd.to_datetime(
normalized["timestamp"],
errors="raise",
)
if timestamps.dt.tz is None:
timestamps = timestamps.dt.tz_localize(source_timezone)
else:
timestamps = timestamps.dt.tz_convert(source_timezone)
normalized["timestamp"] = timestamps
return normalized
def to_json_value(value: Any) -> Any:
"""
Convertit une valeur Pandas/Numpy en valeur
compatible JSON.
"""
if value is None:
return None
try:
if pd.isna(value):
return None
except TypeError, ValueError:
pass
if isinstance(value, pd.Timestamp):
return value.isoformat()
if hasattr(value, "item"):
return value.item()
return value
async def ensure_dataset(
connection: AsyncConnection,
metadata: dict[str, Any],
sha256: str,
source_timezone: str,
storage_uri: str,
) -> int:
"""
Crée l'entrée dataset si elle n'existe pas.
Le SHA-256 permet de reconnaître un fichier déjà importé
et participe à l'idempotence et à la traçabilité.
"""
result = await connection.execute(
text(
"""
SELECT dataset_id
FROM dataset
WHERE archive_sha256 = :sha256
LIMIT 1
"""
),
{
"sha256": sha256,
},
)
existing = result.scalar_one_or_none()
if existing is not None:
return int(existing)
metadata_summary = {
"generator_version": metadata.get("generator_version"),
"total_sites": metadata.get("total_sites"),
"total_records": metadata.get("total_records"),
"date_range": metadata.get("date_range"),
"frequency": metadata.get("frequency"),
"null_injection_enabled": metadata.get("null_injection_enabled"),
"null_strategies": metadata.get("null_strategies"),
"importer": "historical_import_v1",
}
result = await connection.execute(
text(
"""
INSERT INTO dataset (
dataset_name,
archive_sha256,
storage_uri,
source_timezone,
"metadata"
)
VALUES (
:dataset_name,
:archive_sha256,
:storage_uri,
:source_timezone,
CAST(:metadata AS jsonb)
)
RETURNING dataset_id
"""
),
{
"dataset_name": ("EnerVision historical dataset 2023-2024"),
"archive_sha256": sha256,
"storage_uri": storage_uri,
"source_timezone": source_timezone,
"metadata": json.dumps(
metadata_summary,
ensure_ascii=False,
),
},
)
return int(result.scalar_one())
async def upsert_sites(
connection: AsyncConnection,
frame: pd.DataFrame,
) -> None:
"""Insère ou met à jour les sites du dataset."""
sites = cast(
list[dict[str, Any]],
frame[
[
"site_id",
"site_type",
"site_name",
]
]
.drop_duplicates(subset=["site_id"])
.to_dict(orient="records"),
)
await connection.execute(
text(
"""
INSERT INTO site (
site_id,
site_type,
site_name
)
VALUES (
:site_id,
:site_type,
:site_name
)
ON CONFLICT (site_id)
DO UPDATE SET
site_type = EXCLUDED.site_type,
site_name = EXCLUDED.site_name
"""
),
sites,
)
def build_reading_batch(
chunk: pd.DataFrame,
dataset_id: int,
) -> list[dict[str, Any]]:
"""
Transforme un chunk Pandas en lignes prêtes
à être chargées dans la table reading.
"""
rows: list[dict[str, Any]] = []
records = cast(
list[dict[str, Any]],
chunk.to_dict(orient="records"),
)
for record in records:
quality, reasons = classify_quality(record)
raw_data = {
column: to_json_value(value)
for column, value in record.items()
if column != "_source_timestamp"
}
# Dans raw_data, on conserve le timestamp
# exactement tel qu'il était dans le CSV.
raw_data["timestamp"] = to_json_value(record["_source_timestamp"])
rows.append(
{
"site_id": record["site_id"],
"timestamp": record["timestamp"],
"source": SOURCE_NAME,
"dataset_id": dataset_id,
# Non fourni par le dataset historique.
"consumption_kw": None,
"consumption_kwh": to_json_value(record["consumption_kwh"]),
"consumption_euros": to_json_value(record["consumption_euros"]),
# Non fournis par le CSV historique.
"voltage_v": None,
"current_a": None,
"power_factor": None,
"temperature_celsius": (to_json_value(record["temperature_celsius"])),
"humidity_percent": (to_json_value(record["humidity_percent"])),
"solar_irradiance_wm2": (to_json_value(record["solar_irradiance_wm2"])),
"is_working_hours": bool(record["is_working_hours"]),
"data_quality": quality,
"null_reasons": reasons,
# Aucune imputation pendant l'ingestion RAW.
# Les valeurs manquantes sont conservées telles quelles
# afin de préserver la donnée source.
"imputed_values": None,
"imputation_method": None,
# Conservation de la donnée source
# pour la traçabilité.
"raw_data": json.dumps(
raw_data,
ensure_ascii=False,
),
}
)
return rows
READING_INSERT = text(
"""
INSERT INTO reading (
site_id,
timestamp,
source,
dataset_id,
consumption_kw,
consumption_kwh,
consumption_euros,
voltage_v,
current_a,
power_factor,
temperature_celsius,
humidity_percent,
solar_irradiance_wm2,
is_working_hours,
data_quality,
null_reasons,
imputed_values,
imputation_method,
raw_data
)
VALUES (
:site_id,
:timestamp,
:source,
:dataset_id,
:consumption_kw,
:consumption_kwh,
:consumption_euros,
:voltage_v,
:current_a,
:power_factor,
:temperature_celsius,
:humidity_percent,
:solar_irradiance_wm2,
:is_working_hours,
:data_quality,
:null_reasons,
CAST(:imputed_values AS jsonb),
:imputation_method,
CAST(:raw_data AS jsonb)
)
ON CONFLICT DO NOTHING
"""
)
async def import_historical(
csv_path: Path,
metadata_path: Path,
source_timezone: str,
batch_size: int,
dry_run: bool,
storage_uri: str,
) -> None:
"""
Exécute le pipeline ETL historique EnerVision.
Étapes :
1. Extract
2. Validate
3. Transform
4. Load
"""
metadata = load_metadata(metadata_path)
frame = pd.read_csv(csv_path)
validate_source(
frame,
metadata,
)
print(f"Lignes : {len(frame)}")
print(f"Sites : {frame['site_id'].nunique()}")
print(f"Période : {frame['timestamp'].min()} -> {frame['timestamp'].max()}")
print(f"Doublons : {frame.duplicated(['site_id', 'timestamp']).sum()}")
print("\nValeurs NULL :")
print(frame[MEASURE_COLUMNS].isna().sum())
sha256 = compute_sha256(csv_path)
print(f"\nSHA-256 : {sha256}")
if dry_run:
print("\nDry-run terminé : aucune donnée écrite.")
return
normalized = normalize_timestamps(
frame,
source_timezone,
)
settings = get_settings()
engine = create_async_engine(
str(settings.database_url),
pool_pre_ping=True,
)
try:
async with engine.begin() as connection:
dataset_id = await ensure_dataset(
connection=connection,
metadata=metadata,
sha256=sha256,
source_timezone=source_timezone,
storage_uri=storage_uri,
)
await upsert_sites(
connection,
normalized,
)
result = await connection.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE dataset_id = :dataset_id
AND source = :source
"""
),
{
"dataset_id": dataset_id,
"source": SOURCE_NAME,
},
)
before = int(result.scalar_one())
for start in range(
0,
len(normalized),
batch_size,
):
chunk = normalized.iloc[start : start + batch_size]
rows = build_reading_batch(
chunk,
dataset_id,
)
await connection.execute(
READING_INSERT,
rows,
)
loaded = min(
start + batch_size,
len(normalized),
)
print(f"Chargement : {loaded}/{len(normalized)}")
result = await connection.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE dataset_id = :dataset_id
AND source = :source
"""
),
{
"dataset_id": dataset_id,
"source": SOURCE_NAME,
},
)
after = int(result.scalar_one())
print("\nImport terminé.")
print(f"dataset_id : {dataset_id}")
print(f"lectures avant : {before}")
print(f"lectures après : {after}")
print(f"nouvelles lectures : {after - before}")
finally:
await engine.dispose()
def parse_args() -> argparse.Namespace:
"""Définit les arguments CLI de l'import."""
parser = argparse.ArgumentParser(description=("Import historique EnerVision"))
parser.add_argument(
"--csv",
type=Path,
required=True,
help="Chemin vers le CSV historique.",
)
parser.add_argument(
"--metadata",
type=Path,
required=True,
help=("Chemin vers le fichier dataset_metadata.json."),
)
parser.add_argument(
"--source-timezone",
default="UTC",
help=("Timezone associée aux timestamps du dataset. Défaut : UTC."),
)
parser.add_argument(
"--batch-size",
type=int,
default=1000,
help=("Nombre de lignes insérées par batch. Défaut : 1000."),
)
parser.add_argument(
"--dry-run",
action="store_true",
help=("Valide les données sans écrire en base."),
)
return parser.parse_args()
def main() -> None:
"""Point d'entrée CLI du pipeline."""
args = parse_args()
if args.batch_size <= 0:
raise ValueError("--batch-size doit être strictement supérieur à 0.")
# resolve() est volontairement exécuté ici,
# dans la partie synchrone du programme.
# Cela évite une opération filesystem bloquante
# à l'intérieur d'une fonction async.
storage_uri = args.csv.resolve().as_uri()
asyncio.run(
import_historical(
csv_path=args.csv,
metadata_path=args.metadata,
source_timezone=(args.source_timezone),
batch_size=args.batch_size,
dry_run=args.dry_run,
storage_uri=storage_uri,
)
)
if __name__ == "__main__":
main()
+21
View File
@@ -0,0 +1,21 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert
class AlertRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> Sequence[Alert]:
requete = select(Alert).order_by(Alert.timestamp.desc(), Alert.alert_id.desc())
if site_id is not None:
requete = requete.where(Alert.site_id == site_id)
if severity is not None:
requete = requete.where(Alert.severity == severity)
return (await self._session.scalars(requete)).all()
+21
View File
@@ -1,4 +1,5 @@
from collections.abc import Sequence
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -19,3 +20,23 @@ class ReadingRepository:
.order_by(Reading.site_id, Reading.timestamp.desc())
)
return (await self._session.execute(requete)).scalars().all()
async def list_history(
self,
*,
start: datetime,
end: datetime,
site_id: str | None = None,
limit: int,
offset: int,
) -> Sequence[Reading]:
requete = (
select(Reading)
.where(Reading.timestamp >= start, Reading.timestamp < end)
.order_by(Reading.timestamp.desc(), Reading.reading_id.desc())
.limit(limit)
.offset(offset)
)
if site_id is not None:
requete = requete.where(Reading.site_id == site_id)
return (await self._session.scalars(requete)).all()
+34
View File
@@ -0,0 +1,34 @@
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
class AlertType(StrEnum):
SPIKE = "spike"
THRESHOLD = "threshold"
ANOMALY = "anomaly"
OUTAGE = "outage"
SENSOR = "sensor"
class AlertSeverity(StrEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class AlertResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
alert_id: int
site_id: str
timestamp: datetime
type: AlertType
severity: AlertSeverity
message: str
value: float | None
threshold: float | None
metric: str | None
prediction_id: int | None
+45
View File
@@ -0,0 +1,45 @@
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict
class ReadingSource(StrEnum):
CSV = "csv"
API_CURRENT = "api_current"
API_HISTORY = "api_history"
class ReadingDataQuality(StrEnum):
GOOD = "good"
PARTIAL = "partial"
DEGRADED = "degraded"
CRITICAL = "critical"
class ReadingResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
reading_id: int
site_id: str
timestamp: datetime
source: ReadingSource
consumption_kw: float | None
consumption_kwh: float | None
# Piège : `Decimal` (miroir de `Numeric(14, 2)` en base, pour ne pas arrondir un montant)
# sérialise en chaîne dans le JSON, pas en nombre — un consommateur qui ferait un `parseFloat`
# naïf perdrait la précision que ce choix visait à garder.
consumption_euros: Decimal | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
solar_irradiance_wm2: float | None
is_working_hours: bool | None
data_quality: ReadingDataQuality | None
null_reasons: list[str] | None
imputed_values: dict[str, Any] | None
imputation_method: str | None
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class SensorDiagnosticResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
status: Literal["ok", "failing"]
since: datetime | None = Field(
description=(
"Horodatage de la dernière lecture reçue pour ce site. Ce n'est pas le début de la "
"panne : l'historique ne permet pas de le dater sans requête supplémentaire."
)
)
class SiteSensorsResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
consumption: SensorDiagnosticResponse
electrical: SensorDiagnosticResponse
temperature: SensorDiagnosticResponse
humidity: SensorDiagnosticResponse
network: SensorDiagnosticResponse
class SiteSensorStatusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
sensors: SiteSensorsResponse
overall: Literal["ok", "degraded", "critical"]
class SensorStatusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime
sites: list[SiteSensorStatusResponse]
+14
View File
@@ -0,0 +1,14 @@
from collections.abc import Sequence
from app.models.energy import Alert
from app.repositories.alert import AlertRepository
class AlertService:
def __init__(self, *, alerts: AlertRepository) -> None:
self._alerts = alerts
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> Sequence[Alert]:
return await self._alerts.list_all(site_id=site_id, severity=severity)
+59
View File
@@ -0,0 +1,59 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from app.models.energy import Reading
from app.repositories.reading import ReadingRepository
FENETRE_PAR_DEFAUT = timedelta(hours=24)
FENETRE_MAXIMALE = timedelta(days=90)
class FenetreInverseeError(Exception):
"""`start` est postérieur ou égal à `end`."""
class FenetreTropLargeError(Exception):
"""L'écart entre `start` et `end` dépasse `FENETRE_MAXIMALE`."""
class ReadingService:
def __init__(self, *, readings: ReadingRepository) -> None:
self._readings = readings
async def list_history(
self,
*,
site_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int,
offset: int,
) -> Sequence[Reading]:
debut, fin = self._resoudre_fenetre(start, end)
return await self._readings.list_history(
site_id=site_id, start=debut, end=fin, limit=limit, offset=offset
)
@staticmethod
def _resoudre_fenetre(
start: datetime | None, end: datetime | None
) -> tuple[datetime, datetime]:
# Piège : un datetime naïf (sans fuseau dans la chaîne ISO reçue) fait échouer la
# comparaison à `reading.timestamp` (`timestamptz`) au niveau du pilote, en 500 plutôt
# qu'un refus propre. On le traite comme de l'UTC plutôt que de le rejeter.
debut = _vers_utc(start)
fin = _vers_utc(end) or datetime.now(UTC)
if debut is None:
debut = fin - FENETRE_PAR_DEFAUT
if debut >= fin:
raise FenetreInverseeError
if fin - debut > FENETRE_MAXIMALE:
raise FenetreTropLargeError
return debut, fin
def _vers_utc(instant: datetime | None) -> datetime | None:
if instant is None:
return None
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
+137
View File
@@ -0,0 +1,137 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
CapteurStatus = Literal["ok", "failing"]
OverallStatus = Literal["ok", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
RAISON_VERS_CAPTEUR: dict[str, str] = {
"consumption_sensor_failure": "consumption",
"electrical_sensor_failure": "electrical",
"temperature_sensor_failure": "temperature",
"humidity_sensor_failure": "humidity",
"network_loss": "network",
}
CHAMPS_PAR_CAPTEUR: dict[str, tuple[str, ...]] = {
"consumption": ("consumption_kw",),
"electrical": ("voltage_v", "current_a", "power_factor"),
"temperature": ("temperature_celsius",),
"humidity": ("humidity_percent",),
}
@dataclass(frozen=True, slots=True)
class DiagnosticCapteur:
status: CapteurStatus
since: datetime | None
@dataclass(frozen=True, slots=True)
class SanteCapteurs:
consumption: DiagnosticCapteur
electrical: DiagnosticCapteur
temperature: DiagnosticCapteur
humidity: DiagnosticCapteur
network: DiagnosticCapteur
@dataclass(frozen=True, slots=True)
class SanteSite:
site_id: str
site_name: str
sensors: SanteCapteurs
overall: OverallStatus
@dataclass(frozen=True, slots=True)
class EtatCapteurs:
timestamp: datetime
sites: list[SanteSite]
class SensorService:
def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def status(self) -> EtatCapteurs:
sites = await self._sites.list_all()
dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()}
return EtatCapteurs(
timestamp=datetime.now(UTC),
sites=[_sante_site(site, dernieres.get(site.site_id)) for site in sites],
)
def _sante_site(site: Site, derniere: Reading | None) -> SanteSite:
if derniere is None:
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=_tout_en_echec(since=None),
overall="critical",
)
qualite = derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical"
overall = _overall_depuis_qualite(qualite)
if overall == "critical":
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=_tout_en_echec(since=derniere.timestamp),
overall="critical",
)
raisons_signalees = {
RAISON_VERS_CAPTEUR[raison]
for raison in (derniere.null_reasons or [])
if raison in RAISON_VERS_CAPTEUR
}
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=SanteCapteurs(
consumption=_diagnostic("consumption", derniere, raisons_signalees),
electrical=_diagnostic("electrical", derniere, raisons_signalees),
temperature=_diagnostic("temperature", derniere, raisons_signalees),
humidity=_diagnostic("humidity", derniere, raisons_signalees),
network=_diagnostic("network", derniere, raisons_signalees),
),
overall=overall,
)
def _overall_depuis_qualite(qualite: str) -> OverallStatus:
if qualite == "good":
return "ok"
if qualite in ("partial", "degraded"):
return "degraded"
return "critical"
def _diagnostic(capteur: str, derniere: Reading, raisons_signalees: set[str]) -> DiagnosticCapteur:
champs = CHAMPS_PAR_CAPTEUR.get(capteur, ())
en_echec = capteur in raisons_signalees or any(
getattr(derniere, champ) is None for champ in champs
)
return DiagnosticCapteur(
status="failing" if en_echec else "ok",
since=derniere.timestamp if en_echec else None,
)
def _tout_en_echec(since: datetime | None) -> SanteCapteurs:
echec = DiagnosticCapteur(status="failing", since=since)
return SanteCapteurs(
consumption=echec, electrical=echec, temperature=echec, humidity=echec, network=echec
)
+765
View File
@@ -921,6 +921,110 @@
}
}
},
"/api/v1/alerts": {
"get": {
"tags": [
"alerts"
],
"summary": "Liste les alertes",
"operationId": "list_alerts_api_v1_alerts_get",
"security": [
{
"Jeton d'accès": []
}
],
"parameters": [
{
"name": "site_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id"
}
},
{
"name": "severity",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/AlertSeverity"
},
{
"type": "null"
}
],
"title": "Severity"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AlertResponse"
},
"title": "Response List Alerts Api V1 Alerts 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": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"422": {
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationErrorResponse"
}
}
}
}
}
}
},
"/api/v1/recommendations": {
"get": {
"tags": [
@@ -1123,6 +1227,217 @@
}
]
}
},
"/api/v1/readings": {
"get": {
"tags": [
"readings"
],
"summary": "Liste l'historique des lectures",
"operationId": "list_readings_api_v1_readings_get",
"security": [
{
"Jeton d'accès": []
}
],
"parameters": [
{
"name": "site_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id"
}
},
{
"name": "start",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Start"
}
},
{
"name": "end",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "End"
}
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"maximum": 2000,
"minimum": 1,
"default": 500,
"title": "Limit"
}
},
{
"name": "offset",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0,
"title": "Offset"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ReadingResponse"
},
"title": "Response List Readings Api V1 Readings 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": "Mot de passe provisoire à changer (`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"
}
}
}
},
"400": {
"description": "Fenêtre temporelle invalide : `start` postérieur ou égal à `end`, ou écart entre les deux supérieur à 90 jours.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/sensors/status": {
"get": {
"tags": [
"sensors"
],
"summary": "État de santé des capteurs par site",
"operationId": "get_status_api_v1_sensors_status_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SensorStatusResponse"
}
}
}
},
"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"
}
}
}
}
},
"security": [
{
"Jeton d'accès": []
}
]
}
}
},
"components": {
@@ -1135,6 +1450,112 @@
],
"title": "AccountKind"
},
"AlertResponse": {
"properties": {
"alert_id": {
"type": "integer",
"title": "Alert Id"
},
"site_id": {
"type": "string",
"title": "Site Id"
},
"timestamp": {
"type": "string",
"format": "date-time",
"title": "Timestamp"
},
"type": {
"$ref": "#/components/schemas/AlertType"
},
"severity": {
"$ref": "#/components/schemas/AlertSeverity"
},
"message": {
"type": "string",
"title": "Message"
},
"value": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Value"
},
"threshold": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Threshold"
},
"metric": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Metric"
},
"prediction_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Prediction Id"
}
},
"type": "object",
"required": [
"alert_id",
"site_id",
"timestamp",
"type",
"severity",
"message",
"value",
"threshold",
"metric",
"prediction_id"
],
"title": "AlertResponse"
},
"AlertSeverity": {
"type": "string",
"enum": [
"low",
"medium",
"high",
"critical"
],
"title": "AlertSeverity"
},
"AlertType": {
"type": "string",
"enum": [
"spike",
"threshold",
"anomaly",
"outage",
"sensor"
],
"title": "AlertType"
},
"ErrorResponse": {
"properties": {
"detail": {
@@ -1314,6 +1735,225 @@
],
"title": "ReadinessStatus"
},
"ReadingDataQuality": {
"type": "string",
"enum": [
"good",
"partial",
"degraded",
"critical"
],
"title": "ReadingDataQuality"
},
"ReadingResponse": {
"properties": {
"reading_id": {
"type": "integer",
"title": "Reading Id"
},
"site_id": {
"type": "string",
"title": "Site Id"
},
"timestamp": {
"type": "string",
"format": "date-time",
"title": "Timestamp"
},
"source": {
"$ref": "#/components/schemas/ReadingSource"
},
"consumption_kw": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Consumption Kw"
},
"consumption_kwh": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Consumption Kwh"
},
"consumption_euros": {
"anyOf": [
{
"type": "string",
"pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"
},
{
"type": "null"
}
],
"title": "Consumption Euros"
},
"voltage_v": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Voltage V"
},
"current_a": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Current A"
},
"power_factor": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Power Factor"
},
"temperature_celsius": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Temperature Celsius"
},
"humidity_percent": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Humidity Percent"
},
"solar_irradiance_wm2": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Solar Irradiance Wm2"
},
"is_working_hours": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Is Working Hours"
},
"data_quality": {
"anyOf": [
{
"$ref": "#/components/schemas/ReadingDataQuality"
},
{
"type": "null"
}
]
},
"null_reasons": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Null Reasons"
},
"imputed_values": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Imputed Values"
},
"imputation_method": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Imputation Method"
}
},
"type": "object",
"required": [
"reading_id",
"site_id",
"timestamp",
"source",
"consumption_kw",
"consumption_kwh",
"consumption_euros",
"voltage_v",
"current_a",
"power_factor",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
"is_working_hours",
"data_quality",
"null_reasons",
"imputed_values",
"imputation_method"
],
"title": "ReadingResponse"
},
"ReadingSource": {
"type": "string",
"enum": [
"csv",
"api_current",
"api_history"
],
"title": "ReadingSource"
},
"RecommendationResponse": {
"properties": {
"recommendation_id": {
@@ -1362,6 +2002,59 @@
],
"title": "Role"
},
"SensorDiagnosticResponse": {
"properties": {
"status": {
"type": "string",
"enum": [
"ok",
"failing"
],
"title": "Status"
},
"since": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Since",
"description": "Horodatage de la dernière lecture reçue pour ce site. Ce n'est pas le début de la panne : l'historique ne permet pas de le dater sans requête supplémentaire."
}
},
"type": "object",
"required": [
"status",
"since"
],
"title": "SensorDiagnosticResponse"
},
"SensorStatusResponse": {
"properties": {
"timestamp": {
"type": "string",
"format": "date-time",
"title": "Timestamp"
},
"sites": {
"items": {
"$ref": "#/components/schemas/SiteSensorStatusResponse"
},
"type": "array",
"title": "Sites"
}
},
"type": "object",
"required": [
"timestamp",
"sites"
],
"title": "SensorStatusResponse"
},
"SiteResponse": {
"properties": {
"site_id": {
@@ -1421,6 +2114,66 @@
],
"title": "SiteResponse"
},
"SiteSensorStatusResponse": {
"properties": {
"site_id": {
"type": "string",
"title": "Site Id"
},
"site_name": {
"type": "string",
"title": "Site Name"
},
"sensors": {
"$ref": "#/components/schemas/SiteSensorsResponse"
},
"overall": {
"type": "string",
"enum": [
"ok",
"degraded",
"critical"
],
"title": "Overall"
}
},
"type": "object",
"required": [
"site_id",
"site_name",
"sensors",
"overall"
],
"title": "SiteSensorStatusResponse"
},
"SiteSensorsResponse": {
"properties": {
"consumption": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
},
"electrical": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
},
"temperature": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
},
"humidity": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
},
"network": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
}
},
"type": "object",
"required": [
"consumption",
"electrical",
"temperature",
"humidity",
"network"
],
"title": "SiteSensorsResponse"
},
"SiteSummaryResponse": {
"properties": {
"site_id": {
@@ -1738,6 +2491,10 @@
"name": "sites",
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`."
},
{
"name": "alerts",
"description": "Consultation des alertes de consommation. Accessible à partir du rôle `lecteur`."
},
{
"name": "recommendations",
"description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`."
@@ -1745,6 +2502,14 @@
{
"name": "stats",
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle `lecteur`."
},
{
"name": "readings",
"description": "Historique des lectures de consommation. Fenêtre temporelle plafonnée à 90 jours, 24 dernières heures par défaut si `start`/`end` sont omis. Accessible à partir du rôle `lecteur`."
},
{
"name": "sensors",
"description": "État de santé des capteurs par site. Réservé au rôle `admin`."
}
]
}
+2
View File
@@ -16,6 +16,7 @@ dependencies = [
"pyjwt>=2.10",
"argon2-cffi>=23.1",
"anyio>=4.0",
"pandas>=3.0.5",
]
[dependency-groups]
@@ -26,6 +27,7 @@ dev = [
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
"httpx>=0.28.1",
"pandas-stubs>=3.0.5.260914",
]
[build-system]
+137
View File
@@ -0,0 +1,137 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_alert_service, get_current_principal
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Alert
from app.schemas.alert import AlertSeverity
def principal(role: Role = Role.LECTEUR) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def alert(alert_id: int = 1, site_id: str = "site-1", severity: str = "high") -> Alert:
return Alert(
alert_id=alert_id,
source_alert_id=f"ALR-{alert_id}",
site_id=site_id,
source="enervision",
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
type="threshold",
severity=severity,
message="Dépassement du seuil configuré",
value=812.5,
threshold=720.0,
metric="consumption_kw",
prediction_id=None,
raw_data={},
)
class FauxService:
def __init__(self) -> None:
self.alert = alert()
self.appels: list[tuple[str | None, str | None]] = []
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> list[Alert]:
self.appels.append((site_id, severity))
return [self.alert]
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal()
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi(app: FastAPI, lecteur_connecte: None) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_alert_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_alert_service, None)
async def test_list_alerts_returns_the_alerts(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/alerts")
assert response.status_code == 200
corps = response.json()
assert corps == [
{
"alert_id": 1,
"site_id": "site-1",
"timestamp": "2026-09-16T00:00:00Z",
"type": "threshold",
"severity": "high",
"message": "Dépassement du seuil configuré",
"value": 812.5,
"threshold": 720.0,
"metric": "consumption_kw",
"prediction_id": None,
}
]
async def test_list_alerts_transmits_the_site_id_filter(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
service = servi()
await client.get("/api/v1/alerts?site_id=site-1")
assert service.appels == [("site-1", None)]
async def test_list_alerts_transmits_the_severity_filter(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
service = servi()
await client.get("/api/v1/alerts?severity=critical")
assert service.appels == [(None, AlertSeverity.CRITICAL)]
async def test_list_alerts_returns_422_for_an_unknown_severity(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/alerts?severity=invalide")
assert response.status_code == 422
async def test_list_alerts_returns_an_empty_list_when_there_is_nothing(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=[])
response = await client.get("/api/v1/alerts")
assert response.status_code == 200
assert response.json() == []
+3
View File
@@ -31,9 +31,12 @@ ROUTES_A_ROLE = {
("POST", "/api/v1/users/{id}/password-reset"),
("GET", "/api/v1/sites"),
("GET", "/api/v1/sites/{site_id}"),
("GET", "/api/v1/alerts"),
("GET", "/api/v1/recommendations"),
("GET", "/api/v1/recommendations/{recommendation_id}"),
("GET", "/api/v1/stats/summary"),
("GET", "/api/v1/readings"),
("GET", "/api/v1/sensors/status"),
}
+198
View File
@@ -0,0 +1,198 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_reading_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Reading
from app.services.reading import FenetreInverseeError, FenetreTropLargeError
def principal(role: Role = Role.LECTEUR) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def reading(reading_id: int = 1, site_id: str = "site-1") -> Reading:
return Reading(
reading_id=reading_id,
site_id=site_id,
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
source="api_current",
consumption_kw=42.5,
consumption_kwh=None,
consumption_euros=None,
voltage_v=230.0,
current_a=None,
power_factor=None,
temperature_celsius=None,
humidity_percent=None,
solar_irradiance_wm2=None,
is_working_hours=True,
data_quality="good",
null_reasons=None,
imputed_values=None,
imputation_method=None,
raw_data={},
)
class FauxService:
def __init__(self, leve: Exception | None = None) -> None:
self.reading = reading()
self.leve = leve
self.appels: list[tuple[str | None, str | None, str | None, int, int]] = []
async def list_history(
self,
*,
site_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int,
offset: int,
) -> list[Reading]:
self.appels.append((site_id, start, end, limit, offset))
if self.leve is not None:
raise self.leve
return [self.reading]
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal()
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi(app: FastAPI, lecteur_connecte: None) -> Iterator[Callable[..., FauxService]]:
def installe(*, leve: Exception | None = None) -> FauxService:
service = FauxService(leve=leve)
app.dependency_overrides[get_reading_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_reading_service, None)
async def test_list_readings_returns_the_readings(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/readings")
assert response.status_code == 200
corps = response.json()
assert corps == [
{
"reading_id": 1,
"site_id": "site-1",
"timestamp": "2026-09-16T00:00:00Z",
"source": "api_current",
"consumption_kw": 42.5,
"consumption_kwh": None,
"consumption_euros": None,
"voltage_v": 230.0,
"current_a": None,
"power_factor": None,
"temperature_celsius": None,
"humidity_percent": None,
"solar_irradiance_wm2": None,
"is_working_hours": True,
"data_quality": "good",
"null_reasons": None,
"imputed_values": None,
"imputation_method": None,
}
]
async def test_list_readings_transmits_the_filters_and_pagination(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
service = servi()
response = await client.get(
"/api/v1/readings",
params={
"site_id": "site-1",
"start": "2026-09-01T00:00:00Z",
"end": "2026-09-02T00:00:00Z",
"limit": 50,
"offset": 10,
},
)
assert response.status_code == 200
assert service.appels == [
(
"site-1",
datetime(2026, 9, 1, tzinfo=UTC),
datetime(2026, 9, 2, tzinfo=UTC),
50,
10,
)
]
async def test_list_readings_returns_400_when_the_window_is_inverted(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(leve=FenetreInverseeError())
response = await client.get("/api/v1/readings")
assert response.status_code == 400
async def test_list_readings_returns_400_when_the_window_is_too_large(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(leve=FenetreTropLargeError())
response = await client.get("/api/v1/readings")
assert response.status_code == 400
async def test_list_readings_returns_422_for_a_limit_above_the_maximum(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/readings", params={"limit": 5000})
assert response.status_code == 422
async def test_list_readings_returns_422_for_a_negative_offset(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/readings", params={"offset": -1})
assert response.status_code == 422
async def test_list_readings_returns_an_empty_list_when_there_is_nothing(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=[])
response = await client.get("/api/v1/readings")
assert response.status_code == 200
assert response.json() == []
+91
View File
@@ -0,0 +1,91 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_sensor_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.services.sensor import DiagnosticCapteur, EtatCapteurs, SanteCapteurs, SanteSite
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def principal(role: Role = Role.ADMIN) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
class FauxService:
def __init__(self) -> None:
ok = DiagnosticCapteur(status="ok", since=None)
en_echec = DiagnosticCapteur(status="failing", since=TIMESTAMP)
self.etat = EtatCapteurs(
timestamp=TIMESTAMP,
sites=[
SanteSite(
site_id="SITE001",
site_name="Bureau Paris La Défense",
sensors=SanteCapteurs(
consumption=ok,
electrical=ok,
temperature=en_echec,
humidity=ok,
network=ok,
),
overall="degraded",
)
],
)
async def status(self) -> EtatCapteurs:
return self.etat
@pytest.fixture
def admin_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal()
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi(app: FastAPI, admin_connecte: None) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_sensor_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_sensor_service, None)
async def test_get_status_returns_the_service_result(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/sensors/status")
assert response.status_code == 200
corps = response.json()
assert corps["sites"][0]["site_id"] == "SITE001"
assert corps["sites"][0]["overall"] == "degraded"
assert corps["sites"][0]["sensors"]["temperature"]["status"] == "failing"
assert corps["sites"][0]["sensors"]["consumption"]["status"] == "ok"
async def test_get_status_refuses_a_reader(app: FastAPI, client: AsyncClient) -> None:
app.dependency_overrides[get_current_principal] = lambda: principal(Role.LECTEUR)
response = await client.get("/api/v1/sensors/status")
assert response.status_code == 403
@@ -0,0 +1,239 @@
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
@@ -0,0 +1,91 @@
import uuid
from datetime import UTC, datetime
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert
from app.repositories.alert import AlertRepository
from app.schemas.alert import AlertSeverity
from tests.repositories.test_site import creer as creer_site
from tests.repositories.test_site import identifiant as identifiant_site
pytestmark = pytest.mark.integration
async def creer_alerte(session: AsyncSession, *, site_id: str, **overrides: object) -> Alert:
alerte = Alert(
source_alert_id=overrides.get("source_alert_id", f"ALR-{uuid.uuid4().hex[:12]}"),
site_id=site_id,
source=overrides.get("source", "enervision"),
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
type=overrides.get("type", "threshold"),
severity=overrides.get("severity", "high"),
message=overrides.get("message", "Dépassement du seuil configuré"),
value=overrides.get("value", 812.5),
threshold=overrides.get("threshold", 720.0),
metric=overrides.get("metric", "consumption_kw"),
prediction_id=overrides.get("prediction_id"),
raw_data=overrides.get("raw_data", {}),
)
session.add(alerte)
await session.flush()
return alerte
async def test_list_all_returns_the_alerts_sorted_by_timestamp_descending(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = AlertRepository(session)
ancienne = await creer_alerte(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)
)
recente = await creer_alerte(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
)
alertes = await depot.list_all()
identifiants = [
a.alert_id for a in alertes if a.alert_id in (ancienne.alert_id, recente.alert_id)
]
await session.rollback()
assert identifiants == [recente.alert_id, ancienne.alert_id]
async def test_list_all_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = AlertRepository(session)
voulue = await creer_alerte(session, site_id=premier.site_id)
await creer_alerte(session, site_id=second.site_id)
alertes = await depot.list_all(site_id=premier.site_id)
identifiants = [a.alert_id for a in alertes]
await session.rollback()
assert identifiants == [voulue.alert_id]
async def test_list_all_filters_by_severity(session: AsyncSession) -> None:
site = await creer_site(session)
depot = AlertRepository(session)
voulue = await creer_alerte(session, site_id=site.site_id, severity="critical")
await creer_alerte(session, site_id=site.site_id, severity="low")
alertes = await depot.list_all(severity=AlertSeverity.CRITICAL)
identifiants = [a.alert_id for a in alertes]
await session.rollback()
assert identifiants == [voulue.alert_id]
async def test_list_all_returns_an_empty_list_when_there_is_nothing(
session: AsyncSession,
) -> None:
depot = AlertRepository(session)
alertes = await depot.list_all(site_id=identifiant_site())
assert list(alertes) == []
@@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from tests.repositories.test_site import creer as creer_site
from tests.repositories.test_site import identifiant as identifiant_site
pytestmark = pytest.mark.integration
@@ -25,6 +27,20 @@ def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Read
)
async def creer_lecture(session: AsyncSession, *, site_id: str, **overrides: object) -> Reading:
reading = Reading(
site_id=site_id,
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
source=overrides.get("source", "api_current"),
consumption_kw=overrides.get("consumption_kw", 10.0),
data_quality=overrides.get("data_quality", "good"),
raw_data=overrides.get("raw_data", {}),
)
session.add(reading)
await session.flush()
return reading
async def test_latest_by_site_keeps_only_the_most_recent_reading(session: AsyncSession) -> None:
site_id = identifiant()
maintenant = datetime.now(UTC)
@@ -70,3 +86,110 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) ->
await session.rollback()
assert identifiants == {premier, second}
async def test_list_history_orders_the_readings_by_timestamp_descending(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
ancienne = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)
)
recente = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
)
resultats = await depot.list_history(
start=datetime(2026, 8, 1, tzinfo=UTC),
end=datetime(2026, 10, 1, tzinfo=UTC),
limit=100,
offset=0,
)
identifiants = [
r.reading_id for r in resultats if r.reading_id in (ancienne.reading_id, recente.reading_id)
]
await session.rollback()
assert identifiants == [recente.reading_id, ancienne.reading_id]
async def test_list_history_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = ReadingRepository(session)
voulue = await creer_lecture(session, site_id=premier.site_id)
await creer_lecture(session, site_id=second.site_id)
resultats = await depot.list_history(
site_id=premier.site_id,
start=datetime(2026, 8, 1, tzinfo=UTC),
end=datetime(2026, 10, 1, tzinfo=UTC),
limit=100,
offset=0,
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [voulue.reading_id]
async def test_list_history_excludes_readings_outside_the_window(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
dedans = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 10, tzinfo=UTC)
)
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 8, 1, tzinfo=UTC))
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 10, 1, tzinfo=UTC))
resultats = await depot.list_history(
site_id=site.site_id,
start=datetime(2026, 9, 1, tzinfo=UTC),
end=datetime(2026, 9, 30, tzinfo=UTC),
limit=100,
offset=0,
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [dedans.reading_id]
async def test_list_history_respects_limit_and_offset(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
lectures = [
await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, jour, tzinfo=UTC)
)
for jour in (1, 2, 3)
]
resultats = await depot.list_history(
site_id=site.site_id,
start=datetime(2026, 8, 1, tzinfo=UTC),
end=datetime(2026, 10, 1, tzinfo=UTC),
limit=1,
offset=1,
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [lectures[1].reading_id]
async def test_list_history_returns_an_empty_list_when_there_is_nothing(
session: AsyncSession,
) -> None:
depot = ReadingRepository(session)
resultats = await depot.list_history(
site_id=identifiant_site(),
start=datetime(2026, 8, 1, tzinfo=UTC),
end=datetime(2026, 10, 1, tzinfo=UTC),
limit=100,
offset=0,
)
assert list(resultats) == []
+55
View File
@@ -0,0 +1,55 @@
from datetime import UTC, datetime
from app.models.energy import Alert
from app.services.alert import AlertService
def alert(
alert_id: int = 1,
site_id: str = "site-1",
severity: str = "high",
) -> Alert:
return Alert(
alert_id=alert_id,
source_alert_id=f"ALR-{alert_id}",
site_id=site_id,
source="enervision",
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
type="threshold",
severity=severity,
message="Dépassement du seuil configuré",
value=812.5,
threshold=720.0,
metric="consumption_kw",
prediction_id=None,
raw_data={},
)
class FakeRepository:
def __init__(self, alerts: list[Alert]) -> None:
self._alerts = alerts
self.appels: list[tuple[str | None, str | None]] = []
async def list_all(
self, *, site_id: str | None = None, severity: str | None = None
) -> list[Alert]:
self.appels.append((site_id, severity))
return self._alerts
async def test_list_all_returns_the_repository_alerts() -> None:
service = AlertService(alerts=FakeRepository([alert(1), alert(2)]))
alertes = await service.list_all()
assert [a.alert_id for a in alertes] == [1, 2]
async def test_list_all_relays_the_filters_to_the_repository() -> None:
depot = FakeRepository([])
service = AlertService(alerts=depot)
await service.list_all(site_id="site-1", severity="critical")
assert depot.appels == [("site-1", "critical")]
+153
View File
@@ -0,0 +1,153 @@
from datetime import UTC, datetime, timedelta
import pytest
from app.models.energy import Reading
from app.services.reading import (
FENETRE_MAXIMALE,
FENETRE_PAR_DEFAUT,
FenetreInverseeError,
FenetreTropLargeError,
ReadingService,
)
def reading(reading_id: int = 1, site_id: str = "site-1") -> Reading:
return Reading(
reading_id=reading_id,
site_id=site_id,
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
source="api_current",
consumption_kw=10.0,
data_quality="good",
raw_data={},
)
class FakeRepository:
def __init__(self, readings: list[Reading]) -> None:
self._readings = readings
self.appels: list[tuple[str | None, datetime, datetime, int, int]] = []
async def list_history(
self,
*,
start: datetime,
end: datetime,
site_id: str | None = None,
limit: int,
offset: int,
) -> list[Reading]:
self.appels.append((site_id, start, end, limit, offset))
return self._readings
async def test_list_history_returns_the_repository_readings() -> None:
service = ReadingService(readings=FakeRepository([reading(1), reading(2)]))
lectures = await service.list_history(limit=500, offset=0)
assert [r.reading_id for r in lectures] == [1, 2]
async def test_list_history_relays_the_site_id_limit_and_offset() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
debut = datetime(2026, 9, 1, tzinfo=UTC)
fin = datetime(2026, 9, 2, tzinfo=UTC)
await service.list_history(site_id="site-1", start=debut, end=fin, limit=50, offset=10)
assert depot.appels == [("site-1", debut, fin, 50, 10)]
async def test_list_history_defaults_to_the_last_24_hours_when_no_window_is_given() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
avant = datetime.now(UTC)
await service.list_history(limit=500, offset=0)
apres = datetime.now(UTC)
_, debut, fin, _, _ = depot.appels[0]
assert avant <= fin <= apres
assert fin - debut == FENETRE_PAR_DEFAUT
async def test_list_history_defaults_end_to_now_when_only_start_is_given() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
debut = datetime.now(UTC) - timedelta(hours=1)
avant = datetime.now(UTC)
await service.list_history(start=debut, limit=500, offset=0)
apres = datetime.now(UTC)
_, debut_transmis, fin, _, _ = depot.appels[0]
assert debut_transmis == debut
assert avant <= fin <= apres
async def test_list_history_defaults_start_to_24_hours_before_end_when_only_end_is_given() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
fin = datetime(2026, 9, 16, tzinfo=UTC)
await service.list_history(end=fin, limit=500, offset=0)
_, debut, fin_transmise, _, _ = depot.appels[0]
assert fin_transmise == fin
assert debut == fin - FENETRE_PAR_DEFAUT
async def test_list_history_normalizes_naive_datetimes_to_utc() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
await service.list_history(
start=datetime(2026, 9, 1), end=datetime(2026, 9, 2), limit=500, offset=0
)
_, debut, fin, _, _ = depot.appels[0]
assert debut == datetime(2026, 9, 1, tzinfo=UTC)
assert fin == datetime(2026, 9, 2, tzinfo=UTC)
async def test_list_history_raises_when_start_is_after_end() -> None:
service = ReadingService(readings=FakeRepository([]))
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,
)
async def test_list_history_raises_when_start_equals_end() -> None:
service = ReadingService(readings=FakeRepository([]))
instant = datetime(2026, 9, 1, tzinfo=UTC)
with pytest.raises(FenetreInverseeError):
await service.list_history(start=instant, end=instant, limit=500, offset=0)
async def test_list_history_raises_when_the_window_exceeds_the_maximum_span() -> None:
service = ReadingService(readings=FakeRepository([]))
debut = datetime(2026, 1, 1, tzinfo=UTC)
fin = debut + FENETRE_MAXIMALE + timedelta(seconds=1)
with pytest.raises(FenetreTropLargeError):
await service.list_history(start=debut, end=fin, limit=500, offset=0)
async def test_list_history_accepts_a_window_exactly_at_the_maximum_span() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
debut = datetime(2026, 1, 1, tzinfo=UTC)
fin = debut + FENETRE_MAXIMALE
await service.list_history(start=debut, end=fin, limit=500, offset=0)
assert depot.appels == [(None, debut, fin, 500, 0)]
+224
View File
@@ -0,0 +1,224 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
from app.services.sensor import SensorService
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
@dataclass
class FauxSite:
site_id: str
site_name: str
@dataclass
class FauxLecture:
site_id: str
timestamp: datetime
data_quality: str | None
null_reasons: list[str] | None = field(default_factory=list)
consumption_kw: float | None = 10.0
voltage_v: float | None = 230.0
current_a: float | None = 5.0
power_factor: float | None = 0.95
temperature_celsius: float | None = 21.0
humidity_percent: float | None = 40.0
class FauxDepotSites:
def __init__(self, sites: list[FauxSite]) -> None:
self._sites = sites
async def list_all(self) -> list[FauxSite]:
return self._sites
class FauxDepotLectures:
def __init__(self, lectures: list[FauxLecture]) -> None:
self._lectures = lectures
async def latest_by_site(self) -> list[FauxLecture]:
return self._lectures
async def test_status_marks_a_site_without_any_reading_as_critical_with_every_sensor_failing() -> (
None
):
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([]), # type: ignore[arg-type]
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "critical"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "failing"
assert capteur.since is None
async def test_status_marks_every_sensor_ok_on_a_good_quality_reading_with_no_null_field() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "good")]), # type: ignore[arg-type]
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "ok"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "ok"
assert capteur.since is None
async def test_status_flags_the_sensor_named_in_null_reasons() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[
FauxLecture(
"A",
TIMESTAMP,
"partial",
null_reasons=["temperature_sensor_failure"],
temperature_celsius=None,
)
]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "degraded"
assert site.sensors.temperature.status == "failing"
assert site.sensors.temperature.since == TIMESTAMP
assert site.sensors.consumption.status == "ok"
assert site.sensors.electrical.status == "ok"
assert site.sensors.humidity.status == "ok"
assert site.sensors.network.status == "ok"
async def test_status_flags_a_sensor_from_a_null_field_even_without_a_null_reason() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], humidity_percent=None)]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.sensors.humidity.status == "failing"
assert site.sensors.humidity.since == TIMESTAMP
async def test_status_flags_electrical_as_failing_when_any_of_its_three_fields_is_null() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], power_factor=None)]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.sensors.electrical.status == "failing"
async def test_status_forces_every_sensor_to_failing_when_overall_is_critical() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "critical", null_reasons=[])]), # type: ignore[arg-type]
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "critical"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "failing"
assert capteur.since == TIMESTAMP
async def test_status_treats_an_unknown_data_quality_as_critical() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, None, null_reasons=[])]), # type: ignore[arg-type]
)
etat = await service.status()
assert etat.sites[0].overall == "critical"
async def test_status_ignores_an_unknown_null_reason() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[FauxLecture("A", TIMESTAMP, "good", null_reasons=["something_else"])]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "ok"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "ok"
async def test_status_flags_network_from_null_reasons_only() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[
FauxLecture(
"A",
TIMESTAMP,
"partial",
null_reasons=["network_loss"],
)
]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "degraded"
assert site.sensors.network.status == "failing"
assert site.sensors.network.since == TIMESTAMP
assert site.sensors.consumption.status == "ok"
assert site.sensors.electrical.status == "ok"
assert site.sensors.temperature.status == "ok"
assert site.sensors.humidity.status == "ok"
+109
View File
@@ -1,6 +1,11 @@
version = 1
revision = 3
requires-python = "==3.14.*"
resolution-markers = [
"sys_platform == 'win32'",
"sys_platform == 'emscripten'",
"sys_platform != 'emscripten' and sys_platform != 'win32'",
]
[[package]]
name = "alembic"
@@ -311,6 +316,7 @@ dependencies = [
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "pandas" },
{ name = "prometheus-fastapi-instrumentator" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" },
@@ -324,6 +330,7 @@ dependencies = [
dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pandas-stubs" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
@@ -337,6 +344,7 @@ requires-dist = [
{ name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "pandas", specifier = ">=3.0.5" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.13.5" },
{ name = "pydantic-settings", specifier = ">=2.15.0" },
@@ -350,6 +358,7 @@ requires-dist = [
dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", specifier = ">=2.3.1" },
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
@@ -595,6 +604,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "numpy"
version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
{ url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
{ url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
{ url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
{ url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
{ url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
{ url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
{ url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
{ url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
{ url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
{ url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
{ url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
{ url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
{ url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
]
[[package]]
name = "packaging"
version = "26.3"
@@ -604,6 +642,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pandas"
version = "3.0.5"
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" }
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" },
]
[[package]]
name = "pandas-stubs"
version = "3.0.5.260914"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/93/8948ae6c1e1e3d6833596fd266f7be2d27c1451b8be094975ad42c5e842e/pandas_stubs-3.0.5.260914.tar.gz", hash = "sha256:3f6fc1f147f68fd89c007105e7c94a948acb4ecd7eb20dc1c02e153c4ed5c250", size = 117622, upload-time = "2026-09-14T16:42:35.065Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/cb/5ad79e02a556cc23fed5816de0109fa8af660c66cfa5f4af74c3e8d4cd26/pandas_stubs-3.0.5.260914-py3-none-any.whl", hash = "sha256:39a1300c5c5c55fdf609e3476805decce5d5015539a4dcb683449f8feaeee2fb", size = 177344, upload-time = "2026-09-14T16:42:33.771Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
@@ -788,6 +867,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.3"
@@ -857,6 +948,15 @@ wheels = [
{ 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" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.52"
@@ -916,6 +1016,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "tzdata"
version = "2026.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
]
[[package]]
name = "uvicorn"
version = "0.53.0"
+47
View File
@@ -0,0 +1,47 @@
# ==================
# Étape 1 : Build
# ==================
# Image pour frontend
FROM node:24-alpine3.22 AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
# Installation des dépendances du projet avec npm
RUN npm ci
# Copie du code source vers le conteneur
COPY . .
# Build
RUN npm run build
# ==================
# Étape 2 : Runner
# ==================
FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner
# Copie de la configuration de nginx
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=root:root --chmod=755 --from=builder /app/dist/*/browser /usr/share/nginx/html
# Create necessary directories with proper permissions for nginx
RUN mkdir -p /var/log/nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html
# Use a non-root user for security best practices
USER nginx
# Frontend : port 3000
# Backend : port 8000
EXPOSE 3000
# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]
+1
View File
@@ -81,6 +81,7 @@
"builder": "@angular/build:unit-test",
"options": {
"coverage": true,
"isolate": true,
"coverageReporters": [
"text-summary",
"lcov",
+32
View File
@@ -0,0 +1,32 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~ /\. {
deny all;
}
}
}
+10 -2
View File
@@ -1,13 +1,21 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import {ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners} from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { mockApiInterceptor } from './core/interceptors/mock-api-interceptor';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {catchError, firstValueFrom, of} from 'rxjs';
import {AuthService} from './core/services/auth.service';
import {authInterceptor} from './core/interceptors/auth-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([mockApiInterceptor])),
provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor])),
provideAppInitializer(() => {
const auth = inject(AuthService);
// Un 401 ici est normal : ça veut juste dire qu'il n'y a pas de session.
return firstValueFrom(auth.refreshShared().pipe(catchError(() => of(null))));
}),
],
};
+5 -1
View File
@@ -1,9 +1,13 @@
import { Routes } from '@angular/router';
import {authGuard} from './core/guards/auth-guard';
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
{
path: 'dashboard',
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
canActivate: [authGuard],
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
},
];
@@ -0,0 +1,67 @@
import { TestBed } from '@angular/core/testing';
import { Router, ActivatedRouteSnapshot } from '@angular/router';
import { vi } from 'vitest';
import { authGuard } from './auth-guard';
import { AuthService } from '../services/auth.service';
describe('authGuard', () => {
let authMock: { isAuthenticated: ReturnType<typeof vi.fn>; principal: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(() => {
authMock = { isAuthenticated: vi.fn(), principal: vi.fn() };
routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
});
it('redirige vers /login si non authentifié', () => {
authMock.isAuthenticated.mockReturnValue(false);
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: {} } as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(false);
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('redirige vers /login si le rôle ne correspond pas', () => {
authMock.isAuthenticated.mockReturnValue(true);
authMock.principal.mockReturnValue({ role: 'lecteur' });
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(false);
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('autorise si authentifié et rôle correspondant', () => {
authMock.isAuthenticated.mockReturnValue(true);
authMock.principal.mockReturnValue({ role: 'admin' });
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(true);
});
it('autorise si authentifié et aucun rôle requis', () => {
authMock.isAuthenticated.mockReturnValue(true);
authMock.principal.mockReturnValue({ role: 'lecteur' });
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: {} } as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(true);
});
});
@@ -0,0 +1,21 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = (route) => {
const auth = inject(AuthService);
const router = inject(Router);
if (!auth.isAuthenticated()) {
router.navigate(['/login']);
return false;
}
const requiredRole = route.data['role'] as string | undefined;
if (requiredRole && auth.principal()?.role !== requiredRole) {
router.navigate(['/login']);
return false;
}
return true;
};
@@ -0,0 +1,161 @@
import { TestBed } from '@angular/core/testing';
import {
HttpClient,
HttpHandlerFn,
HttpHeaders,
HttpRequest,
provideHttpClient,
withInterceptors
} from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { authInterceptor } from './auth-interceptor';
import { AuthService } from '../services/auth.service';
describe('authInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let authMock: { getAccessToken: ReturnType<typeof vi.fn>; clearSession: ReturnType<typeof vi.fn>; refreshShared: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(() => {
authMock = {
getAccessToken: vi.fn().mockReturnValue('fake-token'),
clearSession: vi.fn(),
refreshShared: vi.fn(),
};
routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('ajoute le header Authorization quand un token est disponible', () => {
http.get('/api/v1/stats/summary').subscribe();
const req = httpMock.expectOne('/api/v1/stats/summary');
expect(req.request.headers.get('Authorization')).toBe('Bearer fake-token');
req.flush({});
});
it("n'ajoute pas le header Authorization sur /auth/login", () => {
http.post('/api/v1/auth/login', {}).subscribe();
const req = httpMock.expectOne('/api/v1/auth/login');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush({});
});
it('ajoute withCredentials sur les routes /auth/*', () => {
http.post('/api/v1/auth/login', {}).subscribe();
const req = httpMock.expectOne('/api/v1/auth/login');
expect(req.request.withCredentials).toBe(true);
req.flush({});
});
it('redirige vers /change-password sur un 403 avec ce detail précis', () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush({ detail: 'password_change_required' }, { status: 403, statusText: 'Forbidden' });
expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']);
});
it('ne redirige pas sur un 403 avec un autre detail', () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush({ detail: 'Droits insuffisants' }, { status: 403, statusText: 'Forbidden' });
expect(routerMock.navigate).not.toHaveBeenCalled();
});
it('déconnecte et redirige vers /login sur un 401 avec error="invalid_token"', () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush(
{},
{ status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="invalid_token"' }) }
);
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('déconnecte directement sur un 401 provenant de /auth/refresh, sans tenter de rafraîchir', () => {
http.post('/api/v1/auth/refresh', {}).subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/auth/refresh');
req.flush({}, { status: 401, statusText: 'Unauthorized' });
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('rafraîchit puis rejoue la requête sur un 401 avec error="expired"', () => {
authMock.refreshShared.mockReturnValue(of({ access_token: 'new-token' }));
authMock.getAccessToken.mockReturnValueOnce('old-token').mockReturnValue('new-token');
let result: unknown;
http.get('/api/v1/dashboard').subscribe((r) => (result = r));
const firstReq = httpMock.expectOne('/api/v1/dashboard');
firstReq.flush({}, { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="expired"' }) });
const retriedReq = httpMock.expectOne('/api/v1/dashboard');
expect(retriedReq.request.headers.get('Authorization')).toBe('Bearer new-token');
retriedReq.flush({ ok: true });
expect(result).toEqual({ ok: true });
});
it('déconnecte si le rafraîchissement échoue après un 401 "expired"', () => {
authMock.refreshShared.mockReturnValue(throwError(() => new Error('refresh failed')));
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush({}, { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="expired"' }) });
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it("propage l'erreur telle quelle si ce n'est pas une HttpErrorResponse", () => {
const req = new HttpRequest('GET', '/api/v1/dashboard');
const boom = new Error('erreur inattendue, pas HTTP');
const next: HttpHandlerFn = () => throwError(() => boom);
let captured: unknown;
TestBed.runInInjectionContext(() => {
authInterceptor(req, next).subscribe({ error: (e) => (captured = e) });
});
expect(captured).toBe(boom);
});
it('propage un 401 sur /auth/login sans tenter de rafraîchir ni déconnecter', () => {
http.post('/api/v1/auth/login', {}).subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/auth/login');
req.flush({}, { status: 401, statusText: 'Unauthorized' });
expect(authMock.refreshShared).not.toHaveBeenCalled();
expect(authMock.clearSession).not.toHaveBeenCalled();
});
it("propage un 401 dont le WWW-Authenticate ne correspond à aucun cas connu", () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush(
{},
{ status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="unknown_case"' }) }
);
expect(authMock.refreshShared).not.toHaveBeenCalled();
expect(authMock.clearSession).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,77 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { TokenResponse } from '../../shared/models/auth.model';
function parseAuthError(response: HttpErrorResponse): string | null {
const header = response.headers?.get('WWW-Authenticate') ?? '';
const match = header.match(/error="([^"]+)"/);
return match ? match[1] : null;
}
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const isAuthRoute = req.url.includes('/auth/');
let request = isAuthRoute ? req.clone({ withCredentials: true }) : req;
const token = auth.getAccessToken();
if (token && !req.url.endsWith('/auth/login')) {
request = request.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
return next(request).pipe(
catchError((error: unknown) => {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
if (error.status === 403) {
const detail = (error.error as { detail?: string })?.detail;
if (detail === 'password_change_required') {
router.navigate(['/change-password']);
}
return throwError(() => error);
}
if (error.status !== 401 || req.url.endsWith('/auth/login')) {
return throwError(() => error);
}
if (req.url.endsWith('/auth/refresh')) {
auth.clearSession();
router.navigate(['/login']);
return throwError(() => error);
}
const kind = parseAuthError(error);
if (kind === 'invalid_token') {
auth.clearSession();
router.navigate(['/login']);
return throwError(() => error);
}
if (kind === 'expired' || kind === 'token_stale') {
return (auth.refreshShared() as Observable<TokenResponse>).pipe(
switchMap(() => {
const retried = request.clone({
setHeaders: { Authorization: `Bearer ${auth.getAccessToken()}` },
});
return next(retried);
}),
catchError((refreshError) => {
auth.clearSession();
router.navigate(['/login']);
return throwError(() => refreshError);
})
);
}
return throwError(() => error);
})
);
};
@@ -0,0 +1,86 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AuthService } from './auth.service';
import { environment } from '../../../environments/environment';
describe('AuthService', () => {
let service: AuthService;
let httpMock: HttpTestingController;
const tokenResponse = {
access_token: 'abc123',
token_type: 'bearer',
expires_in: 900,
principal: {
id: '1',
email: 'a@a.com',
role: 'admin' as const,
kind: 'human' as const,
must_change_password: false,
},
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('stocke le token et le principal après un login réussi', () => {
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/auth/login`);
expect(req.request.withCredentials).toBe(true);
req.flush(tokenResponse);
expect(service.getAccessToken()).toBe('abc123');
expect(service.principal()?.email).toBe('a@a.com');
expect(service.isAuthenticated()).toBe(true);
});
it('efface la session au logout', () => {
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
service.logout().subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/logout`).flush(null);
expect(service.getAccessToken()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
});
it("ne déclenche qu'un seul appel réseau si refreshShared est appelé plusieurs fois avant la réponse", () => {
service.refreshShared().subscribe();
service.refreshShared().subscribe();
service.refreshShared().subscribe();
const requests = httpMock.match(`${environment.apiUrl}/auth/refresh`);
expect(requests.length).toBe(1);
requests[0].flush(tokenResponse);
});
it('met à jour la session après un changement de mot de passe réussi', () => {
service.changePassword({ current_password: 'old', new_password: 'new-password-1234' }).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/auth/password`);
req.flush(tokenResponse);
expect(service.getAccessToken()).toBe('abc123');
});
it('récupère le principal courant via /auth/me', () => {
let result: unknown;
service.me().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/auth/me`);
expect(req.request.method).toBe('GET');
req.flush(tokenResponse.principal);
expect(result).toEqual(tokenResponse.principal);
});
});
@@ -0,0 +1,69 @@
import { Service, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap, finalize, shareReplay } from 'rxjs';
import { LoginRequest, PasswordChangeRequest, Principal, TokenResponse } from '../../shared/models/auth.model';
import { environment } from '../../../environments/environment';
@Service()
export class AuthService {
private http = inject(HttpClient);
// Jamais de localStorage/sessionStorage/cookie côté JS : juste un signal en
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
private accessTokenSignal = signal<string | null>(null);
private principalSignal = signal<Principal | null>(null);
readonly principal = this.principalSignal.asReadonly();
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
private rotation$?: Observable<TokenResponse>;
getAccessToken(): string | null {
return this.accessTokenSignal();
}
private setSession(response: TokenResponse): void {
this.accessTokenSignal.set(response.access_token);
this.principalSignal.set(response.principal);
}
clearSession(): void {
this.accessTokenSignal.set(null);
this.principalSignal.set(null);
}
login(credentials: LoginRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
}
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
// appelants (sinon le serveur révoque toute la session sur des rotations concurrentes).
refreshShared(): Observable<TokenResponse> {
this.rotation$ ??= this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/refresh`, {}, { withCredentials: true })
.pipe(
tap((response) => this.setSession(response)),
finalize(() => (this.rotation$ = undefined)),
shareReplay(1)
);
return this.rotation$;
}
logout(): Observable<void> {
return this.http
.post<void>(`${environment.apiUrl}/auth/logout`, {}, { withCredentials: true })
.pipe(tap(() => this.clearSession()));
}
changePassword(payload: PasswordChangeRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/password`, payload, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
}
me(): Observable<Principal> {
return this.http.get<Principal>(`${environment.apiUrl}/auth/me`);
}
}
@@ -0,0 +1,31 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Nouveau mot de passe</h1>
<p class="auth-subtitle">Votre mot de passe est provisoire, vous devez le modifier avant de continuer</p>
<label for="current_password">Mot de passe actuel</label>
<input
id="current_password"
type="password"
formControlName="current_password"
autocomplete="current-password"
/>
<label for="new_password">Nouveau mot de passe</label>
<input
id="new_password"
type="password"
formControlName="new_password"
autocomplete="new-password"
/>
<span class="auth-hint">12 à 128 caractères</span>
@if (errorMessage()) {
<p class="auth-error">{{ errorMessage() }}</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Modification...' : 'Valider' }}
</button>
</form>
</div>
@@ -0,0 +1,88 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
line-height: 1.4;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-hint {
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
@@ -0,0 +1,88 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ChangePassword } from './change-password';
import { AuthService } from '../../../core/services/auth.service';
describe('ChangePassword', () => {
let authMock: { changePassword: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => {
authMock = { changePassword: vi.fn() };
routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({
imports: [ChangePassword, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
}).compileComponents();
});
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'old', new_password: 'trop-court' });
component.onSubmit();
expect(authMock.changePassword).not.toHaveBeenCalled();
});
it('redirige vers /dashboard après un changement réussi', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' });
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
component.onSubmit();
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
});
it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' });
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
component.onSubmit();
fixture.detectChanges(); // rend le bloc @if (errorMessage())
expect(component.errorMessage()).toContain('incorrect');
const errorEl = fixture.nativeElement.querySelector('.auth-error');
expect(errorEl?.textContent).toContain('incorrect');
});
it('désactive le bouton tant que le formulaire est invalide', () => {
const fixture = TestBed.createComponent(ChangePassword);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull();
});
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' });
fixture.detectChanges();
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
const form = fixture.nativeElement.querySelector('form');
form.dispatchEvent(new Event('submit'));
fixture.detectChanges();
expect(authMock.changePassword).toHaveBeenCalledWith({
current_password: 'ancien-mot-de-passe',
new_password: 'un-nouveau-mot-de-passe-valide',
});
});
});
@@ -0,0 +1,41 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service';
@Component({
selector: 'app-change-password',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './change-password.html',
styleUrl: './change-password.scss',
})
export class ChangePassword {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
errorMessage = signal<string | null>(null);
isLoading = signal(false);
form = this.fb.nonNullable.group({
current_password: ['', Validators.required],
new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]],
});
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.auth.changePassword(this.form.getRawValue()).subscribe({
next: (response) => {
this.router.navigate(['/dashboard']);
},
error: () => {
this.isLoading.set(false);
this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).');
},
});
}
}
@@ -0,0 +1,36 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Connexion</h1>
<p class="auth-subtitle">Accédez à votre espace EnerVision</p>
<label for="email">Email</label>
<input
id="email"
type="email"
formControlName="email"
autocomplete="username"
placeholder="vous@enervision.fr"
/>
<label for="password">Mot de passe</label>
<input
id="password"
type="password"
formControlName="password"
autocomplete="current-password"
/>
@if (errorMessage()) {
<p class="auth-error">
{{ errorMessage() }}
@if (retryAfterSeconds(); as seconds) {
(réessayez dans {{ seconds }}s)
}
</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Connexion...' : 'Se connecter' }}
</button>
</form>
</div>
@@ -0,0 +1,81 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
@@ -0,0 +1,110 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { Login } from './login';
import { AuthService } from '../../../core/services/auth.service';
describe('Login', () => {
let authMock: { login: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => {
authMock = { login: vi.fn() };
routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({
imports: [Login, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
}).compileComponents();
});
it('ne soumet pas si le formulaire est invalide', () => {
const fixture = TestBed.createComponent(Login);
fixture.componentInstance.onSubmit();
expect(authMock.login).not.toHaveBeenCalled();
});
it('redirige vers /change-password si must_change_password est vrai', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'secret' });
authMock.login.mockReturnValue(of({ principal: { role: 'admin', must_change_password: true } }));
component.onSubmit();
expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']);
});
it('redirige vers /dashboard si le mot de passe est déjà à jour', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'secret' });
authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } }));
component.onSubmit();
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
});
it('affiche un message générique sur un 401', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'wrong' });
authMock.login.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
component.onSubmit();
fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template
expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.');
const errorEl = fixture.nativeElement.querySelector('.auth-error');
expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.');
});
it("affiche le délai d'attente sur un 429 avec Retry-After", () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'wrong' });
authMock.login.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 429, headers: new HttpHeaders({ 'Retry-After': '30' }) }))
);
component.onSubmit();
fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds)
expect(component.retryAfterSeconds()).toBe(30);
const errorEl = fixture.nativeElement.querySelector('.auth-error');
expect(errorEl?.textContent).toContain('30s');
});
it('désactive le bouton tant que le formulaire est invalide', () => {
const fixture = TestBed.createComponent(Login);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull();
});
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'secret' });
fixture.detectChanges();
authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } }));
const form = fixture.nativeElement.querySelector('form');
form.dispatchEvent(new Event('submit'));
fixture.detectChanges();
expect(authMock.login).toHaveBeenCalledWith({ email: 'a@a.com', password: 'secret' });
});
});
@@ -0,0 +1,55 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './login.html',
styleUrl: './login.scss',
})
export class Login {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
errorMessage = signal<string | null>(null);
retryAfterSeconds = signal<number | null>(null);
isLoading = signal(false);
form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required],
});
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.retryAfterSeconds.set(null);
this.auth.login(this.form.getRawValue()).subscribe({
next: (response) => {
if (response.principal.must_change_password) {
this.router.navigate(['/change-password']);
return;
}
this.router.navigate(['/dashboard']);
},
error: (error: HttpErrorResponse) => {
this.isLoading.set(false);
if (error.status === 429) {
const retryAfter = error.headers.get('Retry-After');
this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null);
this.errorMessage.set('Trop de tentatives, réessayez plus tard.');
return;
}
this.errorMessage.set('Email ou mot de passe incorrect.');
},
});
}
}
@@ -1,7 +1,10 @@
<div class="dashboard">
<header class="dashboard__header">
<div>
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
</div>
<button type="button" class="logout-button" (click)="onLogout()">Déconnexion</button>
</header>
@if (error(); as message) {
@@ -144,3 +144,30 @@ h2 {
.alert-item__message {
font-size: 0.9rem;
}
.dashboard__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.logout-button {
padding: 0.5rem 1rem;
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
color: #374151;
cursor: pointer;
&:hover {
background: #f3f4f6;
}
}
@@ -4,6 +4,8 @@ import { of, throwError } from 'rxjs';
import { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service';
import {AuthService} from '../../core/services/auth.service';
import {Router} from '@angular/router';
vi.mock('chart.js', () => {
class ChartMock {
@@ -92,4 +94,58 @@ describe('Dashboard', () => {
expect(fixture.componentInstance.alerts().length).toBe(0);
});
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
const routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.logout).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = {
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
clearSession: vi.fn(),
};
const routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
});
@@ -2,10 +2,12 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
import { DecimalPipe } from '@angular/common';
import { Router } from '@angular/router';
import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
import { AlertsService } from '../../core/services/alerts.service';
import { AuthService } from '../../core/services/auth.service';
import { StatsSummary } from '../../shared/models/stats.model';
import { Alert } from '../../shared/models/alert.model';
@@ -23,6 +25,8 @@ const UNAVAILABLE_MESSAGE =
export class Dashboard implements OnInit {
private statsService = inject(StatsService);
private alertsService = inject(AlertsService);
private auth = inject(AuthService);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
stats = signal<StatsSummary | null>(null);
@@ -50,6 +54,17 @@ export class Dashboard implements OnInit {
});
}
onLogout(): void {
this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']),
error: () => {
// Même si l'appel réseau échoue, on considère l'utilisateur déconnecté localement.
this.auth.clearSession();
this.router.navigate(['/login']);
},
});
}
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
@@ -0,0 +1,26 @@
export type Role = 'lecteur' | 'operateur' | 'admin';
export interface LoginRequest {
email: string;
password: string;
}
export interface PasswordChangeRequest {
current_password: string;
new_password: string;
}
export interface Principal {
id: string;
email: string;
role: Role;
kind: 'human';
must_change_password: boolean;
}
export interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
principal: Principal;
}
@@ -1,5 +1,5 @@
export const environment = {
production: true,
apiUrl: 'http://localhost:8000/api/v1',
apiUrl: '/api/v1',
useMockFixtures: false,
};
View File
+7
View File
@@ -43,5 +43,12 @@ services:
- "${BACKEND_PORT:-8000}:8000"
restart: unless-stopped
frontend:
build: ./apps/frontend
ports:
- "${FRONTEND_PORT:-3000}:80"
restart: unless-stopped
volumes:
pgdata:
+101
View File
@@ -0,0 +1,101 @@
# 0005 - Modèle de prédiction de consommation : LightGBM
- Statut : accepté
- Date : 2026-09-17
## Contexte
Le schéma `prediction` contraint déjà la forme de la solution (deux cibles de régression,
`consumption_kw` instantané et `consumption_kwh` sur `period_minutes`, un statut
`insufficient_data` à détecter explicitement), mais aucun modèle n'était choisi. Trois
contraintes non négociables cadrent le choix, discutées dans l'issue #89 :
1. **EC06** (grille de notation individuelle) exige un modèle **entraîné, versionné avec
MLflow**, exposé via un endpoint fonctionnel, avec **surveillance du drift** en production.
2. **Aucun GPU dédié** : l'infra tourne on-premise sur une VM à 4 CPU / 8 Gio RAM (ou
`Standard_B2s`/`B2ms` côté Azure, 2 vCPU max) — Azure Machine Learning est de toute façon
bloqué par la politique Azure du projet.
3. **Délai serré** : le jalon J3 arrive à échéance le lendemain de la décision, J4 concentre déjà
26 issues sur 4 jours. Un modèle long à mettre en œuvre retarde la chaîne complète (service de
scoring #37, moteur de recommandations #38, tests ML #44/#45, tous bloqués par ce choix).
Le jeu de données est déjà disponible (`all_sites_combined.csv`, fourni par le formateur) : 7
sites, 2 ans au pas horaire (~17 500 lignes/site), avec `temperature_celsius`,
`humidity_percent`, `solar_irradiance_wm2` en régresseurs exogènes et des features calendaires
déjà dérivées.
## Options comparées
| Critère | Prophet | LightGBM/XGBoost | NeuralProphet | SARIMA | Holt-Winters | Mistral (LLM) |
|---|---|---|---|---|---|---|
| Saisonnalités multiples (jour/semaine/an) | Oui, nativement | Oui, via features engineered | Oui, nativement, + autorégression | Une seule, lourd à régler (SARIMAX) | Une seule, aucune | Non conçu pour ça |
| Régresseurs exogènes | Oui, mais doivent être connus dans le futur au moment de la prédiction | Oui, via lags/moyennes glissantes sur le passé | Oui, natif | Difficile en multivarié | Aucun support | Contexte de prompt seulement, non appris |
| Coût de calcul (VM sans GPU) | Faible | Faible | Élevé (deep learning) | Faible | Faible | Élevé à prohibitif |
| Versionnable MLflow | Oui, nativement | Oui, nativement | Pas de support direct | Oui, générique | Pas de support direct | Rien à versionner (pas un modèle entraîné) |
| Granularité | Un modèle par site (ou par site × métrique) | Un seul modèle global sur tous les sites | Un par site | Un par site | Un par site | — |
| Effort avant l'échéance | Faible | Moyen (feature engineering) | Élevé | Moyen à élevé | Faible en soi | Élevé, ou factice |
## Décision
**LightGBM, un seul modèle global** couvrant tous les sites, plutôt qu'un modèle par site
(Prophet) ou par famille de site. Cible : `consumption_kwh`, avec `period_minutes` comme feature
d'entrée plutôt que comme étape d'agrégation post-prédiction. Suivi et versioning via **MLflow**
(tracking + registre de modèles), sur le magasin local par défaut dans un premier temps —
l'hébergement sur l'infra k3s reste une question ouverte, non bloquante pour démarrer.
Raisons retenues, au-delà du tableau ci-dessus :
- **Un modèle global plutôt qu'un modèle par site** évite la fragilité des sites les moins
fournis en historique : ils bénéficient de ce qu'apprennent les autres sites, ce qu'un Prophet
par site ne permet pas.
- **Aucune dépendance à une prévision météo future.** Prophet exige que ses régresseurs
(`add_regressor`) soient connus au moment prédit ; `temperature_celsius`,
`humidity_percent` et `solar_irradiance_wm2` sont des mesures passées, pas des prévisions, et
aucune source de prévision météo n'existe dans le projet. LightGBM s'en sort avec des features
de lag/moyenne glissante calculées sur l'historique déjà présent dans `reading`, cf.
`ml/enervision_ml/features.py` — un choix qui vaut aussi bien à l'entraînement qu'au futur
scoring.
- **Apprentissage direct sur `consumption_kwh`** avec `period_minutes` en feature, sans étape
d'agrégation intermédiaire que la sortie continue de Prophet aurait demandée.
- **Coût de calcul compatible avec l'infra on-premise sans GPU.**
Débat complet, comparatif détaillé et décision finale : issue #89 (Johan, phyri0s,
ValentinDeFaria), actée en réunion d'équipe du 2026-09-17 et validée par l'ensemble de l'équipe.
## Conséquences
- Le pipeline d'entraînement (`ml/`, ce commit) lit `reading` + `site` par connexion PostgreSQL
directe et construit ses features par lags/moyennes glissantes plutôt que par régresseurs
contemporains, cf. `docs/ML-START.md`.
- Le rôle PostgreSQL dédié `enervision_ml` (lecture seule sur `reading`/`site`) n'est pas encore
provisionné : dette déjà assumée par l'ADR 0003 pour les comptes ETL/ML, `ML_DATABASE_URL`
pointe pour l'instant vers la même base que le backend applicatif en développement.
- Le service de scoring (#37), le moteur de recommandations (#38) et les tests de dérive
(#44/#45) restent à construire ; ils consommeront le même module `enervision_ml.features`, qui
doit rester strictement identique entre entraînement et scoring pour éviter un train/serve skew
silencieux.
- La surveillance de drift exigée par EC06 n'est pas encore implémentée : ce ticket ne livre que
l'entraînement et son suivi MLflow (paramètres, métriques, artefact modèle), pas le monitoring
en production.
- L'hébergement de MLflow sur l'infra k3s reste une question ouverte ; le magasin SQLite local
(`ml/mlflow.db`, ignoré par git) suffit pour l'instant à comparer des runs sur un poste.
## Alternatives écartées
- **Prophet** : proposition initiale, écartée après débat pour les raisons ci-dessus (modèle par
site, dépendance à une météo future indisponible, agrégation kWh en post-traitement). Reste un
candidat solide si un jour le projet doit produire une décomposition tendance/saisonnalité
explicable pour un usage différent.
- **Mistral (LLM)** : aucun produit dédié aux séries temporelles ; interroger un LLM généraliste
ne constitue pas un modèle entraîné et versionnable au sens MLflow, et le fine-tuning est hors
budget de calcul et hors délai.
- **SARIMA** : ne gère pas nativement plusieurs régresseurs exogènes ; réglage (p,d,q,P,D,Q) plus
long que le délai disponible.
- **NeuralProphet** : fait tout ce que fait Prophet et apprend en plus des motifs autorégressifs,
mais coûte plus cher en calcul (pas de GPU disponible) et n'a pas d'outil MLflow direct — piste
d'évolution possible, non engageante à ce stade.
- **Holt-Winters** : écarté d'entrée, pas seulement différé — aucun support de régresseurs
exogènes, alors que la météo et l'irradiance sont nécessaires ici.
- **CatBoost** : même famille que LightGBM, gère nativement les colonnes catégorielles (comme
`site_type`) sans encodage manuel. Non rejeté, différé : candidat à comparer si LightGBM
plafonne en précision.
+2 -1
View File
@@ -74,9 +74,10 @@ collecteur ne vient le lire.
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|---|---|---|---|---|
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites` et `recommendations` en lecture (endpoints → services → repositories → models) |
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary` et `readings` en lecture (endpoints → services → repositories → models) |
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures |
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement (features par lags/moyennes glissantes, baseline de persistance saisonnière, suivi MLflow local), voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Scoring, endpoint et surveillance de dérive pas encore construits |
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
+39 -21
View File
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid
flowchart TB
ep["endpoints<br/>health, auth, users, sites,<br/>recommendations, stats"]
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, sensors"]
sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService,<br/>SiteService, RecommendationService,<br/>StatsService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, recommendation, reading"]
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, SensorService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading"]
md["models<br/>10 tables"]
db[("PostgreSQL")]
@@ -142,9 +142,12 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 |
| GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 |
| GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 |
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
@@ -156,21 +159,34 @@ Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le f
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose
donc de modifier la liste dans ce fichier de test.
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit à réutiliser
pour les suivantes (`reading`, `dataset`, `prediction`, `alert`, `recommendation`) : les quatre
couches `endpoints → services → repositories → models` y sont toutes présentes, sur des tables
déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`,
contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par
`AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par
`execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base
réelle. `GET /recommendations` et `GET /recommendations/{recommendation_id}` reprennent le même
gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. Une recommandation ne
porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore
exposée. `GET /stats/summary` agrège deux repositories (`SiteRepository`, `ReadingRepository`)
dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit
route-par-table. Le contrat détaillé pour le frontend est dans
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour
`GET /alerts` puis pour les suivantes (`dataset`, `prediction`) : les quatre couches
`endpoints → services → repositories → models` y sont toutes présentes, sur des tables déjà créées
par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, contrairement aux
routes d'administration qui exigent `admin`. `SiteRepository` lit par `AsyncSession.scalar()` (une
ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par `execute()`, ce qui la rend
testable par la fixture `fake_session` au niveau endpoint sans base réelle. `GET /recommendations`
et `GET /recommendations/{recommendation_id}` reprennent le même gabarit à la lettre,
`recommendation_id` étant un entier plutôt qu'un texte. Une recommandation ne porte pas `site_id` :
elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET
/stats/summary` et `GET /sensors/status` agrègent chacune deux repositories (`SiteRepository`,
`ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elles n'entrent donc
pas dans ce gabarit route-par-table. Le contrat détaillé pour le frontend est dans
[31-contrat-authentification.md](31-contrat-authentification.md).
`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable,
donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/
owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni
fenêtre temporelle maximale). `ReadingService` porte donc une couche de validation absente des
autres routes de lecture : `start`/`end` sont optionnels (24 dernières heures par défaut si les
deux sont omis, l'un défaut par rapport à l'autre sinon), l'écart entre les deux est plafonné à 90
jours (`FENETRE_MAXIMALE`), et `limit`/`offset` (défaut 500, plafond 2000) empêchent qu'une fenêtre
large mais peu dense reste malgré tout coûteuse. Un dépassement de plafond répond `400` (règle
métier, portée par le service) plutôt que `422` (réservé à la validation structurelle de FastAPI,
par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est traité comme de
l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait
côté pilote, en `500` plutôt qu'un refus propre.
### `/health/ready`
Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un
@@ -245,8 +261,8 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv
### Ajouter une route métier
Checklist pour toute nouvelle route sur le gabarit `sites`/`recommendations`/`stats`
(`reading`, `dataset`, `prediction`, `alert`) :
Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/
`readings`/`sensors` (`dataset`, `prediction`) :
1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN`
au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux
@@ -323,7 +339,9 @@ Trois fichiers méritent d'être connus avant de toucher à l'authentification :
agir sur le site B. C'est la limite connue du modèle, et le risque BOLA du top 10 API.
- **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage, plus le `REVOKE` sur
`audit_log`. Dette assumée, décrite dans les ADR 0003 et 0004.
- **Pagination et fenêtrage** des lectures de séries temporelles, qui conditionnent la forme des
endpoints métier. Sans plafond dur, une requête sur dix ans d'historique suffit à faire tomber
l'API.
- **Pagination et fenêtrage** : posés sur `GET /readings` (fenêtre plafonnée à 90 jours,
`limit`/`offset` plafonné à 2000), mais toujours en `limit`/`offset` simple — pas de curseur ni
de plan de secours si un `offset` élevé sur une fenêtre dense devient lent en pratique.
`statement_timeout` reste absent au niveau de la connexion, donc rien n'empêche une requête
individuelle de tourner longtemps si les plafonds au-dessus d'elle s'avéraient insuffisants.
- **Politique de versionnement de l'API** au-delà du préfixe `/api/v1`.
+70
View File
@@ -231,3 +231,73 @@ et ne sont pas considérées comme des alertes actuelles.
- Les mesures API ne sont pas rattachées à un dataset historique.
- Une alerte peut être associée à une prévision du même site.
- Une alerte peut donner lieu à plusieurs recommandations.
## Ingestion des données historiques
Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre du projet.
Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période du 1er janvier 2023 au 31 décembre 2024.
Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données. Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`.
### Architecture du flux
```text
Dataset CSV + métadonnées JSON
|
v
historical_import.py
|
+------+------+
| |
v v
Validation SHA-256
| Traçabilité
+------+------+
|
v
Normalisation
+ qualité data
|
v
Chargement par batches
|
v
PostgreSQL / TimescaleDB
| | |
v v v
dataset site reading
```
Le pipeline est développé en Python.
Pandas est utilisé pour l'extraction, la validation et la préparation des données. SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB.
Une empreinte SHA-256 permet d'identifier le dataset utilisé et d'assurer sa traçabilité.
Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources. Aucune imputation n'est réalisée à cette étape.
Le chargement des mesures est effectué par batches de 1 000 lignes.
Les données provenant du dataset CSV sont identifiées par `source = "csv"` et associées à leur `dataset_id`.
### Résultats validés
Le chargement de référence a permis d'obtenir :
- 1 dataset ;
- 7 sites ;
- 122 647 mesures ;
- 0 doublon détecté dans le dataset source.
L'idempotence a également été vérifiée par une deuxième exécution du pipeline : aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647.
La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline est disponible dans `etl/README.md`.
### Évolution prévue
L'étape suivante consiste à orchestrer les traitements Data avec Apache Airflow.
L'orchestration réutilisera la logique ETL existante afin de séparer la logique de traitement de la planification, du suivi des exécutions et de la gestion des erreurs.
Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle de Machine Learning.
+2 -1
View File
@@ -22,6 +22,7 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints
| Argon2id m=19456 t=2 p=1, re-hachage passif quand les paramètres changent | `app/core/hashing.py` | A02 Cryptographic Failures, A07 Identification and Authentication Failures |
| Message et temps de réponse identiques quelle que soit la cause de l'échec, haché leurre sur adresse inconnue | `app/services/auth.py` | A07, API2 |
| Limitation de débit à fenêtre glissante sur trois clés, évaluée avant le hachage | `app/services/auth.py`, `app/repositories/login_attempt.py` | A07, API4 Unrestricted Resource Consumption |
| `GET /readings` : fenêtre temporelle plafonnée à 90 jours (24h par défaut), `limit`/`offset` plafonné à 2000, refus `400` si la fenêtre est inversée ou trop large | `app/services/reading.py` | API4 |
| Absence de verrouillage de compte, qui serait un déni de service | ADR 0002 | API4 |
| Jeton de rafraîchissement opaque, haché en base, rotation avec détection de réutilisation | `app/services/auth.py`, `app/repositories/refresh_token.py` | A07, API2 |
| Séparation structurelle accès / rafraîchissement, impossible à confondre | ADR 0002 | API2 |
@@ -50,7 +51,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu
| Item | État | Raison |
|---|---|---|
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
| **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. |
| **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. |
| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. |
| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. |
+347 -7
View File
@@ -1,9 +1,349 @@
# ETL
# Pipeline ETL — EnerVision
Orchestration Apache Airflow : ingestion des mesures, agregations continues,
controles de qualite. Non initialise, voir le ticket dedie.
## Objectif
- `airflow/dags` : DAGs.
- `airflow/plugins` : operateurs et hooks maison.
- `airflow/include` : requetes SQL et ressources referencees par les DAGs.
- `airflow/tests` : tests d'integrite des DAGs.
Le pipeline ETL EnerVision permet d'intégrer les données énergétiques historiques dans PostgreSQL/TimescaleDB.
Cette première étape du pipeline Data permet de charger le dataset fourni dans le cadre du projet, contenant les mesures énergétiques de 7 sites sur la période du 1er janvier 2023 au 31 décembre 2024.
Le pipeline assure :
- l'extraction des données sources ;
- la validation de leur structure et de leur cohérence ;
- la normalisation des données nécessaires au stockage ;
- le suivi de la qualité des données ;
- la traçabilité du dataset importé ;
- le chargement des données dans PostgreSQL/TimescaleDB ;
- l'idempotence du chargement afin d'éviter la création de doublons.
## Données sources
Le dataset est fourni par le formateur dans le cadre du projet EnerVision.
Il contient les deux fichiers suivants :
```text
all_sites_combined.csv
dataset_metadata.json
```
Ces fichiers sont nécessaires une seule fois pour initialiser les données historiques de l'environnement.
Ils ne sont pas versionnés dans Git. Chaque membre de l'équipe récupère manuellement une fois les fichiers fournis par le formateur et les place dans :
```text
data/raw/
```
Structure locale attendue :
```text
data/
└── raw/
├── .gitkeep
├── all_sites_combined.csv
└── dataset_metadata.json
```
Le fichier `.gitkeep` est versionné afin de conserver le répertoire `data/raw/` dans Git. Les fichiers CSV et JSON sont ignorés par Git.
## Technologies utilisées
| Technologie | Utilisation |
|---|---|
| Python | Développement du pipeline ETL |
| Pandas | Lecture, validation et transformation des données |
| JSON | Lecture des métadonnées du dataset |
| hashlib / SHA-256 | Identification, intégrité et traçabilité du dataset |
| SQLAlchemy Async | Connexion et chargement asynchrone en base |
| PostgreSQL | Stockage relationnel |
| TimescaleDB | Stockage des séries temporelles énergétiques |
| Docker Compose | Exécution de l'environnement local |
| Alembic | Gestion des migrations du schéma |
| uv | Gestion et exécution de l'environnement Python |
| Ruff | Contrôle de la qualité du code |
| Pytest | Tests automatisés |
## Fonctionnement du pipeline
Le script principal d'import se trouve dans :
```text
apps/backend/app/etl/historical_import.py
```
Le flux d'import est le suivant :
```text
CSV + métadonnées JSON
|
v
Extraction
|
v
Validation
|
v
Traçabilité SHA-256
|
v
Transformation
|
v
Chargement par batches
|
v
PostgreSQL / TimescaleDB
```
### 1. Extraction
Le pipeline charge :
- `all_sites_combined.csv` avec Pandas ;
- `dataset_metadata.json` avec le module JSON de Python.
### 2. Validation
Avant toute écriture en base, le pipeline contrôle notamment :
- la présence des colonnes obligatoires ;
- le nombre de lignes ;
- la cohérence des identifiants des sites ;
- la cohérence des informations associées aux sites ;
- les doublons sur le couple `(site_id, timestamp)` ;
- les timestamps ;
- les valeurs manquantes.
Une incohérence détectée pendant cette étape interrompt l'import avant le chargement.
### 3. Dry-run
Un mode `--dry-run` permet d'exécuter les contrôles sans écrire de données dans PostgreSQL.
Il permet notamment de vérifier :
- le nombre de lignes ;
- le nombre de sites ;
- la période couverte ;
- les doublons ;
- les valeurs NULL ;
- l'empreinte SHA-256.
### 4. Traçabilité
Une empreinte SHA-256 est calculée à partir du fichier CSV afin d'identifier le dataset utilisé.
Empreinte SHA-256 du dataset validé :
```text
6E3777A97A5660B11855750B9028F70BE72138A11F26795F3A35D9CE74CE0C8D
```
Cette empreinte participe à la traçabilité du dataset chargé.
### 5. Transformation
Les timestamps sont normalisés avec la timezone :
```text
UTC
```
Le pipeline détermine également la qualité des mesures à partir des données disponibles.
Les valeurs manquantes sont conservées pendant cette phase afin de préserver la donnée source.
Aucune imputation n'est réalisée pendant l'ingestion :
```text
imputed_values = NULL
imputation_method = NULL
```
### 6. Chargement
Le chargement est réalisé avec SQLAlchemy Async dans PostgreSQL/TimescaleDB.
Les données sont enregistrées dans les tables :
```text
dataset
site
reading
```
Les mesures sont chargées par batches de :
```text
1000 lignes
```
Les mesures provenant du dataset CSV utilisent :
```text
source = "csv"
dataset_id = identifiant du dataset
```
Cette représentation respecte les contraintes définies dans le schéma de la base.
## Dataset validé
Le dataset traité contient :
- 122 647 mesures ;
- 7 sites ;
- une période du 01/01/2023 au 31/12/2024 ;
- 0 doublon détecté dans les données sources.
Valeurs manquantes identifiées :
| Variable | Nombre de valeurs NULL |
|---|---:|
| `consumption_kwh` | 2 840 |
| `consumption_euros` | 2 487 |
| `temperature_celsius` | 3 416 |
| `humidity_percent` | 3 423 |
| `solar_irradiance_wm2` | 3 964 |
## Exécution en dry-run
Depuis le dossier :
```text
apps/backend/
```
exécuter :
```powershell
uv run python -m app.etl.historical_import `
--csv ..\..\data\raw\all_sites_combined.csv `
--metadata ..\..\data\raw\dataset_metadata.json `
--source-timezone UTC `
--dry-run
```
Aucune donnée n'est écrite dans la base pendant cette exécution.
## Chargement réel
Depuis `apps/backend/` :
```powershell
uv run python -m app.etl.historical_import `
--csv ..\..\data\raw\all_sites_combined.csv `
--metadata ..\..\data\raw\dataset_metadata.json `
--source-timezone UTC
```
Le chargement est effectué progressivement par batches.
Exemple :
```text
Chargement : 1000/122647
Chargement : 2000/122647
...
Chargement : 122647/122647
```
## Résultats obtenus
Après le chargement initial, les contrôles en base ont confirmé :
```text
datasets = 1
sites = 7
readings = 122647
source = csv
```
Le premier import a créé :
```text
nouvelles lectures : 122647
```
## Idempotence
Le pipeline a été exécuté une deuxième fois avec exactement le même dataset afin de vérifier son idempotence.
Résultat :
```text
lectures avant : 122647
lectures après : 122647
nouvelles lectures : 0
```
Une nouvelle exécution du même import ne crée donc pas de mesures supplémentaires pour le dataset testé.
## Vérifications SQL
Depuis la racine du projet, vérifier le nombre d'enregistrements avec :
```powershell
docker compose exec db psql -U enervision -d enervision -c "SELECT COUNT(*) AS datasets FROM dataset; SELECT COUNT(*) AS sites FROM site; SELECT COUNT(*) AS readings FROM reading;"
```
Résultat attendu après l'import initial :
```text
datasets = 1
sites = 7
readings = 122647
```
Vérifier la source des mesures avec :
```powershell
docker compose exec db psql -U enervision -d enervision -c "SELECT source, COUNT(*) FROM reading GROUP BY source ORDER BY source;"
```
Résultat attendu :
```text
csv | 122647
```
## Tests et qualité
Les tests automatisés du pipeline sont situés dans :
```text
apps/backend/tests/etl/
```
Ils couvrent notamment :
- la validation du dataset ;
- les colonnes obligatoires ;
- la détection des doublons ;
- la cohérence des sites ;
- la normalisation des timestamps ;
- la gestion des valeurs manquantes ;
- la classification de la qualité des données ;
- la construction des mesures destinées à la BDD ;
- le respect des contraintes du modèle de données.
Exécuter les tests ETL :
```powershell
uv run pytest tests\etl -v
```
Contrôler la qualité du code :
```powershell
uv run ruff check app\etl tests\etl
```
## Suite du pipeline Data
L'import historique constitue la première brique du pipeline Data EnerVision.
La prochaine étape consiste à orchestrer les traitements ETL avec Apache Airflow, puis à préparer les données nécessaires à l'entraînement du modèle de Machine Learning.
Airflow sera utilisé comme orchestrateur des traitements existants et ne remplacera pas la logique métier déjà implémentée dans le pipeline ETL.
+1
View File
@@ -0,0 +1 @@
3.14
+88
View File
@@ -0,0 +1,88 @@
# ML EnerVision
Pipeline d'entrainement du modele de prevision de consommation energetique. Contexte complet :
[ADR 0005](../docs/adr/0005-modele-prediction-lightgbm.md) (choix du modele) et
[ML-START.md](../ML-START.md) (mecanisme d'acces aux donnees).
| Element | Choix |
|--------------|-----------------------------------------------|
| Python | 3.14 |
| Gestionnaire | uv (`uv.lock` fait foi) |
| Modele | LightGBM (regression, un seul modele global) |
| Suivi | MLflow (parametres, metriques, artefact) |
| Lint/format | ruff |
| Typage | mypy en mode strict |
| Tests | pytest, donnees synthetiques uniquement |
Projet Python independant de `apps/backend` : le service FastAPI n'a aucune raison d'embarquer
LightGBM/MLflow en dependance de production juste pour un script d'entrainement lance a la main.
## Installation
```bash
uv sync --all-groups
```
## Donnees
Deux sources, qui produisent le meme schema en sortie de `enervision_ml.data` (voir le module
pour le detail) :
- **CSV** (`--csv`), chemin de demarrage : lit directement `ml/data/all_sites_combined.csv`, le
jeu de donnees fourni pour le jalon J3. Ce dossier est ignore par git (gros fichier, local a
chaque poste) : recuperer le CSV et `dataset_metadata.json` aupres de l'equipe et les placer
dans `ml/data/` avant d'entrainer sur cette source.
- **PostgreSQL** (par defaut, sans `--csv`) : connexion directe a `reading` + `site` via
`ML_DATABASE_URL`, le chemin cible decrit dans `ML-START.md`. Le role PostgreSQL dedie
`enervision_ml` (lecture seule) n'est pas encore provisionne (dette assumee, cf. ADR 0003 et
ADR 0005) ; en attendant, pointer `ML_DATABASE_URL` vers la meme base que le backend suffit en
developpement.
## Entrainement
```bash
uv run python -m enervision_ml.train --csv data/all_sites_combined.csv
# ou, une fois la base peuplee et ML_DATABASE_URL positionnee :
uv run python -m enervision_ml.train
```
Ecrit le modele entraine dans `models/lightgbm-consumption.txt` (`Booster.save_model()`, dossier
ignore par git) et journalise la run dans MLflow : parametres, MAE/RMSE/MAPE du modele **et** de
la baseline de persistance saisonniere (consommation de la meme heure, une semaine avant), et
l'artefact modele. Sans `MLFLOW_TRACKING_URI`, MLflow ecrit dans un magasin SQLite local
(`./mlflow.db`, ignore par git) : `uv run mlflow ui` pour le consulter.
`--test-fraction` (0.15 par defaut) fixe la part la plus recente de l'historique reservee a la
validation. La coupure est **chronologique**, jamais un tirage aleatoire de lignes : un tirage
aleatoire laisserait des lignes de validation "voir" des lignes d'entrainement via leurs
lags/moyennes glissantes, une fuite qui masquerait un surapprentissage.
## Commandes
```bash
uv run ruff check . # lint
uv run ruff format . # format
uv run mypy enervision_ml tests # typage strict
uv run pytest # tests
```
Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-lint`,
`make ml-typecheck`, `make ml-test`, `make ml-check`, `make ml-train` (`CSV=chemin` optionnel).
## Ou ecrire les tests
Aucun test ne touche PostgreSQL ni un serveur MLflow distant : `enervision_ml.data.load_from_csv`
et le chargement CSV de test suffisent a exercer `build_features` sur des donnees reelles ou
synthetiques, et `enervision_ml.train.train()` accepte un `tracking_uri` SQLite isole (`tmp_path`
pytest) pour un test de bout en bout sans effet de bord. `enervision_ml.data.load_from_database`
n'est pas encore couvert : il n'existe aucune base PostgreSQL a interroger en CI ni dans cet
environnement de developpement pour le moment.
## Piege a connaitre
`enervision_ml.features.build_features` est **le seul endroit** qui doit construire les features
du modele, a l'entrainement comme au futur scoring (service #37, pas encore construit). Si les
deux divergent meme legerement (une fenetre de moyenne glissante calculee differemment, par
exemple), le modele recoit en production des features qui ne ressemblent plus a ce qu'il a
appris, et ses predictions deviennent silencieusement mauvaises sans qu'aucune erreur ne se
declenche. Ne jamais reecrire cette logique ailleurs : importer `enervision_ml.features`.
View File
+16
View File
@@ -0,0 +1,16 @@
"""Baseline de persistance saisonniere, la barre a depasser pour justifier LightGBM.
Predit la consommation de l'heure cible par celle de la meme heure, une semaine avant
(`consumption_kwh_lag_168h`) : une consommation energetique horaire est dominee par le cycle
hebdomadaire (jours ouvres contre week-end), donc ce naif-la est deja un concurrent serieux.
"""
import pandas as pd
from enervision_ml.features import TARGET_COLUMN
SEASONAL_LAG_COLUMN = f"{TARGET_COLUMN}_lag_168h"
def seasonal_persistence_predictions(features: pd.DataFrame) -> pd.Series:
return features[SEASONAL_LAG_COLUMN]
+38
View File
@@ -0,0 +1,38 @@
"""Configuration minimale du pipeline, lue depuis l'environnement.
Pas de `BaseSettings` Pydantic ici : contrairement a `apps/backend`, ce n'est pas un service qui
tourne en continu mais un script CLI lance a la main (cf. `docs/ML-START.md`), donc pas de
surface de configuration a valider au demarrage d'un processus long.
"""
import os
# Piege : ce n'est pas `DATABASE_URL` (celui du backend applicatif, proprietaire du schema).
# `docs/ML-START.md` et l'ADR 0003 designent un role PostgreSQL dedie et restreint en lecture,
# `enervision_ml`, non encore provisionne (dette assumee). Reutiliser `DATABASE_URL` par defaut
# ferait tourner l'entrainement avec les droits d'ecriture complets de l'application, en
# silence.
ML_DATABASE_URL_ENV = "ML_DATABASE_URL"
MLFLOW_EXPERIMENT_NAME = "consumption-forecast"
MLFLOW_TRACKING_URI_ENV = "MLFLOW_TRACKING_URI"
def database_url() -> str:
valeur = os.environ.get(ML_DATABASE_URL_ENV)
if not valeur:
raise RuntimeError(
f"{ML_DATABASE_URL_ENV} n'est pas defini. Elle doit pointer vers un role "
"PostgreSQL en lecture seule sur `reading`/`site` (voir docs/ML-START.md)."
)
return valeur
def mlflow_tracking_uri() -> str | None:
"""`None` laisse MLflow choisir son magasin local par defaut.
Piege : ce n'est plus `./mlruns` en clair depuis MLflow 3 (magasin fichier "maintenance
mode", refuse une URI `file:` explicite sauf `MLFLOW_ALLOW_FILE_STORE=true`), mais une base
SQLite locale (`./mlflow.db`).
"""
return os.environ.get(MLFLOW_TRACKING_URI_ENV)
+68
View File
@@ -0,0 +1,68 @@
"""Chargement des donnees d'entrainement.
Deux chemins, qui doivent produire le meme schema de sortie (colonnes `site_id`, `timestamp`,
`consumption_kwh`, `temperature_celsius`, `humidity_percent`, `solar_irradiance_wm2`,
`is_working_hours`, `site_type`, `capacity_kw`), consomme ensuite par `enervision_ml.features` :
- `load_from_database` : le chemin cible decrit dans `docs/ML-START.md`, connexion PostgreSQL
directe (`reading` + `site`), pas par l'API. C'est celui qu'utilisera le pipeline en
production, une fois le role PostgreSQL dedie `enervision_ml` provisionne (dette assumee,
documentee dans `CLAUDE.md` et l'ADR 0003 : pour l'instant, la meme chaine de connexion que le
backend applicatif convient en developpement).
- `load_from_csv` : chemin de demarrage, tant que la base locale n'est pas peuplee. Lit
directement `ml/data/all_sites_combined.csv` (jeu de donnees fourni pour le jalon J3, cf.
issue #89), le meme fichier que celui consomme par
`apps/backend/app/etl/historical_import.py`. `capacity_kw` n'existe pas dans ce CSV : la
colonne est renvoyee a `NaN`, que LightGBM gere nativement comme valeur manquante.
"""
from pathlib import Path
import pandas as pd
from sqlalchemy import text
from sqlalchemy.engine import Connectable
OUTPUT_COLUMNS = [
"site_id",
"timestamp",
"consumption_kwh",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
"is_working_hours",
"site_type",
"capacity_kw",
]
_READING_QUERY = text(
"""
SELECT
r.site_id,
r.timestamp,
r.consumption_kwh,
r.temperature_celsius,
r.humidity_percent,
r.solar_irradiance_wm2,
r.is_working_hours,
s.site_type,
s.capacity_kw
FROM reading r
JOIN site s ON s.site_id = r.site_id
ORDER BY r.site_id, r.timestamp
"""
)
def load_from_database(connection: Connectable) -> pd.DataFrame:
"""Lit l'historique complet `reading` + `site` depuis PostgreSQL."""
frame = pd.read_sql(_READING_QUERY, connection)
return frame[OUTPUT_COLUMNS]
def load_from_csv(csv_path: Path) -> pd.DataFrame:
"""Lit le jeu de donnees CSV historique (chemin de demarrage, hors base)."""
frame = pd.read_csv(csv_path, parse_dates=["timestamp"])
frame["capacity_kw"] = float("nan")
frame["is_working_hours"] = frame["is_working_hours"].astype(bool)
return frame[OUTPUT_COLUMNS]
+129
View File
@@ -0,0 +1,129 @@
"""Construction des features pour le modele de consommation.
Module partage entre l'entrainement et le futur scoring (cf. `docs/ML-START.md`) : la fonction
qui construit les features doit rester strictement identique des deux cotes, sous peine de
"train/serve skew" silencieux (le modele recoit en production des features qui ne ressemblent
plus a ce qu'il a appris).
"""
from collections.abc import Sequence
import pandas as pd
# Cible de l'entrainement : consommation en kWh, jamais consumption_kw (absent des lectures
# historiques CSV, cf. `apps/backend/app/etl/historical_import.py`).
TARGET_COLUMN = "consumption_kwh"
# Decalages horaires utilises pour les lags et moyennes glissantes : une heure avant, un jour
# avant (meme heure), une semaine avant (meme heure, meme jour) - saisonnalites usuelles d'une
# consommation energetique horaire.
LAG_HOURS: Sequence[int] = (1, 24, 168)
ROLLING_WINDOWS_HOURS: Sequence[int] = (24, 168)
STATIC_FEATURE_COLUMNS: Sequence[str] = ("site_type", "capacity_kw")
CALENDAR_FEATURE_COLUMNS: Sequence[str] = (
"hour",
"day_of_week",
"month",
"is_weekend",
"is_working_hours",
)
WEATHER_COLUMNS: Sequence[str] = (
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
)
def build_features(frame: pd.DataFrame) -> pd.DataFrame:
"""Construit la matrice de features a partir de lectures brutes triees par site.
`frame` doit porter au minimum : `site_id`, `timestamp`, `consumption_kwh`,
`is_working_hours`, les trois colonnes meteo, et les colonnes statiques de site
(`site_type`, `capacity_kw`). Une ligne par `(site_id, timestamp)`, sans doublon.
Piege : la meteo n'entre dans les features que decalee (lag/moyenne glissante), jamais a
l'instant cible. A l'entrainement comme au scoring, la meteo au moment predit n'est pas une
mesure mais une prevision que le projet n'a pas — l'utiliser telle quelle romprait le
contrat entre entrainement et usage reel (la feature ne serait tout simplement plus
disponible en production). Cf. debat d'architecture dans l'issue #89.
"""
travail = frame.sort_values(["site_id", "timestamp"]).reset_index(drop=True)
calendrier = _calendar_features(travail["timestamp"])
decalees = _lagged_features(travail)
features = pd.concat(
[
travail[["site_id", "timestamp"]],
travail[list(STATIC_FEATURE_COLUMNS)],
calendrier,
travail[["is_working_hours"]],
decalees,
travail[[TARGET_COLUMN]],
],
axis=1,
)
# `period_minutes` : resolution temporelle de la cible. Les lectures historiques sont toutes
# au pas horaire (cf. `dataset_metadata.json`, `frequency: "1h""), donc une constante pour
# l'instant. Exposee comme feature plutot que supposee implicitement, pour que le modele
# puisse un jour apprendre sur d'autres resolutions sans reentrainement de zero.
features["period_minutes"] = 60
return features
def feature_columns() -> list[str]:
"""Liste ordonnee des colonnes d'entree du modele (hors identifiants et cible)."""
lag_columns = [f"consumption_kwh_lag_{h}h" for h in LAG_HOURS]
rolling_columns = [
f"{colonne}_rolling_mean_{fenetre}h"
for colonne in (TARGET_COLUMN, *WEATHER_COLUMNS)
for fenetre in ROLLING_WINDOWS_HOURS
]
weather_lag_columns = [f"{colonne}_lag_1h" for colonne in WEATHER_COLUMNS]
return [
*STATIC_FEATURE_COLUMNS,
*CALENDAR_FEATURE_COLUMNS,
"period_minutes",
*lag_columns,
*rolling_columns,
*weather_lag_columns,
]
def _calendar_features(timestamps: pd.Series) -> pd.DataFrame:
instants = pd.to_datetime(timestamps)
return pd.DataFrame(
{
"hour": instants.dt.hour,
"day_of_week": instants.dt.dayofweek,
"month": instants.dt.month,
"is_weekend": instants.dt.dayofweek.isin([5, 6]).astype(int),
}
)
def _lagged_features(travail: pd.DataFrame) -> pd.DataFrame:
par_site = travail.groupby("site_id", sort=False)
colonnes: dict[str, pd.Series] = {}
for decalage in LAG_HOURS:
colonnes[f"{TARGET_COLUMN}_lag_{decalage}h"] = par_site[TARGET_COLUMN].shift(decalage)
for colonne in (TARGET_COLUMN, *WEATHER_COLUMNS):
decale = par_site[colonne].shift(1)
for fenetre in ROLLING_WINDOWS_HOURS:
colonnes[f"{colonne}_rolling_mean_{fenetre}h"] = decale.groupby(
travail["site_id"]
).transform(lambda serie, fenetre=fenetre: serie.rolling(fenetre, min_periods=1).mean())
for colonne in WEATHER_COLUMNS:
colonnes[f"{colonne}_lag_1h"] = par_site[colonne].shift(1)
return pd.DataFrame(colonnes, index=travail.index)
+24
View File
@@ -0,0 +1,24 @@
"""Metriques de regression partagees entre le modele et la baseline."""
import numpy as np
import pandas as pd
from sklearn.metrics import mean_absolute_error, root_mean_squared_error
def regression_metrics(y_true: pd.Series, y_pred: pd.Series) -> dict[str, float]:
"""MAE, RMSE et MAPE (en %), sur les paires non nulles des deux series."""
valides = y_true.notna() & y_pred.notna()
reel = y_true[valides]
predit = y_pred[valides]
# MAPE diverge a consommation nulle : les mesures a zero (site a l'arret) sont exclues de ce
# seul ratio, pas des autres metriques.
non_nul = reel != 0
mape = float(np.mean(np.abs((reel[non_nul] - predit[non_nul]) / reel[non_nul])) * 100)
return {
"mae": float(mean_absolute_error(reel, predit)),
"rmse": float(root_mean_squared_error(reel, predit)),
"mape": mape,
"n_observations": int(valides.sum()),
}
+245
View File
@@ -0,0 +1,245 @@
"""Entrainement du modele LightGBM de prevision de consommation energetique.
CLI autonome, sur le meme gabarit que `apps/backend/app/etl/historical_import.py`
(argparse, connexion directe a la base). Cf. `docs/ML-START.md`, section 1.
uv run python -m enervision_ml.train --csv ../ml/data/all_sites_combined.csv
uv run python -m enervision_ml.train # lit ML_DATABASE_URL
Le modele entraine est ecrit en fichier (`Booster.save_model()`) et suivi par MLflow (parametres,
metriques, artefact). La base ne stocke jamais le modele lui-meme, seulement une reference vers
lui (`prediction.model_reference`, pose par le futur service de scoring - hors perimetre ici).
"""
import argparse
from pathlib import Path
from typing import Any
import lightgbm as lgb
import mlflow
import mlflow.lightgbm
import pandas as pd
from sqlalchemy import create_engine
from enervision_ml import config
from enervision_ml.baseline import seasonal_persistence_predictions
from enervision_ml.data import load_from_csv, load_from_database
from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns
from enervision_ml.metrics import regression_metrics
CATEGORICAL_FEATURES = ["site_type"]
LIGHTGBM_PARAMS: dict[str, Any] = {
"objective": "regression",
"metric": "mae",
"learning_rate": 0.05,
"num_leaves": 63,
"min_data_in_leaf": 50,
"feature_fraction": 0.8,
"bagging_fraction": 0.8,
"bagging_freq": 1,
"verbosity": -1,
}
NUM_BOOST_ROUND = 1000
EARLY_STOPPING_ROUNDS = 50
DEFAULT_TEST_FRACTION = 0.15
def load_raw_frame(csv_path: Path | None) -> pd.DataFrame:
"""Lit les lectures brutes, depuis le CSV de demarrage ou depuis PostgreSQL."""
if csv_path is not None:
return load_from_csv(csv_path)
engine = create_engine(config.database_url())
try:
return load_from_database(engine)
finally:
engine.dispose()
def chronological_split(
features: pd.DataFrame, test_fraction: float
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Coupe par date de coupure, jamais par tirage aleatoire de lignes.
Une coupure aleatoire laisserait des lignes d'apres la coupure "voir" des lignes d'avant via
leurs lags/moyennes glissantes, une fuite qui masquerait un surapprentissage a l'evaluation.
"""
coupure = features["timestamp"].quantile(1 - test_fraction)
entrainement = features[features["timestamp"] < coupure]
validation = features[features["timestamp"] >= coupure]
return entrainement, validation
def prepare_dataset(frame: pd.DataFrame, columns: list[str]) -> tuple[pd.DataFrame, pd.Series]:
typee = frame.copy()
typee["site_type"] = typee["site_type"].astype("category")
return typee[columns], typee[TARGET_COLUMN]
def train(
*,
csv_path: Path | None,
model_output: Path,
test_fraction: float = DEFAULT_TEST_FRACTION,
tracking_uri: str | None = None,
) -> tuple[dict[str, float], dict[str, float]]:
"""Execute le pipeline complet et rend (metriques du modele, metriques de la baseline)."""
raw = load_raw_frame(csv_path)
features = build_features(raw)
columns = feature_columns()
# Les premieres 168h par site n'ont pas de lag hebdomadaire complet : ni entrainables, ni
# comparables a la baseline saisonniere qui en depend.
utilisable = features.dropna(subset=[TARGET_COLUMN, f"{TARGET_COLUMN}_lag_168h"])
entrainement, validation = chronological_split(utilisable, test_fraction)
if entrainement.empty or validation.empty:
raise ValueError(
"Fenetre d'entrainement ou de validation vide : jeu de donnees trop court pour "
f"test_fraction={test_fraction}."
)
X_train, y_train = prepare_dataset(entrainement, columns)
X_valid, y_valid = prepare_dataset(validation, columns)
train_set = lgb.Dataset(
X_train,
label=y_train,
categorical_feature=CATEGORICAL_FEATURES,
free_raw_data=False,
)
valid_set = lgb.Dataset(
X_valid,
label=y_valid,
reference=train_set,
categorical_feature=CATEGORICAL_FEATURES,
free_raw_data=False,
)
booster = lgb.train(
LIGHTGBM_PARAMS,
train_set,
num_boost_round=NUM_BOOST_ROUND,
valid_sets=[valid_set],
callbacks=[
lgb.early_stopping(EARLY_STOPPING_ROUNDS, verbose=False),
lgb.log_evaluation(period=0),
],
)
predictions = pd.Series(
booster.predict(X_valid, num_iteration=booster.best_iteration),
index=X_valid.index,
)
model_metrics = regression_metrics(y_valid, predictions)
baseline_metrics = regression_metrics(y_valid, seasonal_persistence_predictions(validation))
model_output.parent.mkdir(parents=True, exist_ok=True)
booster.save_model(str(model_output))
_log_to_mlflow(
tracking_uri=tracking_uri,
booster=booster,
model_metrics=model_metrics,
baseline_metrics=baseline_metrics,
n_train=len(X_train),
n_valid=len(X_valid),
test_fraction=test_fraction,
model_output=model_output,
)
return model_metrics, baseline_metrics
def _log_to_mlflow(
*,
tracking_uri: str | None,
booster: lgb.Booster,
model_metrics: dict[str, float],
baseline_metrics: dict[str, float],
n_train: int,
n_valid: int,
test_fraction: float,
model_output: Path,
) -> None:
uri = tracking_uri or config.mlflow_tracking_uri()
if uri is not None:
mlflow.set_tracking_uri(uri)
mlflow.set_experiment(config.MLFLOW_EXPERIMENT_NAME)
with mlflow.start_run():
mlflow.log_params(
{
**LIGHTGBM_PARAMS,
"num_boost_round": booster.best_iteration or NUM_BOOST_ROUND,
"test_fraction": test_fraction,
"n_train": n_train,
"n_valid": n_valid,
}
)
mlflow.log_metrics({f"model_{cle}": valeur for cle, valeur in model_metrics.items()})
mlflow.log_metrics({f"baseline_{cle}": valeur for cle, valeur in baseline_metrics.items()})
mlflow.lightgbm.log_model(booster, name="model")
mlflow.log_artifact(str(model_output))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Entrainement du modele LightGBM EnerVision")
parser.add_argument(
"--csv",
type=Path,
default=None,
help=(
"Chemin vers le CSV historique (chemin de demarrage). Omis, lit ML_DATABASE_URL "
"et se connecte directement a PostgreSQL (reading + site)."
),
)
parser.add_argument(
"--model-output",
type=Path,
default=Path("models/lightgbm-consumption.txt"),
help="Chemin d'ecriture du modele entraine. Defaut : models/lightgbm-consumption.txt.",
)
parser.add_argument(
"--test-fraction",
type=float,
default=DEFAULT_TEST_FRACTION,
help=(
"Part la plus recente de l'historique reservee a la validation. "
f"Defaut : {DEFAULT_TEST_FRACTION}."
),
)
parser.add_argument(
"--mlflow-tracking-uri",
default=None,
help="Surcharge MLFLOW_TRACKING_URI. Omis, magasin SQLite local (./mlflow.db).",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
model_metrics, baseline_metrics = train(
csv_path=args.csv,
model_output=args.model_output,
test_fraction=args.test_fraction,
tracking_uri=args.mlflow_tracking_uri,
)
print("Modele LightGBM :", model_metrics)
print("Baseline saisonniere (t-168h) :", baseline_metrics)
if model_metrics["mae"] < baseline_metrics["mae"]:
gain = (1 - model_metrics["mae"] / baseline_metrics["mae"]) * 100
print(f"LightGBM bat la baseline de {gain:.1f}% de MAE.")
else:
print("LightGBM ne bat pas la baseline saisonniere sur ce decoupage.")
if __name__ == "__main__":
main()
View File
+79
View File
@@ -0,0 +1,79 @@
[project]
name = "enervision-ml"
version = "0.1.0"
description = "Pipeline d'entrainement et de scoring du modele de prediction EnerVision (LightGBM)"
requires-python = ">=3.14,<3.15"
dependencies = [
"pandas>=3.0.5",
"sqlalchemy>=2.0.52",
"psycopg[binary]>=3.2",
"lightgbm>=4.6",
"scikit-learn>=1.7",
"mlflow>=3.0",
]
[dependency-groups]
dev = [
"ruff>=0.16.7",
"mypy>=2.3.1",
"pytest>=9.1.1",
"pandas-stubs>=3.0.5.260914",
]
[build-system]
requires = ["hatchling>=1.32.0"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["enervision_ml"]
[tool.ruff]
line-length = 100
target-version = "py314"
src = ["enervision_ml", "tests"]
[tool.ruff.lint]
select = [
"E", "W",
"F",
"I",
"N",
"UP",
"B",
"C4",
"SIM",
"TID",
"RUF",
"S",
"PT",
]
# N806 : `X`/`y` (donnees/cible) est la convention scikit-learn/LightGBM, pas une variable mal
# nommee.
ignore = ["B008", "N806"]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"]
[tool.ruff.lint.isort]
known-first-party = ["enervision_ml"]
[tool.ruff.format]
quote-style = "double"
[tool.mypy]
python_version = "3.14"
strict = true
warn_unreachable = true
[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_defs = false
[[tool.mypy.overrides]]
module = ["lightgbm.*", "mlflow.*", "sklearn.*"]
ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers -m 'not integration'"
markers = ["integration: requiert une base PostgreSQL joignable"]
+11
View File
@@ -0,0 +1,11 @@
import pandas as pd
from enervision_ml.baseline import SEASONAL_LAG_COLUMN, seasonal_persistence_predictions
def test_seasonal_persistence_predictions_returns_the_168h_lag_column() -> None:
features = pd.DataFrame({SEASONAL_LAG_COLUMN: [1.0, 2.0, 3.0], "autre_colonne": [9, 9, 9]})
predictions = seasonal_persistence_predictions(features)
assert predictions.tolist() == [1.0, 2.0, 3.0]
+96
View File
@@ -0,0 +1,96 @@
from datetime import UTC, datetime, timedelta
from typing import cast
import pandas as pd
from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns
def make_site_reading(
site_id: str, *, heures: int, depart: datetime, valeur: float = 10.0
) -> pd.DataFrame:
instants = [depart + timedelta(hours=h) for h in range(heures)]
return pd.DataFrame(
{
"site_id": site_id,
"timestamp": instants,
TARGET_COLUMN: [valeur + h for h in range(heures)],
"temperature_celsius": [15.0] * heures,
"humidity_percent": [50.0] * heures,
"solar_irradiance_wm2": [0.0] * heures,
"is_working_hours": [True] * heures,
"site_type": "office",
"capacity_kw": 100.0,
}
)
def two_site_frame(heures: int = 200) -> pd.DataFrame:
depart = datetime(2026, 1, 1, tzinfo=UTC)
return pd.concat(
[
make_site_reading("site-a", heures=heures, depart=depart, valeur=10.0),
make_site_reading("site-b", heures=heures, depart=depart, valeur=1000.0),
],
ignore_index=True,
)
def test_build_features_returns_every_declared_feature_column() -> None:
features = build_features(two_site_frame())
manquantes = set(feature_columns()) - set(features.columns)
assert manquantes == set()
def test_build_features_sets_a_constant_period_minutes() -> None:
features = build_features(two_site_frame())
assert (features["period_minutes"] == 60).all()
def test_build_features_lag_1h_matches_the_previous_hour_of_the_same_site() -> None:
features = build_features(two_site_frame(heures=200))
site_a = features[features["site_id"] == "site-a"].reset_index(drop=True)
assert site_a.loc[10, f"{TARGET_COLUMN}_lag_1h"] == site_a.loc[9, TARGET_COLUMN]
def test_build_features_lag_168h_is_nan_before_a_full_week_of_history() -> None:
features = build_features(two_site_frame(heures=200))
site_a = features[features["site_id"] == "site-a"].reset_index(drop=True)
assert pd.isna(site_a.loc[100, f"{TARGET_COLUMN}_lag_168h"])
assert not pd.isna(site_a.loc[168, f"{TARGET_COLUMN}_lag_168h"])
def test_build_features_never_leaks_lags_across_sites() -> None:
# site-b demarre a 1000 : si un lag de site-a s'y glissait, la valeur sortirait de son
# echelle (10, 11, 12, ...).
features = build_features(two_site_frame(heures=200))
site_b = features[features["site_id"] == "site-b"].reset_index(drop=True)
assert cast(float, site_b.loc[5, f"{TARGET_COLUMN}_lag_1h"]) >= 1000.0
def test_build_features_rolling_mean_excludes_the_current_hour() -> None:
# Valeurs constantes sauf la derniere ligne : si la moyenne glissante incluait l'heure
# courante, la constante ne resterait pas stable jusqu'au bout.
depart = datetime(2026, 1, 1, tzinfo=UTC)
frame = make_site_reading("site-a", heures=200, depart=depart, valeur=10.0)
frame[TARGET_COLUMN] = 10.0
frame.loc[frame.index[-1], TARGET_COLUMN] = 10_000.0
features = build_features(frame).reset_index(drop=True)
assert features.loc[len(features) - 1, f"{TARGET_COLUMN}_rolling_mean_24h"] == 10.0
def test_build_features_computes_calendar_fields_from_the_timestamp() -> None:
depart = datetime(2026, 1, 3, 6, tzinfo=UTC) # un samedi, 6h
features = build_features(make_site_reading("site-a", heures=1, depart=depart))
assert features.loc[0, "hour"] == 6
assert features.loc[0, "day_of_week"] == 5
assert features.loc[0, "is_weekend"] == 1
+45
View File
@@ -0,0 +1,45 @@
import pandas as pd
import pytest
from enervision_ml.metrics import regression_metrics
def test_regression_metrics_computes_mae_and_rmse_on_known_values() -> None:
y_true = pd.Series([10.0, 20.0, 30.0])
y_pred = pd.Series([12.0, 18.0, 33.0])
resultat = regression_metrics(y_true, y_pred)
assert resultat["mae"] == pytest.approx(7 / 3)
assert resultat["n_observations"] == 3
def test_regression_metrics_ignores_rows_with_a_missing_value() -> None:
y_true = pd.Series([10.0, None, 30.0])
y_pred = pd.Series([12.0, 18.0, None])
resultat = regression_metrics(y_true, y_pred)
assert resultat["n_observations"] == 1
assert resultat["mae"] == 2.0
def test_regression_metrics_excludes_zero_actuals_from_mape_only() -> None:
y_true = pd.Series([0.0, 10.0])
y_pred = pd.Series([5.0, 12.0])
resultat = regression_metrics(y_true, y_pred)
assert resultat["n_observations"] == 2
assert resultat["mape"] == pytest.approx(20.0)
def test_metrics_are_zero_for_a_perfect_prediction() -> None:
y_true = pd.Series([10.0, 20.0])
y_pred = pd.Series([10.0, 20.0])
resultat = regression_metrics(y_true, y_pred)
assert resultat["mae"] == 0.0
assert resultat["rmse"] == 0.0
assert resultat["mape"] == 0.0
+76
View File
@@ -0,0 +1,76 @@
from datetime import UTC, datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns
from enervision_ml.train import chronological_split, prepare_dataset, train
def make_frame(site_id: str, *, heures: int, depart: datetime) -> pd.DataFrame:
instants = [depart + timedelta(hours=h) for h in range(heures)]
rng = np.random.default_rng(42)
return pd.DataFrame(
{
"site_id": site_id,
"timestamp": instants,
TARGET_COLUMN: 100.0 + 10.0 * np.sin(np.arange(heures) / 24) + rng.normal(0, 1, heures),
"temperature_celsius": 15.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"is_working_hours": True,
"site_type": "office",
"capacity_kw": 100.0,
}
)
def test_chronological_split_puts_the_most_recent_rows_in_validation() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
features = make_frame("site-a", heures=200, depart=depart)
entrainement, validation = chronological_split(features, test_fraction=0.2)
assert entrainement["timestamp"].max() < validation["timestamp"].min()
# La coupure vient d'un quantile sur les dates : une approximation du taux demande, pas un
# decompte exact de lignes.
assert abs(len(validation) - 0.2 * len(features)) <= 2
def test_prepare_dataset_types_site_type_as_a_pandas_category() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
features = build_features(make_frame("site-a", heures=200, depart=depart))
X, y = prepare_dataset(features, feature_columns())
assert X["site_type"].dtype.name == "category"
assert y.name == TARGET_COLUMN
def test_train_runs_end_to_end_on_synthetic_data_and_beats_a_dummy_baseline(
tmp_path: Path,
) -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
frame = pd.concat(
[
make_frame("site-a", heures=400, depart=depart),
make_frame("site-b", heures=400, depart=depart),
],
ignore_index=True,
)
csv_path = tmp_path / "synthetic.csv"
frame.to_csv(csv_path, index=False)
model_metrics, baseline_metrics = train(
csv_path=csv_path,
model_output=tmp_path / "model.txt",
test_fraction=0.2,
tracking_uri=f"sqlite:///{tmp_path / 'mlflow.db'}",
)
assert (tmp_path / "model.txt").exists()
assert model_metrics["n_observations"] > 0
assert model_metrics["mae"] >= 0
assert baseline_metrics["n_observations"] == model_metrics["n_observations"]
Generated
+1977
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
sonar.projectKey=ProjetPiscine_EnerVision
sonar.organization=groupe3-ener-vision
# This is the name and version displayed in the SonarCloud UI.
#sonar.projectName=ProjetPiscine_EnerVision
#sonar.projectVersion=1.0
# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
#sonar.sources=.
# Encoding of the source code. Default is default system encoding
#sonar.sourceEncoding=UTF-8