korkece fve predikce, grafy predikci
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-22 19:26:46 +02:00
parent ffe80679cc
commit 9ca4b4c577
10 changed files with 819 additions and 5 deletions

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import json import json
import logging import logging
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta, timezone
from typing import Annotated, Any from typing import Annotated, Any
import asyncpg import asyncpg
@@ -522,3 +522,159 @@ async def get_site_forecast_pv_slots_range(
if not isinstance(slots, list): if not isinstance(slots, list):
slots = [] slots = []
return {"slots": 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]}

View File

@@ -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.';

View File

@@ -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.';

View File

@@ -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. | | **Telemetry výroba FVE:** Registry 672/673/667 jsou **signed** W; `pv_power_w` = max(0,pv1)+max(0,pv2)+max(0,gen) (dashboard); sloupce pv1/pv2/gen ukládají signed pro audit. |
| **Ekonomika baterie:** snížení `reserve_soc_percent` na 10 % a `degradation_cost_czk_kwh` na 0.1500 (migrace `V026__battery_economics_tuning.sql`), úpravy objective pro ekonomicky konzistentnější nabíjení/vybíjení. | | **Ekonomika baterie:** snížení `reserve_soc_percent` na 10 % a `degradation_cost_czk_kwh` na 0.1500 (migrace `V026__battery_economics_tuning.sql`), úpravy objective pro ekonomicky konzistentnější nabíjení/vybíjení. |
| **Planning UI operátor akce:** trvale viditelné akce import/forecast/init plan, volba data OTE (dnes/zítra), zobrazení `pv_scarcity_factor` ve stavu plánu. | | **Planning UI operátor akce:** trvale viditelné akce import/forecast/init plan, volba data OTE (dnes/zítra), zobrazení `pv_scarcity_factor` ve stavu plánu. |
| **PV delta profil cutoff historie:** 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`). |
--- ---

View File

@@ -6,6 +6,7 @@ import { useWsLogErrorCount } from './hooks/useWsLogErrorCount'
import { Dashboard } from './pages/Dashboard' import { Dashboard } from './pages/Dashboard'
import Economics from './pages/Economics' import Economics from './pages/Economics'
import EnergyFlows from './pages/EnergyFlows' import EnergyFlows from './pages/EnergyFlows'
import ForecastVsActual from './pages/ForecastVsActual'
import { Logs } from './pages/Logs' import { Logs } from './pages/Logs'
import Planning from './pages/Planning' import Planning from './pages/Planning'
import SiteConfiguration from './pages/SiteConfiguration' import SiteConfiguration from './pages/SiteConfiguration'
@@ -70,6 +71,9 @@ function AppLayout() {
<NavLink to="/planning" className={tabClass}> <NavLink to="/planning" className={tabClass}>
Plánování Plánování
</NavLink> </NavLink>
<NavLink to="/forecast-vs-actual" className={tabClass}>
Srovnání
</NavLink>
<NavLink to="/economics" className={tabClass}> <NavLink to="/economics" className={tabClass}>
Ekonomika Ekonomika
</NavLink> </NavLink>
@@ -111,6 +115,7 @@ export default function App() {
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="planning" element={<Planning />} /> <Route path="planning" element={<Planning />} />
<Route path="forecast-vs-actual" element={<ForecastVsActual />} />
<Route path="economics" element={<Economics />} /> <Route path="economics" element={<Economics />} />
<Route path="energy-flows" element={<EnergyFlows />} /> <Route path="energy-flows" element={<EnergyFlows />} />
<Route path="site-config" element={<SiteConfiguration />} /> <Route path="site-config" element={<SiteConfiguration />} />

View File

@@ -117,6 +117,74 @@ export async function getForecastPvSlotsRange(
return Array.isArray(data?.slots) ? data.slots : [] 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<ForecastPvSlotCorrectedRow[]> {
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<Telemetry15mRow[]> {
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<BaselineLoadSlotRow[]> {
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 */ /** GET /api/v1/sites/{id}/prices?date=YYYY-MM-DD */
export type SiteEffectivePriceRowDto = { export type SiteEffectivePriceRowDto = {
site_id: number site_id: number

View File

@@ -31,11 +31,18 @@ function sumW(a: number | null, b: number | null): number | null {
return (a ?? 0) + (b ?? 0) 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[] = [ export const ENERGY_LEGEND: EnergyLegendItem[] = [
{ key: 'fve_real', label: 'FVE skutečnost', color: COL.fve }, { key: 'fve_real', label: 'FVE skutečnost', color: COL.fve },
{ key: 'fve_pred', label: 'FVE předpověď', color: COL.fve, dashed: true }, { 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_real', label: 'Spotřeba skutečnost', color: COL.baz },
{ key: 'baz_pred', label: 'Spotřeba předpověď', color: COL.baz, dashed: true }, { key: 'baz_pred', label: 'Spotřeba předpověď', color: COL.baz, dashed: true },
{ key: 'ev', label: 'EV plán', color: COL.ev }, { 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 series = useMemo(() => {
const fveReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.pv_power_w) : null)) 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 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 bazReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.load_power_w) : null))
const bazPred = slots.map((s) => kwFromW(s.load_baseline_w)) 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))) 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 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)) 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]) }, [slots, nowIndex])
const bgPlugin = useMemo( const bgPlugin = useMemo(
@@ -126,6 +136,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
opts: { opts: {
fill?: boolean | 'origin' fill?: boolean | 'origin'
dashed?: boolean dashed?: boolean
dash?: number[]
yAxisID?: string yAxisID?: string
order: number order: number
borderWidth?: number borderWidth?: number
@@ -137,7 +148,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
backgroundColor: backgroundColor:
opts.fill === true ? `${color}33` : opts.fill === 'origin' ? `${color}40` : undefined, opts.fill === true ? `${color}33` : opts.fill === 'origin' ? `${color}40` : undefined,
fill: opts.fill ?? false, 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), borderWidth: opts.borderWidth ?? (opts.dashed ? 1 : 1.2),
pointRadius: 0, pointRadius: 0,
hitRadius: 6, 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('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('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_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, { mkDs('buy_price', 'Nákup', series.buy, COL.buy, {
dashed: true, dashed: true,
yAxisID: 'y1', yAxisID: 'y1',
@@ -267,6 +284,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
s.fveReal, s.fveReal,
s.bazPred, s.bazPred,
s.fvePred, s.fvePred,
s.fveCorr,
s.buy, s.buy,
s.sell, s.sell,
] ]
@@ -290,6 +308,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
'fve_real', 'fve_real',
'baz_pred', 'baz_pred',
'fve_pred', 'fve_pred',
'fve_corr',
'buy_price', 'buy_price',
'sell_price', 'sell_price',
] as const ] 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" className="h-2.5 w-4 shrink-0 rounded-sm border border-white/10"
style={{ style={{
backgroundColor: off ? 'transparent' : item.color, backgroundColor: off ? 'transparent' : item.color,
borderStyle: item.dashed ? 'dashed' : 'solid', borderStyle: item.dashStyle === 'dotted' ? 'dotted' : item.dashed ? 'dashed' : 'solid',
}} }}
/> />
{item.label} {item.label}

View File

@@ -5,6 +5,7 @@ import {
getCurrentPlan, getCurrentPlan,
getSiteForecastPv, getSiteForecastPv,
getSitePrices, getSitePrices,
getForecastPvSlotsRangeCorrected,
type SiteEffectivePriceRowDto, type SiteEffectivePriceRowDto,
} from '../api/backend' } from '../api/backend'
import { getJson } from '../api/postgrest' import { getJson } from '../api/postgrest'
@@ -279,6 +280,20 @@ export function useDashboardData(siteId: number | null) {
if (!fc) continue if (!fc) continue
addForecastToByStart(fc, forecastBySlot) 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<ReturnType<typeof getForecastPvSlotsRangeCorrected>>,
)
const correctedBySlot = new Map<string, number>()
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) { for (const ymd of weekDates) {
const fc = forecastByYmd.get(ymd) ?? null const fc = forecastByYmd.get(ymd) ?? null
if (!fc) { if (!fc) {
@@ -364,6 +379,10 @@ export function useDashboardData(siteId: number | null) {
base.pv_a_forecast_w = fc.a base.pv_a_forecast_w = fc.a
base.pv_b_forecast_w = fc.b 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) const pi = planBySlot.get(k)
if (pi) mergeInterval(base, pi) if (pi) mergeInterval(base, pi)

View File

@@ -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 (
<div className="h-[240px] w-full rounded-xl border border-slate-800 bg-slate-900/40 p-2 pt-4">
<div className="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</div>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.6} />
<XAxis dataKey="timeLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval={7} />
<YAxis
tick={{ fill: '#94a3b8', fontSize: 11 }}
label={{ value: 'kW', angle: -90, position: 'insideLeft', fill: '#64748b', fontSize: 11 }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '8px',
}}
labelStyle={{ color: '#e2e8f0' }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line
type="monotone"
dataKey="actual_kw"
name="Skutečnost"
stroke="#e2e8f0"
strokeWidth={2}
dot={false}
connectNulls
/>
{showForecast ? (
<Line
type="monotone"
dataKey="forecast_kw"
name="Předpověď"
stroke="#ef9f27"
strokeWidth={1.5}
dot={false}
connectNulls
strokeDasharray="5 4"
/>
) : null}
{showCorrected ? (
<Line
type="monotone"
dataKey="corrected_kw"
name="Korigovaná"
stroke="#ef9f27"
strokeWidth={1.5}
dot={false}
connectNulls
strokeDasharray="2 3"
/>
) : null}
</LineChart>
</ResponsiveContainer>
</div>
)
}
export default function ForecastVsActual() {
const { site: siteRow, ready: siteReady, error: siteErr } = useSiteStatus()
const siteId = siteRow?.site_id ?? null
const [metric, setMetric] = useState<MetricKey>('pv')
const [days, setDays] = useState(20)
const [ready, setReady] = useState(false)
const [error, setError] = useState<string | null>(null)
const [telemetry, setTelemetry] = useState<Telemetry15mRow[]>([])
const [pvSlots, setPvSlots] = useState<ForecastPvSlotCorrectedRow[]>([])
const [baselineSlots, setBaselineSlots] = useState<BaselineLoadSlotRow[]>([])
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<string, { tel?: Telemetry15mRow; pv?: ForecastPvSlotCorrectedRow; base?: BaselineLoadSlotRow }>()
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<string, Point[]>()
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 (
<div className="min-h-screen bg-gray-950 p-4 text-slate-100 md:p-8">
<div className="mx-auto max-w-7xl space-y-5">
<header className="border-b border-slate-800/80 pb-5">
<h1 className="text-2xl font-bold tracking-tight text-white">Srovnání predikce vs skutečnost</h1>
<p className="mt-1 text-sm text-slate-400">Posledních {days} dní (po dnech, 15min sloty)</p>
</header>
{!siteReady ? (
<p className="text-sm text-slate-500">Načítám lokalitu</p>
) : siteErr ? (
<p className="text-sm text-red-200">{siteErr}</p>
) : siteId == null ? (
<p className="text-sm text-slate-500">Žádná vybraná lokalita.</p>
) : (
<div className="flex flex-wrap items-center gap-2">
<button type="button" className={tabClass(metric === 'pv')} onClick={() => setMetric('pv')}>
FVE
</button>
<button type="button" className={tabClass(metric === 'load')} onClick={() => setMetric('load')}>
Spotřeba
</button>
<button type="button" className={tabClass(metric === 'grid')} onClick={() => setMetric('grid')}>
Síť
</button>
<div className="ml-auto flex items-center gap-2">
<label className="text-xs text-slate-400">
Dní:{' '}
<input
className="ml-1 w-20 rounded-md border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100"
type="number"
min={3}
max={60}
value={days}
onChange={(e) => setDays(Math.max(3, Math.min(60, Number(e.target.value) || 20)))}
/>
</label>
<button
type="button"
onClick={() => void load()}
className="rounded-lg bg-slate-800 px-3 py-2 text-sm font-semibold text-slate-100 hover:bg-slate-700"
>
Obnovit
</button>
</div>
</div>
)}
{error ? (
<div className="rounded-xl border border-red-500/40 bg-red-950/40 px-4 py-3 text-sm text-red-200" role="alert">
{error}
</div>
) : null}
{!ready ? (
<p className="text-sm text-slate-500">Načítám data</p>
) : daysGrouped.length === 0 ? (
<p className="text-sm text-slate-500">Žádná data pro zvolený rozsah.</p>
) : (
<section className="space-y-4">
{daysGrouped.map(({ day, points }) => (
<DayChart
key={day}
title={`${fmtDayLabel(day)} · ${title}`}
points={points}
showForecast={showForecast}
showCorrected={showCorrected}
/>
))}
</section>
)}
</div>
</div>
)
}

View File

@@ -14,6 +14,8 @@ export type SlotData = {
gen_port_power_w: number | null gen_port_power_w: number | null
pv_a_forecast_w: number | null pv_a_forecast_w: number | null
pv_b_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 load_baseline_w: number | null
ev1_setpoint_w: number | null ev1_setpoint_w: number | null
ev2_setpoint_w: number | null ev2_setpoint_w: number | null