From 49175d8ff77c9e056c4153c02c693cd0a890810a Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 23 Sep 2026 15:55:12 +0200 Subject: [PATCH] fix: conflict --- apps/backend/app/etl/mock_api_import.py | 42 +++++++++++++------ .../backend/tests/etl/test_mock_api_import.py | 31 ++++++++++++-- docs/architecture/10-infra.md | 6 ++- docs/architecture/40-data.md | 29 +++++++------ 4 files changed, 77 insertions(+), 31 deletions(-) diff --git a/apps/backend/app/etl/mock_api_import.py b/apps/backend/app/etl/mock_api_import.py index 71e2e32..dc1ddb6 100644 --- a/apps/backend/app/etl/mock_api_import.py +++ b/apps/backend/app/etl/mock_api_import.py @@ -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( diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index 132a718..5156077 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -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"), ) diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 9b47cc7..b35e56f 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -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. diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 88dc21d..91ae347 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -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