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

@@ -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}

View File

@@ -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.';

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">