diff --git a/db/migration/V067__asset_heat_pump_site_index.sql b/db/migration/V067__asset_heat_pump_site_index.sql new file mode 100644 index 0000000..2201c04 --- /dev/null +++ b/db/migration/V067__asset_heat_pump_site_index.sql @@ -0,0 +1,8 @@ +-- ============================================================= +-- V067__asset_heat_pump_site_index.sql +-- Zrychlení filtrování asset_heat_pump podle site_id (PostgREST). +-- ============================================================= + +create index if not exists idx_asset_heat_pump_site + on ems.asset_heat_pump (site_id); + diff --git a/db/views/R__058_vw_latest_telemetry.sql b/db/views/R__058_vw_latest_telemetry.sql index b5528ba..580aa76 100644 --- a/db/views/R__058_vw_latest_telemetry.sql +++ b/db/views/R__058_vw_latest_telemetry.sql @@ -67,9 +67,9 @@ COMMENT ON VIEW ems.vw_latest_ev_charger IS CREATE OR REPLACE VIEW ems.vw_latest_heat_pump WITH (security_invoker = false) AS -SELECT DISTINCT ON (t.heat_pump_id) - t.site_id, - t.heat_pump_id, +SELECT + hp.site_id, + hp.id AS heat_pump_id, hp.code AS heat_pump_code, t.measured_at, t.outdoor_temp_c, @@ -81,11 +81,25 @@ SELECT DISTINCT ON (t.heat_pump_id) t.defrost_active, t.alarm_code, -- Odhadovaný COP pro aktuální venkovní teplotu - ems.fn_cop_estimate(t.heat_pump_id, t.outdoor_temp_c) AS cop_estimated, + ems.fn_cop_estimate(hp.id, t.outdoor_temp_c) AS cop_estimated, now() - t.measured_at AS data_age -FROM ems.telemetry_heat_pump t -JOIN ems.asset_heat_pump hp ON hp.id = t.heat_pump_id -ORDER BY t.heat_pump_id, t.measured_at DESC; +FROM ems.asset_heat_pump hp +LEFT JOIN LATERAL ( + SELECT + thp.measured_at, + thp.outdoor_temp_c, + thp.tuv_tank_temp_c, + thp.water_outlet_temp_c, + thp.power_w, + thp.operating_mode, + thp.cop_actual, + thp.defrost_active, + thp.alarm_code + FROM ems.telemetry_heat_pump thp + WHERE thp.heat_pump_id = hp.id + ORDER BY thp.measured_at DESC + LIMIT 1 +) t ON true; COMMENT ON VIEW ems.vw_latest_heat_pump IS 'Nejnovější telemetrická data pro každé tepelné čerpadlo včetně odhadovaného COP. diff --git a/frontend/src/hooks/useDashboardData.ts b/frontend/src/hooks/useDashboardData.ts index dca3f52..a5dcb68 100644 --- a/frontend/src/hooks/useDashboardData.ts +++ b/frontend/src/hooks/useDashboardData.ts @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getCurrentPlan, - getSiteForecastPv, getSitePrices, getForecastPvSlotsRangeCorrected, type SiteEffectivePriceRowDto, @@ -245,45 +244,17 @@ export function useDashboardData(siteId: number | null) { }) } - const forecastBySlot = new Map() const forecastDays: ForecastDayTotal[] = [] const weekDates = Array.from({ length: 7 }, (_, d) => pragueAddCalendarDays(todayPrague, d)) - const forecastFetchDates = [...new Set([...dates, ...weekDates])].sort() - const forecastResults = await Promise.all( - forecastFetchDates.map((ymd) => - getSiteForecastPv(siteId, ymd) - .then((fc) => ({ ymd, fc })) - .catch(() => ({ ymd, fc: null as Awaited> | null })), - ), - ) - const forecastByYmd = new Map(forecastResults.map((r) => [r.ymd, r.fc])) - const addForecastToByStart = ( - fc: NonNullable>>, - byStart: Map, - ) => { - for (const x of fc.pv_a ?? []) { - const t = new Date(x.interval_start).getTime() - const p = Number(x.power_w ?? 0) - const cur = byStart.get(slotTimeKey(t)) ?? { a: 0, b: 0 } - cur.a += p - byStart.set(slotTimeKey(t), cur) - } - for (const x of fc.pv_b ?? []) { - const t = new Date(x.interval_start).getTime() - const p = Number(x.power_w ?? 0) - const cur = byStart.get(slotTimeKey(t)) ?? { a: 0, b: 0 } - cur.b += p - byStart.set(slotTimeKey(t), cur) - } - } - for (const { fc } of forecastResults) { - if (!fc) continue - addForecastToByStart(fc, forecastBySlot) - } + // 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 correctedSlots = await getForecastPvSlotsRangeCorrected(siteId, windowFromIso, windowToIso).catch( + 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>, ) const correctedBySlot = new Map() @@ -295,16 +266,14 @@ export function useDashboardData(siteId: number | null) { correctedBySlot.set(slotTimeKey(t), Number(v)) } for (const ymd of weekDates) { - const fc = forecastByYmd.get(ymd) ?? null - if (!fc) { - forecastDays.push({ date: ymd, label: ymd, kwh: 0 }) - continue - } - const byStart = new Map() - addForecastToByStart(fc, byStart) let kwh = 0 - for (const [, v] of byStart) { - kwh += ((v.a + v.b) * 0.25) / 1000 + // 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', @@ -374,14 +343,12 @@ export function useDashboardData(siteId: number | null) { base.sell_price = pr.sell } - const fc = forecastBySlot.get(k) - if (fc) { - base.pv_a_forecast_w = fc.a - base.pv_b_forecast_w = fc.b - } 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)