-- sloty pro LP: ceny, forecast, baseline, EV připojení + masky allow_charge / allow_discharge_export create or replace function ems.fn_load_planning_slots_full( p_site_id int, p_from timestamptz, p_to timestamptz, p_current_soc_wh numeric ) returns table ( slot_ord int, interval_start timestamptz, buy_price numeric, sell_price numeric, is_predicted_price boolean, pv_a_forecast_w int, pv_b_forecast_w int, load_baseline_w int, ev1_connected boolean, ev2_connected boolean, allow_charge boolean, allow_discharge_export boolean, night_baseload_target_wh numeric, night_baseload_buffer_wh numeric, safety_soc_target_wh numeric, future_avoided_buy_czk_kwh numeric, future_sell_opportunity_czk_kwh numeric, is_daytime_pv_surplus_slot boolean ) language plpgsql volatile as $fn$ declare v_charge_buf numeric; v_discharge_buf numeric; v_usable numeric; v_min_soc_wh numeric; v_soc_max_wh numeric; v_energy_to_fill numeric; v_exportable numeric; v_charge_eff numeric; v_discharge_eff numeric; v_max_charge_w numeric; v_max_discharge_w numeric; v_per_slot_charge_wh numeric; v_per_slot_discharge_wh numeric; v_grid_target_wh numeric; v_discharge_target_wh numeric; v_cum numeric; r_slot record; v_n_am int; v_n_pm int; v_chg_am_wh numeric; v_chg_pm_wh numeric; v_dis_am_wh numeric; v_dis_pm_wh numeric; v_reserve_wh numeric; v_daytime_en boolean; v_night_buf_pct numeric; begin drop table if exists _ems_plan_slot_wk; create temp table _ems_plan_slot_wk on commit drop as with prof as ( select ems.fn_pv_forecast_delta_profile( p_site_id, greatest(p_from, now() - interval '120 days'), now() ) as j ), delta_unnest as ( select (kv.key)::int as pv_array_id, (x->>'slot_of_day')::int as slot_of_day, (x->>'delta_w')::int as delta_w from prof cross join lateral jsonb_each((prof.j)->'deltas_by_array') kv(key, value) cross join lateral jsonb_array_elements(kv.value->'deltas') x ), legacy_slot_delta as ( select (x->>'slot_of_day')::int as slot_of_day, (x->>'delta_w')::int as delta_w from prof cross join lateral jsonb_array_elements((prof.j)->'deltas') x ), slot_spine as ( select gs as interval_start from generate_series( p_from, (p_to - interval '15 minutes')::timestamptz, interval '15 minutes' ) as gs ) select (row_number() over (order by s.interval_start) - 1)::int as slot_ord, s.interval_start, coalesce( ep.effective_buy_price_czk_kwh, ems.fn_get_predicted_price(p_site_id, s.interval_start) ) as buy_price, coalesce( ep.effective_sell_price_czk_kwh, ems.fn_get_predicted_price(p_site_id, s.interval_start) * 0.85 ) as sell_price, (ep.effective_buy_price_czk_kwh is null) as is_predicted_price, coalesce(fpi_a.power_w, 0)::int as pv_a_forecast_w, coalesce(fpi_b.power_w, 0)::int as pv_b_forecast_w, coalesce( ( select bs.avg_power_w from ems.consumption_baseline_stats bs where bs.site_id = p_site_id and bs.day_of_week = extract( dow from s.interval_start at time zone 'Europe/Prague' )::int and bs.hour_of_day = extract( hour from s.interval_start at time zone 'Europe/Prague' )::int limit 1 ), 500 )::int as load_baseline_w, (coalesce(ev1.status, 'available') not in ('available', 'unavailable')) as ev1_connected, (coalesce(ev2.status, 'available') not in ('available', 'unavailable')) as ev2_connected, greatest( 0, coalesce(fpi_a.power_w, 0) + coalesce(fpi_b.power_w, 0) - coalesce( ( select bs.avg_power_w from ems.consumption_baseline_stats bs where bs.site_id = p_site_id and bs.day_of_week = extract( dow from s.interval_start at time zone 'Europe/Prague' )::int and bs.hour_of_day = extract( hour from s.interval_start at time zone 'Europe/Prague' )::int limit 1 ), 500 ) )::int as pv_surplus_w, false::boolean as allow_charge, false::boolean as allow_discharge_export from slot_spine s left join ems.vw_site_effective_price ep on ep.site_id = p_site_id and ep.interval_start = s.interval_start left join lateral ( with uq as ( select distinct on (apa.id) apa.id as pv_array_id, fpi.power_w from ems.asset_pv_array apa join ems.forecast_pv_run fpr on fpr.pv_array_id = apa.id and fpr.site_id = apa.site_id and fpr.status = 'ok' join ems.forecast_pv_interval fpi on fpi.run_id = fpr.id and fpi.pv_array_id = apa.id and fpi.interval_start = s.interval_start where apa.site_id = p_site_id and apa.controllable is true order by apa.id, fpr.created_at desc ), slot_of as ( select ( (extract(hour from (s.interval_start at time zone 'Europe/Prague'))::int * 60) + extract(minute from (s.interval_start at time zone 'Europe/Prague'))::int ) / 15 as slot_of_day ), tot as (select coalesce(sum(uq.power_w), 0)::numeric as w from uq) select coalesce(sum( greatest( 0, uq.power_w - coalesce( du.delta_w, case when exists (select 1 from delta_unnest limit 1) then null else round( ld.delta_w::numeric * uq.power_w::numeric / nullif((select w from tot), 0) )::int end, 0 ) ) ), 0)::int as power_w from uq cross join slot_of cross join tot left join delta_unnest du on du.pv_array_id = uq.pv_array_id and du.slot_of_day = slot_of.slot_of_day left join legacy_slot_delta ld on ld.slot_of_day = slot_of.slot_of_day ) fpi_a on true left join lateral ( with uq as ( select distinct on (apa.id) apa.id as pv_array_id, fpi.power_w from ems.asset_pv_array apa join ems.forecast_pv_run fpr on fpr.pv_array_id = apa.id and fpr.site_id = apa.site_id and fpr.status = 'ok' join ems.forecast_pv_interval fpi on fpi.run_id = fpr.id and fpi.pv_array_id = apa.id and fpi.interval_start = s.interval_start where apa.site_id = p_site_id and apa.controllable is false order by apa.id, fpr.created_at desc ), slot_of as ( select ( (extract(hour from (s.interval_start at time zone 'Europe/Prague'))::int * 60) + extract(minute from (s.interval_start at time zone 'Europe/Prague'))::int ) / 15 as slot_of_day ), tot as (select coalesce(sum(uq.power_w), 0)::numeric as w from uq) select coalesce(sum( greatest( 0, uq.power_w - coalesce( du.delta_w, case when exists (select 1 from delta_unnest limit 1) then null else round( ld.delta_w::numeric * uq.power_w::numeric / nullif((select w from tot), 0) )::int end, 0 ) ) ), 0)::int as power_w from uq cross join slot_of cross join tot left join delta_unnest du on du.pv_array_id = uq.pv_array_id and du.slot_of_day = slot_of.slot_of_day left join legacy_slot_delta ld on ld.slot_of_day = slot_of.slot_of_day ) fpi_b on true left join lateral ( select t.status from ems.telemetry_ev_charger t join ems.asset_ev_charger ch on ch.id = t.charger_id where t.site_id = p_site_id and ch.code = 'ev-charger-1' order by t.measured_at desc limit 1 ) ev1 on true left join lateral ( select t.status from ems.telemetry_ev_charger t join ems.asset_ev_charger ch on ch.id = t.charger_id where t.site_id = p_site_id and ch.code = 'ev-charger-2' order by t.measured_at desc limit 1 ) ev2 on true; if not exists (select 1 from _ems_plan_slot_wk) then raise exception 'No planning slots available – check market prices and horizon settings'; end if; select coalesce(ab.charge_slot_buffer, 0::numeric), coalesce(ab.discharge_slot_buffer, 0::numeric), ab.usable_capacity_wh::numeric, (ab.min_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric, (coalesce(ab.planner_max_soc_percent, ab.max_soc_percent) / 100.0 * ab.usable_capacity_wh)::numeric, greatest(coalesce(ab.charge_efficiency, 1::numeric), 0.0001::numeric), least( coalesce(ai.max_battery_charge_w, ai.max_charge_power_w), coalesce( ab.bms_max_charge_w, case when ab.max_charge_c_rate is not null then (ab.max_charge_c_rate * ab.usable_capacity_wh)::bigint end, coalesce(ai.max_battery_charge_w, ai.max_charge_power_w) ) )::numeric, least( coalesce(ai.max_battery_discharge_w, ai.max_discharge_power_w), coalesce( ab.bms_max_discharge_w, case when ab.max_discharge_c_rate is not null then (ab.max_discharge_c_rate * ab.usable_capacity_wh)::bigint end, coalesce(ai.max_battery_discharge_w, ai.max_discharge_power_w) ) )::numeric, greatest(coalesce(ab.discharge_efficiency, 1::numeric), 0.0001::numeric), (ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric, coalesce(ab.planner_daytime_charge_target_enabled, true), coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric) into v_charge_buf, v_discharge_buf, v_usable, v_min_soc_wh, v_soc_max_wh, v_charge_eff, v_max_charge_w, v_max_discharge_w, v_discharge_eff, v_reserve_wh, v_daytime_en, v_night_buf_pct from ems.asset_battery ab join ems.asset_inverter ai on ai.id = ab.inverter_id and ai.site_id = ab.site_id where ab.site_id = p_site_id order by ab.id limit 1; if v_usable is null then raise exception 'No asset_battery for site_id=%', p_site_id; end if; v_per_slot_charge_wh := v_max_charge_w * v_charge_eff * 0.25; v_per_slot_discharge_wh := v_max_discharge_w * v_discharge_eff * 0.25; v_energy_to_fill := v_soc_max_wh - p_current_soc_wh; v_exportable := v_soc_max_wh - v_min_soc_wh; v_grid_target_wh := v_energy_to_fill * v_charge_buf; v_discharge_target_wh := v_exportable * v_discharge_buf; -- Rozpočet na půl dne (Europe/Prague): 00:00–12:00 vs 12:00–24:00; chybějící segment dostane celý budget. -- Nabíjecí rozpočet dál dělíme 50/50 (kvůli rozprostření v rámci dne), ale exportní vybíjení volíme globálně podle sell_price. select coalesce( count(*) filter ( where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12 ), 0 )::int, coalesce( count(*) filter ( where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12 ), 0 )::int into v_n_am, v_n_pm from _ems_plan_slot_wk wk; if v_n_am <= 0 then v_chg_am_wh := 0; v_chg_pm_wh := v_grid_target_wh; elsif v_n_pm <= 0 then v_chg_am_wh := v_grid_target_wh; v_chg_pm_wh := 0; else v_chg_am_wh := v_grid_target_wh / 2.0; v_chg_pm_wh := v_grid_target_wh - v_chg_am_wh; end if; -- charge mask (sloupce temp tabulky kvalifikujeme: RETURNS TABLE dělá PL proměnné stejných jmen) if v_charge_buf <= 0 then update _ems_plan_slot_wk wk set allow_charge = true; elsif v_energy_to_fill <= 0 then -- Pokud rolling replan startuje s baterií plnou, nechceme zablokovat budoucí nabíjení po vybití. -- Povolit alespoň nabíjení v PV surplus slotech, aby solver mohl vytvořit headroom a pak ho znovu zaplnit z FVE. update _ems_plan_slot_wk wk set allow_charge = (wk.pv_surplus_w > 0); else update _ems_plan_slot_wk wk set allow_charge = (wk.pv_surplus_w > 0); v_cum := 0; for r_slot in select wk.slot_ord from _ems_plan_slot_wk wk where wk.pv_surplus_w <= 0 and extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12 order by wk.buy_price, wk.slot_ord loop exit when v_cum >= v_chg_am_wh; exit when v_per_slot_charge_wh <= 0; update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord; v_cum := v_cum + v_per_slot_charge_wh; end loop; v_cum := 0; for r_slot in select wk.slot_ord from _ems_plan_slot_wk wk where wk.pv_surplus_w <= 0 and extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12 order by wk.buy_price, wk.slot_ord loop exit when v_cum >= v_chg_pm_wh; exit when v_per_slot_charge_wh <= 0; update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord; v_cum := v_cum + v_per_slot_charge_wh; end loop; end if; -- discharge-export mask if v_discharge_buf <= 0 then update _ems_plan_slot_wk wk set allow_discharge_export = true; elsif v_exportable <= 0 then update _ems_plan_slot_wk wk set allow_discharge_export = false; else update _ems_plan_slot_wk wk set allow_discharge_export = false; v_cum := 0; for r_slot in select wk.slot_ord from _ems_plan_slot_wk wk order by wk.sell_price desc, wk.slot_ord desc loop exit when v_cum >= v_discharge_target_wh; exit when v_per_slot_discharge_wh <= 0; update _ems_plan_slot_wk wk set allow_discharge_export = true where wk.slot_ord = r_slot.slot_ord; v_cum := v_cum + v_per_slot_discharge_wh; end loop; end if; return query with night_tot as ( select coalesce(sum(w2.load_baseline_w), 0) * 0.25 as night_wh from _ems_plan_slot_wk w2 where extract(hour from w2.interval_start at time zone 'Europe/Prague') >= 20 or extract(hour from w2.interval_start at time zone 'Europe/Prague') < 6 ), enriched as ( select w.slot_ord, w.interval_start, w.buy_price, w.sell_price, w.is_predicted_price, w.pv_a_forecast_w, w.pv_b_forecast_w, w.load_baseline_w, w.ev1_connected, w.ev2_connected, w.allow_charge, w.allow_discharge_export, nt.night_wh as night_baseload_target_wh, nt.night_wh * (v_night_buf_pct / 100.0) as night_baseload_buffer_wh, case when not v_daytime_en then null::numeric when extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 19 then least( v_soc_max_wh, v_reserve_wh + (nt.night_wh + nt.night_wh * (v_night_buf_pct / 100.0)) * greatest( 0::numeric, least( 1::numeric, ( extract(hour from w.interval_start at time zone 'Europe/Prague')::numeric + ( extract(minute from w.interval_start at time zone 'Europe/Prague')::numeric / 60.0 ) - 6.0 ) / 14.0 ) ) ) else null::numeric end as safety_soc_target_wh, coalesce( max(w.buy_price) over ( order by w.slot_ord rows between 1 following and unbounded following ), w.buy_price ) as future_avoided_buy_czk_kwh, coalesce( max(w.sell_price) over ( order by w.slot_ord rows between 1 following and unbounded following ), w.sell_price ) as future_sell_opportunity_czk_kwh, ( extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 18 and w.pv_surplus_w > 0 ) as is_daytime_pv_surplus_slot from _ems_plan_slot_wk w cross join night_tot nt ) select e.slot_ord, e.interval_start, e.buy_price, e.sell_price, e.is_predicted_price, e.pv_a_forecast_w, e.pv_b_forecast_w, e.load_baseline_w, e.ev1_connected, e.ev2_connected, e.allow_charge, e.allow_discharge_export, e.night_baseload_target_wh, e.night_baseload_buffer_wh, e.safety_soc_target_wh, e.future_avoided_buy_czk_kwh, e.future_sell_opportunity_czk_kwh, e.is_daytime_pv_surplus_slot from enriched e order by e.slot_ord; end; $fn$; comment on function ems.fn_load_planning_slots_full(int, timestamptz, timestamptz, numeric) is '15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). ' 'Masky charge/discharge-export se berou zvlášť pro 00–12 a 12–24 Europe/Prague (polovina budgetu na segment). ' 'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent). ' 'Denní safety vstupy: night_baseload_* (20:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), ' 'lookahead max buy/sell pro měkké LP penalizace.';