From 568b5847485aca497b33559cb9f12ca5b522af11 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Wed, 22 Apr 2026 22:17:28 +0200 Subject: [PATCH] kalibrace per pole --- backend/app/routers/sites.py | 87 +++++ .../V057__site_pv_forecast_calibration.sql | 41 ++ .../R__022_fn_fill_forecast_accuracy.sql | 82 +++- .../R__063_fn_load_planning_slots_full.sql | 95 ++++- .../R__078_fn_pv_forecast_delta_profile.sql | 365 ++++++++++-------- ...9_fn_forecast_pv_slots_range_corrected.sql | 193 +++++---- .../R__072_z_postgrest_ems_anon_grants.sql | 1 + docs/04-modules/forecast.md | 1 + docs/04-modules/planning.md | 1 + docs/05-todo.md | 10 +- frontend/src/api/backend.ts | 43 +++ .../analysis/pv_delta_profile_diagnostics.sql | 53 ++- 12 files changed, 705 insertions(+), 267 deletions(-) create mode 100644 db/migration/V057__site_pv_forecast_calibration.sql diff --git a/backend/app/routers/sites.py b/backend/app/routers/sites.py index 85c96b2..30c5997 100644 --- a/backend/app/routers/sites.py +++ b/backend/app/routers/sites.py @@ -604,6 +604,93 @@ async def get_site_forecast_pv_slots_range_corrected( 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") async def get_site_telemetry_15m_range( site_id: int, diff --git a/db/migration/V057__site_pv_forecast_calibration.sql b/db/migration/V057__site_pv_forecast_calibration.sql new file mode 100644 index 0000000..a018b08 --- /dev/null +++ b/db/migration/V057__site_pv_forecast_calibration.sql @@ -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; diff --git a/db/routines/R__022_fn_fill_forecast_accuracy.sql b/db/routines/R__022_fn_fill_forecast_accuracy.sql index 308d045..a99d346 100644 --- a/db/routines/R__022_fn_fill_forecast_accuracy.sql +++ b/db/routines/R__022_fn_fill_forecast_accuracy.sql @@ -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'; diff --git a/db/routines/R__063_fn_load_planning_slots_full.sql b/db/routines/R__063_fn_load_planning_slots_full.sql index 7777559..7a8ae24 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -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 diff --git a/db/routines/R__078_fn_pv_forecast_delta_profile.sql b/db/routines/R__078_fn_pv_forecast_delta_profile.sql index bf6fe1c..ec0aed6 100644 --- a/db/routines/R__078_fn_pv_forecast_delta_profile.sql +++ b/db/routines/R__078_fn_pv_forecast_delta_profile.sql @@ -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í).'; diff --git a/db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql b/db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql index 4c62743..dea2268 100644 --- a/db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql +++ b/db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql @@ -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í.'; diff --git a/db/views/R__072_z_postgrest_ems_anon_grants.sql b/db/views/R__072_z_postgrest_ems_anon_grants.sql index 6587ac1..1bdc5e3 100644 --- a/db/views/R__072_z_postgrest_ems_anon_grants.sql +++ b/db/views/R__072_z_postgrest_ems_anon_grants.sql @@ -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; diff --git a/docs/04-modules/forecast.md b/docs/04-modules/forecast.md index f583b88..0c81881 100644 --- a/docs/04-modules/forecast.md +++ b/docs/04-modules/forecast.md @@ -98,6 +98,7 @@ def calculate_pv_power( - Runtime guard: hodnota se clampuje do rozmezí `2..16`. - 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ů. +- **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). --- diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index e6281df..44bdefc 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -27,6 +27,7 @@ - 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. - **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á: - pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie) diff --git a/docs/05-todo.md b/docs/05-todo.md index 5e08516..efc10fd 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -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. | | **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. | -| **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 | --- diff --git a/frontend/src/api/backend.ts b/frontend/src/api/backend.ts index 0febe6f..1b079d6 100644 --- a/frontend/src/api/backend.ts +++ b/frontend/src/api/backend.ts @@ -117,6 +117,7 @@ export async function getForecastPvSlotsRange( 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 = { interval_start: string pv_forecast_total_w?: number | null @@ -124,6 +125,26 @@ export type ForecastPvSlotCorrectedRow = { 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 +} + export type ForecastPvSlotsCorrectedParams = { delta_from?: string delta_to?: string @@ -144,6 +165,28 @@ export async function getForecastPvSlotsRangeCorrected( 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 { + const { data } = await client.get( + `/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 = { slot_start: string site_id: number diff --git a/scripts/analysis/pv_delta_profile_diagnostics.sql b/scripts/analysis/pv_delta_profile_diagnostics.sql index 7866a61..90f137f 100644 --- a/scripts/analysis/pv_delta_profile_diagnostics.sql +++ b/scripts/analysis/pv_delta_profile_diagnostics.sql @@ -1,5 +1,6 @@ -- 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. -- 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 JOIN params p ON s.id = p.site_id ), -cutoff AS ( - SELECT timestamptz '2026-04-11T22:00:00Z' AS min_ts +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.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 ( 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, - greatest(p.half_life_days, 1) AS half_life_days, - greatest(p.threshold_w, 0) AS threshold_w + greatest(e.half_life_days, 1::numeric) AS half_life_days, + greatest(e.threshold_w, 0::numeric) AS threshold_w FROM params p + CROSS JOIN eff e ), best AS ( SELECT @@ -49,12 +60,14 @@ best AS ( 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 ( +slots_array 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 @@ -64,7 +77,17 @@ slots AS ( FROM best b CROSS JOIN tz 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 ( 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)) * ( CASE - WHEN (SELECT p_top_n_days FROM params) IS NULL THEN 1::numeric - WHEN (SELECT p_top_n_days FROM params) < 1 THEN 1::numeric - WHEN dr.rn <= (SELECT p_top_n_days FROM params) THEN 1::numeric + 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 p_non_top_day_factor FROM params), 0.02)) + least(1::numeric, coalesce((SELECT non_top_day_factor FROM eff), 0.02)) ) END ) @@ -171,7 +194,7 @@ filtered AS ( ), greatest( 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