Merge branch 'feat/entrainement-du-modele' of https://github.com/ineszang/ProjetPiscine_EnerVision into feat/registry-modele-prediction
This commit is contained in:
+53
-7
@@ -2,7 +2,7 @@
|
||||
|
||||
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).
|
||||
[ML-START.md](../docs/ML-START.md) (mecanisme d'acces aux donnees).
|
||||
|
||||
| Element | Choix |
|
||||
|--------------|-----------------------------------------------|
|
||||
@@ -95,13 +95,53 @@ developpement local. Un deploiement partage demandera des secrets, de l'authenti
|
||||
stockage d'artefacts dedie (S3/MinIO). Le port 5000 doit etre libre : arreter `mlflow ui` avant,
|
||||
ou changer le mapping (`"5001:5000"`) dans le compose.
|
||||
|
||||
## Scoring
|
||||
|
||||
```bash
|
||||
uv run python -m enervision_ml.score --csv data/all_sites_combined.csv
|
||||
# ou, une fois la base peuplee et ML_DATABASE_URL positionnee :
|
||||
uv run python -m enervision_ml.score
|
||||
```
|
||||
|
||||
Calcule, pour chaque site (ou un seul avec `--site-id`), la consommation prevue de l'heure suivant
|
||||
sa derniere lecture connue, et ecrit une ligne dans `prediction`. Etapes, cf. `ML-START.md`
|
||||
section 2 :
|
||||
|
||||
1. Lit une fenetre recente de `reading`+`site` (21 jours par defaut, une marge au-dessus des 168h
|
||||
necessaires au lag hebdomadaire) plutot que tout l'historique -- le meme piege que celui deja
|
||||
corrige sur `GET /readings` (fenetre non plafonnee sur une hypertable).
|
||||
2. Ajoute une ligne "future" par site (l'heure suivante) et calcule ses features avec
|
||||
`enervision_ml.features.build_features`, **exactement** la meme fonction qu'a l'entrainement.
|
||||
3. Si le lag de 168h est absent (moins d'une semaine d'historique pour ce site) : ecrit
|
||||
`status="insufficient_data"` directement, sans jamais appeler LightGBM.
|
||||
4. Sinon : appelle `booster.predict(...)` et ecrit `status="available"` avec la valeur predite.
|
||||
|
||||
`--model` pointe vers le fichier entraine (`models/lightgbm-consumption.txt` par defaut).
|
||||
`model_reference` en base est le hache SHA-256 (tronque) du fichier modele, pas son nom de
|
||||
fichier : `train.py` reecrit toujours le meme chemin a chaque entrainement, donc le nom seul ne
|
||||
distinguerait pas deux versions du modele.
|
||||
|
||||
En mode `--csv`, rien n'est ecrit en base : c'est un instantane historique fige (l'heure "future"
|
||||
calculee a partir de la fin du CSV n'existe dans aucune base reelle), utile pour valider le
|
||||
pipeline sans base joignable.
|
||||
|
||||
**Limite assumee** : la feature `is_working_hours` de la ligne future est recopiee depuis la
|
||||
derniere lecture reelle, pas recalculee -- il n'existe aucune regle horaire ouvrable dans ce
|
||||
depot (elle vit dans le generateur du jeu de donnees d'origine). L'approximation n'est fausse
|
||||
qu'aux heures de bascule ouverture/fermeture, sur une seule feature parmi une dizaine, pour une
|
||||
prevision a un seul pas.
|
||||
|
||||
`prediction` n'a pas de contrainte d'unicite sur `(site_id, target_at)` : chaque run de scoring
|
||||
insere une nouvelle ligne plutot que d'ecraser la precedente, pour garder une trace de chaque
|
||||
prevision (utile plus tard pour comparer prevision et realise, surveillance de derive #44/#45).
|
||||
|
||||
## Commandes
|
||||
|
||||
```bash
|
||||
uv run ruff check . # lint
|
||||
uv run ruff format . # format
|
||||
uv run mypy enervision_ml tests # typage strict
|
||||
uv run pytest # tests
|
||||
uv run pytest # tests + couverture (ml/coverage.xml avec --cov-report=xml, lu par Sonar)
|
||||
```
|
||||
|
||||
Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-lint`,
|
||||
@@ -119,8 +159,14 @@ environnement de developpement pour le moment.
|
||||
## Piege a connaitre
|
||||
|
||||
`enervision_ml.features.build_features` est **le seul endroit** qui doit construire les features
|
||||
du modele, a l'entrainement comme au futur scoring (service #37, pas encore construit). Si les
|
||||
deux divergent meme legerement (une fenetre de moyenne glissante calculee differemment, par
|
||||
exemple), le modele recoit en production des features qui ne ressemblent plus a ce qu'il a
|
||||
appris, et ses predictions deviennent silencieusement mauvaises sans qu'aucune erreur ne se
|
||||
declenche. Ne jamais reecrire cette logique ailleurs : importer `enervision_ml.features`.
|
||||
du modele, a l'entrainement comme au scoring (`enervision_ml.score`). Si les deux divergent meme
|
||||
legerement (une fenetre de moyenne glissante calculee differemment, par exemple), le modele
|
||||
recoit en production des features qui ne ressemblent plus a ce qu'il a appris, et ses predictions
|
||||
deviennent silencieusement mauvaises sans qu'aucune erreur ne se declenche. Ne jamais reecrire
|
||||
cette logique ailleurs : importer `enervision_ml.features`.
|
||||
|
||||
## Et cote API ?
|
||||
|
||||
`GET /api/v1/predictions` (backend, `apps/backend`) lit ce que `enervision_ml.score` a ecrit dans
|
||||
`prediction` -- la derniere prevision par site, jamais un recalcul a la volee. FastAPI ne fait
|
||||
jamais tourner LightGBM lui-meme, cf. `ML-START.md` section 3.
|
||||
|
||||
@@ -16,6 +16,7 @@ Deux chemins, qui doivent produire le meme schema de sortie (colonnes `site_id`,
|
||||
colonne est renvoyee a `NaN`, que LightGBM gere nativement comme valeur manquante.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
@@ -34,6 +35,14 @@ OUTPUT_COLUMNS = [
|
||||
"capacity_kw",
|
||||
]
|
||||
|
||||
NUMERIC_COLUMNS = [
|
||||
"consumption_kwh",
|
||||
"temperature_celsius",
|
||||
"humidity_percent",
|
||||
"solar_irradiance_wm2",
|
||||
"capacity_kw",
|
||||
]
|
||||
|
||||
_READING_QUERY = text(
|
||||
"""
|
||||
SELECT
|
||||
@@ -53,10 +62,43 @@ _READING_QUERY = text(
|
||||
)
|
||||
|
||||
|
||||
_RECENT_READING_QUERY = text(
|
||||
"""
|
||||
SELECT
|
||||
r.site_id,
|
||||
r.timestamp,
|
||||
r.consumption_kwh,
|
||||
r.temperature_celsius,
|
||||
r.humidity_percent,
|
||||
r.solar_irradiance_wm2,
|
||||
r.is_working_hours,
|
||||
s.site_type,
|
||||
s.capacity_kw
|
||||
FROM reading r
|
||||
JOIN site s ON s.site_id = r.site_id
|
||||
WHERE r.timestamp >= :since
|
||||
ORDER BY r.site_id, r.timestamp
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def load_from_database(connection: Connectable) -> pd.DataFrame:
|
||||
"""Lit l'historique complet `reading` + `site` depuis PostgreSQL."""
|
||||
"""Lit l'historique complet `reading` + `site` depuis PostgreSQL. Entrainement seulement :
|
||||
le scoring n'a besoin que d'une fenetre recente, cf. `load_recent_from_database`.
|
||||
"""
|
||||
frame = pd.read_sql(_READING_QUERY, connection)
|
||||
return frame[OUTPUT_COLUMNS]
|
||||
return _typer(frame[OUTPUT_COLUMNS])
|
||||
|
||||
|
||||
def load_recent_from_database(connection: Connectable, *, since: datetime) -> pd.DataFrame:
|
||||
"""Lit `reading` + `site` depuis `since` seulement, pour le scoring.
|
||||
|
||||
Piege evite : un `SELECT` sans borne sur l'hypertable complete juste pour scorer le prochain
|
||||
pas horaire serait la meme erreur que celle corrigee sur `GET /readings` (fenetre non
|
||||
plafonnee sur une table pouvant porter des annees d'historique).
|
||||
"""
|
||||
frame = pd.read_sql(_RECENT_READING_QUERY, connection, params={"since": since})
|
||||
return _typer(frame[OUTPUT_COLUMNS])
|
||||
|
||||
|
||||
def load_from_csv(csv_path: Path) -> pd.DataFrame:
|
||||
@@ -65,4 +107,25 @@ def load_from_csv(csv_path: Path) -> pd.DataFrame:
|
||||
frame["capacity_kw"] = float("nan")
|
||||
frame["is_working_hours"] = frame["is_working_hours"].astype(bool)
|
||||
|
||||
return frame[OUTPUT_COLUMNS]
|
||||
return _typer(frame[OUTPUT_COLUMNS])
|
||||
|
||||
|
||||
def _typer(frame: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Force le typage numerique attendu par LightGBM.
|
||||
|
||||
Piege reel, pas theorique : `site.capacity_kw` n'est peuple par aucun pipeline d'ingestion
|
||||
aujourd'hui (`historical_import.py` ne pose que `site_type`/`site_name`). Une colonne
|
||||
entierement `NULL` revient de `pd.read_sql` en dtype `object` plutot que `float64`, ce que
|
||||
LightGBM refuse ("pandas dtypes must be int, float or bool"). `pd.to_numeric` corrige aussi
|
||||
n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas
|
||||
seulement `capacity_kw`.
|
||||
|
||||
Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas
|
||||
seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement
|
||||
`NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au
|
||||
scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees.
|
||||
"""
|
||||
typee = frame.copy()
|
||||
for colonne in NUMERIC_COLUMNS:
|
||||
typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce")
|
||||
return typee
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Scoring du modele LightGBM : calcule et enregistre la consommation prevue du prochain pas
|
||||
horaire, par site.
|
||||
|
||||
CLI autonome, sur le meme gabarit que `enervision_ml.train` et
|
||||
`apps/backend/app/etl/historical_import.py`. Cf. `docs/ML-START.md`, section 2.
|
||||
|
||||
uv run python -m enervision_ml.score --csv ../ml/data/all_sites_combined.csv
|
||||
uv run python -m enervision_ml.score # lit ML_DATABASE_URL, ecrit dans `prediction`
|
||||
|
||||
Reutilise `enervision_ml.features.build_features` tel quel (jamais reecrit) : c'est la garantie
|
||||
contre le train/serve skew documentee dans ce module.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import lightgbm as lgb
|
||||
import pandas as pd
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from enervision_ml import config
|
||||
from enervision_ml.data import load_from_csv, load_recent_from_database
|
||||
from enervision_ml.features import TARGET_COLUMN, WEATHER_COLUMNS, build_features, feature_columns
|
||||
|
||||
# Marge au-dessus des 168h necessaires au lag hebdomadaire, pour absorber les trous de mesure.
|
||||
LOOKBACK = timedelta(days=21)
|
||||
|
||||
# Au-dela de ce seuil, la derniere lecture d'un site est trop vieille pour que "l'heure
|
||||
# suivante" ait un sens operationnel : ce n'est plus une prevision a un pas, c'est un site dont
|
||||
# l'ingestion s'est probablement arretee. Sans cette borne, `build_scoring_frame` produirait
|
||||
# quand meme un `target_at` (derniere lecture + 1h), et rien en aval (ni l'API, ni le dashboard)
|
||||
# ne distingue une prevision fraiche d'une prevision vieille de plusieurs jours.
|
||||
MAX_STALENESS = timedelta(hours=24)
|
||||
|
||||
TARGET_METRIC = "consumption_kwh"
|
||||
PERIOD_MINUTES = 60
|
||||
LAG_168H_COLUMN = f"{TARGET_COLUMN}_lag_168h"
|
||||
INSUFFICIENT_DATA_REASON = (
|
||||
"Historique insuffisant : moins de 168h de consumption_kwh disponibles pour ce site."
|
||||
)
|
||||
|
||||
|
||||
def _stale_reason(age: pd.Timedelta) -> str:
|
||||
return (
|
||||
f"Dernière lecture vieille de {age.total_seconds() / 3600:.0f}h "
|
||||
f"(seuil {MAX_STALENESS.total_seconds() / 3600:.0f}h) : ingestion probablement "
|
||||
"arrêtée pour ce site."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScoredSite:
|
||||
site_id: str
|
||||
target_at: datetime
|
||||
status: str
|
||||
predicted_value: float | None
|
||||
failure_reason: str | None
|
||||
|
||||
|
||||
def model_reference(model_path: Path) -> str:
|
||||
"""Identifiant stable du modele utilise, insensible au fait que `train.py` reecrive
|
||||
toujours le meme nom de fichier a chaque entrainement (pas de versioning par nom, cf.
|
||||
`ml/README.md`)."""
|
||||
empreinte = hashlib.sha256(model_path.read_bytes()).hexdigest()
|
||||
return f"lightgbm-{empreinte[:12]}"
|
||||
|
||||
|
||||
def build_scoring_frame(recent: pd.DataFrame, *, site_id: str | None = None) -> pd.DataFrame:
|
||||
"""Ajoute une ligne future (l'heure suivant la derniere lecture connue) par site, et calcule
|
||||
ses features par `build_features` -- exactement comme a l'entrainement, seule la cible de
|
||||
cette ligne est inconnue.
|
||||
|
||||
Piege assume : `is_working_hours` de la ligne future est copie de la derniere lecture reelle,
|
||||
pas recalcule. Il n'existe aucune regle horaire ouvrable dans ce depot (elle vit dans le
|
||||
generateur du jeu de donnees d'origine, hors de ce code) ; l'approximation n'est fausse
|
||||
qu'aux heures de bascule (ouverture/fermeture), sur une seule feature parmi une dizaine, pour
|
||||
une prevision a un pas seulement.
|
||||
"""
|
||||
travail = recent if site_id is None else recent[recent["site_id"] == site_id]
|
||||
if travail.empty:
|
||||
return build_features(travail)
|
||||
|
||||
dernieres = (
|
||||
travail.sort_values("timestamp").groupby("site_id", as_index=False, sort=False).tail(1)
|
||||
).copy()
|
||||
dernieres["timestamp"] = dernieres["timestamp"] + pd.Timedelta(hours=1)
|
||||
dernieres[TARGET_COLUMN] = float("nan")
|
||||
# Meteo future inconnue (cf. piege documente dans `enervision_ml.features.build_features`) :
|
||||
# laisser `NaN` ici n'a aucun effet sur les features utilisees, qui ne prennent la meteo que
|
||||
# decalee.
|
||||
for colonne in WEATHER_COLUMNS:
|
||||
dernieres[colonne] = float("nan")
|
||||
|
||||
etendu = pd.concat([travail, dernieres], ignore_index=True)
|
||||
features = build_features(etendu)
|
||||
return features.groupby("site_id", as_index=False, sort=False).tail(1).reset_index(drop=True)
|
||||
|
||||
|
||||
def score(
|
||||
booster: lgb.Booster, scoring_frame: pd.DataFrame, *, instant: datetime
|
||||
) -> list[ScoredSite]:
|
||||
resultats: list[ScoredSite] = []
|
||||
|
||||
# `timestamp` de la ligne de scoring vaut derniere lecture + 1h (cf. `build_scoring_frame`) :
|
||||
# on en deduit l'age de cette derniere lecture par rapport a `instant`.
|
||||
travail = scoring_frame.copy()
|
||||
travail["_age"] = instant - (travail["timestamp"] - pd.Timedelta(hours=1))
|
||||
|
||||
perimes = travail[travail["_age"] > MAX_STALENESS]
|
||||
for enregistrement in _records(perimes):
|
||||
resultats.append(
|
||||
ScoredSite(
|
||||
site_id=enregistrement["site_id"],
|
||||
target_at=enregistrement["timestamp"].to_pydatetime(),
|
||||
status="insufficient_data",
|
||||
predicted_value=None,
|
||||
failure_reason=_stale_reason(enregistrement["_age"]),
|
||||
)
|
||||
)
|
||||
|
||||
a_jour = travail[travail["_age"] <= MAX_STALENESS]
|
||||
|
||||
insuffisants = a_jour[a_jour[LAG_168H_COLUMN].isna()]
|
||||
for enregistrement in _records(insuffisants):
|
||||
resultats.append(
|
||||
ScoredSite(
|
||||
site_id=enregistrement["site_id"],
|
||||
target_at=enregistrement["timestamp"].to_pydatetime(),
|
||||
status="insufficient_data",
|
||||
predicted_value=None,
|
||||
failure_reason=INSUFFICIENT_DATA_REASON,
|
||||
)
|
||||
)
|
||||
|
||||
suffisants = a_jour[a_jour[LAG_168H_COLUMN].notna()]
|
||||
if not suffisants.empty:
|
||||
typee = suffisants.copy()
|
||||
typee["site_type"] = typee["site_type"].astype("category")
|
||||
predictions = booster.predict(typee[feature_columns()])
|
||||
for enregistrement, valeur in zip(_records(suffisants), predictions, strict=True):
|
||||
resultats.append(
|
||||
ScoredSite(
|
||||
site_id=enregistrement["site_id"],
|
||||
target_at=enregistrement["timestamp"].to_pydatetime(),
|
||||
status="available",
|
||||
predicted_value=float(valeur),
|
||||
failure_reason=None,
|
||||
)
|
||||
)
|
||||
|
||||
return resultats
|
||||
|
||||
|
||||
def _records(frame: pd.DataFrame) -> list[dict[str, Any]]:
|
||||
return cast(list[dict[str, Any]], frame.to_dict(orient="records"))
|
||||
|
||||
|
||||
_INSERT_PREDICTION = text(
|
||||
"""
|
||||
INSERT INTO prediction (
|
||||
site_id, target_at, target_metric, period_minutes,
|
||||
predicted_value, model_reference, status, failure_reason
|
||||
) VALUES (
|
||||
:site_id, :target_at, :target_metric, :period_minutes,
|
||||
:predicted_value, :model_reference, :status, :failure_reason
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def write_predictions(
|
||||
connection: Connection, resultats: list[ScoredSite], *, reference: str
|
||||
) -> None:
|
||||
"""Ecrit une ligne par site score. Insertion seule, jamais de mise a jour : `prediction`
|
||||
n'a pas de contrainte d'unicite sur `(site_id, target_at)`, chaque run garde sa propre trace
|
||||
plutot que d'ecraser la precedente -- utile plus tard pour comparer prevision et realise
|
||||
(surveillance de derive, #44/#45)."""
|
||||
if not resultats:
|
||||
return
|
||||
|
||||
lignes = [
|
||||
{
|
||||
"site_id": r.site_id,
|
||||
"target_at": r.target_at,
|
||||
"target_metric": TARGET_METRIC,
|
||||
"period_minutes": PERIOD_MINUTES,
|
||||
"predicted_value": r.predicted_value,
|
||||
"model_reference": reference,
|
||||
"status": r.status,
|
||||
"failure_reason": r.failure_reason,
|
||||
}
|
||||
for r in resultats
|
||||
]
|
||||
connection.execute(_INSERT_PREDICTION, lignes)
|
||||
|
||||
|
||||
def _load_recent_from_csv(csv_path: Path, *, now: datetime | None) -> tuple[pd.DataFrame, datetime]:
|
||||
brute = load_from_csv(csv_path)
|
||||
instant = now or (
|
||||
brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC)
|
||||
)
|
||||
return brute[brute["timestamp"] >= instant - LOOKBACK], instant
|
||||
|
||||
|
||||
def _score_frame(
|
||||
recent: pd.DataFrame, *, model_path: Path, site_id: str | None, instant: datetime
|
||||
) -> list[ScoredSite]:
|
||||
scoring_frame = build_scoring_frame(recent, site_id=site_id)
|
||||
if scoring_frame.empty:
|
||||
return []
|
||||
|
||||
booster = lgb.Booster(model_file=str(model_path))
|
||||
return score(booster, scoring_frame, instant=instant)
|
||||
|
||||
|
||||
def run_scoring(
|
||||
*,
|
||||
model_path: Path,
|
||||
csv_path: Path | None = None,
|
||||
site_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> list[ScoredSite]:
|
||||
"""Score le prochain pas horaire par site et l'ecrit dans `prediction`.
|
||||
|
||||
En mode `--csv`, rien n'est ecrit : c'est un instantane historique fige (l'heure "future"
|
||||
calculee n'existe dans aucune base reelle), utile pour valider le pipeline sans base
|
||||
joignable, cf. `ml/README.md`. `site_id` n'est filtre qu'une fois, dans
|
||||
`build_scoring_frame` : le filtrer aussi ici serait redondant.
|
||||
"""
|
||||
if csv_path is not None:
|
||||
recent, instant = _load_recent_from_csv(csv_path, now=now)
|
||||
return _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant)
|
||||
|
||||
# Un seul engine pour la lecture et l'ecriture de ce run, plutot qu'un par etape.
|
||||
engine = create_engine(config.database_url())
|
||||
try:
|
||||
instant = now or datetime.now(UTC)
|
||||
recent = load_recent_from_database(engine, since=instant - LOOKBACK)
|
||||
resultats = _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant)
|
||||
|
||||
reference = model_reference(model_path)
|
||||
with engine.begin() as connection:
|
||||
write_predictions(connection, resultats, reference=reference)
|
||||
|
||||
return resultats
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Scoring du modele LightGBM EnerVision")
|
||||
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
type=Path,
|
||||
default=Path("models/lightgbm-consumption.txt"),
|
||||
help="Chemin du modele entraine. Defaut : models/lightgbm-consumption.txt.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--csv",
|
||||
type=Path,
|
||||
default=None,
|
||||
help=(
|
||||
"Instantane historique de demarrage/demo, rien n'est ecrit en base. Omis, lit "
|
||||
"ML_DATABASE_URL, se connecte a PostgreSQL et ecrit dans `prediction`."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--site-id",
|
||||
default=None,
|
||||
help="Ne score que ce site. Omis, tous les sites presents dans la fenetre recente.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--now",
|
||||
type=_parse_instant,
|
||||
default=None,
|
||||
help=(
|
||||
"Instant de reference (ISO 8601), pour tester ou demontrer le scoring cote base sur "
|
||||
"des donnees anciennes (ex. le jeu de donnees historique, qui s'arrete fin 2024). "
|
||||
"Omis, horloge systeme reelle."
|
||||
),
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _parse_instant(valeur: str) -> datetime:
|
||||
instant = datetime.fromisoformat(valeur)
|
||||
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
resultats = run_scoring(
|
||||
model_path=args.model, csv_path=args.csv, site_id=args.site_id, now=args.now
|
||||
)
|
||||
|
||||
if not resultats:
|
||||
print("Aucun site a scorer (aucune lecture recente dans la fenetre).")
|
||||
return
|
||||
|
||||
for r in resultats:
|
||||
if r.status == "available":
|
||||
print(f"{r.site_id} @ {r.target_at} : {r.predicted_value:.2f} kWh")
|
||||
else:
|
||||
print(f"{r.site_id} @ {r.target_at} : {r.status} ({r.failure_reason})")
|
||||
|
||||
if args.csv is not None:
|
||||
print("\nMode --csv : instantane historique, rien ecrit en base.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+14
-4
@@ -17,6 +17,7 @@ dev = [
|
||||
"ruff>=0.16.7",
|
||||
"mypy>=2.3.1",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-cov>=7.1.0",
|
||||
"pandas-stubs>=3.0.5.260914",
|
||||
]
|
||||
|
||||
@@ -47,9 +48,9 @@ select = [
|
||||
"S",
|
||||
"PT",
|
||||
]
|
||||
# N806 : `X`/`y` (donnees/cible) est la convention scikit-learn/LightGBM, pas une variable mal
|
||||
# nommee.
|
||||
ignore = ["B008", "N806"]
|
||||
# N806/N803 : `X`/`y` (donnees/cible) est la convention scikit-learn/LightGBM, pas une variable
|
||||
# ou un argument mal nomme.
|
||||
ignore = ["B008", "N806", "N803"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**/*.py" = ["S101"]
|
||||
@@ -75,5 +76,14 @@ ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q --strict-markers -m 'not integration'"
|
||||
addopts = "-q --strict-markers -m 'not integration' --cov=enervision_ml --cov-report=term-missing"
|
||||
markers = ["integration: requiert une base PostgreSQL joignable"]
|
||||
|
||||
# Rapport lu par SonarCloud (`ml/coverage.xml`, cf. sonar-project.properties), meme mecanisme que
|
||||
# apps/backend. Pas de seuil ici : celui de la quality gate porte sur le code nouveau.
|
||||
[tool.coverage.run]
|
||||
source = ["enervision_ml"]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv
|
||||
|
||||
_CSV_HEADER = (
|
||||
"site_id,timestamp,consumption_kwh,temperature_celsius,humidity_percent,"
|
||||
"solar_irradiance_wm2,is_working_hours,site_type"
|
||||
)
|
||||
|
||||
|
||||
def write_csv(tmp_path: Path, *lignes: str) -> Path:
|
||||
csv_path = tmp_path / "recent.csv"
|
||||
csv_path.write_text("\n".join([_CSV_HEADER, *lignes]) + "\n")
|
||||
return csv_path
|
||||
|
||||
|
||||
def test_load_from_csv_types_every_numeric_column_as_float(tmp_path: Path) -> None:
|
||||
csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office")
|
||||
|
||||
frame = load_from_csv(csv_path)
|
||||
|
||||
for colonne in NUMERIC_COLUMNS:
|
||||
assert frame[colonne].dtype == "float64"
|
||||
|
||||
|
||||
def test_load_from_csv_coerces_a_corrupted_measurement_to_nan(tmp_path: Path) -> None:
|
||||
# Reproduit une valeur de capteur corrompue plutot que vraiment manquante : `pandas` type
|
||||
# alors la colonne entiere en `object`, pas en `float64` rempli de `NaN` -- le meme genre de
|
||||
# divergence de typage que celle que `pd.read_sql` produit sur une colonne SQL entierement
|
||||
# `NULL` (cf. `site.capacity_kw`, jamais peuplee par aucun pipeline d'ingestion aujourd'hui).
|
||||
csv_path = write_csv(
|
||||
tmp_path,
|
||||
"SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office",
|
||||
"SITE001,2026-01-01T01:00:00,capteur_hs,15.2,50.5,0.0,True,office",
|
||||
)
|
||||
|
||||
frame = load_from_csv(csv_path)
|
||||
|
||||
assert frame["consumption_kwh"].dtype == "float64"
|
||||
assert frame["consumption_kwh"].iloc[0] == 10.5
|
||||
assert pd.isna(frame["consumption_kwh"].iloc[1])
|
||||
|
||||
|
||||
def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None:
|
||||
# `capacity_kw` n'existe pas dans ce CSV : `load_from_csv` la pose elle-meme a `NaN`. Cette
|
||||
# affectation directe est deja un `float`, contrairement au cas `pd.read_sql` -- ce test
|
||||
# garde le contrat visible malgre tout, au cas ou l'implementation changerait.
|
||||
csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office")
|
||||
|
||||
frame = load_from_csv(csv_path)
|
||||
|
||||
assert frame["capacity_kw"].dtype == "float64"
|
||||
assert pd.isna(frame["capacity_kw"].iloc[0])
|
||||
@@ -0,0 +1,277 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from enervision_ml.features import TARGET_COLUMN
|
||||
from enervision_ml.score import (
|
||||
LAG_168H_COLUMN,
|
||||
MAX_STALENESS,
|
||||
ScoredSite,
|
||||
build_scoring_frame,
|
||||
model_reference,
|
||||
run_scoring,
|
||||
score,
|
||||
write_predictions,
|
||||
)
|
||||
|
||||
|
||||
def make_recent(
|
||||
site_id: str, *, heures: int, depart: datetime, valeur: float = 10.0
|
||||
) -> pd.DataFrame:
|
||||
instants = [depart + timedelta(hours=h) for h in range(heures)]
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"site_id": site_id,
|
||||
"timestamp": instants,
|
||||
TARGET_COLUMN: [valeur + h for h in range(heures)],
|
||||
"temperature_celsius": 15.0,
|
||||
"humidity_percent": 50.0,
|
||||
"solar_irradiance_wm2": 0.0,
|
||||
"is_working_hours": True,
|
||||
"site_type": "office",
|
||||
"capacity_kw": 100.0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FakeBooster:
|
||||
def __init__(self, valeur: float = 42.0) -> None:
|
||||
self.valeur = valeur
|
||||
self.appels: list[int] = []
|
||||
|
||||
def predict(self, X: Any) -> list[float]:
|
||||
self.appels.append(len(X))
|
||||
return [self.valeur] * len(X)
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self) -> None:
|
||||
self.appels: list[tuple[Any, Any]] = []
|
||||
|
||||
def execute(self, statement: Any, parameters: Any = None) -> None:
|
||||
self.appels.append((statement, parameters))
|
||||
|
||||
|
||||
def test_build_scoring_frame_adds_one_row_per_site_one_hour_after_the_last_reading() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
recent = pd.concat(
|
||||
[
|
||||
make_recent("site-a", heures=200, depart=depart),
|
||||
make_recent("site-b", heures=200, depart=depart),
|
||||
],
|
||||
ignore_index=True,
|
||||
)
|
||||
|
||||
scoring_frame = build_scoring_frame(recent)
|
||||
|
||||
assert set(scoring_frame["site_id"]) == {"site-a", "site-b"}
|
||||
derniere_lecture = depart + timedelta(hours=199)
|
||||
assert (scoring_frame["timestamp"] == derniere_lecture + timedelta(hours=1)).all()
|
||||
|
||||
|
||||
def test_build_scoring_frame_computes_lags_from_real_history() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
recent = make_recent("site-a", heures=200, depart=depart)
|
||||
|
||||
scoring_frame = build_scoring_frame(recent)
|
||||
|
||||
ligne = scoring_frame.iloc[0]
|
||||
# La cible future n'existe pas : le lag d'1h doit valoir la toute derniere valeur reelle.
|
||||
assert ligne[f"{TARGET_COLUMN}_lag_1h"] == recent[TARGET_COLUMN].iloc[-1]
|
||||
|
||||
|
||||
def test_build_scoring_frame_flags_insufficient_history_under_168_hours() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
recent = make_recent("site-a", heures=100, depart=depart)
|
||||
|
||||
scoring_frame = build_scoring_frame(recent)
|
||||
|
||||
assert pd.isna(scoring_frame.iloc[0][LAG_168H_COLUMN])
|
||||
|
||||
|
||||
def test_build_scoring_frame_accepts_a_full_week_of_history() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
recent = make_recent("site-a", heures=169, depart=depart)
|
||||
|
||||
scoring_frame = build_scoring_frame(recent)
|
||||
|
||||
assert not pd.isna(scoring_frame.iloc[0][LAG_168H_COLUMN])
|
||||
|
||||
|
||||
def test_build_scoring_frame_filters_to_a_single_site() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
recent = pd.concat(
|
||||
[
|
||||
make_recent("site-a", heures=200, depart=depart),
|
||||
make_recent("site-b", heures=200, depart=depart),
|
||||
],
|
||||
ignore_index=True,
|
||||
)
|
||||
|
||||
scoring_frame = build_scoring_frame(recent, site_id="site-a")
|
||||
|
||||
assert scoring_frame["site_id"].tolist() == ["site-a"]
|
||||
|
||||
|
||||
def test_build_scoring_frame_returns_empty_when_there_is_no_recent_reading() -> None:
|
||||
recent = make_recent("site-a", heures=0, depart=datetime(2026, 1, 1, tzinfo=UTC))
|
||||
|
||||
scoring_frame = build_scoring_frame(recent)
|
||||
|
||||
assert scoring_frame.empty
|
||||
|
||||
|
||||
def target_at_for(depart: datetime, heures: int) -> datetime:
|
||||
"""`target_at` que produira `build_scoring_frame` pour ce jeu synthetique (derniere lecture
|
||||
+ 1h) : l'utiliser comme `instant` donne un age d'1h, largement sous le seuil de peremption,
|
||||
pour les tests qui ne visent pas ce filtre."""
|
||||
return depart + timedelta(hours=heures)
|
||||
|
||||
|
||||
def test_score_marks_insufficient_history_without_calling_the_model() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
scoring_frame = build_scoring_frame(make_recent("site-a", heures=100, depart=depart))
|
||||
booster = FakeBooster()
|
||||
|
||||
resultats = score(
|
||||
booster, # type: ignore[arg-type]
|
||||
scoring_frame,
|
||||
instant=target_at_for(depart, 100),
|
||||
)
|
||||
|
||||
assert resultats == [
|
||||
ScoredSite(
|
||||
site_id="site-a",
|
||||
target_at=resultats[0].target_at,
|
||||
status="insufficient_data",
|
||||
predicted_value=None,
|
||||
failure_reason=resultats[0].failure_reason,
|
||||
)
|
||||
]
|
||||
assert booster.appels == []
|
||||
|
||||
|
||||
def test_score_predicts_when_history_is_sufficient() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
|
||||
booster = FakeBooster(valeur=99.5)
|
||||
|
||||
resultats = score(
|
||||
booster, # type: ignore[arg-type]
|
||||
scoring_frame,
|
||||
instant=target_at_for(depart, 200),
|
||||
)
|
||||
|
||||
assert len(resultats) == 1
|
||||
assert resultats[0].status == "available"
|
||||
assert resultats[0].predicted_value == 99.5
|
||||
assert resultats[0].failure_reason is None
|
||||
assert booster.appels == [1]
|
||||
|
||||
|
||||
def test_score_marks_a_stale_site_as_insufficient_data_without_calling_the_model() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
# Historique largement suffisant (168h+), mais l'instant de reference est loin apres la
|
||||
# derniere lecture : la fraicheur doit primer sur la disponibilite de l'historique.
|
||||
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
|
||||
instant = target_at_for(depart, 200) + MAX_STALENESS + timedelta(hours=1)
|
||||
booster = FakeBooster()
|
||||
|
||||
resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type]
|
||||
|
||||
assert len(resultats) == 1
|
||||
assert resultats[0].status == "insufficient_data"
|
||||
assert resultats[0].predicted_value is None
|
||||
assert "vieille" in (resultats[0].failure_reason or "")
|
||||
assert booster.appels == []
|
||||
|
||||
|
||||
def test_score_accepts_a_reading_exactly_at_the_staleness_threshold() -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
|
||||
# `target_at_for(...)` donne deja un age d'1h (cf. sa docstring) : retrancher cette heure
|
||||
# pour retomber exactement sur le seuil, ni en dessous ni au dessus.
|
||||
instant = target_at_for(depart, 200) + MAX_STALENESS - timedelta(hours=1)
|
||||
booster = FakeBooster(valeur=12.0)
|
||||
|
||||
resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type]
|
||||
|
||||
assert resultats[0].status == "available"
|
||||
assert booster.appels == [1]
|
||||
|
||||
|
||||
def test_write_predictions_does_nothing_when_there_is_nothing_to_write() -> None:
|
||||
connection = FakeConnection()
|
||||
|
||||
write_predictions(connection, [], reference="lightgbm-test") # type: ignore[arg-type]
|
||||
|
||||
assert connection.appels == []
|
||||
|
||||
|
||||
def test_write_predictions_sends_one_row_per_result() -> None:
|
||||
connection = FakeConnection()
|
||||
resultats = [
|
||||
ScoredSite("site-a", datetime(2026, 1, 1, tzinfo=UTC), "available", 42.0, None),
|
||||
ScoredSite(
|
||||
"site-b",
|
||||
datetime(2026, 1, 1, tzinfo=UTC),
|
||||
"insufficient_data",
|
||||
None,
|
||||
"pas assez d'historique",
|
||||
),
|
||||
]
|
||||
|
||||
write_predictions(connection, resultats, reference="lightgbm-test") # type: ignore[arg-type]
|
||||
|
||||
assert len(connection.appels) == 1
|
||||
_, lignes = connection.appels[0]
|
||||
assert len(lignes) == 2
|
||||
assert lignes[0]["model_reference"] == "lightgbm-test"
|
||||
assert lignes[0]["target_metric"] == "consumption_kwh"
|
||||
assert lignes[0]["period_minutes"] == 60
|
||||
|
||||
|
||||
def test_model_reference_is_stable_for_the_same_file_content(tmp_path: Path) -> None:
|
||||
model_path = tmp_path / "model.txt"
|
||||
model_path.write_bytes(b"contenu-du-modele")
|
||||
|
||||
assert model_reference(model_path) == model_reference(model_path)
|
||||
|
||||
|
||||
def test_model_reference_changes_with_the_file_content(tmp_path: Path) -> None:
|
||||
premier = tmp_path / "model-a.txt"
|
||||
premier.write_bytes(b"version-1")
|
||||
second = tmp_path / "model-b.txt"
|
||||
second.write_bytes(b"version-2")
|
||||
|
||||
assert model_reference(premier) != model_reference(second)
|
||||
|
||||
|
||||
def test_run_scoring_in_csv_mode_scores_without_touching_a_database(tmp_path: Path) -> None:
|
||||
depart = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
frame = pd.concat(
|
||||
[
|
||||
make_recent("site-a", heures=400, depart=depart),
|
||||
make_recent("site-b", heures=400, depart=depart),
|
||||
],
|
||||
ignore_index=True,
|
||||
)
|
||||
csv_path = tmp_path / "recent.csv"
|
||||
frame.to_csv(csv_path, index=False)
|
||||
|
||||
model_path = tmp_path / "model.txt"
|
||||
model_path.write_bytes(b"peu importe le contenu pour ce test")
|
||||
|
||||
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||
monkeypatch.setattr(
|
||||
"enervision_ml.score.lgb.Booster", lambda model_file: FakeBooster(valeur=7.0)
|
||||
)
|
||||
|
||||
resultats = run_scoring(model_path=model_path, csv_path=csv_path)
|
||||
|
||||
assert {r.site_id for r in resultats} == {"site-a", "site-b"}
|
||||
assert all(r.status == "available" for r in resultats)
|
||||
assert all(r.predicted_value == 7.0 for r in resultats)
|
||||
Generated
+55
@@ -388,6 +388,45 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.16.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "50.0.1"
|
||||
@@ -494,6 +533,7 @@ dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pandas-stubs" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
@@ -512,6 +552,7 @@ dev = [
|
||||
{ name = "mypy", specifier = ">=2.3.1" },
|
||||
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||
{ name = "ruff", specifier = ">=0.16.7" },
|
||||
]
|
||||
|
||||
@@ -1591,6 +1632,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
|
||||
Reference in New Issue
Block a user