Files
ems/db/migration/V110__ev_presence_notify.sql
Dusan Vojacek c03f9dd9d6 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>
2026-06-14 22:55:17 +02:00

225 lines
8.6 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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 ~2030 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 2030 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íltolerance) + (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.';