diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index 279f4d9..f75f8db 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -760,6 +760,27 @@ _EV_PRESENCE_LAST_DATA: dict[int, float] = {} _EV_PRESENCE_LAST_STATE: dict[int, str] = {} _EV_PLUG_NUDGE_LAST: dict[int, float] = {} +#: Geofence arrival obs (trigger='geofence_arrival') — příjezd domů BEZ píchnutí +#: do wallboxu. DEFAULT VYPNUTO (env EV_GEOFENCE_ARRIVAL_OBS_ENABLED=true zapne); +#: vypnuté = funkce běží jako dřív, jen se nový obs nezapisuje (golden gate / +#: plánovač beze změny). Debounce: vyžaduje N po sobě jdoucích čtení at_home=true +#: (GPS jitter u 150m hranice nesmí jeden flip brát jako příjezd). Dedup: emituje +#: jen jednou na epizodu (po emitu se "odzbrojí", znovu se "nabije" až po odjezdu); +#: a vůbec neběží, když je auto na wallboxu (plug-in cesta je autoritativní — +#: poll_tesla_presence se při otevřené session vrací dřív, viz `plugged`). +EV_GEOFENCE_ARRIVAL_CONFIRM_SAMPLES = 2 +_EV_GEOFENCE_HOME_STREAK: dict[int, int] = {} +_EV_GEOFENCE_ARMED: dict[int, bool] = {} + + +def _ev_geofence_obs_enabled() -> bool: + """Feature flag: zápis geofence_arrival obs (default false → inertní).""" + import os + + return (os.getenv("EV_GEOFENCE_ARRIVAL_OBS_ENABLED") or "").strip().lower() in ( + "1", "true", "yes", "on", + ) + def ev_presence_transition(prev_at_home: bool | None, new_at_home: bool | None) -> str | None: """Čistá detekce přechodu: 'arrived' / 'left' / None (testovatelné).""" @@ -772,6 +793,41 @@ def ev_presence_transition(prev_at_home: bool | None, new_at_home: bool | None) return None +def ev_geofence_arrival_decision( + vehicle_id: int, + at_home: bool | None, + confirm_samples: int = EV_GEOFENCE_ARRIVAL_CONFIRM_SAMPLES, +) -> bool: + """Debounce + dedup geofence příjezdu (čistá, testovatelná funkce nad stavem). + + Vstup `at_home` je výsledek aktuálního geofence čtení (None = poloha neznámá, + např. auto spí → stav se NEMĚNÍ). Vrací True právě jednou za epizodu příjezdu, + a to až po `confirm_samples` po sobě jdoucích čteních at_home=true: + + - at_home is None → neznámé, streak ani armed se nemění (žádné rozhodnutí). + - at_home is False → auto je pryč: vynuluj streak, "nabij" (armed=True), aby + příští potvrzený příjezd mohl emitovat. + - at_home is True → inkrementuj streak; pokud streak dosáhl prahu a jsme + armed, "odzbroj" (armed=False) a vrať True (emituj jednou). + + Tím se jeden GPS flip u hranice nepočítá jako příjezd a opakovaná at_home=true + čtení během stání doma negenerují duplicitní obs. + """ + if at_home is None: + return False + if at_home is False: + _EV_GEOFENCE_HOME_STREAK[vehicle_id] = 0 + _EV_GEOFENCE_ARMED[vehicle_id] = True + return False + # at_home is True + streak = _EV_GEOFENCE_HOME_STREAK.get(vehicle_id, 0) + 1 + _EV_GEOFENCE_HOME_STREAK[vehicle_id] = streak + if streak >= confirm_samples and _EV_GEOFENCE_ARMED.get(vehicle_id, False): + _EV_GEOFENCE_ARMED[vehicle_id] = False + return True + return False + + async def poll_tesla_presence(site_id: int, db: asyncpg.Connection) -> None: """Přítomnost vozidla: /vehicles state (nebudí) + při online poloha → geofence. @@ -830,6 +886,7 @@ async def poll_tesla_presence(site_id: int, db: asyncpg.Connection) -> None: distance_m = None charging_state = None shift_state = None + st = None if api_state == "online" and (woke_up or data_due): _EV_PRESENCE_LAST_DATA[int(veh["id"])] = loop_now try: @@ -865,6 +922,34 @@ async def poll_tesla_presence(site_id: int, db: asyncpg.Connection) -> None: int(veh["id"]), api_state, at_home, distance_m, charging_state, shift_state, ) + # Geofence příjezd (auto přijelo domů, NEpíchnuté — sem se dostaneme jen když + # NENÍ otevřená session, viz `plugged` výše: wallbox je autoritativní). Debounce + # + dedup řeší ev_geofence_arrival_decision; zápis je za feature flagem (default + # off → inertní). Zapisuje se z presence readu (st), proto jen když máme st se + # SoC i odometrem, ať jízda (km z odometru) dostane platný arrival. + if _ev_geofence_obs_enabled(): + emit = ev_geofence_arrival_decision(int(veh["id"]), at_home) + if emit and st is not None and st.get("battery_level") is not None: + try: + await db.execute( + "select ems.fn_ev_vehicle_obs_insert($1::int, $2::int, 'geofence_arrival', $3::numeric, $4::numeric, $5::text)", + site_id, + int(veh["id"]), + st.get("odometer_km"), + float(st["battery_level"]), + st.get("charging_state"), + ) + logger.info( + "EV geofence arrival obs (site=%s, vehicle=%s): soc=%s%%, odo=%s km", + site_id, veh["id"], + st["battery_level"], st.get("odometer_km"), + ) + except Exception: + logger.exception( + "EV geofence arrival obs failed (site=%s, vehicle=%s)", + site_id, veh["id"], + ) + trans = ev_presence_transition(prev["at_home"] if prev else None, at_home) if trans == "arrived" and charging_state == "Disconnected": if loop_now - _EV_PLUG_NUDGE_LAST.get(int(veh["id"]), 0.0) < EV_PLUG_NUDGE_COOLDOWN_S: diff --git a/db/migration/V109__ev_obs_geofence_trigger.sql b/db/migration/V109__ev_obs_geofence_trigger.sql new file mode 100644 index 0000000..68bedb7 --- /dev/null +++ b/db/migration/V109__ev_obs_geofence_trigger.sql @@ -0,0 +1,26 @@ +-- Geofence arrival trigger pro EV pozorování. +-- +-- Dosud arrival obs (ems.ev_vehicle_obs) vznikalo JEN z wallboxu (plug-in přes +-- fn_ev_session_transition). Když uživatel nepíchne, jízda se nezaznamenala a +-- spotřební forecast (ev_trip → ev_usage_stats) o ní nevěděl. +-- +-- Telemetry_collector už dnes z Tesla polohy (geofence, scope location, BEZ +-- buzení auta) detekuje přechod pryč→domů do ems.ev_presence_obs. Tato migrace +-- rozšiřuje povolené hodnoty ev_vehicle_obs.trigger o 'geofence_arrival', aby +-- presence cesta mohla zapsat příjezd i bez píchnutí do wallboxu. +-- +-- Zpětná kompatibilita: stávající hodnoty 'arrival' / 'departure' / 'manual' +-- zůstávají platné; přidává se jen nová hodnota. Žádná data se nemění. +-- Párování jízd (fn_ev_build_trips) bere 'geofence_arrival' jako platný arrival +-- (R__096); wallbox 'arrival' zůstává autoritativní, geofence je doplněk pro +-- případy, kdy auto stojí doma nepíchnuté. + +alter table ems.ev_vehicle_obs + drop constraint if exists ev_vehicle_obs_trigger_check; + +alter table ems.ev_vehicle_obs + add constraint ev_vehicle_obs_trigger_check + check (trigger in ('arrival', 'departure', 'manual', 'geofence_arrival')); + +comment on column ems.ev_vehicle_obs.trigger is +'Zdroj pozorování: arrival/departure z wallboxu (plug-in/out, autoritativní), manual ruční, geofence_arrival z Tesla polohy (přijel domů, nepíchnutý — auto vzhůru, čtení nebudí). geofence_arrival se páruje jako příjezd v fn_ev_build_trips.'; diff --git a/db/routines/R__096_fn_ev_usage.sql b/db/routines/R__096_fn_ev_usage.sql index 9c089d9..757d0e8 100644 --- a/db/routines/R__096_fn_ev_usage.sql +++ b/db/routines/R__096_fn_ev_usage.sql @@ -44,7 +44,7 @@ begin select a.* into v_arr from ems.ev_vehicle_obs a where a.vehicle_id = r.vehicle_id - and a.trigger = 'arrival' + and a.trigger in ('arrival', 'geofence_arrival') and a.observed_at > r.observed_at and a.odometer_km is not null order by a.observed_at @@ -79,6 +79,9 @@ begin end; $fn$; +comment on function ems.fn_ev_build_trips is +'Spáruje každý nespárovaný odjezd (trigger=departure) s nejbližším následujícím příjezdem téhož vozidla. Příjezd = trigger ''arrival'' (wallbox plug-in, autoritativní) NEBO ''geofence_arrival'' (Tesla poloha, auto přijelo domů nepíchnuté). km z odometru, kWh z ΔSoC.'; + -- Přepočet týdenního rytmu z jízd za lookback okno (plný přepočet, ne EMA — -- rebuild-friendly; jízdy s nabíjením cestou se počítají do km, ne do kWh). create or replace function ems.fn_update_ev_usage_stats(