1769 lines
68 KiB
TypeScript
1769 lines
68 KiB
TypeScript
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 prod−kup</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)} Kč` : '—'}</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>TČ: {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 až 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)} Kč`
|
||
: `${Math.abs(summary.total_expected_cost_czk).toFixed(2)} Kč`}
|
||
</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)} Kč`
|
||
: '—'}
|
||
</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)} Kč`
|
||
: '—'}
|
||
</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)} Kč`
|
||
: '—'}
|
||
</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">Kč/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">TČ</th>
|
||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Výnos Kč</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>
|
||
)
|
||
}
|