fix: conflict
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user