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 { return slots.reduce( (acc, slot) => { const day = pragueDayKey(slot.interval_start) if (!acc[day]) acc[day] = [] acc[day].push(slot) return acc }, {} as Record, ) } 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 ( {formatPlanPowerW(w)} ) } function VynosKcCell({ v }: { v: number | null | undefined }) { if (v == null || Number.isNaN(Number(v))) { return — } const n = Number(v) const color = n < 0 ? 'text-emerald-400' : n > 0 ? 'text-red-400' : 'text-slate-500' return ( {n.toFixed(4)} ) } 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 — } return ( ) } function PlanSolverInsights({ snap }: { snap: PlanSolverSnapshot }) { const cutoffLabel = snap.chargeAcquisitionCutoffAt ? formatLocal(snap.chargeAcquisitionCutoffAt) : '—' return (

Solver — masky a arbitráž

Nákupní cena zásoby (acquisition)
{snap.chargeAcquisitionKwh != null ? `${snap.chargeAcquisitionKwh.toFixed(3)} Kč/kWh` : '—'}
Řez před 1. exportem
{cutoffLabel}
Sloty ⚡ nabíjení
{snap.chargeMaskCount}
Sloty ↓ export bat.
{snap.exportMaskCount}

⚡ = allow_charge · ↓ = allow_discharge_export (z posledního běhu solveru). Zelený/oranžový okraj řádku = stejná legenda.

) } 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 (

{formatLocal(i.interval_start)}

{ex.label} {(() => { const b = deyeModeBadge(i) return ( Deye {b.label} ) })()} {mask?.allow_charge ? ( ⚡ nabíjení OK ) : null} {mask?.allow_discharge_export ? ( ↓ export bat. OK ) : null}
Cena kup / prod
{i.effective_buy_price?.toFixed(3) ?? '—'} / {i.effective_sell_price?.toFixed(3) ?? '—'} {isPredictedPriceSlot(i, nowMs) ? ' (odhad)' : ''}
Spread prod−kup
0 ? 'text-emerald-400' : ''}> {spread != null ? `${spread.toFixed(3)} Kč/kWh` : '—'}
Bat. / síť / SoC
{formatPlanPowerW(i.battery_setpoint_w)} / {formatPlanPowerW(i.grid_setpoint_w)} /{' '} {i.battery_soc_target_pct != null ? `${i.battery_soc_target_pct.toFixed(1)} %` : '—'}
FVE / dům
{formatPlanPowerW(slotFveDisplayW(i, nowMs))} / {formatPlanPowerW(i.load_baseline_w)}
Škrcení A
{(i.pv_a_curtailed_w ?? 0) > 0 ? `${i.pv_a_curtailed_w} W` : '—'}
Výnos slotu
{i.expected_cost_czk != null ? `${i.expected_cost_czk.toFixed(4)} Kč` : '—'}

{deyeSetpointLabel(i)}

