-- 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 ) language plpgsql stable 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; begin drop table if exists _ems_plan_slot_wk; create temp table _ems_plan_slot_wk on commit drop as with 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 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 ( select coalesce(sum(u.power_w), 0)::int as power_w from ( select distinct on (apa.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 ) u ) fpi_a on true left join lateral ( select coalesce(sum(u.power_w), 0)::int as power_w from ( select distinct on (apa.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 ) u ) 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, (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) 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 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; -- charge mask if v_charge_buf <= 0 then update _ems_plan_slot_wk set allow_charge = true; elsif v_energy_to_fill <= 0 then update _ems_plan_slot_wk set allow_charge = false; else update _ems_plan_slot_wk set allow_charge = (pv_surplus_w > 0); v_cum := 0; for r_slot in select slot_ord from _ems_plan_slot_wk where pv_surplus_w <= 0 order by buy_price, slot_ord loop exit when v_cum >= v_grid_target_wh; exit when v_per_slot_charge_wh <= 0; update _ems_plan_slot_wk set allow_charge = true where 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 set allow_discharge_export = true; elsif v_exportable <= 0 then update _ems_plan_slot_wk set allow_discharge_export = false; else update _ems_plan_slot_wk set allow_discharge_export = false; v_cum := 0; for r_slot in select slot_ord from _ems_plan_slot_wk order by sell_price desc, 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 set allow_discharge_export = true where slot_ord = r_slot.slot_ord; v_cum := v_cum + v_per_slot_discharge_wh; end loop; end if; return query 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 from _ems_plan_slot_wk w order by w.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).';