From 52bedcf67d65a0b86c446a657f4907491ab5fa55 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Thu, 21 May 2026 13:22:33 +0200 Subject: [PATCH] uprava UI pro planovani --- docs/04-modules/planning.md | 2 +- frontend/src/components/StatePanel.tsx | 5 +- frontend/src/hooks/useDashboardData.ts | 4 + frontend/src/lib/planSolverSnapshot.ts | 83 ++++++ frontend/src/pages/Planning.tsx | 345 ++++++++++++++++++++++++- frontend/src/types/dashboard.ts | 3 + frontend/src/types/plan.ts | 2 + 7 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/planSolverSnapshot.ts diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index c3c7117..fcf5f71 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -665,7 +665,7 @@ Planner v2 má do `planning_interval` zapisovat stejné základní položky jako - pokud je zapnuté `PLANNING_ENGINE_COMPARE_ENABLED`, backend spočítá obě verze nad stejným vstupem, aktivní verzi zapíše do plánu a druhou uloží i jako samostatný read-only `planning_run` se stavem `comparison` - compare čtení jde přes `GET /api/v1/sites/{site_id}/plan/compare` → jedno volání `ems.fn_plan_compare_bundle` (aktivní plán + `fn_planning_run_debug` comparison runu) - **Výkon `/plan/current` a `/plan/compare` (V079+):** read-model `ems.fn_plan_current_bundle` dříve při každém HTTP requestu přepočítával `fn_pv_forecast_delta_profile` nad celou historií `forecast_accuracy` (~stovky tisíc řádků na site) a kanonický PV forecast na 96 h. Od **V079** se delta profil cacheuje v `site_pv_forecast_calibration.delta_profile_cache` (refresh po `fn_fill_forecast_accuracy` a po `PATCH …/pv-forecast-calibration` přes `fn_refresh_site_pv_delta_profile_cache`; čtení přes `fn_pv_forecast_delta_profile_cached`, TTL 30 min). Kanonický PV pro graf se počítá jen za horizontem uloženého plánu (`horizon_end` → `horizon_start + 96 h`), ne pro sloty už v `planning_interval`. Ověření: `curl -w '%{time_total}\n' http://…/plan/current` před/po migraci; první request po deployi může být pomalý dokud cache nezaplní job (15 min) nebo ručně `select ems.fn_refresh_site_pv_delta_profile_cache();` -- FE stránka `frontend/src/pages/Planning.tsx` ukazuje souhrn aktivní verze, compare verze, slotové rozdíly a compare křivku baterie v grafu +- FE stránka `frontend/src/pages/Planning.tsx` ukazuje souhrn aktivní verze, compare verze, slotové rozdíly a compare křivku baterie v grafu. Od 2026-05 navíc: **acquisition** a počty masek z `planning_run.solver_params` (blok „Solver — masky a arbitráž“), sloupce **Export** (`export_mode`) a **Masky** (⚡ `allow_charge` / ↓ `allow_discharge_export`), pásy v grafu (zelená/oranžová okna), detail slotu po kliknutí na řádek. Dashboard `StatePanel` v tooltipu Deye uvádí `export_mode` z plánu. - fyzicky se na střídač aplikuje jen aktivní plán; compare běh slouží jen pro audit a vizualizaci ### Shrnutí v jedné větě diff --git a/frontend/src/components/StatePanel.tsx b/frontend/src/components/StatePanel.tsx index 442529b..e60d92a 100644 --- a/frontend/src/components/StatePanel.tsx +++ b/frontend/src/components/StatePanel.tsx @@ -18,11 +18,14 @@ function deyeModeLabel(s: SlotData): string { function deyeModeBadge(s: SlotData): { label: string; klass: string; title: string } { const m = s.deye_physical_mode + const em = (s.export_mode ?? 'NONE').toString().trim().toUpperCase() + const exportHint = + em !== 'NONE' ? ` · plán export ${em}` : '' 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)', + title: `SELL (selling first): reg142=0, reg178=32 (grid peak shaving off)${exportHint}`, } } if (m === 'CHARGE') { diff --git a/frontend/src/hooks/useDashboardData.ts b/frontend/src/hooks/useDashboardData.ts index 855071a..a141a24 100644 --- a/frontend/src/hooks/useDashboardData.ts +++ b/frontend/src/hooks/useDashboardData.ts @@ -94,6 +94,8 @@ function emptySlot(iso: string): SlotData { grid_power_w: null, grid_setpoint_w: null, deye_physical_mode: null, + export_mode: null, + export_limit_w: null, load_power_w: null, gen_port_power_w: null, pv_a_forecast_w: null, @@ -119,6 +121,8 @@ function mergeInterval(s: SlotData, p: PlanningIntervalDto): void { s.battery_setpoint_w = p.battery_setpoint_w ?? s.battery_setpoint_w s.grid_setpoint_w = p.grid_setpoint_w ?? s.grid_setpoint_w if (p.deye_physical_mode != null) s.deye_physical_mode = p.deye_physical_mode + if (p.export_mode != null) s.export_mode = p.export_mode + if (p.export_limit_w != null) s.export_limit_w = p.export_limit_w s.ev1_setpoint_w = p.ev1_setpoint_w ?? s.ev1_setpoint_w s.ev2_setpoint_w = p.ev2_setpoint_w ?? s.ev2_setpoint_w if (s.ev1_setpoint_w == null && s.ev2_setpoint_w == null && p.ev_charge_power_w != null) { diff --git a/frontend/src/lib/planSolverSnapshot.ts b/frontend/src/lib/planSolverSnapshot.ts new file mode 100644 index 0000000..df5b2f2 --- /dev/null +++ b/frontend/src/lib/planSolverSnapshot.ts @@ -0,0 +1,83 @@ +/** Parsování `planning_run.solver_params` z GET /plan/current (run je celý JSON řádek). */ + +export type PlanMaskSlot = { + allow_charge: boolean + allow_discharge_export: boolean +} + +export type PlanSolverSnapshot = { + chargeAcquisitionKwh: number | null + chargeAcquisitionCutoffAt: string | null + masksByIso: Map + chargeMaskCount: number + exportMaskCount: number +} + +function recordBool(v: unknown): boolean { + return v === true +} + +function recordNumber(v: unknown): number | null { + if (v == null) return null + const n = Number(v) + return Number.isFinite(n) ? n : null +} + +function recordString(v: unknown): string | null { + return typeof v === 'string' && v.length > 0 ? v : null +} + +export function parsePlanSolverSnapshot( + run: Record | null | undefined, +): PlanSolverSnapshot | null { + if (run == null) return null + const raw = run.solver_params + if (raw == null || typeof raw !== 'object') return null + const sp = raw as Record + const inputs = + sp.inputs != null && typeof sp.inputs === 'object' + ? (sp.inputs as Record) + : null + + const masksByIso = new Map() + const masks = sp.masks + if (Array.isArray(masks)) { + for (const m of masks) { + if (m == null || typeof m !== 'object') continue + const row = m as Record + const slot = recordString(row.slot) + if (slot == null) continue + masksByIso.set(slot, { + allow_charge: recordBool(row.allow_charge), + allow_discharge_export: recordBool(row.allow_discharge_export), + }) + } + } + + let chargeMaskCount = 0 + let exportMaskCount = 0 + for (const v of masksByIso.values()) { + if (v.allow_charge) chargeMaskCount += 1 + if (v.allow_discharge_export) exportMaskCount += 1 + } + + return { + chargeAcquisitionKwh: inputs + ? recordNumber(inputs.charge_acquisition_buy_czk_kwh) + : null, + chargeAcquisitionCutoffAt: inputs + ? recordString(inputs.charge_acquisition_cutoff_at) + : null, + masksByIso, + chargeMaskCount, + exportMaskCount, + } +} + +export function maskForInterval( + snapshot: PlanSolverSnapshot | null, + intervalStartIso: string, +): PlanMaskSlot | null { + if (snapshot == null) return null + return snapshot.masksByIso.get(intervalStartIso) ?? null +} diff --git a/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx index c95e2b6..9b7b1a7 100644 --- a/frontend/src/pages/Planning.tsx +++ b/frontend/src/pages/Planning.tsx @@ -17,6 +17,7 @@ import { Cell, ComposedChart, Line, + ReferenceArea, ResponsiveContainer, Tooltip, XAxis, @@ -32,6 +33,12 @@ import { } 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, @@ -379,6 +386,7 @@ function genCutoffBadge(i: PlanningIntervalDto): { show: boolean; label: string; 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') @@ -387,9 +395,229 @@ function tableRowClass( if (buy != null && buy < 0) parts.push('bg-green-950/80') else if (sell != null && sell < 0) parts.push('bg-red-950/80') if ((i.pv_a_curtailed_w ?? 0) > 0) parts.push('border-l-4 border-l-yellow-500') + else if (mask?.allow_charge) parts.push('border-l-4 border-l-emerald-600/70') + else if (mask?.allow_discharge_export) parts.push('border-l-4 border-l-orange-500/70') return parts.join(' ') } +function exportModeBadge(i: PlanningIntervalDto): { + label: string + klass: string + title: string +} { + const m = (i.export_mode ?? 'NONE').toString().trim().toUpperCase() + const cap = + i.export_limit_w != null && i.export_limit_w > 0 + ? ` · limit ${formatPlanPowerW(i.export_limit_w)}` + : '' + if (m === 'BATTERY_SELL') { + return { + label: 'BAT→síť', + klass: 'bg-orange-500/20 text-orange-100 ring-1 ring-orange-500/40', + title: `Export z baterie do sítě (BATTERY_SELL)${cap}`, + } + } + if (m === 'PV_SURPLUS') { + return { + label: 'FVE→síť', + klass: 'bg-amber-500/15 text-amber-100 ring-1 ring-amber-500/35', + title: `Export přebytku FVE (PV_SURPLUS)${cap}`, + } + } + return { + label: '—', + klass: 'text-slate-600', + title: `Bez exportu do sítě (NONE)${cap}`, + } +} + +function MaskIconsCell({ mask }: { mask: PlanMaskSlot | null }) { + if (mask == null) { + return — + } + return ( + + + + ⚡ + + + ↓ + + + + ) +} + +function PlanSolverInsights({ snap }: { snap: PlanSolverSnapshot }) { + const cutoffLabel = snap.chargeAcquisitionCutoffAt + ? formatLocal(snap.chargeAcquisitionCutoffAt) + : '—' + return ( +
+

