prepnuti k planovani na kkorigovany forecast
This commit is contained in:
@@ -25,7 +25,8 @@ import {
|
||||
|
||||
import {
|
||||
getCurrentPlan,
|
||||
getForecastPvSlotsRange,
|
||||
getForecastPvSlotsRangeCorrected,
|
||||
type ForecastPvSlotCorrectedRow,
|
||||
postImportSitePrices,
|
||||
postRunForecast,
|
||||
postRunPlan,
|
||||
@@ -108,7 +109,11 @@ function groupByDay(slots: PlanningIntervalDto[]): Record<string, PlanningInterv
|
||||
)
|
||||
}
|
||||
|
||||
function dayStats(slots: PlanningIntervalDto[]): {
|
||||
function dayStats(
|
||||
slots: PlanningIntervalDto[],
|
||||
nowMs: number,
|
||||
correctedPvByIso?: Map<string, number>,
|
||||
): {
|
||||
fveKwh: number
|
||||
exportKwh: number
|
||||
avgBuy: number | null
|
||||
@@ -118,7 +123,8 @@ function dayStats(slots: PlanningIntervalDto[]): {
|
||||
let expWh = 0
|
||||
const buys: number[] = []
|
||||
for (const s of slots) {
|
||||
fveWh += (s.pv_forecast_total_w ?? 0) * slotHours
|
||||
const fveW = slotFveDisplayW(s, nowMs, correctedPvByIso)
|
||||
fveWh += (fveW ?? 0) * slotHours
|
||||
const gw = s.grid_setpoint_w ?? 0
|
||||
if (gw < 0) expWh += -gw * slotHours
|
||||
if (s.effective_buy_price != null) buys.push(s.effective_buy_price)
|
||||
@@ -140,7 +146,11 @@ type PlanTableRow =
|
||||
}
|
||||
| { kind: 'slot'; i: PlanningIntervalDto }
|
||||
|
||||
function buildPlanTableRows(visibleSlots: PlanningIntervalDto[]): 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()
|
||||
const rows: PlanTableRow[] = []
|
||||
@@ -151,7 +161,7 @@ function buildPlanTableRows(visibleSlots: PlanningIntervalDto[]): PlanTableRow[]
|
||||
kind: 'summary',
|
||||
dayKey: dk,
|
||||
dateLabel: formatPragueDateLabel(sl[0]!.interval_start),
|
||||
...dayStats(sl),
|
||||
...dayStats(sl, nowMs, correctedPvByIso),
|
||||
})
|
||||
for (const i of sl) rows.push({ kind: 'slot', i })
|
||||
}
|
||||
@@ -168,10 +178,30 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Vizuál FVE: API posílá součet A+B (`pv_forecast_total_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).
|
||||
* 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).
|
||||
* Pokud je hodnota null (data chybí), proxy z ceny nákupu (W) jen u grafu přes pvAProxyW.
|
||||
*/
|
||||
/** Slot jen z řady forecast (za horizontem planning_interval) — doplnění grafu. */
|
||||
function isForecastExtensionInterval(i: PlanningIntervalDto): boolean {
|
||||
@@ -215,11 +245,25 @@ function pvAProxyW(i: PlanningIntervalDto): number {
|
||||
return Math.max(0, Math.min(15000, w))
|
||||
}
|
||||
|
||||
/** Budoucí slot (od začátku ještě nenastal): předpověď; proběhlý / probíhající: telemetrie z auditu. */
|
||||
function slotFveDisplayW(i: PlanningIntervalDto, nowMs: number): number | null {
|
||||
/** 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)
|
||||
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 {
|
||||
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
|
||||
@@ -242,8 +286,16 @@ function formatPlanPowerW(w: number | null): string {
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function FveWCell({ i, nowMs }: { i: PlanningIntervalDto; nowMs: number }) {
|
||||
const w = slotFveDisplayW(i, nowMs)
|
||||
function FveWCell({
|
||||
i,
|
||||
nowMs,
|
||||
correctedPvByIso,
|
||||
}: {
|
||||
i: PlanningIntervalDto
|
||||
nowMs: number
|
||||
correctedPvByIso?: Map<string, number>
|
||||
}) {
|
||||
const w = slotFveDisplayW(i, nowMs, correctedPvByIso)
|
||||
const color =
|
||||
w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500'
|
||||
return (
|
||||
@@ -492,7 +544,7 @@ function PlanTooltip({
|
||||
<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).
|
||||
Mimo uložený horizont plánu — jen předpověď FVE (po korekci delty, stejně jako v přehledu).
|
||||
</div>
|
||||
)}
|
||||
{pred && (
|
||||
@@ -503,7 +555,7 @@ function PlanTooltip({
|
||||
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '}
|
||||
{sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'}
|
||||
</div>
|
||||
<div>FVE (A / předpověď): {fveDisplay}</div>
|
||||
<div>FVE (korig. předpověď / audit): {fveDisplay}</div>
|
||||
<div>SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}</div>
|
||||
<div>Dům: {i.load_baseline_w ?? '—'} W</div>
|
||||
<div>Baterie: {i.battery_setpoint_w ?? '—'} W</div>
|
||||
@@ -587,9 +639,7 @@ 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 [forecastPvCorrectedRows, setForecastPvCorrectedRows] = useState<ForecastPvSlotCorrectedRow[]>([])
|
||||
const [forecastRefreshKey, setForecastRefreshKey] = useState(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -624,18 +674,23 @@ export default function Planning() {
|
||||
let cancelled = false
|
||||
const fromIso = new Date(slotFloorMs).toISOString()
|
||||
const toIso = new Date(slotFloorMs + 96 * 60 * 60 * 1000).toISOString()
|
||||
void getForecastPvSlotsRange(siteId, fromIso, toIso)
|
||||
void getForecastPvSlotsRangeCorrected(siteId, fromIso, toIso)
|
||||
.then((rows) => {
|
||||
if (!cancelled) setForecastPvRange(rows)
|
||||
if (!cancelled) setForecastPvCorrectedRows(rows)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setForecastPvRange([])
|
||||
if (!cancelled) setForecastPvCorrectedRows([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [siteId, loading, slotFloorMs, data?.run?.id, forecastRefreshKey])
|
||||
|
||||
const correctedPvByIso = useMemo(
|
||||
() => buildCorrectedPvByIso(forecastPvCorrectedRows),
|
||||
[forecastPvCorrectedRows],
|
||||
)
|
||||
|
||||
const futureSlots = useMemo(() => {
|
||||
if (!data?.intervals?.length) return []
|
||||
return data.intervals
|
||||
@@ -650,24 +705,19 @@ export default function Planning() {
|
||||
}, [futureSlots, nowMs, tableHorizonH])
|
||||
|
||||
const forecastOverlay = useMemo(() => {
|
||||
if (!forecastPvRange.length) return [] as PlanningIntervalDto[]
|
||||
if (!forecastPvCorrectedRows.length) return [] as PlanningIntervalDto[]
|
||||
const planStarts = new Set(futureSlots.map((s) => s.interval_start))
|
||||
const out: PlanningIntervalDto[] = []
|
||||
for (const r of forecastPvRange) {
|
||||
for (const r of forecastPvCorrectedRows) {
|
||||
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),
|
||||
),
|
||||
)
|
||||
const pv = pvDisplayWFromCorrectedRow(r)
|
||||
out.push(syntheticForecastOnlyInterval(iso, pv))
|
||||
}
|
||||
return out
|
||||
}, [forecastPvRange, futureSlots])
|
||||
}, [forecastPvCorrectedRows, futureSlots])
|
||||
|
||||
/** Graf: LP sloty + za horizont plánu řada FVE předpovědi (stejná logika jako JOIN v /plan/current). */
|
||||
/** Graf: LP sloty + za horizont plánu řada FVE z `pv-slots-corrected` (korig. jako přehled). */
|
||||
const chartMergedSlots = useMemo(() => {
|
||||
return [...futureSlots, ...forecastOverlay].sort(
|
||||
(a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start),
|
||||
@@ -682,7 +732,10 @@ export default function Planning() {
|
||||
})
|
||||
}, [chartMergedSlots, nowMs, chartHorizonH, slotFloorMs])
|
||||
|
||||
const planTableRows = useMemo(() => buildPlanTableRows(visibleSlots), [visibleSlots])
|
||||
const planTableRows = useMemo(
|
||||
() => buildPlanTableRows(visibleSlots, nowMs, correctedPvByIso),
|
||||
[visibleSlots, nowMs, correctedPvByIso],
|
||||
)
|
||||
const showGenCut = useMemo(() => hasGenCutoff(visibleSlots), [visibleSlots])
|
||||
|
||||
const xTicks = useMemo(() => {
|
||||
@@ -705,13 +758,13 @@ export default function Planning() {
|
||||
return chartIntervals.map((i) => ({
|
||||
label: formatLocalTime(i.interval_start),
|
||||
ts: slotStartUtcMs(i.interval_start),
|
||||
pv_a_w: pvAProxyW(i),
|
||||
pv_a_w: pvChartFveW(i, nowMs, correctedPvByIso),
|
||||
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])
|
||||
}, [chartIntervals, nowMs, correctedPvByIso])
|
||||
|
||||
async function onReplan() {
|
||||
if (siteId == null) return
|
||||
@@ -827,9 +880,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 ({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.
|
||||
Aktuální LP plán ({site?.site_name ?? 'lokalita'}) — tabulka jen z uloženého horizontu; sloupec a křivka FVE
|
||||
používají <span className="text-slate-300">korigovanou předpověď</span> (delta profil, stejně jako přehled a
|
||||
vstupy solveru). Graf za horizont plánu doplňuje stejnou řadu až do 96 h.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -991,7 +1044,7 @@ export default function Planning() {
|
||||
/>
|
||||
{!chartRows.length ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
Žádná data pro graf (plán + předpověď FVE do {chartHorizonH} h od aktuálního slotu). Spusťte forecast, pokud
|
||||
Žádná data pro graf (plán + korig. FVE do {chartHorizonH} h od aktuálního slotu). Spusťte forecast, pokud
|
||||
chybí křivka výroby.
|
||||
</p>
|
||||
) : (
|
||||
@@ -1041,7 +1094,7 @@ export default function Planning() {
|
||||
yAxisId="power"
|
||||
type="monotone"
|
||||
dataKey="pv_a_w"
|
||||
name="FVE (A) / předpověď"
|
||||
name="FVE / korig. předpověď"
|
||||
stroke="#ca8a04"
|
||||
fill="#eab308"
|
||||
fillOpacity={0.35}
|
||||
@@ -1111,7 +1164,10 @@ export default function Planning() {
|
||||
</th>
|
||||
) : null}
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">FVE W</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium" title="Budoucí sloty: korigovaná předpověď z delta profilu; proběhlé sloty z auditu.">
|
||||
<span className="block">FVE W</span>
|
||||
<span className="block text-[10px] font-normal normal-case text-slate-600">delta · audit</span>
|
||||
</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Dům W</th>
|
||||
<th
|
||||
className="whitespace-nowrap py-2 pr-2 font-medium"
|
||||
@@ -1217,7 +1273,7 @@ export default function Planning() {
|
||||
? `${i.battery_soc_target_pct.toFixed(1)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<FveWCell i={i} nowMs={nowMs} />
|
||||
<FveWCell i={i} nowMs={nowMs} correctedPvByIso={correctedPvByIso} />
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
{formatPlanPowerW(i.load_baseline_w)}
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user