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

209 lines
6.6 KiB
PL/PgSQL

-- EV spotřební forecast (SQL-first): vložení pozorování, párování jízd,
-- statistiky per DOW, odvození target SoC a očekávaného odjezdu.
create or replace function ems.fn_ev_vehicle_obs_insert(
p_site_id int,
p_vehicle_id int,
p_trigger text,
p_odometer_km numeric,
p_soc_pct numeric,
p_charging_state text
)
returns bigint
language sql
as $fn$
insert into ems.ev_vehicle_obs (
site_id, vehicle_id, trigger, odometer_km, soc_pct, charging_state
)
values (
p_site_id, p_vehicle_id, p_trigger, p_odometer_km, p_soc_pct, p_charging_state
)
returning id;
$fn$;
-- Spáruje odjezdy s nejbližším následujícím příjezdem téhož vozidla.
create or replace function ems.fn_ev_build_trips()
returns int
language plpgsql
as $fn$
declare
v_count int := 0;
r record;
v_arr record;
begin
for r in
select o.*
from ems.ev_vehicle_obs o
where o.trigger = 'departure'
and o.odometer_km is not null
and not exists (
select 1 from ems.ev_trip t where t.departure_obs_id = o.id
)
order by o.vehicle_id, o.observed_at
loop
select a.* into v_arr
from ems.ev_vehicle_obs a
where a.vehicle_id = r.vehicle_id
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
limit 1;
if v_arr.id is null then
continue; -- jízda ještě neskončila
end if;
insert into ems.ev_trip (
vehicle_id, departure_obs_id, arrival_obs_id,
departed_at, arrived_at, km, kwh_est, charged_away
)
select
r.vehicle_id, r.id, v_arr.id,
r.observed_at, v_arr.observed_at,
greatest(0, v_arr.odometer_km - r.odometer_km),
case
when r.soc_pct is null or v_arr.soc_pct is null then null
when v_arr.soc_pct > r.soc_pct then null -- nabíjeno cestou
else round(((r.soc_pct - v_arr.soc_pct) / 100.0
* av.battery_capacity_kwh)::numeric, 2)
end,
coalesce(v_arr.soc_pct > r.soc_pct, false)
from ems.asset_vehicle av
where av.id = r.vehicle_id
on conflict (departure_obs_id) do nothing;
v_count := v_count + 1;
end loop;
return v_count;
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(
p_lookback_days int default 60
)
returns int
language plpgsql
as $fn$
declare
v_built int;
v_count int;
begin
v_built := ems.fn_ev_build_trips();
with daily as (
select
t.vehicle_id,
(t.departed_at at time zone 'Europe/Prague')::date as d,
extract(dow from t.departed_at at time zone 'Europe/Prague')::int as dow,
sum(t.kwh_est) filter (where not t.charged_away) as day_kwh,
sum(t.km) as day_km,
min(extract(hour from t.departed_at at time zone 'Europe/Prague')
+ extract(minute from t.departed_at at time zone 'Europe/Prague') / 60.0
) as first_departure_hour
from ems.ev_trip t
where t.departed_at >= now() - make_interval(days => p_lookback_days)
and t.km >= 1.0 -- přepojení kabelu bez jízdy nepočítat
group by 1, 2, 3
),
agg as (
select
vehicle_id, dow,
avg(day_kwh) as avg_kwh,
stddev(day_kwh) as sd_kwh,
avg(day_km) as avg_km,
avg(first_departure_hour) as avg_dep,
count(*) as samples
from daily
group by 1, 2
)
insert into ems.ev_usage_stats (
vehicle_id, day_of_week, avg_day_kwh, stddev_day_kwh,
avg_day_km, avg_departure_hour, sample_count, last_updated
)
select
vehicle_id, dow, round(avg_kwh::numeric, 2), round(coalesce(sd_kwh, 0)::numeric, 2),
round(avg_km::numeric, 1), round(avg_dep::numeric, 2), samples, now()
from agg
on conflict (vehicle_id, day_of_week) do update set
avg_day_kwh = excluded.avg_day_kwh,
stddev_day_kwh = excluded.stddev_day_kwh,
avg_day_km = excluded.avg_day_km,
avg_departure_hour = excluded.avg_departure_hour,
sample_count = excluded.sample_count,
last_updated = now();
get diagnostics v_count = row_count;
return v_count;
end;
$fn$;
comment on function ems.fn_update_ev_usage_stats is
'Spáruje nové jízdy (fn_ev_build_trips) a přepočte ev_usage_stats za lookback okno. Job 00:50.';
-- Příští očekávaný den s jízdou (>= 3 km průměru a >= 4 vzorky) v horizontu 7 dní.
create or replace function ems.fn_ev_next_departure(
p_vehicle_id int,
p_from timestamptz default now()
)
returns timestamptz
language sql
stable
as $fn$
select min(dep)
from (
select (
((p_from at time zone 'Europe/Prague')::date + offs)::timestamp
+ make_interval(mins => (round(s.avg_departure_hour * 60))::int)
) at time zone 'Europe/Prague' as dep
from generate_series(0, 7) offs
join ems.ev_usage_stats s
on s.vehicle_id = p_vehicle_id
and s.day_of_week = extract(
dow from (p_from at time zone 'Europe/Prague')::date + offs
)::int
where s.sample_count >= 4
and s.avg_day_km >= 3.0
) cand
where cand.dep > p_from + interval '30 minutes';
$fn$;
comment on function ems.fn_ev_next_departure is
'Nejbližší typický odjezd vozidla dle ev_usage_stats (DOW s >=4 vzorky a >=3 km). NULL = málo dat → volající použije default_deadline_hour.';
-- Target SoC pro odjezd v daný den: P80 denní spotřeby + rezerva 10 p.b.,
-- clamp do [min_target_soc_pct, 100]. NULL = málo dat.
create or replace function ems.fn_ev_required_soc(
p_vehicle_id int,
p_departure_at timestamptz
)
returns numeric
language sql
stable
as $fn$
select least(100.0, greatest(
av.min_target_soc_pct,
ceil(
(s.avg_day_kwh + 0.8416 * s.stddev_day_kwh) -- ~P80
/ nullif(av.battery_capacity_kwh, 0) * 100.0
) + 10.0
))
from ems.asset_vehicle av
join ems.ev_usage_stats s
on s.vehicle_id = av.id
and s.day_of_week = extract(
dow from p_departure_at at time zone 'Europe/Prague'
)::int
where av.id = p_vehicle_id
and s.sample_count >= 4
and s.avg_day_kwh is not null;
$fn$;
comment on function ems.fn_ev_required_soc is
'Cílové SoC (%) pro odjezd: P80 spotřeby dne v týdnu + 10 p.b. rezerva, mez [min_target_soc_pct, 100]. NULL = málo dat → default_target_soc_pct.';