-- 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 ) 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_purchase_pricing_mode text; 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; v_grid_target_wh := greatest(v_energy_to_fill, 0) * v_charge_buf; v_discharge_target_wh := v_exportable * v_discharge_buf; -- 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; -- charge mask: dvě nezávislé vrstvy -- -- A) PV-surplus sloty (pv_surplus_w > 0): ranking dle sell_price ASC. -- Nejlevnější PV-surplus sloty vybereme, dokud kumulativní -- PV surplus nepokryje charge target (energy_to_fill × charge_buf). -- Zbylé PV-surplus sloty mají allow_charge = false → PV jde do sítě. -- Toto je hlavní mechanismus proti mikro-cyklování z PV: -- v drahých slotech se PV prodává přímo, nabíjení jen v levných. -- -- B) Non-PV sloty (pv_surplus_w <= 0): AM/PM budget, OTE-first (jen spot nákup). -- U purchase_pricing_mode = fixed se grid nabíjení neplánuje — buy je -- v každém slotu stejný, cyklus ze sítě by byl čistá ztráta; nabíjení jen z FVE. 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; -- A) PV-surplus: cheapest sell_price first 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 order by wk.sell_price, wk.slot_ord loop exit when v_cum >= v_grid_target_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; if v_purchase_pricing_mode <> 'fixed' then -- B) Non-PV AM: OTE-first, then predicted, ordered by buy_price 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.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; 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; -- B) Non-PV PM: OTE-first, then predicted, ordered by buy_price 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.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; 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; end if; -- Referenční nákup pro arbitráž exportu: nejlevnější buy mezi sloty, kde lze nabíjet -- (ne buy ve stejném slotu — střídač nekupuje a neprodává současně). select coalesce( min(wk.buy_price) filter (where wk.allow_charge), min(wk.buy_price) ) into v_ref_buy_czk_kwh from _ems_plan_slot_wk wk; v_ref_buy_czk_kwh := coalesce(v_ref_buy_czk_kwh, 0); -- 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; 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 is '15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). ' 'Charge mask: PV-surplus sloty rankované dle sell_price ASC – nejlevnější pokrývají charge target, zbytek → PV do sítě; ' 'non-PV sloty dle buy_price s AM/PM rozpočtem 50/50 a OTE-first prioritou (is_predicted_price::int ASC). ' 'Discharge-export mask: nejdražší sell_price sloty globálně. ' '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.';