From eb8dd0368f8698ed7084076bf4c4cce752fe5389 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 10 Apr 2026 20:48:41 +0200 Subject: [PATCH] fix telemtrie na dahsbaordu (15min misto 1h) --- CLAUDE.md | 3 +- ...V039__telemetry_inverter_15m_aggregate.sql | 33 +++++++++++++++++++ db/views/R__vw_telemetry_15m_7d.sql | 19 +++++++++++ db/views/R__z_postgrest_ems_anon_grants.sql | 1 + docs/02-architecture.md | 1 + docs/03-data-model.md | 9 +++++ docs/04-modules/telemetry.md | 15 +++++++++ .../src/components/charts/SocTuvChart.tsx | 18 ++++++++-- frontend/src/hooks/useDashboardData.ts | 33 +++++++++---------- frontend/src/pages/Dashboard.tsx | 10 ++++-- frontend/src/types/ems.ts | 12 +++++++ 11 files changed, 131 insertions(+), 23 deletions(-) create mode 100644 db/migration/V039__telemetry_inverter_15m_aggregate.sql create mode 100644 db/views/R__vw_telemetry_15m_7d.sql diff --git a/CLAUDE.md b/CLAUDE.md index 04a1418..0bae281 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,7 +129,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá | `modbus_command` | Journal Modbus zápisů (pending → written → verified / mismatch / failed); retry a vazba na `planning_run`; u Deye exportu `deye_physical_mode` (PASSIVE/SELL/CHARGE). | | `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. | -**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_latest_telemetry`, `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`. +**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`. --- @@ -166,6 +166,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan | Bazální spotřeba | `docs/04-modules/consumption.md` | | TČ, COP, TUV | `docs/04-modules/heat-pump.md`, `db/routines/R__fn_cop_estimate.sql` | | Modbus, telemetrie, agregace | `docs/04-modules/telemetry.md` | +| Dashboard přehled – 15min grafy slotů, SoC vs. živá telemetrie | `docs/04-modules/telemetry.md` (CA `telemetry_inverter_15m`, view `vw_telemetry_15m_7d`), `frontend/src/hooks/useDashboardData.ts`, `frontend/src/components/charts/SocTuvChart.tsx` | | Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` | | Deye registry (FC 0x10, 108/109/141/142/178/143) | `docs/04-modules/modbus-registers.md` | | Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` | diff --git a/db/migration/V039__telemetry_inverter_15m_aggregate.sql b/db/migration/V039__telemetry_inverter_15m_aggregate.sql new file mode 100644 index 0000000..470d835 --- /dev/null +++ b/db/migration/V039__telemetry_inverter_15m_aggregate.sql @@ -0,0 +1,33 @@ +-- ============================================================ +-- 15min continuous aggregate telemetrie střídače (dashboard sloty) +-- ============================================================ +-- Zarovnáno s 15min sloty UI (UTC time_bucket = floorSlotUtcMs v frontendu). +-- Hodinový CA telemetry_inverter_hourly zůstává pro dlouhé grafy / legacy. + +CREATE MATERIALIZED VIEW IF NOT EXISTS ems.telemetry_inverter_15m +WITH (timescaledb.continuous) AS +SELECT + time_bucket('15 minutes', measured_at) AS slot_start, + site_id, + AVG(pv_power_w)::INT AS avg_pv_w, + AVG(battery_power_w)::INT AS avg_battery_w, + AVG(grid_power_w)::INT AS avg_grid_w, + AVG(load_power_w)::INT AS avg_load_w, + LAST(battery_soc_percent, measured_at) AS last_soc_pct, + COUNT(*) AS sample_count +FROM ems.telemetry_inverter +GROUP BY slot_start, site_id +WITH NO DATA; + +-- Refresh: ≥2× time_bucket (15 min) → start_offset > 30 min +SELECT add_continuous_aggregate_policy( + 'ems.telemetry_inverter_15m', + start_offset => INTERVAL '45 minutes', + end_offset => INTERVAL '1 minute', + schedule_interval => INTERVAL '15 minutes' +); + +COMMENT ON MATERIALIZED VIEW ems.telemetry_inverter_15m IS +'Čtvrthodinové agregáty telemetrie střídače. TimescaleDB continuous aggregate. +Refresh každých 15 minut. Dashboard přehled (sloty 15 min). +View vw_telemetry_15m_7d je v repeatable R__vw_telemetry_15m_7d.sql.'; diff --git a/db/views/R__vw_telemetry_15m_7d.sql b/db/views/R__vw_telemetry_15m_7d.sql new file mode 100644 index 0000000..3b471ec --- /dev/null +++ b/db/views/R__vw_telemetry_15m_7d.sql @@ -0,0 +1,19 @@ +-- ============================================================= +-- R__vw_telemetry_15m_7d.sql +-- EMS Platform – telemetrie střídače po 15 min (dashboard sloty) +-- Repeatable migration – jedna aktuální definice view +-- ============================================================= +-- Zdroj: continuous aggregate ems.telemetry_inverter_15m (V039). +-- security_invoker=false: PostgREST ems_anon čte bez GRANT na podkladový CA. + +CREATE OR REPLACE VIEW ems.vw_telemetry_15m_7d +WITH (security_invoker = false) +AS +SELECT * +FROM ems.telemetry_inverter_15m +WHERE slot_start >= now() - INTERVAL '7 days' +ORDER BY slot_start DESC; + +COMMENT ON VIEW ems.vw_telemetry_15m_7d IS +'Telemetrie střídače po 15 min za 7 dní (zdroj: telemetry_inverter_15m). +security_invoker=false: čtení přes PostgREST role ems_anon bez GRANT na podkladový CA.'; diff --git a/db/views/R__z_postgrest_ems_anon_grants.sql b/db/views/R__z_postgrest_ems_anon_grants.sql index 2762bb4..fec15de 100644 --- a/db/views/R__z_postgrest_ems_anon_grants.sql +++ b/db/views/R__z_postgrest_ems_anon_grants.sql @@ -26,6 +26,7 @@ GRANT SELECT ON ems.vw_audit_weekly TO ems_anon; GRANT SELECT ON ems.vw_mode_log_recent TO ems_anon; GRANT SELECT ON ems.vw_operating_mode TO ems_anon; GRANT SELECT ON ems.vw_telemetry_hourly_7d TO ems_anon; +GRANT SELECT ON ems.vw_telemetry_15m_7d TO ems_anon; GRANT SELECT ON ems.forecast_accuracy TO ems_anon; GRANT SELECT ON ems.vw_forecast_accuracy_by_lead_time TO ems_anon; GRANT SELECT ON ems.vw_forecast_accuracy_daily TO ems_anon; diff --git a/docs/02-architecture.md b/docs/02-architecture.md index c90b43c..b7519f6 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -82,6 +82,7 @@ ems-platform/ views/ R__vw_site_effective_price.sql R__vw_latest_telemetry.sql + R__vw_telemetry_15m_7d.sql R__vw_actual_baseline.sql R__vw_audit_summary.sql R__vw_heat_pump_cop_history.sql diff --git a/docs/03-data-model.md b/docs/03-data-model.md index a9e8f08..f8091a1 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -266,6 +266,15 @@ CREATE TABLE telemetry_inverter ( -- SELECT create_hypertable('telemetry_inverter', 'measured_at'); ``` +### Continuous aggregates a views (výkon střídače pro UI) + +Z `telemetry_inverter` počítá TimescaleDB materializované agregáty (viz `db/migration/V011__indexes_and_aggregates.sql`, `V039__telemetry_inverter_15m_aggregate.sql`): + +- **`telemetry_inverter_hourly`** – hodinové průměry + `LAST(battery_soc_percent, measured_at)`; čtení přes view **`vw_telemetry_hourly_7d`**. +- **`telemetry_inverter_15m`** – čtvrthodinové bucket odpovídající 15min slotům EMS; čtení přes **`vw_telemetry_15m_7d`** (definice v **`db/views/R__vw_telemetry_15m_7d.sql`**, repeatable). + +PostgREST role `ems_anon` má `SELECT` na tyto views (ne na samotné CA); u view nad CA je `security_invoker = false`, stejně jako u `vw_telemetry_hourly_7d` (viz `db/views/R__z_postgrest_ems_anon_grants.sql`). + ### `telemetry_ev_charger` Stav EV nabíječek. diff --git a/docs/04-modules/telemetry.md b/docs/04-modules/telemetry.md index cd7d11c..5dbec3f 100644 --- a/docs/04-modules/telemetry.md +++ b/docs/04-modules/telemetry.md @@ -142,6 +142,21 @@ GROUP BY site_id, time_bucket('15 minutes', measured_at); --- +## Timescale continuous aggregates (střídač → dashboard) + +Nad `ems.telemetry_inverter` běží dva **continuous aggregate** (TimescaleDB); oba se periodicky obnovují (řádově každých 15 minut). Definice CA je ve **verzovaných** migracích (`V011`, `V039`); **view** nad CA držíme v **repeatable** souborech (`db/views/R__*.sql`), aby šla měnit jedna aktuální definice bez nové V migrace. + +| Objekt | Bucket | View pro PostgREST / UI | Poznámka | +|--------|--------|-------------------------|----------| +| `ems.telemetry_inverter_hourly` | 1 hodina | `ems.vw_telemetry_hourly_7d` | CA a view v **V011**; `security_invoker` v **V031**. Hodinové trendy. | +| `ems.telemetry_inverter_15m` | 15 minut | `ems.vw_telemetry_15m_7d` | **`db/views/R__vw_telemetry_15m_7d.sql`** – posledních 7 dní, zarovnání s 15min sloty přehledu. | + +**Frontend přehled** (`frontend/src/hooks/useDashboardData.ts`): skutečné výkony a SoC po slotech bere z **`/vw_telemetry_15m_7d`** (klíč slotu = začátek 15min intervalu v UTC, stejně jako `floorSlotUtcMs` v grafu). Horní karty a **aktuální SoC** v grafu jsou dál z **`vw_site_status`** (poslední 1min vzorek) a z WebSocketu `/ws/telemetry`, aby „teď“ odpovídalo boxu i po refreshi agregátu. + +**Plánovač** počáteční SoC nečte z těchto view – bere poslední řádek z `ems.telemetry_inverter` (`planning_engine._load_site_context`). + +--- + ## Konfigurace (env proměnné) ```env diff --git a/frontend/src/components/charts/SocTuvChart.tsx b/frontend/src/components/charts/SocTuvChart.tsx index 561db4b..fa9852e 100644 --- a/frontend/src/components/charts/SocTuvChart.tsx +++ b/frontend/src/components/charts/SocTuvChart.tsx @@ -13,9 +13,11 @@ import { type Props = { slots: SlotData[] nowIndex: number + /** Stejný zdroj jako horní karta SOC (živá telemetrie); bod v aktuálním slotu. */ + liveBatSoc?: number | null } -export function SocTuvChart({ slots, nowIndex }: Props) { +export function SocTuvChart({ slots, nowIndex, liveBatSoc = null }: Props) { const canvasRef = useRef(null) const chartRef = useRef(null) @@ -50,12 +52,22 @@ export function SocTuvChart({ slots, nowIndex }: Props) { ) const series = useMemo(() => { - const socReal = slots.map((s, i) => (i <= nowIndex ? s.soc_actual_pct : null)) + const socReal = slots.map((s, i) => { + if (i > nowIndex) return null + if ( + i === nowIndex && + liveBatSoc != null && + Number.isFinite(liveBatSoc) + ) { + return liveBatSoc + } + return s.soc_actual_pct + }) const socPlan = slots.map((s) => s.soc_plan_pct) const tuvReal = slots.map((s, i) => (i <= nowIndex ? s.tuv_actual_c : null)) const tuvPlan = slots.map((s) => s.tuv_plan_c) return { socReal, socPlan, tuvReal, tuvPlan } - }, [slots, nowIndex]) + }, [slots, nowIndex, liveBatSoc]) const bgPlugin = useMemo( () => createSlotBackgroundPluginRefs(slotsRef, negRangesRef), diff --git a/frontend/src/hooks/useDashboardData.ts b/frontend/src/hooks/useDashboardData.ts index b27c2a2..cb33d1b 100644 --- a/frontend/src/hooks/useDashboardData.ts +++ b/frontend/src/hooks/useDashboardData.ts @@ -22,7 +22,7 @@ import type { HeatPumpLatestRow, ModeLogRecentRow, SiteStatusRow, - TelemetryHourly7dRow, + Telemetry15m7dRow, } from '../types/ems' import type { PlanningIntervalDto } from '../types/plan' @@ -56,13 +56,6 @@ function buildLiveMetrics( } } -function hourFloorUtcMs(ms: number): number { - const d = new Date(ms) - d.setUTCMinutes(0, 0, 0) - d.setUTCSeconds(0, 0) - return d.getTime() -} - /** 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', { @@ -198,7 +191,7 @@ export function useDashboardData(siteId: number | null) { const [ planMaybe, statusArr, - hourly7d, + telemetry15m7d, auditHourly, modeLog, hpArr, @@ -211,10 +204,10 @@ export function useDashboardData(siteId: number | null) { throw e }), getJson('/vw_site_status', { site_id: `eq.${siteId}` }), - getJson('/vw_telemetry_hourly_7d', { + getJson('/vw_telemetry_15m_7d', { site_id: `eq.${siteId}`, - order: 'hour.asc', - limit: '500', + order: 'slot_start.asc', + limit: '1000', }), getJson('/vw_audit_today_hourly', { site_id: `eq.${siteId}`, @@ -297,10 +290,10 @@ export function useDashboardData(siteId: number | null) { } setForecastWeek(forecastDays) - const hourlyMap = new Map() - if (Array.isArray(hourly7d)) { - for (const r of hourly7d) { - hourlyMap.set(new Date(r.hour).getTime(), r) + const telemetryBySlot = new Map() + if (Array.isArray(telemetry15m7d)) { + for (const r of telemetry15m7d) { + telemetryBySlot.set(slotTimeKey(new Date(r.slot_start).getTime()), r) } } @@ -321,7 +314,7 @@ export function useDashboardData(siteId: number | null) { const base = emptySlot(iso) const k = slotTimeKey(startMs) - const tel = hourlyMap.get(hourFloorUtcMs(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 @@ -373,6 +366,12 @@ export function useDashboardData(siteId: number | null) { }) } + 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) { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e272e32..aea2b11 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -35,7 +35,9 @@ const MemoRegimeBar = memo(RegimeBar, (prev, next) => ) const MemoSocTuvChart = memo(SocTuvChart, (prev, next) => - prev.slots === next.slots && prev.nowIndex === next.nowIndex, + prev.slots === next.slots && + prev.nowIndex === next.nowIndex && + prev.liveBatSoc === next.liveBatSoc, ) function fmtKw2(w: number | null | undefined): string { @@ -324,7 +326,11 @@ export function Dashboard() { chartArea={chartArea} />
- +
)} diff --git a/frontend/src/types/ems.ts b/frontend/src/types/ems.ts index 6687d43..c4ab625 100644 --- a/frontend/src/types/ems.ts +++ b/frontend/src/types/ems.ts @@ -48,6 +48,18 @@ export type TelemetryHourly7dRow = { sample_count: number | null } +/** ems.vw_telemetry_15m_7d (řádky z telemetry_inverter_15m) */ +export type Telemetry15m7dRow = { + slot_start: string + site_id: number + avg_pv_w: number | null + avg_battery_w: number | null + avg_grid_w: number | null + avg_load_w: number | null + last_soc_pct: string | number | null + sample_count: number | null +} + /** ems.vw_latest_heat_pump */ export type HeatPumpLatestRow = { site_id: number