Compare commits

...
Author SHA1 Message Date
ineszang44 2fd428feba Merge branch 'dev' into feat/pipeline-ci 2026-09-18 11:43:19 +02:00
Johan LEROYandGitHub 297d85a0ca Merge pull request #84 from ineszang/feat/endpoint-sites-current
feat(backend): expose GET /api/v1/sites/{site_id}/current
2026-09-18 10:33:53 +02:00
ineszangandGitHub af58172742 Merge pull request #99 from ineszang/feat/sonar-dashboard
test: ajout de propriétés dans le fichier de config pour sonarqube
2026-09-18 10:31:19 +02:00
Johan LEROY b433e01fa8 fix(backend): départage aussi les égalités de timestamp dans latest_by_site
Backend / Lint, typage et tests (push) Successful in 1m18s
`latest_by_site` portait le même défaut que `latest_for_site` : `DISTINCT ON (site_id)`
ordonné sur `site_id, timestamp DESC` sans départage, alors que `uq_reading_source`
autorise deux lignes au même `site_id`+`timestamp` quand la `source` diffère.
`/stats/summary` pouvait donc afficher une consommation différente d'un appel à
l'autre pour un site alimenté par un backfill CSV et une écriture live.

Test `integration` dédié, qui échoue sans le correctif.
2026-09-18 10:28:04 +02:00
Johan LEROY 5eb74aa64a fix(backend): traite la revue de phyri0s sur la PR #84
Tri non déterministe : `latest_for_site` départage désormais les égalités de
timestamp par `reading_id` décroissant, comme `list_history`. `uq_reading_source`
autorise deux lignes au même `site_id`+`timestamp` quand la `source` diffère, donc
le `LIMIT 1` pouvait renvoyer l'une ou l'autre d'un appel à l'autre.

Tests : trois tests `integration` sur `latest_for_site` (plus récente, égalité de
timestamp, isolation par site). Le test d'égalité échoue sans le correctif ci-dessus.

Duplication : `DataQuality` et le repli vers `critical` sortent dans
`app/services/data_quality.py`, partagé par `stats.py`, `site.py` et `sensor.py`,
qui en portaient trois copies indépendantes. Supprime au passage deux
`# type: ignore[assignment]`.
2026-09-18 10:28:04 +02:00
ineszang44 cc0a58ac4c test: sonarqube 2026-09-18 10:23:09 +02:00
ineszang44 2400b6f05e test: sonarqube 2026-09-18 10:21:56 +02:00
ineszang44 9e33c276d6 test: sonarqube 2026-09-18 10:16:31 +02:00
ineszang44 6cb9ac00cb test: sonarqube 2026-09-18 10:14:06 +02:00
ineszang44 c1f63889c1 test: sonarqube 2026-09-18 10:10:05 +02:00
ineszang44 5875e8c239 test: sonarqube 2026-09-18 10:01:28 +02:00
ineszang44 c8383014a8 Merge branch 'dev' into feat/sonar-dashboard 2026-09-18 09:52:27 +02:00
PhyriosandGitHub 3cf9194d4c Add progress update for project on 2026-09-18
Document progress update for the project as of September 18, 2026, detailing closed issues, merged PRs, and individual contributions.
2026-09-18 09:24:26 +02:00
PhyriosandGitHub 8def1e23af Add progress report for 15/09/2026 review meeting 2026-09-18 09:23:41 +02:00
Johan LEROYandGitHub 56c134beb0 Merge pull request #95 from ineszang/feat/site-list-view
feat(frontend): vue liste des sites
2026-09-17 16:04:09 +02:00
Johan LEROY 8fb5ab9f65 fix(frontend): traite la revue de phyri0s sur la PR #95
Frontend / build (push) Successful in 9m42s
Frontend / test (push) Failing after 5m23s
Frontend / SonarQube (push) Skipped
- Reutilise .ev-link pour le lien "Detail" de la liste des sites au
  lieu de dupliquer ses regles de style.
- site.location vide est traite comme absent (affiche "-"), pas
  seulement null/undefined.
- siteId de la page detail suit desormais route.paramMap de facon
  reactive plutot qu'une lecture ponctuelle du snapshot, pour rester
  a jour quand Angular reutilise l'instance du composant en changeant
  de site.
- Ajoute provideRouter([]) manquant dans un test dashboard existant,
  necessaire depuis l'ajout du lien "Voir les sites" au rebase sur dev.
2026-09-17 16:01:15 +02:00
Johan LEROY e22feac2c4 fix(frontend): navigation cohérente entre les pages authentifiées
Logo cliquable vers le tableau de bord (ev-brand-link) et fil d'Ariane
(ev-breadcrumb) sur les sous-pages, pour éviter les impasses de
navigation entre dashboard, liste des sites et détail de site.
2026-09-17 15:58:50 +02:00
Johan LEROY 5581cb1ef3 feat(frontend): vue liste des sites
Nouveau SitesService (GET /sites) et page SiteList consommant le design
système (ev-card, ev-badge, ev-alert, ev-brand). Ajoute la route /sites,
un lien depuis le dashboard, et une route détail /sites/:siteId pointant
vers un placeholder minimal en attendant l'issue #51.

