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. """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, 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é espacées uniformément, sur toute la fenêtre `[start_time, end_time)` demandée, la première
empiriquement). `limit` = nombre d'heures de la fenêtre est donc le seul réglage cohérent avec au tout début de la fenêtre (vérifié empiriquement). Deux façons d'obtenir une lecture
le grain horaire du reste du schéma (`period_minutes=60`, historique CSV à une ligne/heure) ; alignée sur l'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. - 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 duree = end_time - start_time
heures, reste = divmod(duree.total_seconds(), 3600) heures, reste = divmod(duree.total_seconds(), 3600)
if reste != 0: # Fenêtre plus courte qu'une heure, ou pas un multiple entier : aucun `limit` supérieur à 1
raise ValueError( # n'espacerait ses lectures d'1h pile (l'espacement vaut toujours durée / limit). Seule la
"La fenêtre doit couvrir un nombre entier d'heures pour obtenir une lecture par " # lecture unique, ancrée sur `start_time`, reste alignée.
f"heure alignée : [{start_time.isoformat()}, {end_time.isoformat()}) n'en couvre pas." limit = int(heures) if reste == 0 and heures >= 1 else 1
)
if heures > MAX_LIMIT: if limit > MAX_LIMIT:
raise ValueError( 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." "lectures accepté par l'API Mock."
) )
return int(heures) return limit
async def import_mock_api_history( 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 assert limite == 21 * 24
def test_limit_for_window_rejects_a_partial_hour() -> None: def test_limit_for_window_falls_back_to_one_reading_under_an_hour() -> None:
with pytest.raises(ValueError, match="nombre entier d'heures"): # 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( mock_api_import.limit_for_window(
datetime.fromisoformat("2026-09-02T12:00:00+00:00"), datetime.fromisoformat("2026-09-02T12:05:00+00:00"),
datetime.fromisoformat("2026-09-02T12:30: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`. 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 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 déclenchement manuel, mais la fenêtre transmise au script backend part de l'heure pile qui
les tables communes `site` et `reading`. Le décalage à `:45` laisse quinze minutes avant le 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`. 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. `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 à **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 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 fenêtre `[start_time, end_time)` demandée, la première au tout début de la fenêtre (vérifié
l'API). Une fenêtre d'une heure avec `limit=1000`, le réglage d'origine, renvoyait donc 1000 empiriquement en interrogeant directement l'API). Une fenêtre d'une heure avec `limit=1000`, le
lectures espacées de 3,6 secondes à l'intérieur de cette heure, pas une lecture horaire, réglage d'origine, renvoyait donc 1000 lectures espacées de 3,6 secondes à l'intérieur de cette
incompatible avec les lags positionnels de `build_features`. Plutôt que documenter la règle heure, pas une lecture horaire, incompatible avec les lags positionnels de `build_features`.
« `limit` = nombre d'heures de la fenêtre » et compter sur chaque appelant pour la respecter, Plutôt que documenter la règle « `limit` = nombre d'heures de la fenêtre » et compter sur chaque
`limit_for_window()` la porte : `import_mock_api_history()` calcule `limit` depuis la fenêtre appelant pour la respecter, `limit_for_window()` la porte : `import_mock_api_history()` calcule
reçue, refuse une fenêtre qui ne couvre pas un nombre entier d'heures, et refuse un intervalle de `limit` depuis la fenêtre reçue, refuse une fenêtre dont `start_time` ne tombe pas pile sur
plus de 1000 heures (le plafond `limit` de l'API). `--limit` n'existe donc plus côté CLI. Le DAG l'heure (c'est elle qui ancre l'alignement), et refuse un intervalle de plus de 1000 heures (le
`mock_api_import` interroge toujours une fenêtre d'1h (`interval=timedelta(hours=1)`, voir plafond `limit` de l'API). `--limit` n'existe donc plus côté CLI. Deux formes de fenêtre sont
[10-infra.md](10-infra.md)) : la fenêtre `[:45, :45)` place chaque lecture à :45, pas à :00 (la gérées : un multiple entier d'heures (`limit` = ce nombre d'heures, une lecture par heure
première lecture atterrit au début de la fenêtre demandée), un décalage constant sans effet sur espacée d'1h pile, chemin du backfill manuel) ou une fenêtre plus courte qu'une heure, ou qui
les lags positionnels ni sur les jointures en aval. 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 ### Flux d'ingestion API Mock