planner v2 vc. porovnani
Some checks failed
CI and deploy / migration-check (push) Failing after 20s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-15 23:03:32 +02:00
parent d89d8b1e3a
commit 7490ac3d70
11 changed files with 900 additions and 29 deletions

View File

@@ -25,13 +25,18 @@ import {
import {
getCurrentPlan,
getPlanCompare,
postImportSitePrices,
postRunForecast,
postRunPlan,
} from '../api/backend'
import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants'
import { useSiteStatus } from '../hooks/useSiteStatus'
import type { CurrentPlanResponse, PlanningIntervalDto } from '../types/plan'
import type {
CurrentPlanResponse,
PlanningCompareResponse,
PlanningIntervalDto,
} from '../types/plan'
const TZ = 'Europe/Prague'
@@ -389,10 +394,21 @@ type ChartRow = {
pv_a_w: number
battery_soc_target_pct: number | null
battery_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
@@ -494,6 +510,7 @@ function PlanTooltip({
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
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>
@@ -520,6 +537,7 @@ function PlanTooltip({
<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>: {i.heat_pump_enabled ? 'zapnuto' : 'vypnuto'}</div>
<div>
@@ -592,8 +610,11 @@ export default function Planning() {
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')
@@ -604,10 +625,32 @@ export default function Planning() {
const load = useCallback(async () => {
if (siteId == null) return
setLoading(true)
setCompareLoading(true)
setError(null)
setCompareError(null)
try {
const res = await getCurrentPlan(siteId)
setData(res)
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 })
@@ -618,6 +661,7 @@ export default function Planning() {
}
} finally {
setLoading(false)
setCompareLoading(false)
}
}, [siteId])
@@ -679,16 +723,19 @@ export default function Planning() {
}, [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,
compare_battery_setpoint_w: compareMap.get(i.interval_start)?.battery_setpoint_w ?? null,
effective_buy_price: i.effective_buy_price,
raw: i,
}))
}, [chartIntervals, nowMs])
}, [chartIntervals, nowMs, compareData?.comparison?.intervals])
async function onReplan() {
if (siteId == null) return
@@ -795,6 +842,10 @@ export default function Planning() {
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">
@@ -956,6 +1007,139 @@ export default function Planning() {
</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)}`
: '—'}
</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)}`
: '—'}
</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)}`
: '—'}
</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>
<HorizonToggle
@@ -1029,6 +1213,17 @@ export default function Planning() {
/>
))}
</Bar>
<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"
@@ -1056,7 +1251,7 @@ export default function Planning() {
)}
</section>
{/* Sekce 3 */}
{/* 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} />