sjednoceni forecastu
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-05 10:42:49 +02:00
parent 459f33d55c
commit 5b383e9028
9 changed files with 461 additions and 253 deletions

View File

@@ -25,8 +25,6 @@ import {
import {
getCurrentPlan,
getForecastPvSlotsRangeCorrected,
type ForecastPvSlotCorrectedRow,
postImportSitePrices,
postRunForecast,
postRunPlan,
@@ -112,7 +110,6 @@ function groupByDay(slots: PlanningIntervalDto[]): Record<string, PlanningInterv
function dayStats(
slots: PlanningIntervalDto[],
nowMs: number,
correctedPvByIso?: Map<string, number>,
): {
fveKwh: number
exportKwh: number
@@ -123,7 +120,7 @@ function dayStats(
let expWh = 0
const buys: number[] = []
for (const s of slots) {
const fveW = slotFveDisplayW(s, nowMs, correctedPvByIso)
const fveW = slotFveDisplayW(s, nowMs)
fveWh += (fveW ?? 0) * slotHours
const gw = s.grid_setpoint_w ?? 0
if (gw < 0) expWh += -gw * slotHours
@@ -149,7 +146,6 @@ type PlanTableRow =
function buildPlanTableRows(
visibleSlots: PlanningIntervalDto[],
nowMs: number,
correctedPvByIso?: Map<string, number>,
): PlanTableRow[] {
const groups = groupByDay(visibleSlots)
const dayKeys = [...new Set(visibleSlots.map((s) => pragueDayKey(s.interval_start)))].sort()
@@ -161,7 +157,7 @@ function buildPlanTableRows(
kind: 'summary',
dayKey: dk,
dateLabel: formatPragueDateLabel(sl[0]!.interval_start),
...dayStats(sl, nowMs, correctedPvByIso),
...dayStats(sl, nowMs),
})
for (const i of sl) rows.push({ kind: 'slot', i })
}
@@ -178,26 +174,6 @@ function horizonToggleClass(active: boolean): string {
: '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
}
/**
* Budoucí slot: `pv_forecast_total_w` z /plan/current je raw z forecast_pv_interval; pro zobrazení
* preferujeme korekci z `pv-slots-corrected` (soulad s LP vstupy a přehledem).
@@ -248,27 +224,19 @@ function pvAProxyW(i: PlanningIntervalDto): number {
}
/** Křivka FVE ve grafu: korig. / audit, jinak stejná cena-proxy jako dřív. */
function pvChartFveW(i: PlanningIntervalDto, nowMs: number, correctedPvByIso?: Map<string, number>): number {
const w = slotFveDisplayW(i, nowMs, correctedPvByIso)
function pvChartFveW(i: PlanningIntervalDto, nowMs: number): number {
const w = slotFveDisplayW(i, nowMs)
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 {
function slotFveDisplayW(i: PlanningIntervalDto, nowMs: number): number | null {
const start = slotStartUtcMs(i.interval_start)
const future = start >= nowMs
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
if (f != null) return Number(f)
return null
return f != null ? Number(f) : null
}
const a = i.pv_power_w
if (a != null) return Number(a)
@@ -291,13 +259,11 @@ function formatPlanPowerW(w: number | null): string {
function FveWCell({
i,
nowMs,
correctedPvByIso,
}: {
i: PlanningIntervalDto
nowMs: number
correctedPvByIso?: Map<string, number>
}) {
const w = slotFveDisplayW(i, nowMs, correctedPvByIso)
const w = slotFveDisplayW(i, nowMs)
const color =
w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500'
return (
@@ -659,7 +625,6 @@ export default function Planning() {
const [selectedStart, setSelectedStart] = useState<string | null>(null)
const [tableHorizonH, setTableHorizonH] = useState<HorizonHours>(48)
const [chartHorizonH, setChartHorizonH] = useState<HorizonHours>(48)
const [forecastPvCorrectedRows, setForecastPvCorrectedRows] = useState<ForecastPvSlotCorrectedRow[]>([])
const [forecastRefreshKey, setForecastRefreshKey] = useState(0)
const load = useCallback(async () => {
@@ -689,27 +654,7 @@ export default function Planning() {
const nowMs = Date.now()
const slotFloorMs = floorSlotUtcMs(nowMs)
useEffect(() => {
if (siteId == null || loading) return
let cancelled = false
const fromIso = new Date(slotFloorMs).toISOString()
const toIso = new Date(slotFloorMs + 96 * 60 * 60 * 1000).toISOString()
void getForecastPvSlotsRangeCorrected(siteId, fromIso, toIso)
.then((rows) => {
if (!cancelled) setForecastPvCorrectedRows(rows)
})
.catch(() => {
if (!cancelled) setForecastPvCorrectedRows([])
})
return () => {
cancelled = true
}
}, [siteId, loading, slotFloorMs, data?.run?.id, forecastRefreshKey])
const correctedPvByIso = useMemo(
() => buildCorrectedPvByIso(forecastPvCorrectedRows),
[forecastPvCorrectedRows],
)
// PV forecast je kanonicky v /plan/current (DB read-model), takže už netaháme separátní pv-slots-corrected.
const futureSlots = useMemo(() => {
if (!data?.intervals?.length) return []
@@ -724,25 +669,10 @@ export default function Planning() {
return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs)
}, [futureSlots, nowMs, tableHorizonH])
const forecastOverlay = useMemo(() => {
if (!forecastPvCorrectedRows.length) return [] as PlanningIntervalDto[]
const planStarts = new Set(futureSlots.map((s) => s.interval_start))
const out: PlanningIntervalDto[] = []
for (const r of forecastPvCorrectedRows) {
const iso = typeof r.interval_start === 'string' ? r.interval_start : null
if (!iso || planStarts.has(iso)) continue
const pv = pvDisplayWFromCorrectedRow(r)
out.push(syntheticForecastOnlyInterval(iso, pv))
}
return out
}, [forecastPvCorrectedRows, futureSlots])
/** Graf: LP sloty + za horizont plánu řada FVE z `pv-slots-corrected` (korig. jako přehled). */
/** Graf: sloty z /plan/current (obsahují i forecast-only řádky za horizontem LP). */
const chartMergedSlots = useMemo(() => {
return [...futureSlots, ...forecastOverlay].sort(
(a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start),
)
}, [futureSlots, forecastOverlay])
return [...futureSlots].sort((a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start))
}, [futureSlots])
const chartIntervals = useMemo(() => {
const endMs = nowMs + chartHorizonH * 60 * 60 * 1000
@@ -753,8 +683,8 @@ export default function Planning() {
}, [chartMergedSlots, nowMs, chartHorizonH, slotFloorMs])
const planTableRows = useMemo(
() => buildPlanTableRows(visibleSlots, nowMs, correctedPvByIso),
[visibleSlots, nowMs, correctedPvByIso],
() => buildPlanTableRows(visibleSlots, nowMs),
[visibleSlots, nowMs],
)
const showGenCut = useMemo(() => hasGenCutoff(visibleSlots), [visibleSlots])
@@ -778,13 +708,13 @@ export default function Planning() {
return chartIntervals.map((i) => ({
label: formatLocalTime(i.interval_start),
ts: slotStartUtcMs(i.interval_start),
pv_a_w: pvChartFveW(i, nowMs, correctedPvByIso),
pv_a_w: pvChartFveW(i, nowMs),
battery_soc_target_pct: i.battery_soc_target_pct,
battery_setpoint_w: i.battery_setpoint_w ?? 0,
effective_buy_price: i.effective_buy_price,
raw: i,
}))
}, [chartIntervals, nowMs, correctedPvByIso])
}, [chartIntervals, nowMs])
async function onReplan() {
if (siteId == null) return
@@ -1293,7 +1223,7 @@ export default function Planning() {
? `${i.battery_soc_target_pct.toFixed(1)}`
: '—'}
</td>
<FveWCell i={i} nowMs={nowMs} correctedPvByIso={correctedPvByIso} />
<FveWCell i={i} nowMs={nowMs} />
<td className="pr-2 font-mono tabular-nums text-slate-300">
{formatPlanPowerW(i.load_baseline_w)}
</td>