-- sloty pro LP: ceny, forecast, baseline, EV připojení + masky allow_charge / allow_discharge_export -- DROP: změna RETURNS TABLE (nové sloupce) — CREATE OR REPLACE na rozdílný row type v PG neprojde. -- Musí být plná signatura (pg_proc ukládá int jako integer); DROP bez () funkci se směrem nemaže. drop function if exists ems.fn_load_planning_slots_full( integer, timestamp with time zone, timestamp with time zone, numeric ); 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, charge_acquisition_buy_czk_kwh numeric, charge_acquisition_cutoff_at timestamptz ) 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_reserve_wh numeric; v_daytime_en boolean; v_night_buf_pct numeric; v_degrad_czk_kwh numeric; v_ref_buy_czk_kwh numeric; v_ref_buy_am_czk_kwh numeric; v_ref_buy_pm_czk_kwh numeric; v_purchase_pricing_mode text; v_lookahead_slots int := 4; v_grid_charge_cap_am int; v_grid_charge_cap_pm int; v_buy_lookahead_eps numeric := 0.05; v_buy_charge_band_czk_kwh numeric := 0.40; v_grid_filled_wh numeric := 0; v_pv_layer_cap_wh numeric; v_grid_slots_am int := 0; v_grid_slots_pm int := 0; v_acquisition_cutoff timestamptz; v_charge_acquisition numeric; v_est_grid_wh numeric; v_est_pv_wh numeric; v_est_grid_cost numeric; v_est_pv_cost numeric; 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 ), pv_canon as ( select r.interval_start, coalesce(r.pv_a_forecast_canonical_w, 0)::int as pv_a_forecast_w, coalesce(r.pv_b_forecast_canonical_w, 0)::int as pv_b_forecast_w from jsonb_to_recordset( ems.fn_forecast_pv_slots_range_canonical_ab( p_site_id, p_from, p_to, now(), greatest(p_from, now() - interval '120 days'), now() ) ) as r( interval_start timestamptz, pv_a_forecast_canonical_w bigint, pv_b_forecast_canonical_w bigint ) ) 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(pv.pv_a_forecast_w, 0)::int as pv_a_forecast_w, coalesce(pv.pv_b_forecast_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(pv.pv_a_forecast_w, 0) + coalesce(pv.pv_b_forecast_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 pv_canon pv on pv.interval_start = s.interval_start 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 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), coalesce(ab.degradation_cost_czk_kwh, 0.15::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, v_degrad_czk_kwh 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; select coalesce(smc.purchase_pricing_mode, 'spot') into v_purchase_pricing_mode from ems.site_market_config smc where smc.site_id = p_site_id and smc.valid_from <= p_from and (smc.valid_to is null or smc.valid_to > p_from) order by smc.valid_from desc limit 1; v_purchase_pricing_mode := coalesce(v_purchase_pricing_mode, 'spot'); 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; -- Rozpočet masek: buffer neinfluje počet slotů nad skutečný deficit; nad reserve jen deficit. if p_current_soc_wh >= v_reserve_wh then v_grid_target_wh := greatest(v_energy_to_fill, 0); else v_grid_target_wh := least( greatest(v_energy_to_fill, 0) * v_charge_buf, greatest(v_energy_to_fill, 0) ); end if; v_discharge_target_wh := v_exportable * v_discharge_buf; -- Referenční nákup: globální min (export brána) + per AM/PM pás (grid nabíjení). select coalesce(min(wk.buy_price), 0) into v_ref_buy_czk_kwh from _ems_plan_slot_wk wk; select coalesce(min(wk.buy_price), v_ref_buy_czk_kwh) into v_ref_buy_am_czk_kwh from _ems_plan_slot_wk wk where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12; select coalesce(min(wk.buy_price), v_ref_buy_czk_kwh) into v_ref_buy_pm_czk_kwh from _ems_plan_slot_wk wk where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12; -- Lookahead min buy (VT→NT) a store_score pro vrstvu A. alter table _ems_plan_slot_wk add column if not exists future_sell_lookahead numeric, add column if not exists buy_min_next_n numeric, add column if not exists store_score numeric; update _ems_plan_slot_wk wk set future_sell_lookahead = coalesce( ( select max(w2.sell_price) from _ems_plan_slot_wk w2 where w2.slot_ord > wk.slot_ord ), wk.sell_price ), buy_min_next_n = ( select min(w2.buy_price) from _ems_plan_slot_wk w2 where w2.slot_ord > wk.slot_ord and w2.slot_ord <= wk.slot_ord + v_lookahead_slots ), store_score = coalesce( ( select max(w2.sell_price) from _ems_plan_slot_wk w2 where w2.slot_ord > wk.slot_ord ), wk.sell_price ) - wk.sell_price - greatest(0::numeric, wk.buy_price - wk.sell_price); -- AM/PM rozpočet grid charging (Europe/Prague 00–12 vs 12–24). -- Chybějící segment dostane celý budget. 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; if v_per_slot_charge_wh > 0 then v_grid_charge_cap_am := greatest( 1, least(24, ceil(v_chg_am_wh / v_per_slot_charge_wh)::int) ); v_grid_charge_cap_pm := greatest( 1, least(24, ceil(v_chg_pm_wh / v_per_slot_charge_wh)::int) ); else v_grid_charge_cap_am := 6; v_grid_charge_cap_pm := 6; end if; -- charge mask: grid arbitráž (B) před FVE (A); AM/PM rozpočet Wh zůstává 50/50. -- -- B) Grid ze sítě: spot, buy v pásmu AM/PM ≤ min(buy v pásmu)+band, lookahead VT→NT; -- i při pv_surplus>0; cap slotů ∝ rozpočet Wh / per_slot_charge_wh. -- A) PV-surplus: store_score DESC, doplní jen zbytek do energy_to_fill po vrstvě B. if v_charge_buf <= 0 then update _ems_plan_slot_wk wk set allow_charge = true; elsif v_energy_to_fill <= 0 then update _ems_plan_slot_wk wk set allow_charge = true; else update _ems_plan_slot_wk wk set allow_charge = false; v_grid_filled_wh := 0; if v_purchase_pricing_mode <> 'fixed' then -- B) Grid AM (dříve než PV, vlastní 50 % rozpočtu Wh) v_cum := 0; v_grid_slots_am := 0; for r_slot in select wk.slot_ord from _ems_plan_slot_wk wk where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12 and wk.buy_price <= v_ref_buy_am_czk_kwh + v_buy_charge_band_czk_kwh and ( wk.buy_min_next_n is null or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps ) order by wk.is_predicted_price::int, wk.buy_price, wk.slot_ord loop exit when v_cum >= v_chg_am_wh; exit when v_per_slot_charge_wh <= 0; exit when v_grid_slots_am >= v_grid_charge_cap_am; 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; v_grid_slots_am := v_grid_slots_am + 1; end loop; v_grid_filled_wh := v_grid_filled_wh + v_cum; -- B) Grid PM v_cum := 0; v_grid_slots_pm := 0; for r_slot in select wk.slot_ord from _ems_plan_slot_wk wk where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12 and wk.buy_price <= v_ref_buy_pm_czk_kwh + v_buy_charge_band_czk_kwh and ( wk.buy_min_next_n is null or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps ) order by wk.is_predicted_price::int, wk.buy_price, wk.slot_ord loop exit when v_cum >= v_chg_pm_wh; exit when v_per_slot_charge_wh <= 0; exit when v_grid_slots_pm >= v_grid_charge_cap_pm; 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; v_grid_slots_pm := v_grid_slots_pm + 1; end loop; v_grid_filled_wh := v_grid_filled_wh + v_cum; end if; -- A) PV-surplus: jen zbytek kapacity po grid vrstvě B v_pv_layer_cap_wh := greatest(v_energy_to_fill - v_grid_filled_wh, 0); v_cum := 0; for r_slot in select wk.slot_ord, wk.pv_surplus_w from _ems_plan_slot_wk wk where wk.pv_surplus_w > 0 and wk.sell_price >= wk.buy_price - v_degrad_czk_kwh order by wk.store_score desc nulls last, wk.slot_ord loop exit when v_cum >= v_pv_layer_cap_wh; update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord; v_cum := v_cum + least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25; 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 where ( case when v_purchase_pricing_mode = 'fixed' then wk.sell_price > v_degrad_czk_kwh else wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh end ) 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; -- Vážená acquisition cena zásoby (grid + FVE opportunity) jen pro sloty PŘED prvním -- plánovaným exportem z baterie — nepočítá nákup po večerním/nočním vybití do sítě. select min(wk.interval_start) into v_acquisition_cutoff from _ems_plan_slot_wk wk where wk.allow_discharge_export; -- Acquisition: vážený buy v allow_charge slotech před 1. exportem (ne future_sell z FVE). select coalesce(sum( case when wk.allow_charge and ( v_acquisition_cutoff is null or wk.interval_start < v_acquisition_cutoff ) then v_per_slot_charge_wh else 0 end ), 0), coalesce(sum( case when wk.allow_charge and ( v_acquisition_cutoff is null or wk.interval_start < v_acquisition_cutoff ) then wk.buy_price * v_per_slot_charge_wh else 0 end ), 0) into v_est_grid_wh, v_est_grid_cost from _ems_plan_slot_wk wk; v_est_pv_wh := 0; v_est_pv_cost := 0; if v_est_grid_wh > 0 then v_charge_acquisition := v_est_grid_cost / v_est_grid_wh; else v_charge_acquisition := coalesce( (v_ref_buy_am_czk_kwh + v_ref_buy_pm_czk_kwh) / 2.0, v_ref_buy_czk_kwh ); 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, v_charge_acquisition as charge_acquisition_buy_czk_kwh, v_acquisition_cutoff as charge_acquisition_cutoff_at from enriched e order by e.slot_ord; end; $fn$; comment on function ems.fn_load_planning_slots_full is '15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). ' 'Charge mask A: PV-surplus dle store_score DESC (future_sell−sell−max(0,buy−sell)); zbytek → PV export. ' 'Charge mask B: non-PV jen spot, buy≤ref_buy+degrad, lookahead min buy v N slotech, cap 6 slotů AM/PM. ' 'ref_buy = min(buy) horizontu. Discharge-export: nejdražší sell kde sell>ref_buy+degrad (spot). ' '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. ' 'charge_acquisition_buy_czk_kwh: vážený buy v allow_charge slotech před charge_acquisition_cutoff_at. ' 'Grid maska B běží před PV vrstvou A; AM/PM rozpočet Wh 50/50; cap slotů z rozpočtu / per_slot_charge_wh.';