feat(ev): proaktivní notifikace 'píchni auto' (default-off)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
112
backend/services/ev_presence_notify.py
Normal file
112
backend/services/ev_presence_notify.py
Normal file
@@ -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)
|
||||
224
db/migration/V110__ev_presence_notify.sql
Normal file
224
db/migration/V110__ev_presence_notify.sql
Normal file
@@ -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.';
|
||||
@@ -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ů
|
||||
|
||||
Reference in New Issue
Block a user