Files
ems/db/routines/R__063_fn_load_planning_slots_full.sql
Dusan Vojacek bd06779fe5
Some checks failed
CI and deploy / migration-check (push) Failing after 24s
CI and deploy / deploy (push) Has been skipped
fix BA a KV nefunkcni vecerni prodej
2026-05-24 10:49:35 +02:00

967 lines
34 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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_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;
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');
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;
-- 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í 0006 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 0012 vs 1224).
-- 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;
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);
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 0004 (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 wk.sell_price >= r_slot.evening_peak_sell - v_degrad_czk_kwh
and (
case
when v_purchase_pricing_mode = 'fixed' then
-- Večerní peak: vyvést i když sell < fixní buy (KV1), pokud je to denní maximum výkupu.
true
else
wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
end
);
end if;
end loop;
-- Ranní pásmo před prvním sell<0 (511 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 (
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 (
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 (
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
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;
-- 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
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_sellsellmax(0,buysell)); 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:0006:00 Europe/Prague), safety_soc_target_wh (619), '
'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.';