fix FE 96h forecast
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 až 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">
|
||||
|
||||
Reference in New Issue
Block a user