prepnuti k planovani na kkorigovany forecast
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-23 00:16:23 +02:00
parent c928e2234d
commit f6e239aa8d

View File

@@ -25,7 +25,8 @@ import {
import { import {
getCurrentPlan, getCurrentPlan,
getForecastPvSlotsRange, getForecastPvSlotsRangeCorrected,
type ForecastPvSlotCorrectedRow,
postImportSitePrices, postImportSitePrices,
postRunForecast, postRunForecast,
postRunPlan, postRunPlan,
@@ -108,7 +109,11 @@ function groupByDay(slots: PlanningIntervalDto[]): Record<string, PlanningInterv
) )
} }
function dayStats(slots: PlanningIntervalDto[]): { function dayStats(
slots: PlanningIntervalDto[],
nowMs: number,
correctedPvByIso?: Map<string, number>,
): {
fveKwh: number fveKwh: number
exportKwh: number exportKwh: number
avgBuy: number | null avgBuy: number | null
@@ -118,7 +123,8 @@ function dayStats(slots: PlanningIntervalDto[]): {
let expWh = 0 let expWh = 0
const buys: number[] = [] const buys: number[] = []
for (const s of slots) { for (const s of slots) {
fveWh += (s.pv_forecast_total_w ?? 0) * slotHours const fveW = slotFveDisplayW(s, nowMs, correctedPvByIso)
fveWh += (fveW ?? 0) * slotHours
const gw = s.grid_setpoint_w ?? 0 const gw = s.grid_setpoint_w ?? 0
if (gw < 0) expWh += -gw * slotHours if (gw < 0) expWh += -gw * slotHours
if (s.effective_buy_price != null) buys.push(s.effective_buy_price) if (s.effective_buy_price != null) buys.push(s.effective_buy_price)
@@ -140,7 +146,11 @@ type PlanTableRow =
} }
| { kind: 'slot'; i: PlanningIntervalDto } | { kind: 'slot'; i: PlanningIntervalDto }
function buildPlanTableRows(visibleSlots: PlanningIntervalDto[]): PlanTableRow[] { function buildPlanTableRows(
visibleSlots: PlanningIntervalDto[],
nowMs: number,
correctedPvByIso?: Map<string, number>,
): PlanTableRow[] {
const groups = groupByDay(visibleSlots) const groups = groupByDay(visibleSlots)
const dayKeys = [...new Set(visibleSlots.map((s) => pragueDayKey(s.interval_start)))].sort() const dayKeys = [...new Set(visibleSlots.map((s) => pragueDayKey(s.interval_start)))].sort()
const rows: PlanTableRow[] = [] const rows: PlanTableRow[] = []
@@ -151,7 +161,7 @@ function buildPlanTableRows(visibleSlots: PlanningIntervalDto[]): PlanTableRow[]
kind: 'summary', kind: 'summary',
dayKey: dk, dayKey: dk,
dateLabel: formatPragueDateLabel(sl[0]!.interval_start), dateLabel: formatPragueDateLabel(sl[0]!.interval_start),
...dayStats(sl), ...dayStats(sl, nowMs, correctedPvByIso),
}) })
for (const i of sl) rows.push({ kind: 'slot', i }) for (const i of sl) rows.push({ kind: 'slot', i })
} }
@@ -168,10 +178,30 @@ function horizonToggleClass(active: boolean): string {
: 'border-slate-600 bg-slate-800/80 text-slate-300 hover:bg-slate-800' : 'border-slate-600 bg-slate-800/80 text-slate-300 hover:bg-slate-800'
} }
/** Stejná logika jako přehled: korigovaný výkon z delty, jinak raw součet z API řádku. */
function pvDisplayWFromCorrectedRow(r: ForecastPvSlotCorrectedRow): number | null {
const c = r.pv_forecast_corrected_w
if (c != null && Number.isFinite(Number(c))) return Number(c)
const raw = r.pv_forecast_total_w
if (raw != null && Number.isFinite(Number(raw))) return Number(raw)
return null
}
function buildCorrectedPvByIso(rows: ForecastPvSlotCorrectedRow[]): Map<string, number> {
const m = new Map<string, number>()
for (const r of rows) {
const iso = typeof r.interval_start === 'string' ? r.interval_start : null
if (!iso) continue
const v = pvDisplayWFromCorrectedRow(r)
if (v != null && Number.isFinite(v)) m.set(iso, v)
}
return m
}
/** /**
* Vizuál FVE: API posílá součet A+B (`pv_forecast_total_w`). * Budoucí slot: `pv_forecast_total_w` z /plan/current je raw z forecast_pv_interval; pro zobrazení
* Pokud je hodnota null (data chybí), použijeme jednoduchou proxy z ceny nákupu (W). * preferujeme korekci z `pv-slots-corrected` (soulad s LP vstupy a přehledem).
* Čistá nula = platná předpověď „bez výroby“ (např. noc). * 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. */ /** Slot jen z řady forecast (za horizontem planning_interval) — doplnění grafu. */
function isForecastExtensionInterval(i: PlanningIntervalDto): boolean { function isForecastExtensionInterval(i: PlanningIntervalDto): boolean {
@@ -215,11 +245,25 @@ function pvAProxyW(i: PlanningIntervalDto): number {
return Math.max(0, Math.min(15000, w)) return Math.max(0, Math.min(15000, w))
} }
/** Budoucí slot (od začátku ještě nenastal): předpověď; proběhlý / probíhající: telemetrie z auditu. */ /** Křivka FVE ve grafu: korig. / audit, jinak stejná cena-proxy jako dřív. */
function slotFveDisplayW(i: PlanningIntervalDto, nowMs: number): number | null { function pvChartFveW(i: PlanningIntervalDto, nowMs: number, correctedPvByIso?: Map<string, number>): number {
const w = slotFveDisplayW(i, nowMs, correctedPvByIso)
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,
correctedPvByIso?: Map<string, number>,
): number | null {
const start = slotStartUtcMs(i.interval_start) const start = slotStartUtcMs(i.interval_start)
const future = start >= nowMs const future = start >= nowMs
if (future) { if (future) {
const iso = i.interval_start
const corr = correctedPvByIso?.get(iso)
if (corr !== undefined && Number.isFinite(corr)) return corr
const f = i.pv_forecast_total_w const f = i.pv_forecast_total_w
if (f != null) return Number(f) if (f != null) return Number(f)
return null return null
@@ -242,8 +286,16 @@ function formatPlanPowerW(w: number | null): string {
return String(v) return String(v)
} }
function FveWCell({ i, nowMs }: { i: PlanningIntervalDto; nowMs: number }) { function FveWCell({
const w = slotFveDisplayW(i, nowMs) i,
nowMs,
correctedPvByIso,
}: {
i: PlanningIntervalDto
nowMs: number
correctedPvByIso?: Map<string, number>
}) {
const w = slotFveDisplayW(i, nowMs, correctedPvByIso)
const color = const color =
w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500' w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500'
return ( return (
@@ -492,7 +544,7 @@ function PlanTooltip({
<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>
{ext && ( {ext && (
<div className="mb-1 text-[10px] font-sans font-normal normal-case text-amber-200/90"> <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 (Open-Meteo). Mimo uložený horizont plánu jen předpověď FVE (po korekci delty, stejně jako v přehledu).
</div> </div>
)} )}
{pred && ( {pred && (
@@ -503,7 +555,7 @@ function PlanTooltip({
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '} Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '}
{sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'} {sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'}
</div> </div>
<div>FVE (A / předpověď): {fveDisplay}</div> <div>FVE (korig. předpověď / audit): {fveDisplay}</div>
<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>
@@ -587,9 +639,7 @@ export default function Planning() {
const [selectedStart, setSelectedStart] = useState<string | null>(null) const [selectedStart, setSelectedStart] = useState<string | null>(null)
const [tableHorizonH, setTableHorizonH] = useState<HorizonHours>(48) const [tableHorizonH, setTableHorizonH] = useState<HorizonHours>(48)
const [chartHorizonH, setChartHorizonH] = useState<HorizonHours>(48) const [chartHorizonH, setChartHorizonH] = useState<HorizonHours>(48)
const [forecastPvRange, setForecastPvRange] = useState< const [forecastPvCorrectedRows, setForecastPvCorrectedRows] = useState<ForecastPvSlotCorrectedRow[]>([])
{ interval_start: string; pv_forecast_total_w?: number | null }[]
>([])
const [forecastRefreshKey, setForecastRefreshKey] = useState(0) const [forecastRefreshKey, setForecastRefreshKey] = useState(0)
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -624,18 +674,23 @@ export default function Planning() {
let cancelled = false let cancelled = false
const fromIso = new Date(slotFloorMs).toISOString() const fromIso = new Date(slotFloorMs).toISOString()
const toIso = new Date(slotFloorMs + 96 * 60 * 60 * 1000).toISOString() const toIso = new Date(slotFloorMs + 96 * 60 * 60 * 1000).toISOString()
void getForecastPvSlotsRange(siteId, fromIso, toIso) void getForecastPvSlotsRangeCorrected(siteId, fromIso, toIso)
.then((rows) => { .then((rows) => {
if (!cancelled) setForecastPvRange(rows) if (!cancelled) setForecastPvCorrectedRows(rows)
}) })
.catch(() => { .catch(() => {
if (!cancelled) setForecastPvRange([]) if (!cancelled) setForecastPvCorrectedRows([])
}) })
return () => { return () => {
cancelled = true cancelled = true
} }
}, [siteId, loading, slotFloorMs, data?.run?.id, forecastRefreshKey]) }, [siteId, loading, slotFloorMs, data?.run?.id, forecastRefreshKey])
const correctedPvByIso = useMemo(
() => buildCorrectedPvByIso(forecastPvCorrectedRows),
[forecastPvCorrectedRows],
)
const futureSlots = useMemo(() => { const futureSlots = useMemo(() => {
if (!data?.intervals?.length) return [] if (!data?.intervals?.length) return []
return data.intervals return data.intervals
@@ -650,24 +705,19 @@ export default function Planning() {
}, [futureSlots, nowMs, tableHorizonH]) }, [futureSlots, nowMs, tableHorizonH])
const forecastOverlay = useMemo(() => { const forecastOverlay = useMemo(() => {
if (!forecastPvRange.length) return [] as PlanningIntervalDto[] if (!forecastPvCorrectedRows.length) return [] as PlanningIntervalDto[]
const planStarts = new Set(futureSlots.map((s) => s.interval_start)) const planStarts = new Set(futureSlots.map((s) => s.interval_start))
const out: PlanningIntervalDto[] = [] const out: PlanningIntervalDto[] = []
for (const r of forecastPvRange) { for (const r of forecastPvCorrectedRows) {
const iso = typeof r.interval_start === 'string' ? r.interval_start : null const iso = typeof r.interval_start === 'string' ? r.interval_start : null
if (!iso || planStarts.has(iso)) continue if (!iso || planStarts.has(iso)) continue
const pv = r.pv_forecast_total_w const pv = pvDisplayWFromCorrectedRow(r)
out.push( out.push(syntheticForecastOnlyInterval(iso, pv))
syntheticForecastOnlyInterval(
iso,
pv == null || Number.isNaN(Number(pv)) ? null : Number(pv),
),
)
} }
return out return out
}, [forecastPvRange, futureSlots]) }, [forecastPvCorrectedRows, futureSlots])
/** Graf: LP sloty + za horizont plánu řada FVE předpovědi (stejná logika jako JOIN v /plan/current). */ /** Graf: LP sloty + za horizont plánu řada FVE z `pv-slots-corrected` (korig. jako přehled). */
const chartMergedSlots = useMemo(() => { const chartMergedSlots = useMemo(() => {
return [...futureSlots, ...forecastOverlay].sort( return [...futureSlots, ...forecastOverlay].sort(
(a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start), (a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start),
@@ -682,7 +732,10 @@ export default function Planning() {
}) })
}, [chartMergedSlots, nowMs, chartHorizonH, slotFloorMs]) }, [chartMergedSlots, nowMs, chartHorizonH, slotFloorMs])
const planTableRows = useMemo(() => buildPlanTableRows(visibleSlots), [visibleSlots]) const planTableRows = useMemo(
() => buildPlanTableRows(visibleSlots, nowMs, correctedPvByIso),
[visibleSlots, nowMs, correctedPvByIso],
)
const showGenCut = useMemo(() => hasGenCutoff(visibleSlots), [visibleSlots]) const showGenCut = useMemo(() => hasGenCutoff(visibleSlots), [visibleSlots])
const xTicks = useMemo(() => { const xTicks = useMemo(() => {
@@ -705,13 +758,13 @@ export default function Planning() {
return chartIntervals.map((i) => ({ return chartIntervals.map((i) => ({
label: formatLocalTime(i.interval_start), label: formatLocalTime(i.interval_start),
ts: slotStartUtcMs(i.interval_start), ts: slotStartUtcMs(i.interval_start),
pv_a_w: pvAProxyW(i), pv_a_w: pvChartFveW(i, nowMs, correctedPvByIso),
battery_soc_target_pct: i.battery_soc_target_pct, battery_soc_target_pct: i.battery_soc_target_pct,
battery_setpoint_w: i.battery_setpoint_w ?? 0, battery_setpoint_w: i.battery_setpoint_w ?? 0,
effective_buy_price: i.effective_buy_price, effective_buy_price: i.effective_buy_price,
raw: i, raw: i,
})) }))
}, [chartIntervals]) }, [chartIntervals, nowMs, correctedPvByIso])
async function onReplan() { async function onReplan() {
if (siteId == null) return if (siteId == null) return
@@ -827,9 +880,9 @@ export default function Planning() {
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-xl font-semibold tracking-tight text-white">Plánování</h1> <h1 className="text-xl font-semibold tracking-tight text-white">Plánování</h1>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
Aktuální LP plán ({site?.site_name ?? 'lokalita'}) tabulka jen z uloženého horizontu; graf 24 / 48 / 96 h Aktuální LP plán ({site?.site_name ?? 'lokalita'}) tabulka jen z uloženého horizontu; sloupec a křivka FVE
doplňuje za plánem <span className="text-slate-300">FVE předpověď</span> (Open-Meteo), aby šlo vidět počasí používají <span className="text-slate-300">korigovanou předpověď</span> (delta profil, stejně jako přehled a
i mimo optimalizovaný úsek. vstupy solveru). Graf za horizont plánu doplňuje stejnou řadu do 96 h.
</p> </p>
</header> </header>
@@ -991,7 +1044,7 @@ export default function Planning() {
/> />
{!chartRows.length ? ( {!chartRows.length ? (
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
Žádná data pro graf (plán + předpověď FVE do {chartHorizonH} h od aktuálního slotu). Spusťte forecast, pokud Žá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. chybí křivka výroby.
</p> </p>
) : ( ) : (
@@ -1041,7 +1094,7 @@ export default function Planning() {
yAxisId="power" yAxisId="power"
type="monotone" type="monotone"
dataKey="pv_a_w" dataKey="pv_a_w"
name="FVE (A) / předpověď" name="FVE / korig. předpověď"
stroke="#ca8a04" stroke="#ca8a04"
fill="#eab308" fill="#eab308"
fillOpacity={0.35} fillOpacity={0.35}
@@ -1111,7 +1164,10 @@ export default function Planning() {
</th> </th>
) : null} ) : null}
<th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th> <th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">FVE W</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">Dům W</th>
<th <th
className="whitespace-nowrap py-2 pr-2 font-medium" className="whitespace-nowrap py-2 pr-2 font-medium"
@@ -1217,7 +1273,7 @@ export default function Planning() {
? `${i.battery_soc_target_pct.toFixed(1)}` ? `${i.battery_soc_target_pct.toFixed(1)}`
: '—'} : '—'}
</td> </td>
<FveWCell i={i} nowMs={nowMs} /> <FveWCell i={i} nowMs={nowMs} correctedPvByIso={correctedPvByIso} />
<td className="pr-2 font-mono tabular-nums text-slate-300"> <td className="pr-2 font-mono tabular-nums text-slate-300">
{formatPlanPowerW(i.load_baseline_w)} {formatPlanPowerW(i.load_baseline_w)}
</td> </td>