sjednoceni forecastu
This commit is contained in:
97
.cursor/plans/unify_pv_correction_source_95b01fce.plan.md
Normal file
97
.cursor/plans/unify_pv_correction_source_95b01fce.plan.md
Normal file
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
230
db/routines/R__088_fn_forecast_pv_slots_range_canonical_ab.sql
Normal file
230
db/routines/R__088_fn_forecast_pv_slots_range_canonical_ab.sql
Normal file
@@ -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.';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
|
||||
import {
|
||||
getCurrentPlan,
|
||||
getForecastPvSlotsRangeCorrected,
|
||||
type ForecastPvSlotCorrectedRow,
|
||||
postImportSitePrices,
|
||||
postRunForecast,
|
||||
postRunPlan,
|
||||
@@ -112,7 +110,6 @@ function groupByDay(slots: PlanningIntervalDto[]): Record<string, PlanningInterv
|
||||
function dayStats(
|
||||
slots: PlanningIntervalDto[],
|
||||
nowMs: number,
|
||||
correctedPvByIso?: Map<string, number>,
|
||||
): {
|
||||
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<string, number>,
|
||||
): 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<string, number> {
|
||||
const m = new Map<string, number>()
|
||||
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<string, number>): 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<string, number>,
|
||||
): 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<string, number>
|
||||
}) {
|
||||
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<string | null>(null)
|
||||
const [tableHorizonH, setTableHorizonH] = useState<HorizonHours>(48)
|
||||
const [chartHorizonH, setChartHorizonH] = useState<HorizonHours>(48)
|
||||
const [forecastPvCorrectedRows, setForecastPvCorrectedRows] = useState<ForecastPvSlotCorrectedRow[]>([])
|
||||
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)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<FveWCell i={i} nowMs={nowMs} correctedPvByIso={correctedPvByIso} />
|
||||
<FveWCell i={i} nowMs={nowMs} />
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
{formatPlanPowerW(i.load_baseline_w)}
|
||||
</td>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user