kalibrace per pole
This commit is contained in:
@@ -604,6 +604,93 @@ async def get_site_forecast_pv_slots_range_corrected(
|
|||||||
return {"slots": [s for s in slots if isinstance(s, dict)]}
|
return {"slots": [s for s in slots if isinstance(s, dict)]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{site_id}/forecast/pv-delta-profile")
|
||||||
|
async def get_site_forecast_pv_delta_profile(
|
||||||
|
site_id: int,
|
||||||
|
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
|
from_ts: datetime = Query(
|
||||||
|
...,
|
||||||
|
alias="from",
|
||||||
|
description="Začátek okna historie pro výpočet delty [from, to)",
|
||||||
|
),
|
||||||
|
to_ts: datetime = Query(
|
||||||
|
...,
|
||||||
|
alias="to",
|
||||||
|
description="Konec okna (max. 120 dní za from; typicky now)",
|
||||||
|
),
|
||||||
|
half_life_days: float = Query(
|
||||||
|
14,
|
||||||
|
ge=1,
|
||||||
|
le=90,
|
||||||
|
description="Half-life vážení (dny) pro delta profil",
|
||||||
|
),
|
||||||
|
threshold_w: int = Query(
|
||||||
|
150,
|
||||||
|
ge=0,
|
||||||
|
le=10_000,
|
||||||
|
description="Ignorovat sloty s nízkou výrobou (W) při odhadu profilu",
|
||||||
|
),
|
||||||
|
top_n_days: int | None = Query(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
le=31,
|
||||||
|
description="Top N kalendářních dní podle day_score (NULL = z kalibrace / výchozí funkce)",
|
||||||
|
),
|
||||||
|
non_top_day_factor: float | None = Query(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
le=1,
|
||||||
|
description="Ztlumení vah mimo top N (NULL = z kalibrace / default)",
|
||||||
|
),
|
||||||
|
day_weight_gamma: float | None = Query(
|
||||||
|
None,
|
||||||
|
ge=0.25,
|
||||||
|
le=8,
|
||||||
|
description="Exponent na day_weight (NULL = z kalibrace / default)",
|
||||||
|
),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""JSON z `ems.fn_pv_forecast_delta_profile` (`deltas`, `deltas_by_array`, cutoff z DB)."""
|
||||||
|
if to_ts <= from_ts:
|
||||||
|
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
|
||||||
|
if to_ts - from_ts > timedelta(days=120):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Span between 'from' and 'to' must be at most 120 days",
|
||||||
|
)
|
||||||
|
async with db.acquire() as conn:
|
||||||
|
site_ok = await conn.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||||
|
)
|
||||||
|
if not site_ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
raw = await fetch_json(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
select ems.fn_pv_forecast_delta_profile(
|
||||||
|
$1::int,
|
||||||
|
$2::timestamptz,
|
||||||
|
$3::timestamptz,
|
||||||
|
$4::numeric,
|
||||||
|
$5::int,
|
||||||
|
$6::int,
|
||||||
|
$7::numeric,
|
||||||
|
$8::numeric
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
from_ts,
|
||||||
|
to_ts,
|
||||||
|
half_life_days,
|
||||||
|
threshold_w,
|
||||||
|
top_n_days,
|
||||||
|
non_top_day_factor,
|
||||||
|
day_weight_gamma,
|
||||||
|
)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {}
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{site_id}/timeseries/telemetry-15m")
|
@router.get("/{site_id}/timeseries/telemetry-15m")
|
||||||
async def get_site_telemetry_15m_range(
|
async def get_site_telemetry_15m_range(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
|
|||||||
41
db/migration/V057__site_pv_forecast_calibration.sql
Normal file
41
db/migration/V057__site_pv_forecast_calibration.sql
Normal 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;
|
||||||
@@ -12,7 +12,8 @@ BEGIN
|
|||||||
site_id, pv_array_id, interval_start, run_id,
|
site_id, pv_array_id, interval_start, run_id,
|
||||||
forecast_power_w, forecast_created_at, lead_time_hours,
|
forecast_power_w, forecast_created_at, lead_time_hours,
|
||||||
actual_power_w, actual_filled_at,
|
actual_power_w, actual_filled_at,
|
||||||
error_w, error_pct
|
error_w, error_pct,
|
||||||
|
learning_eligible, learning_exclude_reason
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
fpr.site_id,
|
fpr.site_id,
|
||||||
@@ -25,10 +26,17 @@ BEGIN
|
|||||||
EXTRACT(EPOCH FROM (fpi.interval_start - fpr.created_at))
|
EXTRACT(EPOCH FROM (fpi.interval_start - fpr.created_at))
|
||||||
/ 3600.0, 2
|
/ 3600.0, 2
|
||||||
) AS lead_time_hours,
|
) 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
|
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
|
WHEN slot.avg_actual_w IS NOT NULL
|
||||||
AND slot.avg_actual_w > 0
|
AND slot.avg_actual_w > 0
|
||||||
THEN ROUND(
|
THEN ROUND(
|
||||||
@@ -37,10 +45,62 @@ BEGIN
|
|||||||
4
|
4
|
||||||
)
|
)
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END AS error_pct
|
END AS error_pct,
|
||||||
|
v.learning_eligible,
|
||||||
|
v.learning_exclude_reason
|
||||||
FROM ems.forecast_pv_interval fpi
|
FROM ems.forecast_pv_interval fpi
|
||||||
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
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
|
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 (
|
LEFT JOIN LATERAL (
|
||||||
SELECT AVG(
|
SELECT AVG(
|
||||||
CASE
|
CASE
|
||||||
@@ -61,7 +121,9 @@ BEGIN
|
|||||||
actual_power_w = EXCLUDED.actual_power_w,
|
actual_power_w = EXCLUDED.actual_power_w,
|
||||||
actual_filled_at = EXCLUDED.actual_filled_at,
|
actual_filled_at = EXCLUDED.actual_filled_at,
|
||||||
error_w = EXCLUDED.error_w,
|
error_w = EXCLUDED.error_w,
|
||||||
error_pct = EXCLUDED.error_pct;
|
error_pct = EXCLUDED.error_pct,
|
||||||
|
learning_eligible = EXCLUDED.learning_eligible,
|
||||||
|
learning_exclude_reason = EXCLUDED.learning_exclude_reason;
|
||||||
|
|
||||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||||
RETURN v_count;
|
RETURN v_count;
|
||||||
@@ -70,6 +132,8 @@ $$;
|
|||||||
|
|
||||||
COMMENT ON FUNCTION ems.fn_fill_forecast_accuracy(INT, INT) IS
|
COMMENT ON FUNCTION ems.fn_fill_forecast_accuracy(INT, INT) IS
|
||||||
'Doplní skutečné hodnoty výroby do forecast_accuracy z telemetrie.
|
'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í.
|
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).
|
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';
|
Pro první backfill: SELECT ems.fn_fill_forecast_accuracy(2, 8760) -- 1 rok';
|
||||||
|
|||||||
@@ -44,7 +44,28 @@ declare
|
|||||||
begin
|
begin
|
||||||
drop table if exists _ems_plan_slot_wk;
|
drop table if exists _ems_plan_slot_wk;
|
||||||
create temp table _ems_plan_slot_wk on commit drop as
|
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
|
select gs as interval_start
|
||||||
from generate_series(
|
from generate_series(
|
||||||
p_from,
|
p_from,
|
||||||
@@ -108,9 +129,9 @@ begin
|
|||||||
left join ems.vw_site_effective_price ep
|
left join ems.vw_site_effective_price ep
|
||||||
on ep.site_id = p_site_id and ep.interval_start = s.interval_start
|
on ep.site_id = p_site_id and ep.interval_start = s.interval_start
|
||||||
left join lateral (
|
left join lateral (
|
||||||
select coalesce(sum(u.power_w), 0)::int as power_w
|
with uq as (
|
||||||
from (
|
|
||||||
select distinct on (apa.id)
|
select distinct on (apa.id)
|
||||||
|
apa.id as pv_array_id,
|
||||||
fpi.power_w
|
fpi.power_w
|
||||||
from ems.asset_pv_array apa
|
from ems.asset_pv_array apa
|
||||||
join ems.forecast_pv_run fpr
|
join ems.forecast_pv_run fpr
|
||||||
@@ -124,12 +145,42 @@ begin
|
|||||||
where apa.site_id = p_site_id
|
where apa.site_id = p_site_id
|
||||||
and apa.controllable is true
|
and apa.controllable is true
|
||||||
order by apa.id, fpr.created_at desc
|
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
|
) fpi_a on true
|
||||||
left join lateral (
|
left join lateral (
|
||||||
select coalesce(sum(u.power_w), 0)::int as power_w
|
with uq as (
|
||||||
from (
|
|
||||||
select distinct on (apa.id)
|
select distinct on (apa.id)
|
||||||
|
apa.id as pv_array_id,
|
||||||
fpi.power_w
|
fpi.power_w
|
||||||
from ems.asset_pv_array apa
|
from ems.asset_pv_array apa
|
||||||
join ems.forecast_pv_run fpr
|
join ems.forecast_pv_run fpr
|
||||||
@@ -143,7 +194,37 @@ begin
|
|||||||
where apa.site_id = p_site_id
|
where apa.site_id = p_site_id
|
||||||
and apa.controllable is false
|
and apa.controllable is false
|
||||||
order by apa.id, fpr.created_at desc
|
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
|
) fpi_b on true
|
||||||
left join lateral (
|
left join lateral (
|
||||||
select t.status
|
select t.status
|
||||||
|
|||||||
@@ -1,136 +1,153 @@
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- Profil systematické chyby PV forecastu po 15min slotu dne
|
-- 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_site_id int,
|
||||||
p_data_from timestamptz,
|
p_data_from timestamptz,
|
||||||
p_data_to timestamptz default now(),
|
p_data_to timestamptz DEFAULT now(),
|
||||||
p_half_life_days numeric default 14,
|
p_half_life_days numeric DEFAULT 14,
|
||||||
p_threshold_w int default 150,
|
p_threshold_w int DEFAULT 150,
|
||||||
p_top_n_days int default 2,
|
p_top_n_days int DEFAULT 3,
|
||||||
p_non_top_day_factor numeric default 0.02,
|
p_non_top_day_factor numeric DEFAULT 0.02,
|
||||||
p_day_weight_gamma numeric default 1.0
|
p_day_weight_gamma numeric DEFAULT 1.0
|
||||||
)
|
)
|
||||||
returns jsonb
|
RETURNS jsonb
|
||||||
language sql
|
LANGUAGE sql
|
||||||
stable
|
STABLE
|
||||||
as $fn$
|
AS $fn$
|
||||||
with tz as (
|
WITH eff AS (
|
||||||
select coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague') as tz_name
|
SELECT
|
||||||
from ems.site s
|
coalesce(cal.delta_learn_min_ts, timestamptz '2026-04-11T22:00:00Z') AS delta_learn_min_ts,
|
||||||
where s.id = p_site_id
|
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).
|
tz AS (
|
||||||
-- (UTC okamžik odpovídá DST v dubnu: půlnoc v Praze = předchozí den 22:00 UTC.)
|
SELECT coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague') AS tz_name
|
||||||
-- Před tím mohou být v `forecast_accuracy` nekonzistentní historická data (telemetrie signed/unsigned).
|
FROM ems.site s
|
||||||
cutoff as (
|
WHERE s.id = p_site_id
|
||||||
select timestamptz '2026-04-11T22:00:00Z' as min_ts
|
|
||||||
),
|
),
|
||||||
bounds as (
|
bounds AS (
|
||||||
select
|
SELECT
|
||||||
greatest(p_data_from, p_data_to - interval '120 days', (select min_ts from cutoff)) as ts_from,
|
greatest(
|
||||||
p_data_to as ts_to,
|
p_data_from,
|
||||||
greatest(p_half_life_days, 1) as half_life_days,
|
p_data_to - interval '120 days',
|
||||||
greatest(p_threshold_w, 0) as threshold_w
|
(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
|
best AS (
|
||||||
-- vybereme nejnovější forecast (forecast_created_at) který je <= interval_start (lead_time >= 0)
|
SELECT
|
||||||
best as (
|
|
||||||
select
|
|
||||||
fa.interval_start,
|
fa.interval_start,
|
||||||
fa.pv_array_id,
|
fa.pv_array_id,
|
||||||
fa.forecast_power_w,
|
fa.forecast_power_w,
|
||||||
fa.actual_power_w,
|
fa.actual_power_w,
|
||||||
fa.forecast_created_at,
|
fa.forecast_created_at,
|
||||||
row_number() over (
|
row_number() OVER (
|
||||||
partition by fa.interval_start, fa.pv_array_id
|
PARTITION BY fa.interval_start, fa.pv_array_id
|
||||||
order by fa.forecast_created_at desc
|
ORDER BY fa.forecast_created_at DESC
|
||||||
) as rn
|
) AS rn
|
||||||
from ems.forecast_accuracy fa
|
FROM ems.forecast_accuracy fa
|
||||||
cross join bounds b
|
CROSS JOIN bounds b
|
||||||
where fa.site_id = p_site_id
|
WHERE fa.site_id = p_site_id
|
||||||
and fa.interval_start >= b.ts_from
|
AND fa.interval_start >= b.ts_from
|
||||||
and fa.interval_start < b.ts_to
|
AND fa.interval_start < b.ts_to
|
||||||
and fa.actual_power_w is not null
|
AND fa.actual_power_w IS NOT NULL
|
||||||
and fa.forecast_created_at <= fa.interval_start
|
AND fa.forecast_created_at <= fa.interval_start
|
||||||
|
AND coalesce(fa.learning_eligible, true) IS TRUE
|
||||||
),
|
),
|
||||||
slots as (
|
slots AS (
|
||||||
select
|
SELECT
|
||||||
b.interval_start,
|
b.interval_start,
|
||||||
sum(b.forecast_power_w)::numeric as forecast_total_w,
|
b.pv_array_id,
|
||||||
sum(b.actual_power_w)::numeric as actual_total_w,
|
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(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
|
+ extract(minute FROM (b.interval_start AT TIME ZONE tz.tz_name))::int
|
||||||
) / 15 as slot_of_day,
|
) / 15 AS slot_of_day,
|
||||||
(b.interval_start at time zone tz.tz_name)::date as day_local,
|
(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
|
extract(epoch FROM (now() - b.interval_start)) / 86400.0 AS age_days
|
||||||
from best b
|
FROM best b
|
||||||
cross join tz
|
CROSS JOIN tz
|
||||||
where b.rn = 1
|
WHERE b.rn = 1
|
||||||
group by b.interval_start, slot_of_day, day_local, tz.tz_name
|
|
||||||
),
|
),
|
||||||
-- Denní „clear-ish“ skóre: preferujeme dny s hladkou křivkou výroby a vysokou denní energií
|
slot_totals AS (
|
||||||
-- relativně k ostatním dnům v okně (mraky dělají vysokofrekvenční šum na 15min, který není dobrý anchor pro slot bias).
|
SELECT
|
||||||
day_energy as (
|
s.interval_start,
|
||||||
select
|
|
||||||
s.day_local,
|
s.day_local,
|
||||||
sum(s.actual_total_w)::numeric / 4000.0 as energy_kwh
|
s.slot_of_day,
|
||||||
from slots s
|
max(s.age_days) AS age_days,
|
||||||
group by s.day_local
|
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 (
|
day_energy AS (
|
||||||
select percentile_cont(0.5) within group (order by de.energy_kwh) as med_kwh
|
SELECT st.day_local, sum(st.actual_total_w)::numeric / 4000.0 AS energy_kwh
|
||||||
from day_energy de
|
FROM slot_totals st
|
||||||
|
GROUP BY st.day_local
|
||||||
),
|
),
|
||||||
slot_steps as (
|
ref AS (
|
||||||
select
|
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY de.energy_kwh) AS med_kwh
|
||||||
s.*,
|
FROM day_energy de
|
||||||
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)
|
|
||||||
),
|
),
|
||||||
day_jump as (
|
slot_steps AS (
|
||||||
select
|
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,
|
ss.day_local,
|
||||||
percentile_cont(0.5) within group (order by abs(ss.actual_total_w - ss.prev_actual_w)) as med_jump_w
|
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
|
FROM slot_steps ss
|
||||||
where ss.prev_actual_w is not null
|
WHERE ss.prev_actual_w IS NOT NULL
|
||||||
group by ss.day_local
|
GROUP BY ss.day_local
|
||||||
),
|
),
|
||||||
day_med as (
|
day_med AS (
|
||||||
select
|
SELECT
|
||||||
s.day_local,
|
st.day_local,
|
||||||
percentile_cont(0.5) within group (order by s.actual_total_w) as p50_actual_w
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY st.actual_total_w) AS p50_actual_w
|
||||||
from slots s
|
FROM slot_totals st
|
||||||
where s.actual_total_w > (select threshold_w from bounds)
|
WHERE st.actual_total_w > (SELECT threshold_w FROM bounds)
|
||||||
group by s.day_local
|
GROUP BY st.day_local
|
||||||
),
|
),
|
||||||
day_stats as (
|
day_stats AS (
|
||||||
select
|
SELECT
|
||||||
de.day_local,
|
de.day_local,
|
||||||
de.energy_kwh,
|
de.energy_kwh,
|
||||||
dj.med_jump_w,
|
dj.med_jump_w,
|
||||||
dm.p50_actual_w,
|
dm.p50_actual_w,
|
||||||
case
|
CASE
|
||||||
when (select med_kwh from ref) is null or (select med_kwh from ref) <= 0 then 0.5
|
WHEN (SELECT med_kwh FROM ref) IS NULL OR (SELECT med_kwh FROM ref) <= 0 THEN 0.5
|
||||||
else greatest(
|
ELSE greatest(
|
||||||
0.0,
|
0.0,
|
||||||
least(
|
least(
|
||||||
1.0,
|
1.0,
|
||||||
(de.energy_kwh - (select med_kwh from ref) * 0.55)
|
(de.energy_kwh - (SELECT med_kwh FROM ref) * 0.55)
|
||||||
/ nullif((select med_kwh from ref) * 0.35, 0)
|
/ nullif((SELECT med_kwh FROM ref) * 0.35, 0)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end as w_energy,
|
END AS w_energy,
|
||||||
case
|
CASE
|
||||||
when dj.med_jump_w is null or dm.p50_actual_w is null then 0.35
|
WHEN dj.med_jump_w IS NULL OR dm.p50_actual_w IS NULL THEN 0.35
|
||||||
else greatest(
|
ELSE greatest(
|
||||||
0.0,
|
0.0,
|
||||||
least(
|
least(
|
||||||
1.0,
|
1.0,
|
||||||
@@ -141,34 +158,34 @@ as $fn$
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end as w_smooth
|
END AS w_smooth
|
||||||
from day_energy de
|
FROM day_energy de
|
||||||
left join day_jump dj on dj.day_local = de.day_local
|
LEFT JOIN day_jump dj ON dj.day_local = de.day_local
|
||||||
left join day_med dm on dm.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 (
|
||||||
day_rank as (
|
SELECT
|
||||||
select
|
|
||||||
ds.day_local,
|
ds.day_local,
|
||||||
row_number() over (
|
row_number() OVER (
|
||||||
order by
|
ORDER BY
|
||||||
(coalesce(ds.w_energy, 0.35) * coalesce(ds.w_smooth, 0.35)) desc,
|
(coalesce(ds.w_energy, 0.35) * coalesce(ds.w_smooth, 0.35)) DESC,
|
||||||
ds.day_local desc
|
ds.day_local DESC
|
||||||
) as rn
|
) AS rn
|
||||||
from day_stats ds
|
FROM day_stats ds
|
||||||
),
|
),
|
||||||
filtered as (
|
filtered AS (
|
||||||
select
|
SELECT
|
||||||
|
s.pv_array_id,
|
||||||
s.slot_of_day,
|
s.slot_of_day,
|
||||||
(s.forecast_total_w - s.actual_total_w) as error_w,
|
(s.forecast_w - s.actual_w) AS error_w,
|
||||||
exp(-s.age_days / nullif((select half_life_days from bounds), 0))
|
exp(-s.age_days / nullif((SELECT half_life_days FROM bounds), 0))
|
||||||
* (
|
* (
|
||||||
case
|
CASE
|
||||||
when p_top_n_days is null then 1::numeric
|
WHEN (SELECT top_n_days FROM eff) IS NULL THEN 1::numeric
|
||||||
when p_top_n_days < 1 then 1::numeric
|
WHEN (SELECT top_n_days FROM eff) < 1 THEN 1::numeric
|
||||||
when dr.rn <= p_top_n_days then 1::numeric
|
WHEN dr.rn <= (SELECT top_n_days FROM eff) THEN 1::numeric
|
||||||
else greatest(0::numeric, least(1::numeric, coalesce(p_non_top_day_factor, 0.02)))
|
ELSE greatest(0::numeric, least(1::numeric, coalesce((SELECT non_top_day_factor FROM eff), 0.02)))
|
||||||
end
|
END
|
||||||
)
|
)
|
||||||
* (
|
* (
|
||||||
0.05
|
0.05
|
||||||
@@ -178,37 +195,45 @@ as $fn$
|
|||||||
0.0,
|
0.0,
|
||||||
least(1.0, coalesce(ds.w_energy, 0.35) * coalesce(ds.w_smooth, 0.35))
|
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
|
) AS w
|
||||||
from slots s
|
FROM slots s
|
||||||
cross join bounds b
|
CROSS JOIN bounds b
|
||||||
left join day_stats ds on ds.day_local = s.day_local
|
CROSS JOIN eff
|
||||||
left join day_rank dr on dr.day_local = s.day_local
|
JOIN slot_totals st ON st.interval_start = s.interval_start
|
||||||
where s.slot_of_day between 0 and 95
|
LEFT JOIN day_stats ds ON ds.day_local = s.day_local
|
||||||
and (s.actual_total_w > b.threshold_w or s.forecast_total_w > b.threshold_w)
|
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 (
|
agg_by_array AS (
|
||||||
select
|
SELECT
|
||||||
slot_of_day,
|
f.pv_array_id,
|
||||||
count(*) as sample_count,
|
f.slot_of_day,
|
||||||
sum(w) as w_sum,
|
count(*) AS sample_count,
|
||||||
case
|
sum(f.w) AS w_sum,
|
||||||
when sum(w) > 0 then sum(error_w * w) / sum(w)
|
CASE
|
||||||
else null
|
WHEN sum(f.w) > 0 THEN sum(f.error_w * f.w) / sum(f.w)
|
||||||
end as delta_w
|
ELSE NULL
|
||||||
from filtered
|
END AS delta_w
|
||||||
group by slot_of_day
|
FROM filtered f
|
||||||
|
GROUP BY f.pv_array_id, f.slot_of_day
|
||||||
),
|
),
|
||||||
spine as (
|
agg_total AS (
|
||||||
select generate_series(0, 95) as slot_of_day
|
SELECT
|
||||||
)
|
sp.slot_of_day,
|
||||||
select jsonb_build_object(
|
sum(coalesce(ab.sample_count, 0))::bigint AS sample_count,
|
||||||
'site_id', p_site_id,
|
sum(coalesce(round(ab.delta_w)::int, 0))::int AS delta_w
|
||||||
'data_from', (select ts_from from bounds),
|
FROM generate_series(0, 95) AS sp(slot_of_day)
|
||||||
'data_to', (select ts_to from bounds),
|
LEFT JOIN agg_by_array ab ON ab.slot_of_day = sp.slot_of_day
|
||||||
'half_life_days', (select half_life_days from bounds),
|
GROUP BY sp.slot_of_day
|
||||||
'threshold_w', (select threshold_w from bounds),
|
),
|
||||||
|
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',
|
'deltas',
|
||||||
coalesce(
|
coalesce(
|
||||||
jsonb_agg(
|
jsonb_agg(
|
||||||
@@ -217,14 +242,46 @@ as $fn$
|
|||||||
'delta_w', coalesce(round(a.delta_w)::int, 0),
|
'delta_w', coalesce(round(a.delta_w)::int, 0),
|
||||||
'sample_count', coalesce(a.sample_count, 0)
|
'sample_count', coalesce(a.sample_count, 0)
|
||||||
)
|
)
|
||||||
order by sp.slot_of_day
|
ORDER BY sp.slot_of_day
|
||||||
),
|
),
|
||||||
'[]'::jsonb
|
'[]'::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
|
||||||
)
|
)
|
||||||
from spine sp
|
SELECT jsonb_build_object(
|
||||||
left join agg a on a.slot_of_day = sp.slot_of_day;
|
'site_id', p_site_id,
|
||||||
|
'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(at.delta_w, 0),
|
||||||
|
'sample_count', coalesce(at.sample_count, 0)
|
||||||
|
)
|
||||||
|
ORDER BY sp.slot_of_day
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'deltas_by_array', (SELECT deltas_by_array FROM arrays_block)
|
||||||
|
)
|
||||||
|
FROM spine sp
|
||||||
|
LEFT JOIN agg_total at ON at.slot_of_day = sp.slot_of_day;
|
||||||
$fn$;
|
$fn$;
|
||||||
|
|
||||||
comment on function ems.fn_pv_forecast_delta_profile is
|
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.';
|
'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í).';
|
||||||
|
|||||||
@@ -1,125 +1,156 @@
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- PV forecast sloty (15min) + aditivně korigovaný forecast
|
-- 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_site_id int,
|
||||||
p_from timestamptz,
|
p_from timestamptz,
|
||||||
p_to timestamptz,
|
p_to timestamptz,
|
||||||
p_delta_data_from timestamptz,
|
p_delta_data_from timestamptz,
|
||||||
p_delta_data_to timestamptz default now(),
|
p_delta_data_to timestamptz DEFAULT now(),
|
||||||
p_half_life_days numeric default 14,
|
p_half_life_days numeric DEFAULT 14,
|
||||||
p_threshold_w int default 150
|
p_threshold_w int DEFAULT 150
|
||||||
)
|
)
|
||||||
returns jsonb
|
RETURNS jsonb
|
||||||
language sql
|
LANGUAGE sql
|
||||||
stable
|
STABLE
|
||||||
as $fn$
|
AS $fn$
|
||||||
with tz as (
|
WITH tz AS (
|
||||||
select coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague') as tz_name
|
SELECT coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague') AS tz_name
|
||||||
from ems.site s
|
FROM ems.site s
|
||||||
where s.id = p_site_id
|
WHERE s.id = p_site_id
|
||||||
),
|
),
|
||||||
bounds as (
|
bounds AS (
|
||||||
select
|
SELECT
|
||||||
date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') as ts_from,
|
date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') AS ts_from,
|
||||||
case
|
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 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'
|
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')
|
ELSE date_bin(interval '15 minutes', p_to, timestamptz '1970-01-01T00:00:00Z')
|
||||||
end as ts_to
|
END AS ts_to
|
||||||
),
|
),
|
||||||
slot_spine as (
|
slot_spine AS (
|
||||||
select gs as interval_start
|
SELECT gs AS interval_start
|
||||||
from bounds b,
|
FROM bounds b,
|
||||||
generate_series(
|
generate_series(
|
||||||
b.ts_from,
|
b.ts_from,
|
||||||
(b.ts_to - interval '15 minutes')::timestamptz,
|
(b.ts_to - interval '15 minutes')::timestamptz,
|
||||||
interval '15 minutes'
|
interval '15 minutes'
|
||||||
) as gs
|
) AS gs
|
||||||
),
|
),
|
||||||
fc as (
|
fc_by_array AS (
|
||||||
select
|
SELECT DISTINCT ON (fpi.interval_start, fpr.pv_array_id)
|
||||||
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.interval_start,
|
||||||
fpi.power_w
|
fpr.pv_array_id,
|
||||||
from ems.forecast_pv_interval fpi
|
fpi.power_w::bigint AS power_w
|
||||||
join ems.forecast_pv_run fpr on fpr.id = fpi.run_id
|
FROM ems.forecast_pv_interval fpi
|
||||||
join ems.asset_pv_array apa
|
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
||||||
on apa.id = fpr.pv_array_id
|
JOIN ems.asset_pv_array apa
|
||||||
and apa.site_id = fpr.site_id
|
ON apa.id = fpr.pv_array_id
|
||||||
cross join bounds b
|
AND apa.site_id = fpr.site_id
|
||||||
where fpr.site_id = p_site_id
|
CROSS JOIN bounds b
|
||||||
and fpr.status = 'ok'
|
WHERE fpr.site_id = p_site_id
|
||||||
and fpi.interval_start >= b.ts_from
|
AND fpr.status = 'ok'
|
||||||
and fpi.interval_start < b.ts_to
|
AND fpi.interval_start >= b.ts_from
|
||||||
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
|
AND fpi.interval_start < b.ts_to
|
||||||
) u
|
ORDER BY fpi.interval_start, fpr.pv_array_id, fpr.created_at DESC
|
||||||
group by u.interval_start
|
|
||||||
),
|
),
|
||||||
profile as (
|
fc_totals AS (
|
||||||
select ems.fn_pv_forecast_delta_profile(
|
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_site_id,
|
||||||
p_delta_data_from,
|
p_delta_data_from,
|
||||||
p_delta_data_to,
|
p_delta_data_to,
|
||||||
p_half_life_days,
|
p_half_life_days,
|
||||||
p_threshold_w
|
p_threshold_w
|
||||||
) as j
|
) AS j
|
||||||
),
|
),
|
||||||
deltas as (
|
delta_by_array AS (
|
||||||
select
|
SELECT (kv.key)::int AS pv_array_id,
|
||||||
(x->>'slot_of_day')::int as slot_of_day,
|
(x->>'slot_of_day')::int AS slot_of_day,
|
||||||
(x->>'delta_w')::int as delta_w,
|
(x->>'delta_w')::int AS delta_w
|
||||||
(x->>'sample_count')::int as sample_count
|
FROM profile p
|
||||||
from profile p
|
CROSS JOIN LATERAL jsonb_each((p.j)->'deltas_by_array') kv(key, value)
|
||||||
cross join lateral jsonb_array_elements(p.j->'deltas') as x
|
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
|
||||||
)
|
)
|
||||||
select coalesce(
|
WHERE fa.interval_start = s.interval_start
|
||||||
jsonb_agg(
|
)
|
||||||
jsonb_build_object(
|
ELSE greatest(
|
||||||
'interval_start', s.interval_start,
|
|
||||||
'pv_forecast_total_w', coalesce(fc.pv_forecast_total_w, 0),
|
|
||||||
'pv_forecast_corrected_w',
|
|
||||||
greatest(
|
|
||||||
0,
|
0,
|
||||||
coalesce(fc.pv_forecast_total_w, 0)::int
|
coalesce(ft.pv_forecast_total_w, 0)::bigint
|
||||||
- coalesce(
|
- coalesce(
|
||||||
(
|
(
|
||||||
select d.delta_w
|
SELECT d.delta_w
|
||||||
from deltas d
|
FROM deltas_legacy d
|
||||||
cross join tz
|
CROSS JOIN tz
|
||||||
where d.slot_of_day = (
|
WHERE d.slot_of_day = (
|
||||||
(
|
(
|
||||||
(extract(hour from (s.interval_start at time zone tz.tz_name))::int * 60)
|
(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(minute FROM (s.interval_start AT TIME ZONE tz.tz_name))::int
|
||||||
) / 15
|
) / 15
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
0
|
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',
|
'slot_of_day',
|
||||||
(
|
(
|
||||||
(
|
(
|
||||||
(extract(hour from (s.interval_start at time zone tz.tz_name))::int * 60)
|
(extract(hour FROM (c.interval_start AT TIME ZONE tz.tz_name))::int * 60)
|
||||||
+ extract(minute from (s.interval_start at time zone tz.tz_name))::int
|
+ extract(minute FROM (c.interval_start AT TIME ZONE tz.tz_name))::int
|
||||||
) / 15
|
) / 15
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
order by s.interval_start
|
ORDER BY c.interval_start
|
||||||
),
|
),
|
||||||
'[]'::jsonb
|
'[]'::jsonb
|
||||||
)
|
)
|
||||||
from slot_spine s
|
FROM corrected c
|
||||||
cross join tz
|
CROSS JOIN tz;
|
||||||
left join fc on fc.interval_start = s.interval_start;
|
|
||||||
$fn$;
|
$fn$;
|
||||||
|
|
||||||
comment on function ems.fn_forecast_pv_slots_range_corrected is
|
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í.';
|
'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í.';
|
||||||
|
|||||||
@@ -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_hourly_7d TO ems_anon;
|
||||||
GRANT SELECT ON ems.vw_telemetry_15m_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.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_by_lead_time TO ems_anon;
|
||||||
GRANT SELECT ON ems.vw_forecast_accuracy_daily TO ems_anon;
|
GRANT SELECT ON ems.vw_forecast_accuracy_daily TO ems_anon;
|
||||||
GRANT SELECT ON ems.consumption_baseline_stats TO ems_anon;
|
GRANT SELECT ON ems.consumption_baseline_stats TO ems_anon;
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ def calculate_pv_power(
|
|||||||
- Runtime guard: hodnota se clampuje do rozmezí `2..16`.
|
- Runtime guard: hodnota se clampuje do rozmezí `2..16`.
|
||||||
- Default je `7` dní.
|
- Default je `7` dní.
|
||||||
- Endpoint `GET /api/v1/sites/{site_id}/forecast/pv?date=YYYY-MM-DD` vrací vždy poslední `ok` run per `(interval_start, pv_array_id)` (`DISTINCT ON`), takže UI nevidí duplikáty z historických běhů.
|
- Endpoint `GET /api/v1/sites/{site_id}/forecast/pv?date=YYYY-MM-DD` vrací vždy poslední `ok` run per `(interval_start, pv_array_id)` (`DISTINCT ON`), takže UI nevidí duplikáty z historických běhů.
|
||||||
|
- **Kalibrace delty:** `GET /api/v1/sites/{site_id}/forecast/pv-delta-profile?from=…&to=…` vrací JSON z `ems.fn_pv_forecast_delta_profile` (`deltas`, `deltas_by_array`, `delta_learn_min_ts` z `ems.site_pv_forecast_calibration`). Volitelné query parametry: `half_life_days`, `threshold_w`, `top_n_days`, `non_top_day_factor`, `day_weight_gamma` (NULL u numerických přepsání = hodnota z kalibrační tabulky / default funkce).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
- horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu).
|
- horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu).
|
||||||
- **Uložené vstupy plánu** (`planning_interval`): `load_baseline_w`, `pv_*_forecast_raw_w`, `pv_*_forecast_solver_w` pro UI a audit.
|
- **Uložené vstupy plánu** (`planning_interval`): `load_baseline_w`, `pv_*_forecast_raw_w`, `pv_*_forecast_solver_w` pro UI a audit.
|
||||||
- **Více FVE polí s různou orientací:** `planning_engine._load_slots` sčítá predikovaný výkon za 15min přes **všechna** `asset_pv_array` dané lokality — `pv_a_forecast_w` = součet řádků s `controllable = true`, `pv_b_forecast_w` = součet s `controllable = false`. Pro každé pole a slot se bere **nejnovější** `forecast_pv_run` (`ORDER BY created_at DESC`, `DISTINCT ON (pv_array_id)`). Curtailment v LP zůstává **jedno** agregované `pv_a` (součet řiditelných polí); per-string curtailment by vyžadovalo rozšíření modelu.
|
- **Více FVE polí s různou orientací:** `planning_engine._load_slots` sčítá predikovaný výkon za 15min přes **všechna** `asset_pv_array` dané lokality — `pv_a_forecast_w` = součet řádků s `controllable = true`, `pv_b_forecast_w` = součet s `controllable = false`. Pro každé pole a slot se bere **nejnovější** `forecast_pv_run` (`ORDER BY created_at DESC`, `DISTINCT ON (pv_array_id)`). Curtailment v LP zůstává **jedno** agregované `pv_a` (součet řiditelných polí); per-string curtailment by vyžadovalo rozšíření modelu.
|
||||||
|
- **Kalibrace PV forecastu (delta profil):** tabulka `ems.site_pv_forecast_calibration` drží per `site_id` mimo jiné `delta_learn_min_ts` (dolní mez řádků z `forecast_accuracy` pro učení delty), volitelně `pv_curtailment_policy_effective_from` a přepsání parametrů (`top_n_days`, `half_life_days`, …). `ems.fn_fill_forecast_accuracy` nastavuje `learning_eligible` / `learning_exclude_reason` (sloty před cutoffem, nebo se škrcením / gen cut-off / záznamem v `ems.cutoff_switch_log` po účinnosti policy se z učení vyřadí; u škrcení zůstává `actual_power_w` NULL). `ems.fn_pv_forecast_delta_profile` vrací `deltas_by_array` i součtové `deltas`; `ems.fn_load_planning_slots_full` aplikuje stejnou **per-pole** korekci jako UI (`fn_forecast_pv_slots_range_corrected`); pokud v JSON profilu chybí `deltas_by_array`, použije se souhrnné `deltas` rozpuštěné podle podílu výkonu pole na slotu (solver má tak stále použitou korekci i bez per-pole JSON).
|
||||||
|
|
||||||
Solver optimalizuje celý horizont (typicky do konce známých OTE dat, strop z `fn_planning_horizon_end`) najednou, čímž přirozeně zvládá:
|
Solver optimalizuje celý horizont (typicky do konce známých OTE dat, strop z `fn_planning_horizon_end`) najednou, čímž přirozeně zvládá:
|
||||||
- pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie)
|
- pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie)
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec
|
|||||||
| **Telemetry – výroba FVE:** Registry 672/673/667 jsou **signed** W; `pv_power_w` = max(0,pv1)+max(0,pv2)+max(0,gen) (dashboard); sloupce pv1/pv2/gen ukládají signed pro audit. |
|
| **Telemetry – výroba FVE:** Registry 672/673/667 jsou **signed** W; `pv_power_w` = max(0,pv1)+max(0,pv2)+max(0,gen) (dashboard); sloupce pv1/pv2/gen ukládají signed pro audit. |
|
||||||
| **Ekonomika baterie:** snížení `reserve_soc_percent` na 10 % a `degradation_cost_czk_kwh` na 0.1500 (migrace `V026__battery_economics_tuning.sql`), úpravy objective pro ekonomicky konzistentnější nabíjení/vybíjení. |
|
| **Ekonomika baterie:** snížení `reserve_soc_percent` na 10 % a `degradation_cost_czk_kwh` na 0.1500 (migrace `V026__battery_economics_tuning.sql`), úpravy objective pro ekonomicky konzistentnější nabíjení/vybíjení. |
|
||||||
| **Planning UI operátor akce:** trvale viditelné akce import/forecast/init plan, volba data OTE (dnes/zítra), zobrazení `pv_scarcity_factor` ve stavu plánu. |
|
| **Planning UI operátor akce:** trvale viditelné akce import/forecast/init plan, volba data OTE (dnes/zítra), zobrazení `pv_scarcity_factor` ve stavu plánu. |
|
||||||
| **PV delta profil – cutoff historie:** minimální začátek učení delty je **2026-04-12 (Europe/Prague)** (UTC `2026-04-11T22:00:00Z`); cutoff je zafixovaný v `db/routines/R__078_fn_pv_forecast_delta_profile.sql` (ignoruje starší data i při širším `p_data_from`). |
|
| **PV delta profil – kalibrace per site:** cutoff a parametry učení jsou v `ems.site_pv_forecast_calibration` (seed výchozí `delta_learn_min_ts` = UTC `2026-04-11T22:00:00Z`); `R__078` / `fn_fill_forecast_accuracy` respektují `learning_eligible` a škrcení. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Budoucí vylepšení (PV kalibrace)
|
||||||
|
|
||||||
|
| Popis | Kde | Kdo |
|
||||||
|
|-------|-----|-----|
|
||||||
|
| Telemetrické flagy derating (místo heuristiky z `planning_interval`), volitelné rozšíření API o korigovaný výkon per `pv_array_id` v grafu. | `db/routines/R__022_fn_fill_forecast_accuracy.sql`, collector | programátor |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export async function getForecastPvSlotsRange(
|
|||||||
return Array.isArray(data?.slots) ? data.slots : []
|
return Array.isArray(data?.slots) ? data.slots : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Řádek z GET /sites/{id}/forecast/pv-slots-corrected — backend může doplnit další pole. */
|
||||||
export type ForecastPvSlotCorrectedRow = {
|
export type ForecastPvSlotCorrectedRow = {
|
||||||
interval_start: string
|
interval_start: string
|
||||||
pv_forecast_total_w?: number | null
|
pv_forecast_total_w?: number | null
|
||||||
@@ -124,6 +125,26 @@ export type ForecastPvSlotCorrectedRow = {
|
|||||||
slot_of_day?: number | null
|
slot_of_day?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jedna položka slot profilu z `ems.fn_pv_forecast_delta_profile` (JSON). */
|
||||||
|
export type PvDeltaProfileSlotEntry = {
|
||||||
|
slot_of_day: number
|
||||||
|
delta_w: number
|
||||||
|
sample_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Volitelný JSON profilu delty (ladění / budoucí UI); `deltas` = součet přes pole, `deltas_by_array` = per pole. */
|
||||||
|
export type PvForecastDeltaProfileJson = {
|
||||||
|
site_id?: number
|
||||||
|
data_from?: string
|
||||||
|
data_to?: string
|
||||||
|
delta_learn_min_ts?: string
|
||||||
|
half_life_days?: number
|
||||||
|
threshold_w?: number
|
||||||
|
top_n_days?: number | null
|
||||||
|
deltas?: PvDeltaProfileSlotEntry[]
|
||||||
|
deltas_by_array?: Record<string, { deltas: PvDeltaProfileSlotEntry[] }>
|
||||||
|
}
|
||||||
|
|
||||||
export type ForecastPvSlotsCorrectedParams = {
|
export type ForecastPvSlotsCorrectedParams = {
|
||||||
delta_from?: string
|
delta_from?: string
|
||||||
delta_to?: string
|
delta_to?: string
|
||||||
@@ -144,6 +165,28 @@ export async function getForecastPvSlotsRangeCorrected(
|
|||||||
return Array.isArray(data?.slots) ? data.slots : []
|
return Array.isArray(data?.slots) ? data.slots : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PvDeltaProfileQueryParams = {
|
||||||
|
half_life_days?: number
|
||||||
|
threshold_w?: number
|
||||||
|
top_n_days?: number | null
|
||||||
|
non_top_day_factor?: number | null
|
||||||
|
day_weight_gamma?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /sites/{id}/forecast/pv-delta-profile — přímo JSON z `ems.fn_pv_forecast_delta_profile`. */
|
||||||
|
export async function getPvForecastDeltaProfile(
|
||||||
|
siteId: number,
|
||||||
|
fromIso: string,
|
||||||
|
toIso: string,
|
||||||
|
params?: PvDeltaProfileQueryParams,
|
||||||
|
): Promise<PvForecastDeltaProfileJson> {
|
||||||
|
const { data } = await client.get<PvForecastDeltaProfileJson>(
|
||||||
|
`/sites/${siteId}/forecast/pv-delta-profile`,
|
||||||
|
{ params: { from: fromIso, to: toIso, ...params }, timeout: 45_000 },
|
||||||
|
)
|
||||||
|
return data != null && typeof data === 'object' ? data : {}
|
||||||
|
}
|
||||||
|
|
||||||
export type Telemetry15mRow = {
|
export type Telemetry15mRow = {
|
||||||
slot_start: string
|
slot_start: string
|
||||||
site_id: number
|
site_id: number
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
-- Diagnostika: z kterých kalendářních dní (Europe/Prague) se skládá váha pro delta profil
|
-- Diagnostika: z kterých kalendářních dní (Europe/Prague) se skládá váha pro delta profil
|
||||||
-- (stejná logika jako ems.fn_pv_forecast_delta_profile: best → slots → day_stats → day_rank → váhy w).
|
-- (zarovnáno s ems.fn_pv_forecast_delta_profile: eff z site_pv_forecast_calibration, best s learning_eligible,
|
||||||
|
-- agregace slotů na úroveň site pro day_rank / váhy w — stejné jako slot_totals v R__078).
|
||||||
--
|
--
|
||||||
-- Uprav params (site_id, okno, half_life, threshold, top_n_days / non_top / gamma) a spusť v psql.
|
-- Uprav params (site_id, okno, half_life, threshold, top_n_days / non_top / gamma) a spusť v psql.
|
||||||
-- Jedna řádka = jeden kalendářní den v okně; p_top_n_days mění tier u vah (ne počet řádků).
|
-- Jedna řádka = jeden kalendářní den v okně; p_top_n_days mění tier u vah (ne počet řádků).
|
||||||
@@ -20,16 +21,26 @@ tz AS (
|
|||||||
FROM ems.site s
|
FROM ems.site s
|
||||||
JOIN params p ON s.id = p.site_id
|
JOIN params p ON s.id = p.site_id
|
||||||
),
|
),
|
||||||
cutoff AS (
|
eff AS (
|
||||||
SELECT timestamptz '2026-04-11T22:00:00Z' AS min_ts
|
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.p_top_n_days) AS top_n_days,
|
||||||
|
coalesce(cal.non_top_day_factor, p.p_non_top_day_factor) AS non_top_day_factor,
|
||||||
|
coalesce(cal.day_weight_gamma, p.p_day_weight_gamma) AS day_weight_gamma
|
||||||
|
FROM params p
|
||||||
|
JOIN ems.site s ON s.id = p.site_id
|
||||||
|
LEFT JOIN ems.site_pv_forecast_calibration cal ON cal.site_id = s.id
|
||||||
),
|
),
|
||||||
bounds AS (
|
bounds AS (
|
||||||
SELECT
|
SELECT
|
||||||
greatest(p.p_data_from, p.p_data_to - interval '120 days', (SELECT min_ts FROM cutoff)) AS ts_from,
|
greatest(p.p_data_from, p.p_data_to - interval '120 days', e.delta_learn_min_ts) AS ts_from,
|
||||||
p.p_data_to AS ts_to,
|
p.p_data_to AS ts_to,
|
||||||
greatest(p.half_life_days, 1) AS half_life_days,
|
greatest(e.half_life_days, 1::numeric) AS half_life_days,
|
||||||
greatest(p.threshold_w, 0) AS threshold_w
|
greatest(e.threshold_w, 0::numeric) AS threshold_w
|
||||||
FROM params p
|
FROM params p
|
||||||
|
CROSS JOIN eff e
|
||||||
),
|
),
|
||||||
best AS (
|
best AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -49,12 +60,14 @@ best AS (
|
|||||||
AND fa.interval_start < b.ts_to
|
AND fa.interval_start < b.ts_to
|
||||||
AND fa.actual_power_w IS NOT NULL
|
AND fa.actual_power_w IS NOT NULL
|
||||||
AND fa.forecast_created_at <= fa.interval_start
|
AND fa.forecast_created_at <= fa.interval_start
|
||||||
|
AND coalesce(fa.learning_eligible, true) IS TRUE
|
||||||
),
|
),
|
||||||
slots AS (
|
slots_array AS (
|
||||||
SELECT
|
SELECT
|
||||||
b.interval_start,
|
b.interval_start,
|
||||||
sum(b.forecast_power_w)::numeric AS forecast_total_w,
|
b.pv_array_id,
|
||||||
sum(b.actual_power_w)::numeric AS actual_total_w,
|
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(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
|
+ extract(minute FROM (b.interval_start AT TIME ZONE tz.tz_name))::int
|
||||||
@@ -64,7 +77,17 @@ slots AS (
|
|||||||
FROM best b
|
FROM best b
|
||||||
CROSS JOIN tz
|
CROSS JOIN tz
|
||||||
WHERE b.rn = 1
|
WHERE b.rn = 1
|
||||||
GROUP BY b.interval_start, slot_of_day, day_local, tz.tz_name
|
),
|
||||||
|
slots AS (
|
||||||
|
SELECT
|
||||||
|
sa.interval_start,
|
||||||
|
sum(sa.forecast_w)::numeric AS forecast_total_w,
|
||||||
|
sum(sa.actual_w)::numeric AS actual_total_w,
|
||||||
|
sa.slot_of_day,
|
||||||
|
sa.day_local,
|
||||||
|
max(sa.age_days) AS age_days
|
||||||
|
FROM slots_array sa
|
||||||
|
GROUP BY sa.interval_start, sa.slot_of_day, sa.day_local
|
||||||
),
|
),
|
||||||
day_energy AS (
|
day_energy AS (
|
||||||
SELECT s.day_local, sum(s.actual_total_w)::numeric / 4000.0 AS energy_kwh
|
SELECT s.day_local, sum(s.actual_total_w)::numeric / 4000.0 AS energy_kwh
|
||||||
@@ -152,12 +175,12 @@ filtered AS (
|
|||||||
exp(-s.age_days / nullif((SELECT half_life_days FROM bounds), 0))
|
exp(-s.age_days / nullif((SELECT half_life_days FROM bounds), 0))
|
||||||
* (
|
* (
|
||||||
CASE
|
CASE
|
||||||
WHEN (SELECT p_top_n_days FROM params) IS NULL THEN 1::numeric
|
WHEN (SELECT top_n_days FROM eff) IS NULL THEN 1::numeric
|
||||||
WHEN (SELECT p_top_n_days FROM params) < 1 THEN 1::numeric
|
WHEN (SELECT top_n_days FROM eff) < 1 THEN 1::numeric
|
||||||
WHEN dr.rn <= (SELECT p_top_n_days FROM params) THEN 1::numeric
|
WHEN dr.rn <= (SELECT top_n_days FROM eff) THEN 1::numeric
|
||||||
ELSE greatest(
|
ELSE greatest(
|
||||||
0::numeric,
|
0::numeric,
|
||||||
least(1::numeric, coalesce((SELECT p_non_top_day_factor FROM params), 0.02))
|
least(1::numeric, coalesce((SELECT non_top_day_factor FROM eff), 0.02))
|
||||||
)
|
)
|
||||||
END
|
END
|
||||||
)
|
)
|
||||||
@@ -171,7 +194,7 @@ filtered AS (
|
|||||||
),
|
),
|
||||||
greatest(
|
greatest(
|
||||||
0.25,
|
0.25,
|
||||||
least(coalesce((SELECT p_day_weight_gamma FROM params), 1.0), 8.0)
|
least(coalesce((SELECT day_weight_gamma FROM eff), 1.0), 8.0)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
) AS w
|
) AS w
|
||||||
|
|||||||
Reference in New Issue
Block a user