korkece fve predikce, grafy predikci
This commit is contained in:
300
frontend/src/pages/ForecastVsActual.tsx
Normal file
300
frontend/src/pages/ForecastVsActual.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user