842 lines
29 KiB
PL/PgSQL
842 lines
29 KiB
PL/PgSQL
-- 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;
|
||
|
||
-- První výkupní okno: sell nad min(buy) **téhož kalendářního dne** (Prague), ne globální
|
||
-- min(buy) zítra (NT) — jinak okno začne už ~15:30 a dnešní PM grid dostane 1 slot.
|
||
select min(wk.interval_start)
|
||
into v_export_window_start
|
||
from _ems_plan_slot_wk wk
|
||
where wk.sell_price > (
|
||
select coalesce(min(w2.buy_price), wk.buy_price) + v_degrad_czk_kwh
|
||
from _ems_plan_slot_wk w2
|
||
where (w2.interval_start at time zone 'Europe/Prague')::date
|
||
= (wk.interval_start at time zone 'Europe/Prague')::date
|
||
);
|
||
|
||
-- 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;
|
||
|
||
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 (
|
||
v_export_window_start is null
|
||
or w2.interval_start < v_export_window_start
|
||
)
|
||
),
|
||
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 v_export_window_start is not null
|
||
and wk.interval_start < v_export_window_start
|
||
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 v_export_window_start is not null
|
||
and wk.interval_start < v_export_window_start
|
||
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;
|
||
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: ne nabíjet z FVE když sell výrazně pod budoucím výkupním oknem.
|
||
and 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 > v_degrad_czk_kwh
|
||
else
|
||
wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
||
end
|
||
)
|
||
-- Na dni prvního sell<0 nepočítat noční „šrot“ (00–04) do globálního rozpočtu —
|
||
-- jinak vyčerpá Wh před ranní špičkou (home-01: půlnoc 3,7 vs. 07:00 3,06).
|
||
and not (
|
||
v_first_neg_prague_date is not null
|
||
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
|
||
)
|
||
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
|
||
wk.sell_price > v_degrad_czk_kwh
|
||
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 (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;
|
||
|
||
-- 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_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.';
|