planner v2 vc. porovnani
This commit is contained in:
@@ -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>TČ: {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)} 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>
|
||||
<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} />
|
||||
|
||||
Reference in New Issue
Block a user