fix FE 96h forecast
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-19 21:40:55 +02:00
parent 70d306961a
commit ee4355f17f
4 changed files with 234 additions and 7 deletions

View File

@@ -99,6 +99,24 @@ export async function getCurrentPlan(siteId: number): Promise<CurrentPlanRespons
return data
}
/** Řada FVE předpovědi (součet polí) po 15 min — doplnění grafu za horizont uloženého plánu. */
export type ForecastPvSlotRow = {
interval_start: string
pv_forecast_total_w?: number | null
}
export async function getForecastPvSlotsRange(
siteId: number,
fromIso: string,
toIso: string,
): Promise<ForecastPvSlotRow[]> {
const { data } = await client.get<{ slots?: ForecastPvSlotRow[] }>(
`/sites/${siteId}/forecast/pv-slots`,
{ params: { from: fromIso, to: toIso }, timeout: 45_000 },
)
return Array.isArray(data?.slots) ? data.slots : []
}
/** GET /api/v1/sites/{id}/prices?date=YYYY-MM-DD */
export type SiteEffectivePriceRowDto = {
site_id: number

View File

@@ -23,7 +23,13 @@ import {
YAxis,
} from 'recharts'
import { getCurrentPlan, postImportSitePrices, postRunForecast, postRunPlan } from '../api/backend'
import {
getCurrentPlan,
getForecastPvSlotsRange,
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'
@@ -163,6 +169,37 @@ function horizonToggleClass(active: boolean): string {
* 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).
*/
/** Slot jen z řady forecast (za horizontem planning_interval) — doplnění grafu. */
function isForecastExtensionInterval(i: PlanningIntervalDto): boolean {
return (
i.battery_setpoint_w == null &&
i.grid_setpoint_w == null &&
i.expected_cost_czk == null
)
}
function syntheticForecastOnlyInterval(
interval_start: string,
pv_forecast_total_w: number | null,
): PlanningIntervalDto {
return {
interval_start,
battery_setpoint_w: null,
battery_soc_target_pct: null,
grid_setpoint_w: null,
ev1_setpoint_w: null,
ev2_setpoint_w: null,
heat_pump_enabled: null,
pv_a_curtailed_w: null,
expected_cost_czk: null,
effective_buy_price: null,
effective_sell_price: null,
is_predicted_price: false,
pv_forecast_total_w,
load_baseline_w: null,
}
}
function pvAProxyW(i: PlanningIntervalDto): number {
const pv = i.pv_forecast_total_w
if (pv != null && pv > 0) return pv
@@ -384,6 +421,7 @@ function PlanTooltip({
if (!active || !payload?.length) return null
const p = payload[0].payload
const i = p.raw
const ext = isForecastExtensionInterval(i)
const buy = i.effective_buy_price
const sell = i.effective_sell_price
const pred = isPredictedPriceSlot(i, nowMs)
@@ -393,6 +431,11 @@ function PlanTooltip({
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>
{ext && (
<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).
</div>
)}
{pred && (
<div className="mb-1 text-[10px] uppercase tracking-wide text-slate-500">Cena: odhad (predikce)</div>
)}
@@ -485,6 +528,10 @@ export default function Planning() {
const [selectedStart, setSelectedStart] = useState<string | null>(null)
const [tableHorizonH, setTableHorizonH] = useState<HorizonHours>(48)
const [chartHorizonH, setChartHorizonH] = useState<HorizonHours>(48)
const [forecastPvRange, setForecastPvRange] = useState<
{ interval_start: string; pv_forecast_total_w?: number | null }[]
>([])
const [forecastRefreshKey, setForecastRefreshKey] = useState(0)
const load = useCallback(async () => {
if (siteId == null) return
@@ -513,6 +560,23 @@ 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 getForecastPvSlotsRange(siteId, fromIso, toIso)
.then((rows) => {
if (!cancelled) setForecastPvRange(rows)
})
.catch(() => {
if (!cancelled) setForecastPvRange([])
})
return () => {
cancelled = true
}
}, [siteId, loading, slotFloorMs, data?.run?.id, forecastRefreshKey])
const futureSlots = useMemo(() => {
if (!data?.intervals?.length) return []
return data.intervals
@@ -526,10 +590,38 @@ export default function Planning() {
return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs)
}, [futureSlots, nowMs, tableHorizonH])
const forecastOverlay = useMemo(() => {
if (!forecastPvRange.length) return [] as PlanningIntervalDto[]
const planStarts = new Set(futureSlots.map((s) => s.interval_start))
const out: PlanningIntervalDto[] = []
for (const r of forecastPvRange) {
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),
),
)
}
return out
}, [forecastPvRange, futureSlots])
/** Graf: LP sloty + za horizont plánu řada FVE předpovědi (stejná logika jako JOIN v /plan/current). */
const chartMergedSlots = useMemo(() => {
return [...futureSlots, ...forecastOverlay].sort(
(a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start),
)
}, [futureSlots, forecastOverlay])
const chartIntervals = useMemo(() => {
const endMs = nowMs + chartHorizonH * 60 * 60 * 1000
return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs)
}, [futureSlots, nowMs, chartHorizonH])
return chartMergedSlots.filter((s) => {
const t = slotStartUtcMs(s.interval_start)
return t >= slotFloorMs && t <= endMs
})
}, [chartMergedSlots, nowMs, chartHorizonH, slotFloorMs])
const planTableRows = useMemo(() => buildPlanTableRows(visibleSlots), [visibleSlots])
@@ -568,6 +660,7 @@ export default function Planning() {
try {
await postRunPlan(siteId, 'rolling')
await load()
setForecastRefreshKey((k) => k + 1)
} catch (e) {
setError(axiosDetail(e) || 'Přepočet selhal')
} finally {
@@ -609,6 +702,7 @@ export default function Planning() {
try {
const r = await postRunForecast(siteId)
toast.success(`Forecast: ${r.intervals_saved} intervalů, ${r.pv_arrays} FVE polí`)
setForecastRefreshKey((k) => k + 1)
await runRollingReload()
} catch (e) {
toast.error('Forecast selhal', { description: axiosDetail(e) })
@@ -632,6 +726,7 @@ export default function Planning() {
)
const fc = await postRunForecast(siteId)
toast.success(`Forecast: ${fc.intervals_saved} intervalů, ${fc.pv_arrays} FVE polí`)
setForecastRefreshKey((k) => k + 1)
await runRollingReload()
toast.success('Plán přepočítán (rolling).')
} catch (e) {
@@ -672,8 +767,9 @@ export default function Planning() {
<header className="space-y-1">
<h1 className="text-xl font-semibold tracking-tight text-white">Plánování</h1>
<p className="text-sm text-slate-400">
Aktuální LP plán 96 h od aktuálního slotu ({site?.site_name ?? 'lokalita'}) tabulka a graf lze zúžit
horizontem 24 / 48 / 96 h.
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 <span className="text-slate-300">FVE předpověď</span> (Open-Meteo), aby šlo vidět počasí
i mimo optimalizovaný úsek.
</p>
</header>
@@ -828,10 +924,15 @@ export default function Planning() {
{/* Sekce 2 */}
<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 value={chartHorizonH} onChange={setChartHorizonH} disabled={futureSlots.length === 0} />
<HorizonToggle
value={chartHorizonH}
onChange={setChartHorizonH}
disabled={chartMergedSlots.length === 0}
/>
{!chartRows.length ? (
<p className="text-sm text-slate-500">
Žádná data pro graf (budoucí sloty aktivního plánu, horizont {chartHorizonH} h).
Žádná data pro graf (plán + předpověď FVE do {chartHorizonH} h od aktuálního slotu). Spusťte forecast, pokud
chybí křivka výroby.
</p>
) : (
<div className="h-[350px] w-full">