uprava UI pro planovani
This commit is contained in:
@@ -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`
|
- 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)
|
- 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(<site_id>);`
|
- **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(<site_id>);`
|
||||||
- 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
|
- fyzicky se na střídač aplikuje jen aktivní plán; compare běh slouží jen pro audit a vizualizaci
|
||||||
|
|
||||||
### Shrnutí v jedné větě
|
### Shrnutí v jedné větě
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ function deyeModeLabel(s: SlotData): string {
|
|||||||
|
|
||||||
function deyeModeBadge(s: SlotData): { label: string; klass: string; title: string } {
|
function deyeModeBadge(s: SlotData): { label: string; klass: string; title: string } {
|
||||||
const m = s.deye_physical_mode
|
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') {
|
if (m === 'SELL') {
|
||||||
return {
|
return {
|
||||||
label: 'SELL',
|
label: 'SELL',
|
||||||
klass: 'bg-orange-500/15 text-orange-200 ring-1 ring-orange-500/35',
|
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') {
|
if (m === 'CHARGE') {
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ function emptySlot(iso: string): SlotData {
|
|||||||
grid_power_w: null,
|
grid_power_w: null,
|
||||||
grid_setpoint_w: null,
|
grid_setpoint_w: null,
|
||||||
deye_physical_mode: null,
|
deye_physical_mode: null,
|
||||||
|
export_mode: null,
|
||||||
|
export_limit_w: null,
|
||||||
load_power_w: null,
|
load_power_w: null,
|
||||||
gen_port_power_w: null,
|
gen_port_power_w: null,
|
||||||
pv_a_forecast_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.battery_setpoint_w = p.battery_setpoint_w ?? s.battery_setpoint_w
|
||||||
s.grid_setpoint_w = p.grid_setpoint_w ?? s.grid_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.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.ev1_setpoint_w = p.ev1_setpoint_w ?? s.ev1_setpoint_w
|
||||||
s.ev2_setpoint_w = p.ev2_setpoint_w ?? s.ev2_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) {
|
if (s.ev1_setpoint_w == null && s.ev2_setpoint_w == null && p.ev_charge_power_w != null) {
|
||||||
|
|||||||
83
frontend/src/lib/planSolverSnapshot.ts
Normal file
83
frontend/src/lib/planSolverSnapshot.ts
Normal file
@@ -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<string, PlanMaskSlot>
|
||||||
|
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<string, unknown> | 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<string, unknown>
|
||||||
|
const inputs =
|
||||||
|
sp.inputs != null && typeof sp.inputs === 'object'
|
||||||
|
? (sp.inputs as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const masksByIso = new Map<string, PlanMaskSlot>()
|
||||||
|
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<string, unknown>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Cell,
|
Cell,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
Line,
|
Line,
|
||||||
|
ReferenceArea,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -32,6 +33,12 @@ import {
|
|||||||
} from '../api/backend'
|
} from '../api/backend'
|
||||||
import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants'
|
import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants'
|
||||||
import { useSiteStatus } from '../hooks/useSiteStatus'
|
import { useSiteStatus } from '../hooks/useSiteStatus'
|
||||||
|
import {
|
||||||
|
maskForInterval,
|
||||||
|
parsePlanSolverSnapshot,
|
||||||
|
type PlanMaskSlot,
|
||||||
|
type PlanSolverSnapshot,
|
||||||
|
} from '../lib/planSolverSnapshot'
|
||||||
import type {
|
import type {
|
||||||
CurrentPlanResponse,
|
CurrentPlanResponse,
|
||||||
PlanningCompareResponse,
|
PlanningCompareResponse,
|
||||||
@@ -379,6 +386,7 @@ function genCutoffBadge(i: PlanningIntervalDto): { show: boolean; label: string;
|
|||||||
function tableRowClass(
|
function tableRowClass(
|
||||||
i: PlanningIntervalDto,
|
i: PlanningIntervalDto,
|
||||||
selected: boolean,
|
selected: boolean,
|
||||||
|
mask: PlanMaskSlot | null,
|
||||||
): string {
|
): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (selected) parts.push('ring-1 ring-inset ring-cyan-500/50 bg-cyan-950/25')
|
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')
|
if (buy != null && buy < 0) parts.push('bg-green-950/80')
|
||||||
else if (sell != null && sell < 0) parts.push('bg-red-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')
|
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(' ')
|
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 = {
|
type ChartRow = {
|
||||||
label: string
|
label: string
|
||||||
ts: number
|
ts: number
|
||||||
@@ -496,10 +724,12 @@ function PlanTooltip({
|
|||||||
active,
|
active,
|
||||||
payload,
|
payload,
|
||||||
nowMs,
|
nowMs,
|
||||||
|
solverSnap,
|
||||||
}: {
|
}: {
|
||||||
active?: boolean
|
active?: boolean
|
||||||
payload?: Array<{ payload: ChartRow }>
|
payload?: Array<{ payload: ChartRow }>
|
||||||
nowMs: number
|
nowMs: number
|
||||||
|
solverSnap?: PlanSolverSnapshot | null
|
||||||
}) {
|
}) {
|
||||||
if (!active || !payload?.length) return null
|
if (!active || !payload?.length) return null
|
||||||
const p = payload[0].payload
|
const p = payload[0].payload
|
||||||
@@ -514,6 +744,7 @@ function PlanTooltip({
|
|||||||
const exportLimit = i.export_limit_w
|
const exportLimit = i.export_limit_w
|
||||||
const exportMode = i.export_mode ?? 'NONE'
|
const exportMode = i.export_mode ?? 'NONE'
|
||||||
const compareBattery = p.compare_battery_setpoint_w
|
const compareBattery = p.compare_battery_setpoint_w
|
||||||
|
const mask = maskForInterval(solverSnap ?? null, i.interval_start)
|
||||||
return (
|
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="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>
|
<div className="mb-1 font-medium text-slate-100">{formatLocal(i.interval_start)}</div>
|
||||||
@@ -537,6 +768,12 @@ function PlanTooltip({
|
|||||||
{exportLimit != null ? ` · limit ${formatPlanPowerW(exportLimit)}` : ''}
|
{exportLimit != null ? ` · limit ${formatPlanPowerW(exportLimit)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}</div>
|
||||||
<div>Dům: {i.load_baseline_w ?? '—'} W</div>
|
<div>Dům: {i.load_baseline_w ?? '—'} W</div>
|
||||||
<div>Baterie: {i.battery_setpoint_w ?? '—'} W</div>
|
<div>Baterie: {i.battery_setpoint_w ?? '—'} W</div>
|
||||||
@@ -840,6 +1077,35 @@ export default function Planning() {
|
|||||||
const run = data?.run
|
const run = data?.run
|
||||||
const summary = data?.summary
|
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)
|
||||||
|
|
||||||
const showPrepActions = !loading
|
const showPrepActions = !loading
|
||||||
const prepBusy = prepAction !== null
|
const prepBusy = prepAction !== null
|
||||||
|
|
||||||
@@ -949,6 +1215,7 @@ export default function Planning() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{solverSnap != null && <PlanSolverInsights snap={solverSnap} />}
|
||||||
{summary && (
|
{summary && (
|
||||||
<div className="border-t border-slate-800 pt-3 text-sm">
|
<div className="border-t border-slate-800 pt-3 text-sm">
|
||||||
<p className="mb-2 text-slate-500">Summary</p>
|
<p className="mb-2 text-slate-500">Summary</p>
|
||||||
@@ -1146,6 +1413,12 @@ export default function Planning() {
|
|||||||
{/* Sekce 3 */}
|
{/* Sekce 3 */}
|
||||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
<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>
|
<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
|
<HorizonToggle
|
||||||
value={chartHorizonH}
|
value={chartHorizonH}
|
||||||
onChange={setChartHorizonH}
|
onChange={setChartHorizonH}
|
||||||
@@ -1198,7 +1471,29 @@ export default function Planning() {
|
|||||||
offset: 10,
|
offset: 10,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<PlanTooltip nowMs={nowMs} />} />
|
<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
|
<Area
|
||||||
yAxisId="power"
|
yAxisId="power"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
@@ -1287,6 +1582,20 @@ export default function Planning() {
|
|||||||
+ nabíj · − vybíj
|
+ nabíj · − vybíj
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</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>
|
<th className="whitespace-nowrap py-2 pr-2 font-medium">Deye setpoint</th>
|
||||||
{showGenCut ? (
|
{showGenCut ? (
|
||||||
<th className="whitespace-nowrap py-2 pr-2 font-medium" title="GEN port cut-off (BA81 / mikroinvertory)">
|
<th className="whitespace-nowrap py-2 pr-2 font-medium" title="GEN port cut-off (BA81 / mikroinvertory)">
|
||||||
@@ -1322,7 +1631,7 @@ export default function Planning() {
|
|||||||
key={`sum-${row.dayKey}`}
|
key={`sum-${row.dayKey}`}
|
||||||
className="border-b border-slate-700/90 bg-slate-800/70 text-slate-200"
|
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">
|
<td colSpan={tableColCount} className="px-2 py-2 text-xs font-medium">
|
||||||
<span className="text-slate-100">{row.dateLabel}</span>
|
<span className="text-slate-100">{row.dateLabel}</span>
|
||||||
<span className="mx-2 text-slate-600">·</span>
|
<span className="mx-2 text-slate-600">·</span>
|
||||||
<span className="text-slate-400">FVE celkem</span>{' '}
|
<span className="text-slate-400">FVE celkem</span>{' '}
|
||||||
@@ -1345,6 +1654,8 @@ export default function Planning() {
|
|||||||
}
|
}
|
||||||
const i = row.i
|
const i = row.i
|
||||||
const sel = selectedStart === i.interval_start
|
const sel = selectedStart === i.interval_start
|
||||||
|
const slotMask = maskForInterval(solverSnap, i.interval_start)
|
||||||
|
const exBadge = exportModeBadge(i)
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={i.interval_start}
|
key={i.interval_start}
|
||||||
@@ -1357,13 +1668,28 @@ export default function Planning() {
|
|||||||
setSelectedStart((prev) => (prev === i.interval_start ? null : i.interval_start))
|
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)}`}
|
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">
|
<td className="whitespace-nowrap py-1.5 pl-2 pr-2 font-mono text-slate-300">
|
||||||
{formatLocalTime(i.interval_start)}
|
{formatLocalTime(i.interval_start)}
|
||||||
</td>
|
</td>
|
||||||
<CenaCell i={i} nowMs={nowMs} />
|
<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 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">
|
<td className="max-w-[200px] whitespace-normal break-words pr-2 text-slate-300">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -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ý).
|
Žádné budoucí sloty v horizontu {tableHorizonH} h (aktivní plán může být prázdný nebo starý).
|
||||||
</p>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export type SlotData = {
|
|||||||
grid_setpoint_w: number | null
|
grid_setpoint_w: number | null
|
||||||
/** Fyzický režim Deye pro slot (PASSIVE/SELL/CHARGE) z plánu. */
|
/** Fyzický režim Deye pro slot (PASSIVE/SELL/CHARGE) z plánu. */
|
||||||
deye_physical_mode: 'PASSIVE' | 'SELL' | 'CHARGE' | null
|
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
|
load_power_w: number | null
|
||||||
gen_port_power_w: number | null
|
gen_port_power_w: number | null
|
||||||
pv_a_forecast_w: number | null
|
pv_a_forecast_w: number | null
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type PlanningRunDto = {
|
|||||||
horizon_end: string
|
horizon_end: string
|
||||||
forecast_correction_factor: number | null
|
forecast_correction_factor: number | null
|
||||||
solver_duration_ms: number | null
|
solver_duration_ms: number | null
|
||||||
|
/** Snapshot z solve_dispatch (masky, charge_acquisition, …) — volitelné. */
|
||||||
|
solver_params?: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlanningIntervalDto = {
|
export type PlanningIntervalDto = {
|
||||||
|
|||||||
Reference in New Issue
Block a user