Steph680/ormuz
0
1# -*- coding: utf-8 -*-2"""Tests de la confrontation radar / AIS sur un passage unique."""3 4import asyncio5from datetime import timedelta6 7from app import correlation, db, imagery8from app.state import utc_now9 10# Emprises réelles relevées au catalogue Copernicus le 13/09/2026. La trace11# ascendante (~14:16 UTC) couvre la fenêtre AIS ; les descendantes (~02:10)12# n'en couvrent qu'une partie.13ASCENDANT = {"type": "Polygon", "coordinates": [[14 [55.46, 27.25], [55.71, 26.06], [58.19, 26.47], [57.97, 27.66], [55.46, 27.25]]]}15DESCENDANT = {"type": "Polygon", "coordinates": [[16 [53.35, 26.60], [56.20, 25.98], [56.20, 27.91], [53.35, 27.50], [53.35, 26.60]]]}17 18 19# ── Couverture d'un passage ──────────────────────────────────────────────────20 21def test_le_rectangle_englobant_ne_suffit_pas():22 """Une fauchée Sentinel-1 est un parallélogramme oblique : son rectangle23 englobant déborde largement de l'emprise réelle. S'y fier a d'abord fait24 conclure, à tort, qu'un passage couvrait toute la boîte du détroit."""25 coins_boite = [(56.0, 26.15), (57.0, 26.15), (57.0, 26.99), (56.0, 26.99)]26 lons = [p[0] for p in ASCENDANT["coordinates"][0]]27 lats = [p[1] for p in ASCENDANT["coordinates"][0]]28 # Au rectangle englobant, les quatre coins semblent couverts…29 assert all(min(lons) <= x <= max(lons) and min(lats) <= y <= max(lats)30 for x, y in coins_boite)31 # …mais le polygone réel en laisse un dehors (le sud-est).32 assert not imagery.couvre(ASCENDANT, coins_boite)33 34 35def test_la_trace_ascendante_couvre_la_fenetre_ais():36 """C'est la seule zone où la comparaison a un sens, et elle est couverte37 à 100 % — vérifié sur les emprises réelles du catalogue."""38 assert imagery.part_couverte(ASCENDANT, correlation.FENETRE_AIS) == 1.039 40 41def test_la_trace_descendante_est_ecartee():42 part = imagery.part_couverte(DESCENDANT, correlation.FENETRE_AIS)43 assert part < correlation.PART_MIN44 45 46def test_un_angle_manquant_ne_disqualifie_pas_un_passage():47 """Exiger les quatre coins paraissait rigoureux ; en production cela48 rejetait une trace couvrant 99,8 % de la fenêtre pour un angle, et faisait49 tomber la cadence de 6 à 12 jours — la moitié des occasions perdue pour50 une écharde. Le critère porte donc sur la part de surface."""51 # Emprise volontairement rognée sur le coin nord-est de la fenêtre AIS.52 lo0, la0, lo1, la1 = correlation.FENETRE_AIS53 presque = {"type": "Polygon", "coordinates": [[54 [lo0 - 0.2, la0 - 0.2], [lo1 + 0.2, la0 - 0.2],55 [lo1 + 0.2, la1 - 0.25], [lo1 - 0.25, la1 + 0.2],56 [lo0 - 0.2, la1 + 0.2], [lo0 - 0.2, la0 - 0.2]]]}57 assert not imagery.couvre(presque, [(lo1, la1)]) # le coin manque58 part = imagery.part_couverte(presque, correlation.FENETRE_AIS)59 assert part > correlation.PART_MIN # mais la surface y est60 61 62def test_passages_utilisables_tries_et_filtres():63 produits = [64 {"Name": "S1_asc_ancien", "ContentDate": {"Start": "2026-09-07T14:16:29.000Z"},65 "GeoFootprint": ASCENDANT},66 {"Name": "S1_desc", "ContentDate": {"Start": "2026-09-10T02:06:15.000Z"},67 "GeoFootprint": DESCENDANT},68 {"Name": "S1_asc_recent", "ContentDate": {"Start": "2026-09-13T14:16:15.000Z"},69 "GeoFootprint": ASCENDANT},70 ]71 passages = imagery.passages_utilisables(produits, correlation.FENETRE_AIS,72 correlation.PART_MIN)73 assert [p["name"] for p in passages] == ["S1_asc_recent", "S1_asc_ancien"]74 # L'emprise est conservée : les échos seront filtrés dessus, un par un.75 assert passages[0]["emprise"] is ASCENDANT and passages[0]["part"] == 1.076 77 78# ── Rendu borné à une acquisition ────────────────────────────────────────────79 80def test_le_rendu_passage_unique_borne_la_fenetre_temporelle():81 """La mosaïque de neuf jours interdit toute confrontation à l'AIS : ses82 échos ne sont pas simultanés. Le rendu de corrélation ne doit laisser83 entrer qu'une seule acquisition."""84 corps = imagery.build_process_body_instant("2026-09-13T14:16:15")85 plage = corps["input"]["data"][0]["dataFilter"]["timeRange"]86 assert plage["from"] == "2026-09-13T14:13:15Z"87 assert plage["to"] == "2026-09-13T14:19:15Z"88 # La mosaïque, elle, ratisse neuf jours.89 large = imagery.build_process_body("radar", "2026-09-13")90 assert large["input"]["data"][0]["dataFilter"]["timeRange"]["from"] \91 == "2026-09-04T00:00:00Z"92 93 94# ── Appariement ──────────────────────────────────────────────────────────────95 96def test_distance_approchee():97 # ~1 minute de latitude = 1852 m.98 d = correlation.distance_m(26.50, 56.50, 26.5 + 1 / 60, 56.50)99 assert 1800 < d < 1900100 101 102def test_rayon_grandit_avec_l_age_et_la_vitesse():103 """Un navire continue d'avancer entre son rapport AIS et le passage du104 satellite : un rayon fixe déclarerait « sans AIS » des navires simplement105 déplacés. À 15 nœuds, 460 m par minute."""106 immobile = correlation.rayon_incertitude(0, 600)107 rapide = correlation.rayon_incertitude(15, 600)108 assert immobile == correlation.RAYON_MIN_M109 assert rapide > 4000110 # Borné, pour ne pas apparier n'importe quoi.111 assert correlation.rayon_incertitude(30, 99999) == correlation.RAYON_MAX_M112 113 114def _echo(lat, lon):115 return {"lat": lat, "lon": lon, "taille_px": 5, "force": 120}116 117 118def _pos(mmsi, lat, lon, vitesse=0.0, age_s=0):119 quand = (utc_now() - timedelta(seconds=age_s)).isoformat()120 return {"mmsi": mmsi, "lat": lat, "lon": lon, "vitesse": vitesse,121 "position_le": quand}122 123 124def test_echo_apparie_a_un_navire_proche():125 instant = utc_now().isoformat()126 res = correlation.correler([_echo(26.50, 56.50)],127 [_pos(1, 26.5005, 56.5005)], instant)128 assert res["apparies"] == 1 and res["sans_ais"] == 0129 130 131def test_echo_sans_piste_est_compte():132 instant = utc_now().isoformat()133 res = correlation.correler([_echo(26.50, 56.50)],134 [_pos(1, 26.60, 56.60)], instant)135 assert res["sans_ais"] == 1 and res["apparies"] == 0136 assert res["details"][0]["lat"] == 26.50137 138 139def test_echo_hors_fenetre_ais_n_est_pas_juge():140 """Hors du cercle couvert par l'AIS, l'absence de piste ne prouve rien :141 elle dit seulement qu'on ne regardait pas."""142 instant = utc_now().isoformat()143 res = correlation.correler([_echo(26.50, 56.95)], [], instant) # 56.95E : dehors144 assert res["echos_dans_fenetre"] == 0145 assert res["sans_ais"] == 0146 147 148def test_position_trop_vieille_ecartee():149 instant = utc_now().isoformat()150 vieille = _pos(1, 26.5005, 56.5005, age_s=correlation.AGE_MAX_S + 60)151 res = correlation.correler([_echo(26.50, 56.50)], [vieille], instant)152 assert res["positions_ais"] == 0153 assert res["sans_ais"] == 1154 155 156def test_navire_rapide_apparie_malgre_l_ecart():157 """Position vieille de 10 minutes, navire à 15 nœuds : il a pu parcourir158 4,6 km. L'écho à 3 km doit lui être attribué."""159 instant = utc_now().isoformat()160 res = correlation.correler([_echo(26.50, 56.50)],161 [_pos(1, 26.527, 56.50, vitesse=15, age_s=600)],162 instant)163 assert res["apparies"] == 1164 165 166def test_echo_hors_emprise_du_passage_n_est_pas_juge():167 """Dans la fenêtre AIS mais hors de ce que le satellite a vu : l'écho168 n'existe pas, et surtout son absence n'existe pas non plus."""169 instant = utc_now().isoformat()170 # Emprise réduite à l'ouest de la fenêtre : l'écho à 56.60E est dehors.171 etroite = {"type": "Polygon", "coordinates": [[172 [56.0, 26.0], [56.40, 26.0], [56.40, 27.0], [56.0, 27.0], [56.0, 26.0]]]}173 res = correlation.correler([_echo(26.50, 56.60)], [], instant, etroite)174 assert res["echos_dans_fenetre"] == 0 and res["sans_ais"] == 0175 # Sans emprise, le même écho est bien jugé.176 assert correlation.correler([_echo(26.50, 56.60)], [], instant)["sans_ais"] == 1177 178 179def test_bilan_complet():180 instant = utc_now().isoformat()181 echos = [_echo(26.50, 56.50), _echo(26.55, 56.55), _echo(26.50, 56.95)]182 res = correlation.correler(echos, [_pos(1, 26.5002, 56.5002)], instant)183 assert res["echos_dans_fenetre"] == 2 # le troisième est hors fenêtre184 assert res["apparies"] == 1 and res["sans_ais"] == 1185 186 187# ── Prévision du prochain passage ────────────────────────────────────────────188 189def test_cadence_deduite_des_passages_observes():190 """Sur une seule trace, les passages reviennent tous les 6 jours :191 Sentinel-1C et 1D s'y relaient. La cadence est déduite, pas postulée."""192 passages = [{"sensed_at": "2026-09-01T14:16:15"},193 {"sensed_at": "2026-09-07T14:16:29"},194 {"sensed_at": "2026-09-13T14:16:15"}]195 maintenant = correlation._horodatage("2026-09-14T00:00:00+00:00")196 prochain = correlation.prochain_passage(passages, maintenant)197 assert prochain.startswith("2026-09-19T14:16")198 199 200def test_deux_traces_entrelacees_ne_se_moyennent_pas():201 """Cas réel du catalogue : une trace ascendante à 14:16 et une descendante202 à 02:14, chacune tous les 6 jours, décalées. Mélangées, elles donnent des203 intervalles 2, 4, 2, 4… dont la médiane vaut 4 — une cadence à laquelle204 aucun passage n'a jamais lieu. Chaque trace se suit séparément."""205 passages = [{"sensed_at": h} for h in (206 "2026-08-26T14:16:28", "2026-08-28T02:14:10", "2026-09-01T14:16:15",207 "2026-09-03T02:14:29", "2026-09-07T14:16:29", "2026-09-09T02:14:10",208 "2026-09-13T14:16:15")]209 maintenant = correlation._horodatage("2026-09-14T12:00:00+00:00")210 prochain = correlation.prochain_passage(passages, maintenant)211 # La descendante est la prochaine échéance, pas l'ascendante du 19.212 assert prochain.startswith("2026-09-15T02:14")213 214 215def test_traces_regroupees_par_heure():216 dates = [correlation._horodatage(h) for h in (217 "2026-09-01T14:16:15+00:00", "2026-09-07T14:16:29+00:00",218 "2026-09-03T02:14:29+00:00", "2026-09-09T02:14:10+00:00")]219 traces = correlation._traces(dates)220 assert sorted(len(t) for t in traces) == [2, 2]221 222 223def test_prochain_passage_saute_les_dates_passees():224 passages = [{"sensed_at": "2026-08-01T14:16:00"},225 {"sensed_at": "2026-08-07T14:16:00"}]226 maintenant = correlation._horodatage("2026-09-13T00:00:00+00:00")227 assert correlation.prochain_passage(passages, maintenant) > "2026-09-13"228 229 230def test_sans_historique_aucune_prevision():231 assert correlation.prochain_passage([]) == ""232 233 234# ── Instantané AIS ───────────────────────────────────────────────────────────235 236def test_instantane_choisi_le_plus_proche(tmp_path, monkeypatch):237 monkeypatch.setenv("DB_PATH", str(tmp_path / "t.db"))238 scene = "2026-09-13T14:16:15+00:00"239 240 async def run():241 await db.init_db()242 async with db.connect() as conn:243 for releve, mmsi in (("2026-09-13T14:02:00+00:00", 1),244 ("2026-09-13T14:15:00+00:00", 2)):245 await conn.execute(246 "INSERT INTO ais_instantanes (releve_le, mmsi, nom, lat, lon,"247 " vitesse, cap, position_le) VALUES (?, ?, 'X', 26.5, 56.5,"248 " 0, 0, ?)", (releve, mmsi, releve))249 await conn.commit()250 return await correlation.instantane_proche(scene)251 252 releve, positions = asyncio.run(run())253 assert releve == "2026-09-13T14:15:00+00:00" # 1 min avant, pas 14 min254 assert [p["mmsi"] for p in positions] == [2]255 256 257def test_aucun_instantane_dans_la_tolerance(tmp_path, monkeypatch):258 """Sans relevé simultané, on n'invente pas : la scène restera non259 corrélée plutôt que de produire un « tout est sans AIS » trompeur."""260 monkeypatch.setenv("DB_PATH", str(tmp_path / "t.db"))261 262 async def run():263 await db.init_db()264 async with db.connect() as conn:265 await conn.execute(266 "INSERT INTO ais_instantanes (releve_le, mmsi, nom, lat, lon,"267 " vitesse, cap, position_le) VALUES"268 " ('2026-09-13T08:00:00+00:00', 1, 'X', 26.5, 56.5, 0, 0,"269 " '2026-09-13T08:00:00+00:00')")270 await conn.commit()271 return await correlation.instantane_proche("2026-09-13T14:16:15+00:00")272 273 releve, positions = asyncio.run(run())274 assert releve is None and positions == []275 276 277def test_instantane_sans_cle_ne_tente_rien(tmp_path, monkeypatch):278 monkeypatch.setenv("DB_PATH", str(tmp_path / "t.db"))279 monkeypatch.delenv("DATALASTIC_API_KEY", raising=False)280 281 async def run():282 await db.init_db()283 return await correlation.prendre_instantane()284 285 assert asyncio.run(run()) == {"ok": False, "detail": "cle absente"}286 