prepnuti k planovani na kkorigovany forecast
This commit is contained in:
@@ -25,7 +25,8 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getCurrentPlan,
|
getCurrentPlan,
|
||||||
getForecastPvSlotsRange,
|
getForecastPvSlotsRangeCorrected,
|
||||||
|
type ForecastPvSlotCorrectedRow,
|
||||||
postImportSitePrices,
|
postImportSitePrices,
|
||||||
postRunForecast,
|
postRunForecast,
|
||||||
postRunPlan,
|
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
|
fveKwh: number
|
||||||
exportKwh: number
|
exportKwh: number
|
||||||
avgBuy: number | null
|
avgBuy: number | null
|
||||||
@@ -118,7 +123,8 @@ function dayStats(slots: PlanningIntervalDto[]): {
|
|||||||
let expWh = 0
|
let expWh = 0
|
||||||
const buys: number[] = []
|
const buys: number[] = []
|
||||||
for (const s of slots) {
|
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
|
const gw = s.grid_setpoint_w ?? 0
|
||||||
if (gw < 0) expWh += -gw * slotHours
|
if (gw < 0) expWh += -gw * slotHours
|
||||||
if (s.effective_buy_price != null) buys.push(s.effective_buy_price)
|
if (s.effective_buy_price != null) buys.push(s.effective_buy_price)
|
||||||
@@ -140,7 +146,11 @@ type PlanTableRow =
|
|||||||
}
|
}
|
||||||
| { kind: 'slot'; i: PlanningIntervalDto }
|
| { 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 groups = groupByDay(visibleSlots)
|
||||||
const dayKeys = [...new Set(visibleSlots.map((s) => pragueDayKey(s.interval_start)))].sort()
|
const dayKeys = [...new Set(visibleSlots.map((s) => pragueDayKey(s.interval_start)))].sort()
|
||||||
const rows: PlanTableRow[] = []
|
const rows: PlanTableRow[] = []
|
||||||
@@ -151,7 +161,7 @@ function buildPlanTableRows(visibleSlots: PlanningIntervalDto[]): PlanTableRow[]
|
|||||||
kind: 'summary',
|
kind: 'summary',
|
||||||
dayKey: dk,
|
dayKey: dk,
|
||||||
dateLabel: formatPragueDateLabel(sl[0]!.interval_start),
|
dateLabel: formatPragueDateLabel(sl[0]!.interval_start),
|
||||||
...dayStats(sl),
|
...dayStats(sl, nowMs, correctedPvByIso),
|
||||||
})
|
})
|
||||||
for (const i of sl) rows.push({ kind: 'slot', i })
|
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'
|
: '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`).
|
* Budoucí slot: `pv_forecast_total_w` z /plan/current je raw z forecast_pv_interval; pro zobrazení
|
||||||
* Pokud je hodnota null (data chybí), použijeme jednoduchou proxy z ceny nákupu (W).
|
* preferujeme korekci z `pv-slots-corrected` (soulad s LP vstupy a přehledem).
|
||||||
* Čistá nula = platná předpověď „bez výroby“ (např. noc).
|
* 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. */
|
/** Slot jen z řady forecast (za horizontem planning_interval) — doplnění grafu. */
|
||||||
function isForecastExtensionInterval(i: PlanningIntervalDto): boolean {
|
function isForecastExtensionInterval(i: PlanningIntervalDto): boolean {
|
||||||
@@ -215,11 +245,25 @@ function pvAProxyW(i: PlanningIntervalDto): number {
|
|||||||
return Math.max(0, Math.min(15000, w))
|
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. */
|
/** Křivka FVE ve grafu: korig. / audit, jinak stejná cena-proxy jako dřív. */
|
||||||
function slotFveDisplayW(i: PlanningIntervalDto, nowMs: number): number | null {
|
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 start = slotStartUtcMs(i.interval_start)
|
||||||
const future = start >= nowMs
|
const future = start >= nowMs
|
||||||
if (future) {
|
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
|
const f = i.pv_forecast_total_w
|
||||||
if (f != null) return Number(f)
|
if (f != null) return Number(f)
|
||||||
return null
|
return null
|
||||||
@@ -242,8 +286,16 @@ function formatPlanPowerW(w: number | null): string {
|
|||||||
return String(v)
|
return String(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FveWCell({ i, nowMs }: { i: PlanningIntervalDto; nowMs: number }) {
|
function FveWCell({
|
||||||
const w = slotFveDisplayW(i, nowMs)
|
i,
|
||||||
|
nowMs,
|
||||||
|
correctedPvByIso,
|
||||||
|
}: {
|
||||||
|
i: PlanningIntervalDto
|
||||||
|
nowMs: number
|
||||||
|
correctedPvByIso?: Map<string, number>
|
||||||
|
}) {
|
||||||
|
const w = slotFveDisplayW(i, nowMs, correctedPvByIso)
|
||||||
const color =
|
const color =
|
||||||
w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500'
|
w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500'
|
||||||
return (
|
return (
|
||||||
@@ -492,7 +544,7 @@ function PlanTooltip({
|
|||||||
<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 && (
|
{ext && (
|
||||||
<div className="mb-1 text-[10px] font-sans font-normal normal-case text-amber-200/90">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pred && (
|
{pred && (
|
||||||
@@ -503,7 +555,7 @@ function PlanTooltip({
|
|||||||
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '}
|
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '}
|
||||||
{sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'}
|
{sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'}
|
||||||
</div>
|
</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>SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}</div>
|
||||||
<div>Dům: {i.load_baseline_w ?? '—'} W</div>
|
<div>Dům: {i.load_baseline_w ?? '—'} W</div>
|
||||||
<div>Baterie: {i.battery_setpoint_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 [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<
|
const [forecastPvCorrectedRows, setForecastPvCorrectedRows] = useState<ForecastPvSlotCorrectedRow[]>([])
|
||||||
{ interval_start: string; pv_forecast_total_w?: number | null }[]
|
|
||||||
>([])
|
|
||||||
const [forecastRefreshKey, setForecastRefreshKey] = useState(0)
|
const [forecastRefreshKey, setForecastRefreshKey] = useState(0)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -624,18 +674,23 @@ export default function Planning() {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
const fromIso = new Date(slotFloorMs).toISOString()
|
const fromIso = new Date(slotFloorMs).toISOString()
|
||||||
const toIso = new Date(slotFloorMs + 96 * 60 * 60 * 1000).toISOString()
|
const toIso = new Date(slotFloorMs + 96 * 60 * 60 * 1000).toISOString()
|
||||||
void getForecastPvSlotsRange(siteId, fromIso, toIso)
|
void getForecastPvSlotsRangeCorrected(siteId, fromIso, toIso)
|
||||||
.then((rows) => {
|
.then((rows) => {
|
||||||
if (!cancelled) setForecastPvRange(rows)
|
if (!cancelled) setForecastPvCorrectedRows(rows)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setForecastPvRange([])
|
if (!cancelled) setForecastPvCorrectedRows([])
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [siteId, loading, slotFloorMs, data?.run?.id, forecastRefreshKey])
|
}, [siteId, loading, slotFloorMs, data?.run?.id, forecastRefreshKey])
|
||||||
|
|
||||||
|
const correctedPvByIso = useMemo(
|
||||||
|
() => buildCorrectedPvByIso(forecastPvCorrectedRows),
|
||||||
|
[forecastPvCorrectedRows],
|
||||||
|
)
|
||||||
|
|
||||||
const futureSlots = useMemo(() => {
|
const futureSlots = useMemo(() => {
|
||||||
if (!data?.intervals?.length) return []
|
if (!data?.intervals?.length) return []
|
||||||
return data.intervals
|
return data.intervals
|
||||||
@@ -650,24 +705,19 @@ export default function Planning() {
|
|||||||
}, [futureSlots, nowMs, tableHorizonH])
|
}, [futureSlots, nowMs, tableHorizonH])
|
||||||
|
|
||||||
const forecastOverlay = useMemo(() => {
|
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 planStarts = new Set(futureSlots.map((s) => s.interval_start))
|
||||||
const out: PlanningIntervalDto[] = []
|
const out: PlanningIntervalDto[] = []
|
||||||
for (const r of forecastPvRange) {
|
for (const r of forecastPvCorrectedRows) {
|
||||||
const iso = typeof r.interval_start === 'string' ? r.interval_start : null
|
const iso = typeof r.interval_start === 'string' ? r.interval_start : null
|
||||||
if (!iso || planStarts.has(iso)) continue
|
if (!iso || planStarts.has(iso)) continue
|
||||||
const pv = r.pv_forecast_total_w
|
const pv = pvDisplayWFromCorrectedRow(r)
|
||||||
out.push(
|
out.push(syntheticForecastOnlyInterval(iso, pv))
|
||||||
syntheticForecastOnlyInterval(
|
|
||||||
iso,
|
|
||||||
pv == null || Number.isNaN(Number(pv)) ? null : Number(pv),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return out
|
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(() => {
|
const chartMergedSlots = useMemo(() => {
|
||||||
return [...futureSlots, ...forecastOverlay].sort(
|
return [...futureSlots, ...forecastOverlay].sort(
|
||||||
(a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start),
|
(a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start),
|
||||||
@@ -682,7 +732,10 @@ export default function Planning() {
|
|||||||
})
|
})
|
||||||
}, [chartMergedSlots, nowMs, chartHorizonH, slotFloorMs])
|
}, [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 showGenCut = useMemo(() => hasGenCutoff(visibleSlots), [visibleSlots])
|
||||||
|
|
||||||
const xTicks = useMemo(() => {
|
const xTicks = useMemo(() => {
|
||||||
@@ -705,13 +758,13 @@ export default function Planning() {
|
|||||||
return chartIntervals.map((i) => ({
|
return chartIntervals.map((i) => ({
|
||||||
label: formatLocalTime(i.interval_start),
|
label: formatLocalTime(i.interval_start),
|
||||||
ts: slotStartUtcMs(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_soc_target_pct: i.battery_soc_target_pct,
|
||||||
battery_setpoint_w: i.battery_setpoint_w ?? 0,
|
battery_setpoint_w: i.battery_setpoint_w ?? 0,
|
||||||
effective_buy_price: i.effective_buy_price,
|
effective_buy_price: i.effective_buy_price,
|
||||||
raw: i,
|
raw: i,
|
||||||
}))
|
}))
|
||||||
}, [chartIntervals])
|
}, [chartIntervals, nowMs, correctedPvByIso])
|
||||||
|
|
||||||
async function onReplan() {
|
async function onReplan() {
|
||||||
if (siteId == null) return
|
if (siteId == null) return
|
||||||
@@ -827,9 +880,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 ({site?.site_name ?? 'lokalita'}) — tabulka jen z uloženého horizontu; graf 24 / 48 / 96 h
|
Aktuální LP plán ({site?.site_name ?? 'lokalita'}) — tabulka jen z uloženého horizontu; sloupec a křivka FVE
|
||||||
doplňuje za plánem <span className="text-slate-300">FVE předpověď</span> (Open-Meteo), aby šlo vidět počasí
|
používají <span className="text-slate-300">korigovanou předpověď</span> (delta profil, stejně jako přehled a
|
||||||
i mimo optimalizovaný úsek.
|
vstupy solveru). Graf za horizont plánu doplňuje stejnou řadu až do 96 h.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -991,7 +1044,7 @@ export default function Planning() {
|
|||||||
/>
|
/>
|
||||||
{!chartRows.length ? (
|
{!chartRows.length ? (
|
||||||
<p className="text-sm text-slate-500">
|
<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.
|
chybí křivka výroby.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -1041,7 +1094,7 @@ export default function Planning() {
|
|||||||
yAxisId="power"
|
yAxisId="power"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="pv_a_w"
|
dataKey="pv_a_w"
|
||||||
name="FVE (A) / předpověď"
|
name="FVE / korig. předpověď"
|
||||||
stroke="#ca8a04"
|
stroke="#ca8a04"
|
||||||
fill="#eab308"
|
fill="#eab308"
|
||||||
fillOpacity={0.35}
|
fillOpacity={0.35}
|
||||||
@@ -1111,7 +1164,10 @@ export default function Planning() {
|
|||||||
</th>
|
</th>
|
||||||
) : null}
|
) : null}
|
||||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th>
|
<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">Dům W</th>
|
||||||
<th
|
<th
|
||||||
className="whitespace-nowrap py-2 pr-2 font-medium"
|
className="whitespace-nowrap py-2 pr-2 font-medium"
|
||||||
@@ -1217,7 +1273,7 @@ export default function Planning() {
|
|||||||
? `${i.battery_soc_target_pct.toFixed(1)}`
|
? `${i.battery_soc_target_pct.toFixed(1)}`
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
<FveWCell i={i} nowMs={nowMs} />
|
<FveWCell i={i} nowMs={nowMs} correctedPvByIso={correctedPvByIso} />
|
||||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||||
{formatPlanPowerW(i.load_baseline_w)}
|
{formatPlanPowerW(i.load_baseline_w)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user