458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
import { Loader2, RefreshCw } from 'lucide-react'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import {
|
|
Area,
|
|
CartesianGrid,
|
|
ComposedChart,
|
|
Legend,
|
|
Line,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from 'recharts'
|
|
|
|
import { getCurrentPlan, postRunPlan } from './api/backend'
|
|
import { useSiteStatus } from './hooks/useSiteStatus'
|
|
import type { CurrentPlanResponse, PlanningIntervalDto } from './types/plan'
|
|
|
|
const TZ = 'Europe/Prague'
|
|
|
|
function formatLocal(iso: string): string {
|
|
const d = new Date(iso)
|
|
return d.toLocaleString('cs-CZ', {
|
|
timeZone: TZ,
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
function formatLocalTime(iso: string): string {
|
|
return new Date(iso).toLocaleTimeString('cs-CZ', {
|
|
timeZone: TZ,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
function slotStartUtcMs(iso: string): number {
|
|
return new Date(iso).getTime()
|
|
}
|
|
|
|
function negPrice(i: PlanningIntervalDto): boolean {
|
|
const b = i.effective_buy_price
|
|
const s = i.effective_sell_price
|
|
return (b != null && b < 0) || (s != null && s < 0)
|
|
}
|
|
|
|
function rowHighlight(i: PlanningIntervalDto): string {
|
|
if (negPrice(i)) return 'bg-red-950/45'
|
|
if ((i.pv_a_curtailed_w ?? 0) > 0) return 'bg-amber-950/35'
|
|
return ''
|
|
}
|
|
|
|
type ChartRow = {
|
|
label: string
|
|
ts: number
|
|
pv_kw: number
|
|
baseline_kw: number
|
|
bat_charge_kw: number
|
|
bat_discharge_kw: number
|
|
price: number
|
|
raw: PlanningIntervalDto
|
|
}
|
|
|
|
export default function Planning() {
|
|
const { site, ready: siteReady } = useSiteStatus()
|
|
const siteId = site?.site_id ?? null
|
|
|
|
const [data, setData] = useState<CurrentPlanResponse | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [replanning, setReplanning] = useState(false)
|
|
const [slotDetail, setSlotDetail] = useState<PlanningIntervalDto | null>(null)
|
|
|
|
const load = useCallback(async () => {
|
|
if (siteId == null) return
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await getCurrentPlan(siteId)
|
|
setData(res)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Chyba načtení plánu')
|
|
setData(null)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [siteId])
|
|
|
|
useEffect(() => {
|
|
if (siteId != null) void load()
|
|
}, [siteId, load])
|
|
|
|
const nowMs = Date.now()
|
|
const dayMs = 24 * 60 * 60 * 1000
|
|
|
|
const intervals24h = useMemo(() => {
|
|
if (!data?.intervals?.length) return []
|
|
const end = nowMs + dayMs
|
|
return data.intervals
|
|
.filter((i) => {
|
|
const t = slotStartUtcMs(i.interval_start)
|
|
return t >= nowMs && t < end
|
|
})
|
|
.slice(0, 96)
|
|
}, [data?.intervals, nowMs, dayMs])
|
|
|
|
const chartRows: ChartRow[] = useMemo(() => {
|
|
return intervals24h.map((i) => {
|
|
const bat = i.battery_setpoint_w ?? 0
|
|
const pv = i.pv_forecast_total_w ?? 0
|
|
const base = i.load_baseline_w ?? 0
|
|
const price = i.effective_buy_price ?? 0
|
|
return {
|
|
label: formatLocalTime(i.interval_start),
|
|
ts: slotStartUtcMs(i.interval_start),
|
|
pv_kw: pv / 1000,
|
|
baseline_kw: base / 1000,
|
|
bat_charge_kw: Math.max(0, bat) / 1000,
|
|
bat_discharge_kw: Math.max(0, -bat) / 1000,
|
|
price,
|
|
raw: i,
|
|
}
|
|
})
|
|
}, [intervals24h])
|
|
|
|
async function onReplan() {
|
|
if (siteId == null) return
|
|
setReplanning(true)
|
|
setError(null)
|
|
try {
|
|
await postRunPlan(siteId, 'rolling')
|
|
await load()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Přepočet selhal')
|
|
} finally {
|
|
setReplanning(false)
|
|
}
|
|
}
|
|
|
|
if (!siteReady) {
|
|
return (
|
|
<div className="flex min-h-[40vh] items-center justify-center text-slate-400">
|
|
Načítám lokalitu…
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (siteId == null) {
|
|
return (
|
|
<div className="rounded-lg border border-amber-900/50 bg-amber-950/20 p-4 text-amber-200">
|
|
V PostgREST nebyla nalezena lokalita (vw_site_status). Nelze načíst plán.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const run = data?.run
|
|
const summary = data?.summary
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl space-y-8 p-4 md:p-6">
|
|
<header className="space-y-1">
|
|
<h1 className="text-xl font-semibold tracking-tight text-white">Plánování</h1>
|
|
<p className="text-sm text-slate-400">
|
|
Aktuální LP plán a přehled dalších 24 hodin ({site?.site_name ?? 'lokalita'})
|
|
</p>
|
|
</header>
|
|
|
|
{error && (
|
|
<div className="rounded-md border border-red-900/60 bg-red-950/30 px-3 py-2 text-sm text-red-200">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Sekce 1 */}
|
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
|
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">
|
|
Aktuální plán
|
|
</h2>
|
|
{loading && !run ? (
|
|
<div className="flex items-center gap-2 text-slate-400">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Načítám…
|
|
</div>
|
|
) : !run ? (
|
|
<p className="text-slate-400">Žádný aktivní plán v databázi.</p>
|
|
) : (
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 md:gap-x-8">
|
|
<div>
|
|
<dt className="text-slate-500">Vytvořen</dt>
|
|
<dd className="font-mono text-slate-200">{formatLocal(run.created_at)}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-slate-500">Typ</dt>
|
|
<dd className="capitalize text-slate-200">{run.run_type}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-slate-500">Korekce FVE</dt>
|
|
<dd className="font-mono text-slate-200">
|
|
{run.forecast_correction_factor != null
|
|
? run.forecast_correction_factor.toFixed(4)
|
|
: '—'}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-slate-500">Čas solveru</dt>
|
|
<dd className="font-mono text-slate-200">
|
|
{run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
<button
|
|
type="button"
|
|
onClick={() => void onReplan()}
|
|
disabled={replanning}
|
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500 disabled:opacity-50"
|
|
>
|
|
{replanning ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="h-4 w-4" />
|
|
)}
|
|
Přeplánovat nyní
|
|
</button>
|
|
</div>
|
|
)}
|
|
{summary && run && (
|
|
<div className="mt-4 grid grid-cols-2 gap-3 border-t border-slate-800 pt-4 text-xs text-slate-400 md:grid-cols-5">
|
|
<div>
|
|
<div className="text-slate-500">Očekávané náklady (celkem)</div>
|
|
<div className="font-mono text-slate-200">
|
|
{summary.total_expected_cost_czk.toFixed(2)} Kč
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-slate-500">Curtailment A</div>
|
|
<div className="font-mono text-slate-200">
|
|
{summary.total_pv_curtailed_kwh.toFixed(3)} kWh
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-slate-500">Sloty nabíjení</div>
|
|
<div className="font-mono text-slate-200">{summary.charge_slots}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-slate-500">Sloty vybíjení</div>
|
|
<div className="font-mono text-slate-200">{summary.discharge_slots}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-slate-500">Sloty exportu</div>
|
|
<div className="font-mono text-slate-200">{summary.export_slots}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Sekce 2 */}
|
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
|
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">
|
|
Graf (24 h)
|
|
</h2>
|
|
{!chartRows.length ? (
|
|
<p className="text-sm text-slate-500">Žádná data pro graf v horizontu 24 h.</p>
|
|
) : (
|
|
<div className="h-[380px] w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart
|
|
data={chartRows}
|
|
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
|
|
onClick={(state) => {
|
|
const p = state?.activePayload?.[0]?.payload as ChartRow | undefined
|
|
if (p?.raw) setSlotDetail(p.raw)
|
|
}}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
|
<XAxis dataKey="label" tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
|
<YAxis
|
|
yAxisId="left"
|
|
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
|
label={{ value: 'kW', angle: -90, position: 'insideLeft', fill: '#64748b' }}
|
|
/>
|
|
<YAxis
|
|
yAxisId="right"
|
|
orientation="right"
|
|
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
|
label={{ value: 'Kč/kWh', angle: 90, position: 'insideRight', fill: '#64748b' }}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
background: '#0f172a',
|
|
border: '1px solid #334155',
|
|
borderRadius: 8,
|
|
}}
|
|
formatter={(value: number, name: string) => {
|
|
if (name === 'Cena nákup') return [`${value.toFixed(3)} Kč/kWh`, name]
|
|
return [`${value.toFixed(2)} kW`, name]
|
|
}}
|
|
/>
|
|
<Legend />
|
|
<Area
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="pv_kw"
|
|
name="FVE předpověď"
|
|
stroke="#ca8a04"
|
|
fill="#eab308"
|
|
fillOpacity={0.35}
|
|
/>
|
|
<Line
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="baseline_kw"
|
|
name="Spotřeba baseline"
|
|
stroke="#3b82f6"
|
|
dot={false}
|
|
strokeWidth={2}
|
|
/>
|
|
<Line
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="bat_charge_kw"
|
|
name="Baterie nabíjení"
|
|
stroke="#22c55e"
|
|
dot={false}
|
|
strokeWidth={2}
|
|
/>
|
|
<Line
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="bat_discharge_kw"
|
|
name="Baterie vybíjení"
|
|
stroke="#f97316"
|
|
dot={false}
|
|
strokeWidth={2}
|
|
/>
|
|
<Line
|
|
yAxisId="right"
|
|
type="monotone"
|
|
dataKey="price"
|
|
name="Cena nákup"
|
|
stroke="#94a3b8"
|
|
dot={false}
|
|
strokeWidth={2}
|
|
/>
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
{slotDetail && (
|
|
<div className="mt-4 rounded-lg border border-slate-700 bg-slate-950/60 p-3 text-sm">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className="font-medium text-slate-200">
|
|
Slot {formatLocal(slotDetail.interval_start)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="text-xs text-slate-500 hover:text-slate-300"
|
|
onClick={() => setSlotDetail(null)}
|
|
>
|
|
Zavřít
|
|
</button>
|
|
</div>
|
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-xs text-slate-300 md:grid-cols-3">
|
|
<dt className="text-slate-500">Nákup / prodej</dt>
|
|
<dd className="col-span-1">
|
|
{slotDetail.effective_buy_price?.toFixed(4) ?? '—'} /{' '}
|
|
{slotDetail.effective_sell_price?.toFixed(4) ?? '—'}
|
|
</dd>
|
|
<dt className="text-slate-500">FVE (A+B)</dt>
|
|
<dd>{slotDetail.pv_forecast_total_w ?? '—'} W</dd>
|
|
<dt className="text-slate-500">Baseline</dt>
|
|
<dd>{slotDetail.load_baseline_w ?? '—'} W</dd>
|
|
<dt className="text-slate-500">Baterie</dt>
|
|
<dd>{slotDetail.battery_setpoint_w ?? '—'} W</dd>
|
|
<dt className="text-slate-500">SoC cíl</dt>
|
|
<dd>
|
|
{slotDetail.battery_soc_target_pct != null
|
|
? `${slotDetail.battery_soc_target_pct}%`
|
|
: '—'}
|
|
</dd>
|
|
<dt className="text-slate-500">Síť</dt>
|
|
<dd>{slotDetail.grid_setpoint_w ?? '—'} W</dd>
|
|
<dt className="text-slate-500">EV1 / EV2</dt>
|
|
<dd>
|
|
{slotDetail.ev1_setpoint_w ?? '—'} / {slotDetail.ev2_setpoint_w ?? '—'} W
|
|
</dd>
|
|
<dt className="text-slate-500">TČ</dt>
|
|
<dd>{slotDetail.heat_pump_enabled ? 'Zapnuto' : 'Vypnuto'}</dd>
|
|
<dt className="text-slate-500">Curtailment A</dt>
|
|
<dd>{slotDetail.pv_a_curtailed_w ?? 0} W</dd>
|
|
<dt className="text-slate-500">Náklady slotu</dt>
|
|
<dd>{slotDetail.expected_cost_czk?.toFixed(4) ?? '—'} Kč</dd>
|
|
</dl>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Sekce 3 */}
|
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
|
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">
|
|
Tabulka (96 slotů / 24 h)
|
|
</h2>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-left text-xs">
|
|
<thead>
|
|
<tr className="border-b border-slate-700 text-slate-500">
|
|
<th className="py-2 pr-2 font-medium">Čas</th>
|
|
<th className="py-2 pr-2 font-medium">Nákup</th>
|
|
<th className="py-2 pr-2 font-medium">Prodej</th>
|
|
<th className="py-2 pr-2 font-medium">FVE</th>
|
|
<th className="py-2 pr-2 font-medium">Bat</th>
|
|
<th className="py-2 pr-2 font-medium">Síť</th>
|
|
<th className="py-2 pr-2 font-medium">EV1</th>
|
|
<th className="py-2 pr-2 font-medium">EV2</th>
|
|
<th className="py-2 pr-2 font-medium">TČ</th>
|
|
<th className="py-2 font-medium">Náklady</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{intervals24h.map((i) => (
|
|
<tr key={i.interval_start} className={`border-b border-slate-800/80 ${rowHighlight(i)}`}>
|
|
<td className="whitespace-nowrap py-1.5 pr-2 font-mono text-slate-300">
|
|
{formatLocalTime(i.interval_start)}
|
|
</td>
|
|
<td className="pr-2 font-mono text-slate-300">
|
|
{i.effective_buy_price?.toFixed(2) ?? '—'}
|
|
</td>
|
|
<td className="pr-2 font-mono text-slate-300">
|
|
{i.effective_sell_price?.toFixed(2) ?? '—'}
|
|
</td>
|
|
<td className="pr-2 font-mono text-slate-300">
|
|
{i.pv_forecast_total_w != null ? Math.round(i.pv_forecast_total_w) : '—'}
|
|
</td>
|
|
<td className="pr-2 font-mono text-slate-300">
|
|
{i.battery_setpoint_w ?? '—'}
|
|
</td>
|
|
<td className="pr-2 font-mono text-slate-300">{i.grid_setpoint_w ?? '—'}</td>
|
|
<td className="pr-2 font-mono text-slate-300">{i.ev1_setpoint_w ?? '—'}</td>
|
|
<td className="pr-2 font-mono text-slate-300">{i.ev2_setpoint_w ?? '—'}</td>
|
|
<td className="pr-2 text-slate-300">{i.heat_pump_enabled ? 'Ano' : 'Ne'}</td>
|
|
<td className="font-mono text-slate-300">
|
|
{i.expected_cost_czk?.toFixed(2) ?? '—'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{!intervals24h.length && !loading && (
|
|
<p className="mt-2 text-sm text-slate-500">Žádné řádky v 24h okně.</p>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|