Closes #49
2026-09-17 15:58:35 +02:00
Johan LEROYandGitHub 1d8c986386 Merge pull request #93 from ineszang/feat/design-system
feat(frontend): design système - tokens, composants ui et restylage des pages
2026-09-17 15:56:24 +02:00
Johan LEROY 85cb7c9eeb fix(frontend,backend): traite la revue de phyri0s sur la PR #93
Backend / Lint, typage et tests (push) Successful in 1m23s
Frontend / build (push) Successful in 9m38s
Frontend / test (push) Failing after 5m4s
Frontend / SonarQube (push) Skipped
Corrige les 10 points de la revue du systeme de design : garde-fou de
route explicite pour /docs, /redoc et /static, ton distinct pour les
alertes critical vs high, flex-shrink sur le bon element du badge,
mutualisation du bloc ev-card dans _auth-page.scss, bouton de
deconnexion migre vers ev-button (nouvel input fullWidth), tokens
manquants (--color-danger-hover, --color-warning-text,
--color-text-inverse, --color-critical), test de synchronisation des
deux copies du logo, openapi_avec_logo qui enveloppe application.openapi
au lieu de le reimplementer, doc du frontend et index mis a jour, et
suppression du CSS mort .form-error.
2026-09-17 15:53:55 +02:00
Johan LEROY 39b1d28ead Merge remote-tracking branch 'origin/dev' into feat/design-system
# Conflicts:
#	apps/frontend/src/app/features/auth/change-password/change-password.html
#	apps/frontend/src/app/features/auth/change-password/change-password.ts
#	apps/frontend/src/app/features/auth/login/login.html
#	apps/frontend/src/app/features/auth/login/login.ts
2026-09-17 15:44:10 +02:00
Johan LEROYandGitHub 5394257855 Merge pull request #90 from ineszang/feat/password-policy-forgot-password
feat(auth): politique de complexité du mot de passe et flux de réinitialisation
2026-09-17 15:34:26 +02:00
ineszang44 19cfac1cff fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:48:52 +02:00
ineszang44 1fce577a78 fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:45:54 +02:00
ineszang44 1afaee069f fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:39:48 +02:00
ineszang44 7bc9a09489 fix(frontend): mise à jour des propriétés sonar 2026-09-17 14:38:08 +02:00
ineszang44 4ee2109628 fix(frontend): prise en compte du répertoire du reporter 2026-09-17 14:32:14 +02:00
Johan LEROYandGitHub ec1c05ad54 Merge pull request #102 from ineszang/feat/ml
feat(ml): initialise le pipeline d'entrainement LightGBM (ADR 0005)
2026-09-17 14:29:10 +02:00
ineszang44 334ca5982b fix(frontend): chemin vers lcov.info 2026-09-17 14:26:22 +02:00
ineszang44 2465021d61 fix(frontend): chemin vers lcov.info 2026-09-17 14:22:30 +02:00
ineszang44 278299c2b1 test: configuration sonarqube + config angular 2026-09-17 14:18:54 +02:00
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
ineszang44 ed7311d4ef test: configuration sonarqube 2026-09-17 13:36:41 +02:00
ineszang44 ff68a51424 feat(frontend): fichier de config pour vitest 2026-09-17 13:36:21 +02:00
ineszang44 11f9b1bcd5 test(frontend): sonarqube 2026-09-17 13:04:36 +02:00
ineszang44 41c18a3bb1 test: sonarqube 2026-09-17 12:47:44 +02:00
ineszang44 00ef725249 test: sonarqube 2026-09-17 12:46:04 +02:00
ineszang44 34f35f3ca0 test: sonarqube 2026-09-17 12:26:04 +02:00
ineszang44 8e07168a5e test: ajout de propriétés dans le fichier de config pour sonarqube 2026-09-17 12:19:26 +02:00
Johan LEROY d167b64188 Merge remote-tracking branch 'origin/dev' into feat/endpoint-sites-current
# Conflicts:
#	apps/backend/app/repositories/reading.py
#	apps/backend/openapi.json
#	docs/architecture/20-backend.md
2026-09-17 12:17:05 +02:00
Valentin 2ad7692f1c Ajoute la configuration Dependabot (npm, uv, github-actions, docker) 2026-09-17 12:12:48 +02:00
Johan LEROY cd4fd962be fix(backend): regenere openapi.json avec le x-logo ajoute a main.py
Oublie apres l'ajout de l'extension x-logo dans create_app() : le contrat
versionne divergeait du schema genere, faisant echouer
test_the_committed_contract_matches_the_generated_one en CI.
2026-09-17 11:39:24 +02:00
Johan LEROY 517144f7e5 fix(frontend): lockup logo via texte reel plutot qu'un raster recadre
Recomposer icone+texte en une seule image bitmap (recadrage pixel de
l'asset source) donnait un rendu bruite et un espacement fige, impossible
a ajuster proprement (cause du "gros espace entre le texte et l'image"
remonte). Nouveau composant ev-brand : icone PNG nette + texte "EnerVision"
reel en police systeme, tailles liees en em pour que le lockup grossisse en
gardant le meme rapport, et un ecart controlable en CSS plutot que fige
dans un fichier image.
2026-09-17 11:36:29 +02:00
Johan LEROY cc7ca2d359 fix(frontend): logo plus grand et fond de page coherent sur toutes les pages
Logo des pages auth agrandi (64px -> 96px) : encore trop petit avec la
premiere passe. Fond de page (--color-bg) applique globalement sur body
plutot que par page, pour que le dashboard et les pages auth partagent le
meme socle visuel. Logo et respiration du dashboard ajustes en consequence.
2026-09-17 11:30:19 +02:00
Johan LEROY bbafe7d119 fix(frontend): logo/favicon corrects et pages d'authentification plus posees
Le src="logo.png" relatif resolvait mal sur les routes autres que "/" :
chemin absolu "/logo.png". Titre de page "Frontend" -> "EnerVision", favicon
regenere depuis l'icone reelle du logo. Pages login/change-password
retravaillees (fond degrade de marque, carte plus large, logo et titre plus
presents) via une classe .auth-page partagee plutot que dupliquee par page.
2026-09-17 11:29:07 +02:00
Johan LEROY 9c78c6dc38 feat(frontend): design système - tokens, composants ui et restylage des pages
Centralise les couleurs/rayons/espacements dispersés en dur dans chaque page
(login, change-password, dashboard) en tokens CSS partagés, ajoute un petit
set de composants standalone réutilisables (ev-button, ev-card, ev-alert,
ev-badge) et intègre le logo EnerVision en en-tête des pages ainsi que dans
Swagger/ReDoc côté backend.

Refs #91
2026-09-17 11:14:02 +02:00
ineszang44 8fd2f19cce chore(frontend): workflow -> suppression du code inutile 2026-09-17 09:38:03 +02:00
Johan LEROY 2f97e4d434 fix(backend): corrige formatage ruff et typage mypy sur sites/current
CI en échec sur ruff format (ligne trop longue) et mypy (retour Any non
annoté, assignation Literal non étroite). Corrige sans changer le
comportement.
2026-09-16 15:27:05 +02:00
Johan LEROY 07ea8d21dc feat(backend): expose GET /api/v1/sites/{site_id}/current pour l'issue #29
Ajoute la dernière mesure d'un site (SiteService.current), en réutilisant
la vérification d'existence déjà en place pour GET /sites/{site_id} :
SiteService gagne une dépendance ReadingRepository, sur le modèle de
composition déjà utilisé par StatsService/SensorService. Un site connu
sans lecture rend 200 avec les champs de mesure à null et
data_quality="critical" ; seul un site_id absent rend 404.
2026-09-16 15:25:14 +02:00
103 changed files with 5252 additions and 413 deletions
+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"
+15 -23
View File
@@ -2,19 +2,6 @@ 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/**"
@@ -23,6 +10,7 @@ on:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
# Ordre de lancement des jobs
# build -> test -> sonarqube -> deploy
@@ -54,24 +42,28 @@ jobs:
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm test -- --watch=false
- run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
sonarqube:
needs: [build, test]
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@v6
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
fetch-depth: 0
- name: Download coverage
uses: actions/download-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0
uses: SonarSource/sonarqube-scan-action@v8
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
+8
View File
@@ -58,6 +58,14 @@ data/raw/*
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
+1 -1
View File
@@ -171,7 +171,7 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)]
def get_site_service(session: SessionDep) -> SiteService:
return SiteService(sites=SiteRepository(session))
return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session))
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
+17 -1
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status
from app.api.deps import LecteurDep, SiteServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.site import SiteResponse
from app.schemas.site import SiteCurrentResponse, SiteResponse
from app.services.site import SiteNotFoundError
router = APIRouter()
@@ -34,3 +34,19 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteResponse.model_validate(site)
@router.get(
"/{site_id}/current",
response_model=SiteCurrentResponse,
summary="Dernière mesure d'un site",
responses=REPONSES_INTROUVABLE,
)
async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse:
try:
actuel = await service.current(site_id)
except SiteNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteCurrentResponse.model_validate(actuel)
+39 -2
View File
@@ -1,9 +1,14 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.staticfiles import StaticFiles
from prometheus_fastapi_instrumentator import Instrumentator
from starlette.requests import Request
from starlette.responses import HTMLResponse
from app.api.errors import register_error_handlers
from app.api.middleware import SecurityHeadersMiddleware
@@ -18,6 +23,8 @@ logger = get_logger(__name__)
METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
EN_TETES_AUTORISES = ["Authorization", "Content-Type"]
STATIC_DIR = Path(__file__).parent / "static"
LOGO_URL = "/static/logo-icon.png"
@asynccontextmanager
@@ -43,11 +50,41 @@ def create_app(settings: Settings | None = None) -> FastAPI:
openapi_tags=TAGS,
debug=resolved.debug,
lifespan=lifespan,
docs_url="/docs" if documentee else None,
redoc_url="/redoc" if documentee else None,
docs_url=None,
redoc_url=None,
openapi_url="/openapi.json" if documentee else None,
)
if documentee:
application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# ReDoc supporte nativement `info.x-logo` (extension Redocly) pour afficher un logo
# en en-tête ; Swagger UI n'a pas d'equivalent, il ne reprend que le favicon.
openapi_original = application.openapi
def openapi_avec_logo() -> dict[str, object]:
schema = openapi_original()
schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"}
return schema
application.openapi = openapi_avec_logo # type: ignore[method-assign]
@application.get("/docs", include_in_schema=False)
async def docs_swagger(_: Request) -> HTMLResponse:
return get_swagger_ui_html(
openapi_url="/openapi.json",
title=f"{application.title} · Swagger UI",
swagger_favicon_url=LOGO_URL,
)
@application.get("/redoc", include_in_schema=False)
async def docs_redoc(_: Request) -> HTMLResponse:
return get_redoc_html(
openapi_url="/openapi.json",
title=f"{application.title} · ReDoc",
redoc_favicon_url=LOGO_URL,
)
application.add_middleware(SecurityHeadersMiddleware)
if resolved.allowed_origins:
+15 -2
View File
@@ -13,14 +13,27 @@ class ReadingRepository:
async def latest_by_site(self) -> Sequence[Reading]:
# `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule
# ligne par site, la plus récente grâce à l'ordre composite qui suit.
# ligne par site, la plus récente grâce à l'ordre composite qui suit. `reading_id` départage
# les égalités de timestamp, que `uq_reading_source` autorise à `source` différente.
requete = (
select(Reading)
.distinct(Reading.site_id)
.order_by(Reading.site_id, Reading.timestamp.desc())
.order_by(Reading.site_id, Reading.timestamp.desc(), Reading.reading_id.desc())
)
return (await self._session.execute(requete)).scalars().all()
async def latest_for_site(self, site_id: str) -> Reading | None:
# Piège : `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la
# `source` diffère. Sans `reading_id` en départage, le `LIMIT 1` renverrait au hasard.
requete = (
select(Reading)
.where(Reading.site_id == site_id)
.order_by(Reading.timestamp.desc(), Reading.reading_id.desc())
.limit(1)
)
lecture: Reading | None = await self._session.scalar(requete)
return lecture
async def list_history(
self,
*,
+20
View File
@@ -1,3 +1,6 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
@@ -10,3 +13,20 @@ class SiteResponse(BaseModel):
location: str | None
capacity_kw: float | None
status: str | None
class SiteCurrentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: Literal["good", "partial", "degraded", "critical"]
+18
View File
@@ -0,0 +1,18 @@
# Contrainte : `ck_reading_quality` accepte NULL et quatre valeurs seulement, alors que le contrat
# frontend n'a aucune valeur pour l'absence de qualité. `qualite_ou_critique()` replie donc sur
# `critical`, la seule des quatre qui n'induise pas une confiance qu'on n'a pas. `QUALITES_CONNUES`
# reste exposé pour les appelants qui doivent distinguer un `critical` stocké d'un repli.
from typing import Literal, get_args
DataQuality = Literal["good", "partial", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset(get_args(DataQuality))
_PAR_VALEUR: dict[str, DataQuality] = {valeur: valeur for valeur in get_args(DataQuality)}
def qualite_ou_critique(valeur: str | None) -> DataQuality:
if valeur is None:
return "critical"
return _PAR_VALEUR.get(valeur, "critical")
+2 -3
View File
@@ -5,12 +5,11 @@ from typing import Literal
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.data_quality import qualite_ou_critique
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",
@@ -80,7 +79,7 @@ def _sante_site(site: Site, derniere: Reading | None) -> SanteSite:
overall="critical",
)
qualite = derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical"
qualite = qualite_ou_critique(derniere.data_quality)
overall = _overall_depuis_qualite(qualite)
if overall == "critical":
+57 -1
View File
@@ -1,7 +1,11 @@
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from app.models.energy import Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.data_quality import DataQuality, qualite_ou_critique
class SiteError(Exception):
@@ -12,9 +16,26 @@ class SiteNotFoundError(SiteError):
pass
@dataclass(frozen=True, slots=True)
class SiteCurrentReading:
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: DataQuality
class SiteService:
def __init__(self, *, sites: SiteRepository) -> None:
def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def list_all(self) -> Sequence[Site]:
return await self._sites.list_all()
@@ -24,3 +45,38 @@ class SiteService:
if site is None:
raise SiteNotFoundError(site_id)
return site
async def current(self, site_id: str) -> SiteCurrentReading:
site = await self.get_by_id(site_id)
derniere = await self._readings.latest_for_site(site_id)
if derniere is None:
return SiteCurrentReading(
timestamp=None,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=None,
consumption_kwh=None,
voltage_v=None,
current_a=None,
power_factor=None,
temperature_celsius=None,
humidity_percent=None,
null_reasons=[],
data_quality="critical",
)
return SiteCurrentReading(
timestamp=derniere.timestamp,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=derniere.consumption_kw,
consumption_kwh=derniere.consumption_kwh,
voltage_v=derniere.voltage_v,
current_a=derniere.current_a,
power_factor=derniere.power_factor,
temperature_celsius=derniere.temperature_celsius,
humidity_percent=derniere.humidity_percent,
null_reasons=derniere.null_reasons or [],
data_quality=qualite_ou_critique(derniere.data_quality),
)
+2 -9
View File
@@ -1,14 +1,10 @@
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
DataQuality = Literal["good", "partial", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
from app.services.data_quality import QUALITES_CONNUES, DataQuality, qualite_ou_critique
@dataclass(frozen=True, slots=True)
@@ -58,13 +54,10 @@ class StatsService:
@staticmethod
def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption:
capacite = site.capacity_kw or 0
# Piège : `data_quality` est nul dès qu'un site n'a jamais reçu de lecture, ou que le
# producteur n'a pas su la qualifier. Le contrat frontend n'a pas de valeur pour ce cas,
# `critical` est la seule des quatre qui n'induit pas une confiance qu'on n'a pas.
qualite: DataQuality = "critical"
consommation = None
if derniere is not None and derniere.data_quality in QUALITES_CONNUES:
qualite = derniere.data_quality # type: ignore[assignment]
qualite = qualite_ou_critique(derniere.data_quality)
consommation = derniere.consumption_kw
charge = (
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+226 -1
View File
@@ -4,7 +4,11 @@
"title": "EnerVision API",
"summary": "Collecte, analyse et restitution de séries temporelles énergétiques.",
"description": "\nToutes les routes sont préfixées par `/api/v1`.\n\n**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`.\nLe jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il\nsuffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un\nnouveau jeton d'accès et fait tourner le cookie.\n\n**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du\nprécédent.\n\n**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut\n`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe\nprovisoire avant toute autre action.\n\nLe parcours de session complet est décrit dans\n`docs/architecture/31-contrat-authentification.md`.\n",
"version": "0.1.0"
"version": "0.1.0",
"x-logo": {
"url": "/static/logo-icon.png",
"altText": "EnerVision"
}
},
"paths": {
"/api/v1/health/live": {
@@ -1111,6 +1115,93 @@
}
}
},
"/api/v1/sites/{site_id}/current": {
"get": {
"tags": [
"sites"
],
"summary": "Dernière mesure d'un site",
"operationId": "get_current_api_v1_sites__site_id__current_get",
"security": [
{
"Jeton d'accès": []
}
],
"parameters": [
{
"name": "site_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Site Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SiteCurrentResponse"
}
}
}
},
"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"
}
}
}
},
"404": {
"description": "Aucun site ne porte cet identifiant.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/alerts": {
"get": {
"tags": [
@@ -2293,6 +2384,140 @@
],
"title": "SensorStatusResponse"
},
"SiteCurrentResponse": {
"properties": {
"timestamp": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Timestamp"
},
"site_id": {
"type": "string",
"title": "Site Id"
},
"site_type": {
"type": "string",
"title": "Site Type"
},
"consumption_kw": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Consumption Kw"
},
"consumption_kwh": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Consumption Kwh"
},
"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"
},
"null_reasons": {
"items": {
"type": "string"
},
"type": "array",
"title": "Null Reasons"
},
"data_quality": {
"type": "string",
"enum": [
"good",
"partial",
"degraded",
"critical"
],
"title": "Data Quality"
}
},
"type": "object",
"required": [
"timestamp",
"site_id",
"site_type",
"consumption_kw",
"consumption_kwh",
"voltage_v",
"current_a",
"power_factor",
"temperature_celsius",
"humidity_percent",
"null_reasons",
"data_quality"
],
"title": "SiteCurrentResponse"
},
"SiteResponse": {
"properties": {
"site_id": {
+1
View File
@@ -31,6 +31,7 @@ ROUTES_A_ROLE = {
("POST", "/api/v1/users/{id}/password-reset"),
("GET", "/api/v1/sites"),
("GET", "/api/v1/sites/{site_id}"),
("GET", "/api/v1/sites/{site_id}/current"),
("GET", "/api/v1/alerts"),
("GET", "/api/v1/recommendations"),
("GET", "/api/v1/recommendations/{recommendation_id}"),
@@ -81,3 +81,18 @@ async def test_the_declared_routes_are_actually_reachable(app: FastAPI) -> None:
)
def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None:
assert ("GET", chemin) in ROUTES_PUBLIQUES
# Piège : ni les routes `include_in_schema=False` (/docs, /redoc) ni un `Mount` Starlette
# (/static) n'apparaissent dans `app.openapi()["paths"]`. `routes_declarees()` ne les voit
# donc jamais, et elles échapperaient silencieusement au garde-fou ci-dessus.
@pytest.mark.parametrize(
"chemin",
["/docs", "/redoc", "/static/logo-icon.png"],
ids=["swagger_ui", "redoc", "logo_statique"],
)
async def test_the_documentation_routes_are_public_by_design(
app: FastAPI, client: AsyncClient, chemin: str
) -> None:
response = await client.get(chemin)
assert response.status_code == 200
+51 -1
View File
@@ -1,4 +1,5 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
@@ -9,7 +10,9 @@ from app.api.deps import get_current_principal, get_site_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Site
from app.services.site import SiteNotFoundError
from app.services.site import SiteCurrentReading, SiteNotFoundError
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def principal(role: Role = Role.LECTEUR) -> Principal:
@@ -33,10 +36,28 @@ def site(site_id: str = "site-1") -> Site:
)
def lecture_actuelle(site_id: str = "site-1") -> SiteCurrentReading:
return SiteCurrentReading(
timestamp=TIMESTAMP,
site_id=site_id,
site_type="industriel",
consumption_kw=87.34,
consumption_kwh=87.34,
voltage_v=401.2,
current_a=132.5,
power_factor=0.923,
temperature_celsius=22.1,
humidity_percent=58.4,
null_reasons=[],
data_quality="good",
)
class FauxService:
def __init__(self, erreur: Exception | None = None) -> None:
self._erreur = erreur
self.site = site()
self.actuel = lecture_actuelle()
async def list_all(self) -> list[Site]:
return [self.site]
@@ -46,6 +67,11 @@ class FauxService:
raise self._erreur
return self.site
async def current(self, site_id: str) -> SiteCurrentReading:
if self._erreur is not None:
raise self._erreur
return self.actuel
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
@@ -109,6 +135,30 @@ async def test_get_site_returns_404_for_an_unknown_site(
assert response.status_code == 404
async def test_get_current_returns_the_latest_reading(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/sites/site-1/current")
assert response.status_code == 200
corps = response.json()
assert corps["site_id"] == "site-1"
assert corps["data_quality"] == "good"
assert corps["consumption_kw"] == 87.34
async def test_get_current_returns_404_for_an_unknown_site(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(SiteNotFoundError("site-inconnu"))
response = await client.get("/api/v1/sites/site-inconnu/current")
assert response.status_code == 404
async def test_list_sites_reaches_the_repository_through_the_session(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
@@ -88,6 +88,73 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) ->
assert identifiants == {premier, second}
async def test_latest_by_site_breaks_a_timestamp_tie_on_the_last_written_reading(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
horodatage = datetime(2026, 9, 15, tzinfo=UTC)
await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10
)
derniere = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42
)
resultats = await depot.latest_by_site()
retenues = [r.reading_id for r in resultats if r.site_id == site.site_id]
await session.rollback()
assert retenues == [derniere.reading_id]
async def test_latest_for_site_returns_the_most_recent_reading(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
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)
)
trouvee = await depot.latest_for_site(site.site_id)
reading_id = trouvee.reading_id if trouvee else None
await session.rollback()
assert reading_id == recente.reading_id
async def test_latest_for_site_breaks_a_timestamp_tie_on_the_last_written_reading(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
horodatage = datetime(2026, 9, 15, tzinfo=UTC)
await creer_lecture(session, site_id=site.site_id, timestamp=horodatage, source="api_history")
derniere = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_current"
)
trouvee = await depot.latest_for_site(site.site_id)
reading_id = trouvee.reading_id if trouvee else None
await session.rollback()
assert reading_id == derniere.reading_id
async def test_latest_for_site_ignores_the_readings_of_the_other_sites(
session: AsyncSession,
) -> None:
sans_lecture = await creer_site(session)
autre = await creer_site(session)
depot = ReadingRepository(session)
await creer_lecture(session, site_id=autre.site_id)
trouvee = await depot.latest_for_site(sans_lecture.site_id)
await session.rollback()
assert trouvee is None
async def test_list_history_orders_the_readings_by_timestamp_descending(
session: AsyncSession,
) -> None:
+80 -7
View File
@@ -1,8 +1,13 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
import pytest
from app.models.energy import Site
from app.services.site import SiteNotFoundError, SiteService
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def site(site_id: str = "site-1") -> Site:
return Site(
@@ -15,6 +20,21 @@ def site(site_id: str = "site-1") -> Site:
)
@dataclass
class FauxLecture:
site_id: str
timestamp: datetime = TIMESTAMP
consumption_kw: float | None = 87.34
consumption_kwh: float | None = 87.34
voltage_v: float | None = 401.2
current_a: float | None = 132.5
power_factor: float | None = 0.923
temperature_celsius: float | None = 22.1
humidity_percent: float | None = 58.4
null_reasons: list[str] | None = field(default_factory=list)
data_quality: str | None = "good"
class FakeRepository:
def __init__(self, sites: list[Site]) -> None:
self._sites = sites
@@ -26,24 +46,77 @@ class FakeRepository:
return next((s for s in self._sites if s.site_id == site_id), None)
async def test_list_all_returns_the_repository_sites() -> None:
service = SiteService(sites=FakeRepository([site("a"), site("b")]))
class FauxDepotLectures:
def __init__(self, lectures: dict[str, FauxLecture]) -> None:
self._lectures = lectures
sites = await service.list_all()
async def latest_for_site(self, site_id: str) -> FauxLecture | None:
return self._lectures.get(site_id)
def service(sites: list[Site], lectures: dict[str, FauxLecture] | None = None) -> SiteService:
return SiteService(
sites=FakeRepository(sites), # type: ignore[arg-type]
readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type]
)
async def test_list_all_returns_the_repository_sites() -> None:
svc = service([site("a"), site("b")])
sites = await svc.list_all()
assert [s.site_id for s in sites] == ["a", "b"]
async def test_get_by_id_returns_the_matching_site() -> None:
service = SiteService(sites=FakeRepository([site("a")]))
svc = service([site("a")])
trouve = await service.get_by_id("a")
trouve = await svc.get_by_id("a")
assert trouve.site_id == "a"
async def test_get_by_id_raises_when_the_site_is_unknown() -> None:
service = SiteService(sites=FakeRepository([]))
svc = service([])
with pytest.raises(SiteNotFoundError):
await service.get_by_id("inconnu")
await svc.get_by_id("inconnu")
async def test_current_raises_when_the_site_is_unknown() -> None:
svc = service([])
with pytest.raises(SiteNotFoundError):
await svc.current("inconnu")
async def test_current_returns_every_field_as_null_when_the_site_has_no_reading() -> None:
svc = service([site("a")])
actuel = await svc.current("a")
assert actuel.timestamp is None
assert actuel.consumption_kw is None
assert actuel.data_quality == "critical"
assert actuel.null_reasons == []
async def test_current_copies_every_field_from_the_latest_reading() -> None:
svc = service([site("a")], {"a": FauxLecture(site_id="a")})
actuel = await svc.current("a")
assert actuel.timestamp == TIMESTAMP
assert actuel.site_type == "industriel"
assert actuel.consumption_kw == 87.34
assert actuel.voltage_v == 401.2
assert actuel.data_quality == "good"
async def test_current_treats_an_unknown_data_quality_as_critical() -> None:
svc = service([site("a")], {"a": FauxLecture(site_id="a", data_quality=None)})
actuel = await svc.current("a")
assert actuel.data_quality == "critical"
+13
View File
@@ -0,0 +1,13 @@
# Piège : le logo est committé indépendamment à deux endroits (`app/static/`, servi par
# `/docs`/`/redoc`, et `apps/frontend/public/`, servi au front) faute d'étape de build partagée.
# Sans ce test, une mise à jour d'un seul des deux fichiers dérive silencieusement : rien en CI
# ne le détecte.
from pathlib import Path
BACKEND_LOGO = Path(__file__).parent.parent / "app" / "static" / "logo-icon.png"
FRONTEND_LOGO = Path(__file__).parent.parent.parent / "frontend" / "public" / "logo-icon.png"
def test_the_backend_logo_stays_in_sync_with_the_frontend_one() -> None:
assert BACKEND_LOGO.read_bytes() == FRONTEND_LOGO.read_bytes()
+7
View File
@@ -76,6 +76,13 @@ Points à vérifier après toute regénération :
côté backend. Le `docker-compose.yml` n'a aucun service frontend.
4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx).
## Design système
Tokens (couleurs, typo, espacements) et composants partagés (`ev-button`, `ev-card`,
`ev-alert`, `ev-badge`) sont documentés dans
[`docs/architecture/32-design-systeme-frontend.md`](../../docs/architecture/32-design-systeme-frontend.md).
Toute nouvelle page doit les réutiliser plutôt que définir ses propres valeurs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
-9
View File
@@ -86,15 +86,6 @@
"text-summary",
"lcov",
"html"
],
"reporters": [
"default",
[
"junit",
{
"outputFile": "test-results/junit.xml"
}
]
]
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+13
View File
@@ -12,4 +12,17 @@ export const routes: Routes = [
canActivate: [authGuard],
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
},
{
path: 'sites',
canActivate: [authGuard],
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList),
},
{
path: 'sites/:siteId',
canActivate: [authGuard],
loadComponent: () =>
import('./features/sites/site-detail-placeholder/site-detail-placeholder').then(
(m) => m.SiteDetailPlaceholder,
),
},
];
@@ -0,0 +1,41 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { SitesService } from './sites.service';
import { environment } from '../../../environments/environment';
describe('SitesService', () => {
let service: SitesService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(SitesService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('appelle le bon endpoint et retourne la liste des sites', () => {
let result: unknown;
service.getSites().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/sites`);
expect(req.request.method).toBe('GET');
req.flush([
{
site_id: 'SITE001',
site_name: 'Site 1',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
},
]);
expect((result as { site_id: string }[])[0].site_id).toBe('SITE001');
});
});
@@ -0,0 +1,13 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Site } from '../../shared/models/site.model';
@Service()
export class SitesService {
private http = inject(HttpClient);
getSites() {
return this.http.get<Site[]>(`${environment.apiUrl}/sites`);
}
}
@@ -1,31 +1,38 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<form class="auth-card-wrapper" [formGroup]="form" (ngSubmit)="onSubmit()">
<ev-card>
<ev-brand class="auth-brand" />
<h1>Nouveau mot de passe</h1>
<p class="auth-subtitle">Votre mot de passe est provisoire, vous devez le modifier avant de continuer</p>
<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>
<label class="form-label" for="current_password">Mot de passe actuel</label>
<input
id="current_password"
class="form-input"
type="password"
formControlName="current_password"
autocomplete="current-password"
/>
<label for="new_password">Nouveau mot de passe</label>
<label class="form-label" for="new_password">Nouveau mot de passe</label>
<input
id="new_password"
class="form-input"
type="password"
formControlName="new_password"
autocomplete="new-password"
/>
<span class="auth-hint">{{ passwordHint }}</span>
<span class="form-hint">{{ passwordHint }}</span>
@if (errorMessage()) {
<p class="auth-error">{{ errorMessage() }}</p>
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
<ev-button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Modification...' : 'Valider' }}
</button>
</ev-button>
</ev-card>
</form>
</div>
@@ -1,88 +0,0 @@
: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;
}
@@ -63,7 +63,7 @@ describe('ChangePassword', () => {
fixture.detectChanges(); // rend le bloc @if (errorMessage())
expect(component.errorMessage()).toContain('incorrect');
const errorEl = fixture.nativeElement.querySelector('.auth-error');
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('incorrect');
});
@@ -73,7 +73,7 @@ describe('ChangePassword', () => {
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull();
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
});
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
@@ -2,12 +2,16 @@ 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';
import { Button } from '../../../shared/components/ui/button/button';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Brand } from '../../../shared/components/ui/brand/brand';
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
@Component({
selector: 'app-change-password',
standalone: true,
imports: [ReactiveFormsModule],
imports: [ReactiveFormsModule, Button, Card, Alert, Brand],
templateUrl: './change-password.html',
styleUrl: './change-password.scss',
})
@@ -36,7 +40,9 @@ export class ChangePassword {
},
error: () => {
this.isLoading.set(false);
this.errorMessage.set(`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`);
this.errorMessage.set(
`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`,
);
},
});
}
@@ -1,38 +1,43 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<form class="auth-card-wrapper" [formGroup]="form" (ngSubmit)="onSubmit()">
<ev-card>
<ev-brand class="auth-brand" />
<h1>Connexion</h1>
<p class="auth-subtitle">Accédez à votre espace EnerVision</p>
<label for="email">Email</label>
<label class="form-label" for="email">Email</label>
<input
id="email"
class="form-input"
type="email"
formControlName="email"
autocomplete="username"
placeholder="vous@enervision.fr"
/>
<label for="password">Mot de passe</label>
<label class="form-label" for="password">Mot de passe</label>
<input
id="password"
class="form-input"
type="password"
formControlName="password"
autocomplete="current-password"
/>
@if (errorMessage()) {
<p class="auth-error">
<ev-alert severity="danger">
{{ errorMessage() }}
@if (retryAfterSeconds(); as seconds) {
(réessayez dans {{ seconds }}s)
}
</p>
</ev-alert>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
<ev-button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Connexion...' : 'Se connecter' }}
</button>
</ev-button>
<p class="auth-link"><a routerLink="/forgot-password">Mot de passe oublié ?</a></p>
</ev-card>
</form>
</div>
@@ -1,85 +1,3 @@
: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;
}
.auth-link {
margin-top: 1rem;
font-size: 0.85rem;
@@ -79,7 +79,7 @@ describe('Login', () => {
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');
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.');
});
@@ -96,7 +96,7 @@ describe('Login', () => {
fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds)
expect(component.retryAfterSeconds()).toBe(30);
const errorEl = fixture.nativeElement.querySelector('.auth-error');
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
expect(errorEl?.textContent).toContain('30s');
});
@@ -114,7 +114,7 @@ describe('Login', () => {
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull();
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
});
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
@@ -3,12 +3,19 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
import { MESSAGE_LIEN_RESET_INVALIDE, MOTIF_LIEN_RESET_INVALIDE } from '../../../shared/models/auth-redirect-reason';
import { Button } from '../../../shared/components/ui/button/button';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Brand } from '../../../shared/components/ui/brand/brand';
import {
MESSAGE_LIEN_RESET_INVALIDE,
MOTIF_LIEN_RESET_INVALIDE,
} from '../../../shared/models/auth-redirect-reason';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule, RouterLink],
imports: [ReactiveFormsModule, RouterLink, Button, Card, Alert, Brand],
templateUrl: './login.html',
styleUrl: './login.scss',
})
@@ -1,19 +1,33 @@
<div class="dashboard">
<header class="dashboard__header">
<div class="dashboard__brand">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="dashboard__logo" />
</a>
<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>
</div>
<div class="dashboard__actions">
<a routerLink="/sites" class="ev-link">Voir les sites</a>
<ev-button
class="logout-button"
variant="secondary"
[fullWidth]="false"
(click)="onLogout()"
>Déconnexion</ev-button
>
</div>
</header>
@if (error(); as message) {
<p class="banner-error" role="alert">{{ message }}</p>
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (stats(); as s) {
<section class="overview">
<div class="card card--gauge">
<ev-card class="card card--gauge">
<span class="card__label">Consommation vs capacité</span>
<app-consumption-gauge
[consumption]="s.total_consumption_kw"
@@ -23,20 +37,20 @@
>{{ s.total_consumption_kw | number: '1.0-1' }} /
{{ s.total_capacity_kw | number }} kW</span
>
</div>
</ev-card>
<div class="card">
<ev-card class="card">
<span class="card__label">Charge moyenne du parc</span>
<span class="card__value">{{ s.average_load_percent }} %</span>
<div class="progress-bar">
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
</div>
</div>
</ev-card>
<div class="card">
<ev-card class="card">
<span class="card__label">Sites suivis</span>
<span class="card__value">{{ s.total_sites }}</span>
</div>
</ev-card>
</section>
<section class="chart-section">
@@ -50,8 +64,8 @@
<h2>Alertes actives</h2>
<ul class="alerts-list">
@for (alert of alerts(); track alert.alert_id) {
<li class="alert-item" [class]="'alert-item--' + alert.severity">
<span class="alert-item__badge">{{ alert.severity }}</span>
<li class="alert-item">
<ev-badge [tone]="badgeToneForSeverity(alert.severity)">{{ alert.severity }}</ev-badge>
<span class="alert-item__message">{{ alert.message }}</span>
</li>
}
@@ -1,23 +1,22 @@
:host {
--color-good: #2e7d32;
--color-partial: #f9a825;
--color-degraded: #ef6c00;
--color-critical: #c62828;
--color-bg-card: #ffffff;
--color-border: #e5e7eb;
--color-text-muted: #6b7280;
--radius: 10px;
display: block;
font-family: 'Segoe UI', system-ui, sans-serif;
color: #1f2937;
padding: 2rem;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.dashboard__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem;
}
.dashboard__brand {
display: flex;
align-items: center;
gap: 0.85rem;
h1 {
margin: 0;
@@ -26,11 +25,21 @@
}
}
.dashboard__logo {
font-size: 1.3rem;
}
.dashboard__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.dashboard__actions {
display: flex;
align-items: center;
gap: 1rem;
}
h2 {
font-size: 1.1rem;
font-weight: 600;
@@ -38,13 +47,8 @@ h2 {
}
.banner-error {
display: block;
margin: 0 0 1.5rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-critical);
border-left-width: 4px;
border-radius: var(--radius);
background: #fdecea;
color: var(--color-critical);
}
.overview {
@@ -55,14 +59,8 @@ h2 {
}
.card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.card--gauge {
@@ -84,16 +82,16 @@ h2 {
.progress-bar {
height: 6px;
background: #e5e7eb;
border-radius: 999px;
background: var(--color-border-light);
border-radius: var(--radius-pill);
overflow: hidden;
margin-top: 0.25rem;
}
.progress-bar__fill {
height: 100%;
background: #3b82f6;
border-radius: 999px;
background: var(--color-primary);
border-radius: var(--radius-pill);
transition: width 0.3s ease;
}
@@ -115,59 +113,11 @@ h2 {
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-radius: var(--radius);
background: #fef2f2;
border: 1px solid #fecaca;
}
.alert-item__badge {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
padding: 0.2rem 0.55rem;
border-radius: 999px;
color: #fff;
background: var(--color-critical);
flex-shrink: 0;
}
.alert-item--high .alert-item__badge {
background: var(--color-degraded);
}
.alert-item--medium .alert-item__badge {
background: var(--color-partial);
}
.alert-item--low .alert-item__badge {
background: var(--color-good);
border-radius: var(--radius-md);
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border);
}
.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;
}
}
@@ -5,7 +5,7 @@ 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';
import {Router, provideRouter} from '@angular/router';
vi.mock('chart.js', () => {
class ChartMock {
@@ -29,6 +29,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
provideRouter([]),
],
});
@@ -60,6 +61,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
provideRouter([]),
],
});
@@ -86,6 +88,7 @@ describe('Dashboard', () => {
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
provideRouter([]),
],
});
@@ -99,7 +102,6 @@ describe('Dashboard', () => {
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],
@@ -107,18 +109,21 @@ describe('Dashboard', () => {
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.logout).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
expect(navigateSpy).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: [] })) };
@@ -127,25 +132,51 @@ describe('Dashboard', () => {
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 },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
});
it('distingue le ton des sévérités high et critical', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForSeverity('low')).toBe('success');
expect(dashboard.badgeToneForSeverity('medium')).toBe('warning');
expect(dashboard.badgeToneForSeverity('high')).toBe('danger');
expect(dashboard.badgeToneForSeverity('critical')).toBe('critical');
expect(dashboard.badgeToneForSeverity('high')).not.toBe(
dashboard.badgeToneForSeverity('critical'),
);
});
});
@@ -2,23 +2,45 @@ 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 { Router, RouterLink } from '@angular/router';
import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
import { AlertsService } from '../../core/services/alerts.service';
import { AuthService } from '../../core/services/auth.service';
import { StatsSummary } from '../../shared/models/stats.model';
import { Alert } from '../../shared/models/alert.model';
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
import { Card } from '../../shared/components/ui/card/card';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge';
import { Brand } from '../../shared/components/ui/brand/brand';
import { Button } from '../../shared/components/ui/button/button';
const REFRESH_INTERVAL_MS = 10000;
const UNAVAILABLE_MESSAGE =
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
low: 'success',
medium: 'warning',
high: 'danger',
critical: 'critical',
};
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart],
imports: [
DecimalPipe,
RouterLink,
ConsumptionGauge,
SiteLoadChart,
Card,
EvAlert,
Badge,
Brand,
Button,
],
templateUrl: './dashboard.html',
styleUrl: './dashboard.scss',
})
@@ -54,6 +76,10 @@ export class Dashboard implements OnInit {
});
}
badgeToneForSeverity(severity: AlertSeverity): BadgeTone {
return TON_PAR_SEVERITE[severity];
}
onLogout(): void {
this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']),
@@ -0,0 +1,19 @@
<div class="site-detail-placeholder">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
<span>/</span>
<a routerLink="/sites">Sites</a>
</nav>
<header class="site-detail-placeholder__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="site-detail-placeholder__logo" />
</a>
<h1>Site {{ siteId() }}</h1>
</header>
<ev-card>
<p>Le détail de ce site est à venir (voir issue #51).</p>
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
</ev-card>
</div>
@@ -0,0 +1,28 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 640px;
margin: 0 auto;
}
.site-detail-placeholder__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 1.5rem;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
}
.site-detail-placeholder__logo {
font-size: 1.3rem;
}
ev-card p {
margin: 0 0 0.75rem;
}
@@ -0,0 +1,42 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { SiteDetailPlaceholder } from './site-detail-placeholder';
describe('SiteDetailPlaceholder', () => {
it("affiche l'identifiant du site depuis la route", () => {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
TestBed.configureTestingModule({
imports: [SiteDetailPlaceholder],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
],
});
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('SITE001');
});
it('met à jour l\'affichage quand le paramètre change sans recréer le composant', () => {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
TestBed.configureTestingModule({
imports: [SiteDetailPlaceholder],
providers: [
provideRouter([]),
{ provide: ActivatedRoute, useValue: { paramMap } },
],
});
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
fixture.detectChanges();
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('SITE002');
expect(fixture.nativeElement.textContent).not.toContain('SITE001');
});
});
@@ -0,0 +1,19 @@
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { map } from 'rxjs';
import { Card } from '../../../shared/components/ui/card/card';
import { Brand } from '../../../shared/components/ui/brand/brand';
@Component({
selector: 'app-site-detail-placeholder',
standalone: true,
imports: [RouterLink, Card, Brand],
templateUrl: './site-detail-placeholder.html',
styleUrl: './site-detail-placeholder.scss',
})
export class SiteDetailPlaceholder {
private route = inject(ActivatedRoute);
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId'))));
}
@@ -0,0 +1,46 @@
<div class="site-list">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
</nav>
<header class="site-list__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="site-list__logo" />
</a>
<div>
<h1>Sites</h1>
<p class="site-list__subtitle">Vue d'ensemble du parc suivi</p>
</div>
</header>
@if (error(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
<ev-card class="table-card">
<table class="sites-table">
<thead>
<tr>
<th>Nom</th>
<th>Type</th>
<th>Localisation</th>
<th>Capacité (kW)</th>
<th>Statut</th>
<th></th>
</tr>
</thead>
<tbody>
@for (site of sites(); track site.site_id) {
<tr>
<td>{{ site.site_name }}</td>
<td>{{ site.site_type }}</td>
<td>{{ site.location || '-' }}</td>
<td>{{ site.capacity_kw ?? '-' }}</td>
<td><ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge></td>
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
</tr>
}
</tbody>
</table>
</ev-card>
</div>
@@ -0,0 +1,63 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.site-list__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.site-list__logo {
font-size: 1.3rem;
}
.site-list__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.banner-error {
display: block;
margin: 0 0 1.5rem;
}
.table-card {
padding: 0;
overflow: hidden;
}
.sites-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 0.85rem 1.25rem;
text-align: left;
border-bottom: 1px solid var(--color-border-light);
}
th {
font-size: 0.8rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
font-weight: 600;
}
tr:last-child td {
border-bottom: none;
}
}
@@ -0,0 +1,81 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { of, throwError } from 'rxjs';
import { SiteList } from './site-list';
import { SitesService } from '../../../core/services/sites.service';
describe('SiteList', () => {
it('charge et affiche les sites au démarrage', () => {
const sitesMock = {
getSites: vi.fn().mockReturnValue(
of([
{
site_id: 'SITE001',
site_name: 'Site 1',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
},
]),
),
};
TestBed.configureTestingModule({
imports: [SiteList],
providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])],
});
const fixture = TestBed.createComponent(SiteList);
fixture.detectChanges();
expect(sitesMock.getSites).toHaveBeenCalled();
expect(fixture.componentInstance.sites().length).toBe(1);
expect(fixture.componentInstance.error()).toBeNull();
});
it("signale l'indisponibilité quand le chargement échoue", () => {
const sitesMock = { getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
TestBed.configureTestingModule({
imports: [SiteList],
providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])],
});
const fixture = TestBed.createComponent(SiteList);
fixture.detectChanges();
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.sites().length).toBe(0);
});
it('affiche un tiret pour les champs nullables', () => {
const sitesMock = {
getSites: vi.fn().mockReturnValue(
of([
{
site_id: 'SITE002',
site_name: 'Site 2',
site_type: 'bureau',
location: null,
capacity_kw: null,
status: null,
},
]),
),
};
TestBed.configureTestingModule({
imports: [SiteList],
providers: [{ provide: SitesService, useValue: sitesMock }, provideRouter([])],
});
const fixture = TestBed.createComponent(SiteList);
fixture.detectChanges();
const cells = fixture.nativeElement.querySelectorAll('td');
expect(cells[2].textContent.trim()).toBe('-');
expect(cells[3].textContent.trim()).toBe('-');
});
});
@@ -0,0 +1,47 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { catchError, EMPTY, Observable } from 'rxjs';
import { SitesService } from '../../../core/services/sites.service';
import { Site } from '../../../shared/models/site.model';
import { Card } from '../../../shared/components/ui/card/card';
import { Alert } from '../../../shared/components/ui/alert/alert';
import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
import { Brand } from '../../../shared/components/ui/brand/brand';
const UNAVAILABLE_MESSAGE = 'Liste des sites indisponible, réessayez plus tard.';
const TON_PAR_STATUT: Record<string, BadgeTone> = {
actif: 'success',
maintenance: 'warning',
hors_service: 'danger',
};
@Component({
selector: 'app-site-list',
standalone: true,
imports: [RouterLink, Card, Alert, Badge, Brand],
templateUrl: './site-list.html',
styleUrl: './site-list.scss',
})
export class SiteList implements OnInit {
private sitesService = inject(SitesService);
sites = signal<Site[]>([]);
error = signal<string | null>(null);
ngOnInit(): void {
this.sitesService
.getSites()
.pipe(catchError(() => this.reportUnavailable()))
.subscribe((sites) => this.sites.set(sites));
}
badgeToneForStatus(status: string | null): BadgeTone {
return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral';
}
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
}
}
@@ -0,0 +1 @@
<ng-content></ng-content>
@@ -0,0 +1,27 @@
:host {
display: block;
margin: 0;
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: 0.85rem;
line-height: 1.4;
}
:host.ev-alert--success {
background: var(--color-success-bg);
border-color: var(--color-success);
color: var(--color-success);
}
:host.ev-alert--warning {
background: var(--color-warning-bg);
border-color: var(--color-warning);
color: var(--color-warning-text);
}
:host.ev-alert--danger {
background: var(--color-danger-bg);
border-color: var(--color-danger-border);
color: var(--color-danger);
}
@@ -0,0 +1,30 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Alert } from './alert';
@Component({
standalone: true,
imports: [Alert],
template: `<ev-alert severity="success">C'est fait</ev-alert>`,
})
class AlertHost {}
describe('Alert', () => {
it('applique la classe danger par défaut', async () => {
await TestBed.configureTestingModule({ imports: [Alert] }).compileComponents();
const fixture = TestBed.createComponent(Alert);
fixture.detectChanges();
expect(fixture.nativeElement.classList).toContain('ev-alert--danger');
});
it('applique la sévérité demandée et projette le contenu', async () => {
await TestBed.configureTestingModule({ imports: [AlertHost] }).compileComponents();
const fixture = TestBed.createComponent(AlertHost);
fixture.detectChanges();
const el = fixture.nativeElement.querySelector('.ev-alert');
expect(el.classList).toContain('ev-alert--success');
expect(el.textContent).toContain("C'est fait");
});
});
@@ -0,0 +1,21 @@
import { Component, HostBinding, input } from '@angular/core';
export type AlertSeverity = 'success' | 'warning' | 'danger';
@Component({
selector: 'ev-alert',
standalone: true,
templateUrl: './alert.html',
styleUrl: './alert.scss',
})
export class Alert {
severity = input<AlertSeverity>('danger');
@HostBinding('class')
get hostClass(): string {
return `ev-alert ev-alert--${this.severity()}`;
}
@HostBinding('attr.role')
readonly role = 'alert';
}
@@ -0,0 +1,3 @@
<span class="ev-badge" [class]="'ev-badge--' + tone()">
<ng-content></ng-content>
</span>
@@ -0,0 +1,35 @@
:host {
display: inline-flex;
flex-shrink: 0;
}
.ev-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
padding: 0.2rem 0.55rem;
border-radius: var(--radius-pill);
color: var(--color-text-inverse);
}
.ev-badge--success {
background: var(--color-success);
}
.ev-badge--warning {
background: var(--color-warning);
}
.ev-badge--danger {
background: var(--color-danger);
}
.ev-badge--critical {
background: var(--color-critical);
}
.ev-badge--neutral {
background: var(--color-text-muted);
}
@@ -0,0 +1,30 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Badge } from './badge';
@Component({
standalone: true,
imports: [Badge],
template: `<ev-badge tone="danger">critique</ev-badge>`,
})
class BadgeHost {}
describe('Badge', () => {
it('applique le ton neutral par défaut', async () => {
await TestBed.configureTestingModule({ imports: [Badge] }).compileComponents();
const fixture = TestBed.createComponent(Badge);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.ev-badge').classList).toContain('ev-badge--neutral');
});
it('applique le ton demandé et projette le contenu', async () => {
await TestBed.configureTestingModule({ imports: [BadgeHost] }).compileComponents();
const fixture = TestBed.createComponent(BadgeHost);
fixture.detectChanges();
const el = fixture.nativeElement.querySelector('.ev-badge');
expect(el.classList).toContain('ev-badge--danger');
expect(el.textContent).toContain('critique');
});
});
@@ -0,0 +1,13 @@
import { Component, input } from '@angular/core';
export type BadgeTone = 'success' | 'warning' | 'danger' | 'critical' | 'neutral';
@Component({
selector: 'ev-badge',
standalone: true,
templateUrl: './badge.html',
styleUrl: './badge.scss',
})
export class Badge {
tone = input<BadgeTone>('neutral');
}
@@ -0,0 +1,2 @@
<img src="/logo-icon.png" alt="" class="ev-brand__icon" />
<span class="ev-brand__name">EnerVision</span>
@@ -0,0 +1,19 @@
:host {
display: inline-flex;
align-items: center;
gap: 0.45em;
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text);
line-height: 1;
}
.ev-brand__icon {
height: 1.3em;
width: auto;
flex-shrink: 0;
}
.ev-brand__name {
white-space: nowrap;
}
@@ -0,0 +1,14 @@
import { TestBed } from '@angular/core/testing';
import { Brand } from './brand';
describe('Brand', () => {
it("affiche l'icône et le nom EnerVision", async () => {
await TestBed.configureTestingModule({ imports: [Brand] }).compileComponents();
const fixture = TestBed.createComponent(Brand);
fixture.detectChanges();
const icon = fixture.nativeElement.querySelector('img.ev-brand__icon');
expect(icon).toBeTruthy();
expect(fixture.nativeElement.textContent).toContain('EnerVision');
});
});
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'ev-brand',
standalone: true,
templateUrl: './brand.html',
styleUrl: './brand.scss',
})
export class Brand {}
@@ -0,0 +1,9 @@
<button
[type]="type()"
class="ev-button"
[class]="'ev-button--' + variant()"
[class.ev-button--inline]="!fullWidth()"
[disabled]="disabled()"
>
<ng-content></ng-content>
</button>
@@ -0,0 +1,55 @@
.ev-button {
width: 100%;
padding: 0.7rem;
border: none;
border-radius: var(--radius-sm);
font-size: 0.95rem;
font-weight: 600;
font-family: var(--font-family);
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
&.ev-button--inline {
width: auto;
}
}
.ev-button--primary {
background: var(--color-primary);
color: var(--color-text-inverse);
&:disabled {
background: var(--color-disabled);
}
&:not(:disabled):hover {
background: var(--color-primary-hover);
}
}
.ev-button--secondary {
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-label);
&:not(:disabled):hover {
background: var(--color-bg);
}
}
.ev-button--danger {
background: var(--color-danger);
color: var(--color-text-inverse);
&:disabled {
background: var(--color-disabled);
}
&:not(:disabled):hover {
background: var(--color-danger-hover);
}
}
@@ -0,0 +1,50 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Button } from './button';
@Component({
standalone: true,
imports: [Button],
template: `<ev-button>Valider</ev-button>`,
})
class ButtonHost {}
describe('Button', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [Button] }).compileComponents();
});
it('applique la classe de la variante primary par défaut', () => {
const fixture = TestBed.createComponent(Button);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.classList).toContain('ev-button--primary');
});
it('applique la classe de la variante demandée', () => {
const fixture = TestBed.createComponent(Button);
fixture.componentRef.setInput('variant', 'danger');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.classList).toContain('ev-button--danger');
});
it('désactive le bouton natif quand disabled est vrai', () => {
const fixture = TestBed.createComponent(Button);
fixture.componentRef.setInput('disabled', true);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.disabled).toBe(true);
});
it('projette le contenu', async () => {
await TestBed.configureTestingModule({ imports: [ButtonHost] }).compileComponents();
const fixture = TestBed.createComponent(ButtonHost);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button').textContent).toContain('Valider');
});
});
@@ -0,0 +1,16 @@
import { Component, input } from '@angular/core';
export type ButtonVariant = 'primary' | 'secondary' | 'danger';
@Component({
selector: 'ev-button',
standalone: true,
templateUrl: './button.html',
styleUrl: './button.scss',
})
export class Button {
variant = input<ButtonVariant>('primary');
type = input<'button' | 'submit'>('button');
disabled = input(false);
fullWidth = input(true);
}
@@ -0,0 +1 @@
<ng-content></ng-content>
@@ -0,0 +1,10 @@
:host {
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-5);
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
@@ -0,0 +1,22 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Card } from './card';
@Component({
standalone: true,
imports: [Card],
template: `<ev-card><p>Contenu</p></ev-card>`,
})
class CardHost {}
describe('Card', () => {
it('projette son contenu', async () => {
await TestBed.configureTestingModule({ imports: [CardHost] }).compileComponents();
const fixture = TestBed.createComponent(CardHost);
fixture.detectChanges();
const card = fixture.nativeElement.querySelector('ev-card');
expect(card).toBeTruthy();
expect(card.textContent).toContain('Contenu');
});
});
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'ev-card',
standalone: true,
templateUrl: './card.html',
styleUrl: './card.scss',
})
export class Card {}
@@ -0,0 +1,8 @@
export interface Site {
site_id: string;
site_name: string;
site_type: string;
location: string | null;
capacity_kw: number | null;
status: string | null;
}
+2 -2
View File
@@ -2,10 +2,10 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Frontend</title>
<title>EnerVision</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
</head>
<body>
<app-root></app-root>
+11 -1
View File
@@ -1 +1,11 @@
/* You can add global styles to this file, and also import other style files */
@use 'styles/tokens';
@use 'styles/forms';
@use 'styles/auth-page';
@use 'styles/links';
body {
margin: 0;
font-family: var(--font-family);
color: var(--color-text);
background: var(--color-bg);
}
+57
View File
@@ -0,0 +1,57 @@
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: var(--space-4);
box-sizing: border-box;
background:
radial-gradient(circle at 15% 10%, var(--color-primary-light) 0%, transparent 45%),
radial-gradient(circle at 85% 90%, var(--color-primary-light) 0%, transparent 40%),
var(--color-bg);
}
.auth-card-wrapper {
width: 100%;
max-width: 420px;
ev-card {
padding: 3rem 2.5rem;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.06),
0 8px 10px -6px rgba(0, 0, 0, 0.04);
}
.auth-brand {
justify-content: center;
width: 100%;
font-size: 2.1rem;
margin-bottom: 1.75rem;
}
h1 {
margin: 0;
font-size: 1.85rem;
font-weight: 700;
color: var(--color-text);
text-align: center;
}
.auth-subtitle {
margin: 0.4rem 0 2rem;
color: var(--color-text-muted);
font-size: 0.95rem;
line-height: 1.4;
text-align: center;
}
ev-alert {
display: block;
margin-top: 0.75rem;
}
ev-button {
display: block;
margin-top: 1.75rem;
}
}
+31
View File
@@ -0,0 +1,31 @@
.form-label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-label);
margin-bottom: var(--space-1);
margin-top: var(--space-3);
}
.form-input {
width: 100%;
padding: var(--space-2) 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
font-family: var(--font-family);
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.15);
}
}
.form-hint {
display: block;
font-size: 0.75rem;
color: var(--color-disabled);
margin-top: 0.25rem;
}
+34
View File
@@ -0,0 +1,34 @@
.ev-link {
color: var(--color-primary);
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.ev-breadcrumb {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-muted);
margin-bottom: 1.25rem;
a {
color: var(--color-text-muted);
text-decoration: none;
&:hover {
color: var(--color-primary);
text-decoration: underline;
}
}
}
.ev-brand-link {
display: inline-flex;
color: inherit;
text-decoration: none;
}
+43
View File
@@ -0,0 +1,43 @@
:root {
// Marque (dérivé du logo : vert feuille/éclair, halo)
--color-primary: #16a34a;
--color-primary-hover: #15803d;
--color-primary-light: #dcfce7;
// Neutres (texte, bordures, fonds)
--color-text: #1f2937;
--color-text-muted: #6b7280;
--color-label: #374151;
--color-border: #d1d5db;
--color-border-light: #e5e7eb;
--color-bg: #f3f4f6;
--color-surface: #ffffff;
--color-disabled: #9ca3af;
// Sémantique (statuts, alertes)
--color-success: #16a34a;
--color-success-bg: #dcfce7;
--color-warning: #f9a825;
--color-warning-bg: #fef9e7;
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--color-danger-bg: #fef2f2;
--color-danger-border: #fecaca;
--color-critical: #b91c1c;
--color-warning-text: #92400e;
--color-text-inverse: #ffffff;
// Typo, rayons, ombre
--font-family: 'Segoe UI', system-ui, sans-serif;
--radius-sm: 8px;
--radius-md: 12px;
--radius-pill: 999px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
// Espacements
--space-1: 0.35rem;
--space-2: 0.6rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
reportsDirectory: './coverage',
include: ['src/**/*.{ts,tsx,js,jsx}'],
exclude: [
'**/*.spec.*',
'**/*.test.*',
'**/node_modules/**',
'**/dist/**',
],
},
},
})
+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.
+1
View File
@@ -77,6 +77,7 @@ collecteur ne vient le lire.
| 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 |
+6 -1
View File
@@ -142,6 +142,7 @@ 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/sites/{site_id}/current` | Dernière mesure d'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 |
@@ -171,7 +172,11 @@ et `GET /recommendations/{recommendation_id}` reprennent le même gabarit à la
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
pas dans ce gabarit route-par-table. `GET /sites/{site_id}/current` reste sur le gabarit `sites`,
mais `SiteService` gagne la même seconde dépendance (`ReadingRepository`) pour restituer la
dernière `Reading` du site : un site connu sans lecture rend `200` avec tous les champs de mesure
à `null` et `data_quality="critical"`, seul un `site_id` absent de la base rend `404`. 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,
+4
View File
@@ -16,6 +16,10 @@ Ce qui est en place :
- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de
fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le
graphique de charge par site, tous deux construits sur Chart.js.
- Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`,
`ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit
réutiliser plutôt que redéfinir ses propres styles. Détail :
[32-design-systeme-frontend.md](32-design-systeme-frontend.md).
- L'état vit dans des signaux, sans bibliothèque dédiée.
- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test.
- Prettier configuré, parser `angular` pour les gabarits HTML.
@@ -0,0 +1,103 @@
# Design système frontend
Ce que toute nouvelle page ou tout nouveau composant Angular doit réutiliser, plutôt que
redéfinir ses propres couleurs, rayons ou espacements en dur. Contexte : issue
[#91](https://github.com/ineszang/ProjetPiscine_EnerVision/issues/91), née d'une incohérence
visuelle accumulée page après page (aucun jeton partagé n'existait avant ce chantier).
## Tokens
Déclarés en CSS custom properties dans `apps/frontend/src/styles/_tokens.scss`, importés une
seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentaire.
| Variable | Rôle |
|---|---|
| `--color-primary`, `--color-primary-hover`, `--color-primary-light` | Couleur de marque (vert, dérivé du logo), actions principales |
| `--color-text`, `--color-text-muted`, `--color-label` | Hiérarchie de texte (titres, texte secondaire, labels de formulaire) |
| `--color-border`, `--color-border-light` | Bordures d'inputs et de cartes |
| `--color-bg`, `--color-surface` | Fond de page vs fond des cartes/panneaux |
| `--color-disabled` | Éléments désactivés |
| `--color-success` / `-bg`, `--color-warning` / `-bg` / `-text`, `--color-danger` / `-hover` / `-bg` / `-border`, `--color-critical` | États sémantiques (alertes, badges) |
| `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) |
| `--font-family` | Police unique de l'application |
| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) |
| `--shadow-card` | Ombre portée des cartes |
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans
`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
aussi simples que ceux de ce projet. Les erreurs de formulaire, elles, s'affichent via
`<ev-alert severity="danger">`, pas une classe dédiée.
La classe `.auth-page` (`apps/frontend/src/styles/_auth-page.scss`, importée globalement) porte
le fond dégradé et le centrage commun aux pages d'authentification (`login`, `change-password`,
et à terme `forgot-password`/`reset-password`) : elle enveloppe la carte, pas de duplication du
fond par page.
Les classes de navigation partagées (`.ev-link`, `.ev-breadcrumb`, `.ev-brand-link`) sont dans
`apps/frontend/src/styles/_links.scss`, importées globalement. Convention pour toute page de la
zone authentifiée (derrière `authGuard`) : le logo (`<ev-brand>`) est enveloppé dans
`<a routerLink="/dashboard" class="ev-brand-link">` pour ramener au tableau de bord en un clic,
et un `<nav class="ev-breadcrumb">` liste le chemin de retour vers les pages parentes quand la
page n'est pas à la racine (voir `site-list`/`site-detail-placeholder` pour l'exemple).
## Composants partagés
Dans `apps/frontend/src/app/shared/components/ui/`, chacun standalone, à importer directement
dans le tableau `imports` du composant qui l'utilise.
- **`<ev-button>`** (`button/`) : `variant` (`primary` / `secondary` / `danger`, défaut
`primary`), `type` (`button` / `submit`, défaut `button`), `disabled`, `fullWidth` (défaut
`true` ; passer `false` pour un bouton qui ne doit pas occuper toute la largeur de son
conteneur, ex. une action isolée dans un en-tête).
```html
<ev-button type="submit" [disabled]="form.invalid">Valider</ev-button>
<ev-button variant="secondary" [fullWidth]="false">Déconnexion</ev-button>
```
- **`<ev-card>`** (`card/`) : conteneur à padding/rayon/ombre standard, sans input, tout est le
contenu projeté (`<ng-content>`). Le style vit sur `:host` : une classe externe passée par le
parent (`<ev-card class="ma-classe">`) se combine avec le style du composant sans le masquer.
```html
<ev-card><h1>Titre</h1></ev-card>
```
- **`<ev-alert>`** (`alert/`) : `severity` (`success` / `warning` / `danger`, défaut `danger`),
`role="alert"` posé automatiquement. Même principe de style sur `:host`.
```html
<ev-alert severity="danger">Erreur : {{ message }}</ev-alert>
```
- **`<ev-badge>`** (`badge/`) : `tone` (`success` / `warning` / `danger` / `critical` /
`neutral`, défaut `neutral`), pastille à bord arrondi pour un statut court. `danger` et
`critical` sont deux rouges distincts (`--color-danger` vs `--color-critical`, plus sombre) :
une sévérité `critical` ne doit pas se confondre visuellement avec une `high`.
```html
<ev-badge tone="danger">critique</ev-badge>
```
- **`<ev-brand>`** (`brand/`) : lockup icône + « EnerVision », sans input. La taille se pilote
entièrement via `font-size` (l'icône et le texte sont exprimés en `em`, donc ils grossissent
ensemble en gardant le même écart proportionnel) : une page l'agrandit simplement avec
`ev-brand { font-size: 2.1rem; }`. Ne pas recomposer icône + texte en une seule image bitmap :
un essai en ce sens (recadrage pixel de l'asset source) a produit un rendu bruité et un espacement
figé, impossible à ajuster proprement.
```html
<ev-brand />
```
## Logo
L'icône seule (sans le mot-symbole), recadrée depuis l'asset source du projet, vit à deux
endroits qui doivent rester synchronisés si le logo change un jour :
`apps/frontend/public/logo-icon.png` (utilisée par `<ev-brand>`) et
`apps/backend/app/static/logo-icon.png` (référencée par `/docs`, favicon Swagger, et `/redoc` via
l'extension `x-logo` du schéma OpenAPI, voir `app/main.py`). Le mot-symbole « EnerVision » n'est
jamais une image : c'est le texte du composant `<ev-brand>`, en police système.
Le favicon `apps/frontend/public/favicon.ico` est généré depuis la même icône (multi-tailles
16 à 256px).
## Règle pour toute nouvelle page
Utiliser les tokens et les composants ci-dessus plutôt que des valeurs en dur (couleurs
hexadécimales, rayons, espacements). Étendre ce document si un nouveau composant partagé est
créé.
+1
View File
@@ -13,6 +13,7 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard.
| [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration, contrat OpenAPI |
| [30-frontend.md](30-frontend.md) | Angular, arborescence cible, flux HTTP |
| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion |
| [32-design-systeme-frontend.md](32-design-systeme-frontend.md) | Tokens CSS, composants `ev-*` partagés, règle anti-couleur-en-dur |
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents
@@ -0,0 +1,45 @@
# Revue 1 - Cadrage (Jalon 2 - 15/09)
Présents :
- Dorian
- Johan
- Inès
- Meryem
- Valentin
**Objectif:** Valider le périmètre retenu et les choix technologiques initiaux.
## KPIs
| Indicateur | Valeur |
| --- | --- |
| Issues fermées / Issues totales | 4/4 |
| Must fermés / Must total | 4/41 |
| Jours écoulés / jours restants | 2/9 |
## Issues du jalon
| Issue | Titre | Labels | Fermée par |
| --- | --- | --- | --- |
| #58 | Préparer les tests unitaires du frontend pour permettre leur implémentation continue | frontend, test | Dorian |
| #57 | Préparer les tests unitaires du backend pour permettre leur implémentation continue | backend, test | Dorian |
| #18 | Provisionner un cluster K8s single-node (k3s) via Terraform | infra | Dorian |
| #67 | docs: fonder la documentation d'architecture du monorepo | documentation | Johan |
## Décisions prises
| Décision | Porteur | Justification |
| --- | --- | --- |
| Vitest retenu comme framework de tests unitaires frontend (PR #62) | Valentin | Standard de l'écosystème Vite ; permet scripts npm et mesure de couverture dès maintenant. |
| pytest / pytest-asyncio + coverage.py côté backend, seuil de couverture fixé à 85 % (PR #65) | Johan | Le code applicatif est encore un squelette : c'est le bon moment pour poser les conventions de test avant l'arrivée de la logique métier. |
| Cluster K8s single-node (k3s) provisionné via Terraform, version k3s figée en v1.31.5+k3s1, option `--write-kubeconfig-mode 644` retirée (PR #66) | Dorian | Reproductibilité de l'infra on-premise (infra as code) et durcissement sécurité du kubeconfig. |
| Documentation d'architecture en 5 vues Mermaid + index dans `docs/architecture/`, avec statuts Done/In Progress/Target (PR #68) | Johan | Rendu natif GitHub sans assets binaires ; corrige des affirmations obsolètes (frontend, port 4200, arborescence). |
## Actions à mener
| Action | Responsable | Échéance |
| --- | --- | --- |
| Alimenter les tests unitaires frontend au fil des features, sur le socle Vitest posé en #58/#62 | Valentin | J4 (22/09) |
| Implémenter la logique métier backend en respectant la structure de tests (`tests/api`, `tests/db`, `tests/core`, `tests/services`, `tests/repositories`) | Johan | J3 (18/09) |
| Maintenir les vues d'architecture (`docs/architecture/`) à jour à chaque PR modifiant un composant, comme prévu par la règle posée en #67/#68 | Équipe | Continu |
| Brancher `make test` / `make dev` / `make check` dans la CI | Dorian | J3 (18/09) |
+112
View File
@@ -0,0 +1,112 @@
# Point d'avancement · ProjetPiscine_EnerVision · 2026-09-18
Rendu final : **vendredi 25 septembre (J10)**, 9h00 pour le ZIP des livrables et le support EC02.
Source : issues et PR GitHub du repo, branche `dev`. Relevé du 18/09 au matin.
## 1. Ce qui a bougé depuis le point d'hier
Grosse journée : **10 issues fermées**, **8 PR mergées sur `dev`**, **41 commits**.
| Auteur | Commits sur `dev` le 17/09 | Ce qui est arrivé |
|---|---|---|
| **Johan** | 27 | Design système front (#91), politique de mot de passe + mot de passe oublié (#87), vue liste des sites (#49), `GET /sensors/status` (#32), pipeline CI lint+tests (#20, avec Ines) |
| **Meryem** | 6 | Pipeline d'import des données historiques, **#14 fermée** (elle bloquait #15 et #16) |
| **Dorian (phyri0s)** | 3 | `GET /readings` avec fenêtre bornée et pagination, **pipeline d'entraînement LightGBM** posé (#92, ADR 0005) |
| **Valentin** | 4 | Auth frontend mergée (#7), Dependabot (#40), audit de sécu des dépendances en CI |
| **Ines** | 1 | Merge du pipeline CI, config SonarQube |
Le choix ML (#89) a été acté et fermé par tout le monde : **LightGBM, un modèle global**, documenté
dans `docs/adr/0005-modele-prediction-lightgbm.md`. Le module `ml/` existe sur `dev` avec
`data.py`, `features.py`, `train.py`, `baseline.py`, `metrics.py` et 4 fichiers de tests.
Décompte global : **33 issues fermées sur 65** (hier : 25/59). 6 issues créées dans la journée,
32 encore ouvertes, dont **22 sans personne dessus** (hier : 27).
## 2. Où en est chacun
| Membre | Issues ouvertes assignées | Détail |
|---|---|---|
| **Johan** | #19, #29, #51, #61 | Reverse proxy Nginx+TLS · endpoint `current` (PR #84 ouverte depuis le 16/09) · vue détail d'un site (PR #103, CI verte) · tests d'accès rôles |
| **ValentinDeFaria** | #53, #96, #97 | Vue supervision des capteurs (prise hier) · audit dépendances CI (PR #100) · couverture tests front CI (PR #101) |
| **phyri0s (Dorian)** | #5, #30 | Jeu de données d'entraînement simulé · #30 `/readings` **est livrée (PR #94 mergée) mais l'issue n'a pas été fermée** |
| **ineszang** | #21 | Pipeline CD (déploiement SSH) · PR #99 SonarQube approuvée mais check en échec |
| **Meryemel-gham** | **aucune** | #14 fermée hier à 10h11, rien de repris depuis |
| **Non assigné** | **22 issues** | Voir sections 4 et 5 |
## 3. Avancement par jalon
| Jalon | Échéance | Fermées / total | Hier | Statut |
|---|---|---|---|---|
| J1 - Environnement & repo | 14/09 | 5/5 | 5/5 | ✅ |
| J2 - Périmètre & choix techos | 15/09 | 4/4 | 4/4 | ✅ |
| **J3 - Ingestion & backend** | **18/09 (aujourd'hui)** | 9/15 | 6/12 | 🔴 60 %, **échoit ce soir avec 6 ouvertes** |
| **J4 - Architecture, sécurité, frontend** | **21/09 (J+3)** | 10/30 | 6/26 | 🔴 33 %, 20 ouvertes |
| **J5 - Robustesse & livrables** | **23/09 (J+5)** | 1/5 | 0/5 | 🔴 20 %, 4 ouvertes, **0 assignée** |
La journée d'hier a fait gagner 10 points sur J3 et 10 sur J4, mais le périmètre a aussi grossi
(59 → 65 issues). **Le retard identifié hier n'est pas résorbé, il est stabilisé.**
## 4. J3 échoit aujourd'hui : les 6 issues restantes
| Issue | Qui | État réel |
|---|---|---|
| #5 Jeu de données d'entraînement simulé | Dorian | En cours, socle posé hier |
| **#6 Entraîner le modèle** | **personne** | Le pipeline existe (#92), il manque le porteur de l'entraînement effectif |
| **#15 DAG Airflow - normalisation** | **personne** | **Débloquée hier** par la fermeture de #14 |
| **#16 DAG Airflow - chargement micro-batch** | **personne** | **Débloquée hier** par la fermeture de #14 |
| #44 Tests unitaires modèle/recommandations | **personne** | Dépend de #6 |
| #61 Tests d'accès API sécurisée (rôles) | Johan | **Débloquée** : les endpoints métier qui manquaient (#28, #32, #33, #59, #60) sont livrés |
Quatre de ces six n'ont personne dessus, dont les deux DAG Airflow qui viennent tout juste d'être
débloqués. **Meryem, qui a écrit l'ingestion (#14), n'a plus d'issue assignée** : c'est le
rapprochement le plus évident à faire ce matin.
## 5. À décider ensemble ce matin
1. **`main` est figée au 14/09.** `dev` a **161 commits d'avance** et rien n'a jamais été remonté.
Le rendu du 25/09 se fait sur le Git : il faut décider maintenant qui merge `dev` → `main`,
quand, et si on le fait en continu ou en une fois à la fin. Une remontée de 161 commits la
veille du rendu est le risque le plus concret du projet aujourd'hui.
2. **Affecter les 4 issues J3 orphelines aujourd'hui** : #6 (entraînement du modèle), #15 et #16
(DAG Airflow, débloqués), #44 (tests ML). Proposition : #15/#16 à Meryem (continuité de #14),
#6 à Dorian en suite de #5.
3. **Purger la file de PR : 5 ouvertes, dont une depuis le 16/09.**
| PR | Auteur | CI | Ce qui bloque |
|---|---|---|---|
| #84 `sites/{id}/current` | Johan | verte | **ouverte depuis le 16/09**, rien ne la retient |
| #103 vue détail d'un site | Johan | verte sauf SonarCloud | vérification visuelle |
| #101 couverture tests front | Valentin | **entièrement verte** | personne n'a reviewé |
| #100 audit dépendances CI | Valentin | verte sauf SonarCloud | personne n'a reviewé |
| #99 config SonarQube | Ines | **SonarQube en échec** | approuvée, mais le check rouge |
SonarCloud est en échec sur #103 et #100, SonarQube sur #99. On avait acté hier que
« SonarCloud non-bloquant = go de merge ». **Soit on applique cette règle pour de bon et on
merge, soit Ines finit #99 et on redevient strict.** Rester entre les deux fait que rien ne part.
4. **J5 n'a aucun assigné à 5 jours de l'échéance** : #41 (DAST OWASP ZAP), #45 (tests
d'intégration API↔DB↔ML), #46 (E2E Playwright), #47 (tests de charge k6). Ce sont exactement
les livrables qui servent de preuve à EC03 et EC04. À nommer aujourd'hui, même sans démarrer.
5. **Hygiène de suivi** : #30 est livrée (PR #94 mergée hier) mais son issue est ouverte, et #29
attend juste le merge de #84. Deux issues qui font croire à du reste à faire. À fermer.
6. **J4 : 20 ouvertes pour lundi.** Le front concentre le volume (#9 widget alerte de pic, #10 vue
recommandations, #11 responsive, #54 comparateur de scénarios), plus le ML applicatif (#37
service de scoring, #38 moteur de règles) et l'infra (#22 secrets, #24 MinIO, #26 monitoring,
#36 rétention, #42 chiffrement au repos, #43 accessibilité). Hier il a été acté de **ne rien
couper**. À 3 jours de l'échéance et 5 personnes, ce choix se reconfirme ou se révise ce matin,
avec les chiffres sous les yeux.
## 6. Ce qui va bien, et qu'il faut garder
- Le rythme du 17/09 (10 issues fermées, 8 PR mergées) est le bon rythme. Tenu 5 jours, il vide
la file.
- La chaîne de revue fonctionne : Dorian a reviewé #93 et #95, les remarques ont été traitées
avant merge.
- Le backend est essentiellement là : auth + RBAC + audit, `/sites`, `/readings`, `/stats/summary`,
`/alerts`, `/recommendations`, `/sensors/status`, contrat OpenAPI versionné, import historique.
Le reste du projet s'appuie dessus, et ce socle ne bougera plus.
- 5 ADR écrits et à jour : c'est de la matière directement réutilisable pour EC01 et EC02.
+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

Some files were not shown because too many files have changed in this diff Show More