sjednoceni forecastu
This commit is contained in:
@@ -25,8 +25,6 @@ import {
|
||||
|
||||
import {
|
||||
getCurrentPlan,
|
||||
getForecastPvSlotsRangeCorrected,
|
||||
type ForecastPvSlotCorrectedRow,
|
||||
postImportSitePrices,
|
||||
postRunForecast,
|
||||
postRunPlan,
|
||||
@@ -112,7 +110,6 @@ function groupByDay(slots: PlanningIntervalDto[]): Record<string, PlanningInterv
|
||||
function dayStats(
|
||||
slots: PlanningIntervalDto[],
|
||||
nowMs: number,
|
||||
correctedPvByIso?: Map<string, number>,
|
||||
): {
|
||||
fveKwh: number
|
||||
exportKwh: number
|
||||
@@ -123,7 +120,7 @@ function dayStats(
|
||||
let expWh = 0
|
||||
const buys: number[] = []
|
||||
for (const s of slots) {
|
||||
const fveW = slotFveDisplayW(s, nowMs, correctedPvByIso)
|
||||
const fveW = slotFveDisplayW(s, nowMs)
|
||||
fveWh += (fveW ?? 0) * slotHours
|
||||
const gw = s.grid_setpoint_w ?? 0
|
||||
if (gw < 0) expWh += -gw * slotHours
|
||||
@@ -149,7 +146,6 @@ type PlanTableRow =
|
||||
function buildPlanTableRows(
|
||||
visibleSlots: PlanningIntervalDto[],
|
||||
nowMs: number,
|
||||
correctedPvByIso?: Map<string, number>,
|
||||
): PlanTableRow[] {
|
||||
const groups = groupByDay(visibleSlots)
|
||||
const dayKeys = [...new Set(visibleSlots.map((s) => pragueDayKey(s.interval_start)))].sort()
|
||||
@@ -161,7 +157,7 @@ function buildPlanTableRows(
|
||||
kind: 'summary',
|
||||
dayKey: dk,
|
||||
dateLabel: formatPragueDateLabel(sl[0]!.interval_start),
|
||||
...dayStats(sl, nowMs, correctedPvByIso),
|
||||
...dayStats(sl, nowMs),
|
||||
})
|
||||
for (const i of sl) rows.push({ kind: 'slot', i })
|
||||
}
|
||||
@@ -178,26 +174,6 @@ function horizonToggleClass(active: boolean): string {
|
||||
: 'border-slate-600 bg-slate-800/80 text-slate-300 hover:bg-slate-800'
|
||||
}
|
||||
|
||||
/** Stejná logika jako přehled: korigovaný výkon z delty, jinak raw součet z API řádku. */
|
||||
function pvDisplayWFromCorrectedRow(r: ForecastPvSlotCorrectedRow): number | null {
|
||||
const c = r.pv_forecast_corrected_w
|
||||
if (c != null && Number.isFinite(Number(c))) return Number(c)
|
||||
const raw = r.pv_forecast_total_w
|
||||
if (raw != null && Number.isFinite(Number(raw))) return Number(raw)
|
||||
return null
|
||||
}
|
||||
|
||||
function buildCorrectedPvByIso(rows: ForecastPvSlotCorrectedRow[]): Map<string, number> {
|
||||
const m = new Map<string, number>()
|
||||
for (const r of rows) {
|
||||
const iso = typeof r.interval_start === 'string' ? r.interval_start : null
|
||||
if (!iso) continue
|
||||
const v = pvDisplayWFromCorrectedRow(r)
|
||||
if (v != null && Number.isFinite(v)) m.set(iso, v)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* Budoucí slot: `pv_forecast_total_w` z /plan/current je raw z forecast_pv_interval; pro zobrazení
|
||||
* preferujeme korekci z `pv-slots-corrected` (soulad s LP vstupy a přehledem).
|
||||
@@ -248,27 +224,19 @@ function pvAProxyW(i: PlanningIntervalDto): number {
|
||||
}
|
||||
|
||||
/** Křivka FVE ve grafu: korig. / audit, jinak stejná cena-proxy jako dřív. */
|
||||
function pvChartFveW(i: PlanningIntervalDto, nowMs: number, correctedPvByIso?: Map<string, number>): number {
|
||||
const w = slotFveDisplayW(i, nowMs, correctedPvByIso)
|
||||
function pvChartFveW(i: PlanningIntervalDto, nowMs: number): number {
|
||||
const w = slotFveDisplayW(i, nowMs)
|
||||
if (w != null && Number.isFinite(w)) return w
|
||||
return pvAProxyW(i)
|
||||
}
|
||||
|
||||
/** Budoucí slot (od začátku ještě nenastal): korig. předpověď; proběhlý / probíhající: telemetrie z auditu. */
|
||||
function slotFveDisplayW(
|
||||
i: PlanningIntervalDto,
|
||||
nowMs: number,
|
||||
correctedPvByIso?: Map<string, number>,
|
||||
): number | null {
|
||||
function slotFveDisplayW(i: PlanningIntervalDto, nowMs: number): number | null {
|
||||
const start = slotStartUtcMs(i.interval_start)
|
||||
const future = start >= nowMs
|
||||
if (future) {
|
||||
const iso = i.interval_start
|
||||
const corr = correctedPvByIso?.get(iso)
|
||||
if (corr !== undefined && Number.isFinite(corr)) return corr
|
||||
const f = i.pv_forecast_total_w
|
||||
if (f != null) return Number(f)
|
||||
return null
|
||||
return f != null ? Number(f) : null
|
||||
}
|
||||
const a = i.pv_power_w
|
||||
if (a != null) return Number(a)
|
||||
@@ -291,13 +259,11 @@ function formatPlanPowerW(w: number | null): string {
|
||||
function FveWCell({
|
||||
i,
|
||||
nowMs,
|
||||
correctedPvByIso,
|
||||
}: {
|
||||
i: PlanningIntervalDto
|
||||
nowMs: number
|
||||
correctedPvByIso?: Map<string, number>
|
||||
}) {
|
||||
const w = slotFveDisplayW(i, nowMs, correctedPvByIso)
|
||||
const w = slotFveDisplayW(i, nowMs)
|
||||
const color =
|
||||
w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500'
|
||||
return (
|
||||
@@ -659,7 +625,6 @@ export default function Planning() {
|
||||
const [selectedStart, setSelectedStart] = useState<string | null>(null)
|
||||
const [tableHorizonH, setTableHorizonH] = useState<HorizonHours>(48)
|
||||
const [chartHorizonH, setChartHorizonH] = useState<HorizonHours>(48)
|
||||
const [forecastPvCorrectedRows, setForecastPvCorrectedRows] = useState<ForecastPvSlotCorrectedRow[]>([])
|
||||
const [forecastRefreshKey, setForecastRefreshKey] = useState(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -689,27 +654,7 @@ 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 getForecastPvSlotsRangeCorrected(siteId, fromIso, toIso)
|
||||
.then((rows) => {
|
||||
if (!cancelled) setForecastPvCorrectedRows(rows)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setForecastPvCorrectedRows([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [siteId, loading, slotFloorMs, data?.run?.id, forecastRefreshKey])
|
||||
|
||||
const correctedPvByIso = useMemo(
|
||||
() => buildCorrectedPvByIso(forecastPvCorrectedRows),
|
||||
[forecastPvCorrectedRows],
|
||||
)
|
||||
// PV forecast je kanonicky v /plan/current (DB read-model), takže už netaháme separátní pv-slots-corrected.
|
||||
|
||||
const futureSlots = useMemo(() => {
|
||||
if (!data?.intervals?.length) return []
|
||||
@@ -724,25 +669,10 @@ export default function Planning() {
|
||||
return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs)
|
||||
}, [futureSlots, nowMs, tableHorizonH])
|
||||
|
||||
const forecastOverlay = useMemo(() => {
|
||||
if (!forecastPvCorrectedRows.length) return [] as PlanningIntervalDto[]
|
||||
const planStarts = new Set(futureSlots.map((s) => s.interval_start))
|
||||
const out: PlanningIntervalDto[] = []
|
||||
for (const r of forecastPvCorrectedRows) {
|
||||
const iso = typeof r.interval_start === 'string' ? r.interval_start : null
|
||||
if (!iso || planStarts.has(iso)) continue
|
||||
const pv = pvDisplayWFromCorrectedRow(r)
|
||||
out.push(syntheticForecastOnlyInterval(iso, pv))
|
||||
}
|
||||
return out
|
||||
}, [forecastPvCorrectedRows, futureSlots])
|
||||
|
||||
/** Graf: LP sloty + za horizont plánu řada FVE z `pv-slots-corrected` (korig. jako přehled). */
|
||||
/** Graf: sloty z /plan/current (obsahují i forecast-only řádky za horizontem LP). */
|
||||
const chartMergedSlots = useMemo(() => {
|
||||
return [...futureSlots, ...forecastOverlay].sort(
|
||||
(a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start),
|
||||
)
|
||||
}, [futureSlots, forecastOverlay])
|
||||
return [...futureSlots].sort((a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start))
|
||||
}, [futureSlots])
|
||||
|
||||
const chartIntervals = useMemo(() => {
|
||||
const endMs = nowMs + chartHorizonH * 60 * 60 * 1000
|
||||
@@ -753,8 +683,8 @@ export default function Planning() {
|
||||
}, [chartMergedSlots, nowMs, chartHorizonH, slotFloorMs])
|
||||
|
||||
const planTableRows = useMemo(
|
||||
() => buildPlanTableRows(visibleSlots, nowMs, correctedPvByIso),
|
||||
[visibleSlots, nowMs, correctedPvByIso],
|
||||
() => buildPlanTableRows(visibleSlots, nowMs),
|
||||
[visibleSlots, nowMs],
|
||||
)
|
||||
const showGenCut = useMemo(() => hasGenCutoff(visibleSlots), [visibleSlots])
|
||||
|
||||
@@ -778,13 +708,13 @@ export default function Planning() {
|
||||
return chartIntervals.map((i) => ({
|
||||
label: formatLocalTime(i.interval_start),
|
||||
ts: slotStartUtcMs(i.interval_start),
|
||||
pv_a_w: pvChartFveW(i, nowMs, correctedPvByIso),
|
||||
pv_a_w: pvChartFveW(i, nowMs),
|
||||
battery_soc_target_pct: i.battery_soc_target_pct,
|
||||
battery_setpoint_w: i.battery_setpoint_w ?? 0,
|
||||
effective_buy_price: i.effective_buy_price,
|
||||
raw: i,
|
||||
}))
|
||||
}, [chartIntervals, nowMs, correctedPvByIso])
|
||||
}, [chartIntervals, nowMs])
|
||||
|
||||
async function onReplan() {
|
||||
if (siteId == null) return
|
||||
@@ -1293,7 +1223,7 @@ export default function Planning() {
|
||||
? `${i.battery_soc_target_pct.toFixed(1)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<FveWCell i={i} nowMs={nowMs} correctedPvByIso={correctedPvByIso} />
|
||||
<FveWCell i={i} nowMs={nowMs} />
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
{formatPlanPowerW(i.load_baseline_w)}
|
||||
</td>
|
||||
|
||||
@@ -35,6 +35,9 @@ export type PlanningIntervalDto = {
|
||||
/** True pokud cena pro slot byla při plánování predikovaná (DB sloupec `is_predicted_price`). */
|
||||
is_predicted_price: boolean
|
||||
pv_forecast_total_w: number | null
|
||||
/** Kanonický PV forecast použitý solverem (A/B). */
|
||||
pv_a_forecast_solver_w?: number | null
|
||||
pv_b_forecast_solver_w?: number | null
|
||||
/** Průměrná skutečná FVE výkon za slot z audit_interval (GET /plan/current JOIN). */
|
||||
pv_power_w?: number | null
|
||||
load_baseline_w: number | null
|
||||
|
||||
Reference in New Issue
Block a user