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, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts' import { getCurrentPlan, postImportSitePrices, postRunForecast, postRunPlan } from '../api/backend' import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants' import { useSiteStatus } from '../hooks/useSiteStatus' import type { CurrentPlanResponse, 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[]): { 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) { fveWh += (s.pv_forecast_total_w ?? 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[]): 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), }) for (const i of sl) rows.push({ kind: 'slot', i }) } return rows } 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' } /** * Vizuál FVE: API posílá součet A+B (`pv_forecast_total_w`). * Pokud je hodnota null (data chybí), použijeme jednoduchou proxy z ceny nákupu (W). * Čistá nula = platná předpověď „bez výroby“ (např. noc). */ 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)) } /** Budoucí slot (od začátku ještě nenastal): 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 if (f != null) return Number(f) return 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 is_exporting = battery_w < -500 || grid_w < -500 const is_charging = battery_w > 500 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` } if (is_exporting) { const tpPowerW = Math.abs(battery_w) return `⬇ ${fmtKw(tpPowerW)} | reg178 bit4–5=10 (grid PS off)` } if (is_charging) { return `⬆ ${fmtKw(battery_w)} | grid=yes | SOC→${targetSoc}%` } return '~ 2kW | hold' } function tableRowClass( i: PlanningIntervalDto, selected: boolean, ): 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') return parts.join(' ') } type ChartRow = { label: string ts: number pv_a_w: number battery_soc_target_pct: number | null battery_setpoint_w: number effective_buy_price: number | null raw: PlanningIntervalDto } 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, }: { active?: boolean payload?: Array<{ payload: ChartRow }> nowMs: number }) { if (!active || !payload?.length) return null const p = payload[0].payload const i = p.raw 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 return (
{formatLocal(i.interval_start)}
{pred && (
Cena: odhad (predikce)
)}
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '} {sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'}
FVE (A / předpověď): {fveDisplay}
SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}
Baterie: {i.battery_setpoint_w ?? '—'} W
Síť: {i.grid_setpoint_w ?? '—'} W
TČ: {i.heat_pump_enabled ? 'zapnuto' : 'vypnuto'}
EV1: {i.ev1_setpoint_w ?? '—'} W · EV2: {i.ev2_setpoint_w ?? '—'} W
) } 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 [loading, setLoading] = useState(true) const [error, setError] = 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) setError(null) try { const res = await getCurrentPlan(siteId) setData(res) } 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) } }, [siteId]) useEffect(() => { if (siteId != null) void load() }, [siteId, load]) const nowMs = Date.now() const slotFloorMs = floorSlotUtcMs(nowMs) 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]) const chartIntervals = useMemo(() => { const endMs = nowMs + chartHorizonH * 60 * 60 * 1000 return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs) }, [futureSlots, nowMs, chartHorizonH]) const planTableRows = useMemo(() => buildPlanTableRows(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(() => { return chartIntervals.map((i) => ({ label: formatLocalTime(i.interval_start), ts: slotStartUtcMs(i.interval_start), pv_a_w: pvAProxyW(i), battery_soc_target_pct: i.battery_soc_target_pct, battery_setpoint_w: i.battery_setpoint_w ?? 0, effective_buy_price: i.effective_buy_price, raw: i, })) }, [chartIntervals]) 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) } } 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 run = data?.run const summary = data?.summary 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 return (

Plánování

Aktuální LP plán až 96 h od aktuálního slotu ({site?.site_name ?? 'lokalita'}) — tabulka a graf lze zúžit horizontem 24 / 48 / 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ě)
)} {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 */}

Graf plánu

{!chartRows.length ? (

Žádná data pro graf (budoucí sloty aktivního plánu, horizont {chartHorizonH} h).

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

Tabulka slotů

{planTableRows.map((row) => { if (row.kind === 'summary') { return ( ) } const i = row.i const sel = selectedStart === i.interval_start 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)}`} > ) })}
Čas Cena Kč/kWh · kup / prod Bat. W Deye setpoint SoC % FVE W Síť W 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 ?? '—'} {deyeSetpointLabel(i)} {i.battery_soc_target_pct != null ? `${i.battery_soc_target_pct.toFixed(1)}` : '—'} {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ý).

)}
) }