{compare ? (

Compare: bat. {compare.battery_setpoint_w ?? '—'} W · síť {compare.grid_setpoint_w ?? '—'} W · export{' '} {compare.export_mode ?? '—'}

) : null}
) } 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 (
) } 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 (
{formatLocal(i.interval_start)}
{ext && (
Mimo uložený horizont plánu — jen předpověď FVE (po korekci delty, stejně jako v přehledu).
)} {pred && (
Cena: odhad (predikce)
)}
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '} {sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'}
FVE (korig. předpověď / audit): {fveDisplay}
{exportMode !== 'NONE' ? (
Export: {exportMode} {exportLimit != null ? ` · limit ${formatPlanPowerW(exportLimit)}` : ''}
) : null} {mask ? (
Masky: {mask.allow_charge ? '⚡ nabíjení' : '—'} ·{' '} {mask.allow_discharge_export ? '↓ export bat.' : '—'}
) : null}
SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}
Dům: {i.load_baseline_w ?? '—'} W
Baterie: {i.battery_setpoint_w ?? '—'} W
{compareBattery != null ?
Compare baterie: {compareBattery} W
: null}
Síť (čistý EM): {i.grid_setpoint_w ?? '—'} W
TČ: {i.heat_pump_enabled ? 'zapnuto' : 'vypnuto'}
EV1: {i.ev1_setpoint_w ?? '—'} W · EV2: {i.ev2_setpoint_w ?? '—'} W
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ě.
) } function CenaCell({ i, nowMs }: { i: PlanningIntervalDto; nowMs: number }) { const pred = isPredictedPriceSlot(i, nowMs) return ( {pred && ( odhad )} {i.effective_buy_price != null ? i.effective_buy_price.toFixed(3) : '—'} / {i.effective_sell_price != null ? i.effective_sell_price.toFixed(3) : '—'} ) } function HorizonToggle({ value, onChange, disabled, }: { value: HorizonHours onChange: (h: HorizonHours) => void disabled?: boolean }) { const opts: HorizonHours[] = [24, 48, 96] return (
Horizont:
{opts.map((h) => ( ))}
) } export default function Planning() { const { site, ready: siteReady } = useSiteStatus() const siteId = site?.site_id ?? null const [data, setData] = useState(null) const [compareData, setCompareData] = useState(null) const [loading, setLoading] = useState(true) const [compareLoading, setCompareLoading] = useState(true) const [error, setError] = useState(null) const [compareError, setCompareError] = useState(null) const [replanning, setReplanning] = useState(false) const [prepAction, setPrepAction] = useState(null) const [importDate, setImportDate] = useState<'today' | 'tomorrow'>('tomorrow') const [selectedStart, setSelectedStart] = useState(null) const [tableHorizonH, setTableHorizonH] = useState(48) const [chartHorizonH, setChartHorizonH] = useState(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) : 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 (
Načítám lokalitu…
) } if (siteId == null) { return (
V PostgREST nebyla nalezena lokalita (vw_site_status). Nelze načíst plán.
) } 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 (

Plánování

Aktuální LP plán ({site?.site_name ?? 'lokalita'}) — tabulka jen z uloženého horizontu; sloupec a křivka FVE používají korigovanou předpověď (delta profil, stejně jako přehled a vstupy solveru). Graf za horizont plánu doplňuje stejnou řadu až do 96 h.

{error && (
{error}
)} {/* Sekce 1 */}

Status aktivního plánu

{loading && !run ? (
Načítám…
) : !run ? (

Žádný aktivní plán.

{showPrepActions && ( void handleImportPrices()} onForecast={() => void handleRunForecast()} onInit={() => void handleInitializePlan()} /> )}
) : (
Vytvořeno: {formatLocal(run.created_at)} | Typ: {run.run_type}
Horizont: {formatLocal(run.horizon_start)} → {formatLocal(run.horizon_end)}
Korekce FVE forecastu: {correctionPct != null ? ( <> {correctionUp ? ( ) : ( )} {Number.isInteger(correctionPct) ? correctionPct : correctionPct.toLocaleString('cs-CZ', { maximumFractionDigits: 1 })}{' '} % ) : ( '—' )}
Čas výpočtu solveru: {run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
{summary?.pv_scarcity_factor != null && (
PV scarcity factor: {summary.pv_scarcity_factor.toFixed(3)} (nižší = méně očekávaného slunce, ekonomika víc toleruje precharge ze sítě)
)} {solverSnap != null && } {summary && (

Summary

{summary.total_expected_cost_czk >= 0 ? 'Celkové náklady' : 'Celkový příjem'}
{summary.total_expected_cost_czk >= 0 ? `${summary.total_expected_cost_czk.toFixed(2)} Kč` : `${Math.abs(summary.total_expected_cost_czk).toFixed(2)} Kč`}
kWh curtailmentu (A)
{summary.total_pv_curtailed_kwh.toLocaleString('cs-CZ', { minimumFractionDigits: 3, maximumFractionDigits: 3, })}{' '} kWh
Sloty nabíjení / vybíjení / export
{summary.charge_slots} / {summary.discharge_slots} / {summary.export_slots}
)}
{showPrepActions && ( void handleImportPrices()} onForecast={() => void handleRunForecast()} onInit={() => void handleInitializePlan()} wrapClassName="flex flex-wrap justify-end gap-2" /> )}
)}
{/* Sekce 2 */}

Porovnání v1 / v2

