feat(ev): geofence arrival trigger (default-off)

ev_vehicle_obs.trigger += 'geofence_arrival' (V109); presence cesta zapíše příjezd
i bez píchnutí (za flagem EV_GEOFENCE_ARRIVAL_OBS_ENABLED, default OFF); fn_ev_build_trips
páruje. Constraint name ověřen živě. Worktree agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-14 22:55:17 +02:00
parent a32839bf67
commit fc6d9833a7
3 changed files with 115 additions and 1 deletions

View File

@@ -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:

View File

@@ -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.';

View File

@@ -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(