sjednoceni forecastu
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-05 10:42:49 +02:00
parent 459f33d55c
commit 5b383e9028
9 changed files with 461 additions and 253 deletions

View File

@@ -38,43 +38,87 @@ begin
where ab.site_id = p_site_id;
with fc_slot as (
-- Kanonický PV forecast pro UI = to, co solver používá (planning_interval.*_forecast_solver_w),
-- aby seděla bilance v tabulce slotů. Pro sloty mimo uložený plán doplníme forecast-only řádky.
select
u.interval_start,
coalesce(sum(u.power_w), 0)::bigint as pv_forecast_total_w
from (
select distinct on (fpi.interval_start, fpr.pv_array_id)
fpi.interval_start,
fpi.power_w
from ems.forecast_pv_interval fpi
join ems.forecast_pv_run fpr on fpr.id = fpi.run_id
join ems.asset_pv_array apa
on apa.id = fpr.pv_array_id
and apa.site_id = fpr.site_id
where fpr.site_id = p_site_id
and fpr.status = 'ok'
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
) u
group by u.interval_start
c.interval_start,
(coalesce(c.pv_a_forecast_canonical_w, 0) + coalesce(c.pv_b_forecast_canonical_w, 0))::bigint as pv_forecast_total_w,
coalesce(c.pv_a_forecast_canonical_w, 0)::bigint as pv_a_forecast_solver_w,
coalesce(c.pv_b_forecast_canonical_w, 0)::bigint as pv_b_forecast_solver_w
from jsonb_to_recordset(
ems.fn_forecast_pv_slots_range_canonical_ab(
p_site_id,
(v_run->>'horizon_start')::timestamptz,
greatest((v_run->>'horizon_end')::timestamptz, (v_run->>'horizon_start')::timestamptz + interval '96 hours'),
now()
)
) as c(
interval_start timestamptz,
pv_a_forecast_canonical_w bigint,
pv_b_forecast_canonical_w bigint
)
),
joined as (
select
to_jsonb(pi.*)
|| jsonb_build_object(
'pv_power_w', ai.actual_pv_power_w,
'pv_forecast_total_w', fs.pv_forecast_total_w
'pv_forecast_total_w',
coalesce(pi.pv_a_forecast_solver_w, 0)
+ coalesce(pi.pv_b_forecast_solver_w, 0),
'pv_a_forecast_solver_w', pi.pv_a_forecast_solver_w,
'pv_b_forecast_solver_w', pi.pv_b_forecast_solver_w
) as j,
pi.interval_start,
pi.expected_cost_czk,
pi.pv_a_curtailed_w,
pi.battery_setpoint_w,
pi.grid_setpoint_w,
fs.pv_forecast_total_w
(coalesce(pi.pv_a_forecast_solver_w, 0) + coalesce(pi.pv_b_forecast_solver_w, 0))::bigint as pv_forecast_total_w
from ems.planning_interval pi
left join ems.audit_interval ai
on ai.site_id = p_site_id
and ai.interval_start = pi.interval_start
left join fc_slot fs on fs.interval_start = pi.interval_start
where pi.run_id = v_run_id
union all
select
jsonb_build_object(
'interval_start', fs.interval_start,
'battery_setpoint_w', null,
'battery_soc_target_pct', null,
'grid_setpoint_w', null,
'export_limit_w', null,
'export_mode', null,
'deye_physical_mode', null,
'ev1_setpoint_w', null,
'ev2_setpoint_w', null,
'heat_pump_enabled', null,
'pv_a_curtailed_w', null,
'expected_cost_czk', null,
'effective_buy_price', null,
'effective_sell_price', null,
'is_predicted_price', false,
'pv_power_w', null,
'pv_forecast_total_w', fs.pv_forecast_total_w,
'pv_a_forecast_solver_w', fs.pv_a_forecast_solver_w,
'pv_b_forecast_solver_w', fs.pv_b_forecast_solver_w,
'load_baseline_w', null
) as j,
fs.interval_start,
null::numeric as expected_cost_czk,
null::int as pv_a_curtailed_w,
null::int as battery_setpoint_w,
null::int as grid_setpoint_w,
fs.pv_forecast_total_w
from fc_slot fs
where fs.interval_start >= (v_run->>'horizon_start')::timestamptz
and fs.interval_start < greatest((v_run->>'horizon_end')::timestamptz, (v_run->>'horizon_start')::timestamptz + interval '96 hours')
and not exists (
select 1
from ems.planning_interval pi2
where pi2.run_id = v_run_id
and pi2.interval_start = fs.interval_start
)
),
agg as (
select

View File

@@ -65,27 +65,6 @@ declare
begin
drop table if exists _ems_plan_slot_wk;
create temp table _ems_plan_slot_wk on commit drop as
with prof as (
select ems.fn_pv_forecast_delta_profile(
p_site_id,
greatest(p_from, now() - interval '120 days'),
now()
) as j
),
delta_unnest as (
select (kv.key)::int as pv_array_id,
(x->>'slot_of_day')::int as slot_of_day,
(x->>'delta_w')::int as delta_w
from prof
cross join lateral jsonb_each((prof.j)->'deltas_by_array') kv(key, value)
cross join lateral jsonb_array_elements(kv.value->'deltas') x
),
legacy_slot_delta as (
select (x->>'slot_of_day')::int as slot_of_day,
(x->>'delta_w')::int as delta_w
from prof
cross join lateral jsonb_array_elements((prof.j)->'deltas') x
),
slot_spine as (
select gs as interval_start
from generate_series(
@@ -93,6 +72,26 @@ begin
(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,
@@ -106,8 +105,8 @@ begin
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(fpi_a.power_w, 0)::int as pv_a_forecast_w,
coalesce(fpi_b.power_w, 0)::int as pv_b_forecast_w,
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
@@ -127,7 +126,7 @@ begin
(coalesce(ev2.status, 'available') not in ('available', 'unavailable')) as ev2_connected,
greatest(
0,
coalesce(fpi_a.power_w, 0) + coalesce(fpi_b.power_w, 0)
coalesce(pv.pv_a_forecast_w, 0) + coalesce(pv.pv_b_forecast_w, 0)
- coalesce(
(
select bs.avg_power_w
@@ -147,106 +146,9 @@ begin
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 (
with uq as (
select distinct on (apa.id)
apa.id as pv_array_id,
fpi.power_w
from ems.asset_pv_array apa
join ems.forecast_pv_run fpr
on fpr.pv_array_id = apa.id
and fpr.site_id = apa.site_id
and fpr.status = 'ok'
join ems.forecast_pv_interval fpi
on fpi.run_id = fpr.id
and fpi.pv_array_id = apa.id
and fpi.interval_start = s.interval_start
where apa.site_id = p_site_id
and apa.controllable is true
order by apa.id, fpr.created_at desc
),
slot_of as (
select (
(extract(hour from (s.interval_start at time zone 'Europe/Prague'))::int * 60)
+ extract(minute from (s.interval_start at time zone 'Europe/Prague'))::int
) / 15 as slot_of_day
),
tot as (select coalesce(sum(uq.power_w), 0)::numeric as w from uq)
select coalesce(sum(
greatest(
0,
uq.power_w - coalesce(
du.delta_w,
case
when exists (select 1 from delta_unnest limit 1) then null
else round(
ld.delta_w::numeric * uq.power_w::numeric / nullif((select w from tot), 0)
)::int
end,
0
)
)
), 0)::int as power_w
from uq
cross join slot_of
cross join tot
left join delta_unnest du
on du.pv_array_id = uq.pv_array_id
and du.slot_of_day = slot_of.slot_of_day
left join legacy_slot_delta ld
on ld.slot_of_day = slot_of.slot_of_day
) fpi_a on true
left join lateral (
with uq as (
select distinct on (apa.id)
apa.id as pv_array_id,
fpi.power_w
from ems.asset_pv_array apa
join ems.forecast_pv_run fpr
on fpr.pv_array_id = apa.id
and fpr.site_id = apa.site_id
and fpr.status = 'ok'
join ems.forecast_pv_interval fpi
on fpi.run_id = fpr.id
and fpi.pv_array_id = apa.id
and fpi.interval_start = s.interval_start
where apa.site_id = p_site_id
and apa.controllable is false
order by apa.id, fpr.created_at desc
),
slot_of as (
select (
(extract(hour from (s.interval_start at time zone 'Europe/Prague'))::int * 60)
+ extract(minute from (s.interval_start at time zone 'Europe/Prague'))::int
) / 15 as slot_of_day
),
tot as (select coalesce(sum(uq.power_w), 0)::numeric as w from uq)
select coalesce(sum(
greatest(
0,
uq.power_w - coalesce(
du.delta_w,
case
when exists (select 1 from delta_unnest limit 1) then null
else round(
ld.delta_w::numeric * uq.power_w::numeric / nullif((select w from tot), 0)
)::int
end,
0
)
)
), 0)::int as power_w
from uq
cross join slot_of
cross join tot
left join delta_unnest du
on du.pv_array_id = uq.pv_array_id
and du.slot_of_day = slot_of.slot_of_day
left join legacy_slot_delta ld
on ld.slot_of_day = slot_of.slot_of_day
) fpi_b on true
left join lateral (
select t.status
from ems.telemetry_ev_charger t

View File

@@ -0,0 +1,230 @@
-- ============================================================
-- PV forecast sloty (15min) kanonický vstup pro plánování
--
-- Kombinuje:
-- 1) delta-korekci per-array (fn_pv_forecast_delta_profile)
-- 2) rolling multiplikativní faktor vs telemetrie (fn_pv_forecast_correction_factor)
-- s lineárním decay do 1.0 v p_decay_slots.
--
-- Výstup je rozsplitěný na PV-A (controllable=true) a PV-B (controllable=false),
-- protože curtailment v LP smí omezovat jen PV-A.
-- ============================================================
create or replace function ems.fn_forecast_pv_slots_range_canonical_ab(
p_site_id int,
p_from timestamptz,
p_to timestamptz,
p_now timestamptz default now(),
p_delta_data_from timestamptz default (now() - interval '120 days'),
p_delta_data_to timestamptz default now(),
p_half_life_days numeric default 14,
p_threshold_w int default 150,
p_factor_window_h numeric default 1,
p_factor_min_clamp numeric default 0.5,
p_factor_max_clamp numeric default 1.5,
p_decay_slots int default 16
)
returns jsonb
language sql
stable
set work_mem = '64MB'
as $fn$
with tz as (
select coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague') as tz_name
from ems.site s
where s.id = p_site_id
),
bounds as (
select
date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') as ts_from,
case
when p_to <= p_from then date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') + interval '15 minutes'
when p_to > p_from + interval '60 days' then date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') + interval '60 days'
else date_bin(interval '15 minutes', p_to, timestamptz '1970-01-01T00:00:00Z')
end as ts_to,
date_bin(interval '15 minutes', p_now, timestamptz '1970-01-01T00:00:00Z') as now_slot
),
slot_spine as (
select gs as interval_start
from bounds b,
generate_series(
b.ts_from,
(b.ts_to - interval '15 minutes')::timestamptz,
interval '15 minutes'
) as gs
),
slot_tz as (
select
s.interval_start,
(
(extract(hour from (s.interval_start at time zone t.tz_name))::int * 60)
+ extract(minute from (s.interval_start at time zone t.tz_name))::int
) / 15 as slot_of_day
from slot_spine s
cross join tz t
),
factor_raw as (
select ems.fn_pv_forecast_correction_factor(
p_site_id,
(p_now - (p_factor_window_h::text || ' hours')::interval)::timestamptz,
p_now,
p_factor_min_clamp,
p_factor_max_clamp
) as j
),
factor as (
select
coalesce((j->>'correction_factor')::numeric, 1.0::numeric) as rolling_factor
from factor_raw
),
profile as (
select ems.fn_pv_forecast_delta_profile(
p_site_id,
p_delta_data_from,
p_delta_data_to,
p_half_life_days,
p_threshold_w
) as j
),
delta_by_array as (
select (kv.key)::int as pv_array_id,
(x->>'slot_of_day')::int as slot_of_day,
(x->>'delta_w')::int as delta_w
from profile p
cross join lateral jsonb_each((p.j)->'deltas_by_array') kv(key, value)
cross join lateral jsonb_array_elements(kv.value->'deltas') x
),
deltas_legacy as (
select (x->>'slot_of_day')::int as slot_of_day,
(x->>'delta_w')::int as delta_w
from profile p
cross join lateral jsonb_array_elements(p.j->'deltas') x
),
flags as (
select exists (select 1 from delta_by_array) as use_per_array
),
fc_by_array as (
select distinct on (fpi.interval_start, fpr.pv_array_id)
fpi.interval_start,
fpr.pv_array_id,
apa.controllable,
fpi.power_w::bigint as power_w
from bounds b
inner join ems.forecast_pv_interval fpi
on fpi.interval_start >= b.ts_from
and fpi.interval_start < b.ts_to
and fpi.pv_array_id in (
select apa0.id from ems.asset_pv_array apa0 where apa0.site_id = p_site_id
)
inner join ems.forecast_pv_run fpr
on fpr.id = fpi.run_id
and fpr.site_id = p_site_id
and fpr.pv_array_id = fpi.pv_array_id
and fpr.status = 'ok'
inner join ems.asset_pv_array apa
on apa.id = fpr.pv_array_id
and apa.site_id = p_site_id
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
),
fc_with_sod as (
select
fa.interval_start,
fa.pv_array_id,
fa.controllable,
fa.power_w,
st.slot_of_day
from fc_by_array fa
join slot_tz st on st.interval_start = fa.interval_start
),
fc_delta as (
select
f.interval_start,
f.controllable,
sum(f.power_w)::bigint as raw_w,
sum(
greatest(
0::bigint,
f.power_w
- (
case
when fl.use_per_array then coalesce(d.delta_w, 0)::bigint
else coalesce(dl.delta_w, 0)::bigint
end
)
)
)::bigint as delta_w
from fc_with_sod f
cross join flags fl
left join delta_by_array d
on fl.use_per_array
and d.pv_array_id = f.pv_array_id
and d.slot_of_day = f.slot_of_day
left join lateral (
select dl0.delta_w
from deltas_legacy dl0
where dl0.slot_of_day = f.slot_of_day
limit 1
) dl on not fl.use_per_array
group by f.interval_start, f.controllable
),
fc_ab as (
select
st.interval_start,
coalesce(sum(case when fd.controllable then fd.raw_w else 0 end), 0)::bigint as pv_a_forecast_raw_w,
coalesce(sum(case when not fd.controllable then fd.raw_w else 0 end), 0)::bigint as pv_b_forecast_raw_w,
coalesce(sum(case when fd.controllable then fd.delta_w else 0 end), 0)::bigint as pv_a_forecast_delta_w,
coalesce(sum(case when not fd.controllable then fd.delta_w else 0 end), 0)::bigint as pv_b_forecast_delta_w,
st.slot_of_day
from slot_tz st
left join fc_delta fd on fd.interval_start = st.interval_start
group by st.interval_start, st.slot_of_day
),
with_factor as (
select
ab.interval_start,
ab.slot_of_day,
ab.pv_a_forecast_raw_w,
ab.pv_b_forecast_raw_w,
ab.pv_a_forecast_delta_w,
ab.pv_b_forecast_delta_w,
f.rolling_factor,
case
when ab.interval_start < b.now_slot then 1.0::numeric
when p_decay_slots <= 0 then f.rolling_factor
else
case
when ((extract(epoch from (ab.interval_start - b.now_slot)) / 900)::int) >= p_decay_slots then 1.0::numeric
else (1.0::numeric + (f.rolling_factor - 1.0::numeric) * (1.0::numeric - ((extract(epoch from (ab.interval_start - b.now_slot)) / 900)::numeric / p_decay_slots::numeric)))
end
end as rolling_effective_factor
from fc_ab ab
cross join factor f
cross join bounds b
)
select coalesce(
jsonb_agg(
jsonb_build_object(
'interval_start', w.interval_start,
'slot_of_day', w.slot_of_day,
'pv_a_forecast_raw_w', w.pv_a_forecast_raw_w,
'pv_b_forecast_raw_w', w.pv_b_forecast_raw_w,
'pv_a_forecast_delta_w', w.pv_a_forecast_delta_w,
'pv_b_forecast_delta_w', w.pv_b_forecast_delta_w,
'rolling_factor', w.rolling_factor,
'rolling_effective_factor', w.rolling_effective_factor,
'pv_a_forecast_canonical_w', greatest(0::bigint, round(w.pv_a_forecast_delta_w::numeric * w.rolling_effective_factor))::bigint,
'pv_b_forecast_canonical_w', greatest(0::bigint, round(w.pv_b_forecast_delta_w::numeric * w.rolling_effective_factor))::bigint,
'pv_forecast_total_canonical_w',
greatest(0::bigint, round(w.pv_a_forecast_delta_w::numeric * w.rolling_effective_factor))::bigint
+ greatest(0::bigint, round(w.pv_b_forecast_delta_w::numeric * w.rolling_effective_factor))::bigint
)
order by w.interval_start
),
'[]'::jsonb
)
from with_factor w;
$fn$;
comment on function ems.fn_forecast_pv_slots_range_canonical_ab is
'Kanonická PV forecast řada po 15 min pro plánování: delta-korekce per-array (fn_pv_forecast_delta_profile) + rolling multiplikativní faktor (fn_pv_forecast_correction_factor) s decay. Vrací PV-A/PV-B (controllable) i total.';