{compareLoading ? (
Načítám compare…
) : compareError && !compareData ? (
{compareError}
) : compareData ? (
{compareError && (
{compareError}
)}

Aktivní verze

{recordString(compareData.active.run?.run_type) ?? '—'}

Solver: {recordNumber(compareData.active.run?.solver_duration_ms) != null ? `${recordNumber(compareData.active.run?.solver_duration_ms)} ms` : '—'}

Náklady: {recordNumber(compareActiveSummary?.total_expected_cost_czk) != null ? `${recordNumber(compareActiveSummary?.total_expected_cost_czk)?.toFixed(2)} Kč` : '—'}

Compare verze

{recordString(compareData.comparison.run?.run_type) ?? '—'}

Solver: {recordNumber(compareData.comparison.run?.solver_duration_ms) != null ? `${recordNumber(compareData.comparison.run?.solver_duration_ms)} ms` : '—'}

Náklady: {recordNumber(comparePeerSummary?.total_expected_cost_czk) != null ? `${recordNumber(comparePeerSummary?.total_expected_cost_czk)?.toFixed(2)} Kč` : '—'}

Rozdíl

{recordNumber(compareDiff?.total_expected_cost_czk) != null ? `${recordNumber(compareDiff?.total_expected_cost_czk)?.toFixed(2)} Kč` : '—'}

Změněných slotů:{' '} {recordNumber(compareDiff?.changed_slots) != null ? recordNumber(compareDiff?.changed_slots) : '—'}

Aktivní / compare export sloty:{' '} {recordNumber(compareDiff?.active_export_slots) != null ? `${recordNumber(compareDiff?.active_export_slots)} / ${recordNumber(compareDiff?.comparison_export_slots)}` : '—'}

{compareSlotDiffs.length > 0 ? (
{compareSlotDiffs.slice(0, 48).map((row) => ( ))}
Slot Aktivní bat. W Compare bat. W Aktivní grid W Compare grid W Aktivní export Compare export
{formatLocalTime(row.interval_start)} {recordNumber(row.active.battery_setpoint_w) != null ? recordNumber(row.active.battery_setpoint_w) : '—'} {recordNumber(row.comparison.battery_setpoint_w) != null ? recordNumber(row.comparison.battery_setpoint_w) : '—'} {recordNumber(row.active.grid_setpoint_w) != null ? recordNumber(row.active.grid_setpoint_w) : '—'} {recordNumber(row.comparison.grid_setpoint_w) != null ? recordNumber(row.comparison.grid_setpoint_w) : '—'} {recordString(row.active.export_mode) ?? '—'} {recordString(row.comparison.export_mode) ?? '—'}
) : (

Compare běh je uložen, ale nemá slotové rozdíly k zobrazení.

)}
) : (

Compare plán zatím není k dispozici. Spusťte plánování s aktivním režimem v1/v2 compare.

)}
{/* Sekce 3 */}

Graf plánu

{solverSnap != null && (

Pásy: zelená = okno grid nabíjení (⚡) ·{' '} oranžová = okno exportu baterie (↓).

)} {!chartRows.length ? (

Žá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.

) : (
} /> {chartChargeBands.map((b) => ( ))} {chartExportBands.map((b) => ( ))} {chartRows.map((e) => ( = 0 ? '#22c55e' : '#f97316'} fillOpacity={0.85} /> ))}
)}
{/* Sekce 4 */}

Tabulka slotů

{solverSnap != null ? ( ) : null} {showGenCut ? ( ) : null} {planTableRows.map((row) => { if (row.kind === 'summary') { return ( ) } const i = row.i const sel = selectedStart === i.interval_start const slotMask = maskForInterval(solverSnap, i.interval_start) const exBadge = exportModeBadge(i) return ( 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)}`} > {solverSnap != null ? : null} {showGenCut ? ( ) : null} ) })}
Čas Cena Kč/kWh · kup / prod Bat. W + nabíj · − vybíj Export Masky Deye setpoint GEN SoC % FVE W delta · audit Dům W Síť W čistý EM · +odb / −exp EV1 W EV2 W Výnos Kč
{row.dateLabel} · FVE celkem{' '} {row.fveKwh.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh · Export celkem{' '} {row.exportKwh.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh · Prům. cena nákup{' '} {row.avgBuy != null ? `${row.avgBuy.toFixed(3)} Kč/kWh` : '—'}
{formatLocalTime(i.interval_start)} {i.battery_setpoint_w ?? '—'} {exBadge.label !== '—' ? ( {exBadge.label} ) : ( )}
{(() => { const b = deyeModeBadge(i) return ( {b.label} ) })()} {deyeSetpointLabel(i)}
{(() => { const g = genCutoffBadge(i) if (!g.show) return return ( {g.label} ) })()} {i.battery_soc_target_pct != null ? `${i.battery_soc_target_pct.toFixed(1)}` : '—'} {formatPlanPowerW(i.load_baseline_w)} {i.grid_setpoint_w ?? '—'} {i.ev1_setpoint_w ?? '—'} {i.ev2_setpoint_w ?? '—'} {i.heat_pump_enabled ? 'on' : 'off'}
{!visibleSlots.length && !loading && (

Žádné budoucí sloty v horizontu {tableHorizonH} h (aktivní plán může být prázdný nebo starý).

)} {selectedSlot != null && ( )} {!solverSnap && run != null && (

Masky solveru nejsou v tomto běhu — spusťte nový rolling/denní plán po nasazení arbitráže.

)}
) }