fix(backend,airflow,ml): cloture la reconciliation entre les deux sources de lectures (#15)
This commit is contained in:
@@ -47,9 +47,15 @@ NUMERIC_COLUMNS = [
|
||||
# `bool` : `astype(bool)` ferait un `True` d'une absence, et les deux chargeurs divergeraient.
|
||||
FLAG_COLUMNS = ["is_working_hours"]
|
||||
|
||||
# `uq_reading_source` autorise deux lignes au meme (site_id, timestamp) des que `source` differe
|
||||
# (cf. `app/etl/mock_api_import.py`, qui refuse desormais d'importer une fenetre deja couverte par
|
||||
# le CSV, mais ne protege pas le sens inverse). `build_features` suppose une ligne par
|
||||
# (site_id, timestamp) sans doublon : le `DISTINCT ON` l'impose plutot que de la supposer.
|
||||
# 'csv' gagne sur 'api_history' en cas de recouvrement, l'historique etant une source verifiee
|
||||
# alors que l'API Mock est traitee comme une entree hostile (cf. OWASP API10).
|
||||
_READING_QUERY = text(
|
||||
"""
|
||||
SELECT
|
||||
SELECT DISTINCT ON (r.site_id, r.timestamp)
|
||||
r.site_id,
|
||||
r.timestamp,
|
||||
r.consumption_kwh,
|
||||
@@ -61,14 +67,14 @@ _READING_QUERY = text(
|
||||
s.capacity_kw
|
||||
FROM reading r
|
||||
JOIN site s ON s.site_id = r.site_id
|
||||
ORDER BY r.site_id, r.timestamp
|
||||
ORDER BY r.site_id, r.timestamp, (r.source = 'csv') DESC, r.reading_id DESC
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
_RECENT_READING_QUERY = text(
|
||||
"""
|
||||
SELECT
|
||||
SELECT DISTINCT ON (r.site_id, r.timestamp)
|
||||
r.site_id,
|
||||
r.timestamp,
|
||||
r.consumption_kwh,
|
||||
@@ -81,7 +87,7 @@ _RECENT_READING_QUERY = text(
|
||||
FROM reading r
|
||||
JOIN site s ON s.site_id = r.site_id
|
||||
WHERE r.timestamp >= :since AND r.timestamp <= :until
|
||||
ORDER BY r.site_id, r.timestamp
|
||||
ORDER BY r.site_id, r.timestamp, (r.source = 'csv') DESC, r.reading_id DESC
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
+38
-5
@@ -16,7 +16,7 @@ from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import lightgbm as lgb
|
||||
@@ -44,20 +44,30 @@ _INSERT_SITE = text(
|
||||
"""
|
||||
)
|
||||
|
||||
# `source = 'api_history'` impose `dataset_id IS NULL` (ck_reading_dataset_source), ce qui evite
|
||||
# de creer une ligne `dataset`. `raw_data` est NOT NULL, d'ou le litteral jsonb.
|
||||
# `source = 'api_history'` impose `dataset_id IS NULL` (ck_reading_dataset_source) : le defaut
|
||||
# `dataset_id=None` evite de creer une ligne `dataset` pour la plupart des tests. `source='csv'`
|
||||
# impose l'inverse, d'ou `insere_dataset()` quand un test a besoin de cette source precise.
|
||||
# `raw_data` est NOT NULL, d'ou le litteral jsonb.
|
||||
_INSERT_READING = text(
|
||||
"""
|
||||
INSERT INTO reading (
|
||||
site_id, timestamp, source, consumption_kwh, temperature_celsius,
|
||||
site_id, timestamp, source, dataset_id, consumption_kwh, temperature_celsius,
|
||||
humidity_percent, solar_irradiance_wm2, is_working_hours, raw_data
|
||||
) VALUES (
|
||||
:site_id, :timestamp, :source, :consumption_kwh, :temperature_celsius,
|
||||
:site_id, :timestamp, :source, :dataset_id, :consumption_kwh, :temperature_celsius,
|
||||
:humidity_percent, :solar_irradiance_wm2, :is_working_hours, '{}'::jsonb
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
_INSERT_DATASET = text(
|
||||
"""
|
||||
INSERT INTO dataset (dataset_name, archive_sha256, storage_uri, source_timezone, metadata)
|
||||
VALUES (:dataset_name, :archive_sha256, :storage_uri, 'UTC', '{}'::jsonb)
|
||||
RETURNING dataset_id
|
||||
"""
|
||||
)
|
||||
|
||||
_SELECT_PREDICTIONS = text(
|
||||
"""
|
||||
SELECT target_at, predicted_value, status, failure_reason, model_reference
|
||||
@@ -111,6 +121,25 @@ def insere_site(
|
||||
return site_id
|
||||
|
||||
|
||||
def insere_dataset(connexion: Connection) -> int:
|
||||
"""Ligne `dataset` minimale, requise pour inserer une lecture `source='csv'`
|
||||
|
||||
(`ck_reading_dataset_source` impose `dataset_id IS NOT NULL` pour cette seule source).
|
||||
"""
|
||||
marque = uuid4().hex
|
||||
return cast(
|
||||
int,
|
||||
connexion.execute(
|
||||
_INSERT_DATASET,
|
||||
{
|
||||
"dataset_name": f"jeu de test {marque}",
|
||||
"archive_sha256": marque.rjust(64, "0"),
|
||||
"storage_uri": f"file:///test/{marque}.csv",
|
||||
},
|
||||
).scalar_one(),
|
||||
)
|
||||
|
||||
|
||||
def insere_lectures(
|
||||
connexion: Connection,
|
||||
site_id: str,
|
||||
@@ -119,6 +148,7 @@ def insere_lectures(
|
||||
fin: datetime,
|
||||
valeur: float = 50.0,
|
||||
source: str = "api_history",
|
||||
dataset_id: int | None = None,
|
||||
is_working_hours: bool | None = True,
|
||||
) -> list[datetime]:
|
||||
"""Grille horaire contigue finissant a `fin`, incluse.
|
||||
@@ -134,6 +164,7 @@ def insere_lectures(
|
||||
"site_id": site_id,
|
||||
"timestamp": instant,
|
||||
"source": source,
|
||||
"dataset_id": dataset_id,
|
||||
"consumption_kwh": valeur + math.sin(rang / 12.0) * 10.0,
|
||||
"temperature_celsius": 15.0,
|
||||
"humidity_percent": 50.0,
|
||||
@@ -153,6 +184,7 @@ def insere_lecture(
|
||||
instant: datetime,
|
||||
consumption_kwh: float | None = 50.0,
|
||||
source: str = "api_history",
|
||||
dataset_id: int | None = None,
|
||||
is_working_hours: bool | None = True,
|
||||
) -> None:
|
||||
"""Une lecture isolee, quand le test pilote sa valeur plutot que sa forme."""
|
||||
@@ -162,6 +194,7 @@ def insere_lecture(
|
||||
"site_id": site_id,
|
||||
"timestamp": instant,
|
||||
"source": source,
|
||||
"dataset_id": dataset_id,
|
||||
"consumption_kwh": consumption_kwh,
|
||||
"temperature_celsius": 15.0,
|
||||
"humidity_percent": 50.0,
|
||||
|
||||
@@ -11,7 +11,7 @@ from enervision_ml.data import (
|
||||
load_from_database,
|
||||
load_recent_from_database,
|
||||
)
|
||||
from tests.conftest import ANCRAGE, insere_lecture, insere_lectures, insere_site
|
||||
from tests.conftest import ANCRAGE, insere_dataset, insere_lecture, insere_lectures, insere_site
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -39,6 +39,58 @@ def test_load_from_database_joins_the_site_attributes_to_every_reading(
|
||||
assert set(mien["capacity_kw"]) == {250.0}
|
||||
|
||||
|
||||
def test_load_from_database_deduplicates_two_sources_at_the_same_instant(
|
||||
connexion_ml: Connection,
|
||||
) -> None:
|
||||
# `uq_reading_source` autorise deux lignes au meme (site_id, timestamp) des que `source`
|
||||
# differe : le garde-fou vit dans `mock_api_import.py`, pas dans le schema. Le chargeur ML
|
||||
# doit donc imposer lui-meme "une ligne par (site_id, timestamp)", pas la supposer.
|
||||
site_id = insere_site(connexion_ml)
|
||||
dataset_id = insere_dataset(connexion_ml)
|
||||
insere_lecture(
|
||||
connexion_ml, site_id, instant=ANCRAGE, consumption_kwh=10.0, source="api_history"
|
||||
)
|
||||
insere_lecture(
|
||||
connexion_ml,
|
||||
site_id,
|
||||
instant=ANCRAGE,
|
||||
consumption_kwh=99.0,
|
||||
source="csv",
|
||||
dataset_id=dataset_id,
|
||||
)
|
||||
|
||||
frame = load_from_database(connexion_ml)
|
||||
|
||||
mien = frame[frame["site_id"] == site_id]
|
||||
assert len(mien) == 1
|
||||
assert mien["consumption_kwh"].iloc[0] == 99.0
|
||||
|
||||
|
||||
def test_load_recent_from_database_prefers_csv_when_two_sources_share_an_instant(
|
||||
connexion_ml: Connection,
|
||||
) -> None:
|
||||
site_id = insere_site(connexion_ml)
|
||||
dataset_id = insere_dataset(connexion_ml)
|
||||
insere_lecture(
|
||||
connexion_ml, site_id, instant=ANCRAGE, consumption_kwh=10.0, source="api_history"
|
||||
)
|
||||
insere_lecture(
|
||||
connexion_ml,
|
||||
site_id,
|
||||
instant=ANCRAGE,
|
||||
consumption_kwh=99.0,
|
||||
source="csv",
|
||||
dataset_id=dataset_id,
|
||||
)
|
||||
|
||||
frame = load_recent_from_database(
|
||||
connexion_ml, since=ANCRAGE, until=ANCRAGE + timedelta(hours=3)
|
||||
)
|
||||
|
||||
assert len(frame) == 1
|
||||
assert frame["consumption_kwh"].iloc[0] == 99.0
|
||||
|
||||
|
||||
def test_load_recent_from_database_excludes_readings_before_the_since_bound(
|
||||
connexion_ml: Connection,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user