diff --git a/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx index 9de5098..f2f8106 100644 --- a/frontend/src/pages/Planning.tsx +++ b/frontend/src/pages/Planning.tsx @@ -25,7 +25,8 @@ import { import { getCurrentPlan, - getForecastPvSlotsRange, + getForecastPvSlotsRangeCorrected, + type ForecastPvSlotCorrectedRow, postImportSitePrices, postRunForecast, postRunPlan, @@ -108,7 +109,11 @@ function groupByDay(slots: PlanningIntervalDto[]): Record, +): { fveKwh: number exportKwh: number avgBuy: number | null @@ -118,7 +123,8 @@ function dayStats(slots: PlanningIntervalDto[]): { let expWh = 0 const buys: number[] = [] 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 if (gw < 0) expWh += -gw * slotHours if (s.effective_buy_price != null) buys.push(s.effective_buy_price) @@ -140,7 +146,11 @@ type PlanTableRow = } | { kind: 'slot'; i: PlanningIntervalDto } -function buildPlanTableRows(visibleSlots: PlanningIntervalDto[]): PlanTableRow[] { +function buildPlanTableRows( + visibleSlots: PlanningIntervalDto[], + nowMs: number, + correctedPvByIso?: Map, +): PlanTableRow[] { const groups = groupByDay(visibleSlots) const dayKeys = [...new Set(visibleSlots.map((s) => pragueDayKey(s.interval_start)))].sort() const rows: PlanTableRow[] = [] @@ -151,7 +161,7 @@ function buildPlanTableRows(visibleSlots: PlanningIntervalDto[]): PlanTableRow[] kind: 'summary', dayKey: dk, dateLabel: formatPragueDateLabel(sl[0]!.interval_start), - ...dayStats(sl), + ...dayStats(sl, nowMs, correctedPvByIso), }) 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' } +/** 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 { + const m = new Map() + 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`). - * Pokud je hodnota null (data chybí), použijeme jednoduchou proxy z ceny nákupu (W). - * Čistá nula = platná předpověď „bez výroby“ (např. noc). + * 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). + * 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. */ function isForecastExtensionInterval(i: PlanningIntervalDto): boolean { @@ -215,11 +245,25 @@ function pvAProxyW(i: PlanningIntervalDto): number { 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. */ -function slotFveDisplayW(i: PlanningIntervalDto, nowMs: number): number | null { +/** Křivka FVE ve grafu: korig. / audit, jinak stejná cena-proxy jako dřív. */ +function pvChartFveW(i: PlanningIntervalDto, nowMs: number, correctedPvByIso?: Map): 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, +): 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 @@ -242,8 +286,16 @@ function formatPlanPowerW(w: number | null): string { return String(v) } -function FveWCell({ i, nowMs }: { i: PlanningIntervalDto; nowMs: number }) { - const w = slotFveDisplayW(i, nowMs) +function FveWCell({ + i, + nowMs, + correctedPvByIso, +}: { + i: PlanningIntervalDto + nowMs: number + correctedPvByIso?: Map +}) { + const w = slotFveDisplayW(i, nowMs, correctedPvByIso) const color = w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500' return ( @@ -492,7 +544,7 @@ function PlanTooltip({
{formatLocal(i.interval_start)}
{ext && (
- 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).
)} {pred && ( @@ -503,7 +555,7 @@ function PlanTooltip({ Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '} {sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'} -
FVE (A / předpověď): {fveDisplay}
+
FVE (korig. předpověď / audit): {fveDisplay}
SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}
Dům: {i.load_baseline_w ?? '—'} W
Baterie: {i.battery_setpoint_w ?? '—'} W
@@ -587,9 +639,7 @@ export default function Planning() { const [selectedStart, setSelectedStart] = useState(null) const [tableHorizonH, setTableHorizonH] = useState(48) const [chartHorizonH, setChartHorizonH] = useState(48) - const [forecastPvRange, setForecastPvRange] = useState< - { interval_start: string; pv_forecast_total_w?: number | null }[] - >([]) + const [forecastPvCorrectedRows, setForecastPvCorrectedRows] = useState([]) const [forecastRefreshKey, setForecastRefreshKey] = useState(0) const load = useCallback(async () => { @@ -624,18 +674,23 @@ export default function Planning() { let cancelled = false const fromIso = new Date(slotFloorMs).toISOString() const toIso = new Date(slotFloorMs + 96 * 60 * 60 * 1000).toISOString() - void getForecastPvSlotsRange(siteId, fromIso, toIso) + void getForecastPvSlotsRangeCorrected(siteId, fromIso, toIso) .then((rows) => { - if (!cancelled) setForecastPvRange(rows) + if (!cancelled) setForecastPvCorrectedRows(rows) }) .catch(() => { - if (!cancelled) setForecastPvRange([]) + if (!cancelled) setForecastPvCorrectedRows([]) }) return () => { cancelled = true } }, [siteId, loading, slotFloorMs, data?.run?.id, forecastRefreshKey]) + const correctedPvByIso = useMemo( + () => buildCorrectedPvByIso(forecastPvCorrectedRows), + [forecastPvCorrectedRows], + ) + const futureSlots = useMemo(() => { if (!data?.intervals?.length) return [] return data.intervals @@ -650,24 +705,19 @@ export default function Planning() { }, [futureSlots, nowMs, tableHorizonH]) 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 out: PlanningIntervalDto[] = [] - for (const r of forecastPvRange) { + for (const r of forecastPvCorrectedRows) { const iso = typeof r.interval_start === 'string' ? r.interval_start : null if (!iso || planStarts.has(iso)) continue - const pv = r.pv_forecast_total_w - out.push( - syntheticForecastOnlyInterval( - iso, - pv == null || Number.isNaN(Number(pv)) ? null : Number(pv), - ), - ) + const pv = pvDisplayWFromCorrectedRow(r) + out.push(syntheticForecastOnlyInterval(iso, pv)) } 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(() => { return [...futureSlots, ...forecastOverlay].sort( (a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start), @@ -682,7 +732,10 @@ export default function Planning() { }) }, [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 xTicks = useMemo(() => { @@ -705,13 +758,13 @@ export default function Planning() { return chartIntervals.map((i) => ({ label: formatLocalTime(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_setpoint_w: i.battery_setpoint_w ?? 0, effective_buy_price: i.effective_buy_price, raw: i, })) - }, [chartIntervals]) + }, [chartIntervals, nowMs, correctedPvByIso]) async function onReplan() { if (siteId == null) return @@ -827,9 +880,9 @@ export default function Planning() {

Plánování

- Aktuální LP plán ({site?.site_name ?? 'lokalita'}) — tabulka jen z uloženého horizontu; graf 24 / 48 / 96 h - doplňuje za plánem FVE předpověď (Open-Meteo), aby šlo vidět počasí - i mimo optimalizovaný úsek. + Aktuální LP plán ({site?.site_name ?? 'lokalita'}) — tabulka jen z uloženého horizontu; sloupec a křivka FVE + používají korigovanou předpověď (delta profil, stejně jako přehled a + vstupy solveru). Graf za horizont plánu doplňuje stejnou řadu až do 96 h.

@@ -991,7 +1044,7 @@ export default function Planning() { /> {!chartRows.length ? (

- Žá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.

) : ( @@ -1041,7 +1094,7 @@ export default function Planning() { yAxisId="power" type="monotone" dataKey="pv_a_w" - name="FVE (A) / předpověď" + name="FVE / korig. předpověď" stroke="#ca8a04" fill="#eab308" fillOpacity={0.35} @@ -1111,7 +1164,10 @@ export default function Planning() { ) : null} SoC % - FVE W + + FVE W + delta · audit + Dům W - + {formatPlanPowerW(i.load_baseline_w)}