-- 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, min_buy_before_cutoff_czk_kwh numeric, pv_charge_wh_ahead numeric, neg_buy_wh_ahead numeric, grid_charge_suppressed_reason text ) 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_block_export_neg_sell boolean; 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_first_neg_sell_ord int; v_first_neg_prague_date date; v_pre_neg_peak_sell_ord int; v_preneg_export_min_soc_wh numeric; v_morning_zone_peak_sell numeric; v_morning_preneg_start_hour int := 5; v_morning_preneg_end_hour int := 11; v_evening_peak_start_hour int := 17; v_charge_acquisition numeric; v_est_grid_wh numeric; v_est_pv_wh numeric; v_est_grid_cost numeric; v_est_pv_cost numeric; v_export_window_start timestamptz; v_plan_day_prague date; v_acq_v2 numeric; v_acq_prev numeric := -999; v_iter int; v_affected int; v_cum_allowed numeric; v_pv_ahead_total numeric; v_target_deficit numeric; r_unlock record; begin v_plan_day_prague := (p_from at time zone 'Europe/Prague')::date; 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'); select coalesce(sgc.block_export_on_negative_sell, false) into v_block_export_neg_sell from ems.site_grid_connection sgc where sgc.site_id = p_site_id; v_block_export_neg_sell := coalesce(v_block_export_neg_sell, false); 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: charge_slot_buffer zvětší Wh cíl (do soc_max) i cap počtu grid slotů. if v_charge_buf > 0 then v_grid_target_wh := least( greatest(v_energy_to_fill, 0) * v_charge_buf, greatest(v_soc_max_wh - p_current_soc_wh, 0) ); elsif 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, add column if not exists allow_grid_charge boolean default false, add column if not exists export_window_start_at timestamptz, add column if not exists min_buy_before_cutoff numeric, add column if not exists pv_charge_wh_ahead numeric, add column if not exists neg_buy_wh_ahead numeric, add column if not exists grid_charge_suppressed_reason text; -- První výkupní okno **per kalendářní den** (Prague). Globální min přes dny by -- zablokoval NT grid nabíjení (včerejší večerní peak → dnešní 00–06 už „po okně“). update _ems_plan_slot_wk wk set export_window_start_at = ( select min(wx.interval_start) from _ems_plan_slot_wk wx where (wx.interval_start at time zone 'Europe/Prague')::date = (wk.interval_start at time zone 'Europe/Prague')::date and wx.sell_price > ( select coalesce(min(w2.buy_price), wx.buy_price) + v_degrad_czk_kwh from _ems_plan_slot_wk w2 where (w2.interval_start at time zone 'Europe/Prague')::date = (wx.interval_start at time zone 'Europe/Prague')::date ) ); select min(wk.export_window_start_at) into v_export_window_start from _ems_plan_slot_wk wk; 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 and ( wk.export_window_start_at is null or w2.interval_start < wk.export_window_start_at ) ), 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 if v_charge_buf > 0 then v_grid_charge_cap_am := greatest( 1, least(24, ceil((v_chg_am_wh / v_per_slot_charge_wh) * v_charge_buf)::int) ); v_grid_charge_cap_pm := greatest( 1, least(24, ceil((v_chg_pm_wh / v_per_slot_charge_wh) * v_charge_buf)::int) ); else 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) ); end if; 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, nejlevnější sloty v AM/PM do Wh rozpočtu (den plánu → před exportním oknem → buy ASC); -- 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 order by case when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague then 0 else 1 end, case when wk.export_window_start_at is not null and wk.interval_start < wk.export_window_start_at then 0 else 1 end, 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, allow_grid_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; -- PM dostane i nevyčerpaný AM rozpočet (levné NT dopoledne ≠ vyčerpání celého grid_target). v_chg_pm_wh := greatest(v_chg_pm_wh, v_grid_target_wh - v_grid_filled_wh); if v_per_slot_charge_wh > 0 and v_charge_buf > 0 then v_grid_charge_cap_pm := greatest( v_grid_charge_cap_pm, least(24, ceil((v_chg_pm_wh / v_per_slot_charge_wh) * v_charge_buf)::int) ); elsif v_per_slot_charge_wh > 0 then v_grid_charge_cap_pm := greatest( v_grid_charge_cap_pm, least(24, ceil(v_chg_pm_wh / v_per_slot_charge_wh)::int) ); end if; -- 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 order by case when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague then 0 else 1 end, case when wk.export_window_start_at is not null and wk.interval_start < wk.export_window_start_at then 0 else 1 end, 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, allow_grid_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; -- Spot: záporný buy → grid nabíjení ve všech slotech (maximální arbitráž), mimo AM/PM rozpočet. update _ems_plan_slot_wk wk set allow_charge = true, allow_grid_charge = true where wk.buy_price < 0; -- Self-konzistentni filtr vrstvy B (spot): vyloucit drahe grid sloty, pokud PV / buy<0 -- alternativa pokryje deficit SoC pred prvnim exportem. update _ems_plan_slot_wk wk set pv_charge_wh_ahead = sub.pv_wh_ahead, neg_buy_wh_ahead = sub.neg_buy_wh_ahead, min_buy_before_cutoff = sub.min_buy_ahead from ( select wk.slot_ord, least( coalesce(sum( case when w2.slot_ord >= wk.slot_ord and (v_first_neg_sell_ord is null or w2.slot_ord < v_first_neg_sell_ord) and w2.pv_surplus_w > 0 and (w2.sell_price < 0 or w2.buy_price < 0) then least(w2.pv_surplus_w::numeric, v_max_charge_w) * v_charge_eff * 0.25 else 0 end ), 0), v_soc_max_wh - p_current_soc_wh ) as pv_wh_ahead, coalesce(sum( case when w2.slot_ord >= wk.slot_ord and (v_first_neg_sell_ord is null or w2.slot_ord < v_first_neg_sell_ord) and w2.buy_price < 0 then v_per_slot_charge_wh else 0 end ), 0) as neg_buy_wh_ahead, min( case when w2.slot_ord > wk.slot_ord and (v_first_neg_sell_ord is null or w2.slot_ord < v_first_neg_sell_ord) then w2.buy_price else null end ) as min_buy_ahead from _ems_plan_slot_wk wk cross join _ems_plan_slot_wk w2 group by wk.slot_ord ) sub where wk.slot_ord = sub.slot_ord; v_iter := 0; loop v_iter := v_iter + 1; exit when v_iter > 5; select coalesce( sum(wk.buy_price * v_per_slot_charge_wh) filter ( where wk.allow_grid_charge and wk.buy_price >= 0 and (v_first_neg_sell_ord is null or wk.slot_ord < v_first_neg_sell_ord) ) / nullif(sum(v_per_slot_charge_wh) filter ( where wk.allow_grid_charge and wk.buy_price >= 0 and (v_first_neg_sell_ord is null or wk.slot_ord < v_first_neg_sell_ord) ), 0), v_ref_buy_czk_kwh ) into v_acq_v2 from _ems_plan_slot_wk wk; exit when abs(v_acq_v2 - v_acq_prev) < 0.05; v_acq_prev := v_acq_v2; update _ems_plan_slot_wk wk set allow_charge = false, allow_grid_charge = false, grid_charge_suppressed_reason = case when wk.pv_charge_wh_ahead + wk.neg_buy_wh_ahead >= greatest(0, v_soc_max_wh - p_current_soc_wh) * 0.6 then 'cheaper_pv_ahead' else 'cheaper_neg_buy_ahead' end where wk.allow_grid_charge and wk.buy_price > v_acq_v2 - v_degrad_czk_kwh and wk.buy_price >= 0 and ( wk.pv_charge_wh_ahead + wk.neg_buy_wh_ahead >= greatest(0, v_soc_max_wh - p_current_soc_wh) * 0.6 ); get diagnostics v_affected = row_count; exit when v_affected = 0; end loop; select coalesce(sum(v_per_slot_charge_wh) filter (where wk.allow_grid_charge), 0) into v_cum_allowed from _ems_plan_slot_wk wk; select coalesce(min(wk.pv_charge_wh_ahead), 0) into v_pv_ahead_total from _ems_plan_slot_wk wk where wk.slot_ord = 0; v_target_deficit := greatest(0, v_soc_max_wh - p_current_soc_wh) - v_pv_ahead_total; if v_cum_allowed < v_target_deficit * 0.6 then for r_unlock in select wk.slot_ord from _ems_plan_slot_wk wk where wk.grid_charge_suppressed_reason is not null and wk.buy_price < 2 * v_acq_v2 order by wk.buy_price, wk.slot_ord loop update _ems_plan_slot_wk wk set allow_charge = true, allow_grid_charge = true, grid_charge_suppressed_reason = 'safety_failsafe_unlock' where wk.slot_ord = r_unlock.slot_ord; v_cum_allowed := v_cum_allowed + v_per_slot_charge_wh; exit when v_cum_allowed >= v_target_deficit * 0.6; end loop; end if; elsif exists ( select 1 from _ems_plan_slot_wk w2 where w2.sell_price > w2.buy_price + v_degrad_czk_kwh ) then -- Fixní nákup (BA81): buy konstantní — grid nabíjení před exportním oknem, AM/PM rozpočet. 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 order by case when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague then 0 else 1 end, case when wk.export_window_start_at is not null and wk.interval_start < wk.export_window_start_at then 0 else 1 end, wk.is_predicted_price::int, 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, allow_grid_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; v_chg_pm_wh := greatest(v_chg_pm_wh, v_grid_target_wh - v_grid_filled_wh); if v_per_slot_charge_wh > 0 and v_charge_buf > 0 then v_grid_charge_cap_pm := greatest( v_grid_charge_cap_pm, least(24, ceil((v_chg_pm_wh / v_per_slot_charge_wh) * v_charge_buf)::int) ); elsif v_per_slot_charge_wh > 0 then v_grid_charge_cap_pm := greatest( v_grid_charge_cap_pm, least(24, ceil(v_chg_pm_wh / v_per_slot_charge_wh)::int) ); end if; 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 order by case when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague then 0 else 1 end, case when wk.export_window_start_at is not null and wk.interval_start < wk.export_window_start_at then 0 else 1 end, wk.is_predicted_price::int, 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, allow_grid_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); -- Rezervace SoC pro sell<0 okno: pokud v zápor. výkup. slotech máme -- očekávaný PV přebytek X Wh (po efektivitě), snížíme PV vrstvu A o X. -- Důsledek: do okna nedorazíme „plní" (98 % SoC), zbude prostor přijmout PV -- z neg-sell slotů místo exportu do mínusu / curtail pole A. -- Sample neg-sell PV sloty (sell<0 a buy<0, kde sell= buy − degrad), takže redukce je čistá. declare v_neg_window_pv_surplus_wh numeric := 0; begin select coalesce(sum(least(wk.pv_surplus_w::numeric, v_max_charge_w) * v_charge_eff * 0.25), 0) into v_neg_window_pv_surplus_wh from _ems_plan_slot_wk wk where wk.sell_price < 0 and wk.pv_surplus_w > 0; if v_neg_window_pv_surplus_wh > 0 then v_pv_layer_cap_wh := greatest(v_pv_layer_cap_wh - v_neg_window_pv_surplus_wh, 0); end if; end; 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 -- Držet PV na večerní peak jen při kladném výkupu; při sell<0 (záporný výkup) vždy nabíjet z FVE. and ( wk.sell_price < 0 or wk.sell_price >= wk.future_sell_lookahead - 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; -- První záporný výkup v horizontu (od p_from = „budoucí“ sloty od replanu). -- Peak před ním a acquisition cutoff se vážou na STEJNÝ kalendářní den (Prague), -- ne na včerejší večer v tomže horizontu. select min(wk.slot_ord) into v_first_neg_sell_ord from _ems_plan_slot_wk wk where wk.sell_price < 0; if v_first_neg_sell_ord is not null then select (wk.interval_start at time zone 'Europe/Prague')::date into v_first_neg_prague_date from _ems_plan_slot_wk wk where wk.slot_ord = v_first_neg_sell_ord; end if; v_preneg_export_min_soc_wh := v_min_soc_wh + greatest(v_per_slot_discharge_wh, 1000::numeric); -- 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 > wk.buy_price + v_degrad_czk_kwh else wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh end ) -- Před prvním sell<0: do rozpočtu exportu jen sloty bez lepšího sell později tentýž den -- (OTE), ne pevné hodiny 00–04 (home-01: půlnoc 3,7 vs. 07:00 3,06). and not ( v_first_neg_sell_ord is not null and wk.slot_ord < v_first_neg_sell_ord and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date and exists ( select 1 from _ems_plan_slot_wk w2 where w2.slot_ord > wk.slot_ord and w2.slot_ord < v_first_neg_sell_ord and (w2.interval_start at time zone 'Europe/Prague')::date = (wk.interval_start at time zone 'Europe/Prague')::date and w2.sell_price > wk.sell_price + v_degrad_czk_kwh ) ) 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; -- Večerní špičky per kalendářní den (≥17:00 Prague): ne globální max horizontu (jinak půlnoc). for r_slot in select (wk.interval_start at time zone 'Europe/Prague')::date as plan_date, coalesce(max(wk.sell_price), 0) as evening_peak_sell from _ems_plan_slot_wk wk where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= v_evening_peak_start_hour group by (wk.interval_start at time zone 'Europe/Prague')::date loop if r_slot.evening_peak_sell > 0 then update _ems_plan_slot_wk wk set allow_discharge_export = true where (wk.interval_start at time zone 'Europe/Prague')::date = r_slot.plan_date and extract(hour from wk.interval_start at time zone 'Europe/Prague') >= v_evening_peak_start_hour and ( case when v_purchase_pricing_mode = 'fixed' and v_block_export_neg_sell then -- KV1: fixní buy ~6,3; večerní sell často < buy — vývoz ve všech kladných sell slotech ≥17h. wk.sell_price > 0 else wk.sell_price >= r_slot.evening_peak_sell - v_degrad_czk_kwh end ); end if; end loop; -- Ranní pásmo před prvním sell<0 (5–11 Prague): lokální peak, ne půlnoc celého dne. if v_first_neg_sell_ord is not null and v_first_neg_prague_date is not null and p_current_soc_wh >= v_preneg_export_min_soc_wh then select coalesce(max(wk.sell_price), 0) into v_morning_zone_peak_sell from _ems_plan_slot_wk wk where wk.slot_ord < v_first_neg_sell_ord and wk.sell_price >= 0 and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date and extract(hour from wk.interval_start at time zone 'Europe/Prague') between v_morning_preneg_start_hour and v_morning_preneg_end_hour; if v_morning_zone_peak_sell > 0 then update _ems_plan_slot_wk wk set allow_discharge_export = true where wk.slot_ord < v_first_neg_sell_ord and wk.sell_price >= 0 and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date and extract(hour from wk.interval_start at time zone 'Europe/Prague') between v_morning_preneg_start_hour and v_morning_preneg_end_hour and wk.sell_price >= v_morning_zone_peak_sell - v_degrad_czk_kwh; select wk.slot_ord into v_pre_neg_peak_sell_ord from _ems_plan_slot_wk wk where wk.slot_ord < v_first_neg_sell_ord and wk.sell_price >= 0 and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date and extract(hour from wk.interval_start at time zone 'Europe/Prague') between v_morning_preneg_start_hour and v_morning_preneg_end_hour order by wk.sell_price desc, wk.slot_ord limit 1; end if; end if; -- Mezi ranní peak a prvním sell<0: zákaz „pozdního dumpu“ při nízkém sell (07:30 za 2 Kč). if v_first_neg_sell_ord is not null and v_first_neg_prague_date is not null and v_morning_zone_peak_sell is not null and v_morning_zone_peak_sell > 0 then update _ems_plan_slot_wk wk set allow_discharge_export = false where wk.slot_ord < v_first_neg_sell_ord and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date and extract(hour from wk.interval_start at time zone 'Europe/Prague') between v_morning_preneg_start_hour and v_morning_preneg_end_hour and wk.sell_price < v_morning_zone_peak_sell - v_degrad_czk_kwh; update _ems_plan_slot_wk wk set allow_discharge_export = false where wk.slot_ord < v_first_neg_sell_ord and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date and extract(hour from wk.interval_start at time zone 'Europe/Prague') >= v_morning_preneg_start_hour and extract(hour from wk.interval_start at time zone 'Europe/Prague') < v_evening_peak_start_hour and wk.sell_price < v_morning_zone_peak_sell - v_degrad_czk_kwh; end if; -- Ranní peak před sell<0: jen export baterie, ne souběžné nabíjení (LP jinak „nabije“ v 07:00). if v_first_neg_sell_ord is not null and v_first_neg_prague_date is not null then update _ems_plan_slot_wk wk set allow_charge = false where wk.allow_discharge_export and wk.slot_ord < v_first_neg_sell_ord and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date and extract(hour from wk.interval_start at time zone 'Europe/Prague') between v_morning_preneg_start_hour and v_morning_preneg_end_hour; end if; -- Záporný buy: vždy grid nabíjení (mimo rozpočet 6 slotů / PV vrstvu A). update _ems_plan_slot_wk wk set allow_charge = true, allow_grid_charge = true where wk.buy_price < 0; -- Záporný výkup + PV přebytek: nabíjení z FVE (KV1/BA81 block_export), bez filtru future_sell. update _ems_plan_slot_wk wk set allow_charge = true where wk.sell_price < 0 and wk.pv_surplus_w > 0; -- Acquisition: grid nabíjení před prvním exportem ve STEJNÝ den jako záporné výkupní okno -- (ne dřívější večerní export v horizontu rolling replanu). select min(wk.interval_start) into v_acquisition_cutoff from _ems_plan_slot_wk wk where wk.allow_discharge_export and ( v_first_neg_prague_date is null or (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date ); if v_acquisition_cutoff is null then select min(wk.interval_start) into v_acquisition_cutoff from _ems_plan_slot_wk wk where wk.allow_discharge_export; end if; -- Acquisition: jen grid vrstva B (ne odpolední FVE z vrstvy A) před 1. exportem. select coalesce(sum( case when wk.allow_grid_charge and wk.buy_price >= 0 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_grid_charge and wk.buy_price >= 0 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), min( case when wk.allow_grid_charge and wk.buy_price >= 0 and ( v_acquisition_cutoff is null or wk.interval_start < v_acquisition_cutoff ) then wk.buy_price else null end ) into v_est_grid_wh, v_est_grid_cost, v_charge_acquisition 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; elsif v_charge_acquisition is null then -- Fallback: nejnizsi positivni buy v horizontu (nikoli avg ref_buy_am/pm, -- ktery muze byt < 0 kdyz PM zahrnuje zaporne OTE sloty 13-15h). -- Cena akvizice baterie nikdy nesmi byt < 0 (jinak rozhazuje arbitraz -- objective + two_pass divergence). select coalesce(min(wk.buy_price), 0) into v_charge_acquisition from _ems_plan_slot_wk wk where wk.buy_price >= 0; end if; v_charge_acquisition := greatest(v_charge_acquisition, 0); -- v_charge_acquisition z min(grid) zůstane, pokud je jen jeden grid slot před exportem 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, w.min_buy_before_cutoff as min_buy_before_cutoff_czk_kwh, coalesce(w.pv_charge_wh_ahead, 0) as pv_charge_wh_ahead, coalesce(w.neg_buy_wh_ahead, 0) as neg_buy_wh_ahead, w.grid_charge_suppressed_reason 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, e.min_buy_before_cutoff_czk_kwh, e.pv_charge_wh_ahead, e.neg_buy_wh_ahead, e.grid_charge_suppressed_reason 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: spot, nejlevnější buy v AM/PM do Wh rozpočtu (priorita den plánu, před exportním oknem). ' '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.';