korkece fve predikce, grafy predikci
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-22 19:26:46 +02:00
parent ffe80679cc
commit 9ca4b4c577
10 changed files with 819 additions and 5 deletions

View File

@@ -0,0 +1,121 @@
-- ============================================================
-- Profil systematické chyby PV forecastu po 15min slotu dne
-- (aditivní korekce: corrected = max(0, forecast - delta[slot]))
-- ============================================================
create or replace function ems.fn_pv_forecast_delta_profile(
p_site_id int,
p_data_from timestamptz,
p_data_to timestamptz default now(),
p_half_life_days numeric default 14,
p_threshold_w int default 150
)
returns jsonb
language sql
stable
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
),
-- Cutoff z analýzy DB (EMS Postgres): u site_id=2 (`home-01`) začíná být
-- `forecast_accuracy.actual_power_w` spolehlivě vyplněné pro celé kalendářní dny
-- od 2026-04-06 (Europe/Prague). Dřívší dny mají výrazně nižší podíl slotů s actual
-- (částečný backfill / výpadky) a zkreslují delta profil.
cutoff as (
select timestamptz '2026-04-05T22:00:00Z' as min_ts
),
bounds as (
select
greatest(p_data_from, p_data_to - interval '120 days', (select min_ts from cutoff)) as ts_from,
p_data_to as ts_to,
greatest(p_half_life_days, 1) as half_life_days,
greatest(p_threshold_w, 0) as threshold_w
),
-- vezmeme jeden „reprezentativní“ forecast z historie: pro každý interval_start a pv_array_id
-- vybereme nejnovější forecast (forecast_created_at) který je <= interval_start (lead_time >= 0)
best as (
select
fa.interval_start,
fa.pv_array_id,
fa.forecast_power_w,
fa.actual_power_w,
fa.forecast_created_at,
row_number() over (
partition by fa.interval_start, fa.pv_array_id
order by fa.forecast_created_at desc
) as rn
from ems.forecast_accuracy fa
cross join bounds b
where fa.site_id = p_site_id
and fa.interval_start >= b.ts_from
and fa.interval_start < b.ts_to
and fa.actual_power_w is not null
and fa.forecast_created_at <= fa.interval_start
),
slots as (
select
b.interval_start,
sum(b.forecast_power_w)::numeric as forecast_total_w,
sum(b.actual_power_w)::numeric as actual_total_w,
(
(extract(hour from (b.interval_start at time zone tz.tz_name))::int * 60)
+ extract(minute from (b.interval_start at time zone tz.tz_name))::int
) / 15 as slot_of_day,
extract(epoch from (now() - b.interval_start)) / 86400.0 as age_days
from best b
cross join tz
where b.rn = 1
group by b.interval_start, slot_of_day, tz.tz_name
),
filtered as (
select
s.slot_of_day,
(s.forecast_total_w - s.actual_total_w) as error_w,
exp(-s.age_days / nullif((select half_life_days from bounds), 0)) as w
from slots s
cross join bounds b
where s.slot_of_day between 0 and 95
and (s.actual_total_w > b.threshold_w or s.forecast_total_w > b.threshold_w)
),
agg as (
select
slot_of_day,
count(*) as sample_count,
sum(w) as w_sum,
case
when sum(w) > 0 then sum(error_w * w) / sum(w)
else null
end as delta_w
from filtered
group by slot_of_day
),
spine as (
select generate_series(0, 95) as slot_of_day
)
select jsonb_build_object(
'site_id', p_site_id,
'data_from', (select ts_from from bounds),
'data_to', (select ts_to from bounds),
'half_life_days', (select half_life_days from bounds),
'threshold_w', (select threshold_w from bounds),
'deltas',
coalesce(
jsonb_agg(
jsonb_build_object(
'slot_of_day', sp.slot_of_day,
'delta_w', coalesce(round(a.delta_w)::int, 0),
'sample_count', coalesce(a.sample_count, 0)
)
order by sp.slot_of_day
),
'[]'::jsonb
)
)
from spine sp
left join agg a on a.slot_of_day = sp.slot_of_day;
$fn$;
comment on function ems.fn_pv_forecast_delta_profile(int, timestamptz, timestamptz, numeric, int) is
'Aditivní delta profil chyby PV forecastu po 15min slotu dne (96 slotů). Zdroj: forecast_accuracy, vážení exp(-age/half_life_days). Vrací JSON {deltas:[{slot_of_day, delta_w, sample_count}], ...}. Interní minimální cutoff dat (2026-04-06 Europe/Prague) brání učení z nekonzistentní historie před kompletním plněním actual.';

View File

@@ -0,0 +1,123 @@
-- ============================================================
-- PV forecast sloty (15min) + aditivně korigovaný forecast
-- corrected = max(0, forecast - delta_profile[slot_of_day])
-- ============================================================
create or replace function ems.fn_forecast_pv_slots_range_corrected(
p_site_id int,
p_from timestamptz,
p_to timestamptz,
p_delta_data_from timestamptz,
p_delta_data_to timestamptz default now(),
p_half_life_days numeric default 14,
p_threshold_w int default 150
)
returns jsonb
language sql
stable
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
p_from as ts_from,
case
when p_to <= p_from then p_from + interval '15 minutes'
when p_to > p_from + interval '120 hours' then p_from + interval '120 hours'
else p_to
end as ts_to
),
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
),
fc as (
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
cross join bounds b
where fpr.site_id = p_site_id
and fpr.status = 'ok'
and fpi.interval_start >= b.ts_from
and fpi.interval_start < b.ts_to
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
) u
group by u.interval_start
),
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
),
deltas as (
select
(x->>'slot_of_day')::int as slot_of_day,
(x->>'delta_w')::int as delta_w,
(x->>'sample_count')::int as sample_count
from profile p
cross join lateral jsonb_array_elements(p.j->'deltas') as x
)
select coalesce(
jsonb_agg(
jsonb_build_object(
'interval_start', s.interval_start,
'pv_forecast_total_w', coalesce(fc.pv_forecast_total_w, 0),
'pv_forecast_corrected_w',
greatest(
0,
coalesce(fc.pv_forecast_total_w, 0)::int
- coalesce(
(
select d.delta_w
from deltas d
cross join tz
where d.slot_of_day = (
(
(extract(hour from (s.interval_start at time zone tz.tz_name))::int * 60)
+ extract(minute from (s.interval_start at time zone tz.tz_name))::int
) / 15
)
),
0
)
),
'slot_of_day',
(
(
(extract(hour from (s.interval_start at time zone tz.tz_name))::int * 60)
+ extract(minute from (s.interval_start at time zone tz.tz_name))::int
) / 15
)
)
order by s.interval_start
),
'[]'::jsonb
)
from slot_spine s
cross join tz
left join fc on fc.interval_start = s.interval_start;
$fn$;
comment on function ems.fn_forecast_pv_slots_range_corrected(int, timestamptz, timestamptz, timestamptz, timestamptz, numeric, int) is
'JSON pole {interval_start, pv_forecast_total_w, pv_forecast_corrected_w, slot_of_day} po 15 min pro [p_from, p_to). Korekce je aditivní delta profil z fn_pv_forecast_delta_profile.';