-- 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.';