From ee4355f17f7979c97c426a37ec2877a42cf6b512 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 19 Apr 2026 21:40:55 +0200 Subject: [PATCH] fix FE 96h forecast --- backend/app/routers/sites.py | 41 +++++++ .../R__075_fn_forecast_pv_slots_range.sql | 67 ++++++++++ frontend/src/api/backend.ts | 18 +++ frontend/src/pages/Planning.tsx | 115 ++++++++++++++++-- 4 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 db/routines/R__075_fn_forecast_pv_slots_range.sql diff --git a/backend/app/routers/sites.py b/backend/app/routers/sites.py index 093c5c1..91db177 100644 --- a/backend/app/routers/sites.py +++ b/backend/app/routers/sites.py @@ -481,3 +481,44 @@ async def get_site_forecast_pv( if not isinstance(pv_b, list): pv_b = [] return {"pv_a": pv_a, "pv_b": pv_b} + + +@router.get("/{site_id}/forecast/pv-slots") +async def get_site_forecast_pv_slots_range( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pg_pool)], + from_ts: datetime = Query( + ..., + alias="from", + description="Začátek okna [from, to), typicky UTC zaokrouhlené na 15 min", + ), + to_ts: datetime = Query( + ..., + alias="to", + description="Konec polouzavřeného intervalu (max. cca 120 h za from)", + ), +) -> dict[str, list[dict[str, Any]]]: + if to_ts <= from_ts: + raise HTTPException(status_code=422, detail="'to' must be after 'from'") + if to_ts - from_ts > timedelta(hours=120): + raise HTTPException( + status_code=422, + detail="Span between 'from' and 'to' must be at most 120 hours", + ) + async with db.acquire() as conn: + site_ok = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id + ) + if not site_ok: + raise HTTPException(status_code=404, detail="Site not found") + raw = await fetch_json( + conn, + "select ems.fn_forecast_pv_slots_range($1::int, $2::timestamptz, $3::timestamptz)", + site_id, + from_ts, + to_ts, + ) + slots = raw if isinstance(raw, list) else [] + if not isinstance(slots, list): + slots = [] + return {"slots": slots} diff --git a/db/routines/R__075_fn_forecast_pv_slots_range.sql b/db/routines/R__075_fn_forecast_pv_slots_range.sql new file mode 100644 index 0000000..2ae7ab0 --- /dev/null +++ b/db/routines/R__075_fn_forecast_pv_slots_range.sql @@ -0,0 +1,67 @@ +-- 15min sloty součtu FVE výkonů z posledních OK forecast runů (stejná logika jako fc_slot v fn_plan_current_bundle). + +create or replace function ems.fn_forecast_pv_slots_range( + p_site_id int, + p_from timestamptz, + p_to timestamptz +) +returns jsonb +language sql +stable +as $fn$ + with bounds as ( + select + p_from as ts_from, + case + when p_to <= p_from then p_from + interval '15 minutes' + when p_to > p_from + interval '120 hours' then p_from + interval '120 hours' + else p_to + end as ts_to + ), + slot_spine as ( + select gs as interval_start + from bounds b, + generate_series( + b.ts_from, + (b.ts_to - interval '15 minutes')::timestamptz, + interval '15 minutes' + ) as gs + ), + fc as ( + select + u.interval_start, + coalesce(sum(u.power_w), 0)::bigint as pv_forecast_total_w + from ( + select distinct on (fpi.interval_start, fpr.pv_array_id) + fpi.interval_start, + fpi.power_w + from ems.forecast_pv_interval fpi + join ems.forecast_pv_run fpr on fpr.id = fpi.run_id + join ems.asset_pv_array apa + on apa.id = fpr.pv_array_id + and apa.site_id = fpr.site_id + cross join bounds b + where fpr.site_id = p_site_id + and fpr.status = 'ok' + and fpi.interval_start >= b.ts_from + and fpi.interval_start < b.ts_to + order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc + ) u + group by u.interval_start + ) + select coalesce( + jsonb_agg( + jsonb_build_object( + 'interval_start', s.interval_start, + 'pv_forecast_total_w', fc.pv_forecast_total_w + ) + order by s.interval_start + ), + '[]'::jsonb + ) + from slot_spine s + left join fc on fc.interval_start = s.interval_start; +$fn$; + +comment on function ems.fn_forecast_pv_slots_range(int, timestamptz, timestamptz) is + 'JSON pole {interval_start, pv_forecast_total_w} po 15 min pro [p_from, p_to); doplnění grafu plánu za hranicí planning_interval.'; diff --git a/frontend/src/api/backend.ts b/frontend/src/api/backend.ts index be89dca..ff2d47d 100644 --- a/frontend/src/api/backend.ts +++ b/frontend/src/api/backend.ts @@ -99,6 +99,24 @@ export async function getCurrentPlan(siteId: number): Promise { + 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 diff --git a/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx index aa6d386..4119c3d 100644 --- a/frontend/src/pages/Planning.tsx +++ b/frontend/src/pages/Planning.tsx @@ -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 (
{formatLocal(i.interval_start)}
+ {ext && ( +
+ Mimo uložený horizont plánu — jen předpověď FVE (Open-Meteo). +
+ )} {pred && (
Cena: odhad (predikce)
)} @@ -485,6 +528,10 @@ 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 [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() {

Plánování

- 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 FVE předpověď (Open-Meteo), aby šlo vidět počasí + i mimo optimalizovaný úsek.

@@ -828,10 +924,15 @@ export default function Planning() { {/* Sekce 2 */}

Graf plánu

- + {!chartRows.length ? (

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

) : (