CoolFace
Apppublic

Steph680/ormuz

sourceHugging Faceupdated 2d agoView on Hugging Face
0likes
test_correlation.py286 linesDownload Raw Back to tests
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