nova stranka flow a obsluha
This commit is contained in:
@@ -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"])
|
||||
|
||||
|
||||
221
backend/app/routers/energy_flows.py
Normal file
221
backend/app/routers/energy_flows.py
Normal file
@@ -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
|
||||
]
|
||||
28
db/migration/V042__energy_flow_columns.sql
Normal file
28
db/migration/V042__energy_flow_columns.sql
Normal file
@@ -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).';
|
||||
@@ -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.';
|
||||
|
||||
32
db/views/R__vw_energy_flows.sql
Normal file
32
db/views/R__vw_energy_flows.sql
Normal file
@@ -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ů.';
|
||||
@@ -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;
|
||||
|
||||
41
docs/04-modules/energy-flows.md
Normal file
41
docs/04-modules/energy-flows.md
Normal file
@@ -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(<site_id>, '<od>'::timestamptz, now());
|
||||
```
|
||||
702
frontend/package-lock.json
generated
702
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<NavLink to="/economics" className={tabClass}>
|
||||
Ekonomika
|
||||
</NavLink>
|
||||
<NavLink to="/energy-flows" className={tabClass}>
|
||||
Toky
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={tabClass}>
|
||||
Nastavení
|
||||
</NavLink>
|
||||
@@ -104,6 +108,7 @@ export default function App() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="planning" element={<Planning />} />
|
||||
<Route path="economics" element={<Economics />} />
|
||||
<Route path="energy-flows" element={<EnergyFlows />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route path="logs" element={<Logs />} />
|
||||
|
||||
101
frontend/src/components/EnergyFlowSankey.tsx
Normal file
101
frontend/src/components/EnergyFlowSankey.tsx
Normal file
@@ -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 (
|
||||
<div className="flex h-[440px] items-center justify-center text-sm text-slate-500">
|
||||
Žádná data
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const links = buildLinks(totals)
|
||||
if (links.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[440px] items-center justify-center text-sm text-slate-500">
|
||||
V tomto měsíci nejsou žádné modelované toky (chybí audit / telemetrie).
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[440px] w-full min-h-[320px]">
|
||||
<ResponsiveSankey
|
||||
data={{ nodes: [...NODES], links }}
|
||||
margin={{ top: 24, right: 180, bottom: 24, left: 24 }}
|
||||
align="justify"
|
||||
sort="input"
|
||||
colors={{ scheme: 'set2' }}
|
||||
nodeOpacity={1}
|
||||
nodeHoverOpacity={1}
|
||||
nodeThickness={20}
|
||||
nodeSpacing={28}
|
||||
nodeBorderWidth={0}
|
||||
linkOpacity={0.45}
|
||||
linkHoverOpacity={0.75}
|
||||
linkContract={2}
|
||||
enableLinkGradient
|
||||
labelPosition="outside"
|
||||
labelOrientation="horizontal"
|
||||
labelPadding={12}
|
||||
labelTextColor={{ from: 'color', modifiers: [['darker', 1.2]] }}
|
||||
theme={{
|
||||
background: 'transparent',
|
||||
labels: {
|
||||
text: {
|
||||
fill: '#e2e8f0',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
container: {
|
||||
background: '#1e293b',
|
||||
color: '#f8fafc',
|
||||
fontSize: 12,
|
||||
borderRadius: 8,
|
||||
border: '1px solid #334155',
|
||||
},
|
||||
},
|
||||
}}
|
||||
valueFormat={(v) => `${Number(v).toFixed(2)} kWh`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
frontend/src/hooks/useEnergyFlows.ts
Normal file
67
frontend/src/hooks/useEnergyFlows.ts
Normal file
@@ -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<DailyEnergyFlows[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null || !month) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { data } = await backendClient.get<DailyEnergyFlowsResponse>(
|
||||
`/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<IntervalEnergyFlows[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null || !day) {
|
||||
setIntervals([])
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await backendClient.get<IntervalEnergyFlows[]>(
|
||||
`/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 }
|
||||
}
|
||||
321
frontend/src/pages/EnergyFlows.tsx
Normal file
321
frontend/src/pages/EnergyFlows.tsx
Normal file
@@ -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 <div className="py-3 text-center text-xs text-slate-500">Načítání intervalů…</div>
|
||||
}
|
||||
if (intervals.length === 0) {
|
||||
return <div className="py-3 text-center text-xs text-slate-500">Žádné intervaly</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700 text-slate-400">
|
||||
<th className="px-2 py-1 text-left">Čas</th>
|
||||
<th className="px-2 py-1 text-right">PV→Load</th>
|
||||
<th className="px-2 py-1 text-right">PV→Batt</th>
|
||||
<th className="px-2 py-1 text-right">PV→Grid</th>
|
||||
<th className="px-2 py-1 text-right">Batt→Load</th>
|
||||
<th className="px-2 py-1 text-right">Batt→Grid</th>
|
||||
<th className="px-2 py-1 text-right">Grid→Load</th>
|
||||
<th className="px-2 py-1 text-right">Grid→Batt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{intervals.map((iv) => (
|
||||
<tr key={iv.interval_start} className="border-b border-slate-800 hover:bg-slate-800/40">
|
||||
<td className="px-2 py-1 text-slate-300">{fmtTime(iv.interval_start)}</td>
|
||||
<td className="px-2 py-1 text-right">{kwh(iv.pv_to_load_kwh)}</td>
|
||||
<td className="px-2 py-1 text-right">{kwh(iv.pv_to_batt_kwh)}</td>
|
||||
<td className="px-2 py-1 text-right">{kwh(iv.pv_to_grid_kwh)}</td>
|
||||
<td className="px-2 py-1 text-right">{kwh(iv.batt_to_load_kwh)}</td>
|
||||
<td className="px-2 py-1 text-right">{kwh(iv.batt_to_grid_kwh)}</td>
|
||||
<td className="px-2 py-1 text-right">{kwh(iv.grid_to_load_kwh)}</td>
|
||||
<td className="px-2 py-1 text-right">{kwh(iv.grid_to_batt_kwh)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<main className="mx-auto max-w-7xl space-y-6 px-4 py-6 md:px-8">
|
||||
{siteReady && siteRow && (
|
||||
<p className="text-xs text-slate-500">
|
||||
Lokalita: <span className="text-slate-400">{siteRow.site_code}</span> — toky jsou{' '}
|
||||
<strong className="text-slate-400">modelované</strong> prioritní alokací z minutové telemetrie (
|
||||
<code className="rounded bg-slate-800 px-1">fn_fill_audit_interval</code>), ne přímé měření větví.
|
||||
</p>
|
||||
)}
|
||||
{siteError && (
|
||||
<div className="rounded-lg border border-amber-900/50 bg-amber-950/30 px-3 py-2 text-sm text-amber-200">
|
||||
{siteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonth((m) => shiftMonth(m, -1))}
|
||||
className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<h1 className="min-w-[200px] text-center text-lg font-semibold text-white">
|
||||
Toky energie — {monthLabel(month)}
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
||||
className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!siteReady ? (
|
||||
<div className="py-12 text-center text-sm text-slate-500">Načítání lokality…</div>
|
||||
) : siteId == null ? (
|
||||
<div className="py-12 text-center text-sm text-slate-500">Vyberte lokalitu v horní liště.</div>
|
||||
) : loading ? (
|
||||
<div className="py-12 text-center text-sm text-slate-500">Načítání…</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-red-900/50 bg-red-950/30 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{totals && (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-amber-400">Perspektiva FVE</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-white">{totals.pv_production_kwh.toFixed(1)} kWh</p>
|
||||
<ul className="mt-2 space-y-1 text-xs text-slate-400">
|
||||
<li>→ spotřeba: {totals.pv_to_load_kwh.toFixed(2)} kWh</li>
|
||||
<li>→ baterie: {totals.pv_to_batt_kwh.toFixed(2)} kWh</li>
|
||||
<li>→ síť (export): {totals.pv_to_grid_kwh.toFixed(2)} kWh</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-sky-400">Perspektiva síť</p>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Import: <span className="font-semibold text-red-300">{totals.grid_import_kwh.toFixed(2)}</span> kWh
|
||||
{' · '}
|
||||
Export:{' '}
|
||||
<span className="font-semibold text-green-300">{totals.grid_export_kwh.toFixed(2)}</span> kWh
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 text-xs text-slate-400">
|
||||
<li>Import → spotřeba: {totals.grid_to_load_kwh.toFixed(2)} kWh</li>
|
||||
<li>Import → baterie: {totals.grid_to_batt_kwh.toFixed(2)} kWh</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-emerald-400">Perspektiva baterie</p>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Nabito: <span className="text-white">{totals.batt_charge_kwh.toFixed(2)}</span> kWh · Vybito:{' '}
|
||||
<span className="text-white">{totals.batt_discharge_kwh.toFixed(2)}</span> kWh
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 text-xs text-slate-400">
|
||||
<li>Z FVE: {totals.pv_to_batt_kwh.toFixed(2)} kWh · Ze sítě: {totals.grid_to_batt_kwh.toFixed(2)} kWh</li>
|
||||
<li>Do spotřeby: {totals.batt_to_load_kwh.toFixed(2)} kWh · Do sítě: {totals.batt_to_grid_kwh.toFixed(2)} kWh</li>
|
||||
{battEff != null && (
|
||||
<li className="text-slate-500">Poměr vybití/nabití: {battEff.toFixed(0)} % (zjednodušeně)</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||
<h2 className="mb-2 text-sm font-medium text-slate-300">Sankey — součet za měsíc</h2>
|
||||
<EnergyFlowSankey totals={flowOnly} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-800 bg-slate-900">
|
||||
<div className="border-b border-slate-800 px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-slate-300">Denní přehled</h2>
|
||||
</div>
|
||||
{days.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-sm text-slate-500">
|
||||
Žádná data — zkuste jiný měsíc nebo backfill auditu (
|
||||
<code className="rounded bg-slate-800 px-1">fn_fill_audit_range</code>).
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700 text-xs text-slate-400">
|
||||
<th className="px-3 py-2 text-left">Den</th>
|
||||
<th className="px-3 py-2 text-right">PV kWh</th>
|
||||
<th className="px-3 py-2 text-right">Import</th>
|
||||
<th className="px-3 py-2 text-right">Export</th>
|
||||
<th className="px-3 py-2 text-right">Nabití</th>
|
||||
<th className="px-3 py-2 text-right">Vybití</th>
|
||||
<th className="px-3 py-2 text-right">Spotřeba</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{days.map((row) => (
|
||||
<Fragment key={row.day}>
|
||||
<tr
|
||||
className="cursor-pointer border-b border-slate-800 hover:bg-slate-800/50"
|
||||
onClick={() => setExpandedDay((p) => (p === row.day ? null : row.day))}
|
||||
>
|
||||
<td className="px-3 py-2 text-sm text-slate-200">
|
||||
<span className="mr-1 inline-block w-4">
|
||||
{expandedDay === row.day ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
{fmtDay(row.day)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-sm">{row.pv_production_kwh.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right text-sm">{row.grid_import_kwh.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-sm">{row.grid_export_kwh.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-sm">{row.batt_charge_kwh.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-sm">{row.batt_discharge_kwh.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-sm">{row.load_kwh.toFixed(1)}</td>
|
||||
</tr>
|
||||
{expandedDay === row.day && siteId != null ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="bg-slate-900/50 px-4 py-2">
|
||||
<IntervalDetail siteId={siteId} day={row.day} />
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-slate-600">
|
||||
<button
|
||||
type="button"
|
||||
className="underline hover:text-slate-400"
|
||||
onClick={() => void reload()}
|
||||
>
|
||||
Znovu načíst
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
38
frontend/src/types/energy-flows.ts
Normal file
38
frontend/src/types/energy-flows.ts
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user