From 5b383e902836a2b734604e61e819bf0bb2d5d285 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Tue, 5 May 2026 10:42:49 +0200 Subject: [PATCH] sjednoceni forecastu --- ...nify_pv_correction_source_95b01fce.plan.md | 97 ++++++++ backend/services/planning_engine.py | 39 ++- db/routines/R__033_fn_plan_current_bundle.sql | 82 +++++-- .../R__063_fn_load_planning_slots_full.sql | 146 ++--------- ...n_forecast_pv_slots_range_canonical_ab.sql | 230 ++++++++++++++++++ docs/04-modules/forecast.md | 13 + docs/04-modules/planning.md | 2 +- frontend/src/pages/Planning.tsx | 102 ++------ frontend/src/types/plan.ts | 3 + 9 files changed, 461 insertions(+), 253 deletions(-) create mode 100644 .cursor/plans/unify_pv_correction_source_95b01fce.plan.md create mode 100644 db/routines/R__088_fn_forecast_pv_slots_range_canonical_ab.sql diff --git a/.cursor/plans/unify_pv_correction_source_95b01fce.plan.md b/.cursor/plans/unify_pv_correction_source_95b01fce.plan.md new file mode 100644 index 0000000..7b687e1 --- /dev/null +++ b/.cursor/plans/unify_pv_correction_source_95b01fce.plan.md @@ -0,0 +1,97 @@ +--- +name: Unify PV correction source +status: draft +owner: cursor-agent +--- + +## Cíl + +Uděláme **single source of truth** pro PV forecast používaný v plánování tak, aby: + +- **solver** i **UI** četly *stejnou* PV řadu (žádné „dvě korekce dvěma cestami“), +- kanonický výpočet byl v **PostgreSQL**, +- výsledky byly auditovatelné (raw vs delta vs rolling-factor + decay). + +## Zjištěný problém (dnes) + +- Solver používá PV z DB + **multiplikativní rolling faktor** v Pythonu (`compute_correction_factor` + `apply_forecast_correction` v `backend/services/planning_engine.py`). +- UI (Planning tabulka) zobrazuje PV přes endpoint **delta-korekce** (`/forecast/pv-slots-corrected` → `ems.fn_forecast_pv_slots_range_corrected`), což může být jiné číslo než PV, se kterým solver počítal. +- Důsledek: v tabulce slotů nesedí výkonová bilance (UI ukáže např. 5.9 kW, ale plán implicitně pracuje s ~10.4 kW). + +## Cílové chování (nová kanonická DB řada) + +Kanonické PV pro plánování definujeme jako kombinaci obou korekcí: + +1. **Delta-korekce (aditivní)** per PV array (odečíst `delta_profile[slot_of_day]`, clamp na 0) +2. Agregace do **PV-A / PV-B** podle `ems.asset_pv_array.controllable` +3. **Rolling faktor (multiplikativní)** z `ems.fn_pv_forecast_correction_factor(...)` aplikovaný na PV-A i PV-B +4. **Decay (lineární útlum)** faktoru podle offsetu slotu od `now` (stejná logika jako dnes v `apply_forecast_correction`) + +Výstup této kanonické řady se musí propsat do: + +- `ems.fn_load_planning_slots_full` (vstup pro solver), +- `ems.fn_plan_current_bundle` (výstup pro UI), +- a do uložených sloupců `planning_interval.pv_*_forecast_solver_w` (audit). + +## Návrh DB API (kanonická funkce) + +Přidat novou repeatable rutinu, např.: + +- `db/routines/R__0xx_fn_forecast_pv_slots_range_canonical_ab.sql` + +Funkce vrátí JSON pole slotů pro `[from, to)` s minimálně: + +- `interval_start` +- `pv_a_forecast_raw_w`, `pv_b_forecast_raw_w` +- `pv_a_forecast_delta_w`, `pv_b_forecast_delta_w` (po delta-korekci) +- `rolling_factor` (globální faktor) + `rolling_effective_factor` (po decay pro slot) +- `pv_a_forecast_canonical_w`, `pv_b_forecast_canonical_w` (delta × rolling_effective_factor) + +Poznámky: + +- Delta profil už dnes existuje (`ems.fn_pv_forecast_delta_profile`) a `fn_forecast_pv_slots_range_corrected` už umí per-array delty; tu logiku zrecyklujeme. +- Rolling faktor už dnes existuje v DB (`ems.fn_pv_forecast_correction_factor`), jen se dnes aplikuje v Pythonu. +- Decay parametrizovat (např. `p_decay_slots int default 16`, `p_min_clamp numeric`, `p_max_clamp numeric`, `p_window_h numeric`). + +## Změny solveru (Python) + +V `backend/services/planning_engine.py`: + +- Přepnout loader PV slotů na kanonickou DB řadu (A/B corrected). +- **Odstranit** aplikaci PV korekce v Pythonu (nebo ji dočasně nechat za feature flagem jen jako fallback při chybě DB funkce). +- Uložit do `planning_run` diagnostiku (např. `forecast_correction_factor` nahradit/rozšířit o `pv_forecast_method = 'canonical_db_delta+rolling'` + `rolling_factor`). + +## Změny DB pro plánovací sloty a current bundle + +- `db/routines/R__063_fn_load_planning_slots_full.sql`: + - zdroj PV A/B musí být `pv_*_forecast_canonical_w` z nové funkce. + - zachovat raw/solver sloupce pro audit a UI. +- `ems.fn_plan_current_bundle` (repeatable rutina ve `db/routines/`, dohledat a upravit): + - pro intervaly z `planning_interval` vracet explicitně: + - `pv_a_forecast_solver_w`, `pv_b_forecast_solver_w`, a `pv_forecast_total_w = pva+pvb` (aby UI nemuselo „domýšlet“ přes jiné endpointy), + - pro sloty za horizontem (forecast extension) vracet `pv_forecast_total_w` jako **kanonický součet** (canonical A+B) z nové funkce. + +## Změny UI + +V `frontend/src/pages/Planning.tsx`: + +- Pro tabulku slotů a graf použít **jen** PV z `/sites/{id}/plan/current`: + - pro plánované sloty: `pv_a_forecast_solver_w + pv_b_forecast_solver_w` (ne `pv-slots-corrected`), + - pro forecast-only sloty: `pv_forecast_total_w` (které už bude kanonické z DB). +- Endpoint `/forecast/pv-slots-corrected` ponechat pro stránku forecastu a diagnostiku, ale **ne** jako zdroj pro Planning tabulku. + +## Ověření + +- Pro konkrétní slot (např. `home-01`, `10:15` Prague) musí sedět: + - UI PV (z `/plan/current`) == PV v solver vstupu == uložené `planning_interval.pv_*_forecast_solver_w`. +- Výkonová bilance v tabulce slotů: `PV - load - EV - HP = battery + export(+/- import)` bez „magické energie“. +- Doplnit regresní test: UI zobrazuje stejné PV jako `planning_interval` (alespoň na DTO úrovni / snapshot). + +## Dokumentace + +Aktualizovat: + +- `docs/04-modules/forecast.md` (kde vzniká kanonické PV: delta + rolling factor + decay), +- `docs/04-modules/planning.md` (solver čte kanonický PV z DB; UI používá stejné sloupce z `/plan/current`), +- případně krátká poznámka do `docs/02-architecture.md` k „read-model = single point of truth“ pro plán. + diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 17d6843..80c83ac 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -1157,12 +1157,17 @@ async def run_rolling_replan( await _load_site_context(site_id, db) ) - correction_factor, correction_log = await compute_correction_factor(site_id, now, db) - slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh) - slots_before_pv_correction = list(slots) - - slots = apply_forecast_correction(slots, now, correction_factor) + # PV forecast korekce je kanonicky v DB (delta + rolling faktor + decay), + # viz ems.fn_forecast_pv_slots_range_canonical_ab a ems.fn_load_planning_slots_full. + correction_factor, correction_log = 1.0, { + "window_start": None, + "window_end": None, + "actual_pv_wh": None, + "forecast_pv_wh": None, + "correction_factor": None, + "reason": "canonical_db", + } commitment_prev = await _load_previous_plan_charge_commitment_prev_w(site_id, slots, db) @@ -1173,7 +1178,7 @@ async def run_rolling_replan( charge_commitment_prev_w=commitment_prev, ) - slot_inputs = _build_slot_inputs(slots_before_pv_correction, slots) + slot_inputs = _build_slot_inputs(slots, slots) run_id = await _save_planning_run( site_id, results, @@ -1190,26 +1195,10 @@ async def run_rolling_replan( solver_snapshot=solver_snapshot, ) - await db.execute( - """ - select ems.fn_forecast_correction_log_insert( - $1::int, $2::timestamptz, $3::timestamptz, - $4::numeric, $5::numeric, $6::numeric, $7::int - ) - """, - site_id, - correction_log["window_start"], - correction_log["window_end"], - correction_log.get("actual_pv_wh"), - correction_log.get("forecast_pv_wh"), - correction_factor, - run_id, - ) + # Historický log rolling korekce: dřív se psal z Pythonu. Nově se rolling faktor počítá v DB + # v kanonické PV řadě; log se případně přesune do DB (todo). - logger.info( - f"[site={site_id}] Rolling replan done in {duration_ms} ms " - f"(correction={correction_factor:.3f})" - ) + logger.info(f"[site={site_id}] Rolling replan done in {duration_ms} ms (pv=canonical_db)") return run_id, duration_ms diff --git a/db/routines/R__033_fn_plan_current_bundle.sql b/db/routines/R__033_fn_plan_current_bundle.sql index 38ccb1f..d3ab283 100644 --- a/db/routines/R__033_fn_plan_current_bundle.sql +++ b/db/routines/R__033_fn_plan_current_bundle.sql @@ -38,43 +38,87 @@ begin where ab.site_id = p_site_id; with fc_slot as ( + -- Kanonický PV forecast pro UI = to, co solver používá (planning_interval.*_forecast_solver_w), + -- aby seděla bilance v tabulce slotů. Pro sloty mimo uložený plán doplníme forecast-only řádky. select - u.interval_start, - coalesce(sum(u.power_w), 0)::bigint as pv_forecast_total_w - from ( - select distinct on (fpi.interval_start, fpr.pv_array_id) - fpi.interval_start, - fpi.power_w - from ems.forecast_pv_interval fpi - join ems.forecast_pv_run fpr on fpr.id = fpi.run_id - join ems.asset_pv_array apa - on apa.id = fpr.pv_array_id - and apa.site_id = fpr.site_id - where fpr.site_id = p_site_id - and fpr.status = 'ok' - order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc - ) u - group by u.interval_start + c.interval_start, + (coalesce(c.pv_a_forecast_canonical_w, 0) + coalesce(c.pv_b_forecast_canonical_w, 0))::bigint as pv_forecast_total_w, + coalesce(c.pv_a_forecast_canonical_w, 0)::bigint as pv_a_forecast_solver_w, + coalesce(c.pv_b_forecast_canonical_w, 0)::bigint as pv_b_forecast_solver_w + from jsonb_to_recordset( + ems.fn_forecast_pv_slots_range_canonical_ab( + p_site_id, + (v_run->>'horizon_start')::timestamptz, + greatest((v_run->>'horizon_end')::timestamptz, (v_run->>'horizon_start')::timestamptz + interval '96 hours'), + now() + ) + ) as c( + interval_start timestamptz, + pv_a_forecast_canonical_w bigint, + pv_b_forecast_canonical_w bigint + ) ), joined as ( select to_jsonb(pi.*) || jsonb_build_object( 'pv_power_w', ai.actual_pv_power_w, - 'pv_forecast_total_w', fs.pv_forecast_total_w + 'pv_forecast_total_w', + coalesce(pi.pv_a_forecast_solver_w, 0) + + coalesce(pi.pv_b_forecast_solver_w, 0), + 'pv_a_forecast_solver_w', pi.pv_a_forecast_solver_w, + 'pv_b_forecast_solver_w', pi.pv_b_forecast_solver_w ) as j, pi.interval_start, pi.expected_cost_czk, pi.pv_a_curtailed_w, pi.battery_setpoint_w, pi.grid_setpoint_w, - fs.pv_forecast_total_w + (coalesce(pi.pv_a_forecast_solver_w, 0) + coalesce(pi.pv_b_forecast_solver_w, 0))::bigint as pv_forecast_total_w from ems.planning_interval pi left join ems.audit_interval ai on ai.site_id = p_site_id and ai.interval_start = pi.interval_start - left join fc_slot fs on fs.interval_start = pi.interval_start where pi.run_id = v_run_id + union all + select + jsonb_build_object( + 'interval_start', fs.interval_start, + 'battery_setpoint_w', null, + 'battery_soc_target_pct', null, + 'grid_setpoint_w', null, + 'export_limit_w', null, + 'export_mode', null, + 'deye_physical_mode', null, + 'ev1_setpoint_w', null, + 'ev2_setpoint_w', null, + 'heat_pump_enabled', null, + 'pv_a_curtailed_w', null, + 'expected_cost_czk', null, + 'effective_buy_price', null, + 'effective_sell_price', null, + 'is_predicted_price', false, + 'pv_power_w', null, + 'pv_forecast_total_w', fs.pv_forecast_total_w, + 'pv_a_forecast_solver_w', fs.pv_a_forecast_solver_w, + 'pv_b_forecast_solver_w', fs.pv_b_forecast_solver_w, + 'load_baseline_w', null + ) as j, + fs.interval_start, + null::numeric as expected_cost_czk, + null::int as pv_a_curtailed_w, + null::int as battery_setpoint_w, + null::int as grid_setpoint_w, + fs.pv_forecast_total_w + from fc_slot fs + where fs.interval_start >= (v_run->>'horizon_start')::timestamptz + and fs.interval_start < greatest((v_run->>'horizon_end')::timestamptz, (v_run->>'horizon_start')::timestamptz + interval '96 hours') + and not exists ( + select 1 + from ems.planning_interval pi2 + where pi2.run_id = v_run_id + and pi2.interval_start = fs.interval_start + ) ), agg as ( select 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 eabce9a..54b0a9c 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -65,27 +65,6 @@ declare begin drop table if exists _ems_plan_slot_wk; create temp table _ems_plan_slot_wk on commit drop as - with prof as ( - select ems.fn_pv_forecast_delta_profile( - p_site_id, - greatest(p_from, now() - interval '120 days'), - now() - ) as j - ), - delta_unnest as ( - select (kv.key)::int as pv_array_id, - (x->>'slot_of_day')::int as slot_of_day, - (x->>'delta_w')::int as delta_w - from prof - cross join lateral jsonb_each((prof.j)->'deltas_by_array') kv(key, value) - cross join lateral jsonb_array_elements(kv.value->'deltas') x - ), - legacy_slot_delta as ( - select (x->>'slot_of_day')::int as slot_of_day, - (x->>'delta_w')::int as delta_w - from prof - cross join lateral jsonb_array_elements((prof.j)->'deltas') x - ), slot_spine as ( select gs as interval_start from generate_series( @@ -93,6 +72,26 @@ begin (p_to - interval '15 minutes')::timestamptz, interval '15 minutes' ) as gs + ), + pv_canon as ( + select + r.interval_start, + coalesce(r.pv_a_forecast_canonical_w, 0)::int as pv_a_forecast_w, + coalesce(r.pv_b_forecast_canonical_w, 0)::int as pv_b_forecast_w + from jsonb_to_recordset( + ems.fn_forecast_pv_slots_range_canonical_ab( + p_site_id, + p_from, + p_to, + now(), + greatest(p_from, now() - interval '120 days'), + now() + ) + ) as r( + interval_start timestamptz, + pv_a_forecast_canonical_w bigint, + pv_b_forecast_canonical_w bigint + ) ) select (row_number() over (order by s.interval_start) - 1)::int as slot_ord, @@ -106,8 +105,8 @@ begin ems.fn_get_predicted_price(p_site_id, s.interval_start) * 0.85 ) as sell_price, (ep.effective_buy_price_czk_kwh is null) as is_predicted_price, - coalesce(fpi_a.power_w, 0)::int as pv_a_forecast_w, - coalesce(fpi_b.power_w, 0)::int as pv_b_forecast_w, + coalesce(pv.pv_a_forecast_w, 0)::int as pv_a_forecast_w, + coalesce(pv.pv_b_forecast_w, 0)::int as pv_b_forecast_w, coalesce( ( select bs.avg_power_w @@ -127,7 +126,7 @@ begin (coalesce(ev2.status, 'available') not in ('available', 'unavailable')) as ev2_connected, greatest( 0, - coalesce(fpi_a.power_w, 0) + coalesce(fpi_b.power_w, 0) + coalesce(pv.pv_a_forecast_w, 0) + coalesce(pv.pv_b_forecast_w, 0) - coalesce( ( select bs.avg_power_w @@ -147,106 +146,9 @@ begin false::boolean as allow_charge, false::boolean as allow_discharge_export from slot_spine s + left join pv_canon pv on pv.interval_start = s.interval_start left join ems.vw_site_effective_price ep on ep.site_id = p_site_id and ep.interval_start = s.interval_start - left join lateral ( - with uq as ( - select distinct on (apa.id) - apa.id as pv_array_id, - fpi.power_w - from ems.asset_pv_array apa - join ems.forecast_pv_run fpr - on fpr.pv_array_id = apa.id - and fpr.site_id = apa.site_id - and fpr.status = 'ok' - join ems.forecast_pv_interval fpi - on fpi.run_id = fpr.id - and fpi.pv_array_id = apa.id - and fpi.interval_start = s.interval_start - where apa.site_id = p_site_id - and apa.controllable is true - order by apa.id, fpr.created_at desc - ), - slot_of as ( - select ( - (extract(hour from (s.interval_start at time zone 'Europe/Prague'))::int * 60) - + extract(minute from (s.interval_start at time zone 'Europe/Prague'))::int - ) / 15 as slot_of_day - ), - tot as (select coalesce(sum(uq.power_w), 0)::numeric as w from uq) - select coalesce(sum( - greatest( - 0, - uq.power_w - coalesce( - du.delta_w, - case - when exists (select 1 from delta_unnest limit 1) then null - else round( - ld.delta_w::numeric * uq.power_w::numeric / nullif((select w from tot), 0) - )::int - end, - 0 - ) - ) - ), 0)::int as power_w - from uq - cross join slot_of - cross join tot - left join delta_unnest du - on du.pv_array_id = uq.pv_array_id - and du.slot_of_day = slot_of.slot_of_day - left join legacy_slot_delta ld - on ld.slot_of_day = slot_of.slot_of_day - ) fpi_a on true - left join lateral ( - with uq as ( - select distinct on (apa.id) - apa.id as pv_array_id, - fpi.power_w - from ems.asset_pv_array apa - join ems.forecast_pv_run fpr - on fpr.pv_array_id = apa.id - and fpr.site_id = apa.site_id - and fpr.status = 'ok' - join ems.forecast_pv_interval fpi - on fpi.run_id = fpr.id - and fpi.pv_array_id = apa.id - and fpi.interval_start = s.interval_start - where apa.site_id = p_site_id - and apa.controllable is false - order by apa.id, fpr.created_at desc - ), - slot_of as ( - select ( - (extract(hour from (s.interval_start at time zone 'Europe/Prague'))::int * 60) - + extract(minute from (s.interval_start at time zone 'Europe/Prague'))::int - ) / 15 as slot_of_day - ), - tot as (select coalesce(sum(uq.power_w), 0)::numeric as w from uq) - select coalesce(sum( - greatest( - 0, - uq.power_w - coalesce( - du.delta_w, - case - when exists (select 1 from delta_unnest limit 1) then null - else round( - ld.delta_w::numeric * uq.power_w::numeric / nullif((select w from tot), 0) - )::int - end, - 0 - ) - ) - ), 0)::int as power_w - from uq - cross join slot_of - cross join tot - left join delta_unnest du - on du.pv_array_id = uq.pv_array_id - and du.slot_of_day = slot_of.slot_of_day - left join legacy_slot_delta ld - on ld.slot_of_day = slot_of.slot_of_day - ) fpi_b on true left join lateral ( select t.status from ems.telemetry_ev_charger t diff --git a/db/routines/R__088_fn_forecast_pv_slots_range_canonical_ab.sql b/db/routines/R__088_fn_forecast_pv_slots_range_canonical_ab.sql new file mode 100644 index 0000000..c2bc067 --- /dev/null +++ b/db/routines/R__088_fn_forecast_pv_slots_range_canonical_ab.sql @@ -0,0 +1,230 @@ +-- ============================================================ +-- PV forecast sloty (15min) – kanonický vstup pro plánování +-- +-- Kombinuje: +-- 1) delta-korekci per-array (fn_pv_forecast_delta_profile) +-- 2) rolling multiplikativní faktor vs telemetrie (fn_pv_forecast_correction_factor) +-- s lineárním decay do 1.0 v p_decay_slots. +-- +-- Výstup je rozsplitěný na PV-A (controllable=true) a PV-B (controllable=false), +-- protože curtailment v LP smí omezovat jen PV-A. +-- ============================================================ + +create or replace function ems.fn_forecast_pv_slots_range_canonical_ab( + p_site_id int, + p_from timestamptz, + p_to timestamptz, + p_now timestamptz default now(), + p_delta_data_from timestamptz default (now() - interval '120 days'), + p_delta_data_to timestamptz default now(), + p_half_life_days numeric default 14, + p_threshold_w int default 150, + p_factor_window_h numeric default 1, + p_factor_min_clamp numeric default 0.5, + p_factor_max_clamp numeric default 1.5, + p_decay_slots int default 16 +) +returns jsonb +language sql +stable +set work_mem = '64MB' +as $fn$ + with tz as ( + select coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague') as tz_name + from ems.site s + where s.id = p_site_id + ), + bounds as ( + select + date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') as ts_from, + case + when p_to <= p_from then date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') + interval '15 minutes' + when p_to > p_from + interval '60 days' then date_bin(interval '15 minutes', p_from, timestamptz '1970-01-01T00:00:00Z') + interval '60 days' + else date_bin(interval '15 minutes', p_to, timestamptz '1970-01-01T00:00:00Z') + end as ts_to, + date_bin(interval '15 minutes', p_now, timestamptz '1970-01-01T00:00:00Z') as now_slot + ), + slot_spine as ( + select gs as interval_start + from bounds b, + generate_series( + b.ts_from, + (b.ts_to - interval '15 minutes')::timestamptz, + interval '15 minutes' + ) as gs + ), + slot_tz as ( + select + s.interval_start, + ( + (extract(hour from (s.interval_start at time zone t.tz_name))::int * 60) + + extract(minute from (s.interval_start at time zone t.tz_name))::int + ) / 15 as slot_of_day + from slot_spine s + cross join tz t + ), + factor_raw as ( + select ems.fn_pv_forecast_correction_factor( + p_site_id, + (p_now - (p_factor_window_h::text || ' hours')::interval)::timestamptz, + p_now, + p_factor_min_clamp, + p_factor_max_clamp + ) as j + ), + factor as ( + select + coalesce((j->>'correction_factor')::numeric, 1.0::numeric) as rolling_factor + from factor_raw + ), + profile as ( + select ems.fn_pv_forecast_delta_profile( + p_site_id, + p_delta_data_from, + p_delta_data_to, + p_half_life_days, + p_threshold_w + ) as j + ), + delta_by_array as ( + select (kv.key)::int as pv_array_id, + (x->>'slot_of_day')::int as slot_of_day, + (x->>'delta_w')::int as delta_w + from profile p + cross join lateral jsonb_each((p.j)->'deltas_by_array') kv(key, value) + cross join lateral jsonb_array_elements(kv.value->'deltas') x + ), + deltas_legacy as ( + select (x->>'slot_of_day')::int as slot_of_day, + (x->>'delta_w')::int as delta_w + from profile p + cross join lateral jsonb_array_elements(p.j->'deltas') x + ), + flags as ( + select exists (select 1 from delta_by_array) as use_per_array + ), + fc_by_array as ( + select distinct on (fpi.interval_start, fpr.pv_array_id) + fpi.interval_start, + fpr.pv_array_id, + apa.controllable, + fpi.power_w::bigint as power_w + from bounds b + inner join ems.forecast_pv_interval fpi + on fpi.interval_start >= b.ts_from + and fpi.interval_start < b.ts_to + and fpi.pv_array_id in ( + select apa0.id from ems.asset_pv_array apa0 where apa0.site_id = p_site_id + ) + inner join ems.forecast_pv_run fpr + on fpr.id = fpi.run_id + and fpr.site_id = p_site_id + and fpr.pv_array_id = fpi.pv_array_id + and fpr.status = 'ok' + inner join ems.asset_pv_array apa + on apa.id = fpr.pv_array_id + and apa.site_id = p_site_id + order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc + ), + fc_with_sod as ( + select + fa.interval_start, + fa.pv_array_id, + fa.controllable, + fa.power_w, + st.slot_of_day + from fc_by_array fa + join slot_tz st on st.interval_start = fa.interval_start + ), + fc_delta as ( + select + f.interval_start, + f.controllable, + sum(f.power_w)::bigint as raw_w, + sum( + greatest( + 0::bigint, + f.power_w + - ( + case + when fl.use_per_array then coalesce(d.delta_w, 0)::bigint + else coalesce(dl.delta_w, 0)::bigint + end + ) + ) + )::bigint as delta_w + from fc_with_sod f + cross join flags fl + left join delta_by_array d + on fl.use_per_array + and d.pv_array_id = f.pv_array_id + and d.slot_of_day = f.slot_of_day + left join lateral ( + select dl0.delta_w + from deltas_legacy dl0 + where dl0.slot_of_day = f.slot_of_day + limit 1 + ) dl on not fl.use_per_array + group by f.interval_start, f.controllable + ), + fc_ab as ( + select + st.interval_start, + coalesce(sum(case when fd.controllable then fd.raw_w else 0 end), 0)::bigint as pv_a_forecast_raw_w, + coalesce(sum(case when not fd.controllable then fd.raw_w else 0 end), 0)::bigint as pv_b_forecast_raw_w, + coalesce(sum(case when fd.controllable then fd.delta_w else 0 end), 0)::bigint as pv_a_forecast_delta_w, + coalesce(sum(case when not fd.controllable then fd.delta_w else 0 end), 0)::bigint as pv_b_forecast_delta_w, + st.slot_of_day + from slot_tz st + left join fc_delta fd on fd.interval_start = st.interval_start + group by st.interval_start, st.slot_of_day + ), + with_factor as ( + select + ab.interval_start, + ab.slot_of_day, + ab.pv_a_forecast_raw_w, + ab.pv_b_forecast_raw_w, + ab.pv_a_forecast_delta_w, + ab.pv_b_forecast_delta_w, + f.rolling_factor, + case + when ab.interval_start < b.now_slot then 1.0::numeric + when p_decay_slots <= 0 then f.rolling_factor + else + case + when ((extract(epoch from (ab.interval_start - b.now_slot)) / 900)::int) >= p_decay_slots then 1.0::numeric + else (1.0::numeric + (f.rolling_factor - 1.0::numeric) * (1.0::numeric - ((extract(epoch from (ab.interval_start - b.now_slot)) / 900)::numeric / p_decay_slots::numeric))) + end + end as rolling_effective_factor + from fc_ab ab + cross join factor f + cross join bounds b + ) + select coalesce( + jsonb_agg( + jsonb_build_object( + 'interval_start', w.interval_start, + 'slot_of_day', w.slot_of_day, + 'pv_a_forecast_raw_w', w.pv_a_forecast_raw_w, + 'pv_b_forecast_raw_w', w.pv_b_forecast_raw_w, + 'pv_a_forecast_delta_w', w.pv_a_forecast_delta_w, + 'pv_b_forecast_delta_w', w.pv_b_forecast_delta_w, + 'rolling_factor', w.rolling_factor, + 'rolling_effective_factor', w.rolling_effective_factor, + 'pv_a_forecast_canonical_w', greatest(0::bigint, round(w.pv_a_forecast_delta_w::numeric * w.rolling_effective_factor))::bigint, + 'pv_b_forecast_canonical_w', greatest(0::bigint, round(w.pv_b_forecast_delta_w::numeric * w.rolling_effective_factor))::bigint, + 'pv_forecast_total_canonical_w', + greatest(0::bigint, round(w.pv_a_forecast_delta_w::numeric * w.rolling_effective_factor))::bigint + + greatest(0::bigint, round(w.pv_b_forecast_delta_w::numeric * w.rolling_effective_factor))::bigint + ) + order by w.interval_start + ), + '[]'::jsonb + ) + from with_factor w; +$fn$; + +comment on function ems.fn_forecast_pv_slots_range_canonical_ab is + 'Kanonická PV forecast řada po 15 min pro plánování: delta-korekce per-array (fn_pv_forecast_delta_profile) + rolling multiplikativní faktor (fn_pv_forecast_correction_factor) s decay. Vrací PV-A/PV-B (controllable) i total.'; + diff --git a/docs/04-modules/forecast.md b/docs/04-modules/forecast.md index 5771361..f002c15 100644 --- a/docs/04-modules/forecast.md +++ b/docs/04-modules/forecast.md @@ -9,6 +9,19 @@ poslední dostupné uložené forecasty; forecast nespouští implicitně před každým plánovacím během. +## Kanonický forecast pro plánování (single source of truth) + +Pro plánování (solver) a UI tabulky slotů je **kanonický** výkon FVE počítaný v DB funkcí: + +- `ems.fn_forecast_pv_slots_range_canonical_ab(...)` + +Ta kombinuje dvě korekce do jedné řady: + +- **delta-korekci** per `pv_array_id` (z `ems.fn_pv_forecast_delta_profile`) +- **rolling multiplikativní faktor** vs telemetrie (z `ems.fn_pv_forecast_correction_factor`) s lineárním **decay** do 1.0 + +Výstup je rozdělený na **PV‑A** (`controllable=true`, curtailment v LP) a **PV‑B** (`controllable=false`). + --- ## FVE pole na první instalaci (home-01) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 8863018..bdc4f86 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -36,7 +36,7 @@ - **Export bez forecastového capu:** solver ukládá explicitní `planning_interval.export_limit_w` jako tvrdý site/inverter limit a `planning_interval.export_mode` (`NONE` / `PV_SURPLUS` / `BATTERY_SELL`). Exportér z plánu neodvozuje žádný forecastový strop exportu. - **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`, …; **V076** navíc `reference_day_weight_mult` pro „připnuté“ dny níže). **`ems.site_pv_forecast_reference_day`** (**V076**) umožňuje zvýšit váhu konkrétních kalendářních dnů (datum ve `site.timezone` jako u časování slotů) při agregaci δ z `forecast_accuracy` (`fn_pv_forecast_delta_profile`); hromadný zápis **`ems.fn_pv_forecast_sync_reference_days`**, detail **`docs/04-modules/forecast.md`**. `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). Telemetrie: `ems.telemetry_inverter.is_export_limited` nebo `pv_derating_flags <> 0` v okně 15min → stejné vyloučení (`telemetry_derating`). `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). +- **Kanonický PV forecast (delta + rolling):** 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`, …; **V076** navíc `reference_day_weight_mult` pro „připnuté“ dny níže). **`ems.site_pv_forecast_reference_day`** (**V076**) umožňuje zvýšit váhu konkrétních kalendářních dnů (datum ve `site.timezone` jako u časování slotů) při agregaci δ z `forecast_accuracy` (`fn_pv_forecast_delta_profile`); hromadný zápis **`ems.fn_pv_forecast_sync_reference_days`**, detail **`docs/04-modules/forecast.md`**. `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). Telemetrie: `ems.telemetry_inverter.is_export_limited` nebo `pv_derating_flags <> 0` v okně 15min → stejné vyloučení (`telemetry_derating`).\n+\n+ **Single source of truth pro solver i UI** je `ems.fn_forecast_pv_slots_range_canonical_ab`, která v jednom místě kombinuje:\n+ - delta profil (aditivní odečet per-array)\n+ - rolling multiplikativní faktor vs telemetrie (`fn_pv_forecast_correction_factor`) s decay.\n+ `ems.fn_load_planning_slots_full` bere PV A/B z této kanonické funkce; UI je čte z `/plan/current` (bundle obsahuje `pv_*_forecast_solver_w` i `pv_forecast_total_w` jako součet). 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/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx index 14f1aed..a63d532 100644 --- a/frontend/src/pages/Planning.tsx +++ b/frontend/src/pages/Planning.tsx @@ -25,8 +25,6 @@ import { import { getCurrentPlan, - getForecastPvSlotsRangeCorrected, - type ForecastPvSlotCorrectedRow, postImportSitePrices, postRunForecast, postRunPlan, @@ -112,7 +110,6 @@ function groupByDay(slots: PlanningIntervalDto[]): Record, ): { fveKwh: number exportKwh: number @@ -123,7 +120,7 @@ function dayStats( let expWh = 0 const buys: number[] = [] for (const s of slots) { - const fveW = slotFveDisplayW(s, nowMs, correctedPvByIso) + const fveW = slotFveDisplayW(s, nowMs) fveWh += (fveW ?? 0) * slotHours const gw = s.grid_setpoint_w ?? 0 if (gw < 0) expWh += -gw * slotHours @@ -149,7 +146,6 @@ type PlanTableRow = function buildPlanTableRows( visibleSlots: PlanningIntervalDto[], nowMs: number, - correctedPvByIso?: Map, ): PlanTableRow[] { const groups = groupByDay(visibleSlots) const dayKeys = [...new Set(visibleSlots.map((s) => pragueDayKey(s.interval_start)))].sort() @@ -161,7 +157,7 @@ function buildPlanTableRows( kind: 'summary', dayKey: dk, dateLabel: formatPragueDateLabel(sl[0]!.interval_start), - ...dayStats(sl, nowMs, correctedPvByIso), + ...dayStats(sl, nowMs), }) for (const i of sl) rows.push({ kind: 'slot', i }) } @@ -178,26 +174,6 @@ function horizonToggleClass(active: boolean): string { : 'border-slate-600 bg-slate-800/80 text-slate-300 hover:bg-slate-800' } -/** Stejná logika jako přehled: korigovaný výkon z delty, jinak raw součet z API řádku. */ -function pvDisplayWFromCorrectedRow(r: ForecastPvSlotCorrectedRow): number | null { - const c = r.pv_forecast_corrected_w - if (c != null && Number.isFinite(Number(c))) return Number(c) - const raw = r.pv_forecast_total_w - if (raw != null && Number.isFinite(Number(raw))) return Number(raw) - return null -} - -function buildCorrectedPvByIso(rows: ForecastPvSlotCorrectedRow[]): Map { - const m = new Map() - for (const r of rows) { - const iso = typeof r.interval_start === 'string' ? r.interval_start : null - if (!iso) continue - const v = pvDisplayWFromCorrectedRow(r) - if (v != null && Number.isFinite(v)) m.set(iso, v) - } - return m -} - /** * Budoucí slot: `pv_forecast_total_w` z /plan/current je raw z forecast_pv_interval; pro zobrazení * preferujeme korekci z `pv-slots-corrected` (soulad s LP vstupy a přehledem). @@ -248,27 +224,19 @@ function pvAProxyW(i: PlanningIntervalDto): number { } /** Křivka FVE ve grafu: korig. / audit, jinak stejná cena-proxy jako dřív. */ -function pvChartFveW(i: PlanningIntervalDto, nowMs: number, correctedPvByIso?: Map): number { - const w = slotFveDisplayW(i, nowMs, correctedPvByIso) +function pvChartFveW(i: PlanningIntervalDto, nowMs: number): number { + const w = slotFveDisplayW(i, nowMs) if (w != null && Number.isFinite(w)) return w return pvAProxyW(i) } /** Budoucí slot (od začátku ještě nenastal): korig. předpověď; proběhlý / probíhající: telemetrie z auditu. */ -function slotFveDisplayW( - i: PlanningIntervalDto, - nowMs: number, - correctedPvByIso?: Map, -): number | null { +function slotFveDisplayW(i: PlanningIntervalDto, nowMs: number): number | null { const start = slotStartUtcMs(i.interval_start) const future = start >= nowMs if (future) { - const iso = i.interval_start - const corr = correctedPvByIso?.get(iso) - if (corr !== undefined && Number.isFinite(corr)) return corr const f = i.pv_forecast_total_w - if (f != null) return Number(f) - return null + return f != null ? Number(f) : null } const a = i.pv_power_w if (a != null) return Number(a) @@ -291,13 +259,11 @@ function formatPlanPowerW(w: number | null): string { function FveWCell({ i, nowMs, - correctedPvByIso, }: { i: PlanningIntervalDto nowMs: number - correctedPvByIso?: Map }) { - const w = slotFveDisplayW(i, nowMs, correctedPvByIso) + const w = slotFveDisplayW(i, nowMs) const color = w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500' return ( @@ -659,7 +625,6 @@ export default function Planning() { const [selectedStart, setSelectedStart] = useState(null) const [tableHorizonH, setTableHorizonH] = useState(48) const [chartHorizonH, setChartHorizonH] = useState(48) - const [forecastPvCorrectedRows, setForecastPvCorrectedRows] = useState([]) const [forecastRefreshKey, setForecastRefreshKey] = useState(0) const load = useCallback(async () => { @@ -689,27 +654,7 @@ export default function Planning() { const nowMs = Date.now() const slotFloorMs = floorSlotUtcMs(nowMs) - useEffect(() => { - if (siteId == null || loading) return - let cancelled = false - const fromIso = new Date(slotFloorMs).toISOString() - const toIso = new Date(slotFloorMs + 96 * 60 * 60 * 1000).toISOString() - void getForecastPvSlotsRangeCorrected(siteId, fromIso, toIso) - .then((rows) => { - if (!cancelled) setForecastPvCorrectedRows(rows) - }) - .catch(() => { - if (!cancelled) setForecastPvCorrectedRows([]) - }) - return () => { - cancelled = true - } - }, [siteId, loading, slotFloorMs, data?.run?.id, forecastRefreshKey]) - - const correctedPvByIso = useMemo( - () => buildCorrectedPvByIso(forecastPvCorrectedRows), - [forecastPvCorrectedRows], - ) + // PV forecast je kanonicky v /plan/current (DB read-model), takže už netaháme separátní pv-slots-corrected. const futureSlots = useMemo(() => { if (!data?.intervals?.length) return [] @@ -724,25 +669,10 @@ export default function Planning() { return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs) }, [futureSlots, nowMs, tableHorizonH]) - const forecastOverlay = useMemo(() => { - if (!forecastPvCorrectedRows.length) return [] as PlanningIntervalDto[] - const planStarts = new Set(futureSlots.map((s) => s.interval_start)) - const out: PlanningIntervalDto[] = [] - for (const r of forecastPvCorrectedRows) { - const iso = typeof r.interval_start === 'string' ? r.interval_start : null - if (!iso || planStarts.has(iso)) continue - const pv = pvDisplayWFromCorrectedRow(r) - out.push(syntheticForecastOnlyInterval(iso, pv)) - } - return out - }, [forecastPvCorrectedRows, futureSlots]) - - /** Graf: LP sloty + za horizont plánu řada FVE z `pv-slots-corrected` (korig. jako přehled). */ + /** Graf: sloty z /plan/current (obsahují i forecast-only řádky za horizontem LP). */ const chartMergedSlots = useMemo(() => { - return [...futureSlots, ...forecastOverlay].sort( - (a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start), - ) - }, [futureSlots, forecastOverlay]) + return [...futureSlots].sort((a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start)) + }, [futureSlots]) const chartIntervals = useMemo(() => { const endMs = nowMs + chartHorizonH * 60 * 60 * 1000 @@ -753,8 +683,8 @@ export default function Planning() { }, [chartMergedSlots, nowMs, chartHorizonH, slotFloorMs]) const planTableRows = useMemo( - () => buildPlanTableRows(visibleSlots, nowMs, correctedPvByIso), - [visibleSlots, nowMs, correctedPvByIso], + () => buildPlanTableRows(visibleSlots, nowMs), + [visibleSlots, nowMs], ) const showGenCut = useMemo(() => hasGenCutoff(visibleSlots), [visibleSlots]) @@ -778,13 +708,13 @@ export default function Planning() { return chartIntervals.map((i) => ({ label: formatLocalTime(i.interval_start), ts: slotStartUtcMs(i.interval_start), - pv_a_w: pvChartFveW(i, nowMs, correctedPvByIso), + pv_a_w: pvChartFveW(i, nowMs), battery_soc_target_pct: i.battery_soc_target_pct, battery_setpoint_w: i.battery_setpoint_w ?? 0, effective_buy_price: i.effective_buy_price, raw: i, })) - }, [chartIntervals, nowMs, correctedPvByIso]) + }, [chartIntervals, nowMs]) async function onReplan() { if (siteId == null) return @@ -1293,7 +1223,7 @@ export default function Planning() { ? `${i.battery_soc_target_pct.toFixed(1)}` : '—'} - + {formatPlanPowerW(i.load_baseline_w)} diff --git a/frontend/src/types/plan.ts b/frontend/src/types/plan.ts index cfb223a..e3acac5 100644 --- a/frontend/src/types/plan.ts +++ b/frontend/src/types/plan.ts @@ -35,6 +35,9 @@ export type PlanningIntervalDto = { /** True pokud cena pro slot byla při plánování predikovaná (DB sloupec `is_predicted_price`). */ is_predicted_price: boolean pv_forecast_total_w: number | null + /** Kanonický PV forecast použitý solverem (A/B). */ + pv_a_forecast_solver_w?: number | null + pv_b_forecast_solver_w?: number | null /** Průměrná skutečná FVE výkon za slot z audit_interval (GET /plan/current JOIN). */ pv_power_w?: number | null load_baseline_w: number | null