kalibrace per pole
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-22 22:17:28 +02:00
parent 3cd8e44d37
commit 568b584748
12 changed files with 705 additions and 267 deletions

View File

@@ -0,0 +1,41 @@
-- Kalibrace PV forecastu per site (cutoff učení, škrcení policy, volitelné přepsání parametrů delty).
-- forecast_accuracy: flagy pro učení (vyloučení škrcených slotů apod.).
CREATE TABLE ems.site_pv_forecast_calibration (
site_id int NOT NULL PRIMARY KEY REFERENCES ems.site (id) ON DELETE CASCADE,
-- Od tohoto okamžiku (UTC) brát řádky do učení delty / vážených statistik (>=).
delta_learn_min_ts timestamptz NOT NULL,
-- Od kdy platí agresivní export/škrcení policy (NULL = neaplikovat časový filtr u heuristiky škrcení).
pv_curtailment_policy_effective_from timestamptz NULL,
top_n_days int NULL,
non_top_day_factor numeric NULL,
day_weight_gamma numeric NULL,
half_life_days numeric NULL,
threshold_w int NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE ems.site_pv_forecast_calibration IS
'Per-site kalibrace PV delta profilu a pravidla učení. NULL v numerických sloupích = použít default z ems.fn_pv_forecast_delta_profile.';
COMMENT ON COLUMN ems.site_pv_forecast_calibration.delta_learn_min_ts IS
'Dolní mez interval_start pro učení delty z forecast_accuracy (UTC).';
COMMENT ON COLUMN ems.site_pv_forecast_calibration.pv_curtailment_policy_effective_from IS
'Od tohoto času bereme heuristiku škrcení (planning_interval): sloty po tomto datu s curtailment/cut-off se mohou vyloučit z učení.';
ALTER TABLE ems.forecast_accuracy
ADD COLUMN IF NOT EXISTS learning_eligible boolean NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS learning_exclude_reason text NULL;
COMMENT ON COLUMN ems.forecast_accuracy.learning_eligible IS
'false = řádek se nepoužívá pro učení delty (škrcení, před cutoffem, …); actual_power_w může být NULL pro audit.';
COMMENT ON COLUMN ems.forecast_accuracy.learning_exclude_reason IS
'Důvod vyloučení z učení, např. curtailment_or_gen_cutoff, before_delta_learn_min.';
-- Seed: všechny existující lokality — stejný cutoff jako dosud v R__078 (začátek 2026-04-12 Europe/Prague).
INSERT INTO ems.site_pv_forecast_calibration (site_id, delta_learn_min_ts, top_n_days)
SELECT s.id, timestamptz '2026-04-11T22:00:00Z', 3
FROM ems.site s
ON CONFLICT (site_id) DO NOTHING;

View File

@@ -12,7 +12,8 @@ BEGIN
site_id, pv_array_id, interval_start, run_id,
forecast_power_w, forecast_created_at, lead_time_hours,
actual_power_w, actual_filled_at,
error_w, error_pct
error_w, error_pct,
learning_eligible, learning_exclude_reason
)
SELECT
fpr.site_id,
@@ -25,10 +26,17 @@ BEGIN
EXTRACT(EPOCH FROM (fpi.interval_start - fpr.created_at))
/ 3600.0, 2
) AS lead_time_hours,
slot.avg_actual_w::INT AS actual_power_w,
now() AS actual_filled_at,
fpi.power_w - COALESCE(slot.avg_actual_w::INT, 0) AS error_w,
CASE
WHEN v.is_curtailed_learning_slot THEN NULL
ELSE slot.avg_actual_w::INT
END AS actual_power_w,
now() AS actual_filled_at,
CASE
WHEN v.is_curtailed_learning_slot THEN NULL
ELSE fpi.power_w - COALESCE(slot.avg_actual_w::INT, 0)
END AS error_w,
CASE
WHEN v.is_curtailed_learning_slot THEN NULL
WHEN slot.avg_actual_w IS NOT NULL
AND slot.avg_actual_w > 0
THEN ROUND(
@@ -37,10 +45,62 @@ BEGIN
4
)
ELSE NULL
END AS error_pct
END AS error_pct,
v.learning_eligible,
v.learning_exclude_reason
FROM ems.forecast_pv_interval fpi
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
JOIN ems.asset_pv_array pa ON pa.id = fpr.pv_array_id
LEFT JOIN ems.site_pv_forecast_calibration cal
ON cal.site_id = fpr.site_id
LEFT JOIN LATERAL (
SELECT
coalesce(cal.delta_learn_min_ts, timestamptz '2026-04-11T22:00:00Z') AS delta_learn_min_ts,
cal.pv_curtailment_policy_effective_from AS policy_from
) cal_eff ON true
LEFT JOIN LATERAL (
SELECT pi.pv_a_curtailed_w, pi.deye_gen_cutoff_enabled
FROM ems.planning_interval pi
JOIN ems.planning_run pr ON pr.id = pi.run_id
WHERE pr.site_id = fpr.site_id
AND pr.status = 'active'
AND pi.interval_start = fpi.interval_start
LIMIT 1
) ap ON true
LEFT JOIN LATERAL (
SELECT
(fpi.interval_start < cal_eff.delta_learn_min_ts) AS before_learn_cutoff,
(
cal_eff.policy_from IS NOT NULL
AND fpi.interval_start >= cal_eff.policy_from
AND (
coalesce(ap.pv_a_curtailed_w, 0) > 50
OR coalesce(ap.deye_gen_cutoff_enabled, false) IS TRUE
OR EXISTS (
SELECT 1
FROM ems.cutoff_switch_log l
WHERE l.site_id = fpr.site_id
AND l.switched_at >= fpi.interval_start
AND l.switched_at < fpi.interval_start + INTERVAL '15 minutes'
AND l.new_state IS FALSE
)
)
) AS is_curtailed_learning_slot
) flags ON true
LEFT JOIN LATERAL (
SELECT
CASE
WHEN flags.before_learn_cutoff THEN false
WHEN flags.is_curtailed_learning_slot THEN false
ELSE true
END AS learning_eligible,
CASE
WHEN flags.before_learn_cutoff THEN 'before_delta_learn_min'
WHEN flags.is_curtailed_learning_slot THEN 'curtailment_or_export_cutoff'
ELSE NULL
END AS learning_exclude_reason,
flags.is_curtailed_learning_slot
) v ON true
LEFT JOIN LATERAL (
SELECT AVG(
CASE
@@ -58,10 +118,12 @@ BEGIN
AND fpi.interval_start < now() - INTERVAL '15 minutes'
AND fpi.interval_start >= now() - make_interval(hours => p_lookback_hours)
ON CONFLICT (run_id, interval_start) DO UPDATE SET
actual_power_w = EXCLUDED.actual_power_w,
actual_filled_at = EXCLUDED.actual_filled_at,
error_w = EXCLUDED.error_w,
error_pct = EXCLUDED.error_pct;
actual_power_w = EXCLUDED.actual_power_w,
actual_filled_at = EXCLUDED.actual_filled_at,
error_w = EXCLUDED.error_w,
error_pct = EXCLUDED.error_pct,
learning_eligible = EXCLUDED.learning_eligible,
learning_exclude_reason = EXCLUDED.learning_exclude_reason;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
@@ -70,6 +132,8 @@ $$;
COMMENT ON FUNCTION ems.fn_fill_forecast_accuracy(INT, INT) IS
'Doplní skutečné hodnoty výroby do forecast_accuracy z telemetrie.
learning_eligible / learning_exclude_reason: před delta_learn_min_ts (kalibrace site) se nepočítá do učení delty;
po pv_curtailment_policy_effective_from sloty s curtailment / gen cutoff / cutoff_switch_log (export off) mají NULL actual a jsou vyloučeny z učení.
Volat každých 15 minut (spolu s audit_filler) pro inkrementální plnění.
p_lookback_hours: kolik hodin zpět zpracovat (default 48h pro catch-up).
Pro první backfill: SELECT ems.fn_fill_forecast_accuracy(2, 8760) -- 1 rok';

View File

@@ -44,7 +44,28 @@ declare
begin
drop table if exists _ems_plan_slot_wk;
create temp table _ems_plan_slot_wk on commit drop as
with slot_spine 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(
p_from,
@@ -108,9 +129,9 @@ begin
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 coalesce(sum(u.power_w), 0)::int as power_w
from (
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
@@ -124,12 +145,42 @@ begin
where apa.site_id = p_site_id
and apa.controllable is true
order by apa.id, fpr.created_at desc
) u
),
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 (
select coalesce(sum(u.power_w), 0)::int as power_w
from (
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
@@ -143,7 +194,37 @@ begin
where apa.site_id = p_site_id
and apa.controllable is false
order by apa.id, fpr.created_at desc
) u
),
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

View File

@@ -1,136 +1,153 @@
-- ============================================================
-- Profil systematické chyby PV forecastu po 15min slotu dne
-- (aditivní korekce: corrected = max(0, forecast - delta[slot]))
-- (aditivní korekce per pole: corrected_i = max(0, forecast_i - delta_i[slot]))
-- + součtový profil `deltas` pro starší klienty (součet delt přes pole).
-- ============================================================
drop function if exists ems.fn_pv_forecast_delta_profile;
DROP FUNCTION IF EXISTS ems.fn_pv_forecast_delta_profile;
create or replace function ems.fn_pv_forecast_delta_profile(
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,
p_top_n_days int default 2,
p_non_top_day_factor numeric default 0.02,
p_day_weight_gamma numeric default 1.0
p_data_to timestamptz DEFAULT now(),
p_half_life_days numeric DEFAULT 14,
p_threshold_w int DEFAULT 150,
p_top_n_days int DEFAULT 3,
p_non_top_day_factor numeric DEFAULT 0.02,
p_day_weight_gamma numeric DEFAULT 1.0
)
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
RETURNS jsonb
LANGUAGE sql
STABLE
AS $fn$
WITH eff AS (
SELECT
coalesce(cal.delta_learn_min_ts, timestamptz '2026-04-11T22:00:00Z') AS delta_learn_min_ts,
coalesce(cal.half_life_days, p_half_life_days) AS half_life_days,
coalesce(cal.threshold_w, p_threshold_w) AS threshold_w,
coalesce(cal.top_n_days, p_top_n_days) AS top_n_days,
coalesce(cal.non_top_day_factor, p_non_top_day_factor) AS non_top_day_factor,
coalesce(cal.day_weight_gamma, p_day_weight_gamma) AS day_weight_gamma
FROM ems.site s
LEFT JOIN ems.site_pv_forecast_calibration cal ON cal.site_id = s.id
WHERE s.id = p_site_id
),
-- Cutoff: učení delty jen od začátku kalendářního dne 2026-04-12 (Europe/Prague).
-- (UTC okamžik odpovídá DST v dubnu: půlnoc v Praze = předchozí den 22:00 UTC.)
-- Před tím mohou být v `forecast_accuracy` nekonzistentní historická data (telemetrie signed/unsigned).
cutoff as (
select timestamptz '2026-04-11T22:00:00Z' as min_ts
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
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
bounds AS (
SELECT
greatest(
p_data_from,
p_data_to - interval '120 days',
(SELECT delta_learn_min_ts FROM eff)
) AS ts_from,
p_data_to AS ts_to,
greatest((SELECT half_life_days FROM eff), 1::numeric) AS half_life_days,
greatest((SELECT threshold_w FROM eff), 0::numeric) 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
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
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
AND coalesce(fa.learning_eligible, true) IS TRUE
),
slots as (
select
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,
b.pv_array_id,
b.forecast_power_w::numeric AS forecast_w,
b.actual_power_w::numeric AS actual_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,
(b.interval_start at time zone tz.tz_name)::date as day_local,
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, day_local, tz.tz_name
(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,
(b.interval_start AT TIME ZONE tz.tz_name)::date AS day_local,
extract(epoch FROM (now() - b.interval_start)) / 86400.0 AS age_days
FROM best b
CROSS JOIN tz
WHERE b.rn = 1
),
-- Denní „clear-ish“ skóre: preferujeme dny s hladkou křivkou výroby a vysokou denní energií
-- relativně k ostatním dnům v okně (mraky dělají vysokofrekvenční šum na 15min, který není dobrý anchor pro slot bias).
day_energy as (
select
slot_totals AS (
SELECT
s.interval_start,
s.day_local,
sum(s.actual_total_w)::numeric / 4000.0 as energy_kwh
from slots s
group by s.day_local
s.slot_of_day,
max(s.age_days) AS age_days,
sum(s.forecast_w) AS forecast_total_w,
sum(s.actual_w) AS actual_total_w
FROM slots s
GROUP BY s.interval_start, s.day_local, s.slot_of_day
),
ref as (
select percentile_cont(0.5) within group (order by de.energy_kwh) as med_kwh
from day_energy de
day_energy AS (
SELECT st.day_local, sum(st.actual_total_w)::numeric / 4000.0 AS energy_kwh
FROM slot_totals st
GROUP BY st.day_local
),
slot_steps as (
select
s.*,
lag(s.actual_total_w) over (partition by s.day_local order by s.interval_start) as prev_actual_w
from slots s
where s.slot_of_day between 20 and 80
and s.actual_total_w > (select threshold_w from bounds)
ref AS (
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY de.energy_kwh) AS med_kwh
FROM day_energy de
),
day_jump as (
select
slot_steps AS (
SELECT
st.*,
lag(st.actual_total_w) OVER (PARTITION BY st.day_local ORDER BY st.interval_start) AS prev_actual_w
FROM slot_totals st
WHERE st.slot_of_day BETWEEN 20 AND 80
AND st.actual_total_w > (SELECT threshold_w FROM bounds)
),
day_jump AS (
SELECT
ss.day_local,
percentile_cont(0.5) within group (order by abs(ss.actual_total_w - ss.prev_actual_w)) as med_jump_w
from slot_steps ss
where ss.prev_actual_w is not null
group by ss.day_local
percentile_cont(0.5) WITHIN GROUP (ORDER BY abs(ss.actual_total_w - ss.prev_actual_w)) AS med_jump_w
FROM slot_steps ss
WHERE ss.prev_actual_w IS NOT NULL
GROUP BY ss.day_local
),
day_med as (
select
s.day_local,
percentile_cont(0.5) within group (order by s.actual_total_w) as p50_actual_w
from slots s
where s.actual_total_w > (select threshold_w from bounds)
group by s.day_local
day_med AS (
SELECT
st.day_local,
percentile_cont(0.5) WITHIN GROUP (ORDER BY st.actual_total_w) AS p50_actual_w
FROM slot_totals st
WHERE st.actual_total_w > (SELECT threshold_w FROM bounds)
GROUP BY st.day_local
),
day_stats as (
select
day_stats AS (
SELECT
de.day_local,
de.energy_kwh,
dj.med_jump_w,
dm.p50_actual_w,
case
when (select med_kwh from ref) is null or (select med_kwh from ref) <= 0 then 0.5
else greatest(
CASE
WHEN (SELECT med_kwh FROM ref) IS NULL OR (SELECT med_kwh FROM ref) <= 0 THEN 0.5
ELSE greatest(
0.0,
least(
1.0,
(de.energy_kwh - (select med_kwh from ref) * 0.55)
/ nullif((select med_kwh from ref) * 0.35, 0)
(de.energy_kwh - (SELECT med_kwh FROM ref) * 0.55)
/ nullif((SELECT med_kwh FROM ref) * 0.35, 0)
)
)
end as w_energy,
case
when dj.med_jump_w is null or dm.p50_actual_w is null then 0.35
else greatest(
END AS w_energy,
CASE
WHEN dj.med_jump_w IS NULL OR dm.p50_actual_w IS NULL THEN 0.35
ELSE greatest(
0.0,
least(
1.0,
@@ -141,34 +158,34 @@ as $fn$
)
)
)
end as w_smooth
from day_energy de
left join day_jump dj on dj.day_local = de.day_local
left join day_med dm on dm.day_local = de.day_local
END AS w_smooth
FROM day_energy de
LEFT JOIN day_jump dj ON dj.day_local = de.day_local
LEFT JOIN day_med dm ON dm.day_local = de.day_local
),
-- Volitelně: jen top N kalendářních dní podle (w_energy * w_smooth); zbytek ztlumit (bez hardcodu data).
day_rank as (
select
day_rank AS (
SELECT
ds.day_local,
row_number() over (
order by
(coalesce(ds.w_energy, 0.35) * coalesce(ds.w_smooth, 0.35)) desc,
ds.day_local desc
) as rn
from day_stats ds
row_number() OVER (
ORDER BY
(coalesce(ds.w_energy, 0.35) * coalesce(ds.w_smooth, 0.35)) DESC,
ds.day_local DESC
) AS rn
FROM day_stats ds
),
filtered as (
select
filtered AS (
SELECT
s.pv_array_id,
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))
(s.forecast_w - s.actual_w) AS error_w,
exp(-s.age_days / nullif((SELECT half_life_days FROM bounds), 0))
* (
case
when p_top_n_days is null then 1::numeric
when p_top_n_days < 1 then 1::numeric
when dr.rn <= p_top_n_days then 1::numeric
else greatest(0::numeric, least(1::numeric, coalesce(p_non_top_day_factor, 0.02)))
end
CASE
WHEN (SELECT top_n_days FROM eff) IS NULL THEN 1::numeric
WHEN (SELECT top_n_days FROM eff) < 1 THEN 1::numeric
WHEN dr.rn <= (SELECT top_n_days FROM eff) THEN 1::numeric
ELSE greatest(0::numeric, least(1::numeric, coalesce((SELECT non_top_day_factor FROM eff), 0.02)))
END
)
* (
0.05
@@ -178,53 +195,93 @@ as $fn$
0.0,
least(1.0, coalesce(ds.w_energy, 0.35) * coalesce(ds.w_smooth, 0.35))
),
greatest(0.25, least(coalesce(p_day_weight_gamma, 1.0), 8.0))
greatest(0.25, least(coalesce((SELECT day_weight_gamma FROM eff), 1.0), 8.0))
)
) as w
from slots s
cross join bounds b
left join day_stats ds on ds.day_local = s.day_local
left join day_rank dr on dr.day_local = s.day_local
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)
) AS w
FROM slots s
CROSS JOIN bounds b
CROSS JOIN eff
JOIN slot_totals st ON st.interval_start = s.interval_start
LEFT JOIN day_stats ds ON ds.day_local = s.day_local
LEFT JOIN day_rank dr ON dr.day_local = s.day_local
WHERE s.slot_of_day BETWEEN 0 AND 95
AND (s.actual_w > b.threshold_w OR s.forecast_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
agg_by_array AS (
SELECT
f.pv_array_id,
f.slot_of_day,
count(*) AS sample_count,
sum(f.w) AS w_sum,
CASE
WHEN sum(f.w) > 0 THEN sum(f.error_w * f.w) / sum(f.w)
ELSE NULL
END AS delta_w
FROM filtered f
GROUP BY f.pv_array_id, f.slot_of_day
),
spine as (
select generate_series(0, 95) as slot_of_day
agg_total AS (
SELECT
sp.slot_of_day,
sum(coalesce(ab.sample_count, 0))::bigint AS sample_count,
sum(coalesce(round(ab.delta_w)::int, 0))::int AS delta_w
FROM generate_series(0, 95) AS sp(slot_of_day)
LEFT JOIN agg_by_array ab ON ab.slot_of_day = sp.slot_of_day
GROUP BY sp.slot_of_day
),
arrays_block AS (
SELECT coalesce(jsonb_object_agg(apa.id::text, arr.pack), '{}'::jsonb) AS deltas_by_array
FROM ems.asset_pv_array apa
CROSS JOIN LATERAL (
SELECT jsonb_build_object(
'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
)
) AS pack
FROM generate_series(0, 95) AS sp(slot_of_day)
LEFT JOIN agg_by_array a
ON a.pv_array_id = apa.id
AND a.slot_of_day = sp.slot_of_day
) arr
WHERE apa.site_id = p_site_id
),
spine AS (
SELECT generate_series(0, 95) AS slot_of_day
)
select jsonb_build_object(
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),
'data_from', (SELECT ts_from FROM bounds),
'data_to', (SELECT ts_to FROM bounds),
'delta_learn_min_ts', (SELECT delta_learn_min_ts FROM eff),
'half_life_days', (SELECT half_life_days FROM bounds),
'threshold_w', (SELECT threshold_w FROM bounds),
'top_n_days', (SELECT top_n_days FROM eff),
'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)
'delta_w', coalesce(at.delta_w, 0),
'sample_count', coalesce(at.sample_count, 0)
)
order by sp.slot_of_day
ORDER BY sp.slot_of_day
),
'[]'::jsonb
)
),
'deltas_by_array', (SELECT deltas_by_array FROM arrays_block)
)
from spine sp
left join agg a on a.slot_of_day = sp.slot_of_day;
FROM spine sp
LEFT JOIN agg_total at ON at.slot_of_day = sp.slot_of_day;
$fn$;
comment on function ems.fn_pv_forecast_delta_profile is
'Aditivní delta profil chyby PV forecastu po 15min slotu dne (96 slotů). Zdroj: forecast_accuracy, vážení exp(-age/half_life_days) * day_weight (clear-ish dny) * top_n_days (default 3 = jen 3 nejlepší kalendářní dny podle w_energy*w_smooth, ostatní ztlumené non_top_day_factor; explicitní NULL = tier vypnut, váží se všechny dny) * power(day_weight, day_weight_gamma). Vrací JSON {deltas:[{slot_of_day, delta_w, sample_count}], ...}. Cutoff dat od 2026-04-12 Europe/Prague.';
COMMENT ON FUNCTION ems.fn_pv_forecast_delta_profile IS
'Aditivní delta profil PV forecastu po 15min slotu dne (96 slotů) per pv_array_id v `deltas_by_array`; `deltas` je součet delt přes pole (kompatibilita). Zdroj: forecast_accuracy s learning_eligible, cutoff a numerické defaulty z ems.site_pv_forecast_calibration (NULL sloupce = parametry volání).';

View File

@@ -1,125 +1,156 @@
-- ============================================================
-- PV forecast sloty (15min) + aditivně korigovaný forecast
-- corrected = max(0, forecast - delta_profile[slot_of_day])
-- corrected = sum_i max(0, forecast_i - delta_profile_i[slot_of_day])
-- ============================================================
drop function if exists ems.fn_forecast_pv_slots_range_corrected;
DROP FUNCTION IF EXISTS ems.fn_forecast_pv_slots_range_corrected;
create or replace function ems.fn_forecast_pv_slots_range_corrected(
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
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
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
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
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
),
slot_spine as (
select gs as interval_start
from bounds b,
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
) 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
fc_by_array AS (
SELECT DISTINCT ON (fpi.interval_start, fpr.pv_array_id)
fpi.interval_start,
fpr.pv_array_id,
fpi.power_w::bigint AS 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
),
profile as (
select ems.fn_pv_forecast_delta_profile(
fc_totals AS (
SELECT u.interval_start, coalesce(sum(u.power_w), 0)::bigint AS pv_forecast_total_w
FROM fc_by_array 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
) 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(
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
),
corrected AS (
SELECT
s.interval_start,
coalesce(ft.pv_forecast_total_w, 0)::bigint AS pv_forecast_total_w,
coalesce(
CASE
WHEN EXISTS (SELECT 1 FROM delta_by_array LIMIT 1) THEN (
SELECT sum(greatest(0, fa.power_w - coalesce(d.delta_w, 0)))::bigint
FROM fc_by_array fa
CROSS JOIN tz
LEFT JOIN delta_by_array d
ON d.pv_array_id = fa.pv_array_id
AND 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
)
WHERE fa.interval_start = s.interval_start
)
ELSE greatest(
0,
coalesce(fc.pv_forecast_total_w, 0)::int
coalesce(ft.pv_forecast_total_w, 0)::bigint
- coalesce(
(
select d.delta_w
from deltas d
cross join tz
where d.slot_of_day = (
SELECT d.delta_w
FROM deltas_legacy 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
(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
)
),
)
END,
0
)::bigint AS pv_forecast_corrected_w
FROM slot_spine s
LEFT JOIN fc_totals ft ON ft.interval_start = s.interval_start
)
SELECT coalesce(
jsonb_agg(
jsonb_build_object(
'interval_start', c.interval_start,
'pv_forecast_total_w', c.pv_forecast_total_w,
'pv_forecast_corrected_w', c.pv_forecast_corrected_w,
'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
(extract(hour FROM (c.interval_start AT TIME ZONE tz.tz_name))::int * 60)
+ extract(minute FROM (c.interval_start AT TIME ZONE tz.tz_name))::int
) / 15
)
)
order by s.interval_start
ORDER BY c.interval_start
),
'[]'::jsonb
)
from slot_spine s
cross join tz
left join fc on fc.interval_start = s.interval_start;
FROM corrected c
CROSS JOIN tz;
$fn$;
comment on function ems.fn_forecast_pv_slots_range_corrected 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 (parametry delty = defaulty v té funkci). Horizont je omezený na max. 60 dní.';
COMMENT ON FUNCTION ems.fn_forecast_pv_slots_range_corrected 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 per pv_array_id z fn_pv_forecast_delta_profile.deltas_by_array (fallback na jedno pole `deltas`). Horizont max. 60 dní.';

View File

@@ -28,6 +28,7 @@ GRANT SELECT ON ems.vw_operating_mode TO ems_anon;
GRANT SELECT ON ems.vw_telemetry_hourly_7d TO ems_anon;
GRANT SELECT ON ems.vw_telemetry_15m_7d TO ems_anon;
GRANT SELECT ON ems.forecast_accuracy TO ems_anon;
GRANT SELECT ON ems.site_pv_forecast_calibration TO ems_anon;
GRANT SELECT ON ems.vw_forecast_accuracy_by_lead_time TO ems_anon;
GRANT SELECT ON ems.vw_forecast_accuracy_daily TO ems_anon;
GRANT SELECT ON ems.consumption_baseline_stats TO ems_anon;