Files
ems/frontend/src/pages/Planning.tsx
Dusan Vojacek b8515f30df
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped
implmemtace cuttoff genportu
2026-04-20 10:41:10 +02:00

1244 lines
46 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,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import {
getCurrentPlan,
getForecastPvSlotsRange,
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<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[]): {
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 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'
}
/**
* 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).
*/
/** 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 syntheticForecastOnlyInterval(
interval_start: string,
pv_forecast_total_w: number | null,
): PlanningIntervalDto {
return {
interval_start,
battery_setpoint_w: null,
battery_soc_target_pct: null,
grid_setpoint_w: null,
deye_physical_mode: null,
ev1_setpoint_w: null,
ev2_setpoint_w: null,
heat_pump_enabled: null,
pv_a_curtailed_w: null,
expected_cost_czk: null,
effective_buy_price: null,
effective_sell_price: null,
is_predicted_price: false,
pv_forecast_total_w,
load_baseline_w: 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))
}
/** 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 (
<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 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)
return `SELL | ⬇ ${fmtKw(tpPowerW)} | 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) return 'PASSIVE | FVE→síť (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
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)',
}
}
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 (grid_w < 0 && battery_w >= 0) variant = 'FVE→síť (108=0)'
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}; 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): reg179 bits0-1=2 (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,
): 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 (
<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,
}: {
active?: boolean
payload?: Array<{ payload: ChartRow }>
nowMs: number
}) {
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
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 (Open-Meteo).
</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 (A / předpověď): {fveDisplay}</div>
<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>
<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 [loading, setLoading] = useState(true)
const [error, setError] = 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 [forecastPvRange, setForecastPvRange] = useState<
{ interval_start: string; pv_forecast_total_w?: number | null }[]
>([])
const [forecastRefreshKey, setForecastRefreshKey] = useState(0)
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)
useEffect(() => {
if (siteId == null || loading) return
let cancelled = false
const fromIso = new Date(slotFloorMs).toISOString()
const toIso = new Date(slotFloorMs + 96 * 60 * 60 * 1000).toISOString()
void getForecastPvSlotsRange(siteId, fromIso, toIso)
.then((rows) => {
if (!cancelled) setForecastPvRange(rows)
})
.catch(() => {
if (!cancelled) setForecastPvRange([])
})
return () => {
cancelled = true
}
}, [siteId, loading, slotFloorMs, data?.run?.id, forecastRefreshKey])
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 forecastOverlay = useMemo(() => {
if (!forecastPvRange.length) return [] as PlanningIntervalDto[]
const planStarts = new Set(futureSlots.map((s) => s.interval_start))
const out: PlanningIntervalDto[] = []
for (const r of forecastPvRange) {
const iso = typeof r.interval_start === 'string' ? r.interval_start : null
if (!iso || planStarts.has(iso)) continue
const pv = r.pv_forecast_total_w
out.push(
syntheticForecastOnlyInterval(
iso,
pv == null || Number.isNaN(Number(pv)) ? null : Number(pv),
),
)
}
return out
}, [forecastPvRange, futureSlots])
/** Graf: LP sloty + za horizont plánu řada FVE předpovědi (stejná logika jako JOIN v /plan/current). */
const chartMergedSlots = useMemo(() => {
return [...futureSlots, ...forecastOverlay].sort(
(a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start),
)
}, [futureSlots, forecastOverlay])
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), [visibleSlots])
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(() => {
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()
setForecastRefreshKey((k) => k + 1)
} 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í`)
setForecastRefreshKey((k) => k + 1)
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í`)
setForecastRefreshKey((k) => k + 1)
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 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 (
<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; graf 24 / 48 / 96 h
doplňuje za plánem <span className="text-slate-300">FVE předpověď</span> (Open-Meteo), aby šlo vidět počasí
i mimo optimalizovaný úsek.
</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>
)}
{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-1 text-sm font-medium uppercase tracking-wide text-slate-400">Graf plánu</h2>
<HorizonToggle
value={chartHorizonH}
onChange={setChartHorizonH}
disabled={chartMergedSlots.length === 0}
/>
{!chartRows.length ? (
<p className="text-sm text-slate-500">
Žádná data pro graf (plán + předpověď 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} />} />
<Area
yAxisId="power"
type="monotone"
dataKey="pv_a_w"
name="FVE (A) / 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="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 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">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">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">FVE W</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={showGenCut ? 13 : 12} 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
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)}`}
>
<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="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>
)}
</section>
</div>
)
}