+ Solver — masky a arbitráž +

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

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

+
+ ) +} + +function PlanSlotDetail({ + i, + mask, + compare, + nowMs, +}: { + i: PlanningIntervalDto + mask: PlanMaskSlot | null + compare: PlanningIntervalDto | undefined + nowMs: number +}) { + const ex = exportModeBadge(i) + const spread = + i.effective_sell_price != null && i.effective_buy_price != null + ? i.effective_sell_price - i.effective_buy_price + : null + return ( +
+

{formatLocal(i.interval_start)}

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

{deyeSetpointLabel(i)}

+ {compare ? ( +

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

+ ) : null} +
+ ) +} + +type ChartMaskBand = { x1: string; x2: string } + +function buildChartMaskBands( + chartRows: ChartRow[], + snap: PlanSolverSnapshot | null, + flag: 'allow_charge' | 'allow_discharge_export', +): ChartMaskBand[] { + if (snap == null || chartRows.length === 0) return [] + const bands: ChartMaskBand[] = [] + let start: string | null = null + let prev: string | null = null + for (const row of chartRows) { + const m = maskForInterval(snap, row.raw.interval_start) + const on = m != null && m[flag] + if (on) { + if (start == null) start = row.label + prev = row.label + } else if (start != null && prev != null) { + bands.push({ x1: start, x2: prev }) + start = null + prev = null + } + } + if (start != null && prev != null) bands.push({ x1: start, x2: prev }) + return bands +} + type ChartRow = { label: string ts: number @@ -496,10 +724,12 @@ 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 @@ -514,6 +744,7 @@ function PlanTooltip({ const exportLimit = i.export_limit_w const exportMode = i.export_mode ?? 'NONE' const compareBattery = p.compare_battery_setpoint_w + const mask = maskForInterval(solverSnap ?? null, i.interval_start) return (
{formatLocal(i.interval_start)}
@@ -537,6 +768,12 @@ function PlanTooltip({ {exportLimit != null ? ` · limit ${formatPlanPowerW(exportLimit)}` : ''}
) : null} + {mask ? ( +
+ Masky: {mask.allow_charge ? '⚡ nabíjení' : '—'} ·{' '} + {mask.allow_discharge_export ? '↓ export bat.' : '—'} +
+ ) : null}
SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}
Dům: {i.load_baseline_w ?? '—'} W
Baterie: {i.battery_setpoint_w ?? '—'} W
@@ -840,6 +1077,35 @@ export default function Planning() { const run = data?.run const summary = data?.summary + const solverSnap = useMemo( + () => + parsePlanSolverSnapshot( + run != null ? (run as unknown as Record) : undefined, + ), + [run], + ) + + const chartChargeBands = useMemo( + () => buildChartMaskBands(chartRows, solverSnap, 'allow_charge'), + [chartRows, solverSnap], + ) + const chartExportBands = useMemo( + () => buildChartMaskBands(chartRows, solverSnap, 'allow_discharge_export'), + [chartRows, solverSnap], + ) + + const compareIntervalByStart = useMemo(() => { + const list = compareData?.comparison?.intervals ?? [] + return new Map(list.map((i) => [i.interval_start, i])) + }, [compareData?.comparison?.intervals]) + + const selectedSlot = useMemo( + () => visibleSlots.find((s) => s.interval_start === selectedStart) ?? null, + [visibleSlots, selectedStart], + ) + + const tableColCount = 13 + (solverSnap != null ? 1 : 0) + (showGenCut ? 1 : 0) + const showPrepActions = !loading const prepBusy = prepAction !== null @@ -949,6 +1215,7 @@ export default function Planning() { )} + {solverSnap != null && } {summary && (

Summary

@@ -1146,6 +1413,12 @@ export default function Planning() { {/* Sekce 3 */}

Graf plánu

+ {solverSnap != null && ( +

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

+ )} - } /> + } /> + {chartChargeBands.map((b) => ( + + ))} + {chartExportBands.map((b) => ( + + ))} + + Export + + {solverSnap != null ? ( + + Masky + + ) : null} Deye setpoint {showGenCut ? ( @@ -1322,7 +1631,7 @@ export default function Planning() { key={`sum-${row.dayKey}`} className="border-b border-slate-700/90 bg-slate-800/70 text-slate-200" > - + {row.dateLabel} · FVE celkem{' '} @@ -1345,6 +1654,8 @@ export default function Planning() { } const i = row.i const sel = selectedStart === i.interval_start + const slotMask = maskForInterval(solverSnap, i.interval_start) + const exBadge = exportModeBadge(i) return ( (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)}`} + className={`cursor-pointer border-b border-slate-800/80 transition hover:bg-slate-800/40 ${tableRowClass(i, sel, slotMask)}`} > {formatLocalTime(i.interval_start)} {i.battery_setpoint_w ?? '—'} + + {exBadge.label !== '—' ? ( + + {exBadge.label} + + ) : ( + + — + + )} + + {solverSnap != null ? : null}
{(() => { @@ -1423,6 +1749,19 @@ export default function Planning() { Žádné budoucí sloty v horizontu {tableHorizonH} h (aktivní plán může být prázdný nebo starý).

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

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

+ )}
) diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index e4daf3a..cb57d56 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -10,6 +10,9 @@ export type SlotData = { grid_setpoint_w: number | null /** Fyzický režim Deye pro slot (PASSIVE/SELL/CHARGE) z plánu. */ deye_physical_mode: 'PASSIVE' | 'SELL' | 'CHARGE' | null + /** Záměr exportu z LP (NONE / PV_SURPLUS / BATTERY_SELL). */ + export_mode?: 'NONE' | 'PV_SURPLUS' | 'BATTERY_SELL' | null + export_limit_w?: number | null load_power_w: number | null gen_port_power_w: number | null pv_a_forecast_w: number | null diff --git a/frontend/src/types/plan.ts b/frontend/src/types/plan.ts index a670466..44fb885 100644 --- a/frontend/src/types/plan.ts +++ b/frontend/src/types/plan.ts @@ -8,6 +8,8 @@ export type PlanningRunDto = { horizon_end: string forecast_correction_factor: number | null solver_duration_ms: number | null + /** Snapshot z solve_dispatch (masky, charge_acquisition, …) — volitelné. */ + solver_params?: Record | null } export type PlanningIntervalDto = {