From c03f9dd9d6c2c3c3bb82d5c2954ed5731da0f225 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 14 Jun 2026 22:55:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(ev):=20proaktivn=C3=AD=20notifikace=20'p?= =?UTF-8?q?=C3=ADchni=20auto'=20(default-off)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit job ev_presence_notify + fn_ev_presence_nudge_due (SQL-first rozhodnutí+dedup); asset_vehicle.presence_nudge_enabled default false=inertní (V110). Worktree agent. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/lifespan.py | 21 ++ backend/services/ev_presence_notify.py | 112 +++++++++++ db/migration/V110__ev_presence_notify.sql | 224 ++++++++++++++++++++++ docs/04-modules/ev-charging.md | 17 ++ 4 files changed, 374 insertions(+) create mode 100644 backend/services/ev_presence_notify.py create mode 100644 db/migration/V110__ev_presence_notify.sql diff --git a/backend/app/lifespan.py b/backend/app/lifespan.py index 74b4d9c..94e82fb 100644 --- a/backend/app/lifespan.py +++ b/backend/app/lifespan.py @@ -30,6 +30,7 @@ from services.signal_service import ( run_signal_outbound_send_for_active_sites, run_signal_outbound_verify_for_active_sites, ) +from services.ev_presence_notify import run_ev_presence_nudge_for_all_active_sites logger = logging.getLogger(__name__) @@ -161,6 +162,18 @@ async def lifespan(app: FastAPI): except Exception: logger.exception("scheduled_signal_outbound_verify failed") + async def scheduled_ev_presence_nudge() -> None: + """Proaktivní "auto doma + nepíchnuté + levné/přebytek → píchni ho". + + SQL-first rozhodnutí + dedup v ems.fn_ev_presence_nudge_due (insert do + ev_presence_nudge_sent). Default-off per vozidlo (presence_nudge_enabled), + takže job běží inertně, dokud se na nějakém vozidle nezapne. + """ + try: + await run_ev_presence_nudge_for_all_active_sites(app.state.pg_pool) + except Exception: + logger.exception("scheduled_ev_presence_nudge failed") + async def scheduled_pool_control() -> None: # Bazén: SQL-first rozhodnutí (fn_pool_control_tick) — nejlevnější souvislé # okno denního runtime + dump-load při sell<=0; zařadí POOL_PUMP_ON (jen když @@ -437,6 +450,14 @@ async def lifespan(app: FastAPI): id="pool_control", replace_existing=True, ) + scheduler.add_job( + scheduled_ev_presence_nudge, + "cron", + minute="5,30,55", + second=10, + id="ev_presence_nudge", + replace_existing=True, + ) scheduler.add_job(scheduled_daily_plan, "cron", hour=15, minute=0, id="daily_plan") scheduler.add_job( scheduled_rolling_replan, diff --git a/backend/services/ev_presence_notify.py b/backend/services/ev_presence_notify.py new file mode 100644 index 0000000..1e783c8 --- /dev/null +++ b/backend/services/ev_presence_notify.py @@ -0,0 +1,112 @@ +"""Proaktivní notifikace "auto doma + nepíchnuté + levné/přebytek → píchni ho". + +Tenký orchestrátor: veškerá doménová logika (kdo je doma, odpojený, výhodná cena, +SoC pod cílem) i dedup jsou v ems.fn_ev_presence_nudge_due(). Python jen zavolá +funkci pro každou aktivní lokalitu a pro každý vrácený (= nově due, ještě +neposlaný) řádek pošle jeden Discord nudge. + +Dedup je čistě v DB: funkce zapíše řádek do ems.ev_presence_nudge_sent +(on conflict do nothing) a vrátí jen ty, kterým insert skutečně prošel — tedy +jeden nudge na "epizodu" auta doma+odpojeno. Opakované 20–30min ticky proto +nespamují, dokud se auto nepíchne nebo neodjede (čímž se klíč epizody změní). + +DEFAULT-OFF: funkce nevrátí nic, dokud není na vozidle +asset_vehicle.presence_nudge_enabled = true. Job tedy běží inertně. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import asyncpg + +from app.db_json import fetch_json +from services.notification_service import send_discord + +logger = logging.getLogger(__name__) + + +def _fmt_price(value: Any) -> str: + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return "?" + + +def _build_message(row: asyncpg.Record) -> str: + name = row["vehicle_name"] or "EV" + reason = str(row["trigger_reason"] or "") + sell = row["effective_sell_price_czk_kwh"] + buy = row["effective_buy_price_czk_kwh"] + soc = row["battery_level_pct"] + tgt = row["target_soc_pct"] + + if reason == "NEG_OR_ZERO_SELL": + why = f"výkup je teď {_fmt_price(sell)} Kč/kWh (≤ 0) — přebytek se hodí do auta" + else: + why = f"nákup je teď levný: {_fmt_price(buy)} Kč/kWh" + + soc_line = "" + if soc is not None: + soc_line = f"\nBaterie auta: **{_fmt_price(soc)} %**" + ( + f" (cíl {_fmt_price(tgt)} %)" if tgt is not None else "" + ) + + return ( + f"🚗 **{name} je doma a nepíchnuté** — {why}.{soc_line}\n" + f"Píchni ho a plán se o zbytek postará (přebytky / levné sloty)." + ) + + +async def run_ev_presence_nudge_for_site( + site_id: int, conn: asyncpg.Connection +) -> int: + """Jedna lokalita: zavolá fn (dedup v DB) a pošle Discord pro každé due vozidlo. + + Vrátí počet odeslaných notifikací. + """ + try: + rows = await conn.fetch( + "select * from ems.fn_ev_presence_nudge_due($1::int)", + site_id, + ) + except Exception: + logger.exception( + "ev_presence_nudge: fn_ev_presence_nudge_due failed site=%s", site_id + ) + return 0 + + sent = 0 + for row in rows: + try: + await send_discord(conn, site_id, _build_message(row), level="info") + sent += 1 + logger.info( + "ev_presence_nudge sent site=%s vehicle=%s reason=%s", + site_id, + row["vehicle_id"], + row["trigger_reason"], + ) + except Exception: + logger.exception( + "ev_presence_nudge: Discord send failed site=%s vehicle=%s", + site_id, + row["vehicle_id"], + ) + return sent + + +async def run_ev_presence_nudge_for_all_active_sites(pool: asyncpg.Pool) -> None: + """Scheduler entrypoint: projde aktivní lokality a pošle proaktivní nudge.""" + async with pool.acquire() as conn: + raw = await fetch_json(conn, "select ems.fn_vw_site_directory_active()") + sites = raw if isinstance(raw, list) else [] + for site in sites: + if not isinstance(site, dict) or site.get("id") is None: + continue + site_id = int(site["id"]) + try: + await run_ev_presence_nudge_for_site(site_id, conn) + except Exception: + logger.exception("ev_presence_nudge site=%s failed", site_id) diff --git a/db/migration/V110__ev_presence_notify.sql b/db/migration/V110__ev_presence_notify.sql new file mode 100644 index 0000000..ac787bf --- /dev/null +++ b/db/migration/V110__ev_presence_notify.sql @@ -0,0 +1,224 @@ +-- Proaktivní notifikace "auto doma + nepíchnuté + levné/přebytek → píchni ho". +-- +-- Na rozdíl od arrival nudge v telemetry_collector (jen edge příjezd) tohle běží +-- periodicky (scheduler ~20–30 min) a upozorní, i když auto bylo doma už dřív, +-- ale je výhodné ho teď píchnout (sell<=0 NEBO velmi levný buy). +-- +-- SQL-first: rozhodnutí + dedup je v ems.fn_ev_presence_nudge_due(); Python jen IO/Discord. +-- DEFAULT-OFF: per vozidlo flag asset_vehicle.presence_nudge_enabled (default false) → +-- funkce nikoho nevrátí, dokud se na vozidle explicitně nezapne. Inertní pro golden gate. + +-- 1) SoC z presence pozorování (zatím plní jen budoucí telemetrie; NULL = neznámé). +alter table ems.ev_presence_obs + add column if not exists battery_level_pct numeric(5, 2); + +comment on column ems.ev_presence_obs.battery_level_pct is +'SoC trakční baterie vozidla v % z presence pollu (jen když je auto online a vrací charge_state). NULL = neznámé (auto spí / poloha bez SoC).'; + +-- 2) Per vozidlo: zapnutí proaktivní notifikace + cílový SoC práh pro nudge. +alter table ems.asset_vehicle + add column if not exists presence_nudge_enabled boolean not null default false; + +alter table ems.asset_vehicle + add column if not exists presence_nudge_soc_tolerance_pct numeric(5, 2) not null default 5; + +comment on column ems.asset_vehicle.presence_nudge_enabled is +'Zapne proaktivní Discord notifikaci "auto doma a nepíchnuté + levné/přebytek → píchni ho" (job ev_presence_notify). Default false = inertní.'; + +comment on column ems.asset_vehicle.presence_nudge_soc_tolerance_pct is +'Tolerance pod cílovým SoC: nudge se pošle jen když známé SoC < (default_target_soc_pct − tato tolerance). Pokud je SoC neznámé (NULL), prahem se neblokuje.'; + +-- 3) Dedup: jedno potvrzení odeslaného nudge na "epizodu" (vozidlo + klíč stavu). +-- nudge_key = observed_at začátku epizody, kdy auto JE doma a JE odpojené. +-- Dokud epizoda trvá (stejný start), klíč se nemění → on conflict do nothing tlumí +-- opakování každých 20–30 min. Po píchnutí / odjezdu epizoda končí; nový příjezd = +-- nový observed_at = nový klíč = nudge se znovu nabije. +create table if not exists ems.ev_presence_nudge_sent ( + vehicle_id int not null references ems.asset_vehicle (id), + nudge_key timestamptz not null, + sent_at timestamptz not null default now(), + primary key (vehicle_id, nudge_key) +); + +create index if not exists idx_ev_presence_nudge_sent_sent_at + on ems.ev_presence_nudge_sent (sent_at desc); + +comment on table ems.ev_presence_nudge_sent is +'Dedup proaktivních "píchni auto" notifikací: PK vehicle_id + nudge_key (start epizody doma+odpojeno). Jeden nudge na epizodu; po píchnutí/odjezdu se klíč přirozeně změní.'; + +-- 4) Rozhodovací funkce: vrátí vozidla, kde je teď výhodné píchnout, a zapíše dedup. +-- Podmínky (vše musí platit): +-- - vozidlo aktivní a presence_nudge_enabled = true, +-- - poslední pozorování se známou polohou: at_home = true, +-- - charging_state značí odpojeno (Disconnected / NoPower / null po příjezdu), +-- - žádná otevřená ev_session (auto reálně není na wallboxu), +-- - SoC neznámé NEBO SoC < (target − tolerance), +-- - aktuální 15min slot: efektivní sell <= 0 NEBO efektivní buy <= práh +-- (levný buy = pod p_cheap_buy_max_czk_kwh; statický práh, žádný kód zařízení). +-- Dedup: insert do ev_presence_nudge_sent (on conflict do nothing); vrací jen řádky, +-- pro které insert skutečně proběhl (= ještě neposláno pro tuto epizodu). +create or replace function ems.fn_ev_presence_nudge_due( + p_site_id int, + p_now timestamptz default now(), + p_cheap_buy_max_czk_kwh numeric default 1.50 +) +returns table ( + vehicle_id int, + vehicle_name text, + site_id int, + site_code text, + at_home boolean, + battery_level_pct numeric, + target_soc_pct numeric, + charging_state text, + effective_buy_price_czk_kwh numeric, + effective_sell_price_czk_kwh numeric, + trigger_reason text, + nudge_key timestamptz +) +language sql +volatile +as $fn$ + with slot as ( + -- aktuální 15min slot v UTC (zarovnání po Europe/Prague hranicích řeší boundary fn) + select ems.fn_planning_slot_boundary_prague(0, p_now) as interval_start + ), + veh as ( + select + v.id as vehicle_id, + v.name as vehicle_name, + v.site_id, + v.default_target_soc_pct, + v.presence_nudge_soc_tolerance_pct + from ems.asset_vehicle v + where v.site_id = p_site_id + and v.active = true + and v.presence_nudge_enabled = true + ), + last_obs as ( + -- poslední pozorování se ZNÁMOU polohou (at_home not null) per vozidlo + select distinct on (o.vehicle_id) + o.vehicle_id, + o.observed_at, + o.at_home, + o.charging_state, + o.battery_level_pct + from ems.ev_presence_obs o + join veh on veh.vehicle_id = o.vehicle_id + where o.at_home is not null + order by o.vehicle_id, o.observed_at desc + ), + episode as ( + -- poslední "zlom" epizody: nejnovější pozorování, kdy auto bylo pryč nebo připojené. + select + lo.vehicle_id, + coalesce( + ( + select max(o2.observed_at) + from ems.ev_presence_obs o2 + where o2.vehicle_id = lo.vehicle_id + and o2.observed_at <= lo.observed_at + and ( + o2.at_home is distinct from true + or ( + o2.charging_state is not null + and lower(o2.charging_state) not in ('disconnected', 'nopower') + ) + ) + ), + '-infinity'::timestamptz + ) as last_break_at + from last_obs lo + ), + episode_start as ( + select + ep.vehicle_id, + coalesce( + ( + select min(o3.observed_at) + from ems.ev_presence_obs o3 + where o3.vehicle_id = ep.vehicle_id + and o3.observed_at > ep.last_break_at + and o3.at_home = true + ), + -- fallback: žádný explicitní zlom v historii → ber poslední pozorování + (select observed_at from last_obs lo where lo.vehicle_id = ep.vehicle_id) + ) as nudge_key + from episode ep + ), + price as ( + select + ep.site_id, + ep.effective_buy_price_czk_kwh, + ep.effective_sell_price_czk_kwh + from ems.vw_site_effective_price ep, slot + where ep.site_id = p_site_id + and ep.interval_start = slot.interval_start + ), + due as ( + select + v.vehicle_id, + v.vehicle_name, + v.site_id, + lo.at_home, + lo.battery_level_pct, + v.default_target_soc_pct as target_soc_pct, + lo.charging_state, + pr.effective_buy_price_czk_kwh, + pr.effective_sell_price_czk_kwh, + es.nudge_key, + case + when pr.effective_sell_price_czk_kwh <= 0 then 'NEG_OR_ZERO_SELL' + else 'CHEAP_BUY' + end as trigger_reason + from veh v + join last_obs lo on lo.vehicle_id = v.vehicle_id + join episode_start es on es.vehicle_id = v.vehicle_id + cross join price pr + where lo.at_home = true + and ( + lo.charging_state is null + or lower(lo.charging_state) in ('disconnected', 'nopower') + ) + and not exists ( + select 1 + from ems.ev_session sess + where sess.vehicle_id = v.vehicle_id + and sess.session_end is null + ) + and ( + lo.battery_level_pct is null + or lo.battery_level_pct + < (coalesce(v.default_target_soc_pct, 80) - coalesce(v.presence_nudge_soc_tolerance_pct, 5)) + ) + and ( + pr.effective_sell_price_czk_kwh <= 0 + or pr.effective_buy_price_czk_kwh <= p_cheap_buy_max_czk_kwh + ) + ), + ins as ( + insert into ems.ev_presence_nudge_sent (vehicle_id, nudge_key) + select d.vehicle_id, d.nudge_key + from due d + on conflict (vehicle_id, nudge_key) do nothing + returning vehicle_id, nudge_key + ) + select + d.vehicle_id, + d.vehicle_name, + d.site_id, + (select s.code from ems.site s where s.id = d.site_id) as site_code, + d.at_home, + d.battery_level_pct, + d.target_soc_pct, + d.charging_state, + d.effective_buy_price_czk_kwh, + d.effective_sell_price_czk_kwh, + d.trigger_reason, + d.nudge_key + from due d + join ins on ins.vehicle_id = d.vehicle_id and ins.nudge_key = d.nudge_key; +$fn$; + +comment on function ems.fn_ev_presence_nudge_due is +'Proaktivní "píchni auto" notifikace: vozidla doma + odpojená + (SoC neznámé nebo < cíl−tolerance) + (efektivní sell<=0 nebo buy<=práh) v aktuálním 15min slotu. Default-off (asset_vehicle.presence_nudge_enabled). Dedup zápisem do ev_presence_nudge_sent (1 nudge na epizodu doma+odpojeno). Vrací jen nově due řádky pro Discord.'; diff --git a/docs/04-modules/ev-charging.md b/docs/04-modules/ev-charging.md index 999861c..972bfc7 100644 --- a/docs/04-modules/ev-charging.md +++ b/docs/04-modules/ev-charging.md @@ -256,6 +256,23 @@ plánovač slepý k pokroku → phantom 11 kW okna i u plného auta. Funguje pro poslední taper k 100 % (jinak věčné mini-dobíjení → cyklování nabíječky / Tesla notifikace). `charge_done_tolerance_pct = 0` → tvrdě na target. +### Geofence arrival trigger (V109, default-off) + +Příjezd domů se dnes zaznamenává jen z wallboxu (plug-in). `ev_vehicle_obs.trigger` +nově povoluje **`geofence_arrival`**: presence cesta (`telemetry_collector`, z Tesla +polohy bez buzení) při přechodu pryč→domů u **nepíchnutého** auta zapíše obs (SoC, +odometr) → `fn_ev_build_trips` (R__096) ji spáruje jako příjezd → spotřební forecast +ví o jízdě i bez píchnutí. Za env flagem `EV_GEOFENCE_ARRIVAL_OBS_ENABLED` (default +OFF), debounce 2 vzorky, dedup s wallbox arrival (plugged = wallbox autoritativní). + +### Proaktivní notifikace „píchni auto" (V110, default-off) + +Job `ev_presence_notify` (~25 min) pošle Discord nudge, když je auto **doma + +nepíchnuté + (SoC neznámé nebo < target − tolerance) + (efektivní sell ≤ 0 nebo buy +pod prahem)**. SQL-first: rozhodnutí + dedup v `ems.fn_ev_presence_nudge_due` (dedup +přes `ev_presence_nudge_sent`, 1 nudge na epizodu doma+odpojeno). Per-vozidlo flag +`asset_vehicle.presence_nudge_enabled` (default **false** = inertní). + --- ## Statistika příjezdů