From 9ca4b4c577dbda945fc6a493160eeb16ecfe1671 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Wed, 22 Apr 2026 19:26:46 +0200 Subject: [PATCH] korkece fve predikce, grafy predikci --- backend/app/routers/sites.py | 158 ++++++++- .../R__078_fn_pv_forecast_delta_profile.sql | 121 +++++++ ...9_fn_forecast_pv_slots_range_corrected.sql | 123 +++++++ docs/05-todo.md | 1 + frontend/src/App.tsx | 5 + frontend/src/api/backend.ts | 68 ++++ .../src/components/charts/EnergyChart.tsx | 27 +- frontend/src/hooks/useDashboardData.ts | 19 ++ frontend/src/pages/ForecastVsActual.tsx | 300 ++++++++++++++++++ frontend/src/types/dashboard.ts | 2 + 10 files changed, 819 insertions(+), 5 deletions(-) create mode 100644 db/routines/R__078_fn_pv_forecast_delta_profile.sql create mode 100644 db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql create mode 100644 frontend/src/pages/ForecastVsActual.tsx diff --git a/backend/app/routers/sites.py b/backend/app/routers/sites.py index 91db177..640d5c2 100644 --- a/backend/app/routers/sites.py +++ b/backend/app/routers/sites.py @@ -4,7 +4,7 @@ from __future__ import annotations import json import logging -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from typing import Annotated, Any import asyncpg @@ -522,3 +522,159 @@ async def get_site_forecast_pv_slots_range( if not isinstance(slots, list): slots = [] return {"slots": slots} + + +@router.get("/{site_id}/forecast/pv-slots-corrected") +async def get_site_forecast_pv_slots_range_corrected( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pg_pool)], + from_ts: datetime = Query( + ..., + alias="from", + description="Začátek okna [from, to), typicky UTC zaokrouhlené na 15 min", + ), + to_ts: datetime = Query( + ..., + alias="to", + description="Konec polouzavřeného intervalu (max. cca 120 h za from)", + ), + delta_from_ts: datetime | None = Query( + None, + alias="delta_from", + description="Začátek okna historie pro výpočet delta profilu (default: now-60d)", + ), + delta_to_ts: datetime | None = Query( + None, + alias="delta_to", + description="Konec okna historie pro výpočet delta profilu (default: 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", + ), +) -> dict[str, list[dict[str, Any]]]: + if to_ts <= from_ts: + raise HTTPException(status_code=422, detail="'to' must be after 'from'") + if to_ts - from_ts > timedelta(hours=120): + raise HTTPException( + status_code=422, + detail="Span between 'from' and 'to' must be at most 120 hours", + ) + now = datetime.now(tz=timezone.utc) + delta_to = delta_to_ts or now + delta_from = delta_from_ts or (delta_to - timedelta(days=60)) + 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_forecast_pv_slots_range_corrected( + $1::int, + $2::timestamptz, + $3::timestamptz, + $4::timestamptz, + $5::timestamptz, + $6::numeric, + $7::int + ) + """, + site_id, + from_ts, + to_ts, + delta_from, + delta_to, + half_life_days, + threshold_w, + ) + slots = raw if isinstance(raw, list) else [] + if not isinstance(slots, list): + slots = [] + return {"slots": [s for s in slots if isinstance(s, dict)]} + + +@router.get("/{site_id}/timeseries/telemetry-15m") +async def get_site_telemetry_15m_range( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pg_pool)], + from_ts: datetime = Query(..., alias="from", description="Začátek okna [from, to)"), + to_ts: datetime = Query(..., alias="to", description="Konec okna [from, to)"), +) -> dict[str, list[dict[str, Any]]]: + if to_ts <= from_ts: + raise HTTPException(status_code=422, detail="'to' must be after 'from'") + if to_ts - from_ts > timedelta(days=60): + raise HTTPException( + status_code=422, + detail="Span between 'from' and 'to' must be at most 60 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") + rows = await conn.fetch( + """ + select + slot_start, + site_id, + avg_pv_w, + avg_load_w, + avg_grid_w, + avg_battery_w, + last_soc_pct, + sample_count + from ems.telemetry_inverter_15m + where site_id = $1 + and slot_start >= $2::timestamptz + and slot_start < $3::timestamptz + order by slot_start asc + """, + site_id, + from_ts, + to_ts, + ) + return {"slots": [record_to_dict(r) for r in rows]} + + +@router.get("/{site_id}/forecast/load-baseline-slots") +async def get_site_load_baseline_slots_range( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pg_pool)], + from_ts: datetime = Query(..., alias="from", description="Začátek okna [from, to)"), + to_ts: datetime = Query(..., alias="to", description="Konec okna [from, to)"), +) -> dict[str, list[dict[str, Any]]]: + if to_ts <= from_ts: + raise HTTPException(status_code=422, detail="'to' must be after 'from'") + if to_ts - from_ts > timedelta(days=60): + raise HTTPException( + status_code=422, + detail="Span between 'from' and 'to' must be at most 60 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") + rows = await conn.fetch( + """ + select interval_start, forecast_w, confidence_w + from ems.fn_get_baseline_forecast($1::int, $2::timestamptz, $3::timestamptz) + """, + site_id, + from_ts, + to_ts, + ) + return {"slots": [record_to_dict(r) for r in rows]} diff --git a/db/routines/R__078_fn_pv_forecast_delta_profile.sql b/db/routines/R__078_fn_pv_forecast_delta_profile.sql new file mode 100644 index 0000000..9defe5c --- /dev/null +++ b/db/routines/R__078_fn_pv_forecast_delta_profile.sql @@ -0,0 +1,121 @@ +-- ============================================================ +-- Profil systematické chyby PV forecastu po 15min slotu dne +-- (aditivní korekce: corrected = max(0, forecast - delta[slot])) +-- ============================================================ + +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 +) +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 + ), + -- Cutoff z analýzy DB (EMS Postgres): u site_id=2 (`home-01`) začíná být + -- `forecast_accuracy.actual_power_w` spolehlivě vyplněné pro celé kalendářní dny + -- od 2026-04-06 (Europe/Prague). Dřívší dny mají výrazně nižší podíl slotů s actual + -- (částečný backfill / výpadky) a zkreslují delta profil. + cutoff as ( + select timestamptz '2026-04-05T22:00:00Z' as min_ts + ), + 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 + ), + -- 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 + 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 + ), + 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, + ( + (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, + 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, tz.tz_name + ), + filtered as ( + select + 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)) as w + from slots s + cross join bounds b + 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) + ), + 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 + ), + spine as ( + select generate_series(0, 95) as slot_of_day + ) + 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), + '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 + ) + ) + from spine sp + left join agg a on a.slot_of_day = sp.slot_of_day; +$fn$; + +comment on function ems.fn_pv_forecast_delta_profile(int, timestamptz, timestamptz, numeric, int) is + 'Aditivní delta profil chyby PV forecastu po 15min slotu dne (96 slotů). Zdroj: forecast_accuracy, vážení exp(-age/half_life_days). Vrací JSON {deltas:[{slot_of_day, delta_w, sample_count}], ...}. Interní minimální cutoff dat (2026-04-06 Europe/Prague) brání učení z nekonzistentní historie před kompletním plněním actual.'; 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 new file mode 100644 index 0000000..c914bd2 --- /dev/null +++ b/db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql @@ -0,0 +1,123 @@ +-- ============================================================ +-- PV forecast sloty (15min) + aditivně korigovaný forecast +-- corrected = max(0, forecast - delta_profile[slot_of_day]) +-- ============================================================ + +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 +) +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 + p_from as ts_from, + case + when p_to <= p_from then p_from + interval '15 minutes' + when p_to > p_from + interval '120 hours' then p_from + interval '120 hours' + else p_to + end as ts_to + ), + 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 + ), + 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 + ), + 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 + ), + 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( + 0, + coalesce(fc.pv_forecast_total_w, 0)::int + - coalesce( + ( + select d.delta_w + from deltas 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 + ) / 15 + ) + ), + 0 + ) + ), + '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 + ) + ) + order by s.interval_start + ), + '[]'::jsonb + ) + from slot_spine s + cross join tz + left join fc on fc.interval_start = s.interval_start; +$fn$; + +comment on function ems.fn_forecast_pv_slots_range_corrected(int, timestamptz, timestamptz, timestamptz, timestamptz, numeric, int) 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.'; diff --git a/docs/05-todo.md b/docs/05-todo.md index 97207be..1114572 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -18,6 +18,7 @@ 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:** po analýze `ems.forecast_accuracy` pro `home-01` je minimální spolehlivý začátek učení **2026-04-06 (Europe/Prague)** (UTC `2026-04-05T22: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`). | --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d3f044..25874c5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { useWsLogErrorCount } from './hooks/useWsLogErrorCount' import { Dashboard } from './pages/Dashboard' import Economics from './pages/Economics' import EnergyFlows from './pages/EnergyFlows' +import ForecastVsActual from './pages/ForecastVsActual' import { Logs } from './pages/Logs' import Planning from './pages/Planning' import SiteConfiguration from './pages/SiteConfiguration' @@ -70,6 +71,9 @@ function AppLayout() { Plánování + + Srovnání + Ekonomika @@ -111,6 +115,7 @@ export default function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/backend.ts b/frontend/src/api/backend.ts index ff2d47d..0febe6f 100644 --- a/frontend/src/api/backend.ts +++ b/frontend/src/api/backend.ts @@ -117,6 +117,74 @@ export async function getForecastPvSlotsRange( return Array.isArray(data?.slots) ? data.slots : [] } +export type ForecastPvSlotCorrectedRow = { + interval_start: string + pv_forecast_total_w?: number | null + pv_forecast_corrected_w?: number | null + slot_of_day?: number | null +} + +export type ForecastPvSlotsCorrectedParams = { + delta_from?: string + delta_to?: string + half_life_days?: number + threshold_w?: number +} + +export async function getForecastPvSlotsRangeCorrected( + siteId: number, + fromIso: string, + toIso: string, + params?: ForecastPvSlotsCorrectedParams, +): Promise { + const { data } = await client.get<{ slots?: ForecastPvSlotCorrectedRow[] }>( + `/sites/${siteId}/forecast/pv-slots-corrected`, + { params: { from: fromIso, to: toIso, ...params }, timeout: 45_000 }, + ) + return Array.isArray(data?.slots) ? data.slots : [] +} + +export type Telemetry15mRow = { + slot_start: string + site_id: number + avg_pv_w?: number | null + avg_load_w?: number | null + avg_grid_w?: number | null + avg_battery_w?: number | null + last_soc_pct?: number | null + sample_count?: number | null +} + +export async function getTelemetry15mRange( + siteId: number, + fromIso: string, + toIso: string, +): Promise { + const { data } = await client.get<{ slots?: Telemetry15mRow[] }>(`/sites/${siteId}/timeseries/telemetry-15m`, { + params: { from: fromIso, to: toIso }, + timeout: 60_000, + }) + return Array.isArray(data?.slots) ? data.slots : [] +} + +export type BaselineLoadSlotRow = { + interval_start: string + forecast_w: number + confidence_w?: number +} + +export async function getBaselineLoadSlotsRange( + siteId: number, + fromIso: string, + toIso: string, +): Promise { + const { data } = await client.get<{ slots?: BaselineLoadSlotRow[] }>( + `/sites/${siteId}/forecast/load-baseline-slots`, + { params: { from: fromIso, to: toIso }, timeout: 60_000 }, + ) + return Array.isArray(data?.slots) ? data.slots : [] +} + /** GET /api/v1/sites/{id}/prices?date=YYYY-MM-DD */ export type SiteEffectivePriceRowDto = { site_id: number diff --git a/frontend/src/components/charts/EnergyChart.tsx b/frontend/src/components/charts/EnergyChart.tsx index 80f052a..fdca992 100644 --- a/frontend/src/components/charts/EnergyChart.tsx +++ b/frontend/src/components/charts/EnergyChart.tsx @@ -31,11 +31,18 @@ function sumW(a: number | null, b: number | null): number | null { return (a ?? 0) + (b ?? 0) } -export type EnergyLegendItem = { key: string; label: string; color: string; dashed?: boolean } +export type EnergyLegendItem = { + key: string + label: string + color: string + dashed?: boolean + dashStyle?: 'dashed' | 'dotted' +} export const ENERGY_LEGEND: EnergyLegendItem[] = [ { key: 'fve_real', label: 'FVE skutečnost', color: COL.fve }, { key: 'fve_pred', label: 'FVE předpověď', color: COL.fve, dashed: true }, + { key: 'fve_corr', label: 'FVE korigovaná', color: COL.fve, dashed: true, dashStyle: 'dotted' }, { key: 'baz_real', label: 'Spotřeba skutečnost', color: COL.baz }, { key: 'baz_pred', label: 'Spotřeba předpověď', color: COL.baz, dashed: true }, { key: 'ev', label: 'EV plán', color: COL.ev }, @@ -93,6 +100,9 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: const series = useMemo(() => { const fveReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.pv_power_w) : null)) const fvePred = slots.map((s) => kwFromW(sumW(s.pv_a_forecast_w, s.pv_b_forecast_w))) + const fveCorr = slots.map((s) => + kwFromW(s.pv_forecast_corrected_w ?? sumW(s.pv_a_forecast_w, s.pv_b_forecast_w)), + ) const bazReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.load_power_w) : null)) const bazPred = slots.map((s) => kwFromW(s.load_baseline_w)) const ev = slots.map((s) => kwFromW(sumW(s.ev1_setpoint_w, s.ev2_setpoint_w))) @@ -105,7 +115,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: ) const buy = slots.map((s) => (s.buy_price == null ? null : s.buy_price)) const sell = slots.map((s) => (s.sell_price == null ? null : s.sell_price)) - return { fveReal, fvePred, bazReal, bazPred, ev, tc, bat, sit, buy, sell } + return { fveReal, fvePred, fveCorr, bazReal, bazPred, ev, tc, bat, sit, buy, sell } }, [slots, nowIndex]) const bgPlugin = useMemo( @@ -126,6 +136,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: opts: { fill?: boolean | 'origin' dashed?: boolean + dash?: number[] yAxisID?: string order: number borderWidth?: number @@ -137,7 +148,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: backgroundColor: opts.fill === true ? `${color}33` : opts.fill === 'origin' ? `${color}40` : undefined, fill: opts.fill ?? false, - borderDash: opts.dashed ? [5, 4] : undefined, + borderDash: opts.dash ?? (opts.dashed ? [5, 4] : undefined), borderWidth: opts.borderWidth ?? (opts.dashed ? 1 : 1.2), pointRadius: 0, hitRadius: 6, @@ -161,6 +172,12 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: mkDs('fve_real', 'FVE ■', series.fveReal, COL.fve, { fill: true, order: 7 }), mkDs('baz_pred', 'Spotřeba ···', series.bazPred, COL.baz, { dashed: true, order: 8 }), mkDs('fve_pred', 'FVE ···', series.fvePred, COL.fve, { dashed: true, order: 9 }), + mkDs('fve_corr', 'FVE (korig.)', series.fveCorr, COL.fve, { + dashed: true, + dash: [2, 3], + order: 9, + borderWidth: 1, + }), mkDs('buy_price', 'Nákup', series.buy, COL.buy, { dashed: true, yAxisID: 'y1', @@ -267,6 +284,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: s.fveReal, s.bazPred, s.fvePred, + s.fveCorr, s.buy, s.sell, ] @@ -290,6 +308,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: 'fve_real', 'baz_pred', 'fve_pred', + 'fve_corr', 'buy_price', 'sell_price', ] as const @@ -326,7 +345,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: className="h-2.5 w-4 shrink-0 rounded-sm border border-white/10" style={{ backgroundColor: off ? 'transparent' : item.color, - borderStyle: item.dashed ? 'dashed' : 'solid', + borderStyle: item.dashStyle === 'dotted' ? 'dotted' : item.dashed ? 'dashed' : 'solid', }} /> {item.label} diff --git a/frontend/src/hooks/useDashboardData.ts b/frontend/src/hooks/useDashboardData.ts index 0b4b248..dca3f52 100644 --- a/frontend/src/hooks/useDashboardData.ts +++ b/frontend/src/hooks/useDashboardData.ts @@ -5,6 +5,7 @@ import { getCurrentPlan, getSiteForecastPv, getSitePrices, + getForecastPvSlotsRangeCorrected, type SiteEffectivePriceRowDto, } from '../api/backend' import { getJson } from '../api/postgrest' @@ -279,6 +280,20 @@ export function useDashboardData(siteId: number | null) { if (!fc) continue addForecastToByStart(fc, forecastBySlot) } + + const windowFromIso = new Date(windowStart).toISOString() + const windowToIso = new Date(windowStart + TOTAL_SLOTS * SLOT_MS).toISOString() + const correctedSlots = await getForecastPvSlotsRangeCorrected(siteId, windowFromIso, windowToIso).catch( + () => [] as Awaited>, + ) + const correctedBySlot = new Map() + for (const r of correctedSlots) { + const t = new Date(r.interval_start).getTime() + if (!Number.isFinite(t)) continue + const v = r.pv_forecast_corrected_w + if (v == null) continue + correctedBySlot.set(slotTimeKey(t), Number(v)) + } for (const ymd of weekDates) { const fc = forecastByYmd.get(ymd) ?? null if (!fc) { @@ -364,6 +379,10 @@ export function useDashboardData(siteId: number | null) { base.pv_a_forecast_w = fc.a base.pv_b_forecast_w = fc.b } + const corr = correctedBySlot.get(k) + if (corr != null) { + base.pv_forecast_corrected_w = corr + } const pi = planBySlot.get(k) if (pi) mergeInterval(base, pi) diff --git a/frontend/src/pages/ForecastVsActual.tsx b/frontend/src/pages/ForecastVsActual.tsx new file mode 100644 index 0000000..14a9f49 --- /dev/null +++ b/frontend/src/pages/ForecastVsActual.tsx @@ -0,0 +1,300 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' + +import { + getBaselineLoadSlotsRange, + getForecastPvSlotsRangeCorrected, + getTelemetry15mRange, + type BaselineLoadSlotRow, + type ForecastPvSlotCorrectedRow, + type Telemetry15mRow, +} from '../api/backend' +import { useSiteStatus } from '../hooks/useSiteStatus' +import { instantPragueDay } from '../lib/pragueDate' + +type MetricKey = 'pv' | 'load' | 'grid' + +type Point = { + k: string + timeLabel: string + actual_kw: number | null + forecast_kw: number | null + corrected_kw: number | null +} + +function kwFromW(w: number | null | undefined): number | null { + if (w == null || Number.isNaN(Number(w))) return null + return Number(w) / 1000 +} + +function fmtDayLabel(ymd: string): string { + return new Date(ymd + 'T12:00:00Z').toLocaleDateString('cs-CZ', { + weekday: 'short', + day: 'numeric', + month: 'numeric', + timeZone: 'Europe/Prague', + }) +} + +function DayChart({ + title, + points, + showForecast, + showCorrected, +}: { + title: string + points: Point[] + showForecast: boolean + showCorrected: boolean +}) { + return ( +
+
{title}
+ + + + + + + + + {showForecast ? ( + + ) : null} + {showCorrected ? ( + + ) : null} + + +
+ ) +} + +export default function ForecastVsActual() { + const { site: siteRow, ready: siteReady, error: siteErr } = useSiteStatus() + const siteId = siteRow?.site_id ?? null + + const [metric, setMetric] = useState('pv') + const [days, setDays] = useState(20) + const [ready, setReady] = useState(false) + const [error, setError] = useState(null) + + const [telemetry, setTelemetry] = useState([]) + const [pvSlots, setPvSlots] = useState([]) + const [baselineSlots, setBaselineSlots] = useState([]) + + const load = useCallback(async () => { + if (siteId == null) { + setTelemetry([]) + setPvSlots([]) + setBaselineSlots([]) + setError(null) + setReady(true) + return + } + const to = new Date() + const from = new Date(to.getTime() - days * 24 * 60 * 60 * 1000) + const fromIso = from.toISOString() + const toIso = to.toISOString() + try { + const [tel, pv, base] = await Promise.all([ + getTelemetry15mRange(siteId, fromIso, toIso), + getForecastPvSlotsRangeCorrected(siteId, fromIso, toIso), + getBaselineLoadSlotsRange(siteId, fromIso, toIso), + ]) + setTelemetry(tel) + setPvSlots(pv) + setBaselineSlots(base) + setError(null) + } catch (e) { + setTelemetry([]) + setPvSlots([]) + setBaselineSlots([]) + setError(e instanceof Error ? e.message : 'Chyba načítání dat') + } finally { + setReady(true) + } + }, [siteId, days]) + + useEffect(() => { + void load() + }, [load]) + + const byInterval = useMemo(() => { + const map = new Map() + for (const r of telemetry) map.set(r.slot_start, { ...(map.get(r.slot_start) ?? {}), tel: r }) + for (const r of pvSlots) map.set(r.interval_start, { ...(map.get(r.interval_start) ?? {}), pv: r }) + for (const r of baselineSlots) map.set(r.interval_start, { ...(map.get(r.interval_start) ?? {}), base: r }) + return map + }, [telemetry, pvSlots, baselineSlots]) + + const daysGrouped = useMemo(() => { + const byDay = new Map() + const keys = [...byInterval.keys()].sort((a, b) => new Date(a).getTime() - new Date(b).getTime()) + for (const iso of keys) { + const item = byInterval.get(iso) + if (!item?.tel) continue + const d = new Date(iso) + const day = instantPragueDay(iso) + const timeLabel = d.toLocaleTimeString('cs-CZ', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'Europe/Prague', + }) + const k = iso + + let actual_kw: number | null = null + let forecast_kw: number | null = null + let corrected_kw: number | null = null + + if (metric === 'pv') { + actual_kw = kwFromW(item.tel.avg_pv_w) + forecast_kw = kwFromW(item.pv?.pv_forecast_total_w ?? null) + corrected_kw = kwFromW(item.pv?.pv_forecast_corrected_w ?? null) + } else if (metric === 'load') { + actual_kw = kwFromW(item.tel.avg_load_w) + forecast_kw = kwFromW(item.base?.forecast_w ?? null) + corrected_kw = null + } else if (metric === 'grid') { + actual_kw = kwFromW(item.tel.avg_grid_w) + forecast_kw = null + corrected_kw = null + } + + const arr = byDay.get(day) ?? [] + arr.push({ k, timeLabel, actual_kw, forecast_kw, corrected_kw }) + byDay.set(day, arr) + } + return [...byDay.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([day, points]) => ({ day, points })) + }, [byInterval, metric]) + + const title = metric === 'pv' ? 'FVE (výroba)' : metric === 'load' ? 'Spotřeba (bazál)' : 'Síť (signed)' + const showForecast = metric === 'pv' || metric === 'load' + const showCorrected = metric === 'pv' + + const tabClass = (on: boolean) => + `rounded-lg px-3 py-2 text-sm font-medium transition ${on ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'}` + + return ( +
+
+
+

Srovnání predikce vs skutečnost

+

Posledních {days} dní (po dnech, 15min sloty)

+
+ + {!siteReady ? ( +

Načítám lokalitu…

+ ) : siteErr ? ( +

{siteErr}

+ ) : siteId == null ? ( +

Žádná vybraná lokalita.

+ ) : ( +
+ + + +
+ + +
+
+ )} + + {error ? ( +
+ {error} +
+ ) : null} + + {!ready ? ( +

Načítám data…

+ ) : daysGrouped.length === 0 ? ( +

Žádná data pro zvolený rozsah.

+ ) : ( +
+ {daysGrouped.map(({ day, points }) => ( + + ))} +
+ )} +
+
+ ) +} + diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 1f43928..e4daf3a 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -14,6 +14,8 @@ export type SlotData = { gen_port_power_w: number | null pv_a_forecast_w: number | null pv_b_forecast_w: number | null + /** Korigovaný součet FVE forecastu (W). */ + pv_forecast_corrected_w?: number | null load_baseline_w: number | null ev1_setpoint_w: number | null ev2_setpoint_w: number | null