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

@@ -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() {
<NavLink to="/planning" className={tabClass}>
Plánování
</NavLink>
<NavLink to="/forecast-vs-actual" className={tabClass}>
Srovnání
</NavLink>
<NavLink to="/economics" className={tabClass}>
Ekonomika
</NavLink>
@@ -111,6 +115,7 @@ export default function App() {
<Route element={<AppLayout />}>
<Route index element={<Dashboard />} />
<Route path="planning" element={<Planning />} />
<Route path="forecast-vs-actual" element={<ForecastVsActual />} />
<Route path="economics" element={<Economics />} />
<Route path="energy-flows" element={<EnergyFlows />} />
<Route path="site-config" element={<SiteConfiguration />} />

View File

@@ -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<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 */
export type SiteEffectivePriceRowDto = {
site_id: number

View File

@@ -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}

View File

@@ -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<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) {
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)

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
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