fix: conflict

This commit is contained in:
Dorian
2026-09-23 15:55:12 +02:00
parent da7fc52299
commit 49175d8ff7
4 changed files with 77 additions and 31 deletions
+29 -13
View File
@@ -367,28 +367,44 @@ def limit_for_window(start_time: datetime, end_time: datetime) -> int:
"""Nombre de lectures à demander pour que l'API Mock en rende une par heure, alignée.
L'API ne renvoie pas un flux à un rythme naturel : elle répartit exactement `limit` lectures,
espacées uniformément, sur toute la fenêtre `[start_time, end_time)` demandée (vérifié
empiriquement). `limit` = nombre d'heures de la fenêtre est donc le seul réglage cohérent avec
le grain horaire du reste du schéma (`period_minutes=60`, historique CSV à une ligne/heure) ;
un `limit` plus grand fabriquerait des lectures infra-horaires, incompatibles avec les lags
positionnels de `build_features`. La fenêtre doit donc couvrir un nombre entier d'heures.
espacées uniformément, sur toute la fenêtre `[start_time, end_time)` demandée, la première
au tout début de la fenêtre (vérifié empiriquement). Deux façons d'obtenir une lecture
alignée sur l'heure :
- une fenêtre d'exactement N heures (`start_time` sur l'heure) donne, avec `limit=N`, N
lectures espacées d'1h pile, la première à `start_time` : c'est le chemin du backfill
manuel (plusieurs jours d'historique en un seul appel).
- une fenêtre plus courte qu'une heure, ou qui n'est pas un multiple entier d'heure, ne peut
espacer plusieurs lectures d'1h pile (l'espacement de l'API vaut toujours
`durée / limit`) : seule `limit=1` reste alignée, la lecture unique atterrissant à
`start_time`. C'est le chemin du DAG horaire, dont la fenêtre part de l'heure pile qui
précède son déclenchement jusqu'à l'instant du déclenchement lui-même (`:45`), donc plus
courte qu'une heure.
Dans les deux cas, `start_time` doit tomber pile sur l'heure : c'est elle qui ancre
l'alignement, jamais `end_time`. Un `limit` plus grand que celui rendu ici fabriquerait des
lectures infra-horaires, incompatibles avec les lags positionnels de `build_features`.
"""
if start_time.minute or start_time.second or start_time.microsecond:
raise ValueError(
f"La fenêtre doit démarrer pile sur l'heure : {start_time.isoformat()} ne l'est pas."
)
duree = end_time - start_time
heures, reste = divmod(duree.total_seconds(), 3600)
if reste != 0:
raise ValueError(
"La fenêtre doit couvrir un nombre entier d'heures pour obtenir une lecture par "
f"heure alignée : [{start_time.isoformat()}, {end_time.isoformat()}) n'en couvre pas."
)
# Fenêtre plus courte qu'une heure, ou pas un multiple entier : aucun `limit` supérieur à 1
# n'espacerait ses lectures d'1h pile (l'espacement vaut toujours durée / limit). Seule la
# lecture unique, ancrée sur `start_time`, reste alignée.
limit = int(heures) if reste == 0 and heures >= 1 else 1
if heures > MAX_LIMIT:
if limit > MAX_LIMIT:
raise ValueError(
f"La fenêtre demandée couvre {int(heures)}h, au-delà du plafond de {MAX_LIMIT} "
f"La fenêtre demandée couvre {limit}h, au-delà du plafond de {MAX_LIMIT} "
"lectures accepté par l'API Mock."
)
return int(heures)
return limit
async def import_mock_api_history(
+27 -4
View File
@@ -441,11 +441,34 @@ def test_limit_for_window_returns_one_per_hour() -> None:
assert limite == 21 * 24
def test_limit_for_window_rejects_a_partial_hour() -> None:
with pytest.raises(ValueError, match="nombre entier d'heures"):
def test_limit_for_window_falls_back_to_one_reading_under_an_hour() -> None:
# Le DAG horaire (`:45`) demande desormais [heure pile precedente, instant du declenchement) :
# une fenetre plus courte qu'une heure, dont l'espacement `duree/limit` ne peut jamais valoir
# 1h pile pour plus d'une lecture. Seule `limit=1`, ancree sur `start_time`, reste alignee.
limite = mock_api_import.limit_for_window(
datetime.fromisoformat("2026-09-02T12:00:00+00:00"),
datetime.fromisoformat("2026-09-02T12:45:00+00:00"),
)
assert limite == 1
def test_limit_for_window_falls_back_to_one_reading_for_a_non_whole_hour_span() -> None:
# Meme raisonnement pour une fenetre de plus d'une heure mais qui n'en est pas un multiple
# entier : aucun `limit > 1` ne donnerait un espacement d'1h pile.
limite = mock_api_import.limit_for_window(
datetime.fromisoformat("2026-09-02T12:00:00+00:00"),
datetime.fromisoformat("2026-09-02T13:30:00+00:00"),
)
assert limite == 1
def test_limit_for_window_rejects_a_start_time_not_on_the_hour() -> None:
with pytest.raises(ValueError, match="pile sur l'heure"):
mock_api_import.limit_for_window(
datetime.fromisoformat("2026-09-02T12:00:00+00:00"),
datetime.fromisoformat("2026-09-02T12:30:00+00:00"),
datetime.fromisoformat("2026-09-02T12:05:00+00:00"),
datetime.fromisoformat("2026-09-02T13:05:00+00:00"),
)
+4 -2
View File
@@ -99,8 +99,10 @@ modifier.
Le DAG `mock_api_import` exécute le pipeline API Mock toutes les heures, à la minute `:45`.
Un `CronTriggerTimetable` explicite lui attribue un intervalle d'une heure, y compris lors d'un
déclenchement manuel. Il transmet cet intervalle au script backend et charge les mesures dans
les tables communes `site` et `reading`. Le décalage à `:45` laisse quinze minutes avant le
déclenchement manuel, mais la fenêtre transmise au script backend part de l'heure pile qui
précède le déclenchement (pas de l'intervalle Airflow tel quel), pour que la mesure importée
tombe à :00 et non à :45, voir [40-data.md](40-data.md). Le pipeline charge la mesure dans les
tables communes `site` et `reading`. Le décalage à `:45` laisse quinze minutes avant le
scoring exécuté à l'heure pile, puis quinze minutes supplémentaires avant les alertes à `:15`.
`max_active_runs=1` empêche deux exécutions du DAG de se chevaucher.
+17 -12
View File
@@ -442,18 +442,23 @@ Les paramètres de ligne de commande disponibles pour l'import sont :
**Piège sur `limit`, corrigé dans le code plutôt que documenté** : l'API ne renvoie pas un flux à
un rythme naturel, elle répartit exactement `limit` lectures, espacées uniformément, sur toute la
fenêtre `[start_time, end_time)` demandée (vérifié empiriquement en interrogeant directement
l'API). Une fenêtre d'une heure avec `limit=1000`, le réglage d'origine, renvoyait donc 1000
lectures espacées de 3,6 secondes à l'intérieur de cette heure, pas une lecture horaire,
incompatible avec les lags positionnels de `build_features`. Plutôt que documenter la règle
« `limit` = nombre d'heures de la fenêtre » et compter sur chaque appelant pour la respecter,
`limit_for_window()` la porte : `import_mock_api_history()` calcule `limit` depuis la fenêtre
reçue, refuse une fenêtre qui ne couvre pas un nombre entier d'heures, et refuse un intervalle de
plus de 1000 heures (le plafond `limit` de l'API). `--limit` n'existe donc plus côté CLI. Le DAG
`mock_api_import` interroge toujours une fenêtre d'1h (`interval=timedelta(hours=1)`, voir
[10-infra.md](10-infra.md)) : la fenêtre `[:45, :45)` place chaque lecture à :45, pas à :00 (la
première lecture atterrit au début de la fenêtre demandée), un décalage constant sans effet sur
les lags positionnels ni sur les jointures en aval.
fenêtre `[start_time, end_time)` demandée, la première au tout début de la fenêtre (vérifié
empiriquement en interrogeant directement l'API). Une fenêtre d'une heure avec `limit=1000`, le
réglage d'origine, renvoyait donc 1000 lectures espacées de 3,6 secondes à l'intérieur de cette
heure, pas une lecture horaire, incompatible avec les lags positionnels de `build_features`.
Plutôt que documenter la règle « `limit` = nombre d'heures de la fenêtre » et compter sur chaque
appelant pour la respecter, `limit_for_window()` la porte : `import_mock_api_history()` calcule
`limit` depuis la fenêtre reçue, refuse une fenêtre dont `start_time` ne tombe pas pile sur
l'heure (c'est elle qui ancre l'alignement), et refuse un intervalle de plus de 1000 heures (le
plafond `limit` de l'API). `--limit` n'existe donc plus côté CLI. Deux formes de fenêtre sont
gérées : un multiple entier d'heures (`limit` = ce nombre d'heures, une lecture par heure
espacée d'1h pile, chemin du backfill manuel) ou une fenêtre plus courte qu'une heure, ou qui
n'en est pas un multiple entier (`limit=1`, seule valeur qui reste alignée quand l'espacement
`durée / limit` ne peut valoir 1h pile). Le DAG `mock_api_import` est dans ce second cas : il
demande la fenêtre `[heure pile précédant le déclenchement, instant du déclenchement)`, plus
courte qu'une heure, plutôt que l'intervalle Airflow `[data_interval_start, data_interval_end)`
tel quel (`[:45, :45)`) qui aurait placé l'unique lecture à :45, hors de la grille horaire du
reste du schéma.
### Flux d'ingestion API Mock