Files
ems/frontend/src/hooks/useDashboardData.ts
2026-06-11 14:23:23 +02:00

547 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
getCurrentPlan,
getForecastPvSlotsRangeCorrected,
getSitePricesSlotsRange,
} from '../api/backend'
import { getJson } from '../api/postgrest'
import {
currentSlotIndexInWindow,
SLOT_COUNT_BACK,
SLOT_MS,
TOTAL_SLOTS,
floorSlotUtcMs,
} from '../components/charts/chartConstants'
import { pragueAddCalendarDays, pragueCalendarDay } from '../lib/pragueDate'
import type { ForecastDayTotal, LiveMetrics, NegPriceItem, SlotData } from '../types/dashboard'
import type {
AuditTodayHourlyRow,
HeatPumpLatestRow,
ModeLogRecentRow,
SiteStatusRow,
Telemetry15m7dRow,
} from '../types/ems'
import type { PlanningIntervalDto } from '../types/plan'
const POLL_FULL_MS = 60_000
const POLL_LIVE_MS = 15_000
/** Limit řádků vw_telemetry_15m_7d: jen okno zpět (s rezervou), ne celých 7 dní. */
const TELEMETRY_15M_LIMIT = String(Math.ceil(SLOT_COUNT_BACK * 1.2))
function parseNum(v: string | number | null | undefined): number | null {
if (v == null) return null
if (typeof v === 'number' && !Number.isNaN(v)) return v
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function numFromWs(v: unknown): number | null {
if (v == null) return null
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? n : null
}
function buildLiveMetrics(
status: SiteStatusRow | null,
_hp: HeatPumpLatestRow | null,
): LiveMetrics | null {
if (status == null) return null
return {
pv_w: parseNum(status.pv_power_w),
load_w: parseNum(status.load_power_w),
grid_w: parseNum(status.grid_power_w),
bat_soc: parseNum(status.battery_soc_percent),
bat_w: parseNum(status.battery_power_w),
}
}
/** Klíč hodiny v Europe/Prague (pro shodu s vw_audit_today_hourly.hour_local). */
function pragueHourKey(ms: number): string {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Prague',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
hour12: false,
}).format(new Date(ms))
}
function slotTimeKey(ms: number): string {
return String(floorSlotUtcMs(ms))
}
function modeAt(logs: ModeLogRecentRow[], tMs: number): string | null {
let best: ModeLogRecentRow | null = null
let bestA = -Infinity
for (const l of logs) {
const a = new Date(l.activated_at).getTime()
const d = l.deactivated_at ? new Date(l.deactivated_at).getTime() : Number.POSITIVE_INFINITY
if (a <= tMs && tMs < d && a >= bestA) {
bestA = a
best = l
}
}
return best?.mode_code ?? null
}
function emptySlot(iso: string): SlotData {
return {
interval_start: iso,
pv_power_w: null,
battery_power_w: null,
battery_setpoint_w: null,
grid_power_w: null,
grid_setpoint_w: null,
deye_physical_mode: null,
export_mode: null,
export_limit_w: null,
load_power_w: null,
gen_port_power_w: null,
pv_a_forecast_w: null,
pv_b_forecast_w: null,
load_baseline_w: null,
ev1_setpoint_w: null,
ev2_setpoint_w: null,
heat_pump_setpoint_w: null,
heat_pump_enabled: null,
battery_soc_target_pct: null,
buy_price: null,
sell_price: null,
regime_code: null,
regime_is_planned: false,
soc_actual_pct: null,
soc_plan_pct: null,
tuv_actual_c: null,
tuv_plan_c: null,
}
}
function mergeInterval(s: SlotData, p: PlanningIntervalDto): void {
s.battery_setpoint_w = p.battery_setpoint_w ?? s.battery_setpoint_w
s.grid_setpoint_w = p.grid_setpoint_w ?? s.grid_setpoint_w
if (p.deye_physical_mode != null) s.deye_physical_mode = p.deye_physical_mode
if (p.export_mode != null) s.export_mode = p.export_mode
if (p.export_limit_w != null) s.export_limit_w = p.export_limit_w
s.ev1_setpoint_w = p.ev1_setpoint_w ?? s.ev1_setpoint_w
s.ev2_setpoint_w = p.ev2_setpoint_w ?? s.ev2_setpoint_w
if (s.ev1_setpoint_w == null && s.ev2_setpoint_w == null && p.ev_charge_power_w != null) {
s.ev1_setpoint_w = p.ev_charge_power_w
}
if (p.heat_pump_enabled === true) {
s.heat_pump_enabled = true
} else if (p.heat_pump_enabled === false) {
s.heat_pump_enabled = false
}
if (p.heat_pump_setpoint_w != null) {
s.heat_pump_setpoint_w = p.heat_pump_setpoint_w
if (s.heat_pump_enabled == null) {
s.heat_pump_enabled = p.heat_pump_setpoint_w > 0
}
} else if (p.heat_pump_enabled === false) {
s.heat_pump_setpoint_w = 0
s.heat_pump_enabled = false
}
s.load_baseline_w = p.load_baseline_w ?? s.load_baseline_w
s.buy_price = parseNum(p.effective_buy_price) ?? s.buy_price
s.sell_price = parseNum(p.effective_sell_price) ?? s.sell_price
const tgtSoc = parseNum(p.battery_soc_target_pct)
if (tgtSoc != null) {
s.battery_soc_target_pct = tgtSoc
s.soc_plan_pct = tgtSoc
}
const pva = p.pv_forecast_total_w != null ? Math.round(Number(p.pv_forecast_total_w) * 0.6) : null
const pvb = p.pv_forecast_total_w != null ? Math.round(Number(p.pv_forecast_total_w) * 0.4) : null
if (s.pv_a_forecast_w == null && pva != null) s.pv_a_forecast_w = pva
if (s.pv_b_forecast_w == null && pvb != null) s.pv_b_forecast_w = pvb
if (p.heat_pump_setpoint_w != null && p.heat_pump_setpoint_w > 0) {
s.tuv_plan_c = 52
}
}
export function useDashboardData(siteId: number | null) {
const [slots, setSlots] = useState<SlotData[]>([])
const [liveMetrics, setLiveMetrics] = useState<LiveMetrics | null>(null)
const [forecastWeek, setForecastWeek] = useState<ForecastDayTotal[]>([])
const [negPrices, setNegPrices] = useState<NegPriceItem[]>([])
const [error, setError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const [slotsReady, setSlotsReady] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const siteIdRef = useRef(siteId)
siteIdRef.current = siteId
const load = useCallback(async () => {
if (siteId == null) {
setSlots([])
setForecastWeek([])
setNegPrices([])
setLiveMetrics(null)
setError(null)
setReady(true)
setSlotsReady(true)
return
}
const windowStart = floorSlotUtcMs(Date.now()) - SLOT_COUNT_BACK * SLOT_MS
const nIdx = currentSlotIndexInWindow(windowStart)
// Vlna 1 — kritická: vw_site_status (+ TČ) je rychlé, UI se odemkne hned.
let status: SiteStatusRow | null = null
try {
const [statusArr, hpArr] = await Promise.all([
getJson<SiteStatusRow[]>('/vw_site_status', { site_id: `eq.${siteId}` }),
getJson<HeatPumpLatestRow[]>('/vw_latest_heat_pump', { site_id: `eq.${siteId}` }),
])
status = Array.isArray(statusArr) && statusArr[0] ? statusArr[0]! : null
const hp = Array.isArray(hpArr) && hpArr[0] ? hpArr[0]! : null
setLiveMetrics(buildLiveMetrics(status, hp))
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Chyba načítání dashboardu')
} finally {
setReady(true)
}
// Vlna 2 — extended: plán, telemetrie, audit, ceny. Při refetchi zůstávají
// zobrazená stale data (sloty se přepíšou až novými daty, žádné blikání).
try {
const todayPrague = pragueCalendarDay()
const [
planMaybe,
telemetry15m7d,
auditHourly,
modeLog,
priceRows,
] = await Promise.all([
getCurrentPlan(siteId).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) {
return { run: null, intervals: [] as PlanningIntervalDto[], summary: null }
}
throw e
}),
getJson<Telemetry15m7dRow[]>('/vw_telemetry_15m_7d', {
site_id: `eq.${siteId}`,
slot_start: `gte.${new Date(windowStart).toISOString()}`,
order: 'slot_start.asc',
limit: TELEMETRY_15M_LIMIT,
}),
getJson<AuditTodayHourlyRow[]>('/vw_audit_today_hourly', {
site_id: `eq.${siteId}`,
order: 'hour_local.asc',
}),
getJson<ModeLogRecentRow[]>('/vw_mode_log_recent', {
site_id: `eq.${siteId}`,
order: 'activated_at.asc',
limit: '200',
}),
// Ceny bereme přes FastAPI range endpoint (PostgREST /rest je u vás chráněné → 401).
getSitePricesSlotsRange(
siteId,
new Date(windowStart).toISOString(),
new Date(windowStart + TOTAL_SLOTS * SLOT_MS).toISOString(),
),
])
const plan = planMaybe as { intervals: PlanningIntervalDto[] }
const planBySlot = new Map<string, PlanningIntervalDto>()
for (const iv of plan.intervals) {
planBySlot.set(slotTimeKey(new Date(iv.interval_start).getTime()), iv)
}
const priceBySlot = new Map<string, { buy: number | null; sell: number | null }>()
const flatPrices = Array.isArray(priceRows) ? priceRows : []
for (const r of flatPrices) {
const k = slotTimeKey(new Date(r.interval_start).getTime())
priceBySlot.set(k, {
buy: parseNum(r.effective_buy_price_czk_kwh),
sell: parseNum(r.effective_sell_price_czk_kwh),
})
}
const forecastDays: ForecastDayTotal[] = []
const weekDates = Array.from({ length: 7 }, (_, d) => pragueAddCalendarDays(todayPrague, d))
// Forecast pro dashboard bereme pouze z range endpointu (jedno volání místo N× /forecast/pv per den).
// Rozsah musí pokrýt (a) okno slotů pro tabulku a (b) 7denní sumáře.
const windowFromIso = new Date(windowStart).toISOString()
const windowToIso = new Date(windowStart + TOTAL_SLOTS * SLOT_MS).toISOString()
const weekToIso = new Date(floorSlotUtcMs(Date.now()) + 7 * 24 * 60 * 60 * 1000).toISOString()
const forecastToIso = weekToIso > windowToIso ? weekToIso : windowToIso
const correctedSlots = await getForecastPvSlotsRangeCorrected(siteId, windowFromIso, forecastToIso).catch(
() => [] as Awaited<ReturnType<typeof getForecastPvSlotsRangeCorrected>>,
)
const correctedBySlot = new Map<string, number>()
for (const r of correctedSlots) {
const t = new Date(r.interval_start).getTime()
if (!Number.isFinite(t)) continue
const v = r.pv_forecast_corrected_w
if (v == null) continue
correctedBySlot.set(slotTimeKey(t), Number(v))
}
for (const ymd of weekDates) {
let kwh = 0
// Křivka je po 15 min, takže energie = W * 0.25h
// Použijeme correctedBySlot v časovém okně daného prague dne.
const dayStart = new Date(ymd + 'T00:00:00').getTime()
const dayEnd = new Date(pragueAddCalendarDays(ymd, 1) + 'T00:00:00').getTime()
for (let t = dayStart; t < dayEnd; t += SLOT_MS) {
const w = correctedBySlot.get(slotTimeKey(t)) ?? 0
kwh += (w * 0.25) / 1000
}
const label = new Date(ymd + 'T12:00:00Z').toLocaleDateString('cs-CZ', {
weekday: 'short',
day: 'numeric',
month: 'numeric',
timeZone: 'Europe/Prague',
})
forecastDays.push({ date: ymd, label, kwh: Math.round(kwh * 10) / 10 })
}
setForecastWeek(forecastDays)
const telemetryBySlot = new Map<string, Telemetry15m7dRow>()
if (Array.isArray(telemetry15m7d)) {
for (const r of telemetry15m7d) {
telemetryBySlot.set(slotTimeKey(new Date(r.slot_start).getTime()), r)
}
}
const auditMap = new Map<string, AuditTodayHourlyRow>()
if (Array.isArray(auditHourly)) {
for (const r of auditHourly) {
auditMap.set(pragueHourKey(new Date(r.hour_local).getTime()), r)
}
}
const logs = Array.isArray(modeLog) ? modeLog : []
const activeMode = status?.active_mode ?? 'AUTO'
const built: SlotData[] = []
for (let i = 0; i < TOTAL_SLOTS; i++) {
const startMs = windowStart + i * SLOT_MS
const iso = new Date(startMs).toISOString()
const base = emptySlot(iso)
const k = slotTimeKey(startMs)
const tel = telemetryBySlot.get(k)
if (tel) {
base.pv_power_w = tel.avg_pv_w ?? base.pv_power_w
base.battery_power_w = tel.avg_battery_w ?? base.battery_power_w
base.grid_power_w = tel.avg_grid_w ?? base.grid_power_w
base.load_power_w = tel.avg_load_w ?? base.load_power_w
base.soc_actual_pct = parseNum(tel.last_soc_pct) ?? base.soc_actual_pct
}
const aud = auditMap.get(pragueHourKey(startMs))
if (aud) {
if (base.pv_power_w == null && aud.avg_pv_kw != null) {
base.pv_power_w = Math.round((parseNum(aud.avg_pv_kw) ?? 0) * 1000)
}
if (base.load_power_w == null && aud.avg_load_kw != null) {
base.load_power_w = Math.round((parseNum(aud.avg_load_kw) ?? 0) * 1000)
}
if (base.battery_power_w == null && aud.avg_battery_kw != null) {
base.battery_power_w = Math.round((parseNum(aud.avg_battery_kw) ?? 0) * 1000)
}
if (base.grid_power_w == null && aud.avg_grid_kw != null) {
base.grid_power_w = Math.round((parseNum(aud.avg_grid_kw) ?? 0) * 1000)
}
if (base.soc_actual_pct == null) {
base.soc_actual_pct = parseNum(aud.avg_soc_pct) ?? base.soc_actual_pct
}
}
const pr = priceBySlot.get(k)
if (pr) {
base.buy_price = pr.buy
base.sell_price = pr.sell
}
const corr = correctedBySlot.get(k)
if (corr != null) {
base.pv_forecast_corrected_w = corr
// Dashboard neřeší přesné rozdělení po polích; pro UI rozpad použijeme stabilní poměr.
base.pv_a_forecast_w = Math.round(corr * 0.6)
base.pv_b_forecast_w = Math.round(corr * 0.4)
}
const pi = planBySlot.get(k)
if (pi) mergeInterval(base, pi)
const past = i <= nIdx
const regimePast = modeAt(logs, startMs) ?? activeMode
built.push({
...base,
regime_code: past ? regimePast : activeMode,
regime_is_planned: !past,
})
}
const liveSoc = parseNum(status?.battery_soc_percent)
if (liveSoc != null && nIdx >= 0 && nIdx < built.length) {
const cur = built[nIdx]!
built[nIdx] = { ...cur, soc_actual_pct: liveSoc }
}
const neg: NegPriceItem[] = []
const nowMs = Date.now()
for (const r of flatPrices) {
const t = new Date(r.interval_start).getTime()
if (t < nowMs) continue
const buy = parseNum(r.effective_buy_price_czk_kwh)
const sell = parseNum(r.effective_sell_price_czk_kwh)
if ((buy != null && buy < 0) || (sell != null && sell < 0)) {
neg.push({
interval_start: r.interval_start,
buy,
sell,
})
}
}
neg.sort((a, b) => new Date(a.interval_start).getTime() - new Date(b.interval_start).getTime())
setNegPrices(neg.slice(0, 32))
setSlots(built)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Chyba načítání dashboardu')
// Sloty neměnit — během chyby refetche zůstávají zobrazená poslední data.
} finally {
setSlotsReady(true)
}
}, [siteId])
useEffect(() => {
// Změna lokality: data staré site nesmí zůstat zobrazená.
setSlots([])
setSlotsReady(false)
void load()
const id = window.setInterval(() => void load(), POLL_FULL_MS)
return () => window.clearInterval(id)
}, [load])
useEffect(() => {
if (siteId == null) {
setLiveMetrics(null)
return
}
const fetchLive = async () => {
try {
const [statusArr, hpArr] = await Promise.all([
getJson<SiteStatusRow[]>('/vw_site_status', { site_id: `eq.${siteId}` }),
getJson<HeatPumpLatestRow[]>('/vw_latest_heat_pump', { site_id: `eq.${siteId}` }),
])
const status = Array.isArray(statusArr) && statusArr[0] ? statusArr[0]! : null
const hp = Array.isArray(hpArr) && hpArr[0] ? hpArr[0]! : null
setLiveMetrics(buildLiveMetrics(status, hp))
} catch {
/* ignore */
}
}
void fetchLive()
const id = window.setInterval(() => void fetchLive(), POLL_LIVE_MS)
return () => window.clearInterval(id)
}, [siteId])
useEffect(() => {
if (siteId == null) {
wsRef.current?.close()
wsRef.current = null
return
}
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${window.location.host}/ws/telemetry`)
wsRef.current = ws
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data as string) as Record<string, unknown>
if (msg.type !== 'telemetry' || Number(msg.site_id) !== siteIdRef.current) return
const pv = numFromWs(msg.pv_power_w)
const batW = numFromWs(msg.battery_power_w)
const gridW = numFromWs(msg.grid_power_w)
const loadW = numFromWs(msg.load_power_w)
const genW = numFromWs(msg.gen_port_power_w)
const soc = numFromWs(msg.battery_soc_pct)
setLiveMetrics((prev) => ({
pv_w: pv ?? prev?.pv_w ?? null,
load_w: loadW ?? prev?.load_w ?? null,
grid_w: gridW ?? prev?.grid_w ?? null,
bat_soc: soc ?? prev?.bat_soc ?? null,
bat_w: batW ?? prev?.bat_w ?? null,
}))
const tsStr = typeof msg.ts === 'string' ? msg.ts : null
if (!tsStr) return
const tsMs = new Date(tsStr).getTime()
if (!Number.isFinite(tsMs)) return
setSlots((prev) => {
const idx = prev.findIndex((s) => {
const start = new Date(s.interval_start).getTime()
return start <= tsMs && tsMs < start + SLOT_MS
})
if (idx === -1) return prev
const cur = prev[idx]!
const updated = [...prev]
updated[idx] = {
...cur,
pv_power_w: pv ?? cur.pv_power_w,
battery_power_w: batW ?? cur.battery_power_w,
grid_power_w: gridW ?? cur.grid_power_w,
load_power_w: loadW ?? cur.load_power_w,
gen_port_power_w: genW ?? cur.gen_port_power_w,
soc_actual_pct: soc ?? cur.soc_actual_pct,
}
return updated
})
} catch {
/* ignore */
}
}
ws.onclose = () => {
if (wsRef.current === ws) wsRef.current = null
}
return () => {
ws.close()
if (wsRef.current === ws) wsRef.current = null
}
}, [siteId])
const liveNowIndex = useMemo(
() => currentSlotIndexInWindow(floorSlotUtcMs(Date.now()) - SLOT_COUNT_BACK * SLOT_MS),
[slots],
)
const buyNow =
slots.length && liveNowIndex >= 0 && liveNowIndex < slots.length
? slots[liveNowIndex]!.buy_price
: null
const sellNow =
slots.length && liveNowIndex >= 0 && liveNowIndex < slots.length
? slots[liveNowIndex]!.sell_price
: null
return {
slots,
nowIndex: liveNowIndex,
forecastWeek,
negPrices,
error,
ready,
slotsReady,
reload: load,
liveMetrics,
buyNow,
sellNow,
}
}