Files
ems/frontend/src/pages/Planning.tsx
Dusan Vojacek 3b4d54dcc7
Some checks failed
CI and deploy / migration-check (push) Failing after 22s
CI and deploy / deploy (push) Has been skipped
fix planning
2026-05-21 14:18:21 +02:00

1769 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios'
import {
ArrowDownRight,
ArrowUpRight,
CloudSun,
Loader2,
RefreshCw,
Sparkles,
Upload,
} from 'lucide-react'
import { toast } from 'sonner'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Area,
Bar,
CartesianGrid,
Cell,
ComposedChart,
Line,
ReferenceArea,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import {
getCurrentPlan,
getPlanCompare,
postImportSitePrices,
postRunForecast,
postRunPlan,
} from '../api/backend'
import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants'
import { useSiteStatus } from '../hooks/useSiteStatus'
import {
maskForInterval,
parsePlanSolverSnapshot,
type PlanMaskSlot,
type PlanSolverSnapshot,
} from '../lib/planSolverSnapshot'
import type {
CurrentPlanResponse,
PlanningCompareResponse,
PlanningIntervalDto,
} from '../types/plan'
const TZ = 'Europe/Prague'
function formatLocal(iso: string): string {
return new Date(iso).toLocaleString('cs-CZ', {
timeZone: TZ,
day: '2-digit',
month: '2-digit',
year: 'numeric',
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 pragueYmd(d: Date): string {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
function slotStartUtcMs(iso: string): number {
return new Date(iso).getTime()
}
const PREDICTED_LEAD_MS = 36 * 60 * 60 * 1000
const MAX_FUTURE_SLOTS = 384
function pragueDayKey(iso: string): string {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(iso))
}
function formatPragueDateLabel(iso: string): string {
return new Date(iso).toLocaleDateString('cs-CZ', {
timeZone: TZ,
weekday: 'short',
day: 'numeric',
month: 'numeric',
year: 'numeric',
})
}
function isPredictedPriceSlot(i: PlanningIntervalDto, nowMs: number): boolean {
if (i.is_predicted_price === true) return true
if (i.is_predicted_price === false) return false
return slotStartUtcMs(i.interval_start) > nowMs + PREDICTED_LEAD_MS
}
function groupByDay(slots: PlanningIntervalDto[]): Record<string, PlanningIntervalDto[]> {
return slots.reduce(
(acc, slot) => {
const day = pragueDayKey(slot.interval_start)
if (!acc[day]) acc[day] = []
acc[day].push(slot)
return acc
},
{} as Record<string, PlanningIntervalDto[]>,
)
}
function dayStats(
slots: PlanningIntervalDto[],
nowMs: number,
): {
fveKwh: number
exportKwh: number
avgBuy: number | null
} {
const slotHours = SLOT_MS / 3_600_000
let fveWh = 0
let expWh = 0
const buys: number[] = []
for (const s of slots) {
const fveW = slotFveDisplayW(s, nowMs)
fveWh += (fveW ?? 0) * slotHours
const gw = s.grid_setpoint_w ?? 0
if (gw < 0) expWh += -gw * slotHours
if (s.effective_buy_price != null) buys.push(s.effective_buy_price)
}
const avgBuy = buys.length ? buys.reduce((a, b) => a + b, 0) / buys.length : null
return { fveKwh: fveWh / 1000, exportKwh: expWh / 1000, avgBuy }
}
type HorizonHours = 24 | 48 | 96
type PlanTableRow =
| {
kind: 'summary'
dayKey: string
dateLabel: string
fveKwh: number
exportKwh: number
avgBuy: number | null
}
| { kind: 'slot'; i: PlanningIntervalDto }
function buildPlanTableRows(
visibleSlots: PlanningIntervalDto[],
nowMs: number,
): PlanTableRow[] {
const groups = groupByDay(visibleSlots)
const dayKeys = [...new Set(visibleSlots.map((s) => pragueDayKey(s.interval_start)))].sort()
const rows: PlanTableRow[] = []
for (const dk of dayKeys) {
const sl = groups[dk]
if (!sl?.length) continue
rows.push({
kind: 'summary',
dayKey: dk,
dateLabel: formatPragueDateLabel(sl[0]!.interval_start),
...dayStats(sl, nowMs),
})
for (const i of sl) rows.push({ kind: 'slot', i })
}
return rows
}
function hasGenCutoff(slots: PlanningIntervalDto[]): boolean {
return slots.some((s) => s.deye_gen_cutoff_enabled != null)
}
function horizonToggleClass(active: boolean): string {
return active
? 'border-cyan-600 bg-cyan-950/50 text-cyan-100'
: 'border-slate-600 bg-slate-800/80 text-slate-300 hover:bg-slate-800'
}
/**
* Budoucí slot: `pv_forecast_total_w` z /plan/current je raw z forecast_pv_interval; pro zobrazení
* preferujeme korekci z `pv-slots-corrected` (soulad s LP vstupy a přehledem).
* Pokud je hodnota null (data chybí), proxy z ceny nákupu (W) jen u grafu přes pvAProxyW.
*/
/** Slot jen z řady forecast (za horizontem planning_interval) — doplnění grafu. */
function isForecastExtensionInterval(i: PlanningIntervalDto): boolean {
return (
i.battery_setpoint_w == null &&
i.grid_setpoint_w == null &&
i.expected_cost_czk == null
)
}
function pvAProxyW(i: PlanningIntervalDto): number {
const pv = i.pv_forecast_total_w
if (pv != null && pv > 0) return pv
if (pv === 0) return 0
const buy = i.effective_buy_price
if (buy == null) return 0
const w = 6000 - buy * 3500
return Math.max(0, Math.min(15000, w))
}
/** Křivka FVE ve grafu: korig. / audit, jinak stejná cena-proxy jako dřív. */
function pvChartFveW(i: PlanningIntervalDto, nowMs: number): number {
const w = slotFveDisplayW(i, nowMs)
if (w != null && Number.isFinite(w)) return w
return pvAProxyW(i)
}
/** Budoucí slot (od začátku ještě nenastal): korig. předpověď; proběhlý / probíhající: telemetrie z auditu. */
function slotFveDisplayW(i: PlanningIntervalDto, nowMs: number): number | null {
const start = slotStartUtcMs(i.interval_start)
const future = start >= nowMs
if (future) {
const f = i.pv_forecast_total_w
return f != null ? Number(f) : null
}
const a = i.pv_power_w
if (a != null) return Number(a)
const f = i.pv_forecast_total_w
return f != null ? Number(f) : null
}
/** Stejná idea jako výkonové buňky: velké hodnoty v kW, jinak W (bez suffixu u malých čísel jako Bat. W). */
function formatPlanPowerW(w: number | null): string {
if (w == null || Number.isNaN(w)) return '—'
const v = Math.round(Number(w))
if (Math.abs(v) >= 1000) {
const k = v / 1000
const s = k.toFixed(1).replace(/\.0$/, '')
return `${s} kW`
}
return String(v)
}
function FveWCell({
i,
nowMs,
}: {
i: PlanningIntervalDto
nowMs: number
}) {
const w = slotFveDisplayW(i, nowMs)
const color =
w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500'
return (
<td className={`pr-2 font-mono tabular-nums ${color}`}>{formatPlanPowerW(w)}</td>
)
}
function VynosKcCell({ v }: { v: number | null | undefined }) {
if (v == null || Number.isNaN(Number(v))) {
return <td className="pr-2 font-mono tabular-nums text-slate-500"></td>
}
const n = Number(v)
const color = n < 0 ? 'text-emerald-400' : n > 0 ? 'text-red-400' : 'text-slate-500'
return (
<td className={`pr-2 font-mono tabular-nums ${color}`}>{n.toFixed(4)}</td>
)
}
function runTypeBadgeClass(t: string): string {
const u = t.toLowerCase()
if (u === 'daily') return 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-500/35'
if (u === 'rolling') return 'bg-violet-500/15 text-violet-300 ring-1 ring-violet-500/35'
if (u === 'manual') return 'bg-amber-500/15 text-amber-200 ring-1 ring-amber-500/35'
return 'bg-slate-600/40 text-slate-300 ring-1 ring-slate-500/30'
}
function axiosDetail(e: unknown): string {
if (axios.isAxiosError(e)) {
const d = e.response?.data as { detail?: unknown } | undefined
const detail = d?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail
.map((x: { msg?: string }) => (typeof x?.msg === 'string' ? x.msg : ''))
.filter(Boolean)
.join(', ')
}
}
return e instanceof Error ? e.message : 'Neznámá chyba'
}
/** Zrcadlí logiku TOU řádků z `write_inverter_setpoints` (PASSIVE/SELL/CHARGE) pro jeden plánovací interval. */
function deyeSetpointLabel(i: PlanningIntervalDto): string {
const battery_w = i.battery_setpoint_w ?? 0
const grid_w = i.grid_setpoint_w ?? 0
const exportLimitW = i.export_limit_w ?? 0
const tgt = i.battery_soc_target_pct
const targetSoc = tgt != null ? Math.min(95, Math.round(Number(tgt))) : 80
const fmtKw = (w: number) => {
const k = Math.abs(w) / 1000
const s = k.toFixed(1).replace(/\.0$/, '')
return `${s}kW`
}
const pm = (i.deye_physical_mode ?? '').toString().trim().toUpperCase()
if (pm === 'SELL') {
const tpPowerW = Math.abs(battery_w)
const cap = exportLimitW > 0 ? ` | cap ${fmtKw(exportLimitW)}` : ''
return `SELL | ⬇ ${fmtKw(tpPowerW)}${cap} | reg142=0 reg178=32`
}
if (pm === 'CHARGE') {
return `CHARGE | ⬆ ${fmtKw(Math.max(0, battery_w))} | grid=yes | SOC→${targetSoc}%`
}
// PASSIVE (ZERO): doplň informaci o variantě 108/109 podle pravidel (bez wattových prahů).
if (grid_w < 0 && battery_w >= 0) {
const cap = exportLimitW > 0 ? ` | cap ${fmtKw(exportLimitW)}` : ''
return `PASSIVE | FVE→síť${cap} (108=0)`
}
if (grid_w > 0 && battery_w <= 0) return 'PASSIVE | držet bat. (109=0)'
return 'PASSIVE | max/max'
}
function deyeModeBadge(i: PlanningIntervalDto): { label: string; klass: string; title: string } {
const m = (i.deye_physical_mode ?? 'PASSIVE').toString().trim().toUpperCase()
const battery_w = i.battery_setpoint_w ?? 0
const grid_w = i.grid_setpoint_w ?? 0
const exportLimitW = i.export_limit_w ?? 0
const exportMode = (i.export_mode ?? 'NONE').toString().trim().toUpperCase()
const cap = exportLimitW > 0 ? `; hard cap ${formatPlanPowerW(exportLimitW)}` : ''
if (m === 'SELL') {
return {
label: 'SELL',
klass: 'bg-orange-500/15 text-orange-200 ring-1 ring-orange-500/35',
title: `SELL (selling first): reg142=0, reg178=32 (grid peak shaving off)${cap}`,
}
}
if (m === 'CHARGE') {
return {
label: 'CHARGE',
klass: 'bg-sky-500/15 text-sky-200 ring-1 ring-sky-500/35',
title: 'CHARGE (grid charge): TOU grid_charge enabled v time pointech; reg178=48',
}
}
let variant = 'max/max'
if (exportMode === 'PV_SURPLUS' && grid_w < 0) {
variant = 'FVE→síť'
} else if (grid_w < 0 && battery_w >= 0) {
variant = exportMode === 'PV_SURPLUS' ? 'FVE→síť' : 'export'
}
else if (grid_w > 0 && battery_w <= 0) variant = 'držet bat. (109=0)'
return {
label: 'PASSIVE',
klass: 'bg-slate-600/40 text-slate-200 ring-1 ring-slate-500/30',
title: `PASSIVE (ZERO): ${variant}${cap}; reg142=deye_zero_export_mode; reg178=48`,
}
}
function genCutoffBadge(i: PlanningIntervalDto): { show: boolean; label: string; klass: string; title: string } {
// Nevizualizovat na site bez GEN cut-off (null/undefined ve všech slotech).
if (i.deye_gen_cutoff_enabled == null) {
return { show: false, label: '', klass: '', title: '' }
}
if (i.deye_gen_cutoff_enabled === true) {
return {
show: true,
label: 'GEN CUT',
klass: 'bg-red-500/15 text-red-200 ring-1 ring-red-500/35',
title: 'GEN port cut-off (BA81): reg178 bits0-1=3 (MI export cutoff ON)',
}
}
return {
show: true,
label: 'GEN OK',
klass: 'bg-slate-700/30 text-slate-400 ring-1 ring-slate-600/30',
title: 'GEN port připojen (cut-off OFF)',
}
}
function tableRowClass(
i: PlanningIntervalDto,
selected: boolean,
mask: PlanMaskSlot | null,
): string {
const parts: string[] = []
if (selected) parts.push('ring-1 ring-inset ring-cyan-500/50 bg-cyan-950/25')
const buy = i.effective_buy_price
const sell = i.effective_sell_price
if (buy != null && buy < 0) parts.push('bg-green-950/80')
else if (sell != null && sell < 0) parts.push('bg-red-950/80')
if ((i.pv_a_curtailed_w ?? 0) > 0) parts.push('border-l-4 border-l-yellow-500')
else if (mask?.allow_charge) parts.push('border-l-4 border-l-emerald-600/70')
else if (mask?.allow_discharge_export) parts.push('border-l-4 border-l-orange-500/70')
return parts.join(' ')
}
function exportModeBadge(i: PlanningIntervalDto): {
label: string
klass: string
title: string
} {
const m = (i.export_mode ?? 'NONE').toString().trim().toUpperCase()
const cap =
i.export_limit_w != null && i.export_limit_w > 0
? ` · limit ${formatPlanPowerW(i.export_limit_w)}`
: ''
if (m === 'BATTERY_SELL') {
return {
label: 'BAT→síť',
klass: 'bg-orange-500/20 text-orange-100 ring-1 ring-orange-500/40',
title: `Export z baterie do sítě (BATTERY_SELL)${cap}`,
}
}
if (m === 'PV_SURPLUS') {
return {
label: 'FVE→síť',
klass: 'bg-amber-500/15 text-amber-100 ring-1 ring-amber-500/35',
title: `Export přebytku FVE (PV_SURPLUS)${cap}`,
}
}
return {
label: '—',
klass: 'text-slate-600',
title: `Bez exportu do sítě (NONE)${cap}`,
}
}
function MaskIconsCell({ mask }: { mask: PlanMaskSlot | null }) {
if (mask == null) {
return <td className="pr-2 text-slate-600"></td>
}
return (
<td className="pr-2">
<span className="inline-flex items-center gap-1.5 font-mono text-[10px]">
<span
className={
mask.allow_charge
? 'rounded bg-emerald-950/80 px-1 py-0.5 text-emerald-300 ring-1 ring-emerald-700/50'
: 'rounded px-1 py-0.5 text-slate-600'
}
title={mask.allow_charge ? 'Solver: povoleno grid nabíjení' : 'Grid nabíjení zakázáno'}
>
</span>
<span
className={
mask.allow_discharge_export
? 'rounded bg-orange-950/80 px-1 py-0.5 text-orange-200 ring-1 ring-orange-700/50'
: 'rounded px-1 py-0.5 text-slate-600'
}
title={
mask.allow_discharge_export
? 'Solver: povolen export baterie do sítě'
: 'Export baterie do sítě zakázán'
}
>
</span>
</span>
</td>
)
}
function PlanSolverInsights({ snap }: { snap: PlanSolverSnapshot }) {
const cutoffLabel = snap.chargeAcquisitionCutoffAt
? formatLocal(snap.chargeAcquisitionCutoffAt)
: '—'
return (
<div className="rounded-lg border border-slate-700/80 bg-slate-950/50 p-3 text-sm">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-500">
Solver masky a arbitráž
</p>
<dl className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-xs text-slate-500">Nákupní cena zásoby (acquisition)</dt>
<dd className="font-mono text-slate-100">
{snap.chargeAcquisitionKwh != null
? `${snap.chargeAcquisitionKwh.toFixed(3)} Kč/kWh`
: '—'}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Řez před 1. exportem</dt>
<dd className="font-mono text-xs text-slate-200">{cutoffLabel}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Sloty nabíjení</dt>
<dd className="font-mono text-emerald-300">{snap.chargeMaskCount}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Sloty export bat.</dt>
<dd className="font-mono text-orange-300">{snap.exportMaskCount}</dd>
</div>
</dl>
<p className="mt-2 text-[11px] text-slate-500">
= allow_charge · = allow_discharge_export (z posledního běhu solveru). Zelený/oranžový okraj
řádku = stejná legenda.
</p>
</div>
)
}
function PlanSlotDetail({
i,
mask,
compare,
nowMs,
}: {
i: PlanningIntervalDto
mask: PlanMaskSlot | null
compare: PlanningIntervalDto | undefined
nowMs: number
}) {
const ex = exportModeBadge(i)
const spread =
i.effective_sell_price != null && i.effective_buy_price != null
? i.effective_sell_price - i.effective_buy_price
: null
return (
<div className="mt-3 rounded-lg border border-cyan-800/50 bg-cyan-950/20 p-3 text-sm text-slate-200">
<p className="mb-2 font-medium text-cyan-100">{formatLocal(i.interval_start)}</p>
<div className="flex flex-wrap gap-2">
<span className={`inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold ${ex.klass}`} title={ex.title}>
{ex.label}
</span>
{(() => {
const b = deyeModeBadge(i)
return (
<span className={`inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold ${b.klass}`} title={b.title}>
Deye {b.label}
</span>
)
})()}
{mask?.allow_charge ? (
<span className="rounded bg-emerald-950/60 px-2 py-0.5 text-[10px] text-emerald-300"> nabíjení OK</span>
) : null}
{mask?.allow_discharge_export ? (
<span className="rounded bg-orange-950/60 px-2 py-0.5 text-[10px] text-orange-200"> export bat. OK</span>
) : null}
</div>
<dl className="mt-3 grid gap-2 font-mono text-xs sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt className="text-slate-500">Cena kup / prod</dt>
<dd>
{i.effective_buy_price?.toFixed(3) ?? '—'} / {i.effective_sell_price?.toFixed(3) ?? '—'}
{isPredictedPriceSlot(i, nowMs) ? ' (odhad)' : ''}
</dd>
</div>
<div>
<dt className="text-slate-500">Spread prodkup</dt>
<dd className={spread != null && spread > 0 ? 'text-emerald-400' : ''}>
{spread != null ? `${spread.toFixed(3)} Kč/kWh` : '—'}
</dd>
</div>
<div>
<dt className="text-slate-500">Bat. / síť / SoC</dt>
<dd>
{formatPlanPowerW(i.battery_setpoint_w)} / {formatPlanPowerW(i.grid_setpoint_w)} /{' '}
{i.battery_soc_target_pct != null ? `${i.battery_soc_target_pct.toFixed(1)} %` : '—'}
</dd>
</div>
<div>
<dt className="text-slate-500">FVE / dům</dt>
<dd>
{formatPlanPowerW(slotFveDisplayW(i, nowMs))} / {formatPlanPowerW(i.load_baseline_w)}
</dd>
</div>
<div>
<dt className="text-slate-500">Škrcení A</dt>
<dd>{(i.pv_a_curtailed_w ?? 0) > 0 ? `${i.pv_a_curtailed_w} W` : '—'}</dd>
</div>
<div>
<dt className="text-slate-500">Výnos slotu</dt>
<dd>{i.expected_cost_czk != null ? `${i.expected_cost_czk.toFixed(4)}` : '—'}</dd>
</div>
</dl>
<p className="mt-2 text-[11px] text-slate-500">{deyeSetpointLabel(i)}</p>
{compare ? (
<p className="mt-1 text-[11px] text-amber-200/90">
Compare: bat. {compare.battery_setpoint_w ?? '—'} W · síť {compare.grid_setpoint_w ?? '—'} W · export{' '}
{compare.export_mode ?? '—'}
</p>
) : null}
</div>
)
}
type ChartMaskBand = { x1: string; x2: string }
function buildChartMaskBands(
chartRows: ChartRow[],
snap: PlanSolverSnapshot | null,
flag: 'allow_charge' | 'allow_discharge_export',
): ChartMaskBand[] {
if (snap == null || chartRows.length === 0) return []
const bands: ChartMaskBand[] = []
let start: string | null = null
let prev: string | null = null
for (const row of chartRows) {
const m = maskForInterval(snap, row.raw.interval_start)
const on = m != null && m[flag]
if (on) {
if (start == null) start = row.label
prev = row.label
} else if (start != null && prev != null) {
bands.push({ x1: start, x2: prev })
start = null
prev = null
}
}
if (start != null && prev != null) bands.push({ x1: start, x2: prev })
return bands
}
type ChartRow = {
label: string
ts: number
pv_a_w: number
battery_soc_target_pct: number | null
battery_setpoint_w: number
grid_setpoint_w: number
compare_battery_setpoint_w?: number | null
effective_buy_price: number | null
raw: PlanningIntervalDto
}
function recordNumber(value: unknown): number | null {
if (value == null) return null
const n = Number(value)
return Number.isFinite(n) ? n : null
}
function recordString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
type PlanPrepActionsProps = {
prepAction: null | 'import' | 'forecast' | 'init'
replanning: boolean
importDate: 'today' | 'tomorrow'
onImportDateChange: (v: 'today' | 'tomorrow') => void
onImport: () => void
onForecast: () => void
onInit: () => void
wrapClassName?: string
}
function PlanPrepActions({
prepAction,
replanning,
importDate,
onImportDateChange,
onImport,
onForecast,
onInit,
wrapClassName = 'flex flex-wrap gap-2',
}: PlanPrepActionsProps) {
const prepBusy = prepAction !== null
const dis = prepBusy || replanning
return (
<div className={wrapClassName}>
<button
type="button"
onClick={onImport}
disabled={dis}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-600 bg-slate-800/90 px-3 py-2 text-sm font-medium text-slate-100 transition hover:bg-slate-700 disabled:opacity-50"
>
{prepAction === 'import' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
Importovat ceny
</button>
<label className="inline-flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900/60 px-3 py-2 text-xs text-slate-300">
Den OTE
<select
value={importDate}
onChange={(e) => onImportDateChange(e.target.value === 'today' ? 'today' : 'tomorrow')}
disabled={dis}
className="rounded border border-slate-600 bg-slate-800 px-2 py-1 text-xs text-slate-100"
>
<option value="today">dnes</option>
<option value="tomorrow">zítra</option>
</select>
</label>
<button
type="button"
onClick={onForecast}
disabled={dis}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-600 bg-slate-800/90 px-3 py-2 text-sm font-medium text-slate-100 transition hover:bg-slate-700 disabled:opacity-50"
>
{prepAction === 'forecast' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CloudSun className="h-4 w-4" />
)}
Spustit forecast
</button>
<button
type="button"
onClick={onInit}
disabled={dis}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-emerald-700/60 bg-emerald-900/40 px-3 py-2 text-sm font-medium text-emerald-100 transition hover:bg-emerald-800/50 disabled:opacity-50"
>
{prepAction === 'init' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
Inicializovat plán
</button>
</div>
)
}
function PlanTooltip({
active,
payload,
nowMs,
solverSnap,
}: {
active?: boolean
payload?: Array<{ payload: ChartRow }>
nowMs: number
solverSnap?: PlanSolverSnapshot | null
}) {
if (!active || !payload?.length) return null
const p = payload[0].payload
const i = p.raw
const ext = isForecastExtensionInterval(i)
const buy = i.effective_buy_price
const sell = i.effective_sell_price
const pred = isPredictedPriceSlot(i, nowMs)
const fveStr = formatPlanPowerW(p.pv_a_w)
const fveDisplay = fveStr === '—' ? '—' : fveStr.includes('kW') ? fveStr : `${fveStr} W`
const soc = p.battery_soc_target_pct
const exportLimit = i.export_limit_w
const exportMode = i.export_mode ?? 'NONE'
const compareBattery = p.compare_battery_setpoint_w
const mask = maskForInterval(solverSnap ?? null, i.interval_start)
return (
<div className="rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-xs text-slate-200 shadow-xl">
<div className="mb-1 font-medium text-slate-100">{formatLocal(i.interval_start)}</div>
{ext && (
<div className="mb-1 text-[10px] font-sans font-normal normal-case text-amber-200/90">
Mimo uložený horizont plánu jen předpověď FVE (po korekci delty, stejně jako v přehledu).
</div>
)}
{pred && (
<div className="mb-1 text-[10px] uppercase tracking-wide text-slate-500">Cena: odhad (predikce)</div>
)}
<div className="space-y-0.5 font-mono tabular-nums">
<div>
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '}
{sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'}
</div>
<div>FVE (korig. předpověď / audit): {fveDisplay}</div>
{exportMode !== 'NONE' ? (
<div>
Export: {exportMode}
{exportLimit != null ? ` · limit ${formatPlanPowerW(exportLimit)}` : ''}
</div>
) : null}
{mask ? (
<div className="text-slate-400">
Masky: {mask.allow_charge ? '⚡ nabíjení' : '—'} ·{' '}
{mask.allow_discharge_export ? '↓ export bat.' : '—'}
</div>
) : null}
<div>SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}</div>
<div>Dům: {i.load_baseline_w ?? '—'} W</div>
<div>Baterie: {i.battery_setpoint_w ?? '—'} W</div>
{compareBattery != null ? <div>Compare baterie: {compareBattery} W</div> : null}
<div>Síť (čistý EM): {i.grid_setpoint_w ?? '—'} W</div>
<div>: {i.heat_pump_enabled ? 'zapnuto' : 'vypnuto'}</div>
<div>
EV1: {i.ev1_setpoint_w ?? '—'} W · EV2: {i.ev2_setpoint_w ?? '—'} W
</div>
<div className="mt-1 border-t border-slate-700 pt-1 text-[10px] font-sans font-normal normal-case text-slate-500">
Záporná síť = export přes elektroměr (často přebytek FVE). Záporná baterie kryje dům nemusí jít o prodej
energie z akumulátoru do sítě.
</div>
</div>
</div>
)
}
function CenaCell({ i, nowMs }: { i: PlanningIntervalDto; nowMs: number }) {
const pred = isPredictedPriceSlot(i, nowMs)
return (
<td className={`max-w-[200px] pr-2 font-mono text-xs tabular-nums ${pred ? 'text-slate-500' : 'text-slate-300'}`}>
<span className="inline-flex flex-wrap items-center gap-x-1.5 align-middle">
{pred && (
<span
className="shrink-0 rounded bg-slate-700/70 px-1 py-0.5 text-[10px] font-sans font-semibold uppercase tracking-wide text-slate-400"
title="Predikovaná cena (mimo přesné OTE)"
>
odhad
</span>
)}
<span>
{i.effective_buy_price != null ? i.effective_buy_price.toFixed(3) : '—'}
<span className="text-slate-600"> / </span>
{i.effective_sell_price != null ? i.effective_sell_price.toFixed(3) : '—'}
</span>
</span>
</td>
)
}
function HorizonToggle({
value,
onChange,
disabled,
}: {
value: HorizonHours
onChange: (h: HorizonHours) => void
disabled?: boolean
}) {
const opts: HorizonHours[] = [24, 48, 96]
return (
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-xs text-slate-500">Horizont:</span>
<div className="flex gap-1">
{opts.map((h) => (
<button
key={h}
type="button"
disabled={disabled}
onClick={() => onChange(h)}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition disabled:opacity-50 ${horizonToggleClass(value === h)}`}
>
{h}h
</button>
))}
</div>
</div>
)
}
export default function Planning() {
const { site, ready: siteReady } = useSiteStatus()
const siteId = site?.site_id ?? null
const [data, setData] = useState<CurrentPlanResponse | null>(null)
const [compareData, setCompareData] = useState<PlanningCompareResponse | null>(null)
const [loading, setLoading] = useState(true)
const [compareLoading, setCompareLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [compareError, setCompareError] = useState<string | null>(null)
const [replanning, setReplanning] = useState(false)
const [prepAction, setPrepAction] = useState<null | 'import' | 'forecast' | 'init'>(null)
const [importDate, setImportDate] = useState<'today' | 'tomorrow'>('tomorrow')
const [selectedStart, setSelectedStart] = useState<string | null>(null)
const [tableHorizonH, setTableHorizonH] = useState<HorizonHours>(48)
const [chartHorizonH, setChartHorizonH] = useState<HorizonHours>(48)
const load = useCallback(async () => {
if (siteId == null) return
setLoading(true)
setCompareLoading(true)
setError(null)
setCompareError(null)
try {
const [planRes, compareRes] = await Promise.allSettled([
getCurrentPlan(siteId),
getPlanCompare(siteId),
])
if (planRes.status === 'fulfilled') {
setData(planRes.value)
} else if (axios.isAxiosError(planRes.reason) && planRes.reason.response?.status === 404) {
setData({ run: null, intervals: [], summary: null })
setError(null)
} else {
throw planRes.reason
}
if (compareRes.status === 'fulfilled') {
setCompareData(compareRes.value)
} else if (axios.isAxiosError(compareRes.reason) && compareRes.reason.response?.status === 404) {
setCompareData(null)
} else {
setCompareError(axiosDetail(compareRes.reason))
setCompareData(null)
}
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 404) {
setData({ run: null, intervals: [], summary: null })
setError(null)
} else {
setError(e instanceof Error ? e.message : 'Chyba načtení plánu')
setData(null)
}
} finally {
setLoading(false)
setCompareLoading(false)
}
}, [siteId])
useEffect(() => {
if (siteId != null) void load()
}, [siteId, load])
const nowMs = Date.now()
const slotFloorMs = floorSlotUtcMs(nowMs)
// PV forecast je kanonicky v /plan/current (DB read-model), takže už netaháme separátní pv-slots-corrected.
const futureSlots = useMemo(() => {
if (!data?.intervals?.length) return []
return data.intervals
.filter((i) => slotStartUtcMs(i.interval_start) >= slotFloorMs)
.sort((a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start))
.slice(0, MAX_FUTURE_SLOTS)
}, [data?.intervals, slotFloorMs])
const visibleSlots = useMemo(() => {
const endMs = nowMs + tableHorizonH * 60 * 60 * 1000
return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs)
}, [futureSlots, nowMs, tableHorizonH])
/** Graf: sloty z /plan/current (obsahují i forecast-only řádky za horizontem LP). */
const chartMergedSlots = useMemo(() => {
return [...futureSlots].sort((a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start))
}, [futureSlots])
const chartIntervals = useMemo(() => {
const endMs = nowMs + chartHorizonH * 60 * 60 * 1000
return chartMergedSlots.filter((s) => {
const t = slotStartUtcMs(s.interval_start)
return t >= slotFloorMs && t <= endMs
})
}, [chartMergedSlots, nowMs, chartHorizonH, slotFloorMs])
const planTableRows = useMemo(
() => buildPlanTableRows(visibleSlots, nowMs),
[visibleSlots, nowMs],
)
const showGenCut = useMemo(() => hasGenCutoff(visibleSlots), [visibleSlots])
const xTicks = useMemo(() => {
if (!chartIntervals.length) return undefined
const stepH = chartHorizonH <= 24 ? 2 : chartHorizonH <= 48 ? 4 : 6
const stepMs = stepH * 60 * 60 * 1000
const first = slotStartUtcMs(chartIntervals[0].interval_start)
const last = slotStartUtcMs(chartIntervals[chartIntervals.length - 1].interval_start)
const ticks: string[] = []
let t = Math.ceil(first / stepMs) * stepMs
while (t <= last) {
const hit = chartIntervals.find((i) => Math.abs(slotStartUtcMs(i.interval_start) - t) < 30 * 60 * 1000)
if (hit) ticks.push(hit.interval_start)
t += stepMs
}
return ticks.length ? ticks.map((iso) => formatLocalTime(iso)) : undefined
}, [chartIntervals, chartHorizonH])
const chartRows: ChartRow[] = useMemo(() => {
const compareIntervals = compareData?.comparison?.intervals ?? []
const compareMap = new Map(compareIntervals.map((i) => [i.interval_start, i]))
return chartIntervals.map((i) => ({
label: formatLocalTime(i.interval_start),
ts: slotStartUtcMs(i.interval_start),
pv_a_w: pvChartFveW(i, nowMs),
battery_soc_target_pct: i.battery_soc_target_pct,
battery_setpoint_w: i.battery_setpoint_w ?? 0,
grid_setpoint_w: i.grid_setpoint_w ?? 0,
compare_battery_setpoint_w: compareMap.get(i.interval_start)?.battery_setpoint_w ?? null,
effective_buy_price: i.effective_buy_price,
raw: i,
}))
}, [chartIntervals, nowMs, compareData?.comparison?.intervals])
const run = data?.run
const summary = data?.summary
const solverSnap = useMemo(
() =>
parsePlanSolverSnapshot(
run != null ? (run as unknown as Record<string, unknown>) : undefined,
),
[run],
)
const chartChargeBands = useMemo(
() => buildChartMaskBands(chartRows, solverSnap, 'allow_charge'),
[chartRows, solverSnap],
)
const chartExportBands = useMemo(
() => buildChartMaskBands(chartRows, solverSnap, 'allow_discharge_export'),
[chartRows, solverSnap],
)
const compareIntervalByStart = useMemo(() => {
const list = compareData?.comparison?.intervals ?? []
return new Map(list.map((i) => [i.interval_start, i]))
}, [compareData?.comparison?.intervals])
const selectedSlot = useMemo(
() => visibleSlots.find((s) => s.interval_start === selectedStart) ?? null,
[visibleSlots, selectedStart],
)
const tableColCount = 13 + (solverSnap != null ? 1 : 0) + (showGenCut ? 1 : 0)
async function onReplan() {
if (siteId == null) return
setReplanning(true)
setError(null)
try {
await postRunPlan(siteId, 'rolling')
await load()
} catch (e) {
setError(axiosDetail(e) || 'Přepočet selhal')
} finally {
setReplanning(false)
}
}
async function runRollingReload() {
if (siteId == null) return
await postRunPlan(siteId, 'rolling')
await load()
}
async function handleImportPrices() {
if (siteId == null) return
setPrepAction('import')
setError(null)
try {
const selectedDate = new Date()
if (importDate === 'tomorrow') {
selectedDate.setDate(selectedDate.getDate() + 1)
}
const r = await postImportSitePrices(siteId, pragueYmd(selectedDate))
toast.success(
`Ceny: ${r.slots_imported} slotů (${r.date}), první ${r.first_price_czk_kwh.toFixed(3)} Kč/kWh`,
)
await runRollingReload()
} catch (e) {
toast.error('Import cen selhal', { description: axiosDetail(e) })
} finally {
setPrepAction(null)
}
}
async function handleRunForecast() {
if (siteId == null) return
setPrepAction('forecast')
setError(null)
try {
const r = await postRunForecast(siteId)
toast.success(`Forecast: ${r.intervals_saved} intervalů, ${r.pv_arrays} FVE polí`)
await runRollingReload()
} catch (e) {
toast.error('Forecast selhal', { description: axiosDetail(e) })
} finally {
setPrepAction(null)
}
}
async function handleInitializePlan() {
if (siteId == null) return
setPrepAction('init')
setError(null)
try {
const selectedDate = new Date()
if (importDate === 'tomorrow') {
selectedDate.setDate(selectedDate.getDate() + 1)
}
const imp = await postImportSitePrices(siteId, pragueYmd(selectedDate))
toast.success(
`Ceny: ${imp.slots_imported} slotů (${imp.date}), první ${imp.first_price_czk_kwh.toFixed(3)} Kč/kWh`,
)
const fc = await postRunForecast(siteId)
toast.success(`Forecast: ${fc.intervals_saved} intervalů, ${fc.pv_arrays} FVE polí`)
await runRollingReload()
toast.success('Plán přepočítán (rolling).')
} catch (e) {
toast.error('Inicializace selhala', { description: axiosDetail(e) })
} finally {
setPrepAction(null)
}
}
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 showPrepActions = !loading
const prepBusy = prepAction !== null
const correctionPct =
run?.forecast_correction_factor != null ? run.forecast_correction_factor * 100 : null
const correctionUp = (run?.forecast_correction_factor ?? 1) >= 1
const compareActiveSummary = compareData?.active?.summary ?? null
const comparePeerSummary = compareData?.comparison?.summary ?? null
const compareDiff = compareData?.diff ?? null
const compareSlotDiffs = compareData?.slot_diffs ?? []
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 ({site?.site_name ?? 'lokalita'}) tabulka jen z uloženého horizontu; sloupec a křivka FVE
používají <span className="text-slate-300">korigovanou předpověď</span> (delta profil, stejně jako přehled a
vstupy solveru). Graf za horizont plánu doplňuje stejnou řadu do 96 h.
</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">
Status aktivního plánu
</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 ? (
<div className="space-y-3">
<p className="text-slate-400">Žádný aktivní plán.</p>
{showPrepActions && (
<PlanPrepActions
prepAction={prepAction}
replanning={replanning}
importDate={importDate}
onImportDateChange={setImportDate}
onImport={() => void handleImportPrices()}
onForecast={() => void handleRunForecast()}
onInit={() => void handleInitializePlan()}
/>
)}
</div>
) : (
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-200">
<span className="text-slate-500">Vytvořeno:</span>
<span className="font-mono">{formatLocal(run.created_at)}</span>
<span className="text-slate-600">|</span>
<span className="text-slate-500">Typ:</span>
<span
className={`rounded-md px-2 py-0.5 text-xs font-semibold uppercase tracking-wide ${runTypeBadgeClass(run.run_type)}`}
>
{run.run_type}
</span>
</div>
<div className="text-sm">
<span className="text-slate-500">Horizont: </span>
<span className="font-mono text-slate-200">
{formatLocal(run.horizon_start)} {formatLocal(run.horizon_end)}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-slate-500">Korekce FVE forecastu:</span>
<span className="inline-flex items-center gap-1 font-mono text-slate-200">
{correctionPct != null ? (
<>
{correctionUp ? (
<ArrowUpRight className="h-4 w-4 text-emerald-400" aria-hidden />
) : (
<ArrowDownRight className="h-4 w-4 text-amber-400" aria-hidden />
)}
{Number.isInteger(correctionPct)
? correctionPct
: correctionPct.toLocaleString('cs-CZ', { maximumFractionDigits: 1 })}{' '}
%
</>
) : (
'—'
)}
</span>
</div>
<div className="text-sm">
<span className="text-slate-500">Čas výpočtu solveru: </span>
<span className="font-mono text-slate-200">
{run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
</span>
</div>
{summary?.pv_scarcity_factor != null && (
<div className="text-sm">
<span className="text-slate-500">PV scarcity factor: </span>
<span className="font-mono text-slate-200">
{summary.pv_scarcity_factor.toFixed(3)}
</span>
<span className="ml-2 text-xs text-slate-500">
(nižší = méně očekávaného slunce, ekonomika víc toleruje precharge ze sítě)
</span>
</div>
)}
{solverSnap != null && <PlanSolverInsights snap={solverSnap} />}
{summary && (
<div className="border-t border-slate-800 pt-3 text-sm">
<p className="mb-2 text-slate-500">Summary</p>
<dl className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt className="text-xs text-slate-500">
{summary.total_expected_cost_czk >= 0 ? 'Celkové náklady' : 'Celkový příjem'}
</dt>
<dd className="font-mono text-slate-100">
{summary.total_expected_cost_czk >= 0
? `${summary.total_expected_cost_czk.toFixed(2)}`
: `${Math.abs(summary.total_expected_cost_czk).toFixed(2)}`}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">kWh curtailmentu (A)</dt>
<dd className="font-mono text-slate-100">
{summary.total_pv_curtailed_kwh.toLocaleString('cs-CZ', {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
})}{' '}
kWh
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Sloty nabíjení / vybíjení / export</dt>
<dd className="font-mono text-slate-100">
{summary.charge_slots} / {summary.discharge_slots} / {summary.export_slots}
</dd>
</div>
</dl>
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-stretch gap-2 sm:items-end">
{showPrepActions && (
<PlanPrepActions
prepAction={prepAction}
replanning={replanning}
importDate={importDate}
onImportDateChange={setImportDate}
onImport={() => void handleImportPrices()}
onForecast={() => void handleRunForecast()}
onInit={() => void handleInitializePlan()}
wrapClassName="flex flex-wrap justify-end gap-2"
/>
)}
<button
type="button"
onClick={() => void onReplan()}
disabled={replanning || prepBusy}
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
</button>
</div>
</div>
)}
</section>
{/* Sekce 2 */}
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
<h2 className="mb-2 text-sm font-medium uppercase tracking-wide text-slate-400">Porovnání v1 / v2</h2>
{compareLoading ? (
<div className="flex items-center gap-2 text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" /> Načítám compare
</div>
) : compareError && !compareData ? (
<div className="rounded-md border border-amber-900/60 bg-amber-950/30 px-3 py-2 text-sm text-amber-200">
{compareError}
</div>
) : compareData ? (
<div className="space-y-4">
{compareError && (
<div className="rounded-md border border-amber-900/60 bg-amber-950/30 px-3 py-2 text-sm text-amber-200">
{compareError}
</div>
)}
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-lg border border-slate-800 bg-slate-950/40 p-3">
<p className="text-xs uppercase tracking-wide text-slate-500">Aktivní verze</p>
<p className="mt-1 font-mono text-lg text-white">
{recordString(compareData.active.run?.run_type) ?? '—'}
</p>
<p className="text-xs text-slate-500">
Solver: {recordNumber(compareData.active.run?.solver_duration_ms) != null
? `${recordNumber(compareData.active.run?.solver_duration_ms)} ms`
: '—'}
</p>
<p className="mt-2 text-xs text-slate-500">
Náklady: {recordNumber(compareActiveSummary?.total_expected_cost_czk) != null
? `${recordNumber(compareActiveSummary?.total_expected_cost_czk)?.toFixed(2)}`
: '—'}
</p>
</div>
<div className="rounded-lg border border-slate-800 bg-slate-950/40 p-3">
<p className="text-xs uppercase tracking-wide text-slate-500">Compare verze</p>
<p className="mt-1 font-mono text-lg text-white">
{recordString(compareData.comparison.run?.run_type) ?? '—'}
</p>
<p className="text-xs text-slate-500">
Solver: {recordNumber(compareData.comparison.run?.solver_duration_ms) != null
? `${recordNumber(compareData.comparison.run?.solver_duration_ms)} ms`
: '—'}
</p>
<p className="mt-2 text-xs text-slate-500">
Náklady: {recordNumber(comparePeerSummary?.total_expected_cost_czk) != null
? `${recordNumber(comparePeerSummary?.total_expected_cost_czk)?.toFixed(2)}`
: '—'}
</p>
</div>
<div className="rounded-lg border border-cyan-900/50 bg-cyan-950/20 p-3">
<p className="text-xs uppercase tracking-wide text-cyan-200/80">Rozdíl</p>
<p className="mt-1 font-mono text-lg text-cyan-100">
{recordNumber(compareDiff?.total_expected_cost_czk) != null
? `${recordNumber(compareDiff?.total_expected_cost_czk)?.toFixed(2)}`
: '—'}
</p>
<p className="text-xs text-cyan-100/70">
Změněných slotů:{' '}
{recordNumber(compareDiff?.changed_slots) != null ? recordNumber(compareDiff?.changed_slots) : '—'}
</p>
<p className="text-xs text-cyan-100/70">
Aktivní / compare export sloty:{' '}
{recordNumber(compareDiff?.active_export_slots) != null
? `${recordNumber(compareDiff?.active_export_slots)} / ${recordNumber(compareDiff?.comparison_export_slots)}`
: '—'}
</p>
</div>
</div>
{compareSlotDiffs.length > 0 ? (
<div className="max-h-[320px] overflow-auto rounded-lg border border-slate-800/80">
<table className="w-full border-collapse text-left text-xs">
<thead className="sticky top-0 bg-slate-900 text-slate-500 shadow-[0_1px_0_0_rgb(30_41_59)]">
<tr>
<th className="whitespace-nowrap px-2 py-2 font-medium">Slot</th>
<th className="whitespace-nowrap px-2 py-2 font-medium">Aktivní bat. W</th>
<th className="whitespace-nowrap px-2 py-2 font-medium">Compare bat. W</th>
<th className="whitespace-nowrap px-2 py-2 font-medium">Aktivní grid W</th>
<th className="whitespace-nowrap px-2 py-2 font-medium">Compare grid W</th>
<th className="whitespace-nowrap px-2 py-2 font-medium">Aktivní export</th>
<th className="whitespace-nowrap px-2 py-2 font-medium">Compare export</th>
</tr>
</thead>
<tbody>
{compareSlotDiffs.slice(0, 48).map((row) => (
<tr key={row.interval_start} className="border-b border-slate-800/80">
<td className="whitespace-nowrap px-2 py-1.5 font-mono text-slate-300">
{formatLocalTime(row.interval_start)}
</td>
<td className="px-2 py-1.5 font-mono text-slate-200">
{recordNumber(row.active.battery_setpoint_w) != null
? recordNumber(row.active.battery_setpoint_w)
: '—'}
</td>
<td className="px-2 py-1.5 font-mono text-slate-200">
{recordNumber(row.comparison.battery_setpoint_w) != null
? recordNumber(row.comparison.battery_setpoint_w)
: '—'}
</td>
<td className="px-2 py-1.5 font-mono text-slate-200">
{recordNumber(row.active.grid_setpoint_w) != null
? recordNumber(row.active.grid_setpoint_w)
: '—'}
</td>
<td className="px-2 py-1.5 font-mono text-slate-200">
{recordNumber(row.comparison.grid_setpoint_w) != null
? recordNumber(row.comparison.grid_setpoint_w)
: '—'}
</td>
<td className="px-2 py-1.5 font-mono text-slate-200">
{recordString(row.active.export_mode) ?? '—'}
</td>
<td className="px-2 py-1.5 font-mono text-slate-200">
{recordString(row.comparison.export_mode) ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-slate-500">Compare běh je uložen, ale nemá slotové rozdíly k zobrazení.</p>
)}
</div>
) : (
<p className="text-sm text-slate-500">
Compare plán zatím není k dispozici. Spusťte plánování s aktivním režimem v1/v2 compare.
</p>
)}
</section>
{/* Sekce 3 */}
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Graf plánu</h2>
{solverSnap != null && (
<p className="mb-2 text-[11px] text-slate-500">
Pásy: <span className="text-emerald-400/90">zelená</span> = okno grid nabíjení () ·{' '}
<span className="text-orange-400/90">oranžová</span> = okno exportu baterie ().
</p>
)}
<HorizonToggle
value={chartHorizonH}
onChange={setChartHorizonH}
disabled={chartMergedSlots.length === 0}
/>
{!chartRows.length ? (
<p className="text-sm text-slate-500">
Žádná data pro graf (plán + korig. FVE do {chartHorizonH} h od aktuálního slotu). Spusťte forecast, pokud
chybí křivka výroby.
</p>
) : (
<div className="h-[350px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartRows} margin={{ top: 8, right: 72, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="label"
ticks={xTicks}
tick={{ fill: '#94a3b8', fontSize: 9 }}
interval={0}
angle={-35}
textAnchor="end"
height={52}
/>
<YAxis
yAxisId="power"
tick={{ fill: '#94a3b8', fontSize: 10 }}
label={{ value: 'W', angle: -90, position: 'insideLeft', fill: '#64748b', fontSize: 11 }}
/>
<YAxis
yAxisId="soc"
orientation="right"
domain={[0, 100]}
tick={{ fill: '#22c55e', fontSize: 10 }}
label={{ value: 'SoC %', angle: 90, position: 'insideRight', fill: '#22c55e', fontSize: 11 }}
/>
<YAxis
yAxisId="price"
orientation="right"
width={52}
tick={{ fill: '#94a3b8', fontSize: 9 }}
axisLine={{ stroke: '#64748b' }}
tickLine={{ stroke: '#64748b' }}
label={{
value: 'Kč/kWh',
angle: 90,
position: 'insideRight',
fill: '#94a3b8',
fontSize: 10,
offset: 10,
}}
/>
<Tooltip content={<PlanTooltip nowMs={nowMs} solverSnap={solverSnap} />} />
{chartChargeBands.map((b) => (
<ReferenceArea
key={`chg-${b.x1}-${b.x2}`}
yAxisId="power"
x1={b.x1}
x2={b.x2}
strokeOpacity={0}
fill="#10b981"
fillOpacity={0.08}
/>
))}
{chartExportBands.map((b) => (
<ReferenceArea
key={`exp-${b.x1}-${b.x2}`}
x1={b.x1}
x2={b.x2}
yAxisId="power"
strokeOpacity={0}
fill="#f97316"
fillOpacity={0.1}
/>
))}
<Area
yAxisId="power"
type="monotone"
dataKey="pv_a_w"
name="FVE / korig. předpověď"
stroke="#ca8a04"
fill="#eab308"
fillOpacity={0.35}
/>
<Bar yAxisId="power" dataKey="battery_setpoint_w" name="Baterie W" barSize={10} isAnimationActive={false}>
{chartRows.map((e) => (
<Cell
key={e.ts}
fill={e.battery_setpoint_w >= 0 ? '#22c55e' : '#f97316'}
fillOpacity={0.85}
/>
))}
</Bar>
<Line
yAxisId="power"
type="monotone"
dataKey="grid_setpoint_w"
name="Síť W (+odb / exp)"
stroke="#f87171"
dot={false}
strokeWidth={2}
connectNulls
/>
<Line
yAxisId="power"
type="monotone"
dataKey="compare_battery_setpoint_w"
name="Compare baterie W"
stroke="#fb923c"
dot={false}
strokeWidth={2}
strokeDasharray="6 4"
connectNulls
/>
<Line
yAxisId="soc"
type="monotone"
dataKey="battery_soc_target_pct"
name="SoC %"
stroke="#4ade80"
dot={false}
strokeWidth={2}
connectNulls
/>
<Line
yAxisId="price"
type="monotone"
dataKey="effective_buy_price"
name="Cena nákup"
stroke="#94a3b8"
strokeDasharray="5 4"
dot={false}
strokeWidth={2}
connectNulls
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</section>
{/* Sekce 4 */}
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Tabulka slotů</h2>
<HorizonToggle value={tableHorizonH} onChange={setTableHorizonH} disabled={futureSlots.length === 0} />
<div className="max-h-[min(70vh,720px)] overflow-y-auto overflow-x-auto rounded-lg border border-slate-800/80">
<table className="w-full border-collapse text-left text-xs">
<thead className="sticky top-0 z-10 bg-slate-900 shadow-[0_1px_0_0_rgb(30_41_59)]">
<tr className="text-slate-500">
<th className="whitespace-nowrap py-2 pl-2 pr-2 font-medium">Čas</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">
<span className="block">Cena</span>
<span className="block text-[10px] font-normal normal-case text-slate-600">/kWh · kup / prod</span>
</th>
<th
className="whitespace-nowrap py-2 pr-2 font-medium"
title="Setpoint výkonu baterie/invertoru: kladně nabíjení, záporně vybíjení (do spotřeby domu nebo dle regulace do přebytku)."
>
<span className="block">Bat. W</span>
<span className="block text-[10px] font-normal normal-case text-slate-600">
+ nabíj · vybíj
</span>
</th>
<th
className="whitespace-nowrap py-2 pr-2 font-medium"
title="Záměr exportu do sítě z LP (NONE / PV_SURPLUS / BATTERY_SELL)"
>
Export
</th>
{solverSnap != null ? (
<th
className="whitespace-nowrap py-2 pr-2 font-medium"
title="Masky z posledního solveru: ⚡ allow_charge, ↓ allow_discharge_export"
>
Masky
</th>
) : null}
<th className="whitespace-nowrap py-2 pr-2 font-medium">Deye setpoint</th>
{showGenCut ? (
<th className="whitespace-nowrap py-2 pr-2 font-medium" title="GEN port cut-off (BA81 / mikroinvertory)">
GEN
</th>
) : null}
<th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium" title="Budoucí sloty: korigovaná předpověď z delta profilu; proběhlé sloty z auditu.">
<span className="block">FVE W</span>
<span className="block text-[10px] font-normal normal-case text-slate-600">delta · audit</span>
</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Dům W</th>
<th
className="whitespace-nowrap py-2 pr-2 font-medium"
title="Čistý výkon na hlavním elektroměru (point of supply): kladně odběr ze sítě, záporně export. Záporná hodnota často znamená export FVE při spotřebě domu; u záporného Bat. W to nemusí být „prodej z baterie“, ale výsledek bilance FVE + dům + baterie."
>
<span className="block">Síť W</span>
<span className="block text-[10px] font-normal normal-case text-slate-600">
čistý EM · +odb / exp
</span>
</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">EV1 W</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">EV2 W</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium"></th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Výnos </th>
</tr>
</thead>
<tbody>
{planTableRows.map((row) => {
if (row.kind === 'summary') {
return (
<tr
key={`sum-${row.dayKey}`}
className="border-b border-slate-700/90 bg-slate-800/70 text-slate-200"
>
<td colSpan={tableColCount} className="px-2 py-2 text-xs font-medium">
<span className="text-slate-100">{row.dateLabel}</span>
<span className="mx-2 text-slate-600">·</span>
<span className="text-slate-400">FVE celkem</span>{' '}
<span className="font-mono tabular-nums text-slate-200">
{row.fveKwh.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh
</span>
<span className="mx-2 text-slate-600">·</span>
<span className="text-slate-400">Export celkem</span>{' '}
<span className="font-mono tabular-nums text-slate-200">
{row.exportKwh.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh
</span>
<span className="mx-2 text-slate-600">·</span>
<span className="text-slate-400">Prům. cena nákup</span>{' '}
<span className="font-mono tabular-nums text-slate-200">
{row.avgBuy != null ? `${row.avgBuy.toFixed(3)} Kč/kWh` : '—'}
</span>
</td>
</tr>
)
}
const i = row.i
const sel = selectedStart === i.interval_start
const slotMask = maskForInterval(solverSnap, i.interval_start)
const exBadge = exportModeBadge(i)
return (
<tr
key={i.interval_start}
role="button"
tabIndex={0}
onClick={() => setSelectedStart((prev) => (prev === i.interval_start ? null : i.interval_start))}
onKeyDown={(ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault()
setSelectedStart((prev) => (prev === i.interval_start ? null : i.interval_start))
}
}}
className={`cursor-pointer border-b border-slate-800/80 transition hover:bg-slate-800/40 ${tableRowClass(i, sel, slotMask)}`}
>
<td className="whitespace-nowrap py-1.5 pl-2 pr-2 font-mono text-slate-300">
{formatLocalTime(i.interval_start)}
</td>
<CenaCell i={i} nowMs={nowMs} />
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.battery_setpoint_w ?? '—'}</td>
<td className="pr-2">
{exBadge.label !== '—' ? (
<span
className={`inline-flex rounded-md px-1.5 py-0.5 text-[10px] font-semibold ${exBadge.klass}`}
title={exBadge.title}
>
{exBadge.label}
</span>
) : (
<span className="text-slate-600" title={exBadge.title}>
</span>
)}
</td>
{solverSnap != null ? <MaskIconsCell mask={slotMask} /> : null}
<td className="max-w-[200px] whitespace-normal break-words pr-2 text-slate-300">
<div className="flex flex-wrap items-center gap-2">
{(() => {
const b = deyeModeBadge(i)
return (
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-semibold ${b.klass}`}
title={b.title}
>
{b.label}
</span>
)
})()}
<span className="text-slate-400" title={deyeSetpointLabel(i)}>
{deyeSetpointLabel(i)}
</span>
</div>
</td>
{showGenCut ? (
<td className="pr-2">
{(() => {
const g = genCutoffBadge(i)
if (!g.show) return <span className="text-slate-600"></span>
return (
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-semibold ${g.klass}`}
title={g.title}
>
{g.label}
</span>
)
})()}
</td>
) : null}
<td className="pr-2 font-mono tabular-nums text-slate-300">
{i.battery_soc_target_pct != null
? `${i.battery_soc_target_pct.toFixed(1)}`
: '—'}
</td>
<FveWCell i={i} nowMs={nowMs} />
<td className="pr-2 font-mono tabular-nums text-slate-300">
{formatPlanPowerW(i.load_baseline_w)}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.grid_setpoint_w ?? '—'}</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.ev1_setpoint_w ?? '—'}</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.ev2_setpoint_w ?? '—'}</td>
<td className="pr-2 text-slate-300">{i.heat_pump_enabled ? 'on' : 'off'}</td>
<VynosKcCell v={i.expected_cost_czk} />
</tr>
)
})}
</tbody>
</table>
</div>
{!visibleSlots.length && !loading && (
<p className="mt-2 text-sm text-slate-500">
Žádné budoucí sloty v horizontu {tableHorizonH} h (aktivní plán může být prázdný nebo starý).
</p>
)}
{selectedSlot != null && (
<PlanSlotDetail
i={selectedSlot}
mask={maskForInterval(solverSnap, selectedSlot.interval_start)}
compare={compareIntervalByStart.get(selectedSlot.interval_start)}
nowMs={nowMs}
/>
)}
{!solverSnap && run != null && (
<p className="mt-2 text-[11px] text-slate-500">
Masky solveru nejsou v tomto běhu spusťte nový rolling/denní plán po nasazení arbitráže.
</p>
)}
</section>
</div>
)
}