416 lines
14 KiB
PL/PgSQL
416 lines
14 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
|
||
)
|
||
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_dis_am_wh numeric;
|
||
v_dis_pm_wh numeric;
|
||
v_reserve_wh numeric;
|
||
v_daytime_en boolean;
|
||
v_night_buf_pct numeric;
|
||
begin
|
||
drop table if exists _ems_plan_slot_wk;
|
||
create temp table _ems_plan_slot_wk on commit drop as
|
||
with
|
||
slot_spine as (
|
||
select gs as interval_start
|
||
from generate_series(
|
||
p_from,
|
||
(p_to - interval '15 minutes')::timestamptz,
|
||
interval '15 minutes'
|
||
) as gs
|
||
),
|
||
pv_canon as (
|
||
select
|
||
r.interval_start,
|
||
coalesce(r.pv_a_forecast_canonical_w, 0)::int as pv_a_forecast_w,
|
||
coalesce(r.pv_b_forecast_canonical_w, 0)::int as pv_b_forecast_w
|
||
from jsonb_to_recordset(
|
||
ems.fn_forecast_pv_slots_range_canonical_ab(
|
||
p_site_id,
|
||
p_from,
|
||
p_to,
|
||
now(),
|
||
greatest(p_from, now() - interval '120 days'),
|
||
now()
|
||
)
|
||
) as r(
|
||
interval_start timestamptz,
|
||
pv_a_forecast_canonical_w bigint,
|
||
pv_b_forecast_canonical_w bigint
|
||
)
|
||
)
|
||
select
|
||
(row_number() over (order by s.interval_start) - 1)::int as slot_ord,
|
||
s.interval_start,
|
||
coalesce(
|
||
ep.effective_buy_price_czk_kwh,
|
||
ems.fn_get_predicted_price(p_site_id, s.interval_start)
|
||
) as buy_price,
|
||
coalesce(
|
||
ep.effective_sell_price_czk_kwh,
|
||
ems.fn_get_predicted_price(p_site_id, s.interval_start) * 0.85
|
||
) as sell_price,
|
||
(ep.effective_buy_price_czk_kwh is null) as is_predicted_price,
|
||
coalesce(pv.pv_a_forecast_w, 0)::int as pv_a_forecast_w,
|
||
coalesce(pv.pv_b_forecast_w, 0)::int as pv_b_forecast_w,
|
||
coalesce(
|
||
(
|
||
select bs.avg_power_w
|
||
from ems.consumption_baseline_stats bs
|
||
where bs.site_id = p_site_id
|
||
and bs.day_of_week = extract(
|
||
dow from s.interval_start at time zone 'Europe/Prague'
|
||
)::int
|
||
and bs.hour_of_day = extract(
|
||
hour from s.interval_start at time zone 'Europe/Prague'
|
||
)::int
|
||
limit 1
|
||
),
|
||
500
|
||
)::int as load_baseline_w,
|
||
(coalesce(ev1.status, 'available') not in ('available', 'unavailable')) as ev1_connected,
|
||
(coalesce(ev2.status, 'available') not in ('available', 'unavailable')) as ev2_connected,
|
||
greatest(
|
||
0,
|
||
coalesce(pv.pv_a_forecast_w, 0) + coalesce(pv.pv_b_forecast_w, 0)
|
||
- coalesce(
|
||
(
|
||
select bs.avg_power_w
|
||
from ems.consumption_baseline_stats bs
|
||
where bs.site_id = p_site_id
|
||
and bs.day_of_week = extract(
|
||
dow from s.interval_start at time zone 'Europe/Prague'
|
||
)::int
|
||
and bs.hour_of_day = extract(
|
||
hour from s.interval_start at time zone 'Europe/Prague'
|
||
)::int
|
||
limit 1
|
||
),
|
||
500
|
||
)
|
||
)::int as pv_surplus_w,
|
||
false::boolean as allow_charge,
|
||
false::boolean as allow_discharge_export
|
||
from slot_spine s
|
||
left join pv_canon pv on pv.interval_start = s.interval_start
|
||
left join ems.vw_site_effective_price ep
|
||
on ep.site_id = p_site_id and ep.interval_start = s.interval_start
|
||
left join lateral (
|
||
select t.status
|
||
from ems.telemetry_ev_charger t
|
||
join ems.asset_ev_charger ch on ch.id = t.charger_id
|
||
where t.site_id = p_site_id and ch.code = 'ev-charger-1'
|
||
order by t.measured_at desc
|
||
limit 1
|
||
) ev1 on true
|
||
left join lateral (
|
||
select t.status
|
||
from ems.telemetry_ev_charger t
|
||
join ems.asset_ev_charger ch on ch.id = t.charger_id
|
||
where t.site_id = p_site_id and ch.code = 'ev-charger-2'
|
||
order by t.measured_at desc
|
||
limit 1
|
||
) ev2 on true;
|
||
|
||
if not exists (select 1 from _ems_plan_slot_wk) then
|
||
raise exception 'No planning slots available – check market prices and horizon settings';
|
||
end if;
|
||
|
||
select
|
||
coalesce(ab.charge_slot_buffer, 0::numeric),
|
||
coalesce(ab.discharge_slot_buffer, 0::numeric),
|
||
ab.usable_capacity_wh::numeric,
|
||
(ab.min_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
|
||
(coalesce(ab.planner_max_soc_percent, ab.max_soc_percent) / 100.0 * ab.usable_capacity_wh)::numeric,
|
||
greatest(coalesce(ab.charge_efficiency, 1::numeric), 0.0001::numeric),
|
||
least(
|
||
coalesce(ai.max_battery_charge_w, ai.max_charge_power_w),
|
||
coalesce(
|
||
ab.bms_max_charge_w,
|
||
case when ab.max_charge_c_rate is not null
|
||
then (ab.max_charge_c_rate * ab.usable_capacity_wh)::bigint
|
||
end,
|
||
coalesce(ai.max_battery_charge_w, ai.max_charge_power_w)
|
||
)
|
||
)::numeric,
|
||
least(
|
||
coalesce(ai.max_battery_discharge_w, ai.max_discharge_power_w),
|
||
coalesce(
|
||
ab.bms_max_discharge_w,
|
||
case when ab.max_discharge_c_rate is not null
|
||
then (ab.max_discharge_c_rate * ab.usable_capacity_wh)::bigint
|
||
end,
|
||
coalesce(ai.max_battery_discharge_w, ai.max_discharge_power_w)
|
||
)
|
||
)::numeric,
|
||
greatest(coalesce(ab.discharge_efficiency, 1::numeric), 0.0001::numeric),
|
||
(ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
|
||
coalesce(ab.planner_daytime_charge_target_enabled, true),
|
||
coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric)
|
||
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
|
||
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;
|
||
|
||
v_per_slot_charge_wh := v_max_charge_w * v_charge_eff * 0.25;
|
||
v_per_slot_discharge_wh := v_max_discharge_w * v_discharge_eff * 0.25;
|
||
v_energy_to_fill := v_soc_max_wh - p_current_soc_wh;
|
||
v_exportable := v_soc_max_wh - v_min_soc_wh;
|
||
v_grid_target_wh := v_energy_to_fill * v_charge_buf;
|
||
v_discharge_target_wh := v_exportable * v_discharge_buf;
|
||
|
||
-- Rozpočet na půl dne (Europe/Prague): 00:00–12:00 vs 12:00–24:00; chybějící segment dostane celý budget.
|
||
-- Nabíjecí rozpočet dál dělíme 50/50 (kvůli rozprostření v rámci dne), ale exportní vybíjení volíme globálně podle sell_price.
|
||
select
|
||
coalesce(
|
||
count(*) filter (
|
||
where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12
|
||
),
|
||
0
|
||
)::int,
|
||
coalesce(
|
||
count(*) filter (
|
||
where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12
|
||
),
|
||
0
|
||
)::int
|
||
into v_n_am, v_n_pm
|
||
from _ems_plan_slot_wk wk;
|
||
|
||
if v_n_am <= 0 then
|
||
v_chg_am_wh := 0;
|
||
v_chg_pm_wh := v_grid_target_wh;
|
||
elsif v_n_pm <= 0 then
|
||
v_chg_am_wh := v_grid_target_wh;
|
||
v_chg_pm_wh := 0;
|
||
else
|
||
v_chg_am_wh := v_grid_target_wh / 2.0;
|
||
v_chg_pm_wh := v_grid_target_wh - v_chg_am_wh;
|
||
end if;
|
||
|
||
-- charge mask (sloupce temp tabulky kvalifikujeme: RETURNS TABLE dělá PL proměnné stejných jmen)
|
||
if v_charge_buf <= 0 then
|
||
update _ems_plan_slot_wk wk set allow_charge = true;
|
||
elsif v_energy_to_fill <= 0 then
|
||
-- Pokud rolling replan startuje s baterií plnou, nechceme zablokovat budoucí nabíjení po vybití.
|
||
-- Povolit alespoň nabíjení v PV surplus slotech, aby solver mohl vytvořit headroom a pak ho znovu zaplnit z FVE.
|
||
update _ems_plan_slot_wk wk set allow_charge = (wk.pv_surplus_w > 0);
|
||
else
|
||
update _ems_plan_slot_wk wk set allow_charge = (wk.pv_surplus_w > 0);
|
||
v_cum := 0;
|
||
for r_slot in
|
||
select wk.slot_ord
|
||
from _ems_plan_slot_wk wk
|
||
where wk.pv_surplus_w <= 0
|
||
and extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12
|
||
order by wk.buy_price, wk.slot_ord
|
||
loop
|
||
exit when v_cum >= v_chg_am_wh;
|
||
exit when v_per_slot_charge_wh <= 0;
|
||
update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord;
|
||
v_cum := v_cum + v_per_slot_charge_wh;
|
||
end loop;
|
||
v_cum := 0;
|
||
for r_slot in
|
||
select wk.slot_ord
|
||
from _ems_plan_slot_wk wk
|
||
where wk.pv_surplus_w <= 0
|
||
and extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12
|
||
order by wk.buy_price, wk.slot_ord
|
||
loop
|
||
exit when v_cum >= v_chg_pm_wh;
|
||
exit when v_per_slot_charge_wh <= 0;
|
||
update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord;
|
||
v_cum := v_cum + v_per_slot_charge_wh;
|
||
end loop;
|
||
end if;
|
||
|
||
-- 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
|
||
order by wk.sell_price desc, wk.slot_ord desc
|
||
loop
|
||
exit when v_cum >= v_discharge_target_wh;
|
||
exit when v_per_slot_discharge_wh <= 0;
|
||
update _ems_plan_slot_wk wk set allow_discharge_export = true where wk.slot_ord = r_slot.slot_ord;
|
||
v_cum := v_cum + v_per_slot_discharge_wh;
|
||
end loop;
|
||
end if;
|
||
|
||
return query
|
||
with night_tot as (
|
||
select coalesce(sum(w2.load_baseline_w), 0) * 0.25 as night_wh
|
||
from _ems_plan_slot_wk w2
|
||
where extract(hour from w2.interval_start at time zone 'Europe/Prague') >= 20
|
||
or extract(hour from w2.interval_start at time zone 'Europe/Prague') < 6
|
||
),
|
||
enriched as (
|
||
select
|
||
w.slot_ord,
|
||
w.interval_start,
|
||
w.buy_price,
|
||
w.sell_price,
|
||
w.is_predicted_price,
|
||
w.pv_a_forecast_w,
|
||
w.pv_b_forecast_w,
|
||
w.load_baseline_w,
|
||
w.ev1_connected,
|
||
w.ev2_connected,
|
||
w.allow_charge,
|
||
w.allow_discharge_export,
|
||
nt.night_wh as night_baseload_target_wh,
|
||
nt.night_wh * (v_night_buf_pct / 100.0) as night_baseload_buffer_wh,
|
||
case
|
||
when not v_daytime_en then null::numeric
|
||
when extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 19 then
|
||
least(
|
||
v_soc_max_wh,
|
||
v_reserve_wh + (nt.night_wh + nt.night_wh * (v_night_buf_pct / 100.0))
|
||
* greatest(
|
||
0::numeric,
|
||
least(
|
||
1::numeric,
|
||
(
|
||
extract(hour from w.interval_start at time zone 'Europe/Prague')::numeric
|
||
+ (
|
||
extract(minute from w.interval_start at time zone 'Europe/Prague')::numeric
|
||
/ 60.0
|
||
)
|
||
- 6.0
|
||
) / 14.0
|
||
)
|
||
)
|
||
)
|
||
else null::numeric
|
||
end as safety_soc_target_wh,
|
||
coalesce(
|
||
max(w.buy_price) over (
|
||
order by w.slot_ord rows between 1 following and unbounded following
|
||
),
|
||
w.buy_price
|
||
) as future_avoided_buy_czk_kwh,
|
||
coalesce(
|
||
max(w.sell_price) over (
|
||
order by w.slot_ord rows between 1 following and unbounded following
|
||
),
|
||
w.sell_price
|
||
) as future_sell_opportunity_czk_kwh,
|
||
(
|
||
extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 18
|
||
and w.pv_surplus_w > 0
|
||
) as is_daytime_pv_surplus_slot
|
||
from _ems_plan_slot_wk w
|
||
cross join night_tot nt
|
||
)
|
||
select
|
||
e.slot_ord,
|
||
e.interval_start,
|
||
e.buy_price,
|
||
e.sell_price,
|
||
e.is_predicted_price,
|
||
e.pv_a_forecast_w,
|
||
e.pv_b_forecast_w,
|
||
e.load_baseline_w,
|
||
e.ev1_connected,
|
||
e.ev2_connected,
|
||
e.allow_charge,
|
||
e.allow_discharge_export,
|
||
e.night_baseload_target_wh,
|
||
e.night_baseload_buffer_wh,
|
||
e.safety_soc_target_wh,
|
||
e.future_avoided_buy_czk_kwh,
|
||
e.future_sell_opportunity_czk_kwh,
|
||
e.is_daytime_pv_surplus_slot
|
||
from enriched e
|
||
order by e.slot_ord;
|
||
end;
|
||
$fn$;
|
||
|
||
comment on function ems.fn_load_planning_slots_full is
|
||
'15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). '
|
||
'Masky charge/discharge-export se berou zvlášť pro 00–12 a 12–24 Europe/Prague (polovina budgetu na segment). '
|
||
'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.';
|