fix FE 96h forecast
This commit is contained in:
@@ -481,3 +481,44 @@ async def get_site_forecast_pv(
|
|||||||
if not isinstance(pv_b, list):
|
if not isinstance(pv_b, list):
|
||||||
pv_b = []
|
pv_b = []
|
||||||
return {"pv_a": pv_a, "pv_b": 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}
|
||||||
|
|||||||
67
db/routines/R__075_fn_forecast_pv_slots_range.sql
Normal file
67
db/routines/R__075_fn_forecast_pv_slots_range.sql
Normal 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.';
|
||||||
@@ -99,6 +99,24 @@ export async function getCurrentPlan(siteId: number): Promise<CurrentPlanRespons
|
|||||||
return data
|
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 */
|
/** GET /api/v1/sites/{id}/prices?date=YYYY-MM-DD */
|
||||||
export type SiteEffectivePriceRowDto = {
|
export type SiteEffectivePriceRowDto = {
|
||||||
site_id: number
|
site_id: number
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts'
|
} 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 { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants'
|
||||||
import { useSiteStatus } from '../hooks/useSiteStatus'
|
import { useSiteStatus } from '../hooks/useSiteStatus'
|
||||||
import type { CurrentPlanResponse, PlanningIntervalDto } from '../types/plan'
|
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).
|
* 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).
|
* Č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 {
|
function pvAProxyW(i: PlanningIntervalDto): number {
|
||||||
const pv = i.pv_forecast_total_w
|
const pv = i.pv_forecast_total_w
|
||||||
if (pv != null && pv > 0) return pv
|
if (pv != null && pv > 0) return pv
|
||||||
@@ -384,6 +421,7 @@ function PlanTooltip({
|
|||||||
if (!active || !payload?.length) return null
|
if (!active || !payload?.length) return null
|
||||||
const p = payload[0].payload
|
const p = payload[0].payload
|
||||||
const i = p.raw
|
const i = p.raw
|
||||||
|
const ext = isForecastExtensionInterval(i)
|
||||||
const buy = i.effective_buy_price
|
const buy = i.effective_buy_price
|
||||||
const sell = i.effective_sell_price
|
const sell = i.effective_sell_price
|
||||||
const pred = isPredictedPriceSlot(i, nowMs)
|
const pred = isPredictedPriceSlot(i, nowMs)
|
||||||
@@ -393,6 +431,11 @@ function PlanTooltip({
|
|||||||
return (
|
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="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>
|
<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 && (
|
{pred && (
|
||||||
<div className="mb-1 text-[10px] uppercase tracking-wide text-slate-500">Cena: odhad (predikce)</div>
|
<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 [selectedStart, setSelectedStart] = useState<string | null>(null)
|
||||||
const [tableHorizonH, setTableHorizonH] = useState<HorizonHours>(48)
|
const [tableHorizonH, setTableHorizonH] = useState<HorizonHours>(48)
|
||||||
const [chartHorizonH, setChartHorizonH] = 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 () => {
|
const load = useCallback(async () => {
|
||||||
if (siteId == null) return
|
if (siteId == null) return
|
||||||
@@ -513,6 +560,23 @@ export default function Planning() {
|
|||||||
const nowMs = Date.now()
|
const nowMs = Date.now()
|
||||||
const slotFloorMs = floorSlotUtcMs(nowMs)
|
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(() => {
|
const futureSlots = useMemo(() => {
|
||||||
if (!data?.intervals?.length) return []
|
if (!data?.intervals?.length) return []
|
||||||
return data.intervals
|
return data.intervals
|
||||||
@@ -526,10 +590,38 @@ export default function Planning() {
|
|||||||
return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs)
|
return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs)
|
||||||
}, [futureSlots, nowMs, tableHorizonH])
|
}, [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 chartIntervals = useMemo(() => {
|
||||||
const endMs = nowMs + chartHorizonH * 60 * 60 * 1000
|
const endMs = nowMs + chartHorizonH * 60 * 60 * 1000
|
||||||
return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs)
|
return chartMergedSlots.filter((s) => {
|
||||||
}, [futureSlots, nowMs, chartHorizonH])
|
const t = slotStartUtcMs(s.interval_start)
|
||||||
|
return t >= slotFloorMs && t <= endMs
|
||||||
|
})
|
||||||
|
}, [chartMergedSlots, nowMs, chartHorizonH, slotFloorMs])
|
||||||
|
|
||||||
const planTableRows = useMemo(() => buildPlanTableRows(visibleSlots), [visibleSlots])
|
const planTableRows = useMemo(() => buildPlanTableRows(visibleSlots), [visibleSlots])
|
||||||
|
|
||||||
@@ -568,6 +660,7 @@ export default function Planning() {
|
|||||||
try {
|
try {
|
||||||
await postRunPlan(siteId, 'rolling')
|
await postRunPlan(siteId, 'rolling')
|
||||||
await load()
|
await load()
|
||||||
|
setForecastRefreshKey((k) => k + 1)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(axiosDetail(e) || 'Přepočet selhal')
|
setError(axiosDetail(e) || 'Přepočet selhal')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -609,6 +702,7 @@ export default function Planning() {
|
|||||||
try {
|
try {
|
||||||
const r = await postRunForecast(siteId)
|
const r = await postRunForecast(siteId)
|
||||||
toast.success(`Forecast: ${r.intervals_saved} intervalů, ${r.pv_arrays} FVE polí`)
|
toast.success(`Forecast: ${r.intervals_saved} intervalů, ${r.pv_arrays} FVE polí`)
|
||||||
|
setForecastRefreshKey((k) => k + 1)
|
||||||
await runRollingReload()
|
await runRollingReload()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('Forecast selhal', { description: axiosDetail(e) })
|
toast.error('Forecast selhal', { description: axiosDetail(e) })
|
||||||
@@ -632,6 +726,7 @@ export default function Planning() {
|
|||||||
)
|
)
|
||||||
const fc = await postRunForecast(siteId)
|
const fc = await postRunForecast(siteId)
|
||||||
toast.success(`Forecast: ${fc.intervals_saved} intervalů, ${fc.pv_arrays} FVE polí`)
|
toast.success(`Forecast: ${fc.intervals_saved} intervalů, ${fc.pv_arrays} FVE polí`)
|
||||||
|
setForecastRefreshKey((k) => k + 1)
|
||||||
await runRollingReload()
|
await runRollingReload()
|
||||||
toast.success('Plán přepočítán (rolling).')
|
toast.success('Plán přepočítán (rolling).')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -672,8 +767,9 @@ export default function Planning() {
|
|||||||
<header className="space-y-1">
|
<header className="space-y-1">
|
||||||
<h1 className="text-xl font-semibold tracking-tight text-white">Plánování</h1>
|
<h1 className="text-xl font-semibold tracking-tight text-white">Plánování</h1>
|
||||||
<p className="text-sm text-slate-400">
|
<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
|
Aktuální LP plán ({site?.site_name ?? 'lokalita'}) — tabulka jen z uloženého horizontu; graf 24 / 48 / 96 h
|
||||||
horizontem 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>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -828,10 +924,15 @@ export default function Planning() {
|
|||||||
{/* Sekce 2 */}
|
{/* Sekce 2 */}
|
||||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
<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>
|
<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 ? (
|
{!chartRows.length ? (
|
||||||
<p className="text-sm text-slate-500">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[350px] w-full">
|
<div className="h-[350px] w-full">
|
||||||
|
|||||||
Reference in New Issue
Block a user