From f714cab0abdc543f2f45eb04815428744d3d980e Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 10 Apr 2026 22:13:58 +0200 Subject: [PATCH] nova stranka flow a obsluha --- backend/app/main.py | 2 + backend/app/routers/energy_flows.py | 221 ++++++ db/migration/V042__energy_flow_columns.sql | 28 + db/routines/R__fn_fill_audit_interval.sql | 111 ++- db/views/R__vw_energy_flows.sql | 32 + db/views/R__z_postgrest_ems_anon_grants.sql | 1 + docs/04-modules/energy-flows.md | 41 ++ frontend/package-lock.json | 702 +++++++++++++++++++ frontend/package.json | 4 +- frontend/src/App.tsx | 5 + frontend/src/components/EnergyFlowSankey.tsx | 101 +++ frontend/src/hooks/useEnergyFlows.ts | 67 ++ frontend/src/pages/EnergyFlows.tsx | 321 +++++++++ frontend/src/types/energy-flows.ts | 38 + 14 files changed, 1670 insertions(+), 4 deletions(-) create mode 100644 backend/app/routers/energy_flows.py create mode 100644 db/migration/V042__energy_flow_columns.sql create mode 100644 db/views/R__vw_energy_flows.sql create mode 100644 docs/04-modules/energy-flows.md create mode 100644 frontend/src/components/EnergyFlowSankey.tsx create mode 100644 frontend/src/hooks/useEnergyFlows.ts create mode 100644 frontend/src/pages/EnergyFlows.tsx create mode 100644 frontend/src/types/energy-flows.ts diff --git a/backend/app/main.py b/backend/app/main.py index b203f99..90c656f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.db_json import record_to_dict from app.deps import set_pg_pool from app.routers.economics import router as economics_router +from app.routers.energy_flows import router as energy_flows_router from app.routers.ev import router as ev_router from app.routers.full_status import router as full_status_router from app.routers.plan import router as plan_router @@ -526,6 +527,7 @@ app.include_router(plan_router, prefix="/api/v1") app.include_router(ev_router, prefix="/api/v1") app.include_router(full_status_router, prefix="/api/v1") app.include_router(economics_router, prefix="/api/v1") +app.include_router(energy_flows_router, prefix="/api/v1") sites_router = APIRouter(prefix="/api/v1/sites", tags=["sites"]) diff --git a/backend/app/routers/energy_flows.py b/backend/app/routers/energy_flows.py new file mode 100644 index 0000000..13f7c1d --- /dev/null +++ b/backend/app/routers/energy_flows.py @@ -0,0 +1,221 @@ +"""REST API – analýza energetických toků (modelované toky z audit_interval).""" + +from __future__ import annotations + +from datetime import date +from typing import Annotated, Any + +import asyncpg +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +from app.deps import get_pg_pool + +router = APIRouter( + prefix="/sites/{site_id}/energy-flows", + tags=["energy-flows"], +) + +class DailyEnergyFlows(BaseModel): + day: date + interval_count: int + pv_production_kwh: float + grid_import_kwh: float + grid_export_kwh: float + batt_charge_kwh: float + batt_discharge_kwh: float + load_kwh: float + pv_to_load_kwh: float + pv_to_batt_kwh: float + pv_to_grid_kwh: float + batt_to_load_kwh: float + batt_to_grid_kwh: float + grid_to_load_kwh: float + grid_to_batt_kwh: float + + +class DailyEnergyFlowsResponse(BaseModel): + days: list[DailyEnergyFlows] + + +class IntervalEnergyFlows(BaseModel): + interval_start: str + pv_production_kwh: float | None + grid_import_kwh: float | None + grid_export_kwh: float | None + batt_charge_kwh: float | None + batt_discharge_kwh: float | None + load_kwh: float | None + pv_to_load_kwh: float | None + pv_to_batt_kwh: float | None + pv_to_grid_kwh: float | None + batt_to_load_kwh: float | None + batt_to_grid_kwh: float | None + grid_to_load_kwh: float | None + grid_to_batt_kwh: float | None + + +def _num(val: Any) -> float: + if val is None: + return 0.0 + return float(val) + + +def _wh_to_kwh(val: Any) -> float | None: + if val is None: + return None + return round(float(val) / 1000.0, 4) + + +async def _check_site(conn: asyncpg.Connection, site_id: int) -> None: + ok = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id + ) + if not ok: + raise HTTPException(status_code=404, detail="Site not found") + + +def _row_to_daily(r: Any) -> DailyEnergyFlows: + return DailyEnergyFlows( + day=r["day_local"], + interval_count=int(r["interval_count"] or 0), + pv_production_kwh=_num(r["pv_production_kwh"]), + grid_import_kwh=_num(r["grid_import_kwh"]), + grid_export_kwh=_num(r["grid_export_kwh"]), + batt_charge_kwh=_num(r["batt_charge_kwh"]), + batt_discharge_kwh=_num(r["batt_discharge_kwh"]), + load_kwh=_num(r["load_kwh"]), + pv_to_load_kwh=_num(r["pv_to_load_kwh"]), + pv_to_batt_kwh=_num(r["pv_to_batt_kwh"]), + pv_to_grid_kwh=_num(r["pv_to_grid_kwh"]), + batt_to_load_kwh=_num(r["batt_to_load_kwh"]), + batt_to_grid_kwh=_num(r["batt_to_grid_kwh"]), + grid_to_load_kwh=_num(r["grid_to_load_kwh"]), + grid_to_batt_kwh=_num(r["grid_to_batt_kwh"]), + ) + + +@router.get("/daily", response_model=DailyEnergyFlowsResponse) +async def get_energy_flows_daily( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pg_pool)], + month: str = Query( + ..., + description="YYYY-MM", + pattern=r"^\d{4}-\d{2}$", + ), +) -> DailyEnergyFlowsResponse: + try: + year, mon = month.split("-") + month_start = date(int(year), int(mon), 1) + if int(mon) == 12: + month_end = date(int(year) + 1, 1, 1) + else: + month_end = date(int(year), int(mon) + 1, 1) + except (ValueError, IndexError): + raise HTTPException(status_code=400, detail="Invalid month, expected YYYY-MM") + + async with db.acquire() as conn: + await _check_site(conn, site_id) + rows = await conn.fetch( + """ + SELECT + (date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date + AS day_local, + COUNT(*)::int AS interval_count, + ROUND(SUM(COALESCE(ai.actual_pv_production_wh, 0)) / 1000, 3) + AS pv_production_kwh, + ROUND(SUM(COALESCE(ai.actual_grid_import_wh, 0)) / 1000, 3) + AS grid_import_kwh, + ROUND(SUM(COALESCE(ai.actual_grid_export_wh, 0)) / 1000, 3) + AS grid_export_kwh, + ROUND(SUM(COALESCE(ai.actual_batt_charge_wh, 0)) / 1000, 3) + AS batt_charge_kwh, + ROUND(SUM(COALESCE(ai.actual_batt_discharge_wh, 0)) / 1000, 3) + AS batt_discharge_kwh, + ROUND(SUM(COALESCE(ai.actual_load_consumption_wh, 0)) / 1000, 3) + AS load_kwh, + ROUND(SUM(COALESCE(ai.flow_pv_to_load_wh, 0)) / 1000, 3) + AS pv_to_load_kwh, + ROUND(SUM(COALESCE(ai.flow_pv_to_batt_wh, 0)) / 1000, 3) + AS pv_to_batt_kwh, + ROUND(SUM(COALESCE(ai.flow_pv_to_grid_wh, 0)) / 1000, 3) + AS pv_to_grid_kwh, + ROUND(SUM(COALESCE(ai.flow_batt_to_load_wh, 0)) / 1000, 3) + AS batt_to_load_kwh, + ROUND(SUM(COALESCE(ai.flow_batt_to_grid_wh, 0)) / 1000, 3) + AS batt_to_grid_kwh, + ROUND(SUM(COALESCE(ai.flow_grid_to_load_wh, 0)) / 1000, 3) + AS grid_to_load_kwh, + ROUND(SUM(COALESCE(ai.flow_grid_to_batt_wh, 0)) / 1000, 3) + AS grid_to_batt_kwh + FROM ems.audit_interval ai + WHERE ai.site_id = $1 + AND (date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date + >= $2 + AND (date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date + < $3 + GROUP BY 1 + ORDER BY 1 + """, + site_id, + month_start, + month_end, + ) + + return DailyEnergyFlowsResponse(days=[_row_to_daily(r) for r in rows]) + + +@router.get("/daily/{day}/intervals", response_model=list[IntervalEnergyFlows]) +async def get_energy_flows_intervals( + site_id: int, + day: date, + db: Annotated[asyncpg.Pool, Depends(get_pg_pool)], +) -> list[IntervalEnergyFlows]: + async with db.acquire() as conn: + await _check_site(conn, site_id) + rows = await conn.fetch( + """ + SELECT + interval_start, + actual_pv_production_wh, + actual_grid_import_wh, + actual_grid_export_wh, + actual_batt_charge_wh, + actual_batt_discharge_wh, + actual_load_consumption_wh, + flow_pv_to_load_wh, + flow_pv_to_batt_wh, + flow_pv_to_grid_wh, + flow_batt_to_load_wh, + flow_batt_to_grid_wh, + flow_grid_to_load_wh, + flow_grid_to_batt_wh + FROM ems.audit_interval + WHERE site_id = $1 + AND (date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague'))::date = $2 + ORDER BY interval_start + """, + site_id, + day, + ) + + return [ + IntervalEnergyFlows( + interval_start=r["interval_start"].isoformat(), + pv_production_kwh=_wh_to_kwh(r["actual_pv_production_wh"]), + grid_import_kwh=_wh_to_kwh(r["actual_grid_import_wh"]), + grid_export_kwh=_wh_to_kwh(r["actual_grid_export_wh"]), + batt_charge_kwh=_wh_to_kwh(r["actual_batt_charge_wh"]), + batt_discharge_kwh=_wh_to_kwh(r["actual_batt_discharge_wh"]), + load_kwh=_wh_to_kwh(r["actual_load_consumption_wh"]), + pv_to_load_kwh=_wh_to_kwh(r["flow_pv_to_load_wh"]), + pv_to_batt_kwh=_wh_to_kwh(r["flow_pv_to_batt_wh"]), + pv_to_grid_kwh=_wh_to_kwh(r["flow_pv_to_grid_wh"]), + batt_to_load_kwh=_wh_to_kwh(r["flow_batt_to_load_wh"]), + batt_to_grid_kwh=_wh_to_kwh(r["flow_batt_to_grid_wh"]), + grid_to_load_kwh=_wh_to_kwh(r["flow_grid_to_load_wh"]), + grid_to_batt_kwh=_wh_to_kwh(r["flow_grid_to_batt_wh"]), + ) + for r in rows + ] diff --git a/db/migration/V042__energy_flow_columns.sql b/db/migration/V042__energy_flow_columns.sql new file mode 100644 index 0000000..ef88609 --- /dev/null +++ b/db/migration/V042__energy_flow_columns.sql @@ -0,0 +1,28 @@ +-- ============================================================= +-- V042 – Energy flow decomposition (7 directional flows per 15min) +-- Plní se v ems.fn_fill_audit_interval (prioritní alokace per minuta). +-- ============================================================= + +ALTER TABLE ems.audit_interval + ADD COLUMN IF NOT EXISTS flow_pv_to_load_wh NUMERIC(10,1), + ADD COLUMN IF NOT EXISTS flow_pv_to_batt_wh NUMERIC(10,1), + ADD COLUMN IF NOT EXISTS flow_pv_to_grid_wh NUMERIC(10,1), + ADD COLUMN IF NOT EXISTS flow_batt_to_load_wh NUMERIC(10,1), + ADD COLUMN IF NOT EXISTS flow_batt_to_grid_wh NUMERIC(10,1), + ADD COLUMN IF NOT EXISTS flow_grid_to_load_wh NUMERIC(10,1), + ADD COLUMN IF NOT EXISTS flow_grid_to_batt_wh NUMERIC(10,1); + +COMMENT ON COLUMN ems.audit_interval.flow_pv_to_load_wh IS +'Modelovaný tok FVE → spotřeba (Wh/slot). Per-minutová prioritní alokace: PV nejdřív load.'; +COMMENT ON COLUMN ems.audit_interval.flow_pv_to_batt_wh IS +'Modelovaný tok FVE → nabíjení baterie (Wh/slot).'; +COMMENT ON COLUMN ems.audit_interval.flow_pv_to_grid_wh IS +'Modelovaný tok FVE → export do sítě (Wh/slot).'; +COMMENT ON COLUMN ems.audit_interval.flow_batt_to_load_wh IS +'Modelovaný tok vybití baterie → spotřeba (Wh/slot).'; +COMMENT ON COLUMN ems.audit_interval.flow_batt_to_grid_wh IS +'Modelovaný tok vybití baterie → export (Wh/slot).'; +COMMENT ON COLUMN ems.audit_interval.flow_grid_to_load_wh IS +'Modelovaný tok import ze sítě → spotřeba (Wh/slot).'; +COMMENT ON COLUMN ems.audit_interval.flow_grid_to_batt_wh IS +'Modelovaný tok import ze sítě → nabíjení baterie (Wh/slot).'; diff --git a/db/routines/R__fn_fill_audit_interval.sql b/db/routines/R__fn_fill_audit_interval.sql index 9dcad8b..5694124 100644 --- a/db/routines/R__fn_fill_audit_interval.sql +++ b/db/routines/R__fn_fill_audit_interval.sql @@ -45,6 +45,37 @@ DECLARE v_counter_export_last BIGINT; v_delta_import NUMERIC; v_delta_export NUMERIC; + + -- 7 směrových toků (prioritní alokace per minuta; součet W/60 = Wh) + r_flow RECORD; + v_flow_samples INT := 0; + v_acc_ptl NUMERIC := 0; + v_acc_ptb NUMERIC := 0; + v_acc_ptg NUMERIC := 0; + v_acc_btl NUMERIC := 0; + v_acc_btg NUMERIC := 0; + v_acc_gtl NUMERIC := 0; + v_acc_gtb NUMERIC := 0; + v_pv NUMERIC; + v_load_m NUMERIC; + v_gi NUMERIC; + v_ge NUMERIC; + v_bc NUMERIC; + v_bd NUMERIC; + v_ptl NUMERIC; + v_ptb NUMERIC; + v_ptg NUMERIC; + v_btl NUMERIC; + v_btg NUMERIC; + v_gtl NUMERIC; + v_gtb NUMERIC; + v_flow_pv_to_load_wh NUMERIC; + v_flow_pv_to_batt_wh NUMERIC; + v_flow_pv_to_grid_wh NUMERIC; + v_flow_batt_to_load_wh NUMERIC; + v_flow_batt_to_grid_wh NUMERIC; + v_flow_grid_to_load_wh NUMERIC; + v_flow_grid_to_batt_wh NUMERIC; BEGIN -- Najít aktivní plán pro tento interval SELECT pi.* INTO v_plan @@ -195,6 +226,58 @@ BEGIN ); END LOOP; + -- Prioritní alokace toků: PV → load → batt charge → export; pak batt discharge → load/export; grid → zbytek + FOR r_flow IN + SELECT pv_power_w, grid_power_w, battery_power_w, load_power_w + FROM ems.telemetry_inverter + WHERE site_id = p_site_id + AND measured_at >= p_interval_start + AND measured_at < v_interval_end + ORDER BY measured_at + LOOP + v_flow_samples := v_flow_samples + 1; + v_pv := GREATEST(COALESCE(r_flow.pv_power_w, 0)::NUMERIC, 0); + v_load_m := GREATEST(COALESCE(r_flow.load_power_w, 0)::NUMERIC, 0); + v_gi := GREATEST(COALESCE(r_flow.grid_power_w, 0)::NUMERIC, 0); + v_ge := ABS(LEAST(COALESCE(r_flow.grid_power_w, 0)::NUMERIC, 0)); + v_bc := ABS(LEAST(COALESCE(r_flow.battery_power_w, 0)::NUMERIC, 0)); + v_bd := GREATEST(COALESCE(r_flow.battery_power_w, 0)::NUMERIC, 0); + + v_ptl := LEAST(v_pv, v_load_m); + v_ptb := LEAST(v_pv - v_ptl, v_bc); + v_ptg := LEAST(v_pv - v_ptl - v_ptb, v_ge); + v_btl := LEAST(v_bd, v_load_m - v_ptl); + v_btg := LEAST(v_bd - v_btl, GREATEST(0::NUMERIC, v_ge - v_ptg)); + v_gtl := GREATEST(0::NUMERIC, v_load_m - v_ptl - v_btl); + v_gtb := GREATEST(0::NUMERIC, v_bc - v_ptb); + + v_acc_ptl := v_acc_ptl + v_ptl; + v_acc_ptb := v_acc_ptb + v_ptb; + v_acc_ptg := v_acc_ptg + v_ptg; + v_acc_btl := v_acc_btl + v_btl; + v_acc_btg := v_acc_btg + v_btg; + v_acc_gtl := v_acc_gtl + v_gtl; + v_acc_gtb := v_acc_gtb + v_gtb; + END LOOP; + + IF v_flow_samples = 0 THEN + v_flow_pv_to_load_wh := NULL; + v_flow_pv_to_batt_wh := NULL; + v_flow_pv_to_grid_wh := NULL; + v_flow_batt_to_load_wh := NULL; + v_flow_batt_to_grid_wh := NULL; + v_flow_grid_to_load_wh := NULL; + v_flow_grid_to_batt_wh := NULL; + ELSE + v_flow_pv_to_load_wh := ROUND(v_acc_ptl / 60, 1); + v_flow_pv_to_batt_wh := ROUND(v_acc_ptb / 60, 1); + v_flow_pv_to_grid_wh := ROUND(v_acc_ptg / 60, 1); + v_flow_batt_to_load_wh := ROUND(v_acc_btl / 60, 1); + v_flow_batt_to_grid_wh := ROUND(v_acc_btg / 60, 1); + v_flow_grid_to_load_wh := ROUND(v_acc_gtl / 60, 1); + v_flow_grid_to_batt_wh := ROUND(v_acc_gtb / 60, 1); + END IF; + -- Upsert do audit_interval INSERT INTO ems.audit_interval ( site_id, interval_start, planning_run_id, @@ -213,7 +296,14 @@ BEGIN actual_batt_charge_wh, actual_batt_discharge_wh, actual_pv_production_wh, - actual_load_consumption_wh + actual_load_consumption_wh, + flow_pv_to_load_wh, + flow_pv_to_batt_wh, + flow_pv_to_grid_wh, + flow_batt_to_load_wh, + flow_batt_to_grid_wh, + flow_grid_to_load_wh, + flow_grid_to_batt_wh ) VALUES ( p_site_id, p_interval_start, v_run_id, v_avg_pv_power_w, @@ -237,7 +327,14 @@ BEGIN v_batt_charge_wh, v_batt_discharge_wh, v_pv_production_wh, - v_load_consumption_wh + v_load_consumption_wh, + v_flow_pv_to_load_wh, + v_flow_pv_to_batt_wh, + v_flow_pv_to_grid_wh, + v_flow_batt_to_load_wh, + v_flow_batt_to_grid_wh, + v_flow_grid_to_load_wh, + v_flow_grid_to_batt_wh ) ON CONFLICT (site_id, interval_start) DO UPDATE SET planning_run_id = EXCLUDED.planning_run_id, @@ -258,7 +355,14 @@ BEGIN actual_batt_charge_wh = EXCLUDED.actual_batt_charge_wh, actual_batt_discharge_wh = EXCLUDED.actual_batt_discharge_wh, actual_pv_production_wh = EXCLUDED.actual_pv_production_wh, - actual_load_consumption_wh = EXCLUDED.actual_load_consumption_wh; + actual_load_consumption_wh = EXCLUDED.actual_load_consumption_wh, + flow_pv_to_load_wh = EXCLUDED.flow_pv_to_load_wh, + flow_pv_to_batt_wh = EXCLUDED.flow_pv_to_batt_wh, + flow_pv_to_grid_wh = EXCLUDED.flow_pv_to_grid_wh, + flow_batt_to_load_wh = EXCLUDED.flow_batt_to_load_wh, + flow_batt_to_grid_wh = EXCLUDED.flow_batt_to_grid_wh, + flow_grid_to_load_wh = EXCLUDED.flow_grid_to_load_wh, + flow_grid_to_batt_wh = EXCLUDED.flow_grid_to_batt_wh; END; $$; @@ -267,6 +371,7 @@ COMMENT ON FUNCTION ems.fn_fill_audit_interval(INT, TIMESTAMPTZ) IS Agreguje průměry z telemetrie (střídač, EV, TČ), porovná se skutečným plánem a spočítá odchylky. Nově: per-minutový split pro 6 energetických veličin (import/export/batt/PV/load Wh); grid import/export primárně z delta Deye total counterů (reg 522-525), fallback per-minute. +7 směrových toků (flow_*_wh): prioritní alokace per minuta z telemetrie (PV→load→batt→export; baterie→load/export; síť→zbytek). actual_cost_czk = per-direction (import_wh × buy - export_wh × sell). Zelený bonus: součet přes pole s green_bonus_czk_kwh. Volat každých 15 minut pro interval který právě skončil.'; diff --git a/db/views/R__vw_energy_flows.sql b/db/views/R__vw_energy_flows.sql new file mode 100644 index 0000000..a7dbbd3 --- /dev/null +++ b/db/views/R__vw_energy_flows.sql @@ -0,0 +1,32 @@ +-- ============================================================= +-- R__vw_energy_flows.sql +-- Denní agregace 6 základních Wh + 7 směrových toků z audit_interval +-- Repeatable migration (závisí na audit_interval + V042 sloupcech) +-- ============================================================= + +DROP VIEW IF EXISTS ems.vw_energy_flows_daily CASCADE; + +CREATE VIEW ems.vw_energy_flows_daily AS +SELECT + site_id, + (date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague'))::date AS day_local, + ROUND(SUM(COALESCE(actual_pv_production_wh, 0)) / 1000, 3) AS pv_production_kwh, + ROUND(SUM(COALESCE(actual_grid_import_wh, 0)) / 1000, 3) AS grid_import_kwh, + ROUND(SUM(COALESCE(actual_grid_export_wh, 0)) / 1000, 3) AS grid_export_kwh, + ROUND(SUM(COALESCE(actual_batt_charge_wh, 0)) / 1000, 3) AS batt_charge_kwh, + ROUND(SUM(COALESCE(actual_batt_discharge_wh, 0)) / 1000, 3) AS batt_discharge_kwh, + ROUND(SUM(COALESCE(actual_load_consumption_wh, 0)) / 1000, 3) AS load_kwh, + ROUND(SUM(COALESCE(flow_pv_to_load_wh, 0)) / 1000, 3) AS pv_to_load_kwh, + ROUND(SUM(COALESCE(flow_pv_to_batt_wh, 0)) / 1000, 3) AS pv_to_batt_kwh, + ROUND(SUM(COALESCE(flow_pv_to_grid_wh, 0)) / 1000, 3) AS pv_to_grid_kwh, + ROUND(SUM(COALESCE(flow_batt_to_load_wh, 0)) / 1000, 3) AS batt_to_load_kwh, + ROUND(SUM(COALESCE(flow_batt_to_grid_wh, 0)) / 1000, 3) AS batt_to_grid_kwh, + ROUND(SUM(COALESCE(flow_grid_to_load_wh, 0)) / 1000, 3) AS grid_to_load_kwh, + ROUND(SUM(COALESCE(flow_grid_to_batt_wh, 0)) / 1000, 3) AS grid_to_batt_kwh +FROM ems.audit_interval +GROUP BY + site_id, + (date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague'))::date; + +COMMENT ON VIEW ems.vw_energy_flows_daily IS +'Denní součty energie a modelovaných toků (prioritní alokace z fn_fill_audit_interval). kWh z Wh sloupců.'; diff --git a/db/views/R__z_postgrest_ems_anon_grants.sql b/db/views/R__z_postgrest_ems_anon_grants.sql index fec15de..1641b5f 100644 --- a/db/views/R__z_postgrest_ems_anon_grants.sql +++ b/db/views/R__z_postgrest_ems_anon_grants.sql @@ -35,6 +35,7 @@ GRANT SELECT ON ems.market_price_stats TO ems_anon; GRANT SELECT ON ems.tuv_usage_stats TO ems_anon; GRANT SELECT ON ems.baseline_load_forecast_accuracy TO ems_anon; GRANT SELECT ON ems.vw_baseline_load_forecast_accuracy_daily TO ems_anon; +GRANT SELECT ON ems.vw_energy_flows_daily TO ems_anon; grant select on ems.asset_heat_pump to ems_anon; diff --git a/docs/04-modules/energy-flows.md b/docs/04-modules/energy-flows.md new file mode 100644 index 0000000..54e7e44 --- /dev/null +++ b/docs/04-modules/energy-flows.md @@ -0,0 +1,41 @@ +# Analýza energetických toků (Fáze 2) + +## Účel + +Stránka **Toky** (`/energy-flows`) zobrazuje **modelované** směrové toky energie mezi FVE, sítí, baterií a spotřebou za den / měsíc. Nejedná se o přímé měření větví u střídače, ale o **prioritní alokaci** z minutové telemetrie. + +## Výpočet + +Funkce `ems.fn_fill_audit_interval` pro každý 15min slot: + +1. Načte minutové řádky `ems.telemetry_inverter` (`pv_power_w`, `load_power_w`, `grid_power_w`, `battery_power_w`). +2. Pro každou minutu aplikuje alokaci (pořadí): PV → spotřeba → nabíjení baterie → export; pak vybití baterie → spotřeba / export; síť → zbytek spotřeby a nabíjení. +3. Součet výkonů × 1/60 h = Wh za slot; výsledek v sloupcích `flow_*_wh` v `ems.audit_interval`. + +Sloupce: `flow_pv_to_load_wh`, `flow_pv_to_batt_wh`, `flow_pv_to_grid_wh`, `flow_batt_to_load_wh`, `flow_batt_to_grid_wh`, `flow_grid_to_load_wh`, `flow_grid_to_batt_wh`. + +Základní 6 Wh veličin (import/export, PV, baterie, load) zůstává ve Fázi 1; toky jsou nadstavba. + +## API + +- `GET /api/v1/sites/{site_id}/energy-flows/daily?month=YYYY-MM` +- `GET /api/v1/sites/{site_id}/energy-flows/daily/{day}/intervals` + +## SQL + +- View `ems.vw_energy_flows_daily` – denní agregace z `audit_interval`. +- PostgREST role `ems_anon`: `GRANT SELECT` na `vw_energy_flows_daily` (viz repeatable grants). + +## UI + +- Sankey (`@nivo/sankey`) – součet toků za zvolený měsíc. +- Tři perspektivní karty (FVE / síť / baterie). +- Tabulka dnů s rozbalením na 15min intervaly. + +## Backfill + +Po nasazení migrace V042 a opakovatelné rutiny: + +```sql +SELECT ems.fn_fill_audit_range(, ''::timestamptz, now()); +``` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index db3a5d9..0f9ec5b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6,6 +6,8 @@ "": { "name": "ems-frontend", "dependencies": { + "@nivo/core": "^0.99.0", + "@nivo/sankey": "^0.99.0", "axios": "^1.7.9", "chart.js": "^4.4.8", "lucide-react": "^0.468.0", @@ -740,6 +742,304 @@ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" }, + "node_modules/@nivo/colors": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz", + "integrity": "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-color": "^3.0.0", + "@types/d3-scale": "^4.0.8", + "@types/d3-scale-chromatic": "^3.0.0", + "d3-color": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/core": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.99.0.tgz", + "integrity": "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g==", + "license": "MIT", + "dependencies": { + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-shape": "^3.1.6", + "d3-color": "^3.1.0", + "d3-format": "^1.4.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "d3-shape": "^3.2.0", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21", + "react-virtualized-auto-sizer": "^1.0.26", + "use-debounce": "^10.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nivo/donate" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/core/node_modules/@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@nivo/core/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/@nivo/core/node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@nivo/core/node_modules/d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "2" + } + }, + "node_modules/@nivo/core/node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/@nivo/core/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/@nivo/core/node_modules/react-virtualized-auto-sizer": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", + "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==", + "license": "MIT", + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@nivo/legends": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz", + "integrity": "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-scale": "^4.0.8", + "d3-scale": "^4.0.2" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/sankey": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/sankey/-/sankey-0.99.0.tgz", + "integrity": "sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-sankey": "^0.11.2", + "@types/d3-shape": "^3.1.6", + "d3-sankey": "^0.12.3", + "d3-shape": "^3.2.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/sankey/node_modules/@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@nivo/text": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/text/-/text-0.99.0.tgz", + "integrity": "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/text/node_modules/@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@nivo/theming": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.99.0.tgz", + "integrity": "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/tooltip": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.99.0.tgz", + "integrity": "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/tooltip/node_modules/@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/animated": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz", + "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz", + "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.3.tgz", + "integrity": "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.3.tgz", + "integrity": "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.3.tgz", + "integrity": "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1501,6 +1801,30 @@ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" }, + "node_modules/@types/d3-sankey": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.11.2.tgz", + "integrity": "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -1509,6 +1833,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -1778,6 +2108,46 @@ "node": ">=12" } }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -1793,6 +2163,19 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -2929,6 +3312,18 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-debounce": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz", + "integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -3421,6 +3816,233 @@ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" }, + "@nivo/colors": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz", + "integrity": "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w==", + "requires": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-color": "^3.0.0", + "@types/d3-scale": "^4.0.8", + "@types/d3-scale-chromatic": "^3.0.0", + "d3-color": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "lodash": "^4.17.21" + } + }, + "@nivo/core": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.99.0.tgz", + "integrity": "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g==", + "requires": { + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-shape": "^3.1.6", + "d3-color": "^3.1.0", + "d3-format": "^1.4.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "d3-shape": "^3.2.0", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21", + "react-virtualized-auto-sizer": "^1.0.26", + "use-debounce": "^10.0.4" + }, + "dependencies": { + "@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "requires": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + } + }, + "d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "requires": { + "internmap": "^1.0.0" + } + }, + "d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==" + }, + "d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "requires": { + "d3-array": "2" + } + }, + "d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "requires": { + "d3-time": "1 - 2" + } + }, + "internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, + "react-virtualized-auto-sizer": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", + "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==", + "requires": {} + } + } + }, + "@nivo/legends": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz", + "integrity": "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==", + "requires": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-scale": "^4.0.8", + "d3-scale": "^4.0.2" + } + }, + "@nivo/sankey": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/sankey/-/sankey-0.99.0.tgz", + "integrity": "sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw==", + "requires": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-sankey": "^0.11.2", + "@types/d3-shape": "^3.1.6", + "d3-sankey": "^0.12.3", + "d3-shape": "^3.2.0", + "lodash": "^4.17.21" + }, + "dependencies": { + "@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "requires": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + } + } + } + }, + "@nivo/text": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/text/-/text-0.99.0.tgz", + "integrity": "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==", + "requires": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "dependencies": { + "@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "requires": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + } + } + } + }, + "@nivo/theming": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.99.0.tgz", + "integrity": "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg==", + "requires": { + "lodash": "^4.17.21" + } + }, + "@nivo/tooltip": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.99.0.tgz", + "integrity": "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg==", + "requires": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "dependencies": { + "@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "requires": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + } + } + } + }, + "@react-spring/animated": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz", + "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==", + "requires": { + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + } + }, + "@react-spring/core": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz", + "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==", + "requires": { + "@react-spring/animated": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + } + }, + "@react-spring/rafz": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.3.tgz", + "integrity": "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==" + }, + "@react-spring/shared": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.3.tgz", + "integrity": "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==", + "requires": { + "@react-spring/rafz": "~10.0.3", + "@react-spring/types": "~10.0.3" + } + }, + "@react-spring/types": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.3.tgz", + "integrity": "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==" + }, "@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3865,6 +4487,29 @@ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" }, + "@types/d3-sankey": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.11.2.tgz", + "integrity": "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ==", + "requires": { + "@types/d3-shape": "^1" + }, + "dependencies": { + "@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==" + }, + "@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "requires": { + "@types/d3-path": "^1" + } + } + } + }, "@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -3873,6 +4518,11 @@ "@types/d3-time": "*" } }, + "@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==" + }, "@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -4056,6 +4706,43 @@ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" }, + "d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "requires": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + }, + "dependencies": { + "d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "requires": { + "internmap": "^1.0.0" + } + }, + "d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "requires": { + "d3-path": "1" + } + }, + "internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + } + } + }, "d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -4068,6 +4755,15 @@ "d3-time-format": "2 - 4" } }, + "d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "requires": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + } + }, "d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -4756,6 +5452,12 @@ "picocolors": "^1.1.1" } }, + "use-debounce": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz", + "integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==", + "requires": {} + }, "victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4620fee..b56714c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,8 +8,10 @@ "preview": "vite preview" }, "dependencies": { - "chart.js": "^4.4.8", + "@nivo/core": "^0.99.0", + "@nivo/sankey": "^0.99.0", "axios": "^1.7.9", + "chart.js": "^4.4.8", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e9c62e7..4fd154e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { SiteSelectionProvider, useSiteSelection } from './context/SiteSelection import { useWsLogErrorCount } from './hooks/useWsLogErrorCount' import { Dashboard } from './pages/Dashboard' import Economics from './pages/Economics' +import EnergyFlows from './pages/EnergyFlows' import { Logs } from './pages/Logs' import Planning from './pages/Planning' import { Settings } from './pages/Settings' @@ -71,6 +72,9 @@ function AppLayout() { Ekonomika + + Toky + Nastavení @@ -104,6 +108,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/EnergyFlowSankey.tsx b/frontend/src/components/EnergyFlowSankey.tsx new file mode 100644 index 0000000..7b73818 --- /dev/null +++ b/frontend/src/components/EnergyFlowSankey.tsx @@ -0,0 +1,101 @@ +import { ResponsiveSankey } from '@nivo/sankey' + +export type FlowTotals = { + pv_to_load_kwh: number + pv_to_batt_kwh: number + pv_to_grid_kwh: number + batt_to_load_kwh: number + batt_to_grid_kwh: number + grid_to_load_kwh: number + grid_to_batt_kwh: number +} + +const NODES = [ + { id: 'FVE' }, + { id: 'Síť' }, + { id: 'Baterie' }, + { id: 'Spotřeba' }, +] as const + +function buildLinks(t: FlowTotals): { source: string; target: string; value: number }[] { + const out: { source: string; target: string; value: number }[] = [] + const add = (source: string, target: string, v: number) => { + if (v > 0.0005) out.push({ source, target, value: v }) + } + add('FVE', 'Spotřeba', t.pv_to_load_kwh) + add('FVE', 'Baterie', t.pv_to_batt_kwh) + add('FVE', 'Síť', t.pv_to_grid_kwh) + add('Baterie', 'Spotřeba', t.batt_to_load_kwh) + add('Baterie', 'Síť', t.batt_to_grid_kwh) + add('Síť', 'Spotřeba', t.grid_to_load_kwh) + add('Síť', 'Baterie', t.grid_to_batt_kwh) + return out +} + +type Props = { + totals: FlowTotals | null +} + +export function EnergyFlowSankey({ totals }: Props) { + if (!totals) { + return ( +
+ Žádná data +
+ ) + } + + const links = buildLinks(totals) + if (links.length === 0) { + return ( +
+ V tomto měsíci nejsou žádné modelované toky (chybí audit / telemetrie). +
+ ) + } + + return ( +
+ `${Number(v).toFixed(2)} kWh`} + /> +
+ ) +} diff --git a/frontend/src/hooks/useEnergyFlows.ts b/frontend/src/hooks/useEnergyFlows.ts new file mode 100644 index 0000000..be76900 --- /dev/null +++ b/frontend/src/hooks/useEnergyFlows.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useState } from 'react' +import { backendClient } from '../api/backend' +import type { + DailyEnergyFlows, + DailyEnergyFlowsResponse, + IntervalEnergyFlows, +} from '../types/energy-flows' + +export function useEnergyFlowsDaily(siteId: number | null, month: string) { + const [days, setDays] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + if (siteId == null || !month) return + setLoading(true) + setError(null) + try { + const { data } = await backendClient.get( + `/sites/${siteId}/energy-flows/daily`, + { params: { month }, timeout: 30_000 }, + ) + setDays(data.days ?? []) + } catch { + setDays([]) + setError('Nepodařilo se načíst toky energie') + } finally { + setLoading(false) + } + }, [siteId, month]) + + useEffect(() => { + void load() + }, [load]) + + return { days, loading, error, reload: load } +} + +export function useEnergyFlowsIntervals(siteId: number | null, day: string | null) { + const [intervals, setIntervals] = useState([]) + const [loading, setLoading] = useState(false) + + const load = useCallback(async () => { + if (siteId == null || !day) { + setIntervals([]) + return + } + setLoading(true) + try { + const { data } = await backendClient.get( + `/sites/${siteId}/energy-flows/daily/${day}/intervals`, + { timeout: 30_000 }, + ) + setIntervals(Array.isArray(data) ? data : []) + } catch { + setIntervals([]) + } finally { + setLoading(false) + } + }, [siteId, day]) + + useEffect(() => { + void load() + }, [load]) + + return { intervals, loading } +} diff --git a/frontend/src/pages/EnergyFlows.tsx b/frontend/src/pages/EnergyFlows.tsx new file mode 100644 index 0000000..6de83d8 --- /dev/null +++ b/frontend/src/pages/EnergyFlows.tsx @@ -0,0 +1,321 @@ +import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from 'lucide-react' +import { Fragment, useMemo, useState } from 'react' + +import { EnergyFlowSankey, type FlowTotals } from '../components/EnergyFlowSankey' +import { useEnergyFlowsDaily, useEnergyFlowsIntervals } from '../hooks/useEnergyFlows' +import { useSiteStatus } from '../hooks/useSiteStatus' +import { pragueCalendarDay } from '../lib/pragueDate' +import type { DailyEnergyFlows } from '../types/energy-flows' + +function currentMonth(): string { + return pragueCalendarDay().slice(0, 7) +} + +function monthLabel(ym: string): string { + const [y, m] = ym.split('-').map(Number) + const names = [ + 'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen', + 'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec', + ] + return `${names[m - 1]} ${y}` +} + +function shiftMonth(ym: string, delta: number): string { + const [y, m] = ym.split('-').map(Number) + const d = new Date(y, m - 1 + delta, 1) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` +} + +function fmtDay(iso: string): string { + const d = new Date(iso + 'T00:00:00') + return d.toLocaleDateString('cs-CZ', { weekday: 'short', day: 'numeric', month: 'numeric' }) +} + +function fmtTime(iso: string): string { + const d = new Date(iso) + return d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Prague' }) +} + +function kwh(v: number | null | undefined, d = 2): string { + if (v == null) return '–' + return v.toFixed(d) +} + +function aggregateFlows(days: DailyEnergyFlows[]): FlowTotals & { + pv_production_kwh: number + grid_import_kwh: number + grid_export_kwh: number + batt_charge_kwh: number + batt_discharge_kwh: number + load_kwh: number +} { + const z = { + pv_production_kwh: 0, + grid_import_kwh: 0, + grid_export_kwh: 0, + batt_charge_kwh: 0, + batt_discharge_kwh: 0, + load_kwh: 0, + pv_to_load_kwh: 0, + pv_to_batt_kwh: 0, + pv_to_grid_kwh: 0, + batt_to_load_kwh: 0, + batt_to_grid_kwh: 0, + grid_to_load_kwh: 0, + grid_to_batt_kwh: 0, + } + for (const d of days) { + z.pv_production_kwh += d.pv_production_kwh + z.grid_import_kwh += d.grid_import_kwh + z.grid_export_kwh += d.grid_export_kwh + z.batt_charge_kwh += d.batt_charge_kwh + z.batt_discharge_kwh += d.batt_discharge_kwh + z.load_kwh += d.load_kwh + z.pv_to_load_kwh += d.pv_to_load_kwh + z.pv_to_batt_kwh += d.pv_to_batt_kwh + z.pv_to_grid_kwh += d.pv_to_grid_kwh + z.batt_to_load_kwh += d.batt_to_load_kwh + z.batt_to_grid_kwh += d.batt_to_grid_kwh + z.grid_to_load_kwh += d.grid_to_load_kwh + z.grid_to_batt_kwh += d.grid_to_batt_kwh + } + return z +} + +function IntervalDetail({ siteId, day }: { siteId: number; day: string }) { + const { intervals, loading } = useEnergyFlowsIntervals(siteId, day) + + if (loading) { + return
Načítání intervalů…
+ } + if (intervals.length === 0) { + return
Žádné intervaly
+ } + + return ( +
+ + + + + + + + + + + + + + + {intervals.map((iv) => ( + + + + + + + + + + + ))} + +
ČasPV→LoadPV→BattPV→GridBatt→LoadBatt→GridGrid→LoadGrid→Batt
{fmtTime(iv.interval_start)}{kwh(iv.pv_to_load_kwh)}{kwh(iv.pv_to_batt_kwh)}{kwh(iv.pv_to_grid_kwh)}{kwh(iv.batt_to_load_kwh)}{kwh(iv.batt_to_grid_kwh)}{kwh(iv.grid_to_load_kwh)}{kwh(iv.grid_to_batt_kwh)}
+
+ ) +} + +export default function EnergyFlows() { + const { site: siteRow, ready: siteReady, error: siteError } = useSiteStatus() + const siteId = siteRow?.site_id ?? null + + const [month, setMonth] = useState(currentMonth) + const [expandedDay, setExpandedDay] = useState(null) + + const { days, loading, error, reload } = useEnergyFlowsDaily(siteId, month) + + const totals = useMemo(() => (days.length > 0 ? aggregateFlows(days) : null), [days]) + + const flowOnly: FlowTotals | null = totals + ? { + pv_to_load_kwh: totals.pv_to_load_kwh, + pv_to_batt_kwh: totals.pv_to_batt_kwh, + pv_to_grid_kwh: totals.pv_to_grid_kwh, + batt_to_load_kwh: totals.batt_to_load_kwh, + batt_to_grid_kwh: totals.batt_to_grid_kwh, + grid_to_load_kwh: totals.grid_to_load_kwh, + grid_to_batt_kwh: totals.grid_to_batt_kwh, + } + : null + + const battEff = + totals && totals.batt_charge_kwh > 0.01 + ? Math.min(100, (totals.batt_discharge_kwh / totals.batt_charge_kwh) * 100) + : null + + return ( +
+ {siteReady && siteRow && ( +

+ Lokalita: {siteRow.site_code} — toky jsou{' '} + modelované prioritní alokací z minutové telemetrie ( + fn_fill_audit_interval), ne přímé měření větví. +

+ )} + {siteError && ( +
+ {siteError} +
+ )} + +
+ +

+ Toky energie — {monthLabel(month)} +

+ +
+ + {!siteReady ? ( +
Načítání lokality…
+ ) : siteId == null ? ( +
Vyberte lokalitu v horní liště.
+ ) : loading ? ( +
Načítání…
+ ) : error ? ( +
+ {error} +
+ ) : ( + <> + {totals && ( +
+
+

Perspektiva FVE

+

{totals.pv_production_kwh.toFixed(1)} kWh

+
    +
  • → spotřeba: {totals.pv_to_load_kwh.toFixed(2)} kWh
  • +
  • → baterie: {totals.pv_to_batt_kwh.toFixed(2)} kWh
  • +
  • → síť (export): {totals.pv_to_grid_kwh.toFixed(2)} kWh
  • +
+
+
+

Perspektiva síť

+

+ Import: {totals.grid_import_kwh.toFixed(2)} kWh + {' · '} + Export:{' '} + {totals.grid_export_kwh.toFixed(2)} kWh +

+
    +
  • Import → spotřeba: {totals.grid_to_load_kwh.toFixed(2)} kWh
  • +
  • Import → baterie: {totals.grid_to_batt_kwh.toFixed(2)} kWh
  • +
+
+
+

Perspektiva baterie

+

+ Nabito: {totals.batt_charge_kwh.toFixed(2)} kWh · Vybito:{' '} + {totals.batt_discharge_kwh.toFixed(2)} kWh +

+
    +
  • Z FVE: {totals.pv_to_batt_kwh.toFixed(2)} kWh · Ze sítě: {totals.grid_to_batt_kwh.toFixed(2)} kWh
  • +
  • Do spotřeby: {totals.batt_to_load_kwh.toFixed(2)} kWh · Do sítě: {totals.batt_to_grid_kwh.toFixed(2)} kWh
  • + {battEff != null && ( +
  • Poměr vybití/nabití: {battEff.toFixed(0)} % (zjednodušeně)
  • + )} +
+
+
+ )} + +
+

Sankey — součet za měsíc

+ +
+ +
+
+

Denní přehled

+
+ {days.length === 0 ? ( +
+ Žádná data — zkuste jiný měsíc nebo backfill auditu ( + fn_fill_audit_range). +
+ ) : ( +
+ + + + + + + + + + + + + + {days.map((row) => ( + + setExpandedDay((p) => (p === row.day ? null : row.day))} + > + + + + + + + + + {expandedDay === row.day && siteId != null ? ( + + + + ) : null} + + ))} + +
DenPV kWhImportExportNabitíVybitíSpotřeba
+ + {expandedDay === row.day ? : } + + {fmtDay(row.day)} + {row.pv_production_kwh.toFixed(1)}{row.grid_import_kwh.toFixed(2)}{row.grid_export_kwh.toFixed(2)}{row.batt_charge_kwh.toFixed(2)}{row.batt_discharge_kwh.toFixed(2)}{row.load_kwh.toFixed(1)}
+ +
+
+ )} +
+ +

+ +

+ + )} +
+ ) +} diff --git a/frontend/src/types/energy-flows.ts b/frontend/src/types/energy-flows.ts new file mode 100644 index 0000000..b5b5c83 --- /dev/null +++ b/frontend/src/types/energy-flows.ts @@ -0,0 +1,38 @@ +export type DailyEnergyFlows = { + day: string + interval_count: number + pv_production_kwh: number + grid_import_kwh: number + grid_export_kwh: number + batt_charge_kwh: number + batt_discharge_kwh: number + load_kwh: number + pv_to_load_kwh: number + pv_to_batt_kwh: number + pv_to_grid_kwh: number + batt_to_load_kwh: number + batt_to_grid_kwh: number + grid_to_load_kwh: number + grid_to_batt_kwh: number +} + +export type DailyEnergyFlowsResponse = { + days: DailyEnergyFlows[] +} + +export type IntervalEnergyFlows = { + interval_start: string + pv_production_kwh: number | null + grid_import_kwh: number | null + grid_export_kwh: number | null + batt_charge_kwh: number | null + batt_discharge_kwh: number | null + load_kwh: number | null + pv_to_load_kwh: number | null + pv_to_batt_kwh: number | null + pv_to_grid_kwh: number | null + batt_to_load_kwh: number | null + batt_to_grid_kwh: number | null + grid_to_load_kwh: number | null + grid_to_batt_kwh: number | null +}