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>
209 lines
6.6 KiB
PL/PgSQL
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.';
|