x
This commit is contained in:
@@ -10,8 +10,8 @@ DB_PASSWORD=change_me_strong_password
|
|||||||
|
|
||||||
# ---- PostgREST ----
|
# ---- PostgREST ----
|
||||||
POSTGREST_JWT_SECRET=change_me_jwt_secret_min_32_chars
|
POSTGREST_JWT_SECRET=change_me_jwt_secret_min_32_chars
|
||||||
# Pro lokální dev může být stejná jako DB_USER (PostgREST SELECT pod ems_user).
|
# PostgREST anonymní role (viz db/migration/V009__postgrest_roles.sql + R__z_postgrest_ems_anon_grants.sql).
|
||||||
POSTGREST_ANON_ROLE=ems_user
|
POSTGREST_ANON_ROLE=ems_anon
|
||||||
|
|
||||||
# ---- OTE CZ import ----
|
# ---- OTE CZ import ----
|
||||||
OTE_API_URL=https://www.ote-cr.cz/pubapi/v1/market-data/dam
|
OTE_API_URL=https://www.ote-cr.cz/pubapi/v1/market-data/dam
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,3 +10,5 @@ venv/
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
frontend/vendor/
|
||||||
|
frontend/scripts/.native-tmp/
|
||||||
|
|||||||
54
README.md
Normal file
54
README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# EMS Platform
|
||||||
|
|
||||||
|
Systém pro správu energie fotovoltaické elektrárny s baterií, EV nabíječkami a tepelným čerpadlem. Optimalizuje náklady pomocí spotových cen OTE CZ.
|
||||||
|
|
||||||
|
Podrobná architektura, datový model a moduly jsou v [`docs/`](docs/); stručná orientace pro vývoj je v [`CLAUDE.md`](CLAUDE.md).
|
||||||
|
|
||||||
|
## Rychlý start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Uprav .env: DB_PASSWORD, POSTGREST_JWT_SECRET (min 32 znaků)
|
||||||
|
docker compose up --build -d
|
||||||
|
# Počkej ~45s na Flyway migrace a start služeb
|
||||||
|
bash scripts/smoke_test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- **UI:** http://localhost
|
||||||
|
- **API (FastAPI):** http://localhost/api/v1 (případně přímo http://localhost:8000/api/v1)
|
||||||
|
- **PostgREST:** http://localhost/rest (případně http://localhost:3000)
|
||||||
|
|
||||||
|
## Inicializace dat (první spuštění)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Import spotových cen OTE (zítřek v časové zóně lokality)
|
||||||
|
curl -X POST http://localhost/api/v1/sites/1/prices/import
|
||||||
|
|
||||||
|
# 2. PV forecast (Open-Meteo + pvlib → forecast_pv_interval)
|
||||||
|
curl -X POST http://localhost/api/v1/sites/1/forecast/run
|
||||||
|
|
||||||
|
# 3. Spustit optimalizaci (denní plán)
|
||||||
|
curl -X POST "http://localhost/api/v1/sites/1/plan/run?type=daily"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ve webovém rozhraní: stránka **Plánování** a tlačítko **Přeplánovat** spouští *rolling* přepočet (vyžaduje již dostupné ceny a forecast v horizontu solveru).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Komponenta | Technologie | Port |
|
||||||
|
|------------|-------------|------|
|
||||||
|
| DB | PostgreSQL 16 + TimescaleDB | 5432 (mapováno na localhost, interně v síti Docker) |
|
||||||
|
| API | FastAPI + PostgREST | 80 přes Nginx (`/api`, `/rest`) |
|
||||||
|
| Frontend | React + Vite + Tailwind | 80 |
|
||||||
|
| Migrace | Flyway (`db/migration`, `db/routines`, `db/views`) | — |
|
||||||
|
|
||||||
|
## Co ještě nefunguje (před instalací HW)
|
||||||
|
|
||||||
|
- **Telemetrie:** IP adresy Waveshare (Modbus TCP) doplnit v DB / seedu (`db/migration/V003__seed_site_home01.sql` a `site_endpoint`).
|
||||||
|
- **EV nabíječky:** Modbus registry Teltonika zatím často mock / rozpracované.
|
||||||
|
- **Samsung TČ:** Modbus registry pending (mock data).
|
||||||
|
- **Loxone:** nakonfigurovat Virtual Inputs dle [`docs/loxone-integration.md`](docs/loxone-integration.md).
|
||||||
|
|
||||||
|
## Architektura
|
||||||
|
|
||||||
|
Viz [`docs/02-architecture.md`](docs/02-architecture.md).
|
||||||
@@ -21,7 +21,7 @@ class Settings(BaseSettings):
|
|||||||
database_url: str | None = Field(default=None)
|
database_url: str | None = Field(default=None)
|
||||||
|
|
||||||
postgrest_jwt_secret: str = Field(default="")
|
postgrest_jwt_secret: str = Field(default="")
|
||||||
postgrest_anon_role: str = Field(default="ems_user")
|
postgrest_anon_role: str = Field(default="ems_anon")
|
||||||
|
|
||||||
ote_api_url: str = Field(
|
ote_api_url: str = Field(
|
||||||
default="https://www.ote-cr.cz/pubapi/v1/market-data/dam",
|
default="https://www.ote-cr.cz/pubapi/v1/market-data/dam",
|
||||||
|
|||||||
35
backend/app/db_json.py
Normal file
35
backend/app/db_json.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""asyncpg Record → JSON-serializovatelný dict."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
|
||||||
|
def record_to_dict(r: asyncpg.Record) -> dict[str, Any]:
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for k in r.keys():
|
||||||
|
v = r[k]
|
||||||
|
if v is None:
|
||||||
|
out[k] = None
|
||||||
|
elif isinstance(v, datetime):
|
||||||
|
if v.tzinfo is None:
|
||||||
|
v = v.replace(tzinfo=timezone.utc)
|
||||||
|
out[k] = v.isoformat()
|
||||||
|
elif isinstance(v, date):
|
||||||
|
out[k] = v.isoformat()
|
||||||
|
elif isinstance(v, Decimal):
|
||||||
|
out[k] = float(v)
|
||||||
|
elif isinstance(v, UUID):
|
||||||
|
out[k] = str(v)
|
||||||
|
elif isinstance(v, (dict, list, str, int, float, bool)):
|
||||||
|
out[k] = v
|
||||||
|
elif isinstance(v, (bytes, memoryview)):
|
||||||
|
out[k] = bytes(v).decode("utf-8", errors="replace")
|
||||||
|
else:
|
||||||
|
out[k] = str(v)
|
||||||
|
return out
|
||||||
268
backend/app/routers/full_status.py
Normal file
268
backend/app/routers/full_status.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""GET /sites/{site_id}/status/full – monitoring snapshot + alert pravidla."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from app.db_json import record_to_dict
|
||||||
|
from app.deps import get_pg_pool
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"])
|
||||||
|
|
||||||
|
INV_STALE_SEC = 300
|
||||||
|
HEARTBEAT_STALE_SEC = 300
|
||||||
|
EXPECTED_TOMORROW_PRICE_SLOTS = 90
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_utc(dt: datetime | None) -> str | None:
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _age_seconds(at: datetime | None) -> int | None:
|
||||||
|
if at is None:
|
||||||
|
return None
|
||||||
|
if at.tzinfo is None:
|
||||||
|
at = at.replace(tzinfo=timezone.utc)
|
||||||
|
return max(0, int((datetime.now(timezone.utc) - at).total_seconds()))
|
||||||
|
|
||||||
|
|
||||||
|
def _next_plan_interval(
|
||||||
|
intervals: list[dict[str, Any]], now_utc: datetime
|
||||||
|
) -> tuple[str | None, int | None]:
|
||||||
|
"""Nejbližší 15min slot od aktuálního času včetně probíhajícího."""
|
||||||
|
slot_ms = 15 * 60 * 1000
|
||||||
|
boundary_ms = (int(now_utc.timestamp() * 1000) // slot_ms) * slot_ms
|
||||||
|
boundary = datetime.fromtimestamp(boundary_ms / 1000, tz=timezone.utc)
|
||||||
|
for row in sorted(intervals, key=lambda r: r["interval_start"]):
|
||||||
|
istart = row["interval_start"]
|
||||||
|
if isinstance(istart, str):
|
||||||
|
istart = datetime.fromisoformat(istart.replace("Z", "+00:00"))
|
||||||
|
if istart.tzinfo is None:
|
||||||
|
istart = istart.replace(tzinfo=timezone.utc)
|
||||||
|
if istart >= boundary - timedelta(milliseconds=1):
|
||||||
|
bat = row.get("battery_setpoint_w")
|
||||||
|
bi = int(bat) if bat is not None else None
|
||||||
|
return _iso_utc(istart), bi
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status/full")
|
||||||
|
async def get_site_status_full(
|
||||||
|
site_id: int,
|
||||||
|
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
site = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT id, code, name, timezone
|
||||||
|
FROM ems.site
|
||||||
|
WHERE id = $1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
if site is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
|
tz = site["timezone"] or "Europe/Prague"
|
||||||
|
|
||||||
|
mode_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT m.mode_code, d.name AS mode_name, m.activated_at, m.activated_by
|
||||||
|
FROM ems.site_operating_mode m
|
||||||
|
JOIN ems.operating_mode_def d ON d.code = m.mode_code
|
||||||
|
WHERE m.site_id = $1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
hb_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT last_seen, status
|
||||||
|
FROM ems.site_heartbeat
|
||||||
|
WHERE site_id = $1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
inv_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT pv_power_w, battery_soc_percent, grid_power_w, measured_at
|
||||||
|
FROM ems.vw_latest_inverter
|
||||||
|
WHERE site_id = $1
|
||||||
|
ORDER BY measured_at DESC NULLS LAST
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
ev_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT ON (charger_id)
|
||||||
|
charger_code AS code,
|
||||||
|
status,
|
||||||
|
power_w,
|
||||||
|
measured_at
|
||||||
|
FROM ems.vw_latest_ev_charger
|
||||||
|
WHERE site_id = $1
|
||||||
|
ORDER BY charger_id, measured_at DESC NULLS LAST
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
hp_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT power_w, tuv_tank_temp_c, measured_at
|
||||||
|
FROM ems.vw_latest_heat_pump
|
||||||
|
WHERE site_id = $1
|
||||||
|
ORDER BY measured_at DESC NULLS LAST
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
reserve_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT MIN(reserve_soc_percent)::float AS reserve_soc
|
||||||
|
FROM ems.asset_battery
|
||||||
|
WHERE site_id = $1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
run_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT id, created_at
|
||||||
|
FROM ems.planning_run
|
||||||
|
WHERE site_id = $1 AND status = 'active'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
intervals: list[dict[str, Any]] = []
|
||||||
|
if run_row:
|
||||||
|
int_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT interval_start, battery_setpoint_w
|
||||||
|
FROM ems.planning_interval
|
||||||
|
WHERE run_id = $1
|
||||||
|
ORDER BY interval_start
|
||||||
|
""",
|
||||||
|
run_row["id"],
|
||||||
|
)
|
||||||
|
intervals = [record_to_dict(r) for r in int_rows]
|
||||||
|
|
||||||
|
tomorrow_slots = await conn.fetchval(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM ems.vw_site_effective_price v
|
||||||
|
WHERE v.site_id = $1
|
||||||
|
AND (v.interval_start AT TIME ZONE $2)::date =
|
||||||
|
((CURRENT_TIMESTAMP AT TIME ZONE $2)::date + INTERVAL '1 day')::date
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
tz,
|
||||||
|
)
|
||||||
|
tomorrow_slots = int(tomorrow_slots or 0)
|
||||||
|
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
hb_last = hb_row["last_seen"] if hb_row else None
|
||||||
|
hb_age = _age_seconds(hb_last)
|
||||||
|
inv_measured = inv_row["measured_at"] if inv_row else None
|
||||||
|
inv_age = _age_seconds(inv_measured)
|
||||||
|
|
||||||
|
next_start, next_bat = _next_plan_interval(intervals, now_utc)
|
||||||
|
|
||||||
|
ev_list: list[dict[str, Any]] = []
|
||||||
|
for r in ev_rows:
|
||||||
|
ev_list.append(
|
||||||
|
{
|
||||||
|
"code": r["code"],
|
||||||
|
"status": r["status"],
|
||||||
|
"power_w": int(r["power_w"]) if r["power_w"] is not None else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
telemetry: dict[str, Any] = {
|
||||||
|
"inverter": {
|
||||||
|
"pv_power_w": int(inv_row["pv_power_w"]) if inv_row and inv_row["pv_power_w"] is not None else None,
|
||||||
|
"battery_soc_pct": float(inv_row["battery_soc_percent"])
|
||||||
|
if inv_row and inv_row["battery_soc_percent"] is not None
|
||||||
|
else None,
|
||||||
|
"grid_power_w": int(inv_row["grid_power_w"]) if inv_row and inv_row["grid_power_w"] is not None else None,
|
||||||
|
"measured_at": _iso_utc(inv_measured),
|
||||||
|
"age_seconds": inv_age,
|
||||||
|
},
|
||||||
|
"ev_chargers": ev_list,
|
||||||
|
"heat_pump": {
|
||||||
|
"power_w": int(hp_row["power_w"]) if hp_row and hp_row["power_w"] is not None else None,
|
||||||
|
"tank_temp_c": float(hp_row["tuv_tank_temp_c"])
|
||||||
|
if hp_row and hp_row["tuv_tank_temp_c"] is not None
|
||||||
|
else None,
|
||||||
|
"measured_at": _iso_utc(hp_row["measured_at"]) if hp_row else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
has_plan = run_row is not None
|
||||||
|
planning = {
|
||||||
|
"has_active_plan": has_plan,
|
||||||
|
"plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None,
|
||||||
|
"next_interval_start": next_start,
|
||||||
|
"next_battery_setpoint_w": next_bat,
|
||||||
|
}
|
||||||
|
|
||||||
|
mode_code = (mode_row["mode_code"] if mode_row else None) or ""
|
||||||
|
reserve_soc = float(reserve_row["reserve_soc"]) if reserve_row and reserve_row["reserve_soc"] is not None else None
|
||||||
|
soc = float(inv_row["battery_soc_percent"]) if inv_row and inv_row["battery_soc_percent"] is not None else None
|
||||||
|
|
||||||
|
alerts: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
def add_alert(level: Literal["warn", "error"], message: str) -> None:
|
||||||
|
alerts.append({"level": level, "message": message})
|
||||||
|
|
||||||
|
if inv_age is None or inv_age > INV_STALE_SEC:
|
||||||
|
add_alert("error", "Telemetrie střídače nedostupná")
|
||||||
|
|
||||||
|
if not has_plan:
|
||||||
|
add_alert("warn", "Není aktivní plán – EMS neoptimalizuje")
|
||||||
|
|
||||||
|
if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS:
|
||||||
|
add_alert("warn", "Chybí spotové ceny pro zítřek")
|
||||||
|
|
||||||
|
if mode_code.upper() == "MANUAL":
|
||||||
|
add_alert("warn", "Systém v manuálním režimu")
|
||||||
|
|
||||||
|
if reserve_soc is not None and soc is not None and soc < reserve_soc:
|
||||||
|
add_alert("error", "SoC baterie pod rezervou")
|
||||||
|
|
||||||
|
if hb_age is None or hb_age > HEARTBEAT_STALE_SEC:
|
||||||
|
add_alert("error", "EMS heartbeat výpadek")
|
||||||
|
|
||||||
|
alerts.sort(key=lambda a: (0 if a["level"] == "error" else 1, a["message"]))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"site": {"id": site["id"], "code": site["code"], "name": site["name"]},
|
||||||
|
"operating_mode": {
|
||||||
|
"mode_code": mode_row["mode_code"] if mode_row else None,
|
||||||
|
"mode_name": mode_row["mode_name"] if mode_row else None,
|
||||||
|
"activated_at": _iso_utc(mode_row["activated_at"]) if mode_row else None,
|
||||||
|
"activated_by": mode_row["activated_by"] if mode_row else None,
|
||||||
|
},
|
||||||
|
"heartbeat": {
|
||||||
|
"last_seen": _iso_utc(hb_last),
|
||||||
|
"age_seconds": hb_age,
|
||||||
|
"status": hb_row["status"] if hb_row else None,
|
||||||
|
},
|
||||||
|
"telemetry": telemetry,
|
||||||
|
"planning": planning,
|
||||||
|
"alerts": alerts,
|
||||||
|
}
|
||||||
@@ -1,72 +1,33 @@
|
|||||||
"""REST API – aktivní plán a ruční přepočet."""
|
"""REST API – aktivní plán a ruční přepočet."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.db_json import record_to_dict
|
||||||
from app.deps import get_pg_pool
|
from app.deps import get_pg_pool
|
||||||
from services.planning_engine import run_plan_api
|
from services.planning_engine import _current_slot_start, run_plan_api
|
||||||
|
|
||||||
router = APIRouter(prefix="/sites/{site_id}/plan", tags=["plan"])
|
router = APIRouter(prefix="/sites/{site_id}/plan", tags=["plan"])
|
||||||
|
|
||||||
|
PRICE_CHECK_HOURS = 24
|
||||||
class PlanningRunOut(BaseModel):
|
_SLOTS_PER_HOUR = 4
|
||||||
id: int
|
_EXPECTED_PRICE_SLOTS = PRICE_CHECK_HOURS * _SLOTS_PER_HOUR
|
||||||
created_at: datetime
|
|
||||||
run_type: str
|
|
||||||
horizon_start: datetime
|
|
||||||
horizon_end: datetime
|
|
||||||
forecast_correction_factor: float | None = None
|
|
||||||
solver_duration_ms: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PlanningIntervalOut(BaseModel):
|
|
||||||
interval_start: datetime
|
|
||||||
battery_setpoint_w: int | None = None
|
|
||||||
battery_soc_target_pct: float | None = None
|
|
||||||
grid_setpoint_w: int | None = None
|
|
||||||
ev1_setpoint_w: int | None = None
|
|
||||||
ev2_setpoint_w: int | None = None
|
|
||||||
heat_pump_enabled: bool | None = None
|
|
||||||
pv_a_curtailed_w: int | None = None
|
|
||||||
expected_cost_czk: float | None = None
|
|
||||||
effective_buy_price: float | None = None
|
|
||||||
effective_sell_price: float | None = None
|
|
||||||
pv_forecast_total_w: int | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Součet FVE forecast A+B pro graf (k aktuálnímu slotu z DB).",
|
|
||||||
)
|
|
||||||
load_baseline_w: int | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Bazální spotřeba forecast pro graf.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PlanningSummaryOut(BaseModel):
|
|
||||||
total_expected_cost_czk: float
|
|
||||||
total_pv_curtailed_kwh: float
|
|
||||||
charge_slots: int
|
|
||||||
discharge_slots: int
|
|
||||||
export_slots: int
|
|
||||||
|
|
||||||
|
|
||||||
class CurrentPlanResponse(BaseModel):
|
|
||||||
run: PlanningRunOut | None
|
|
||||||
intervals: list[PlanningIntervalOut]
|
|
||||||
summary: PlanningSummaryOut | None
|
|
||||||
|
|
||||||
|
|
||||||
class RunPlanResponse(BaseModel):
|
class RunPlanResponse(BaseModel):
|
||||||
run_id: int
|
run_id: int
|
||||||
solver_duration_ms: int
|
solver_duration_ms: int
|
||||||
|
horizon_start: datetime
|
||||||
|
horizon_end: datetime
|
||||||
|
|
||||||
|
|
||||||
def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
|
def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
total_cost = 0.0
|
total_cost = 0.0
|
||||||
curtailed_wh = 0.0
|
total_curtailed_kwh = 0.0
|
||||||
charge_slots = 0
|
charge_slots = 0
|
||||||
discharge_slots = 0
|
discharge_slots = 0
|
||||||
export_slots = 0
|
export_slots = 0
|
||||||
@@ -75,7 +36,7 @@ def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
|
|||||||
if ec is not None:
|
if ec is not None:
|
||||||
total_cost += float(ec)
|
total_cost += float(ec)
|
||||||
c = row.get("pv_a_curtailed_w") or 0
|
c = row.get("pv_a_curtailed_w") or 0
|
||||||
curtailed_wh += int(c) * 0.25
|
total_curtailed_kwh += int(c) * 0.25 / 1000.0
|
||||||
b = row.get("battery_setpoint_w")
|
b = row.get("battery_setpoint_w")
|
||||||
if b is not None:
|
if b is not None:
|
||||||
if int(b) > 0:
|
if int(b) > 0:
|
||||||
@@ -85,153 +46,110 @@ def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
|
|||||||
g = row.get("grid_setpoint_w")
|
g = row.get("grid_setpoint_w")
|
||||||
if g is not None and int(g) < 0:
|
if g is not None and int(g) < 0:
|
||||||
export_slots += 1
|
export_slots += 1
|
||||||
return PlanningSummaryOut(
|
return {
|
||||||
total_expected_cost_czk=round(total_cost, 4),
|
"total_expected_cost_czk": round(total_cost, 4),
|
||||||
total_pv_curtailed_kwh=round(curtailed_wh / 1000.0, 6),
|
"total_pv_curtailed_kwh": round(total_curtailed_kwh, 6),
|
||||||
charge_slots=charge_slots,
|
"charge_slots": charge_slots,
|
||||||
discharge_slots=discharge_slots,
|
"discharge_slots": discharge_slots,
|
||||||
export_slots=export_slots,
|
"export_slots": export_slots,
|
||||||
)
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/current", response_model=CurrentPlanResponse)
|
@router.get("/current")
|
||||||
async def get_current_plan(
|
async def get_current_plan(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
) -> CurrentPlanResponse:
|
) -> dict[str, Any]:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id)
|
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||||
if not exists:
|
if not site_ok:
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
run_row = await conn.fetchrow(
|
run_row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT id, created_at, run_type, horizon_start, horizon_end,
|
SELECT pr.*
|
||||||
forecast_correction_factor, solver_duration_ms
|
FROM ems.planning_run pr
|
||||||
FROM ems.planning_run
|
WHERE pr.site_id = $1 AND pr.status = 'active'
|
||||||
WHERE site_id = $1 AND status = 'active'
|
ORDER BY pr.created_at DESC
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
if not run_row:
|
if not run_row:
|
||||||
return CurrentPlanResponse(run=None, intervals=[], summary=None)
|
raise HTTPException(status_code=404, detail="No active plan")
|
||||||
|
|
||||||
run_id = run_row["id"]
|
run_id = run_row["id"]
|
||||||
int_rows = await conn.fetch(
|
int_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT *
|
||||||
pi.interval_start,
|
FROM ems.planning_interval
|
||||||
pi.battery_setpoint_w,
|
WHERE run_id = $1
|
||||||
pi.battery_soc_target_pct,
|
ORDER BY interval_start
|
||||||
pi.grid_setpoint_w,
|
|
||||||
pi.ev1_setpoint_w,
|
|
||||||
pi.ev2_setpoint_w,
|
|
||||||
pi.heat_pump_enabled,
|
|
||||||
pi.pv_a_curtailed_w,
|
|
||||||
pi.expected_cost_czk,
|
|
||||||
pi.effective_buy_price,
|
|
||||||
pi.effective_sell_price,
|
|
||||||
COALESCE(fa.power_w, 0) + COALESCE(fb.power_w, 0) AS pv_forecast_total_w,
|
|
||||||
COALESCE(cbi.power_w, 500) AS load_baseline_w
|
|
||||||
FROM ems.planning_interval pi
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT fpi.power_w
|
|
||||||
FROM ems.forecast_pv_interval fpi
|
|
||||||
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
|
||||||
JOIN ems.asset_pv_array apa ON apa.id = fpi.pv_array_id AND apa.site_id = fpr.site_id
|
|
||||||
WHERE fpr.site_id = $2
|
|
||||||
AND apa.code = 'pv-a'
|
|
||||||
AND fpi.interval_start = pi.interval_start
|
|
||||||
AND fpr.status = 'ok'
|
|
||||||
ORDER BY fpr.created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
) fa ON true
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT fpi.power_w
|
|
||||||
FROM ems.forecast_pv_interval fpi
|
|
||||||
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
|
||||||
JOIN ems.asset_pv_array apa ON apa.id = fpi.pv_array_id AND apa.site_id = fpr.site_id
|
|
||||||
WHERE fpr.site_id = $2
|
|
||||||
AND apa.code = 'pv-b'
|
|
||||||
AND fpi.interval_start = pi.interval_start
|
|
||||||
AND fpr.status = 'ok'
|
|
||||||
ORDER BY fpr.created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
) fb ON true
|
|
||||||
LEFT JOIN ems.consumption_baseline_interval cbi
|
|
||||||
ON cbi.site_id = $2
|
|
||||||
AND cbi.interval_start = pi.interval_start
|
|
||||||
AND cbi.data_type = 'forecast'
|
|
||||||
WHERE pi.run_id = $1
|
|
||||||
ORDER BY pi.interval_start
|
|
||||||
""",
|
""",
|
||||||
run_id,
|
run_id,
|
||||||
site_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
intervals_dicts = [dict(r) for r in int_rows]
|
intervals = [record_to_dict(r) for r in int_rows]
|
||||||
summary = _build_summary(intervals_dicts) if intervals_dicts else None
|
summary = _build_summary(intervals)
|
||||||
|
return {"run": record_to_dict(run_row), "intervals": intervals, "summary": summary}
|
||||||
run_out = PlanningRunOut(
|
|
||||||
id=run_row["id"],
|
|
||||||
created_at=run_row["created_at"],
|
|
||||||
run_type=run_row["run_type"],
|
|
||||||
horizon_start=run_row["horizon_start"],
|
|
||||||
horizon_end=run_row["horizon_end"],
|
|
||||||
forecast_correction_factor=float(run_row["forecast_correction_factor"])
|
|
||||||
if run_row["forecast_correction_factor"] is not None
|
|
||||||
else None,
|
|
||||||
solver_duration_ms=run_row["solver_duration_ms"],
|
|
||||||
)
|
|
||||||
|
|
||||||
intervals_out = [
|
|
||||||
PlanningIntervalOut(
|
|
||||||
interval_start=r["interval_start"],
|
|
||||||
battery_setpoint_w=r["battery_setpoint_w"],
|
|
||||||
battery_soc_target_pct=float(r["battery_soc_target_pct"])
|
|
||||||
if r["battery_soc_target_pct"] is not None
|
|
||||||
else None,
|
|
||||||
grid_setpoint_w=r["grid_setpoint_w"],
|
|
||||||
ev1_setpoint_w=r["ev1_setpoint_w"],
|
|
||||||
ev2_setpoint_w=r["ev2_setpoint_w"],
|
|
||||||
heat_pump_enabled=r["heat_pump_enabled"],
|
|
||||||
pv_a_curtailed_w=r["pv_a_curtailed_w"],
|
|
||||||
expected_cost_czk=float(r["expected_cost_czk"])
|
|
||||||
if r["expected_cost_czk"] is not None
|
|
||||||
else None,
|
|
||||||
effective_buy_price=float(r["effective_buy_price"])
|
|
||||||
if r["effective_buy_price"] is not None
|
|
||||||
else None,
|
|
||||||
effective_sell_price=float(r["effective_sell_price"])
|
|
||||||
if r["effective_sell_price"] is not None
|
|
||||||
else None,
|
|
||||||
pv_forecast_total_w=int(r["pv_forecast_total_w"] or 0),
|
|
||||||
load_baseline_w=int(r["load_baseline_w"] or 0),
|
|
||||||
)
|
|
||||||
for r in intervals_dicts
|
|
||||||
]
|
|
||||||
|
|
||||||
return CurrentPlanResponse(run=run_out, intervals=intervals_out, summary=summary)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/run", response_model=RunPlanResponse)
|
@router.post("/run", response_model=RunPlanResponse)
|
||||||
async def post_run_plan(
|
async def post_run_plan(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
plan_type: Literal["daily", "rolling"] = Query(..., alias="type"),
|
plan_type: Literal["daily", "rolling"] = Query("rolling", alias="type"),
|
||||||
) -> RunPlanResponse:
|
) -> RunPlanResponse:
|
||||||
|
window_start = _current_slot_start(datetime.now(timezone.utc))
|
||||||
|
window_end = window_start + timedelta(hours=PRICE_CHECK_HOURS)
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id)
|
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||||
if not exists:
|
if not site_ok:
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
|
price_slots = await conn.fetchval(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(DISTINCT interval_start)::int
|
||||||
|
FROM ems.vw_site_effective_price
|
||||||
|
WHERE site_id = $1
|
||||||
|
AND interval_start >= $2
|
||||||
|
AND interval_start < $3
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
window_start,
|
||||||
|
window_end,
|
||||||
|
)
|
||||||
|
if (price_slots or 0) < _EXPECTED_PRICE_SLOTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Nejsou dostupné tržní ceny. Spusťte nejdřív import cen.",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run_id, duration_ms = await run_plan_api(
|
run_id, solver_duration_ms = await run_plan_api(
|
||||||
site_id, conn, plan_type=plan_type, triggered_by="api"
|
site_id, plan_type, conn, triggered_by="api"
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||||
return RunPlanResponse(run_id=run_id, solver_duration_ms=duration_ms)
|
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT horizon_start, horizon_end
|
||||||
|
FROM ems.planning_run
|
||||||
|
WHERE id = $1
|
||||||
|
""",
|
||||||
|
run_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Planning run row missing after insert")
|
||||||
|
|
||||||
|
return RunPlanResponse(
|
||||||
|
run_id=run_id,
|
||||||
|
solver_duration_ms=solver_duration_ms,
|
||||||
|
horizon_start=row["horizon_start"],
|
||||||
|
horizon_end=row["horizon_end"],
|
||||||
|
)
|
||||||
|
|||||||
47
backend/services/audit_filler.py
Normal file
47
backend/services/audit_filler.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Plnění audit_interval pro dokončené 15min sloty (volá ems.fn_fill_audit_interval)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def fill_audit_for_completed_intervals(site_id: int, db) -> None:
|
||||||
|
"""
|
||||||
|
Naplní audit_interval pro všechny dokončené 15min intervaly
|
||||||
|
za posledních 6 hodin které ještě nemají záznam.
|
||||||
|
Volá PostgreSQL funkci ems.fn_fill_audit_interval().
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
last_complete = now.replace(
|
||||||
|
minute=(now.minute // 15) * 15, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT gs.slot
|
||||||
|
FROM generate_series(
|
||||||
|
$1::timestamptz - interval '6 hours',
|
||||||
|
$1::timestamptz - interval '15 minutes',
|
||||||
|
interval '15 minutes'
|
||||||
|
) AS gs(slot)
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM ems.audit_interval ai
|
||||||
|
WHERE ai.site_id = $2 AND ai.interval_start = gs.slot
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
last_complete,
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
await db.execute(
|
||||||
|
"SELECT ems.fn_fill_audit_interval($1, $2)",
|
||||||
|
site_id,
|
||||||
|
row["slot"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
logger.info("[site=%s] Filled %s missing audit intervals", site_id, len(rows))
|
||||||
425
backend/services/control_exporter.py
Normal file
425
backend/services/control_exporter.py
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
"""Export plánovaných setpointů na Modbus (Deye, EV, TČ) a HTTP do Loxone."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import asyncpg
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from services.telemetry_collector import ModbusDevice
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int:
|
||||||
|
if not power_w or power_w <= 0:
|
||||||
|
return 0
|
||||||
|
return min(32, max(0, int(power_w / (phases * voltage))))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ControlSetpoints:
|
||||||
|
battery_w: int | None
|
||||||
|
grid_export_limit: int
|
||||||
|
ev1_current_a: int
|
||||||
|
ev2_current_a: int
|
||||||
|
heat_pump_enable: bool
|
||||||
|
grid_setpoint_w: int
|
||||||
|
ev1_power_w: int
|
||||||
|
ev2_power_w: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OperatingModeInfo:
|
||||||
|
mode_code: str
|
||||||
|
battery_mode: str
|
||||||
|
grid_mode: str
|
||||||
|
ev_enabled: bool
|
||||||
|
heat_pump_enabled_def: bool
|
||||||
|
loxone_mode_value: int
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_u16(value: int) -> int:
|
||||||
|
return max(0, min(65535, int(value)))
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> OperatingModeInfo | None:
|
||||||
|
sql = """
|
||||||
|
SELECT som.mode_code, omd.battery_mode, omd.grid_mode,
|
||||||
|
omd.ev_enabled, omd.heat_pump_enabled, omd.loxone_mode_value,
|
||||||
|
som.valid_until
|
||||||
|
FROM ems.site_operating_mode som
|
||||||
|
JOIN ems.operating_mode_def omd ON omd.code = som.mode_code
|
||||||
|
WHERE som.site_id = $1
|
||||||
|
"""
|
||||||
|
row = await db.fetchrow(sql, site_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
vu = row["valid_until"]
|
||||||
|
if vu is not None:
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
if vu.tzinfo is None:
|
||||||
|
vu = vu.replace(tzinfo=timezone.utc)
|
||||||
|
if vu <= now_utc:
|
||||||
|
await db.execute("SELECT ems.fn_expire_modes()")
|
||||||
|
row = await db.fetchrow(sql, site_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return OperatingModeInfo(
|
||||||
|
mode_code=row["mode_code"],
|
||||||
|
battery_mode=row["battery_mode"],
|
||||||
|
grid_mode=row["grid_mode"],
|
||||||
|
ev_enabled=bool(row["ev_enabled"]),
|
||||||
|
heat_pump_enabled_def=bool(row["heat_pump_enabled"]),
|
||||||
|
loxone_mode_value=int(row["loxone_mode_value"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_current_slot_plan_row(site_id: int, db: asyncpg.Connection) -> asyncpg.Record | None:
|
||||||
|
"""Řádek plánu pro následující 15min slot (export ~1 min před hranicí, např. 14:29 → 14:30–14:45)."""
|
||||||
|
return await db.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT pi.* FROM ems.planning_interval pi
|
||||||
|
JOIN ems.planning_run pr ON pr.id = pi.run_id
|
||||||
|
WHERE pr.site_id = $1 AND pr.status = 'active'
|
||||||
|
AND pi.interval_start = (
|
||||||
|
SELECT MIN(pi2.interval_start) FROM ems.planning_interval pi2
|
||||||
|
JOIN ems.planning_run pr2 ON pr2.id = pi2.run_id
|
||||||
|
WHERE pr2.site_id = $1 AND pr2.status = 'active'
|
||||||
|
AND pi2.interval_start >= date_trunc('hour', now())
|
||||||
|
+ INTERVAL '15 min' * FLOOR(EXTRACT(MINUTE FROM now()) / 15)
|
||||||
|
+ INTERVAL '15 minutes'
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int:
|
||||||
|
v = await db.fetchval(
|
||||||
|
"""
|
||||||
|
SELECT ai.max_charge_power_w
|
||||||
|
FROM ems.asset_inverter ai
|
||||||
|
WHERE ai.site_id = $1 AND ai.controllable = true AND ai.active = true
|
||||||
|
ORDER BY ai.id
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
if v is None:
|
||||||
|
return 0
|
||||||
|
return int(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> ControlSetpoints | None:
|
||||||
|
code = mode.mode_code
|
||||||
|
if code == "MANUAL":
|
||||||
|
return None
|
||||||
|
|
||||||
|
if code == "AUTO":
|
||||||
|
if pi is None:
|
||||||
|
return None
|
||||||
|
grid_sp = int(pi["grid_setpoint_w"] or 0)
|
||||||
|
ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0
|
||||||
|
ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0
|
||||||
|
hp_en = bool(pi["heat_pump_enabled"])
|
||||||
|
return ControlSetpoints(
|
||||||
|
battery_w=int(pi["battery_setpoint_w"] or 0),
|
||||||
|
grid_export_limit=abs(min(grid_sp, 0)),
|
||||||
|
ev1_current_a=watts_to_amps(ev1_w, phases=3),
|
||||||
|
ev2_current_a=watts_to_amps(ev2_w, phases=1),
|
||||||
|
heat_pump_enable=hp_en,
|
||||||
|
grid_setpoint_w=grid_sp,
|
||||||
|
ev1_power_w=ev1_w,
|
||||||
|
ev2_power_w=ev2_w,
|
||||||
|
)
|
||||||
|
|
||||||
|
if code == "SELF_SUSTAIN":
|
||||||
|
return ControlSetpoints(
|
||||||
|
battery_w=None,
|
||||||
|
grid_export_limit=0,
|
||||||
|
ev1_current_a=0,
|
||||||
|
ev2_current_a=0,
|
||||||
|
heat_pump_enable=False,
|
||||||
|
grid_setpoint_w=0,
|
||||||
|
ev1_power_w=0,
|
||||||
|
ev2_power_w=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if code == "CHARGE_CHEAP":
|
||||||
|
# max_charge doplníme v export_setpoints z DB
|
||||||
|
return ControlSetpoints(
|
||||||
|
battery_w=0,
|
||||||
|
grid_export_limit=0,
|
||||||
|
ev1_current_a=0,
|
||||||
|
ev2_current_a=0,
|
||||||
|
heat_pump_enable=False,
|
||||||
|
grid_setpoint_w=0,
|
||||||
|
ev1_power_w=0,
|
||||||
|
ev2_power_w=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if code == "PRESERVE":
|
||||||
|
return ControlSetpoints(
|
||||||
|
battery_w=0,
|
||||||
|
grid_export_limit=0,
|
||||||
|
ev1_current_a=0,
|
||||||
|
ev2_current_a=0,
|
||||||
|
heat_pump_enable=False,
|
||||||
|
grid_setpoint_w=0,
|
||||||
|
ev1_power_w=0,
|
||||||
|
ev2_power_w=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.warning("Unknown mode_code %s for site export, skipping", code)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def write_inverter_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str:
|
||||||
|
if setpoints.battery_w is None:
|
||||||
|
return "OK inverter: skipped (battery_w=None, Deye unchanged)"
|
||||||
|
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT ai.code, se.host, se.port, se.unit_id
|
||||||
|
FROM ems.asset_inverter ai
|
||||||
|
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
|
||||||
|
WHERE ai.site_id = $1
|
||||||
|
AND ai.controllable = true
|
||||||
|
AND ai.active = true
|
||||||
|
AND se.enabled = true
|
||||||
|
AND se.endpoint_type = 'modbus_tcp'
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return "FAIL inverter: no controllable Modbus endpoint"
|
||||||
|
|
||||||
|
bw = setpoints.battery_w
|
||||||
|
gex = _clamp_u16(setpoints.grid_export_limit)
|
||||||
|
chg = _clamp_u16(bw) if bw >= 0 else 0
|
||||||
|
dis = _clamp_u16(abs(bw)) if bw < 0 else 0
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
code = row["code"]
|
||||||
|
host = row["host"]
|
||||||
|
port = int(row["port"] or 502)
|
||||||
|
unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
|
||||||
|
dev = ModbusDevice(host, port, unit_id, f"inverter-write:{code}")
|
||||||
|
try:
|
||||||
|
if bw >= 0:
|
||||||
|
ok1 = await dev.write_register(0x00F3, chg)
|
||||||
|
ok2 = await dev.write_register(0x00F4, 0)
|
||||||
|
else:
|
||||||
|
ok1 = await dev.write_register(0x00F3, 0)
|
||||||
|
ok2 = await dev.write_register(0x00F4, dis)
|
||||||
|
ok3 = await dev.write_register(0x00F6, gex)
|
||||||
|
if not (ok1 and ok2 and ok3):
|
||||||
|
errors.append(f"{code}: Modbus write failed")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{code}: {e}")
|
||||||
|
finally:
|
||||||
|
await dev.close()
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return "FAIL inverter: " + "; ".join(errors)
|
||||||
|
return f"OK inverter: batt_w={bw} export_limit_w={gex}"
|
||||||
|
|
||||||
|
|
||||||
|
def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int:
|
||||||
|
c = (charger_code or "").strip().lower()
|
||||||
|
if c == "ev-charger-1":
|
||||||
|
a = sp.ev1_current_a
|
||||||
|
elif c == "ev-charger-2":
|
||||||
|
a = sp.ev2_current_a
|
||||||
|
elif c.endswith("-1") or c == "ev1":
|
||||||
|
a = sp.ev1_current_a
|
||||||
|
elif c.endswith("-2") or c == "ev2":
|
||||||
|
a = sp.ev2_current_a
|
||||||
|
else:
|
||||||
|
a = 0
|
||||||
|
if a < 6:
|
||||||
|
a = 0
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
async def write_ev_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str:
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT ec.code, se.host, se.port, se.unit_id
|
||||||
|
FROM ems.asset_ev_charger ec
|
||||||
|
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
||||||
|
WHERE ec.site_id = $1
|
||||||
|
AND ec.schedulable = true
|
||||||
|
AND se.enabled = true
|
||||||
|
AND se.endpoint_type = 'modbus_tcp'
|
||||||
|
ORDER BY ec.code
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return "OK EV: no schedulable chargers"
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
code = row["code"]
|
||||||
|
current_a = _current_limit_for_charger(code, setpoints)
|
||||||
|
logger.info(
|
||||||
|
"EV setpoint [%s]: %sA (TODO: Modbus registers)",
|
||||||
|
code,
|
||||||
|
current_a,
|
||||||
|
)
|
||||||
|
return f"OK EV: logged {len(rows)} charger(s) (Modbus TODO)"
|
||||||
|
|
||||||
|
|
||||||
|
async def write_heat_pump_setpoint(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str:
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT hp.code, se.host, se.port, se.unit_id
|
||||||
|
FROM ems.asset_heat_pump hp
|
||||||
|
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
|
||||||
|
WHERE hp.site_id = $1
|
||||||
|
AND hp.schedulable = true
|
||||||
|
AND se.enabled = true
|
||||||
|
AND se.endpoint_type = 'modbus_tcp'
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return "OK heat pump: no schedulable unit"
|
||||||
|
for row in rows:
|
||||||
|
logger.info(
|
||||||
|
"HP setpoint [%s]: enable=%s (TODO: Modbus registers)",
|
||||||
|
row["code"],
|
||||||
|
setpoints.heat_pump_enable,
|
||||||
|
)
|
||||||
|
return "OK heat pump: logged (Modbus TODO)"
|
||||||
|
|
||||||
|
|
||||||
|
async def send_loxone_setpoints(
|
||||||
|
site_id: int,
|
||||||
|
setpoints: ControlSetpoints,
|
||||||
|
mode: OperatingModeInfo,
|
||||||
|
db: asyncpg.Connection,
|
||||||
|
) -> str:
|
||||||
|
endpoint = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT host, port, protocol
|
||||||
|
FROM ems.site_endpoint
|
||||||
|
WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
if not endpoint:
|
||||||
|
return "OK Loxone: no endpoint, skipped"
|
||||||
|
|
||||||
|
proto = (endpoint["protocol"] or "http").lower()
|
||||||
|
if proto not in ("http", "https"):
|
||||||
|
proto = "http"
|
||||||
|
host = endpoint["host"]
|
||||||
|
port = int(endpoint["port"] or (443 if proto == "https" else 80))
|
||||||
|
base = f"{proto}://{host}:{port}/dev/sps/io"
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
user = settings.loxone_user or os.getenv("LOXONE_USER") or ""
|
||||||
|
password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or ""
|
||||||
|
auth = (user, password) if user else None
|
||||||
|
|
||||||
|
batt_display = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
|
||||||
|
|
||||||
|
paths: list[tuple[str, int]] = [
|
||||||
|
(f"{base}/EMS_Mode/{mode.loxone_mode_value}", mode.loxone_mode_value),
|
||||||
|
(f"{base}/EMS_Battery_Setpoint_W/{batt_display}", batt_display),
|
||||||
|
(f"{base}/EMS_Grid_Setpoint_W/{setpoints.grid_setpoint_w}", setpoints.grid_setpoint_w),
|
||||||
|
(f"{base}/EMS_EV1_Power_W/{setpoints.ev1_power_w}", setpoints.ev1_power_w),
|
||||||
|
(f"{base}/EMS_EV2_Power_W/{setpoints.ev2_power_w}", setpoints.ev2_power_w),
|
||||||
|
(f"{base}/EMS_HeatPump_Enable/{1 if setpoints.heat_pump_enable else 0}", 1 if setpoints.heat_pump_enable else 0),
|
||||||
|
]
|
||||||
|
|
||||||
|
errs: list[str] = []
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
for url, _ in paths:
|
||||||
|
try:
|
||||||
|
r = await client.get(url, auth=auth)
|
||||||
|
r.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
errs.append(f"{url!s}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
return f"FAIL Loxone: client {e}"
|
||||||
|
|
||||||
|
if errs:
|
||||||
|
return "FAIL Loxone: " + "; ".join(errs[:3])
|
||||||
|
return "OK Loxone: all virtual inputs updated"
|
||||||
|
|
||||||
|
|
||||||
|
async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
|
||||||
|
mode = await _fetch_operating_mode(site_id, db)
|
||||||
|
if mode is None:
|
||||||
|
logger.warning("control export site=%s: no operating mode row", site_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if mode.mode_code == "MANUAL":
|
||||||
|
logger.info("control export site=%s: MANUAL, skip writes", site_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
pi = await _fetch_current_slot_plan_row(site_id, db)
|
||||||
|
sp = _build_setpoints(mode, pi)
|
||||||
|
|
||||||
|
if mode.mode_code == "AUTO" and sp is None:
|
||||||
|
if pi is None:
|
||||||
|
logger.warning(
|
||||||
|
"control export site=%s: AUTO but no planning_interval for current slot, skip",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if sp is None:
|
||||||
|
logger.warning(
|
||||||
|
"control export site=%s: no setpoints for mode %s, skip",
|
||||||
|
site_id,
|
||||||
|
mode.mode_code,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if mode.mode_code == "CHARGE_CHEAP":
|
||||||
|
max_ch = await _fetch_max_charge_power_w(site_id, db)
|
||||||
|
sp = ControlSetpoints(
|
||||||
|
battery_w=max_ch,
|
||||||
|
grid_export_limit=0,
|
||||||
|
ev1_current_a=0,
|
||||||
|
ev2_current_a=0,
|
||||||
|
heat_pump_enable=False,
|
||||||
|
grid_setpoint_w=0,
|
||||||
|
ev1_power_w=0,
|
||||||
|
ev2_power_w=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = list(
|
||||||
|
zip(
|
||||||
|
("inverter", "ev", "heat_pump", "loxone"),
|
||||||
|
await asyncio.gather(
|
||||||
|
write_inverter_setpoints(site_id, sp, db),
|
||||||
|
write_ev_setpoints(site_id, sp, db),
|
||||||
|
write_heat_pump_setpoint(site_id, sp, db),
|
||||||
|
send_loxone_setpoints(site_id, sp, mode, db),
|
||||||
|
return_exceptions=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for name, res in results:
|
||||||
|
if isinstance(res, Exception):
|
||||||
|
logger.error("control export site=%s %s: FAIL %s", site_id, name, res)
|
||||||
|
elif isinstance(res, str) and res.startswith("FAIL"):
|
||||||
|
logger.error("control export site=%s %s: %s", site_id, name, res)
|
||||||
|
else:
|
||||||
|
logger.info("control export site=%s %s: %s", site_id, name, res)
|
||||||
247
backend/services/forecast_service.py
Normal file
247
backend/services/forecast_service.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""FVE production forecast from Open-Meteo + pvlib (15min intervals)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pandas as pd
|
||||||
|
import pvlib
|
||||||
|
from pvlib import irradiance
|
||||||
|
from pvlib.pvsystem import pvwatts_dc
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _db_azimuth_to_pvlib(surface_azimuth_db_deg: float) -> float:
|
||||||
|
"""DB: 0=jih, 90=západ, -90=východ → pvlib (N=0, E=90, S=180, W=270)."""
|
||||||
|
return float((surface_azimuth_db_deg + 180) % 360)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Stáhne počasí (Open-Meteo), pro každé FVE pole spočte výkon (pvlib) a uloží intervaly.
|
||||||
|
|
||||||
|
Open-Meteo nepodporuje název ``diffuse_horizontal_irradiance``; používá se
|
||||||
|
``diffuse_radiation`` (DHI) a ``shortwave_radiation`` (GHI). Data jsou
|
||||||
|
``minutely_15`` kvůli 15min slotům v ``ems.forecast_pv_interval``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(celkový_počet_řádků_forecast_pv_interval, počet_FVE_polí)``.
|
||||||
|
Při chybě ``(-1, 0)``. Bez polí ``(0, 0)``.
|
||||||
|
"""
|
||||||
|
site = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT latitude, longitude, timezone
|
||||||
|
FROM ems.site
|
||||||
|
WHERE id = $1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
if site is None:
|
||||||
|
logger.error("fetch_pv_forecast: site id=%s nenalezen", site_id)
|
||||||
|
return -1, 0
|
||||||
|
|
||||||
|
if site["latitude"] is None or site["longitude"] is None:
|
||||||
|
logger.error("fetch_pv_forecast: site id=%s nemá latitude/longitude", site_id)
|
||||||
|
return -1, 0
|
||||||
|
|
||||||
|
lat = float(site["latitude"])
|
||||||
|
lon = float(site["longitude"])
|
||||||
|
tz_name: str = site["timezone"] or "Europe/Prague"
|
||||||
|
|
||||||
|
try:
|
||||||
|
ZoneInfo(tz_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("fetch_pv_forecast: neplatná timezone %r: %s", tz_name, e)
|
||||||
|
return -1, 0
|
||||||
|
|
||||||
|
arrays = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM ems.asset_pv_array
|
||||||
|
WHERE site_id = $1
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
if not arrays:
|
||||||
|
logger.info("fetch_pv_forecast: žádná FVE pole pro site_id=%s", site_id)
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
n_arrays = len(arrays)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
base = settings.open_meteo_api_url.rstrip("/")
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"latitude": lat,
|
||||||
|
"longitude": lon,
|
||||||
|
"minutely_15": ",".join(
|
||||||
|
[
|
||||||
|
"direct_normal_irradiance",
|
||||||
|
"diffuse_radiation",
|
||||||
|
"shortwave_radiation",
|
||||||
|
"temperature_2m",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
"forecast_days": 2,
|
||||||
|
"timezone": "auto",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||||
|
resp = await client.get(base, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.warning("fetch_pv_forecast: timeout Open-Meteo")
|
||||||
|
return -1, 0
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.warning(
|
||||||
|
"fetch_pv_forecast: HTTP %s Open-Meteo: %s",
|
||||||
|
e.response.status_code,
|
||||||
|
e.response.text[:500],
|
||||||
|
)
|
||||||
|
return -1, 0
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.warning("fetch_pv_forecast: HTTP chyba Open-Meteo: %s", e)
|
||||||
|
return -1, 0
|
||||||
|
|
||||||
|
m15 = data.get("minutely_15") or {}
|
||||||
|
times_raw = m15.get("time")
|
||||||
|
if not times_raw or not isinstance(times_raw, list):
|
||||||
|
snippet = json.dumps(data, ensure_ascii=False)[:500]
|
||||||
|
logger.error("fetch_pv_forecast: chybí minutely_15.time, začátek: %s", snippet)
|
||||||
|
return -1, 0
|
||||||
|
|
||||||
|
api_tz = data.get("timezone") or tz_name
|
||||||
|
try:
|
||||||
|
tzinfo = ZoneInfo(api_tz)
|
||||||
|
except Exception:
|
||||||
|
tzinfo = ZoneInfo(tz_name)
|
||||||
|
|
||||||
|
times = pd.DatetimeIndex(pd.to_datetime(times_raw))
|
||||||
|
if times.tz is None:
|
||||||
|
times = times.tz_localize(tzinfo)
|
||||||
|
|
||||||
|
def _series(key: str) -> pd.Series:
|
||||||
|
raw = m15.get(key)
|
||||||
|
if not isinstance(raw, list) or len(raw) != len(times):
|
||||||
|
return pd.Series(0.0, index=times, dtype=float)
|
||||||
|
return pd.Series(
|
||||||
|
[0.0 if v is None else float(v) for v in raw],
|
||||||
|
index=times,
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
|
||||||
|
dni = _series("direct_normal_irradiance")
|
||||||
|
ghi = _series("shortwave_radiation")
|
||||||
|
dhi = _series("diffuse_radiation")
|
||||||
|
temp_air = _series("temperature_2m")
|
||||||
|
|
||||||
|
loc = pvlib.location.Location(lat, lon, tz=api_tz)
|
||||||
|
solar_pos = loc.get_solarposition(times)
|
||||||
|
|
||||||
|
total_rows = 0
|
||||||
|
horizon_start = times[0].tz_convert(timezone.utc).to_pydatetime()
|
||||||
|
horizon_end = (
|
||||||
|
times[-1].tz_convert(timezone.utc).to_pydatetime() + timedelta(minutes=15)
|
||||||
|
)
|
||||||
|
|
||||||
|
for arr in arrays:
|
||||||
|
tilt = float(arr["tilt_deg"] or 0.0)
|
||||||
|
az_db = float(arr["azimuth_deg"] or 0.0)
|
||||||
|
az_pvlib = _db_azimuth_to_pvlib(az_db)
|
||||||
|
pdc0 = float(arr["nominal_power_wp"])
|
||||||
|
shading = float(arr["shading_factor"] or 1.0)
|
||||||
|
|
||||||
|
poa = irradiance.get_total_irradiance(
|
||||||
|
surface_tilt=tilt,
|
||||||
|
surface_azimuth=az_pvlib,
|
||||||
|
solar_zenith=solar_pos["apparent_zenith"],
|
||||||
|
solar_azimuth=solar_pos["azimuth"],
|
||||||
|
dni=dni,
|
||||||
|
ghi=ghi,
|
||||||
|
dhi=dhi,
|
||||||
|
model="haydavies",
|
||||||
|
)["poa_global"].fillna(0).clip(lower=0)
|
||||||
|
|
||||||
|
temp_cell = temp_air + 0.04 * poa
|
||||||
|
p_dc = pvwatts_dc(poa, temp_cell, pdc0, -0.004)
|
||||||
|
p_dc = p_dc.fillna(0).clip(lower=0) * shading
|
||||||
|
power_w = p_dc.round().astype(int)
|
||||||
|
|
||||||
|
model_params: dict[str, Any] = {
|
||||||
|
"source": "open_meteo",
|
||||||
|
"endpoint": base,
|
||||||
|
"params": params,
|
||||||
|
"pvlib_model": "haydavies",
|
||||||
|
"pvwatts_gamma_pdc": -0.004,
|
||||||
|
}
|
||||||
|
|
||||||
|
run_id = await db.fetchval(
|
||||||
|
"""
|
||||||
|
INSERT INTO ems.forecast_pv_run (
|
||||||
|
site_id,
|
||||||
|
pv_array_id,
|
||||||
|
forecast_source,
|
||||||
|
model_params,
|
||||||
|
horizon_start,
|
||||||
|
horizon_end,
|
||||||
|
status
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4::jsonb, $5, $6, 'ok')
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
arr["id"],
|
||||||
|
"open_meteo",
|
||||||
|
json.dumps(model_params),
|
||||||
|
horizon_start,
|
||||||
|
horizon_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for ts, p, g, t in zip(
|
||||||
|
times,
|
||||||
|
power_w,
|
||||||
|
ghi,
|
||||||
|
temp_air,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
interval_start = ts.tz_convert(timezone.utc).to_pydatetime()
|
||||||
|
records.append(
|
||||||
|
(
|
||||||
|
run_id,
|
||||||
|
arr["id"],
|
||||||
|
interval_start,
|
||||||
|
int(p),
|
||||||
|
float(g),
|
||||||
|
float(t),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO ems.forecast_pv_interval (
|
||||||
|
run_id,
|
||||||
|
pv_array_id,
|
||||||
|
interval_start,
|
||||||
|
power_w,
|
||||||
|
irradiance_wm2,
|
||||||
|
temp_c
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
""",
|
||||||
|
records,
|
||||||
|
)
|
||||||
|
total_rows += len(records)
|
||||||
|
|
||||||
|
return total_rows, n_arrays
|
||||||
70
backend/services/heartbeat_service.py
Normal file
70
backend/services/heartbeat_service.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Heartbeat: DB záznam + volitelný HTTP pulz do Loxone."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
EMS_BACKEND_VERSION = "v1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
async def send_heartbeat(
|
||||||
|
site_id: int,
|
||||||
|
db,
|
||||||
|
loxone_host: str | None = None,
|
||||||
|
loxone_port: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
1. Aktualizuje ems.site_heartbeat v DB
|
||||||
|
2. Pokud je Loxone nakonfigurováno, pošle HTTP pulz
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
endpoint = await db.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT host, port, protocol, auth_reference
|
||||||
|
FROM ems.site_endpoint
|
||||||
|
WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
loxone_ok = False
|
||||||
|
if endpoint:
|
||||||
|
proto = (endpoint["protocol"] or "http").lower()
|
||||||
|
if proto not in ("http", "https"):
|
||||||
|
proto = "http"
|
||||||
|
host = loxone_host if loxone_host is not None else endpoint["host"]
|
||||||
|
if loxone_port is not None:
|
||||||
|
port = int(loxone_port)
|
||||||
|
else:
|
||||||
|
port = int(endpoint["port"] or (443 if proto == "https" else 80))
|
||||||
|
|
||||||
|
url = f"{proto}://{host}:{port}/dev/sps/io/EMS_Heartbeat/1"
|
||||||
|
settings = get_settings()
|
||||||
|
user = settings.loxone_user or os.getenv("LOXONE_USER") or ""
|
||||||
|
password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or ""
|
||||||
|
auth = (user, password) if user else None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
await client.get(url, auth=auth)
|
||||||
|
loxone_ok = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Heartbeat Loxone failed (site=%s): %s", site_id, e)
|
||||||
|
|
||||||
|
status = "ok" if (not endpoint or loxone_ok) else "degraded"
|
||||||
|
await db.execute(
|
||||||
|
"SELECT ems.fn_update_heartbeat($1, $2, $3)",
|
||||||
|
site_id,
|
||||||
|
status,
|
||||||
|
EMS_BACKEND_VERSION,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Heartbeat service error (site=%s): %s", site_id, e)
|
||||||
@@ -349,8 +349,6 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
|||||||
logger.info(f"[site={site_id}] Daily plan: {horizon_from} → {horizon_to}")
|
logger.info(f"[site={site_id}] Daily plan: {horizon_from} → {horizon_to}")
|
||||||
|
|
||||||
slots = await _load_slots(site_id, horizon_from, horizon_to, db)
|
slots = await _load_slots(site_id, horizon_from, horizon_to, db)
|
||||||
if not slots:
|
|
||||||
raise RuntimeError(f"No planning slots for site_id={site_id} (prices/forecast horizon?)")
|
|
||||||
|
|
||||||
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp = await _load_site_context(
|
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp = await _load_site_context(
|
||||||
site_id, db
|
site_id, db
|
||||||
@@ -430,9 +428,6 @@ async def run_rolling_replan(
|
|||||||
correction_factor, correction_log = await compute_correction_factor(site_id, now, db)
|
correction_factor, correction_log = await compute_correction_factor(site_id, now, db)
|
||||||
|
|
||||||
slots = await _load_slots(site_id, replan_from, horizon_to, db)
|
slots = await _load_slots(site_id, replan_from, horizon_to, db)
|
||||||
if not slots:
|
|
||||||
logger.warning(f"[site={site_id}] Rolling replan: no slots, running daily plan")
|
|
||||||
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
|
||||||
|
|
||||||
slots = apply_forecast_correction(slots, now, correction_factor)
|
slots = apply_forecast_correction(slots, now, correction_factor)
|
||||||
|
|
||||||
@@ -477,7 +472,13 @@ async def run_rolling_replan(
|
|||||||
return run_id, duration_ms
|
return run_id, duration_ms
|
||||||
|
|
||||||
|
|
||||||
async def run_plan_api(site_id: int, db, plan_type: str, triggered_by: str = "api") -> tuple[int, int]:
|
async def run_plan_api(
|
||||||
|
site_id: int,
|
||||||
|
plan_type: str,
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
triggered_by: str = "api",
|
||||||
|
) -> tuple[int, int]:
|
||||||
"""Ruční / UI spuštění plánu. Vždy vrátí (run_id, solver_duration_ms)."""
|
"""Ruční / UI spuštění plánu. Vždy vrátí (run_id, solver_duration_ms)."""
|
||||||
pt = plan_type.lower().strip()
|
pt = plan_type.lower().strip()
|
||||||
if pt == "daily":
|
if pt == "daily":
|
||||||
@@ -671,10 +672,10 @@ async def _load_site_context(site_id: int, db):
|
|||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
if soc_pct is None:
|
if soc_pct is None:
|
||||||
soc_wh = reserve_wh
|
soc_wh = uc * 0.5
|
||||||
else:
|
else:
|
||||||
soc_wh = float(soc_pct) / 100.0 * uc
|
soc_wh = float(soc_pct) / 100.0 * uc
|
||||||
soc_wh = max(reserve_wh, min(soc_wh, soc_max_wh))
|
soc_wh = max(reserve_wh, min(soc_wh, soc_max_wh))
|
||||||
|
|
||||||
tuv = await db.fetchval(
|
tuv = await db.fetchval(
|
||||||
"""
|
"""
|
||||||
@@ -701,9 +702,9 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
|
|||||||
COALESCE(fpi_a.power_w, 0) AS pv_a_forecast_w,
|
COALESCE(fpi_a.power_w, 0) AS pv_a_forecast_w,
|
||||||
COALESCE(fpi_b.power_w, 0) AS pv_b_forecast_w,
|
COALESCE(fpi_b.power_w, 0) AS pv_b_forecast_w,
|
||||||
COALESCE(cbi.power_w, 500) AS load_baseline_w,
|
COALESCE(cbi.power_w, 500) AS load_baseline_w,
|
||||||
-- EV připojení z aktuálního stavu nabíječek
|
-- EV připojení z poslední telemetrie nabíječek (bez řádku = nepřipojeno)
|
||||||
(ev1.status NOT IN ('available', 'unavailable')) AS ev1_connected,
|
(COALESCE(ev1.status, 'available') NOT IN ('available', 'unavailable')) AS ev1_connected,
|
||||||
(ev2.status NOT IN ('available', 'unavailable')) AS ev2_connected
|
(COALESCE(ev2.status, 'available') NOT IN ('available', 'unavailable')) AS ev2_connected
|
||||||
FROM ems.vw_site_effective_price ep
|
FROM ems.vw_site_effective_price ep
|
||||||
-- FVE pole A forecast
|
-- FVE pole A forecast
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -762,6 +763,10 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
|
|||||||
ev2_connected=bool(d["ev2_connected"]),
|
ev2_connected=bool(d["ev2_connected"]),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if not out:
|
||||||
|
raise RuntimeError(
|
||||||
|
"No planning slots available – check market prices and horizon settings"
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
180
backend/services/price_importer.py
Normal file
180
backend/services/price_importer.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""OTE CZ DAM spot price import (15min slots, shared market table)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MARKET_SOURCE = "OTE_CZ"
|
||||||
|
|
||||||
|
|
||||||
|
async def import_ote_prices(
|
||||||
|
site_id: int,
|
||||||
|
db,
|
||||||
|
target_date: date | None = None,
|
||||||
|
) -> tuple[int, str, float]:
|
||||||
|
"""
|
||||||
|
Stáhne DAM ceny OTE pro zvolený den (nebo „zítřek“ v TZ lokality), uloží 96 slotů (15 min).
|
||||||
|
|
||||||
|
Schéma DB: ``ems.market_interval_price`` má PK ``(market_source, interval_start)``;
|
||||||
|
ceny v ``buy_raw_price_czk_kwh`` / ``sell_raw_price_czk_kwh`` (pro OTE stejné).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(počet_slotů, datum_YMD, první_cena_kč_kwh)``. Počet 96 při úspěchu, -1 při chybě.
|
||||||
|
První cena je cena prvního 15min slotu dne; při chybě 0.0.
|
||||||
|
Datum je prázdný řetězec jen pokud site neexistuje nebo je neplatná timezone.
|
||||||
|
"""
|
||||||
|
row = await db.fetchrow(
|
||||||
|
"SELECT timezone FROM ems.site WHERE id = $1",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
logger.error("import_ote_prices: site id=%s nenalezen", site_id)
|
||||||
|
return -1, "", 0.0
|
||||||
|
|
||||||
|
tz_name: str = row["timezone"] or "Europe/Prague"
|
||||||
|
try:
|
||||||
|
site_tz = ZoneInfo(tz_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("import_ote_prices: neplatná timezone %r: %s", tz_name, e)
|
||||||
|
return -1, "", 0.0
|
||||||
|
|
||||||
|
if target_date is not None:
|
||||||
|
target_day = target_date
|
||||||
|
else:
|
||||||
|
now_local = datetime.now(site_tz)
|
||||||
|
target_day = (now_local + timedelta(days=1)).date()
|
||||||
|
date_str = target_day.isoformat()
|
||||||
|
|
||||||
|
cet = ZoneInfo("Europe/Prague")
|
||||||
|
now_cet = datetime.now(cet)
|
||||||
|
tomorrow_cet = (now_cet + timedelta(days=1)).date()
|
||||||
|
if target_day == tomorrow_cet:
|
||||||
|
cutoff = now_cet.replace(hour=13, minute=30, second=0, microsecond=0)
|
||||||
|
if now_cet < cutoff:
|
||||||
|
logger.warning(
|
||||||
|
"OTE prices for tomorrow may not be available yet (before 13:30 CET)"
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
base_url = settings.ote_api_url.rstrip("/")
|
||||||
|
url = f"{base_url}?date={date_str}"
|
||||||
|
eur_czk = float(settings.eur_czk_rate)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
body = resp.json()
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.warning("import_ote_prices: timeout při GET %s", url)
|
||||||
|
return -1, date_str, 0.0
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.warning(
|
||||||
|
"import_ote_prices: HTTP %s při GET %s: %s",
|
||||||
|
e.response.status_code,
|
||||||
|
url,
|
||||||
|
e.response.text[:500],
|
||||||
|
)
|
||||||
|
return -1, date_str, 0.0
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.warning("import_ote_prices: HTTP chyba při GET %s: %s", url, e)
|
||||||
|
return -1, date_str, 0.0
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("import_ote_prices: neočekávaná chyba při stahování: %s", e)
|
||||||
|
return -1, date_str, 0.0
|
||||||
|
|
||||||
|
hourly_eur_mwh: dict[int, float] | None = None
|
||||||
|
try:
|
||||||
|
points: list[dict[str, Any]] = body["data"]["dataLine"][0]["point"]
|
||||||
|
hourly_eur_mwh = {}
|
||||||
|
for p in points:
|
||||||
|
x = int(p["x"])
|
||||||
|
y = float(p["y"])
|
||||||
|
hourly_eur_mwh[x] = y
|
||||||
|
except (KeyError, TypeError, ValueError, IndexError):
|
||||||
|
snippet = json.dumps(body, ensure_ascii=False)[:500]
|
||||||
|
logger.error("import_ote_prices: neočekádaná struktura OTE, začátek: %s", snippet)
|
||||||
|
return -1, date_str, 0.0
|
||||||
|
|
||||||
|
if len(hourly_eur_mwh) != 24 or set(hourly_eur_mwh.keys()) != set(range(1, 25)):
|
||||||
|
logger.error(
|
||||||
|
"import_ote_prices: očekáváno 24 bodů x=1..24, dostáno klíče %s",
|
||||||
|
sorted(hourly_eur_mwh.keys()),
|
||||||
|
)
|
||||||
|
return -1, date_str, 0.0
|
||||||
|
|
||||||
|
slots: list[tuple[datetime, datetime, float]] = []
|
||||||
|
for h in range(24):
|
||||||
|
x = h + 1
|
||||||
|
eur_mwh = hourly_eur_mwh[x]
|
||||||
|
price_czk_kwh = eur_mwh * eur_czk / 1000.0
|
||||||
|
for minute in (0, 15, 30, 45):
|
||||||
|
interval_start_local = datetime(
|
||||||
|
target_day.year,
|
||||||
|
target_day.month,
|
||||||
|
target_day.day,
|
||||||
|
h,
|
||||||
|
minute,
|
||||||
|
tzinfo=site_tz,
|
||||||
|
)
|
||||||
|
interval_start_utc = interval_start_local.astimezone(timezone.utc)
|
||||||
|
interval_end_utc = interval_start_utc + timedelta(minutes=15)
|
||||||
|
slots.append((interval_start_utc, interval_end_utc, price_czk_kwh))
|
||||||
|
|
||||||
|
for interval_start_utc, interval_end_utc, price in slots:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ems.market_interval_price (
|
||||||
|
market_source,
|
||||||
|
interval_start,
|
||||||
|
interval_end,
|
||||||
|
buy_raw_price_czk_kwh,
|
||||||
|
sell_raw_price_czk_kwh,
|
||||||
|
currency,
|
||||||
|
imported_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'CZK', now())
|
||||||
|
ON CONFLICT (market_source, interval_start)
|
||||||
|
DO UPDATE SET
|
||||||
|
interval_end = EXCLUDED.interval_end,
|
||||||
|
buy_raw_price_czk_kwh = EXCLUDED.buy_raw_price_czk_kwh,
|
||||||
|
sell_raw_price_czk_kwh = EXCLUDED.sell_raw_price_czk_kwh,
|
||||||
|
imported_at = now()
|
||||||
|
""",
|
||||||
|
MARKET_SOURCE,
|
||||||
|
interval_start_utc,
|
||||||
|
interval_end_utc,
|
||||||
|
price,
|
||||||
|
price,
|
||||||
|
)
|
||||||
|
|
||||||
|
first_price = float(slots[0][2]) if slots else 0.0
|
||||||
|
return len(slots), date_str, first_price
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
async def test():
|
||||||
|
conn = await asyncpg.connect(os.getenv("DATABASE_URL"))
|
||||||
|
n, d, fp = await import_ote_prices(1, conn)
|
||||||
|
print(f"Uloženo {n} slotů pro {d}, první cena {fp}")
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
asyncio.run(test())
|
||||||
321
backend/services/telemetry_collector.py
Normal file
321
backend/services/telemetry_collector.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"""Sběr telemetrie z Modbus (Deye) a placeholder záznamy pro EV / TČ."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from pymodbus.client import AsyncModbusTcpClient
|
||||||
|
from pymodbus.exceptions import ConnectionException, ModbusIOException
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_signed_i16(value: int) -> int:
|
||||||
|
v = value & 0xFFFF
|
||||||
|
if v >= 0x8000:
|
||||||
|
return v - 0x10000
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ModbusDevice:
|
||||||
|
def __init__(self, host: str, port: int, unit_id: int, device_name: str) -> None:
|
||||||
|
self._host = host
|
||||||
|
self._port = int(port) if port else 502
|
||||||
|
self._unit_id = int(unit_id) if unit_id is not None else 1
|
||||||
|
self._device_name = device_name
|
||||||
|
self._client: AsyncModbusTcpClient | None = None
|
||||||
|
self._error_count = 0
|
||||||
|
|
||||||
|
def _log_prefix(self) -> str:
|
||||||
|
return f"[{self._device_name}]"
|
||||||
|
|
||||||
|
def _note_communication_failure(self, exc: BaseException | None) -> None:
|
||||||
|
self._error_count += 1
|
||||||
|
if isinstance(exc, ConnectionError):
|
||||||
|
logger.warning("%s ConnectionError: %s", self._log_prefix(), exc)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"%s komunikace selhala: %s",
|
||||||
|
self._log_prefix(),
|
||||||
|
exc if exc is not None else "neznámá chyba",
|
||||||
|
)
|
||||||
|
if self._error_count >= 3:
|
||||||
|
logger.error("%s Opakované chyby komunikace", self._log_prefix())
|
||||||
|
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||||
|
logger.critical(
|
||||||
|
"%s Opakované chyby komunikace, pokus o reconnect",
|
||||||
|
self._log_prefix(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reset_error_count(self) -> None:
|
||||||
|
self._error_count = 0
|
||||||
|
|
||||||
|
async def _ensure_connected(self) -> bool:
|
||||||
|
if self._client is None:
|
||||||
|
self._client = AsyncModbusTcpClient(self._host, port=self._port)
|
||||||
|
if not self._client.connected:
|
||||||
|
try:
|
||||||
|
ok = await self._client.connect()
|
||||||
|
except ConnectionError as e:
|
||||||
|
self._note_communication_failure(e)
|
||||||
|
return False
|
||||||
|
except OSError as e:
|
||||||
|
self._note_communication_failure(e)
|
||||||
|
return False
|
||||||
|
if not ok:
|
||||||
|
self._note_communication_failure(ConnectionError("Modbus connect() returned False"))
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _reconnect(self) -> None:
|
||||||
|
if self._client is not None:
|
||||||
|
self._client.close()
|
||||||
|
self._client = None
|
||||||
|
self._client = AsyncModbusTcpClient(self._host, port=self._port)
|
||||||
|
try:
|
||||||
|
await self._client.connect()
|
||||||
|
except (ConnectionError, OSError) as e:
|
||||||
|
logger.warning("%s reconnect selhal: %s", self._log_prefix(), e)
|
||||||
|
|
||||||
|
async def read_register(self, address: int) -> int:
|
||||||
|
"""Čte jeden holding register. Vrátí 0 při chybě."""
|
||||||
|
try:
|
||||||
|
if not await self._ensure_connected():
|
||||||
|
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||||
|
await self._reconnect()
|
||||||
|
return 0
|
||||||
|
assert self._client is not None
|
||||||
|
resp = await self._client.read_holding_registers(
|
||||||
|
address, count=1, device_id=self._unit_id
|
||||||
|
)
|
||||||
|
if resp.isError() or not getattr(resp, "registers", None):
|
||||||
|
self._note_communication_failure(
|
||||||
|
ConnectionException(f"read_holding_registers@{address:#x}: {resp!r}")
|
||||||
|
)
|
||||||
|
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||||
|
await self._reconnect()
|
||||||
|
return 0
|
||||||
|
self._reset_error_count()
|
||||||
|
return int(resp.registers[0])
|
||||||
|
except ConnectionError as e:
|
||||||
|
self._note_communication_failure(e)
|
||||||
|
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||||
|
await self._reconnect()
|
||||||
|
return 0
|
||||||
|
except (OSError, ModbusIOException, ConnectionException) as e:
|
||||||
|
self._note_communication_failure(e)
|
||||||
|
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||||
|
await self._reconnect()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def read_register_signed(self, address: int) -> int:
|
||||||
|
"""Čte signed int16 (pro výkony které mohou být záporné)."""
|
||||||
|
u = await self.read_register(address)
|
||||||
|
return _to_signed_i16(u)
|
||||||
|
|
||||||
|
async def write_register(self, address: int, value: int) -> bool:
|
||||||
|
"""Zapíše jeden holding register. Vrátí False při chybě."""
|
||||||
|
try:
|
||||||
|
if not await self._ensure_connected():
|
||||||
|
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||||
|
await self._reconnect()
|
||||||
|
return False
|
||||||
|
assert self._client is not None
|
||||||
|
resp = await self._client.write_register(address, value, device_id=self._unit_id)
|
||||||
|
if resp.isError():
|
||||||
|
self._note_communication_failure(
|
||||||
|
ConnectionException(f"write_register@{address:#x}: {resp!r}")
|
||||||
|
)
|
||||||
|
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||||
|
await self._reconnect()
|
||||||
|
return False
|
||||||
|
self._reset_error_count()
|
||||||
|
return True
|
||||||
|
except ConnectionError as e:
|
||||||
|
self._note_communication_failure(e)
|
||||||
|
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||||
|
await self._reconnect()
|
||||||
|
return False
|
||||||
|
except (OSError, ModbusIOException, ConnectionException) as e:
|
||||||
|
self._note_communication_failure(e)
|
||||||
|
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||||
|
await self._reconnect()
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._client is not None:
|
||||||
|
self._client.close()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT ai.id, ai.code, se.host, se.port, se.unit_id
|
||||||
|
FROM ems.asset_inverter ai
|
||||||
|
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
|
||||||
|
WHERE ai.site_id = $1
|
||||||
|
AND ai.active = true
|
||||||
|
AND se.enabled = true
|
||||||
|
AND se.endpoint_type = 'modbus_tcp'
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
measured_at = datetime.now(timezone.utc)
|
||||||
|
for row in rows:
|
||||||
|
inv_id = row["id"]
|
||||||
|
code = row["code"]
|
||||||
|
host = row["host"]
|
||||||
|
port = row["port"] or 502
|
||||||
|
unit_id = row["unit_id"] if row["unit_id"] is not None else 1
|
||||||
|
dev = ModbusDevice(host, port, unit_id, f"inverter:{code}")
|
||||||
|
try:
|
||||||
|
pv_power_w = await dev.read_register(0x0215)
|
||||||
|
battery_soc = await dev.read_register(0x0103)
|
||||||
|
battery_power = await dev.read_register_signed(0x0105)
|
||||||
|
battery_voltage = (await dev.read_register(0x0101)) / 10.0
|
||||||
|
grid_power = await dev.read_register_signed(0x0169)
|
||||||
|
grid_voltage = (await dev.read_register(0x016F)) / 10.0
|
||||||
|
load_power = await dev.read_register(0x0213)
|
||||||
|
inv_temp = (await dev.read_register(0x0220)) / 10.0
|
||||||
|
op_mode = await dev.read_register(0x0168)
|
||||||
|
fault_code = await dev.read_register(0x0180)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ems.telemetry_inverter (
|
||||||
|
site_id, inverter_id, measured_at,
|
||||||
|
pv_power_w, battery_soc_percent, battery_power_w, battery_voltage_v,
|
||||||
|
grid_power_w, grid_voltage_v, load_power_w,
|
||||||
|
inverter_temp_c, operating_mode, fault_code
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1, $2, $3,
|
||||||
|
$4, $5, $6, $7,
|
||||||
|
$8, $9, $10,
|
||||||
|
$11, $12, $13
|
||||||
|
)
|
||||||
|
ON CONFLICT (inverter_id, measured_at) DO NOTHING
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
inv_id,
|
||||||
|
measured_at,
|
||||||
|
pv_power_w,
|
||||||
|
battery_soc,
|
||||||
|
battery_power,
|
||||||
|
battery_voltage,
|
||||||
|
grid_power,
|
||||||
|
grid_voltage,
|
||||||
|
load_power,
|
||||||
|
inv_temp,
|
||||||
|
str(op_mode),
|
||||||
|
fault_code,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("poll_inverter site=%s inverter=%s: %s", site_id, code, e)
|
||||||
|
finally:
|
||||||
|
await dev.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT ec.id, ec.code, se.host, se.port, se.unit_id
|
||||||
|
FROM ems.asset_ev_charger ec
|
||||||
|
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
||||||
|
WHERE ec.site_id = $1
|
||||||
|
AND se.enabled = true
|
||||||
|
AND se.endpoint_type = 'modbus_tcp'
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
measured_at = datetime.now(timezone.utc)
|
||||||
|
for row in rows:
|
||||||
|
code = row["code"]
|
||||||
|
logger.info("TODO: EV charger Modbus registry pending | %s", code)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ems.telemetry_ev_charger (
|
||||||
|
site_id, charger_id, measured_at, connector_id,
|
||||||
|
status, power_w, energy_kwh
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, 1, 'available', 0, 0)
|
||||||
|
ON CONFLICT (charger_id, connector_id, measured_at) DO NOTHING
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
row["id"],
|
||||||
|
measured_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT hp.id, hp.code, se.host, se.port, se.unit_id
|
||||||
|
FROM ems.asset_heat_pump hp
|
||||||
|
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
|
||||||
|
WHERE hp.site_id = $1
|
||||||
|
AND se.enabled = true
|
||||||
|
AND se.endpoint_type = 'modbus_tcp'
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
measured_at = datetime.now(timezone.utc)
|
||||||
|
for row in rows:
|
||||||
|
code = row["code"]
|
||||||
|
logger.info("TODO: heat pump Modbus registry pending (heat_pump=%s)", code)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ems.telemetry_heat_pump (
|
||||||
|
site_id, heat_pump_id, measured_at,
|
||||||
|
power_w, outdoor_temp_c, water_outlet_temp_c, tuv_tank_temp_c,
|
||||||
|
operating_mode
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, 0, 10.0, 45.0, 55.0, 'standby')
|
||||||
|
ON CONFLICT (heat_pump_id, measured_at) DO NOTHING
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
row["id"],
|
||||||
|
measured_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
|
||||||
|
"""Jeden průchod smyčky; vrátí uplynulý čas v sekundách (pro sleep).
|
||||||
|
|
||||||
|
Poll probíhá sekvenčně — jedno asyncpg spojení nesmí obsluhovat paralelní dotazy.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
start = loop.time()
|
||||||
|
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||||
|
for site in sites:
|
||||||
|
sid = site["id"]
|
||||||
|
try:
|
||||||
|
await poll_inverter(sid, conn)
|
||||||
|
await poll_ev_chargers(sid, conn)
|
||||||
|
await poll_heat_pump(sid, conn)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Telemetry loop error site %s: %s", sid, e)
|
||||||
|
return loop.time() - start
|
||||||
|
|
||||||
|
|
||||||
|
async def run_telemetry_loop_wrapper(pool: asyncpg.Pool) -> None:
|
||||||
|
"""Background task: každá iterace získá spojení z poolu; neblokuje pool během sleep."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
elapsed = await run_telemetry_loop(conn)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Telemetry wrapper DB error: %s", e)
|
||||||
|
elapsed = 0.0
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if elapsed > 50:
|
||||||
|
logger.warning("Telemetry loop took %.1fs (>50s)", elapsed)
|
||||||
|
await asyncio.sleep(max(0.0, 60.0 - elapsed))
|
||||||
@@ -73,8 +73,34 @@ SELECT create_hypertable(
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- Kompresní politiky pro staré chunky
|
-- Kompresní politiky pro staré chunky
|
||||||
-- Telemetrie starší 30 dní komprimovat (čtení stačí)
|
-- Telemetrie starší 30 dní komprimovat (čtení stačí)
|
||||||
|
-- Nutné nejdřív zapnout kompresi na hypertable (TimescaleDB 2.x+ / Tiger Data),
|
||||||
|
-- jinak add_compression_policy hlásí chybu o columnstore / compression.
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE ems.telemetry_inverter SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_orderby = 'measured_at DESC',
|
||||||
|
timescaledb.compress_segmentby = 'site_id, inverter_id'
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ems.telemetry_ev_charger SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_orderby = 'measured_at DESC',
|
||||||
|
timescaledb.compress_segmentby = 'site_id, charger_id, connector_id'
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ems.telemetry_heat_pump SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_orderby = 'measured_at DESC',
|
||||||
|
timescaledb.compress_segmentby = 'site_id, heat_pump_id'
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ems.market_interval_price SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_orderby = 'interval_start DESC',
|
||||||
|
timescaledb.compress_segmentby = 'market_source'
|
||||||
|
);
|
||||||
|
|
||||||
SELECT add_compression_policy('ems.telemetry_inverter', INTERVAL '30 days', if_not_exists => TRUE);
|
SELECT add_compression_policy('ems.telemetry_inverter', INTERVAL '30 days', if_not_exists => TRUE);
|
||||||
SELECT add_compression_policy('ems.telemetry_ev_charger', INTERVAL '30 days', if_not_exists => TRUE);
|
SELECT add_compression_policy('ems.telemetry_ev_charger', INTERVAL '30 days', if_not_exists => TRUE);
|
||||||
SELECT add_compression_policy('ems.telemetry_heat_pump', INTERVAL '30 days', if_not_exists => TRUE);
|
SELECT add_compression_policy('ems.telemetry_heat_pump', INTERVAL '30 days', if_not_exists => TRUE);
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
-- =============================================================
|
-- =============================================================
|
||||||
-- V003__seed_site_home01.sql
|
-- V003__seed_site_home01.sql
|
||||||
-- EMS Platform – seed data první lokality home-01
|
-- EMS Platform – seed data první lokality home-01
|
||||||
-- Doplnit: latitude, longitude, IP adresy, azimuty FVE polí
|
--
|
||||||
|
-- Deye Modbus (holding, SUN-20K) – viz docs/04-modules/telemetry.md:
|
||||||
|
-- 0x0215 PV W, 0x0103 SoC %, 0x0105 bat W, 0x0101 bat V×0.1,
|
||||||
|
-- 0x0169 grid W, 0x016F grid L1 V×0.1, 0x0213 load W,
|
||||||
|
-- 0x0220 inv temp ×0.1, 0x0168 mode, 0x0180 fault
|
||||||
|
-- Teltonika / Samsung registry: TODO – doplnit z dokumentace / Loxone šablony
|
||||||
-- =============================================================
|
-- =============================================================
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -13,8 +18,8 @@ VALUES (
|
|||||||
'home-01',
|
'home-01',
|
||||||
'Hlavní objekt',
|
'Hlavní objekt',
|
||||||
'Europe/Prague',
|
'Europe/Prague',
|
||||||
NULL, -- TODO: doplnit GPS
|
49.24466967511591,
|
||||||
NULL, -- TODO: doplnit GPS
|
17.40658656876068,
|
||||||
true,
|
true,
|
||||||
'První instalace. Deye 20kW + 64kWh baterie + 2x Teltonika EV + Samsung TČ.'
|
'První instalace. Deye 20kW + 64kWh baterie + 2x Teltonika EV + Samsung TČ.'
|
||||||
);
|
);
|
||||||
@@ -27,13 +32,11 @@ VALUES (
|
|||||||
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
|
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
|
||||||
SELECT id, 'modbus_tcp', '192.168.1.100', 502, 'modbus_tcp', 1, true, 'Waveshare WS-ETH pro Deye SUN-20K. Unit ID dle DIP přepínače.'
|
SELECT id, 'modbus_tcp', '192.168.1.100', 502, 'modbus_tcp', 1, true, 'Waveshare WS-ETH pro Deye SUN-20K. Unit ID dle DIP přepínače.'
|
||||||
FROM ems.site WHERE code = 'home-01';
|
FROM ems.site WHERE code = 'home-01';
|
||||||
-- TODO: doplnit skutečnou IP adresy Waveshare
|
|
||||||
|
|
||||||
-- Teltonika EV nabíječka 1 přes Waveshare
|
-- Teltonika EV nabíječka 1 přes Waveshare
|
||||||
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
|
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
|
||||||
SELECT id, 'modbus_tcp', '192.168.1.101', 502, 'modbus_tcp', 1, true, 'Waveshare pro Teltonika TeltoCharge #1.'
|
SELECT id, 'modbus_tcp', '192.168.1.101', 502, 'modbus_tcp', 1, true, 'Waveshare pro Teltonika TeltoCharge #1.'
|
||||||
FROM ems.site WHERE code = 'home-01';
|
FROM ems.site WHERE code = 'home-01';
|
||||||
-- TODO: doplnit IP a unit_id
|
|
||||||
|
|
||||||
-- Teltonika EV nabíječka 2 přes Waveshare
|
-- Teltonika EV nabíječka 2 přes Waveshare
|
||||||
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
|
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
|
||||||
@@ -49,7 +52,6 @@ FROM ems.site WHERE code = 'home-01';
|
|||||||
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
|
INSERT INTO ems.site_endpoint (site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes)
|
||||||
SELECT id, 'loxone_http', '192.168.1.10', 80, 'http', NULL, true, 'Loxone Miniserver – příjem setpointů přes Virtual HTTP Inputs.'
|
SELECT id, 'loxone_http', '192.168.1.10', 80, 'http', NULL, true, 'Loxone Miniserver – příjem setpointů přes Virtual HTTP Inputs.'
|
||||||
FROM ems.site WHERE code = 'home-01';
|
FROM ems.site WHERE code = 'home-01';
|
||||||
-- TODO: doplnit IP Loxone
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- SÍŤOVÉ PŘIPOJENÍ
|
-- SÍŤOVÉ PŘIPOJENÍ
|
||||||
@@ -126,12 +128,12 @@ INSERT INTO ems.asset_pv_array (site_id, inverter_id, code, name, nominal_power_
|
|||||||
SELECT
|
SELECT
|
||||||
s.id, inv.id, 'pv-a', 'FVE pole A',
|
s.id, inv.id, 'pv-a', 'FVE pole A',
|
||||||
10000, -- 10 kWp
|
10000, -- 10 kWp
|
||||||
NULL, -- TODO: doplnit azimut (0=jih)
|
184,
|
||||||
NULL, -- TODO: doplnit sklon (stupně)
|
35, -- sklon odhad; upřesnit dle střechy
|
||||||
NULL,
|
NULL,
|
||||||
1.0,
|
1.0,
|
||||||
true,
|
true,
|
||||||
'Hlavní FVE pole řízené Deye střídačem. Doplnit azimut a sklon.'
|
'Hlavní FVE pole řízené Deye střídačem.'
|
||||||
FROM ems.site s
|
FROM ems.site s
|
||||||
JOIN ems.asset_inverter inv ON inv.site_id = s.id AND inv.code = 'deye-main'
|
JOIN ems.asset_inverter inv ON inv.site_id = s.id AND inv.code = 'deye-main'
|
||||||
WHERE s.code = 'home-01';
|
WHERE s.code = 'home-01';
|
||||||
@@ -141,8 +143,8 @@ INSERT INTO ems.asset_pv_array (site_id, inverter_id, code, name, nominal_power_
|
|||||||
SELECT
|
SELECT
|
||||||
s.id, inv.id, 'pv-b', 'FVE pole B (ongrid)',
|
s.id, inv.id, 'pv-b', 'FVE pole B (ongrid)',
|
||||||
10000,
|
10000,
|
||||||
NULL, -- TODO: doplnit azimut
|
184,
|
||||||
NULL, -- TODO: doplnit sklon
|
35,
|
||||||
NULL,
|
NULL,
|
||||||
1.0,
|
1.0,
|
||||||
false,
|
false,
|
||||||
@@ -187,15 +189,15 @@ INSERT INTO ems.asset_heat_pump (
|
|||||||
tuv_temp_sensor_ref, schedulable, notes
|
tuv_temp_sensor_ref, schedulable, notes
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
s.id, 'hp-samsung', 'Samsung', NULL, -- TODO: doplnit model
|
s.id, 'hp-samsung', 'Samsung', 'EHS Mono (placeholder)',
|
||||||
ep.id,
|
ep.id,
|
||||||
NULL, -- TODO: doplnit jmenovitý výkon W
|
12000, -- jmenovitý topný výkon W – upřesnit z datasheetu
|
||||||
NULL, -- TODO: doplnit COP rated
|
3.20, -- COP @ 7 °C – upřesnit
|
||||||
7.0, -- referenční teplota A7/W35
|
7.0, -- referenční teplota A7/W35
|
||||||
30, 15,
|
30, 15,
|
||||||
NULL, -- TODO: doplnit objem zásobníku
|
200, -- objem TUV zásobníku (l) – upřesnit
|
||||||
45, 60, 55,
|
45, 60, 55,
|
||||||
NULL, -- TODO: doplnit odkaz na teplotní čidlo
|
'TODO: Loxone / Modbus čidlo TUV',
|
||||||
true,
|
true,
|
||||||
'Samsung tepelné čerpadlo s Modbus modulem. Řídit dle COP a venkovní teploty (výhodné kolem poledne v chladných měsících).'
|
'Samsung tepelné čerpadlo s Modbus modulem. Řídit dle COP a venkovní teploty (výhodné kolem poledne v chladných měsících).'
|
||||||
FROM ems.site s
|
FROM ems.site s
|
||||||
|
|||||||
5
db/migration/V008__asset_inverter_active.sql
Normal file
5
db/migration/V008__asset_inverter_active.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Zapnutí/vypnutí střídače pro sběr telemetrie a plánování (JOIN s endpointem zůstává nutný).
|
||||||
|
ALTER TABLE ems.asset_inverter
|
||||||
|
ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN ems.asset_inverter.active IS 'Pokud false, střídač se přeskočí při sběru telemetrie a plánování.';
|
||||||
26
db/migration/V009__postgrest_roles.sql
Normal file
26
db/migration/V009__postgrest_roles.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Role pro PostgREST anonymní přístup (read-only).
|
||||||
|
-- GRANT na views je v db/views/R__z_postgrest_ems_anon_grants.sql (Flyway je aplikuje až po R__vw_*).
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'ems_anon') THEN
|
||||||
|
CREATE ROLE ems_anon NOLOGIN;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA ems TO ems_anon;
|
||||||
|
|
||||||
|
-- Read-only na tabulky (existují po V001–V008)
|
||||||
|
GRANT SELECT ON ems.market_interval_price TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.planning_run TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.planning_interval TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.forecast_pv_interval TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.forecast_pv_run TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.operating_mode_def TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.site_operating_mode TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.site_operating_mode_log TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.ev_session TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.asset_vehicle TO ems_anon;
|
||||||
|
|
||||||
|
COMMENT ON ROLE ems_anon IS
|
||||||
|
'Anonymní role pro PostgREST. Read-only přístup na views a vybrané tabulky.
|
||||||
|
Zápisy jdou výhradně přes FastAPI backend který má vlastní DB connection.';
|
||||||
40
db/migration/V010__indexes.sql
Normal file
40
db/migration/V010__indexes.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- V010__indexes.sql
|
||||||
|
-- B-tree indexy pro časté dotazy (plán, telemetrie, ceny, audit, EV, režimy).
|
||||||
|
-- Pozn.: idx_ev_session_active na (charger_id, session_end) je ve V006;
|
||||||
|
-- zde idx_ev_session_site_active doplňuje vyhledávání aktivní session podle site.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- Planning (control exporter hledá aktivní plán pro aktuální slot)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_planning_run_site_status
|
||||||
|
ON ems.planning_run (site_id, status, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_planning_interval_run_start
|
||||||
|
ON ems.planning_interval (run_id, interval_start);
|
||||||
|
|
||||||
|
-- Telemetrie (dashboard čte poslední hodnoty)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telemetry_inverter_site_time
|
||||||
|
ON ems.telemetry_inverter (site_id, measured_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telemetry_ev_site_time
|
||||||
|
ON ems.telemetry_ev_charger (site_id, measured_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telemetry_hp_site_time
|
||||||
|
ON ems.telemetry_heat_pump (site_id, measured_at DESC);
|
||||||
|
|
||||||
|
-- Market prices (forecast + planning čte ceny pro horizont)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_market_price_source_start
|
||||||
|
ON ems.market_interval_price (market_source, interval_start);
|
||||||
|
|
||||||
|
-- Audit (dashboard čte dnešní data)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_interval_site_start
|
||||||
|
ON ems.audit_interval (site_id, interval_start DESC);
|
||||||
|
|
||||||
|
-- EV session (control exporter + UI hledá aktivní session podle lokality)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ev_session_site_active
|
||||||
|
ON ems.ev_session (site_id, session_end)
|
||||||
|
WHERE session_end IS NULL;
|
||||||
|
|
||||||
|
-- Operating mode log
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mode_log_site_time
|
||||||
|
ON ems.site_operating_mode_log (site_id, activated_at DESC);
|
||||||
58
db/migration/V011__indexes_and_aggregates.sql
Normal file
58
db/migration/V011__indexes_and_aggregates.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V011__indexes_and_aggregates.sql
|
||||||
|
-- Doplňuje V010__indexes.sql: indexy na forecast + hourly CA telemetrie.
|
||||||
|
-- (Indexy planning_run, planning_interval, market_price, audit, mode_log,
|
||||||
|
-- ev_session jsou již ve V010 – zde se neopakují, aby nevznikly duplicitní B-stromy.)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Indexy pro výkon (forecast – nové oproti V010)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_forecast_run_site_array
|
||||||
|
ON ems.forecast_pv_run (site_id, pv_array_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_forecast_interval_run_start
|
||||||
|
ON ems.forecast_pv_interval (run_id, interval_start);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TimescaleDB Continuous Aggregates pro dashboard výkon
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Hodinové agregáty telemetrie střídače (pro graf posledních 7 dní)
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS ems.telemetry_inverter_hourly
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('1 hour', measured_at) AS hour,
|
||||||
|
site_id,
|
||||||
|
AVG(pv_power_w)::INT AS avg_pv_w,
|
||||||
|
AVG(battery_power_w)::INT AS avg_battery_w,
|
||||||
|
AVG(grid_power_w)::INT AS avg_grid_w,
|
||||||
|
AVG(load_power_w)::INT AS avg_load_w,
|
||||||
|
LAST(battery_soc_percent, measured_at) AS last_soc_pct,
|
||||||
|
COUNT(*) AS sample_count
|
||||||
|
FROM ems.telemetry_inverter
|
||||||
|
GROUP BY hour, site_id
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
-- Refresh policy: každých 15 minut. Okno musí pokrývat ≥2× time_bucket (1h) → min. šířka >2h.
|
||||||
|
SELECT add_continuous_aggregate_policy(
|
||||||
|
'ems.telemetry_inverter_hourly',
|
||||||
|
start_offset => INTERVAL '2 hours 15 minutes',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '15 minutes'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- View pro použití v dashboardu (7 dní zpět)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW ems.vw_telemetry_hourly_7d AS
|
||||||
|
SELECT *
|
||||||
|
FROM ems.telemetry_inverter_hourly
|
||||||
|
WHERE hour >= now() - INTERVAL '7 days'
|
||||||
|
ORDER BY hour DESC;
|
||||||
|
|
||||||
|
COMMENT ON VIEW ems.telemetry_inverter_hourly IS
|
||||||
|
'Hodinové agregáty telemetrie střídače. TimescaleDB continuous aggregate.
|
||||||
|
Refresh každých 15 minut. Používat pro grafy delší než 1 den.';
|
||||||
@@ -4,6 +4,28 @@
|
|||||||
-- Repeatable migration
|
-- Repeatable migration
|
||||||
-- =============================================================
|
-- =============================================================
|
||||||
|
|
||||||
|
-- Aktuální EMS provozní režim per lokalita (PostgREST / UI)
|
||||||
|
CREATE OR REPLACE VIEW ems.vw_operating_mode AS
|
||||||
|
SELECT
|
||||||
|
s.id AS site_id,
|
||||||
|
s.code AS site_code,
|
||||||
|
m.mode_code AS active_mode,
|
||||||
|
d.name AS mode_name,
|
||||||
|
d.description AS mode_description,
|
||||||
|
d.is_autonomous,
|
||||||
|
m.activated_at,
|
||||||
|
m.activated_by,
|
||||||
|
m.valid_until,
|
||||||
|
m.previous_mode,
|
||||||
|
m.notes AS mode_notes
|
||||||
|
FROM ems.site s
|
||||||
|
LEFT JOIN ems.site_operating_mode m ON m.site_id = s.id
|
||||||
|
LEFT JOIN ems.operating_mode_def d ON d.code = m.mode_code
|
||||||
|
WHERE s.active = true;
|
||||||
|
|
||||||
|
COMMENT ON VIEW ems.vw_operating_mode IS
|
||||||
|
'Aktuální provozní režim EMS per aktivní lokalita (bez telemetrie/heartbeat).';
|
||||||
|
|
||||||
-- Aktuální stav všech lokalit (pro dashboard a PostgREST)
|
-- Aktuální stav všech lokalit (pro dashboard a PostgREST)
|
||||||
CREATE OR REPLACE VIEW ems.vw_site_status AS
|
CREATE OR REPLACE VIEW ems.vw_site_status AS
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
13
db/views/R__z_postgrest_ems_anon_grants.sql
Normal file
13
db/views/R__z_postgrest_ems_anon_grants.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- PostgREST ems_anon: SELECT na views (repeatable – po R__vw_* ve stejném Flyway běhu).
|
||||||
|
|
||||||
|
GRANT SELECT ON ems.vw_site_status TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.vw_site_effective_price TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.vw_latest_inverter TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.vw_latest_heat_pump TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.vw_audit_today_hourly TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.vw_audit_daily TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.vw_audit_weekly TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.vw_mode_log_recent TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.vw_operating_mode TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.telemetry_inverter_hourly TO ems_anon;
|
||||||
|
GRANT SELECT ON ems.vw_telemetry_hourly_7d TO ems_anon;
|
||||||
@@ -47,7 +47,7 @@ services:
|
|||||||
PGRST_DB_URI: postgres://${DB_USER}:${DB_PASSWORD}@db:5432/ems
|
PGRST_DB_URI: postgres://${DB_USER}:${DB_PASSWORD}@db:5432/ems
|
||||||
PGRST_DB_SCHEMA: ems
|
PGRST_DB_SCHEMA: ems
|
||||||
PGRST_DB_EXTRA_SEARCH_PATH: ems
|
PGRST_DB_EXTRA_SEARCH_PATH: ems
|
||||||
PGRST_DB_ANON_ROLE: ${POSTGREST_ANON_ROLE:-ems_user}
|
PGRST_DB_ANON_ROLE: ${POSTGREST_ANON_ROLE:-ems_anon}
|
||||||
PGRST_JWT_SECRET: ${POSTGREST_JWT_SECRET}
|
PGRST_JWT_SECRET: ${POSTGREST_JWT_SECRET}
|
||||||
PGRST_SERVER_PORT: 3000
|
PGRST_SERVER_PORT: 3000
|
||||||
PGRST_OPENAPI_SERVER_PROXY_URI: http://localhost/rest
|
PGRST_OPENAPI_SERVER_PROXY_URI: http://localhost/rest
|
||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
LOXONE_USER: ${LOXONE_USER:-}
|
LOXONE_USER: ${LOXONE_USER:-}
|
||||||
LOXONE_PASSWORD: ${LOXONE_PASSWORD:-}
|
LOXONE_PASSWORD: ${LOXONE_PASSWORD:-}
|
||||||
POSTGREST_JWT_SECRET: ${POSTGREST_JWT_SECRET}
|
POSTGREST_JWT_SECRET: ${POSTGREST_JWT_SECRET}
|
||||||
POSTGREST_ANON_ROLE: ${POSTGREST_ANON_ROLE:-ems_user}
|
POSTGREST_ANON_ROLE: ${POSTGREST_ANON_ROLE:-ems_anon}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Tento soubor slouží jako živý seznam věcí které je potřeba rozhodnout p
|
|||||||
|
|
||||||
- [ ] **Pole B (ongridový)** – Zahrnout do auditu jako "neřízená výroba"? Nebo ignorovat úplně? Komplikuje audit ale zpřesňuje ho.
|
- [ ] **Pole B (ongridový)** – Zahrnout do auditu jako "neřízená výroba"? Nebo ignorovat úplně? Komplikuje audit ale zpřesňuje ho.
|
||||||
|
|
||||||
- [ ] **PostgREST autentikace** – Jaký model? JWT tokeny? Row-level security? Zatím development bez auth, produkce musí mít.
|
- [ ] **PostgREST autentikace** – Jaký model? JWT tokeny? Row-level security? (Anon role `ems_anon` je nastavena – viz Vyřešeno; produkce může vyžadovat JWT/RLS navíc.)
|
||||||
|
|
||||||
- [ ] **Backup a obnova** – Jak se zálohuje PostgreSQL? pg_dump cron? Replikace? Nutné pro produkci.
|
- [ ] **Backup a obnova** – Jak se zálohuje PostgreSQL? pg_dump cron? Replikace? Nutné pro produkci.
|
||||||
|
|
||||||
@@ -36,3 +36,9 @@ Tento soubor slouží jako živý seznam věcí které je potřeba rozhodnout p
|
|||||||
- [ ] Sezónní korekce predikce spotřeby
|
- [ ] Sezónní korekce predikce spotřeby
|
||||||
- [ ] Mobile app / PWA notifikace
|
- [ ] Mobile app / PWA notifikace
|
||||||
- [ ] Integrace s dodavatelem elektřiny pro automatický reporting
|
- [ ] Integrace s dodavatelem elektřiny pro automatický reporting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vyřešeno
|
||||||
|
|
||||||
|
- **PostgREST anon role:** `ems_anon`, read-only na vybrané views a tabulky (migrace `V009__postgrest_roles.sql` + repeatable `R__z_postgrest_ems_anon_grants.sql` kvůli pořadí Flyway); zápisy přes FastAPI. Compose / `.env`: `POSTGREST_ANON_ROLE=ems_anon`, PostgREST `PGRST_DB_ANON_ROLE`.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ server {
|
|||||||
text/plain;
|
text/plain;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000/;
|
proxy_pass http://backend:8000/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
139
frontend/package-lock.json
generated
139
frontend/package-lock.json
generated
@@ -10,7 +10,8 @@
|
|||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.15.0"
|
"recharts": "^2.15.0",
|
||||||
|
"sonner": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.14",
|
"@tailwindcss/vite": "^4.0.14",
|
||||||
@@ -1311,6 +1312,70 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.1.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1",
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
||||||
@@ -2705,6 +2770,16 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonner": {
|
||||||
|
"version": "1.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||||
|
"integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -3562,6 +3637,62 @@
|
|||||||
"@napi-rs/wasm-runtime": "^1.1.1",
|
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||||
"@tybys/wasm-util": "^0.10.1",
|
"@tybys/wasm-util": "^0.10.1",
|
||||||
"tslib": "^2.8.1"
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"bundled": true,
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"@emnapi/wasi-threads": "1.1.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@emnapi/runtime": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"bundled": true,
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@emnapi/wasi-threads": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"bundled": true,
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"bundled": true,
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1",
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@tybys/wasm-util": {
|
||||||
|
"version": "0.10.1",
|
||||||
|
"bundled": true,
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"bundled": true,
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": {
|
"@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
@@ -4469,6 +4600,12 @@
|
|||||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"sonner": {
|
||||||
|
"version": "1.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||||
|
"integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"source-map-js": {
|
"source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "node scripts/run-dev.mjs",
|
||||||
"build": "tsc -b && vite build",
|
"build": "node scripts/run-build.mjs",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"sonner": "^1.7.1"
|
"sonner": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.14",
|
"@tailwindcss/vite": "^4.0.14",
|
||||||
|
|||||||
62
frontend/scripts/ensure-native-bindings.mjs
Normal file
62
frontend/scripts/ensure-native-bindings.mjs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* When optional native deps (e.g. @tailwindcss/oxide-*) fail to install (permissions, npm bugs),
|
||||||
|
* fetch the correct platform package via npm pack and copy the .node file into vendor/.
|
||||||
|
*/
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
|
||||||
|
import { join, dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = join(__dirname, '..')
|
||||||
|
const vendorDir = join(root, 'vendor')
|
||||||
|
|
||||||
|
function isMusl() {
|
||||||
|
try {
|
||||||
|
return execSync('ldd --version', { encoding: 'utf8' }).includes('musl')
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLinuxX64Oxide() {
|
||||||
|
if (process.platform !== 'linux' || process.arch !== 'x64') return null
|
||||||
|
return isMusl()
|
||||||
|
? { pkg: '@tailwindcss/oxide-linux-x64-musl', version: '4.2.2', nodeName: 'tailwindcss-oxide.linux-x64-musl.node' }
|
||||||
|
: { pkg: '@tailwindcss/oxide-linux-x64-gnu', version: '4.2.2', nodeName: 'tailwindcss-oxide.linux-x64-gnu.node' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryResolveOxidePackage(spec) {
|
||||||
|
const sub = spec.pkg.replace('@tailwindcss/', '')
|
||||||
|
const direct = join(root, 'node_modules', '@tailwindcss', sub, 'package.json')
|
||||||
|
if (existsSync(direct)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure() {
|
||||||
|
const spec = detectLinuxX64Oxide()
|
||||||
|
if (!spec) return
|
||||||
|
|
||||||
|
if (tryResolveOxidePackage(spec)) return
|
||||||
|
|
||||||
|
const outPath = join(vendorDir, spec.nodeName)
|
||||||
|
if (existsSync(outPath)) return
|
||||||
|
|
||||||
|
mkdirSync(vendorDir, { recursive: true })
|
||||||
|
const tmp = join(__dirname, '.native-tmp')
|
||||||
|
rmSync(tmp, { recursive: true, force: true })
|
||||||
|
mkdirSync(tmp, { recursive: true })
|
||||||
|
|
||||||
|
execSync(`npm pack ${spec.pkg}@${spec.version}`, { cwd: tmp, stdio: 'inherit' })
|
||||||
|
const tgz = readdirSync(tmp).find((f) => f.endsWith('.tgz'))
|
||||||
|
if (!tgz) throw new Error('ensure-native-bindings: npm pack produced no .tgz')
|
||||||
|
execSync(`tar -xzf "${tgz}"`, { cwd: tmp, stdio: 'inherit' })
|
||||||
|
const nodeSrc = join(tmp, 'package', spec.nodeName)
|
||||||
|
if (!existsSync(nodeSrc)) {
|
||||||
|
throw new Error(`ensure-native-bindings: missing ${nodeSrc}`)
|
||||||
|
}
|
||||||
|
copyFileSync(nodeSrc, outPath)
|
||||||
|
rmSync(tmp, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure()
|
||||||
14
frontend/scripts/run-build-inner.mjs
Normal file
14
frontend/scripts/run-build-inner.mjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
|
||||||
|
function run(scriptArgs) {
|
||||||
|
const r = spawnSync(process.execPath, scriptArgs, { stdio: 'inherit', cwd: root })
|
||||||
|
if (r.status !== 0) process.exit(r.status ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
run([join(root, 'scripts', 'ensure-native-bindings.mjs')])
|
||||||
|
run([join(root, 'node_modules/typescript/bin/tsc'), '-b'])
|
||||||
|
run([join(root, 'node_modules/vite/bin/vite.js'), 'build'])
|
||||||
21
frontend/scripts/run-build.mjs
Normal file
21
frontend/scripts/run-build.mjs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const major = parseInt(process.versions.node.split('.')[0], 10)
|
||||||
|
|
||||||
|
if (major >= 20) {
|
||||||
|
const r = spawnSync(process.execPath, [join(root, 'scripts', 'run-build-inner.mjs')], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: root,
|
||||||
|
})
|
||||||
|
process.exit(r.status ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = spawnSync(
|
||||||
|
'npx',
|
||||||
|
['-y', '-p', 'node@20', 'node', join(root, 'scripts', 'run-build-inner.mjs')],
|
||||||
|
{ stdio: 'inherit', cwd: root, shell: false },
|
||||||
|
)
|
||||||
|
process.exit(r.status ?? 1)
|
||||||
17
frontend/scripts/run-dev-inner.mjs
Normal file
17
frontend/scripts/run-dev-inner.mjs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
|
||||||
|
const ensure = spawnSync(process.execPath, [join(root, 'scripts', 'ensure-native-bindings.mjs')], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: root,
|
||||||
|
})
|
||||||
|
if (ensure.status !== 0) process.exit(ensure.status ?? 1)
|
||||||
|
|
||||||
|
const vite = spawnSync(process.execPath, [join(root, 'node_modules/vite/bin/vite.js')], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: root,
|
||||||
|
})
|
||||||
|
process.exit(vite.status ?? 1)
|
||||||
13
frontend/scripts/run-dev.mjs
Normal file
13
frontend/scripts/run-dev.mjs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const inner = join(root, 'scripts', 'run-dev-inner.mjs')
|
||||||
|
const major = parseInt(process.versions.node.split('.')[0], 10)
|
||||||
|
|
||||||
|
const cmd = major >= 20 ? process.execPath : 'npx'
|
||||||
|
const args = major >= 20 ? [inner] : ['-y', '-p', 'node@20', 'node', inner]
|
||||||
|
|
||||||
|
const r = spawnSync(cmd, args, { stdio: 'inherit', cwd: root, shell: false })
|
||||||
|
process.exit(r.status ?? 1)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import Planning from './Planning'
|
import Planning from './pages/Planning'
|
||||||
import { Dashboard } from './pages/Dashboard'
|
import { Dashboard } from './pages/Dashboard'
|
||||||
import { Settings } from './pages/Settings'
|
import { Settings } from './pages/Settings'
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export default function App() {
|
|||||||
page === 'planning' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
|
page === 'planning' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Plán
|
Plánování
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,457 +0,0 @@
|
|||||||
import { Loader2, RefreshCw } from 'lucide-react'
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
||||||
import {
|
|
||||||
Area,
|
|
||||||
CartesianGrid,
|
|
||||||
ComposedChart,
|
|
||||||
Legend,
|
|
||||||
Line,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from 'recharts'
|
|
||||||
|
|
||||||
import { getCurrentPlan, postRunPlan } from './api/backend'
|
|
||||||
import { useSiteStatus } from './hooks/useSiteStatus'
|
|
||||||
import type { CurrentPlanResponse, PlanningIntervalDto } from './types/plan'
|
|
||||||
|
|
||||||
const TZ = 'Europe/Prague'
|
|
||||||
|
|
||||||
function formatLocal(iso: string): string {
|
|
||||||
const d = new Date(iso)
|
|
||||||
return d.toLocaleString('cs-CZ', {
|
|
||||||
timeZone: TZ,
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLocalTime(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleTimeString('cs-CZ', {
|
|
||||||
timeZone: TZ,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function slotStartUtcMs(iso: string): number {
|
|
||||||
return new Date(iso).getTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
function negPrice(i: PlanningIntervalDto): boolean {
|
|
||||||
const b = i.effective_buy_price
|
|
||||||
const s = i.effective_sell_price
|
|
||||||
return (b != null && b < 0) || (s != null && s < 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowHighlight(i: PlanningIntervalDto): string {
|
|
||||||
if (negPrice(i)) return 'bg-red-950/45'
|
|
||||||
if ((i.pv_a_curtailed_w ?? 0) > 0) return 'bg-amber-950/35'
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChartRow = {
|
|
||||||
label: string
|
|
||||||
ts: number
|
|
||||||
pv_kw: number
|
|
||||||
baseline_kw: number
|
|
||||||
bat_charge_kw: number
|
|
||||||
bat_discharge_kw: number
|
|
||||||
price: number
|
|
||||||
raw: PlanningIntervalDto
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Planning() {
|
|
||||||
const { site, ready: siteReady } = useSiteStatus()
|
|
||||||
const siteId = site?.site_id ?? null
|
|
||||||
|
|
||||||
const [data, setData] = useState<CurrentPlanResponse | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [replanning, setReplanning] = useState(false)
|
|
||||||
const [slotDetail, setSlotDetail] = useState<PlanningIntervalDto | null>(null)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
if (siteId == null) return
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await getCurrentPlan(siteId)
|
|
||||||
setData(res)
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Chyba načtení plánu')
|
|
||||||
setData(null)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [siteId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (siteId != null) void load()
|
|
||||||
}, [siteId, load])
|
|
||||||
|
|
||||||
const nowMs = Date.now()
|
|
||||||
const dayMs = 24 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
const intervals24h = useMemo(() => {
|
|
||||||
if (!data?.intervals?.length) return []
|
|
||||||
const end = nowMs + dayMs
|
|
||||||
return data.intervals
|
|
||||||
.filter((i) => {
|
|
||||||
const t = slotStartUtcMs(i.interval_start)
|
|
||||||
return t >= nowMs && t < end
|
|
||||||
})
|
|
||||||
.slice(0, 96)
|
|
||||||
}, [data?.intervals, nowMs, dayMs])
|
|
||||||
|
|
||||||
const chartRows: ChartRow[] = useMemo(() => {
|
|
||||||
return intervals24h.map((i) => {
|
|
||||||
const bat = i.battery_setpoint_w ?? 0
|
|
||||||
const pv = i.pv_forecast_total_w ?? 0
|
|
||||||
const base = i.load_baseline_w ?? 0
|
|
||||||
const price = i.effective_buy_price ?? 0
|
|
||||||
return {
|
|
||||||
label: formatLocalTime(i.interval_start),
|
|
||||||
ts: slotStartUtcMs(i.interval_start),
|
|
||||||
pv_kw: pv / 1000,
|
|
||||||
baseline_kw: base / 1000,
|
|
||||||
bat_charge_kw: Math.max(0, bat) / 1000,
|
|
||||||
bat_discharge_kw: Math.max(0, -bat) / 1000,
|
|
||||||
price,
|
|
||||||
raw: i,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [intervals24h])
|
|
||||||
|
|
||||||
async function onReplan() {
|
|
||||||
if (siteId == null) return
|
|
||||||
setReplanning(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
await postRunPlan(siteId, 'rolling')
|
|
||||||
await load()
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Přepočet selhal')
|
|
||||||
} finally {
|
|
||||||
setReplanning(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!siteReady) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-[40vh] items-center justify-center text-slate-400">
|
|
||||||
Načítám lokalitu…
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteId == null) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-amber-900/50 bg-amber-950/20 p-4 text-amber-200">
|
|
||||||
V PostgREST nebyla nalezena lokalita (vw_site_status). Nelze načíst plán.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = data?.run
|
|
||||||
const summary = data?.summary
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-6xl space-y-8 p-4 md:p-6">
|
|
||||||
<header className="space-y-1">
|
|
||||||
<h1 className="text-xl font-semibold tracking-tight text-white">Plánování</h1>
|
|
||||||
<p className="text-sm text-slate-400">
|
|
||||||
Aktuální LP plán a přehled dalších 24 hodin ({site?.site_name ?? 'lokalita'})
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md border border-red-900/60 bg-red-950/30 px-3 py-2 text-sm text-red-200">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sekce 1 */}
|
|
||||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
|
||||||
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">
|
|
||||||
Aktuální plán
|
|
||||||
</h2>
|
|
||||||
{loading && !run ? (
|
|
||||||
<div className="flex items-center gap-2 text-slate-400">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" /> Načítám…
|
|
||||||
</div>
|
|
||||||
) : !run ? (
|
|
||||||
<p className="text-slate-400">Žádný aktivní plán v databázi.</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
||||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 md:gap-x-8">
|
|
||||||
<div>
|
|
||||||
<dt className="text-slate-500">Vytvořen</dt>
|
|
||||||
<dd className="font-mono text-slate-200">{formatLocal(run.created_at)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-slate-500">Typ</dt>
|
|
||||||
<dd className="capitalize text-slate-200">{run.run_type}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-slate-500">Korekce FVE</dt>
|
|
||||||
<dd className="font-mono text-slate-200">
|
|
||||||
{run.forecast_correction_factor != null
|
|
||||||
? run.forecast_correction_factor.toFixed(4)
|
|
||||||
: '—'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-slate-500">Čas solveru</dt>
|
|
||||||
<dd className="font-mono text-slate-200">
|
|
||||||
{run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void onReplan()}
|
|
||||||
disabled={replanning}
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{replanning ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Přeplánovat nyní
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{summary && run && (
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3 border-t border-slate-800 pt-4 text-xs text-slate-400 md:grid-cols-5">
|
|
||||||
<div>
|
|
||||||
<div className="text-slate-500">Očekávané náklady (celkem)</div>
|
|
||||||
<div className="font-mono text-slate-200">
|
|
||||||
{summary.total_expected_cost_czk.toFixed(2)} Kč
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-slate-500">Curtailment A</div>
|
|
||||||
<div className="font-mono text-slate-200">
|
|
||||||
{summary.total_pv_curtailed_kwh.toFixed(3)} kWh
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-slate-500">Sloty nabíjení</div>
|
|
||||||
<div className="font-mono text-slate-200">{summary.charge_slots}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-slate-500">Sloty vybíjení</div>
|
|
||||||
<div className="font-mono text-slate-200">{summary.discharge_slots}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-slate-500">Sloty exportu</div>
|
|
||||||
<div className="font-mono text-slate-200">{summary.export_slots}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Sekce 2 */}
|
|
||||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
|
||||||
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">
|
|
||||||
Graf (24 h)
|
|
||||||
</h2>
|
|
||||||
{!chartRows.length ? (
|
|
||||||
<p className="text-sm text-slate-500">Žádná data pro graf v horizontu 24 h.</p>
|
|
||||||
) : (
|
|
||||||
<div className="h-[380px] w-full">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<ComposedChart
|
|
||||||
data={chartRows}
|
|
||||||
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
|
|
||||||
onClick={(state) => {
|
|
||||||
const p = state?.activePayload?.[0]?.payload as ChartRow | undefined
|
|
||||||
if (p?.raw) setSlotDetail(p.raw)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
|
||||||
<XAxis dataKey="label" tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
|
||||||
<YAxis
|
|
||||||
yAxisId="left"
|
|
||||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
|
||||||
label={{ value: 'kW', angle: -90, position: 'insideLeft', fill: '#64748b' }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="right"
|
|
||||||
orientation="right"
|
|
||||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
|
||||||
label={{ value: 'Kč/kWh', angle: 90, position: 'insideRight', fill: '#64748b' }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
background: '#0f172a',
|
|
||||||
border: '1px solid #334155',
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
formatter={(value: number, name: string) => {
|
|
||||||
if (name === 'Cena nákup') return [`${value.toFixed(3)} Kč/kWh`, name]
|
|
||||||
return [`${value.toFixed(2)} kW`, name]
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
<Area
|
|
||||||
yAxisId="left"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="pv_kw"
|
|
||||||
name="FVE předpověď"
|
|
||||||
stroke="#ca8a04"
|
|
||||||
fill="#eab308"
|
|
||||||
fillOpacity={0.35}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
yAxisId="left"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="baseline_kw"
|
|
||||||
name="Spotřeba baseline"
|
|
||||||
stroke="#3b82f6"
|
|
||||||
dot={false}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
yAxisId="left"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="bat_charge_kw"
|
|
||||||
name="Baterie nabíjení"
|
|
||||||
stroke="#22c55e"
|
|
||||||
dot={false}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
yAxisId="left"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="bat_discharge_kw"
|
|
||||||
name="Baterie vybíjení"
|
|
||||||
stroke="#f97316"
|
|
||||||
dot={false}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
yAxisId="right"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="price"
|
|
||||||
name="Cena nákup"
|
|
||||||
stroke="#94a3b8"
|
|
||||||
dot={false}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{slotDetail && (
|
|
||||||
<div className="mt-4 rounded-lg border border-slate-700 bg-slate-950/60 p-3 text-sm">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<span className="font-medium text-slate-200">
|
|
||||||
Slot {formatLocal(slotDetail.interval_start)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-slate-500 hover:text-slate-300"
|
|
||||||
onClick={() => setSlotDetail(null)}
|
|
||||||
>
|
|
||||||
Zavřít
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-xs text-slate-300 md:grid-cols-3">
|
|
||||||
<dt className="text-slate-500">Nákup / prodej</dt>
|
|
||||||
<dd className="col-span-1">
|
|
||||||
{slotDetail.effective_buy_price?.toFixed(4) ?? '—'} /{' '}
|
|
||||||
{slotDetail.effective_sell_price?.toFixed(4) ?? '—'}
|
|
||||||
</dd>
|
|
||||||
<dt className="text-slate-500">FVE (A+B)</dt>
|
|
||||||
<dd>{slotDetail.pv_forecast_total_w ?? '—'} W</dd>
|
|
||||||
<dt className="text-slate-500">Baseline</dt>
|
|
||||||
<dd>{slotDetail.load_baseline_w ?? '—'} W</dd>
|
|
||||||
<dt className="text-slate-500">Baterie</dt>
|
|
||||||
<dd>{slotDetail.battery_setpoint_w ?? '—'} W</dd>
|
|
||||||
<dt className="text-slate-500">SoC cíl</dt>
|
|
||||||
<dd>
|
|
||||||
{slotDetail.battery_soc_target_pct != null
|
|
||||||
? `${slotDetail.battery_soc_target_pct}%`
|
|
||||||
: '—'}
|
|
||||||
</dd>
|
|
||||||
<dt className="text-slate-500">Síť</dt>
|
|
||||||
<dd>{slotDetail.grid_setpoint_w ?? '—'} W</dd>
|
|
||||||
<dt className="text-slate-500">EV1 / EV2</dt>
|
|
||||||
<dd>
|
|
||||||
{slotDetail.ev1_setpoint_w ?? '—'} / {slotDetail.ev2_setpoint_w ?? '—'} W
|
|
||||||
</dd>
|
|
||||||
<dt className="text-slate-500">TČ</dt>
|
|
||||||
<dd>{slotDetail.heat_pump_enabled ? 'Zapnuto' : 'Vypnuto'}</dd>
|
|
||||||
<dt className="text-slate-500">Curtailment A</dt>
|
|
||||||
<dd>{slotDetail.pv_a_curtailed_w ?? 0} W</dd>
|
|
||||||
<dt className="text-slate-500">Náklady slotu</dt>
|
|
||||||
<dd>{slotDetail.expected_cost_czk?.toFixed(4) ?? '—'} Kč</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Sekce 3 */}
|
|
||||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
|
||||||
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">
|
|
||||||
Tabulka (96 slotů / 24 h)
|
|
||||||
</h2>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full border-collapse text-left text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-700 text-slate-500">
|
|
||||||
<th className="py-2 pr-2 font-medium">Čas</th>
|
|
||||||
<th className="py-2 pr-2 font-medium">Nákup</th>
|
|
||||||
<th className="py-2 pr-2 font-medium">Prodej</th>
|
|
||||||
<th className="py-2 pr-2 font-medium">FVE</th>
|
|
||||||
<th className="py-2 pr-2 font-medium">Bat</th>
|
|
||||||
<th className="py-2 pr-2 font-medium">Síť</th>
|
|
||||||
<th className="py-2 pr-2 font-medium">EV1</th>
|
|
||||||
<th className="py-2 pr-2 font-medium">EV2</th>
|
|
||||||
<th className="py-2 pr-2 font-medium">TČ</th>
|
|
||||||
<th className="py-2 font-medium">Náklady</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{intervals24h.map((i) => (
|
|
||||||
<tr key={i.interval_start} className={`border-b border-slate-800/80 ${rowHighlight(i)}`}>
|
|
||||||
<td className="whitespace-nowrap py-1.5 pr-2 font-mono text-slate-300">
|
|
||||||
{formatLocalTime(i.interval_start)}
|
|
||||||
</td>
|
|
||||||
<td className="pr-2 font-mono text-slate-300">
|
|
||||||
{i.effective_buy_price?.toFixed(2) ?? '—'}
|
|
||||||
</td>
|
|
||||||
<td className="pr-2 font-mono text-slate-300">
|
|
||||||
{i.effective_sell_price?.toFixed(2) ?? '—'}
|
|
||||||
</td>
|
|
||||||
<td className="pr-2 font-mono text-slate-300">
|
|
||||||
{i.pv_forecast_total_w != null ? Math.round(i.pv_forecast_total_w) : '—'}
|
|
||||||
</td>
|
|
||||||
<td className="pr-2 font-mono text-slate-300">
|
|
||||||
{i.battery_setpoint_w ?? '—'}
|
|
||||||
</td>
|
|
||||||
<td className="pr-2 font-mono text-slate-300">{i.grid_setpoint_w ?? '—'}</td>
|
|
||||||
<td className="pr-2 font-mono text-slate-300">{i.ev1_setpoint_w ?? '—'}</td>
|
|
||||||
<td className="pr-2 font-mono text-slate-300">{i.ev2_setpoint_w ?? '—'}</td>
|
|
||||||
<td className="pr-2 text-slate-300">{i.heat_pump_enabled ? 'Ano' : 'Ne'}</td>
|
|
||||||
<td className="font-mono text-slate-300">
|
|
||||||
{i.expected_cost_czk?.toFixed(2) ?? '—'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{!intervals24h.length && !loading && (
|
|
||||||
<p className="mt-2 text-sm text-slate-500">Žádné řádky v 24h okně.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios, { type AxiosInstance } from 'axios'
|
import axios, { type AxiosInstance } from 'axios'
|
||||||
|
|
||||||
|
import type { FullStatusResponse } from '../types/fullStatus'
|
||||||
import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan'
|
import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan'
|
||||||
|
|
||||||
const client: AxiosInstance = axios.create({
|
const client: AxiosInstance = axios.create({
|
||||||
@@ -14,6 +15,25 @@ export async function getBackendHealth(): Promise<unknown> {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HealthDetailedResponse = {
|
||||||
|
db: 'ok' | 'error'
|
||||||
|
scheduler: 'running' | 'stopped'
|
||||||
|
telemetry_loop: 'running' | 'stopped'
|
||||||
|
last_telemetry_age_sec: number
|
||||||
|
last_plan_age_sec: number
|
||||||
|
active_jobs: { id: string; next_run_time: string | null }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBackendHealthDetailed(): Promise<HealthDetailedResponse> {
|
||||||
|
const { data } = await client.get<HealthDetailedResponse>('/health/detailed')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSiteStatusFull(siteId: number): Promise<FullStatusResponse> {
|
||||||
|
const { data } = await client.get<FullStatusResponse>(`/sites/${siteId}/status/full`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export type SetSiteModePayload = {
|
export type SetSiteModePayload = {
|
||||||
mode: string
|
mode: string
|
||||||
notes: string | null
|
notes: string | null
|
||||||
@@ -53,4 +73,86 @@ export async function postRunPlan(
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PricesImportResponse = {
|
||||||
|
slots_imported: number
|
||||||
|
date: string
|
||||||
|
first_price_czk_kwh: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postImportSitePrices(
|
||||||
|
siteId: number,
|
||||||
|
date?: string,
|
||||||
|
): Promise<PricesImportResponse> {
|
||||||
|
const { data } = await client.post<PricesImportResponse>(
|
||||||
|
`/sites/${siteId}/prices/import`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
params: date ? { date } : undefined,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ForecastRunResponse = {
|
||||||
|
intervals_saved: number
|
||||||
|
pv_arrays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postRunForecast(siteId: number): Promise<ForecastRunResponse> {
|
||||||
|
const { data } = await client.post<ForecastRunResponse>(
|
||||||
|
`/sites/${siteId}/forecast/run`,
|
||||||
|
null,
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktivní EV session (GET .../ev/sessions/active) – join vozidlo + nabíječka */
|
||||||
|
export type ActiveEvSessionRow = {
|
||||||
|
id: number
|
||||||
|
charger_id: number
|
||||||
|
vehicle_id: number | null
|
||||||
|
session_start: string
|
||||||
|
energy_delivered_wh: number
|
||||||
|
target_soc_pct: number | null
|
||||||
|
target_deadline: string | null
|
||||||
|
make: string | null
|
||||||
|
model: string | null
|
||||||
|
battery_capacity_kwh: number | null
|
||||||
|
default_target_soc_pct: number | null
|
||||||
|
default_deadline_hour: number | null
|
||||||
|
charger_code: string
|
||||||
|
charger_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveEvSessions(siteId: number): Promise<ActiveEvSessionRow[]> {
|
||||||
|
const { data } = await client.get<ActiveEvSessionRow[]>(
|
||||||
|
`/sites/${siteId}/ev/sessions/active`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PatchEvSessionPayload = {
|
||||||
|
target_soc_pct: number | null
|
||||||
|
target_deadline: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PatchEvSessionResponse = {
|
||||||
|
success: boolean
|
||||||
|
session_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchEvSession(
|
||||||
|
siteId: number,
|
||||||
|
sessionId: number,
|
||||||
|
payload: PatchEvSessionPayload,
|
||||||
|
): Promise<PatchEvSessionResponse> {
|
||||||
|
const { data } = await client.patch<PatchEvSessionResponse>(
|
||||||
|
`/sites/${siteId}/ev/sessions/${sessionId}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export { client as backendClient }
|
export { client as backendClient }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Thermometer,
|
Thermometer,
|
||||||
Wrench,
|
Wrench,
|
||||||
X,
|
X,
|
||||||
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
@@ -22,7 +23,7 @@ type ModeDef = {
|
|||||||
description: string
|
description: string
|
||||||
ev: boolean
|
ev: boolean
|
||||||
hp: boolean
|
hp: boolean
|
||||||
Icon: typeof Bot
|
Icon: LucideIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODES: ModeDef[] = [
|
const MODES: ModeDef[] = [
|
||||||
|
|||||||
@@ -8,24 +8,40 @@ const POLL_MS = 30_000
|
|||||||
export function useAuditDailyToday(siteId: number | null) {
|
export function useAuditDailyToday(siteId: number | null) {
|
||||||
const [row, setRow] = useState<AuditDailyRow | null>(null)
|
const [row, setRow] = useState<AuditDailyRow | null>(null)
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (siteId == null) {
|
if (siteId == null) {
|
||||||
setRow(null)
|
setRow(null)
|
||||||
|
setError(null)
|
||||||
setReady(true)
|
setReady(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const rows = await getJson<AuditDailyRow[]>('/vw_audit_daily', {
|
|
||||||
site_id: `eq.${siteId}`,
|
|
||||||
order: 'day_local.desc',
|
|
||||||
limit: '45',
|
|
||||||
})
|
|
||||||
const today = pragueCalendarDay()
|
const today = pragueCalendarDay()
|
||||||
const hit = Array.isArray(rows) ? rows.find((r) => instantPragueDay(r.day_local) === today) : undefined
|
let primary = await getJson<AuditDailyRow[]>('/vw_audit_daily', {
|
||||||
setRow(hit ?? null)
|
site_id: `eq.${siteId}`,
|
||||||
|
day_local: `eq.${today}`,
|
||||||
|
})
|
||||||
|
let chosen: AuditDailyRow | null = null
|
||||||
|
if (Array.isArray(primary) && primary.length > 0) {
|
||||||
|
chosen = primary.find((r) => instantPragueDay(r.day_local) === today) ?? null
|
||||||
|
}
|
||||||
|
if (chosen == null || instantPragueDay(chosen.day_local) !== today) {
|
||||||
|
const recent = await getJson<AuditDailyRow[]>('/vw_audit_daily', {
|
||||||
|
site_id: `eq.${siteId}`,
|
||||||
|
order: 'day_local.desc',
|
||||||
|
limit: '45',
|
||||||
|
})
|
||||||
|
chosen = Array.isArray(recent)
|
||||||
|
? recent.find((r) => instantPragueDay(r.day_local) === today) ?? null
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
setRow(chosen)
|
||||||
|
setError(null)
|
||||||
} catch {
|
} catch {
|
||||||
setRow(null)
|
setRow(null)
|
||||||
|
setError('Denní souhrn auditu se nepodařil načíst')
|
||||||
} finally {
|
} finally {
|
||||||
setReady(true)
|
setReady(true)
|
||||||
}
|
}
|
||||||
@@ -40,6 +56,8 @@ export function useAuditDailyToday(siteId: number | null) {
|
|||||||
return {
|
return {
|
||||||
daily: row,
|
daily: row,
|
||||||
ready,
|
ready,
|
||||||
|
error,
|
||||||
hasDaily: row != null && (row.interval_count ?? 0) > 0,
|
hasDaily: row != null && (row.interval_count ?? 0) > 0,
|
||||||
|
reload: load,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
frontend/src/hooks/useCurrentPlan.ts
Normal file
47
frontend/src/hooks/useCurrentPlan.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import { getCurrentPlan } from '../api/backend'
|
||||||
|
import type { CurrentPlanResponse } from '../types/plan'
|
||||||
|
|
||||||
|
const POLL_MS = 30_000
|
||||||
|
|
||||||
|
const EMPTY: CurrentPlanResponse = { run: null, intervals: [], summary: null }
|
||||||
|
|
||||||
|
export function useCurrentPlan(siteId: number | null) {
|
||||||
|
const [data, setData] = useState<CurrentPlanResponse>(EMPTY)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (siteId == null) {
|
||||||
|
setData(EMPTY)
|
||||||
|
setError(null)
|
||||||
|
setReady(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await getCurrentPlan(siteId)
|
||||||
|
setData(res)
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 404) {
|
||||||
|
setData(EMPTY)
|
||||||
|
setError(null)
|
||||||
|
} else {
|
||||||
|
setData(EMPTY)
|
||||||
|
setError(e instanceof Error ? e.message : 'Nepodařilo se načíst plán')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setReady(true)
|
||||||
|
}
|
||||||
|
}, [siteId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
const id = window.setInterval(() => void load(), POLL_MS)
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return { plan: data, ready, error, reload: load }
|
||||||
|
}
|
||||||
38
frontend/src/hooks/useEVSessions.ts
Normal file
38
frontend/src/hooks/useEVSessions.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { getActiveEvSessions, type ActiveEvSessionRow } from '../api/backend'
|
||||||
|
|
||||||
|
const POLL_MS = 30_000
|
||||||
|
|
||||||
|
export function useEVSessions(siteId: number | null) {
|
||||||
|
const [sessions, setSessions] = useState<ActiveEvSessionRow[]>([])
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (siteId == null) {
|
||||||
|
setSessions([])
|
||||||
|
setReady(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rows = await getActiveEvSessions(siteId)
|
||||||
|
setSessions(rows)
|
||||||
|
setError(null)
|
||||||
|
} catch {
|
||||||
|
setSessions([])
|
||||||
|
setError('EV session se nepodařilo načíst')
|
||||||
|
} finally {
|
||||||
|
setReady(true)
|
||||||
|
}
|
||||||
|
}, [siteId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
if (siteId == null) return
|
||||||
|
const id = window.setInterval(() => void load(), POLL_MS)
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [load, siteId])
|
||||||
|
|
||||||
|
return { sessions, ready, error, reload: load }
|
||||||
|
}
|
||||||
39
frontend/src/hooks/useFullStatus.ts
Normal file
39
frontend/src/hooks/useFullStatus.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { getSiteStatusFull } from '../api/backend'
|
||||||
|
import type { FullStatusResponse } from '../types/fullStatus'
|
||||||
|
|
||||||
|
const POLL_MS = 30_000
|
||||||
|
|
||||||
|
export function useFullStatus(siteId: number | null) {
|
||||||
|
const [data, setData] = useState<FullStatusResponse | null>(null)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (siteId == null) {
|
||||||
|
setData(null)
|
||||||
|
setError(null)
|
||||||
|
setReady(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await getSiteStatusFull(siteId)
|
||||||
|
setData(res)
|
||||||
|
setError(null)
|
||||||
|
} catch {
|
||||||
|
setData(null)
|
||||||
|
setError('Monitoring stav se nepodařilo načíst')
|
||||||
|
} finally {
|
||||||
|
setReady(true)
|
||||||
|
}
|
||||||
|
}, [siteId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
const id = window.setInterval(() => void load(), POLL_MS)
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return { fullStatus: data, ready, error, reload: load }
|
||||||
|
}
|
||||||
@@ -7,13 +7,16 @@ const POLL_MS = 5_000
|
|||||||
export function useSiteStatus() {
|
export function useSiteStatus() {
|
||||||
const [row, setRow] = useState<SiteStatusRow | null>(null)
|
const [row, setRow] = useState<SiteStatusRow | null>(null)
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const rows = await getJson<SiteStatusRow[]>('/vw_site_status')
|
const rows = await getJson<SiteStatusRow[]>('/vw_site_status')
|
||||||
setRow(Array.isArray(rows) && rows.length > 0 ? rows[0]! : null)
|
setRow(Array.isArray(rows) && rows.length > 0 ? rows[0]! : null)
|
||||||
|
setError(null)
|
||||||
} catch {
|
} catch {
|
||||||
setRow(null)
|
setRow(null)
|
||||||
|
setError('Stav lokality se nepodařilo načíst')
|
||||||
} finally {
|
} finally {
|
||||||
setReady(true)
|
setReady(true)
|
||||||
}
|
}
|
||||||
@@ -35,6 +38,7 @@ export function useSiteStatus() {
|
|||||||
return {
|
return {
|
||||||
site: row,
|
site: row,
|
||||||
ready,
|
ready,
|
||||||
|
error,
|
||||||
/** Máme řádek lokality a alespoň jednu telemetrickou hodnotu (jinak skeleton). */
|
/** Máme řádek lokality a alespoň jednu telemetrickou hodnotu (jinak skeleton). */
|
||||||
hasLiveData: row != null && hasTelemetry,
|
hasLiveData: row != null && hasTelemetry,
|
||||||
reload: load,
|
reload: load,
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ export type TelemetryChartPoint = {
|
|||||||
export function useTelemetryToday(siteId: number | null) {
|
export function useTelemetryToday(siteId: number | null) {
|
||||||
const [points, setPoints] = useState<TelemetryChartPoint[]>([])
|
const [points, setPoints] = useState<TelemetryChartPoint[]>([])
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (siteId == null) {
|
if (siteId == null) {
|
||||||
setPoints([])
|
setPoints([])
|
||||||
|
setError(null)
|
||||||
setReady(true)
|
setReady(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -37,6 +39,7 @@ export function useTelemetryToday(siteId: number | null) {
|
|||||||
})
|
})
|
||||||
if (!Array.isArray(rows) || rows.length === 0) {
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
setPoints([])
|
setPoints([])
|
||||||
|
setError(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const mapped: TelemetryChartPoint[] = rows.map((r) => {
|
const mapped: TelemetryChartPoint[] = rows.map((r) => {
|
||||||
@@ -55,8 +58,10 @@ export function useTelemetryToday(siteId: number | null) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
setPoints(mapped)
|
setPoints(mapped)
|
||||||
|
setError(null)
|
||||||
} catch {
|
} catch {
|
||||||
setPoints([])
|
setPoints([])
|
||||||
|
setError('Hodinová data auditu se nepodařila načíst')
|
||||||
} finally {
|
} finally {
|
||||||
setReady(true)
|
setReady(true)
|
||||||
}
|
}
|
||||||
@@ -68,5 +73,5 @@ export function useTelemetryToday(siteId: number | null) {
|
|||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
return { points, ready, hasChartData: points.length > 0 }
|
return { points, ready, error, hasChartData: points.length > 0, reload: load }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
import { Battery, Sun, Zap } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
import { PowerFlowCard } from '../components/PowerFlowCard'
|
import { Sun, Battery, Zap, Home, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import { SocGauge } from '../components/SocGauge'
|
import {
|
||||||
import { TelemetryChart } from '../components/TelemetryChart'
|
Area,
|
||||||
|
Bar,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
import { useAuditDailyToday } from '../hooks/useAuditDailyToday'
|
import { useAuditDailyToday } from '../hooks/useAuditDailyToday'
|
||||||
|
import { useCurrentPlan } from '../hooks/useCurrentPlan'
|
||||||
|
import { useFullStatus } from '../hooks/useFullStatus'
|
||||||
import { useSiteStatus } from '../hooks/useSiteStatus'
|
import { useSiteStatus } from '../hooks/useSiteStatus'
|
||||||
import { useTelemetryToday } from '../hooks/useTelemetryToday'
|
import { useTelemetryToday, type TelemetryChartPoint } from '../hooks/useTelemetryToday'
|
||||||
|
import type { PlanningIntervalDto } from '../types/plan'
|
||||||
|
|
||||||
|
const BAT_PLAN_W = 80
|
||||||
|
|
||||||
|
function fmtKw2(w: number | null | undefined): string {
|
||||||
|
if (w == null || Number.isNaN(w)) return '—'
|
||||||
|
return `${(w / 1000).toFixed(2)} kW`
|
||||||
|
}
|
||||||
|
|
||||||
function fmtEnergy(v: string | number | null | undefined): string {
|
function fmtEnergy(v: string | number | null | undefined): string {
|
||||||
const n = typeof v === 'number' ? v : v == null ? NaN : Number(v)
|
const n = typeof v === 'number' ? v : v == null ? NaN : Number(v)
|
||||||
@@ -18,6 +39,13 @@ function fmtMoney(v: string | number | null | undefined): string {
|
|||||||
return `${n.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč`
|
return `${n.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseNum(v: string | number | null | undefined): number | null {
|
||||||
|
if (v == null) return null
|
||||||
|
if (typeof v === 'number' && !Number.isNaN(v)) return v
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
function modeBadgeClass(code: string | null): string {
|
function modeBadgeClass(code: string | null): string {
|
||||||
const c = (code ?? '').toUpperCase()
|
const c = (code ?? '').toUpperCase()
|
||||||
if (c.includes('AUTO')) return 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/35'
|
if (c.includes('AUTO')) return 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/35'
|
||||||
@@ -27,146 +55,571 @@ function modeBadgeClass(code: string | null): string {
|
|||||||
return 'bg-slate-700/60 text-slate-200 ring-1 ring-slate-600/50'
|
return 'bg-slate-700/60 text-slate-200 ring-1 ring-slate-600/50'
|
||||||
}
|
}
|
||||||
|
|
||||||
function batteryStyles(powerW: number | null | undefined): { border: string; icon: string } {
|
function formatTelemetryAgo(iso: string | null | undefined): string {
|
||||||
if (powerW == null || Number.isNaN(powerW)) {
|
if (iso == null) return '—'
|
||||||
return { border: 'border-l-slate-600', icon: 'text-slate-400' }
|
const diffMin = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000)
|
||||||
}
|
if (diffMin <= 0) return 'právě teď'
|
||||||
if (powerW >= 0) {
|
if (diffMin === 1) return 'před 1 minutou'
|
||||||
return { border: 'border-l-emerald-500', icon: 'text-emerald-400' }
|
if (diffMin >= 2 && diffMin <= 4) return `před ${diffMin} minutami`
|
||||||
}
|
return `před ${diffMin} minutami`
|
||||||
return { border: 'border-l-orange-500', icon: 'text-orange-400' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function gridStyles(powerW: number | null | undefined): { border: string; icon: string } {
|
function floorToSlotUtc(ms: number): number {
|
||||||
if (powerW == null || Number.isNaN(powerW)) {
|
const slot = 15 * 60 * 1000
|
||||||
return { border: 'border-l-slate-600', icon: 'text-slate-400' }
|
return Math.floor(ms / slot) * slot
|
||||||
}
|
|
||||||
if (powerW >= 0) {
|
|
||||||
return { border: 'border-l-red-500', icon: 'text-red-400' }
|
|
||||||
}
|
|
||||||
return { border: 'border-l-emerald-500', icon: 'text-emerald-400' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
|
function nextPlanSlots(intervals: PlanningIntervalDto[], count: number): PlanningIntervalDto[] {
|
||||||
|
if (!intervals.length) return []
|
||||||
|
const sorted = [...intervals].sort(
|
||||||
|
(a, b) => new Date(a.interval_start).getTime() - new Date(b.interval_start).getTime(),
|
||||||
|
)
|
||||||
|
const boundary = floorToSlotUtc(Date.now())
|
||||||
|
const upcoming = sorted.filter((iv) => new Date(iv.interval_start).getTime() >= boundary - 1)
|
||||||
|
return upcoming.slice(0, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
function meanBuyPrice(slots: PlanningIntervalDto[]): number | null {
|
||||||
|
const vals = slots
|
||||||
|
.map((s) => s.effective_buy_price)
|
||||||
|
.filter((x): x is number => x != null && Number.isFinite(x))
|
||||||
|
if (!vals.length) return null
|
||||||
|
return vals.reduce((a, b) => a + b, 0) / vals.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotBgClass(slot: PlanningIntervalDto, avgBuy: number | null): string {
|
||||||
|
const b = slot.battery_setpoint_w ?? 0
|
||||||
|
if (b > BAT_PLAN_W) return 'bg-emerald-500'
|
||||||
|
if (b < -BAT_PLAN_W) return 'bg-orange-500'
|
||||||
|
const buy = slot.effective_buy_price
|
||||||
|
if (buy != null && avgBuy != null && avgBuy > 0) {
|
||||||
|
if (buy > avgBuy * 1.15) return 'bg-red-500'
|
||||||
|
if (buy < avgBuy * 0.85) return 'bg-amber-400'
|
||||||
|
}
|
||||||
|
return 'bg-slate-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSlotLabel(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString('cs-CZ', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: 'Europe/Prague',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartTipPayload = { name?: string; value?: number; dataKey?: string | number }
|
||||||
|
|
||||||
|
function ChartTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean
|
||||||
|
payload?: ChartTipPayload[]
|
||||||
|
label?: string
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="rounded-lg border border-slate-600 bg-slate-900/95 px-3 py-2 text-xs text-slate-100 shadow-xl">
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500">{kicker}</p>
|
<p className="mb-1 font-medium text-slate-200">{label}</p>
|
||||||
<h2 className="text-lg font-semibold text-slate-100">{title}</h2>
|
<ul className="space-y-0.5 tabular-nums">
|
||||||
|
{payload.map((p) => (
|
||||||
|
<li key={String(p.dataKey)} className="flex justify-between gap-6">
|
||||||
|
<span className="text-slate-400">{p.name}</span>
|
||||||
|
<span>{typeof p.value === 'number' ? `${p.value.toFixed(2)} kW` : '—'}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardSkeleton() {
|
function SemicircleSocGauge({ socPercent }: { socPercent: string | number | null | undefined }) {
|
||||||
return <div className="h-[88px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
|
const raw = parseNum(socPercent)
|
||||||
}
|
const pct = raw == null ? null : Math.max(0, Math.min(100, raw))
|
||||||
|
const r = 88
|
||||||
|
const halfLen = Math.PI * r
|
||||||
|
const stroke =
|
||||||
|
pct == null ? 'text-slate-600' : pct < 20 ? 'text-red-500' : pct > 80 ? 'text-blue-500' : 'text-emerald-500'
|
||||||
|
|
||||||
function StatBlock({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
<div className="flex flex-col items-center pt-2">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</p>
|
<div className="relative h-[120px] w-[220px]">
|
||||||
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p>
|
<svg viewBox="0 0 200 110" className="h-full w-full" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M 12 100 A 88 88 0 0 1 188 100"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="14"
|
||||||
|
className="text-slate-800"
|
||||||
|
/>
|
||||||
|
{pct != null && (
|
||||||
|
<path
|
||||||
|
d="M 12 100 A 88 88 0 0 1 188 100"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="14"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={halfLen}
|
||||||
|
strokeDashoffset={halfLen * (1 - pct / 100)}
|
||||||
|
className={stroke}
|
||||||
|
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1">
|
||||||
|
<span className="text-3xl font-bold tabular-nums text-slate-50">
|
||||||
|
{pct == null ? '—' : `${pct.toFixed(0)}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">% SoC</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatSkeleton() {
|
function MetricSkeleton() {
|
||||||
return <div className="h-[76px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
|
return <div className="h-[104px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockSkeleton({ className = '' }: { className?: string }) {
|
||||||
|
return <div className={`animate-pulse rounded-xl border border-slate-800 bg-slate-900/40 ${className}`} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const { site, ready: siteReady, hasLiveData } = useSiteStatus()
|
const { site, ready: siteReady, error: siteError, hasLiveData, reload: reloadSite } = useSiteStatus()
|
||||||
const siteId = site?.site_id ?? null
|
const siteId = site?.site_id ?? null
|
||||||
const { points, ready: telemetryReady, hasChartData } = useTelemetryToday(siteId)
|
const { fullStatus } = useFullStatus(siteId)
|
||||||
const { daily, ready: auditReady, hasDaily } = useAuditDailyToday(siteId)
|
const [alertsOpen, setAlertsOpen] = useState(false)
|
||||||
|
|
||||||
const liveSkeleton = !siteReady || !hasLiveData
|
const {
|
||||||
const chartSkeleton = !telemetryReady || !hasChartData
|
points,
|
||||||
const econSkeleton = !auditReady || !hasDaily
|
ready: chartReady,
|
||||||
|
error: chartError,
|
||||||
|
hasChartData,
|
||||||
|
reload: reloadChart,
|
||||||
|
} = useTelemetryToday(siteId)
|
||||||
|
const {
|
||||||
|
daily,
|
||||||
|
ready: auditReady,
|
||||||
|
error: auditError,
|
||||||
|
hasDaily,
|
||||||
|
reload: reloadAudit,
|
||||||
|
} = useAuditDailyToday(siteId)
|
||||||
|
const { plan, ready: planReady, error: planError, reload: reloadPlan } = useCurrentPlan(siteId)
|
||||||
|
|
||||||
const hbOk = site?.ems_heartbeat_status === 'ok'
|
const fetchError = siteError ?? chartError ?? auditError ?? planError
|
||||||
const bat = batteryStyles(site?.battery_power_w ?? null)
|
const retryAll = () => {
|
||||||
const grd = gridStyles(site?.grid_power_w ?? null)
|
void reloadSite()
|
||||||
|
void reloadChart()
|
||||||
|
void reloadAudit()
|
||||||
|
void reloadPlan()
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricsLoading = !siteReady
|
||||||
|
const chartLoading = !chartReady
|
||||||
|
const summaryLoading = !auditReady
|
||||||
|
const planLoading = !planReady
|
||||||
|
|
||||||
|
const hbOnline = site?.ems_heartbeat_status === 'ok'
|
||||||
|
|
||||||
|
const monitoringAlerts = fullStatus?.alerts ?? []
|
||||||
|
const hasMonitoringAlerts = monitoringAlerts.length > 0
|
||||||
|
const monitoringHasError = monitoringAlerts.some((a) => a.level === 'error')
|
||||||
|
|
||||||
|
const gridW = site?.grid_power_w ?? null
|
||||||
|
const gridLabel =
|
||||||
|
gridW == null || Number.isNaN(gridW)
|
||||||
|
? '—'
|
||||||
|
: gridW >= 0
|
||||||
|
? `+${(gridW / 1000).toFixed(2)} kW import`
|
||||||
|
: `${(gridW / 1000).toFixed(2)} kW export`
|
||||||
|
|
||||||
|
const batW = site?.battery_power_w ?? null
|
||||||
|
const batPct = parseNum(site?.battery_soc_percent)
|
||||||
|
const batSignedKw =
|
||||||
|
batW == null || Number.isNaN(batW) ? null : Math.abs(batW / 1000) * (batW >= 0 ? 1 : -1)
|
||||||
|
|
||||||
|
const planSlots = nextPlanSlots(plan.intervals, 16)
|
||||||
|
const avgBuy = meanBuyPrice(planSlots)
|
||||||
|
|
||||||
|
const chartData: TelemetryChartPoint[] = points
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
|
<div className="min-h-screen bg-slate-950 p-4 text-slate-100 md:p-8">
|
||||||
<div className="mx-auto max-w-7xl space-y-10">
|
<div className="mx-auto max-w-7xl space-y-8">
|
||||||
<header className="flex flex-col gap-4 border-b border-slate-800/80 pb-6 md:flex-row md:items-center md:justify-between">
|
{fetchError ? (
|
||||||
<div>
|
<div
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-white">EMS Platform</h1>
|
className="flex flex-col gap-3 rounded-xl border border-red-500/40 bg-red-950/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
<p className="mt-1 text-sm text-slate-400">Přehled lokality a auditu</p>
|
role="alert"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-red-200">Chyba načítání dat</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => retryAll()}
|
||||||
|
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-500"
|
||||||
|
>
|
||||||
|
Zkusit znovu
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{!siteReady ? (
|
) : null}
|
||||||
<div className="h-10 w-56 animate-pulse rounded-lg bg-slate-800/80" />
|
|
||||||
) : site ? (
|
<header className="border-b border-slate-800/80 pb-6">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<h1 className="text-2xl font-bold tracking-tight text-white">EMS Platform</h1>
|
||||||
<span className="text-sm text-slate-400">{site.site_name}</span>
|
<p className="mt-1 text-sm text-slate-400">Přehled lokality, auditu a plánu</p>
|
||||||
<span
|
|
||||||
className={`rounded-md px-2.5 py-1 text-xs font-semibold uppercase tracking-wide ${modeBadgeClass(site.active_mode)}`}
|
|
||||||
title={site.mode_description ?? undefined}
|
|
||||||
>
|
|
||||||
{site.active_mode ?? '—'}
|
|
||||||
{site.mode_name ? ` · ${site.mode_name}` : ''}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-2 text-xs text-slate-500">
|
|
||||||
<span className="relative flex h-2.5 w-2.5">
|
|
||||||
<span
|
|
||||||
className={`inline-flex h-2.5 w-2.5 rounded-full ${hbOk ? 'bg-emerald-500' : 'bg-red-500'}`}
|
|
||||||
title={site.ems_heartbeat_status ?? 'neznámý'}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
EMS
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Horní metriky */}
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle kicker="Živě" title="Aktuální stav" />
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
{liveSkeleton ? (
|
{metricsLoading ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<>
|
||||||
<CardSkeleton />
|
<MetricSkeleton />
|
||||||
<CardSkeleton />
|
<MetricSkeleton />
|
||||||
<div className="flex min-h-[88px] items-center justify-center rounded-xl border border-slate-800 bg-slate-900/40">
|
<MetricSkeleton />
|
||||||
<div className="h-36 w-36 animate-pulse rounded-full bg-slate-800/80" />
|
<MetricSkeleton />
|
||||||
|
</>
|
||||||
|
) : site == null ? (
|
||||||
|
<p className="col-span-full text-sm text-slate-500">Žádná lokalita ve vw_site_status.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-xl border border-slate-800 border-l-4 border-l-amber-400 bg-slate-900/60 p-4 pl-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
|
||||||
|
<Sun className="h-6 w-6 text-amber-400" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">FVE výroba</p>
|
||||||
|
<p className="text-xl font-semibold tabular-nums text-amber-300">
|
||||||
|
{hasLiveData ? fmtKw2(site.pv_power_w) : '—'}{' '}
|
||||||
|
<span className="text-lg" aria-hidden>
|
||||||
|
☀️
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80 ${
|
||||||
|
batW != null && !Number.isNaN(batW)
|
||||||
|
? batW >= 0
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: 'text-orange-400'
|
||||||
|
: 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Battery className="h-6 w-6" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Baterie</p>
|
||||||
|
<p className="text-xl font-semibold tabular-nums text-slate-100">
|
||||||
|
{batPct == null ? '—' : `${batPct.toFixed(0)}%`}
|
||||||
|
{batSignedKw == null ? '' : ` / ${batSignedKw >= 0 ? '+' : ''}${batSignedKw.toFixed(2)} kW`}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-800">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
batW != null && !Number.isNaN(batW) && batW < 0 ? 'bg-orange-500' : 'bg-emerald-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${batPct ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`rounded-xl border border-slate-800 bg-slate-900/60 p-4 pl-3 border-l-4 ${
|
||||||
|
gridW != null && !Number.isNaN(gridW)
|
||||||
|
? gridW >= 0
|
||||||
|
? 'border-l-red-500'
|
||||||
|
: 'border-l-emerald-500'
|
||||||
|
: 'border-l-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
|
||||||
|
<Zap
|
||||||
|
className={`h-6 w-6 ${
|
||||||
|
gridW != null && !Number.isNaN(gridW)
|
||||||
|
? gridW >= 0
|
||||||
|
? 'text-red-400'
|
||||||
|
: 'text-emerald-400'
|
||||||
|
: 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Síť</p>
|
||||||
|
<p className="text-xl font-semibold tabular-nums text-slate-100">{gridLabel}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500">
|
||||||
|
{gridW != null && !Number.isNaN(gridW)
|
||||||
|
? gridW >= 0
|
||||||
|
? 'import'
|
||||||
|
: 'export'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-800 border-l-4 border-l-blue-500 bg-slate-900/60 p-4 pl-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
|
||||||
|
<Home className="h-6 w-6 text-blue-400" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Spotřeba</p>
|
||||||
|
<p className="text-xl font-semibold tabular-nums text-blue-300">
|
||||||
|
{hasLiveData ? fmtKw2(site.load_power_w) : '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status řádek */}
|
||||||
|
{!metricsLoading && site != null ? (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
<span className="text-slate-500">Aktivní režim:</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-md px-2.5 py-1 text-xs font-semibold uppercase tracking-wide ${modeBadgeClass(site.active_mode)}`}
|
||||||
|
title={site.mode_description ?? undefined}
|
||||||
|
>
|
||||||
|
{site.active_mode ?? '—'}
|
||||||
|
{site.mode_name ? ` · ${site.mode_name}` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2 text-slate-400">
|
||||||
|
<span className="relative flex h-2.5 w-2.5">
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-2.5 w-2.5 rounded-full ${hbOnline ? 'bg-emerald-500' : 'bg-red-500'}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
EMS:{' '}
|
||||||
|
<span className={hbOnline ? 'text-emerald-400' : 'text-red-400'}>
|
||||||
|
{hbOnline ? 'online' : 'offline'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">
|
||||||
|
Poslední telemetrie:{' '}
|
||||||
|
<span className="text-slate-300">{formatTelemetryAgo(site.telemetry_at)}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<CardSkeleton />
|
|
||||||
|
{hasMonitoringAlerts ? (
|
||||||
|
<div className="w-full max-w-2xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAlertsOpen((o) => !o)}
|
||||||
|
className={`flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left text-sm font-medium transition hover:opacity-95 ${
|
||||||
|
monitoringHasError
|
||||||
|
? 'border-red-500/45 bg-red-950/45 text-red-100'
|
||||||
|
: 'border-amber-500/40 bg-amber-950/35 text-amber-100'
|
||||||
|
}`}
|
||||||
|
aria-expanded={alertsOpen}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{monitoringAlerts.length}{' '}
|
||||||
|
{monitoringAlerts.length === 1 ? 'alert' : 'alertů'}
|
||||||
|
{monitoringHasError ? ' · obsahuje chyby' : ''}
|
||||||
|
</span>
|
||||||
|
{alertsOpen ? (
|
||||||
|
<ChevronUp className="h-4 w-4 shrink-0 opacity-80" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-80" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{alertsOpen ? (
|
||||||
|
<ul
|
||||||
|
className={`mt-2 space-y-1.5 rounded-lg border px-3 py-2 text-sm ${
|
||||||
|
monitoringHasError
|
||||||
|
? 'border-red-500/30 bg-red-950/25 text-red-100'
|
||||||
|
: 'border-amber-500/25 bg-amber-950/20 text-amber-50'
|
||||||
|
}`}
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
{monitoringAlerts.map((a, i) => (
|
||||||
|
<li
|
||||||
|
key={`${a.level}-${i}-${a.message}`}
|
||||||
|
className={
|
||||||
|
a.level === 'error'
|
||||||
|
? 'text-red-200'
|
||||||
|
: 'text-amber-200'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="font-semibold uppercase tracking-wide text-[10px] opacity-80">
|
||||||
|
{a.level === 'error' ? 'Chyba' : 'Varování'}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2">{a.message}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : metricsLoading ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="mt-4 h-5 w-full max-w-md animate-pulse rounded bg-slate-800/80" />
|
||||||
<PowerFlowCard label="FVE" powerW={site?.pv_power_w} icon={Sun} borderClass="border-l-amber-400" iconClass="text-amber-400" />
|
) : null}
|
||||||
<PowerFlowCard
|
|
||||||
label="Baterie"
|
|
||||||
powerW={site?.battery_power_w}
|
|
||||||
icon={Battery}
|
|
||||||
borderClass={bat.border}
|
|
||||||
iconClass={bat.icon}
|
|
||||||
/>
|
|
||||||
<SocGauge socPercent={site?.battery_soc_percent} loading={false} />
|
|
||||||
<PowerFlowCard label="Síť" powerW={site?.grid_power_w} icon={Zap} borderClass={grd.border} iconClass={grd.icon} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
{/* Graf + denní souhrn */}
|
||||||
<SectionTitle kicker="Dnes" title="Průběh výkonů (hodinový průměr)" />
|
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<TelemetryChart points={points} loading={chartSkeleton} />
|
<div className="lg:col-span-2">
|
||||||
|
{chartLoading ? (
|
||||||
|
<BlockSkeleton className="h-[300px] w-full" />
|
||||||
|
) : !hasChartData ? (
|
||||||
|
<div className="flex h-[300px] items-center justify-center rounded-xl border border-slate-800 bg-slate-900/40 text-sm text-slate-500">
|
||||||
|
Zatím žádná data pro dnešní den
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-[300px] w-full rounded-xl border border-slate-800 bg-slate-900/40 p-2 pr-4 pb-4 pt-4">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis dataKey="timeLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} width={40} />
|
||||||
|
<Tooltip content={<ChartTooltip />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="pv_kw"
|
||||||
|
name="FVE"
|
||||||
|
stroke="#fbbf24"
|
||||||
|
fill="#fbbf24"
|
||||||
|
fillOpacity={0.25}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="load_kw"
|
||||||
|
name="Spotřeba"
|
||||||
|
stroke="#60a5fa"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
<Bar dataKey="battery_kw" name="Baterie" barSize={14}>
|
||||||
|
{chartData.map((e, i) => (
|
||||||
|
<Cell
|
||||||
|
key={`c-${i}`}
|
||||||
|
fill={
|
||||||
|
e.battery_kw == null || Number.isNaN(e.battery_kw)
|
||||||
|
? '#475569'
|
||||||
|
: e.battery_kw >= 0
|
||||||
|
? '#22c55e'
|
||||||
|
: '#f97316'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="grid_kw"
|
||||||
|
name="Síť"
|
||||||
|
stroke="#94a3b8"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="6 4"
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
{summaryLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<BlockSkeleton className="h-10 w-full" />
|
||||||
|
<BlockSkeleton className="h-10 w-full" />
|
||||||
|
<BlockSkeleton className="h-10 w-full" />
|
||||||
|
<BlockSkeleton className="h-10 w-full" />
|
||||||
|
<BlockSkeleton className="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-5">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500">Dnešní souhrn</h2>
|
||||||
|
<ul className="mt-4 space-y-3 text-sm">
|
||||||
|
<li className="flex justify-between gap-2">
|
||||||
|
<span className="text-slate-400">FVE výroba</span>
|
||||||
|
<span className="tabular-nums text-amber-200">{fmtEnergy(daily?.pv_kwh)}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between gap-2">
|
||||||
|
<span className="text-slate-400">Import ze sítě</span>
|
||||||
|
<span className="tabular-nums text-red-300">{fmtEnergy(daily?.import_kwh)}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between gap-2">
|
||||||
|
<span className="text-slate-400">Export do sítě</span>
|
||||||
|
<span className="tabular-nums text-emerald-300">{fmtEnergy(daily?.export_kwh)}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between gap-2">
|
||||||
|
<span className="text-slate-400">Náklady / příjem</span>
|
||||||
|
{(() => {
|
||||||
|
const c = parseNum(daily?.actual_cost_czk)
|
||||||
|
const cls =
|
||||||
|
c == null
|
||||||
|
? 'text-slate-200'
|
||||||
|
: c > 0
|
||||||
|
? 'text-red-400'
|
||||||
|
: c < 0
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: 'text-slate-200'
|
||||||
|
return <span className={`tabular-nums font-medium ${cls}`}>{fmtMoney(daily?.actual_cost_czk)}</span>
|
||||||
|
})()}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{!hasDaily ? (
|
||||||
|
<p className="mt-3 text-xs text-slate-500">Pro dnešek zatím nejsou uzavřené intervaly auditu.</p>
|
||||||
|
) : null}
|
||||||
|
<SemicircleSocGauge socPercent={site?.battery_soc_percent} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Plán 4 h */}
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle kicker="Dnes" title="Ekonomika auditu" />
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
{econSkeleton ? (
|
Nejbližší plán (4 hodiny)
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
</h2>
|
||||||
<StatSkeleton />
|
{planLoading ? (
|
||||||
<StatSkeleton />
|
<BlockSkeleton className="h-16 w-full" />
|
||||||
<StatSkeleton />
|
) : planSlots.length === 0 ? (
|
||||||
<StatSkeleton />
|
<p className="text-sm text-slate-500">Plán zatím není k dispozici</p>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||||
<StatBlock label="Import" value={fmtEnergy(daily?.import_kwh)} />
|
<div className="flex gap-1">
|
||||||
<StatBlock label="Export" value={fmtEnergy(daily?.export_kwh)} />
|
{planSlots.map((slot, i) => (
|
||||||
<StatBlock label="FVE výroba" value={fmtEnergy(daily?.pv_kwh)} />
|
<div key={`${slot.interval_start}-${i}`} className="min-w-0 flex-1 group relative">
|
||||||
<StatBlock label="Náklady / příjem (audit)" value={fmtMoney(daily?.actual_cost_czk)} />
|
<div
|
||||||
|
className={`h-10 w-full rounded-sm ${slotBgClass(slot, avgBuy)} opacity-90 transition group-hover:opacity-100`}
|
||||||
|
title=""
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden w-max min-w-[140px] -translate-x-1/2 rounded-md border border-slate-600 bg-slate-900 px-2 py-1.5 text-[10px] text-slate-100 shadow-lg group-hover:block">
|
||||||
|
<p className="font-medium text-slate-200">{formatSlotLabel(slot.interval_start)}</p>
|
||||||
|
<p className="tabular-nums text-slate-400">
|
||||||
|
cena:{' '}
|
||||||
|
{slot.effective_buy_price == null
|
||||||
|
? '—'
|
||||||
|
: `${slot.effective_buy_price.toFixed(3)} Kč/kWh`}
|
||||||
|
</p>
|
||||||
|
<p className="tabular-nums text-slate-400">
|
||||||
|
baterie: {fmtKw2(slot.battery_setpoint_w ?? undefined)}
|
||||||
|
</p>
|
||||||
|
<p className="tabular-nums text-slate-400">síť: {fmtKw2(slot.grid_setpoint_w ?? undefined)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-center text-[10px] text-slate-600">16× 15 min · najet myší pro detail</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
687
frontend/src/pages/Planning.tsx
Normal file
687
frontend/src/pages/Planning.tsx
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import {
|
||||||
|
ArrowDownRight,
|
||||||
|
ArrowUpRight,
|
||||||
|
CloudSun,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Sparkles,
|
||||||
|
Upload,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
Bar,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
import { getCurrentPlan, postImportSitePrices, postRunForecast, postRunPlan } from '../api/backend'
|
||||||
|
import { useSiteStatus } from '../hooks/useSiteStatus'
|
||||||
|
import type { CurrentPlanResponse, PlanningIntervalDto } from '../types/plan'
|
||||||
|
|
||||||
|
const TZ = 'Europe/Prague'
|
||||||
|
|
||||||
|
function formatLocal(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('cs-CZ', {
|
||||||
|
timeZone: TZ,
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocalTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString('cs-CZ', {
|
||||||
|
timeZone: TZ,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotStartUtcMs(iso: string): number {
|
||||||
|
return new Date(iso).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vizuál FVE: API posílá součet A+B (`pv_forecast_total_w`).
|
||||||
|
* Pokud je hodnota null (data chybí), použijeme jednoduchou proxy z ceny nákupu (W).
|
||||||
|
* Čistá nula = platná předpověď „bez výroby“ (např. noc).
|
||||||
|
*/
|
||||||
|
function pvAProxyW(i: PlanningIntervalDto): number {
|
||||||
|
const pv = i.pv_forecast_total_w
|
||||||
|
if (pv != null && pv > 0) return pv
|
||||||
|
if (pv === 0) return 0
|
||||||
|
const buy = i.effective_buy_price
|
||||||
|
if (buy == null) return 0
|
||||||
|
const w = 6000 - buy * 3500
|
||||||
|
return Math.max(0, Math.min(15000, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTypeBadgeClass(t: string): string {
|
||||||
|
const u = t.toLowerCase()
|
||||||
|
if (u === 'daily') return 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-500/35'
|
||||||
|
if (u === 'rolling') return 'bg-violet-500/15 text-violet-300 ring-1 ring-violet-500/35'
|
||||||
|
if (u === 'manual') return 'bg-amber-500/15 text-amber-200 ring-1 ring-amber-500/35'
|
||||||
|
return 'bg-slate-600/40 text-slate-300 ring-1 ring-slate-500/30'
|
||||||
|
}
|
||||||
|
|
||||||
|
function axiosDetail(e: unknown): string {
|
||||||
|
if (axios.isAxiosError(e)) {
|
||||||
|
const d = e.response?.data as { detail?: unknown } | undefined
|
||||||
|
const detail = d?.detail
|
||||||
|
if (typeof detail === 'string') return detail
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
return detail
|
||||||
|
.map((x: { msg?: string }) => (typeof x?.msg === 'string' ? x.msg : ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e instanceof Error ? e.message : 'Neznámá chyba'
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableRowClass(
|
||||||
|
i: PlanningIntervalDto,
|
||||||
|
selected: boolean,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (selected) parts.push('ring-1 ring-inset ring-cyan-500/50 bg-cyan-950/25')
|
||||||
|
const buy = i.effective_buy_price
|
||||||
|
const sell = i.effective_sell_price
|
||||||
|
if (buy != null && buy < 0) parts.push('bg-green-950/80')
|
||||||
|
else if (sell != null && sell < 0) parts.push('bg-red-950/80')
|
||||||
|
if ((i.pv_a_curtailed_w ?? 0) > 0) parts.push('border-l-4 border-l-yellow-500')
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartRow = {
|
||||||
|
label: string
|
||||||
|
ts: number
|
||||||
|
pv_a_w: number
|
||||||
|
battery_soc_target_pct: number | null
|
||||||
|
battery_setpoint_w: number
|
||||||
|
effective_buy_price: number | null
|
||||||
|
raw: PlanningIntervalDto
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlanPrepActionsProps = {
|
||||||
|
prepAction: null | 'import' | 'forecast' | 'init'
|
||||||
|
replanning: boolean
|
||||||
|
onImport: () => void
|
||||||
|
onForecast: () => void
|
||||||
|
onInit: () => void
|
||||||
|
wrapClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanPrepActions({
|
||||||
|
prepAction,
|
||||||
|
replanning,
|
||||||
|
onImport,
|
||||||
|
onForecast,
|
||||||
|
onInit,
|
||||||
|
wrapClassName = 'flex flex-wrap gap-2',
|
||||||
|
}: PlanPrepActionsProps) {
|
||||||
|
const prepBusy = prepAction !== null
|
||||||
|
const dis = prepBusy || replanning
|
||||||
|
return (
|
||||||
|
<div className={wrapClassName}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onImport}
|
||||||
|
disabled={dis}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-600 bg-slate-800/90 px-3 py-2 text-sm font-medium text-slate-100 transition hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{prepAction === 'import' ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Importovat ceny
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onForecast}
|
||||||
|
disabled={dis}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-600 bg-slate-800/90 px-3 py-2 text-sm font-medium text-slate-100 transition hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{prepAction === 'forecast' ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CloudSun className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Spustit forecast
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onInit}
|
||||||
|
disabled={dis}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-emerald-700/60 bg-emerald-900/40 px-3 py-2 text-sm font-medium text-emerald-100 transition hover:bg-emerald-800/50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{prepAction === 'init' ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Inicializovat plán
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: ChartRow }> }) {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
const p = payload[0].payload
|
||||||
|
const i = p.raw
|
||||||
|
const buy = i.effective_buy_price
|
||||||
|
const sell = i.effective_sell_price
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-xs text-slate-200 shadow-xl">
|
||||||
|
<div className="mb-1 font-medium text-slate-100">{formatLocal(i.interval_start)}</div>
|
||||||
|
<div className="space-y-0.5 font-mono tabular-nums">
|
||||||
|
<div>
|
||||||
|
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '}
|
||||||
|
{sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'}
|
||||||
|
</div>
|
||||||
|
<div>Baterie: {i.battery_setpoint_w ?? '—'} W</div>
|
||||||
|
<div>Síť: {i.grid_setpoint_w ?? '—'} W</div>
|
||||||
|
<div>TČ: {i.heat_pump_enabled ? 'zapnuto' : 'vypnuto'}</div>
|
||||||
|
<div>
|
||||||
|
EV1: {i.ev1_setpoint_w ?? '—'} W · EV2: {i.ev2_setpoint_w ?? '—'} W
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Planning() {
|
||||||
|
const { site, ready: siteReady } = useSiteStatus()
|
||||||
|
const siteId = site?.site_id ?? null
|
||||||
|
|
||||||
|
const [data, setData] = useState<CurrentPlanResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [replanning, setReplanning] = useState(false)
|
||||||
|
const [prepAction, setPrepAction] = useState<null | 'import' | 'forecast' | 'init'>(null)
|
||||||
|
const [selectedStart, setSelectedStart] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (siteId == null) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await getCurrentPlan(siteId)
|
||||||
|
setData(res)
|
||||||
|
} catch (e) {
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 404) {
|
||||||
|
setData({ run: null, intervals: [], summary: null })
|
||||||
|
setError(null)
|
||||||
|
} else {
|
||||||
|
setError(e instanceof Error ? e.message : 'Chyba načtení plánu')
|
||||||
|
setData(null)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [siteId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (siteId != null) void load()
|
||||||
|
}, [siteId, load])
|
||||||
|
|
||||||
|
const nowMs = Date.now()
|
||||||
|
const dayMs = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const intervals24h = useMemo(() => {
|
||||||
|
if (!data?.intervals?.length) return []
|
||||||
|
const end = nowMs + dayMs
|
||||||
|
return data.intervals
|
||||||
|
.filter((i) => {
|
||||||
|
const t = slotStartUtcMs(i.interval_start)
|
||||||
|
return t >= nowMs && t < end
|
||||||
|
})
|
||||||
|
.slice(0, 96)
|
||||||
|
}, [data?.intervals, nowMs])
|
||||||
|
|
||||||
|
const xTicks = useMemo(() => {
|
||||||
|
if (!intervals24h.length) return undefined
|
||||||
|
const stepMs = 2 * 60 * 60 * 1000
|
||||||
|
const first = slotStartUtcMs(intervals24h[0].interval_start)
|
||||||
|
const last = slotStartUtcMs(intervals24h[intervals24h.length - 1].interval_start)
|
||||||
|
const ticks: string[] = []
|
||||||
|
let t = Math.ceil(first / stepMs) * stepMs
|
||||||
|
while (t <= last) {
|
||||||
|
const hit = intervals24h.find((i) => Math.abs(slotStartUtcMs(i.interval_start) - t) < 30 * 60 * 1000)
|
||||||
|
if (hit) ticks.push(hit.interval_start)
|
||||||
|
t += stepMs
|
||||||
|
}
|
||||||
|
return ticks.length ? ticks.map((iso) => formatLocalTime(iso)) : undefined
|
||||||
|
}, [intervals24h])
|
||||||
|
|
||||||
|
const chartRows: ChartRow[] = useMemo(() => {
|
||||||
|
return intervals24h.map((i) => ({
|
||||||
|
label: formatLocalTime(i.interval_start),
|
||||||
|
ts: slotStartUtcMs(i.interval_start),
|
||||||
|
pv_a_w: pvAProxyW(i),
|
||||||
|
battery_soc_target_pct: i.battery_soc_target_pct,
|
||||||
|
battery_setpoint_w: i.battery_setpoint_w ?? 0,
|
||||||
|
effective_buy_price: i.effective_buy_price,
|
||||||
|
raw: i,
|
||||||
|
}))
|
||||||
|
}, [intervals24h])
|
||||||
|
|
||||||
|
async function onReplan() {
|
||||||
|
if (siteId == null) return
|
||||||
|
setReplanning(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await postRunPlan(siteId, 'rolling')
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Přepočet selhal')
|
||||||
|
} finally {
|
||||||
|
setReplanning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRollingReload() {
|
||||||
|
if (siteId == null) return
|
||||||
|
await postRunPlan(siteId, 'rolling')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportPrices() {
|
||||||
|
if (siteId == null) return
|
||||||
|
setPrepAction('import')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await postImportSitePrices(siteId)
|
||||||
|
toast.success(
|
||||||
|
`Ceny: ${r.slots_imported} slotů (${r.date}), první ${r.first_price_czk_kwh.toFixed(3)} Kč/kWh`,
|
||||||
|
)
|
||||||
|
await runRollingReload()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Import cen selhal', { description: axiosDetail(e) })
|
||||||
|
} finally {
|
||||||
|
setPrepAction(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRunForecast() {
|
||||||
|
if (siteId == null) return
|
||||||
|
setPrepAction('forecast')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await postRunForecast(siteId)
|
||||||
|
toast.success(`Forecast: ${r.intervals_saved} intervalů, ${r.pv_arrays} FVE polí`)
|
||||||
|
await runRollingReload()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Forecast selhal', { description: axiosDetail(e) })
|
||||||
|
} finally {
|
||||||
|
setPrepAction(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInitializePlan() {
|
||||||
|
if (siteId == null) return
|
||||||
|
setPrepAction('init')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const imp = await postImportSitePrices(siteId)
|
||||||
|
toast.success(
|
||||||
|
`Ceny: ${imp.slots_imported} slotů (${imp.date}), první ${imp.first_price_czk_kwh.toFixed(3)} Kč/kWh`,
|
||||||
|
)
|
||||||
|
const fc = await postRunForecast(siteId)
|
||||||
|
toast.success(`Forecast: ${fc.intervals_saved} intervalů, ${fc.pv_arrays} FVE polí`)
|
||||||
|
await runRollingReload()
|
||||||
|
toast.success('Plán přepočítán (rolling).')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Inicializace selhala', { description: axiosDetail(e) })
|
||||||
|
} finally {
|
||||||
|
setPrepAction(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!siteReady) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[40vh] items-center justify-center text-slate-400">
|
||||||
|
Načítám lokalitu…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteId == null) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-amber-900/50 bg-amber-950/20 p-4 text-amber-200">
|
||||||
|
V PostgREST nebyla nalezena lokalita (vw_site_status). Nelze načíst plán.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = data?.run
|
||||||
|
const summary = data?.summary
|
||||||
|
|
||||||
|
const planStale =
|
||||||
|
run != null && Date.now() - new Date(run.created_at).getTime() > 2 * 60 * 60 * 1000
|
||||||
|
const showPrepActions = !loading && (run == null || planStale)
|
||||||
|
const prepBusy = prepAction !== null
|
||||||
|
|
||||||
|
const correctionPct =
|
||||||
|
run?.forecast_correction_factor != null ? run.forecast_correction_factor * 100 : null
|
||||||
|
const correctionUp = (run?.forecast_correction_factor ?? 1) >= 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl space-y-8 p-4 md:p-6">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight text-white">Plánování</h1>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Aktuální LP plán a dalších 24 h od teď ({site?.site_name ?? 'lokalita'})
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-900/60 bg-red-950/30 px-3 py-2 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sekce 1 */}
|
||||||
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
||||||
|
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">
|
||||||
|
Status aktivního plánu
|
||||||
|
</h2>
|
||||||
|
{loading && !run ? (
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Načítám…
|
||||||
|
</div>
|
||||||
|
) : !run ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-slate-400">Žádný aktivní plán.</p>
|
||||||
|
{showPrepActions && (
|
||||||
|
<PlanPrepActions
|
||||||
|
prepAction={prepAction}
|
||||||
|
replanning={replanning}
|
||||||
|
onImport={() => void handleImportPrices()}
|
||||||
|
onForecast={() => void handleRunForecast()}
|
||||||
|
onInit={() => void handleInitializePlan()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-200">
|
||||||
|
<span className="text-slate-500">Vytvořeno:</span>
|
||||||
|
<span className="font-mono">{formatLocal(run.created_at)}</span>
|
||||||
|
<span className="text-slate-600">|</span>
|
||||||
|
<span className="text-slate-500">Typ:</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-md px-2 py-0.5 text-xs font-semibold uppercase tracking-wide ${runTypeBadgeClass(run.run_type)}`}
|
||||||
|
>
|
||||||
|
{run.run_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-slate-500">Horizont: </span>
|
||||||
|
<span className="font-mono text-slate-200">
|
||||||
|
{formatLocal(run.horizon_start)} → {formatLocal(run.horizon_end)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span className="text-slate-500">Korekce FVE forecastu:</span>
|
||||||
|
<span className="inline-flex items-center gap-1 font-mono text-slate-200">
|
||||||
|
{correctionPct != null ? (
|
||||||
|
<>
|
||||||
|
{correctionUp ? (
|
||||||
|
<ArrowUpRight className="h-4 w-4 text-emerald-400" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ArrowDownRight className="h-4 w-4 text-amber-400" aria-hidden />
|
||||||
|
)}
|
||||||
|
{Number.isInteger(correctionPct)
|
||||||
|
? correctionPct
|
||||||
|
: correctionPct.toLocaleString('cs-CZ', { maximumFractionDigits: 1 })}{' '}
|
||||||
|
%
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-slate-500">Čas výpočtu solveru: </span>
|
||||||
|
<span className="font-mono text-slate-200">
|
||||||
|
{run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{summary && (
|
||||||
|
<div className="border-t border-slate-800 pt-3 text-sm">
|
||||||
|
<p className="mb-2 text-slate-500">Summary</p>
|
||||||
|
<dl className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">
|
||||||
|
{summary.total_expected_cost_czk >= 0 ? 'Celkové náklady' : 'Celkový příjem'}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-mono text-slate-100">
|
||||||
|
{summary.total_expected_cost_czk >= 0
|
||||||
|
? `${summary.total_expected_cost_czk.toFixed(2)} Kč`
|
||||||
|
: `${Math.abs(summary.total_expected_cost_czk).toFixed(2)} Kč`}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">kWh curtailmentu (A)</dt>
|
||||||
|
<dd className="font-mono text-slate-100">
|
||||||
|
{summary.total_pv_curtailed_kwh.toLocaleString('cs-CZ', {
|
||||||
|
minimumFractionDigits: 3,
|
||||||
|
maximumFractionDigits: 3,
|
||||||
|
})}{' '}
|
||||||
|
kWh
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-500">Sloty nabíjení / vybíjení / export</dt>
|
||||||
|
<dd className="font-mono text-slate-100">
|
||||||
|
{summary.charge_slots} / {summary.discharge_slots} / {summary.export_slots}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-stretch gap-2 sm:items-end">
|
||||||
|
{showPrepActions && (
|
||||||
|
<PlanPrepActions
|
||||||
|
prepAction={prepAction}
|
||||||
|
replanning={replanning}
|
||||||
|
onImport={() => void handleImportPrices()}
|
||||||
|
onForecast={() => void handleRunForecast()}
|
||||||
|
onInit={() => void handleInitializePlan()}
|
||||||
|
wrapClassName="flex flex-wrap justify-end gap-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onReplan()}
|
||||||
|
disabled={replanning || prepBusy}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{replanning ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
|
Přeplánovat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Sekce 2 */}
|
||||||
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
||||||
|
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">Graf plánu</h2>
|
||||||
|
{!chartRows.length ? (
|
||||||
|
<p className="text-sm text-slate-500">Žádná data pro graf (24 h od teď, max. 96 slotů).</p>
|
||||||
|
) : (
|
||||||
|
<div className="h-[350px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={chartRows} margin={{ top: 8, right: 72, left: 8, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
ticks={xTicks}
|
||||||
|
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
||||||
|
interval={0}
|
||||||
|
angle={-35}
|
||||||
|
textAnchor="end"
|
||||||
|
height={48}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="power"
|
||||||
|
tick={{ fill: '#94a3b8', fontSize: 10 }}
|
||||||
|
label={{ value: 'W', angle: -90, position: 'insideLeft', fill: '#64748b', fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="soc"
|
||||||
|
orientation="right"
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fill: '#22c55e', fontSize: 10 }}
|
||||||
|
label={{ value: 'SoC %', angle: 90, position: 'insideRight', fill: '#22c55e', fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="price"
|
||||||
|
orientation="right"
|
||||||
|
width={52}
|
||||||
|
tick={{ fill: '#94a3b8', fontSize: 9 }}
|
||||||
|
axisLine={{ stroke: '#64748b' }}
|
||||||
|
tickLine={{ stroke: '#64748b' }}
|
||||||
|
label={{
|
||||||
|
value: 'Kč/kWh',
|
||||||
|
angle: 90,
|
||||||
|
position: 'insideRight',
|
||||||
|
fill: '#94a3b8',
|
||||||
|
fontSize: 10,
|
||||||
|
offset: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<PlanTooltip />} />
|
||||||
|
<Area
|
||||||
|
yAxisId="power"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="pv_a_w"
|
||||||
|
name="FVE (A) / předpověď"
|
||||||
|
stroke="#ca8a04"
|
||||||
|
fill="#eab308"
|
||||||
|
fillOpacity={0.35}
|
||||||
|
/>
|
||||||
|
<Bar yAxisId="power" dataKey="battery_setpoint_w" name="Baterie W" barSize={10} isAnimationActive={false}>
|
||||||
|
{chartRows.map((e) => (
|
||||||
|
<Cell
|
||||||
|
key={e.ts}
|
||||||
|
fill={e.battery_setpoint_w >= 0 ? '#22c55e' : '#f97316'}
|
||||||
|
fillOpacity={0.85}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
<Line
|
||||||
|
yAxisId="soc"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="battery_soc_target_pct"
|
||||||
|
name="SoC %"
|
||||||
|
stroke="#4ade80"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="price"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="effective_buy_price"
|
||||||
|
name="Cena nákup"
|
||||||
|
stroke="#94a3b8"
|
||||||
|
strokeDasharray="5 4"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Sekce 3 */}
|
||||||
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
||||||
|
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">Tabulka slotů</h2>
|
||||||
|
<div className="max-h-[400px] overflow-y-auto overflow-x-auto rounded-lg border border-slate-800/80">
|
||||||
|
<table className="w-full border-collapse text-left text-xs">
|
||||||
|
<thead className="sticky top-0 z-10 bg-slate-900 shadow-[0_1px_0_0_rgb(30_41_59)]">
|
||||||
|
<tr className="text-slate-500">
|
||||||
|
<th className="whitespace-nowrap py-2 pl-2 pr-2 font-medium">Čas</th>
|
||||||
|
<th className="whitespace-nowrap py-2 pr-2 font-medium">Cena kup</th>
|
||||||
|
<th className="whitespace-nowrap py-2 pr-2 font-medium">Cena prod</th>
|
||||||
|
<th className="whitespace-nowrap py-2 pr-2 font-medium">Bat. W</th>
|
||||||
|
<th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th>
|
||||||
|
<th className="whitespace-nowrap py-2 pr-2 font-medium">Síť W</th>
|
||||||
|
<th className="whitespace-nowrap py-2 pr-2 font-medium">EV1 W</th>
|
||||||
|
<th className="whitespace-nowrap py-2 pr-2 font-medium">EV2 W</th>
|
||||||
|
<th className="whitespace-nowrap py-2 pr-2 font-medium">TČ</th>
|
||||||
|
<th className="whitespace-nowrap py-2 pr-2 font-medium">Náklady Kč</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{intervals24h.map((i) => {
|
||||||
|
const sel = selectedStart === i.interval_start
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={i.interval_start}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setSelectedStart((prev) => (prev === i.interval_start ? null : i.interval_start))}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||||
|
ev.preventDefault()
|
||||||
|
setSelectedStart((prev) => (prev === i.interval_start ? null : i.interval_start))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`cursor-pointer border-b border-slate-800/80 transition hover:bg-slate-800/40 ${tableRowClass(i, sel)}`}
|
||||||
|
>
|
||||||
|
<td className="whitespace-nowrap py-1.5 pl-2 pr-2 font-mono text-slate-300">
|
||||||
|
{formatLocalTime(i.interval_start)}
|
||||||
|
</td>
|
||||||
|
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||||
|
{i.effective_buy_price?.toFixed(3) ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||||
|
{i.effective_sell_price?.toFixed(3) ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.battery_setpoint_w ?? '—'}</td>
|
||||||
|
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||||
|
{i.battery_soc_target_pct != null
|
||||||
|
? `${i.battery_soc_target_pct.toFixed(1)}`
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.grid_setpoint_w ?? '—'}</td>
|
||||||
|
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.ev1_setpoint_w ?? '—'}</td>
|
||||||
|
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.ev2_setpoint_w ?? '—'}</td>
|
||||||
|
<td className="pr-2 text-slate-300">{i.heat_pump_enabled ? 'on' : 'off'}</td>
|
||||||
|
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||||
|
{i.expected_cost_czk?.toFixed(4) ?? '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{!intervals24h.length && !loading && (
|
||||||
|
<p className="mt-2 text-sm text-slate-500">Žádné řádky v 24h okně.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { Car } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { patchEvSession, type ActiveEvSessionRow } from '../api/backend'
|
||||||
import { ModeLog } from '../components/ModeLog'
|
import { ModeLog } from '../components/ModeLog'
|
||||||
import { ModeSelector } from '../components/ModeSelector'
|
import { ModeSelector } from '../components/ModeSelector'
|
||||||
|
import { useEVSessions } from '../hooks/useEVSessions'
|
||||||
import { useSiteStatus } from '../hooks/useSiteStatus'
|
import { useSiteStatus } from '../hooks/useSiteStatus'
|
||||||
|
|
||||||
function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
|
function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
|
||||||
@@ -11,9 +18,182 @@ function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toDatetimeLocalValue(d: Date): string {
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const h = String(d.getHours()).padStart(2, '0')
|
||||||
|
const min = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}T${h}:${min}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dnešní HH:00 nebo zítřejší, pokud už je po té hodině (včetně celé hodiny). */
|
||||||
|
function nextDeadlineAtHour(hour: number): Date {
|
||||||
|
const now = new Date()
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, 0, 0, 0)
|
||||||
|
if (d.getTime() <= now.getTime()) {
|
||||||
|
d.setDate(d.getDate() + 1)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoToDatetimeLocal(iso: string): string {
|
||||||
|
return toDatetimeLocalValue(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
|
function datetimeLocalToIsoUtc(local: string): string {
|
||||||
|
const d = new Date(local)
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
return d.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function vehicleTitle(s: ActiveEvSessionRow): string {
|
||||||
|
const m = (s.make ?? '').trim()
|
||||||
|
const mo = (s.model ?? '').trim()
|
||||||
|
if (!m && !mo) return 'Neznámé vozidlo'
|
||||||
|
return `${m} ${mo}`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Popisek do toastu – preferuje model (např. Model Y). */
|
||||||
|
function toastVehicleLabel(s: ActiveEvSessionRow): string {
|
||||||
|
const mo = (s.model ?? '').trim()
|
||||||
|
if (mo) return mo
|
||||||
|
return vehicleTitle(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHARGER_SLOTS: { code: string; label: string }[] = [
|
||||||
|
{ code: 'ev-charger-1', label: 'Tesla' },
|
||||||
|
{ code: 'ev-charger-2', label: 'Zoe' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function EvChargerCard({
|
||||||
|
siteId,
|
||||||
|
chargerLabel,
|
||||||
|
session,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
siteId: number
|
||||||
|
chargerLabel: string
|
||||||
|
session: ActiveEvSessionRow | undefined
|
||||||
|
onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const [soc, setSoc] = useState<number | ''>('')
|
||||||
|
const [deadlineLocal, setDeadlineLocal] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) {
|
||||||
|
setSoc('')
|
||||||
|
setDeadlineLocal('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const defSoc = session.target_soc_pct ?? session.default_target_soc_pct ?? 80
|
||||||
|
setSoc(Math.round(Number(defSoc)))
|
||||||
|
if (session.target_deadline) {
|
||||||
|
setDeadlineLocal(isoToDatetimeLocal(session.target_deadline))
|
||||||
|
} else {
|
||||||
|
const h = session.default_deadline_hour ?? 7
|
||||||
|
setDeadlineLocal(toDatetimeLocalValue(nextDeadlineAtHour(h)))
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
session?.id,
|
||||||
|
session?.target_soc_pct,
|
||||||
|
session?.target_deadline,
|
||||||
|
session?.default_deadline_hour,
|
||||||
|
session?.default_target_soc_pct,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900/25 p-8 text-center">
|
||||||
|
<Car className="h-12 w-12 text-slate-600" aria-hidden />
|
||||||
|
<p className="mt-4 text-sm font-medium text-slate-500">Nepřipojeno</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-600">{chargerLabel}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const kwh = ((session.energy_delivered_wh ?? 0) / 1000).toFixed(1)
|
||||||
|
|
||||||
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (soc === '' || !deadlineLocal) return
|
||||||
|
const clamped = Math.min(100, Math.max(10, Math.round(Number(soc))))
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await patchEvSession(siteId, session.id, {
|
||||||
|
target_soc_pct: clamped,
|
||||||
|
target_deadline: datetimeLocalToIsoUtc(deadlineLocal),
|
||||||
|
})
|
||||||
|
toast.success(`Deadline nastaven pro ${toastVehicleLabel(session)}`)
|
||||||
|
onSaved()
|
||||||
|
} catch (err) {
|
||||||
|
const msg =
|
||||||
|
axios.isAxiosError(err) && err.response?.data && typeof err.response.data === 'object'
|
||||||
|
? JSON.stringify(err.response.data)
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Neznámá chyba'
|
||||||
|
toast.error('Uložení se nezdařilo', { description: msg })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-700 bg-slate-900/50 p-4">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-emerald-500/90">Připojeno</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-slate-100">{vehicleTitle(session)}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500">{chargerLabel}</p>
|
||||||
|
<form onSubmit={(e) => void onSubmit(e)} className="mt-4 space-y-3">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Energie v session:{' '}
|
||||||
|
<span className="font-medium text-slate-200">{kwh} kWh</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<label className="flex flex-col text-xs text-slate-500">
|
||||||
|
Target SoC %
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={soc}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
setSoc(v === '' ? '' : Number(v))
|
||||||
|
}}
|
||||||
|
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-200"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
|
||||||
|
Deadline
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={deadlineLocal}
|
||||||
|
onChange={(e) => setDeadlineLocal(e.target.value)}
|
||||||
|
className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-200"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || soc === '' || !deadlineLocal}
|
||||||
|
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Ukládám…' : 'Uložit'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { site, ready, reload } = useSiteStatus()
|
const { site, ready, reload } = useSiteStatus()
|
||||||
const siteId = site?.site_id ?? null
|
const siteId = site?.site_id ?? null
|
||||||
|
const { sessions, ready: evReady, error: evError, reload: reloadEv } = useEVSessions(siteId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
|
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
|
||||||
@@ -31,7 +211,8 @@ export function Settings() {
|
|||||||
<section>
|
<section>
|
||||||
<SectionTitle kicker="Řízení" title="Provozní režim" />
|
<SectionTitle kicker="Řízení" title="Provozní režim" />
|
||||||
<p className="mb-4 max-w-3xl text-sm text-slate-400">
|
<p className="mb-4 max-w-3xl text-sm text-slate-400">
|
||||||
Přepnutí zapíše stav do databáze a notifikuje Loxone. U dočasného režimu lze nastavit čas návratu; po vypršení systém obnoví předchozí režim.
|
Přepnutí zapíše stav do databáze a notifikuje Loxone. U dočasného režimu lze nastavit čas návratu; po
|
||||||
|
vypršení systém obnoví předchozí režim.
|
||||||
</p>
|
</p>
|
||||||
<ModeSelector
|
<ModeSelector
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
@@ -45,52 +226,31 @@ export function Settings() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle kicker="EV" title="Deadline nabíjení (připravuje se)" />
|
<SectionTitle kicker="EV" title="Deadline nabíjení" />
|
||||||
<p className="mb-4 text-sm text-slate-500">
|
<p className="mb-4 max-w-3xl text-sm text-slate-400">
|
||||||
Zatím pouze rozhraní; napojení na API a session přijde v další iteraci.
|
Při připojení vozidla na wallbox se zobrazí aktivní session (dotaz každých 30 s). Cílový SoC a deadline se
|
||||||
|
ukládají do <span className="text-slate-500">ev_session</span> pro plánovač.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
{siteId === null ? (
|
||||||
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
|
<p className="text-sm text-slate-500">Načítám lokalitu…</p>
|
||||||
<p className="text-sm font-medium text-slate-200">Tesla</p>
|
) : !evReady ? (
|
||||||
<div className="mt-3 flex flex-wrap gap-3">
|
<p className="text-sm text-slate-500">Načítám EV session…</p>
|
||||||
<label className="flex flex-col text-xs text-slate-500">
|
) : (
|
||||||
Cílové SoC %
|
<>
|
||||||
<input
|
{evError ? <p className="mb-3 text-sm text-amber-600/90">{evError}</p> : null}
|
||||||
type="number"
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
min={0}
|
{CHARGER_SLOTS.map((slot) => (
|
||||||
max={100}
|
<EvChargerCard
|
||||||
placeholder="např. 80"
|
key={slot.code}
|
||||||
disabled
|
siteId={siteId}
|
||||||
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400"
|
chargerLabel={slot.label}
|
||||||
|
session={sessions.find((s) => s.charger_code === slot.code)}
|
||||||
|
onSaved={() => void reloadEv()}
|
||||||
/>
|
/>
|
||||||
</label>
|
))}
|
||||||
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
|
|
||||||
Deadline
|
|
||||||
<input type="datetime-local" disabled className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400" />
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
|
)}
|
||||||
<p className="text-sm font-medium text-slate-200">Zoe</p>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-3">
|
|
||||||
<label className="flex flex-col text-xs text-slate-500">
|
|
||||||
Cílové SoC %
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
placeholder="např. 80"
|
|
||||||
disabled
|
|
||||||
className="mt-1 w-28 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex min-w-[200px] flex-col text-xs text-slate-500">
|
|
||||||
Deadline
|
|
||||||
<input type="datetime-local" disabled className="mt-1 rounded-lg border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-400" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
41
frontend/src/types/fullStatus.ts
Normal file
41
frontend/src/types/fullStatus.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export type FullStatusAlert = {
|
||||||
|
level: 'warn' | 'error'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FullStatusResponse = {
|
||||||
|
site: { id: number; code: string; name: string }
|
||||||
|
operating_mode: {
|
||||||
|
mode_code: string | null
|
||||||
|
mode_name: string | null
|
||||||
|
activated_at: string | null
|
||||||
|
activated_by: string | null
|
||||||
|
}
|
||||||
|
heartbeat: {
|
||||||
|
last_seen: string | null
|
||||||
|
age_seconds: number | null
|
||||||
|
status: string | null
|
||||||
|
}
|
||||||
|
telemetry: {
|
||||||
|
inverter: {
|
||||||
|
pv_power_w: number | null
|
||||||
|
battery_soc_pct: number | null
|
||||||
|
grid_power_w: number | null
|
||||||
|
measured_at: string | null
|
||||||
|
age_seconds: number | null
|
||||||
|
}
|
||||||
|
ev_chargers: { code: string; status: string | null; power_w: number | null }[]
|
||||||
|
heat_pump: {
|
||||||
|
power_w: number | null
|
||||||
|
tank_temp_c: number | null
|
||||||
|
measured_at: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
planning: {
|
||||||
|
has_active_plan: boolean
|
||||||
|
plan_created_at: string | null
|
||||||
|
next_interval_start: string | null
|
||||||
|
next_battery_setpoint_w: number | null
|
||||||
|
}
|
||||||
|
alerts: FullStatusAlert[]
|
||||||
|
}
|
||||||
@@ -43,4 +43,6 @@ export type CurrentPlanResponse = {
|
|||||||
export type RunPlanResponse = {
|
export type RunPlanResponse = {
|
||||||
run_id: number
|
run_id: number
|
||||||
solver_duration_ms: number
|
solver_duration_ms: number
|
||||||
|
horizon_start: string
|
||||||
|
horizon_end: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
import tailwindcss from '@tailwindcss/vite'
|
import { existsSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
export default defineConfig({
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
plugins: [react(), tailwindcss()],
|
const oxideVendored = [
|
||||||
build: {
|
join(__dirname, 'vendor', 'tailwindcss-oxide.linux-x64-gnu.node'),
|
||||||
outDir: 'dist',
|
join(__dirname, 'vendor', 'tailwindcss-oxide.linux-x64-musl.node'),
|
||||||
assetsDir: 'assets',
|
]
|
||||||
},
|
for (const p of oxideVendored) {
|
||||||
server: {
|
if (existsSync(p)) {
|
||||||
proxy: {
|
process.env.NAPI_RS_NATIVE_LIBRARY_PATH = p
|
||||||
'/api': {
|
break
|
||||||
target: 'http://localhost:8000',
|
}
|
||||||
changeOrigin: true,
|
}
|
||||||
},
|
|
||||||
'/rest': {
|
export default defineConfig(async () => {
|
||||||
target: 'http://localhost:3000',
|
const { default: tailwindcss } = await import('@tailwindcss/vite')
|
||||||
changeOrigin: true,
|
return {
|
||||||
rewrite: (path) => path.replace(/^\/rest/, ''),
|
plugins: [react(), tailwindcss()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
assetsDir: 'assets',
|
||||||
|
chunkSizeWarningLimit: 750,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/rest': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/rest/, ''),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user