This commit is contained in:
Dusan Vojacek
2026-03-20 14:30:03 +01:00
parent 2cc5ccfda7
commit 897b95f728
48 changed files with 4034 additions and 842 deletions

View File

@@ -10,8 +10,8 @@ DB_PASSWORD=change_me_strong_password
# ---- PostgREST ----
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_ANON_ROLE=ems_user
# PostgREST anonymní role (viz db/migration/V009__postgrest_roles.sql + R__z_postgrest_ems_anon_grants.sql).
POSTGREST_ANON_ROLE=ems_anon
# ---- OTE CZ import ----
OTE_API_URL=https://www.ote-cr.cz/pubapi/v1/market-data/dam

2
.gitignore vendored
View File

@@ -10,3 +10,5 @@ venv/
node_modules/
dist/
*.tsbuildinfo
frontend/vendor/
frontend/scripts/.native-tmp/

54
README.md Normal file
View 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).

View File

@@ -21,7 +21,7 @@ class Settings(BaseSettings):
database_url: str | None = Field(default=None)
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(
default="https://www.ote-cr.cz/pubapi/v1/market-data/dam",

35
backend/app/db_json.py Normal file
View 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

View 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,
}

View File

@@ -1,72 +1,33 @@
"""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
import asyncpg
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 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"])
class PlanningRunOut(BaseModel):
id: int
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
PRICE_CHECK_HOURS = 24
_SLOTS_PER_HOUR = 4
_EXPECTED_PRICE_SLOTS = PRICE_CHECK_HOURS * _SLOTS_PER_HOUR
class RunPlanResponse(BaseModel):
run_id: 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
curtailed_wh = 0.0
total_curtailed_kwh = 0.0
charge_slots = 0
discharge_slots = 0
export_slots = 0
@@ -75,7 +36,7 @@ def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
if ec is not None:
total_cost += float(ec)
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")
if b is not None:
if int(b) > 0:
@@ -85,153 +46,110 @@ def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
g = row.get("grid_setpoint_w")
if g is not None and int(g) < 0:
export_slots += 1
return PlanningSummaryOut(
total_expected_cost_czk=round(total_cost, 4),
total_pv_curtailed_kwh=round(curtailed_wh / 1000.0, 6),
charge_slots=charge_slots,
discharge_slots=discharge_slots,
export_slots=export_slots,
)
return {
"total_expected_cost_czk": round(total_cost, 4),
"total_pv_curtailed_kwh": round(total_curtailed_kwh, 6),
"charge_slots": charge_slots,
"discharge_slots": discharge_slots,
"export_slots": export_slots,
}
@router.get("/current", response_model=CurrentPlanResponse)
@router.get("/current")
async def get_current_plan(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> CurrentPlanResponse:
) -> dict[str, Any]:
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id)
if not exists:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
run_row = await conn.fetchrow(
"""
SELECT id, created_at, run_type, horizon_start, horizon_end,
forecast_correction_factor, solver_duration_ms
FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
SELECT pr.*
FROM ems.planning_run pr
WHERE pr.site_id = $1 AND pr.status = 'active'
ORDER BY pr.created_at DESC
LIMIT 1
""",
site_id,
)
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"]
int_rows = await conn.fetch(
"""
SELECT
pi.interval_start,
pi.battery_setpoint_w,
pi.battery_soc_target_pct,
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
SELECT *
FROM ems.planning_interval
WHERE run_id = $1
ORDER BY interval_start
""",
run_id,
site_id,
)
intervals_dicts = [dict(r) for r in int_rows]
summary = _build_summary(intervals_dicts) if intervals_dicts else None
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)
intervals = [record_to_dict(r) for r in int_rows]
summary = _build_summary(intervals)
return {"run": record_to_dict(run_row), "intervals": intervals, "summary": summary}
@router.post("/run", response_model=RunPlanResponse)
async def post_run_plan(
site_id: int,
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:
window_start = _current_slot_start(datetime.now(timezone.utc))
window_end = window_start + timedelta(hours=PRICE_CHECK_HOURS)
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id)
if not exists:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok:
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:
run_id, duration_ms = await run_plan_api(
site_id, conn, plan_type=plan_type, triggered_by="api"
run_id, solver_duration_ms = await run_plan_api(
site_id, plan_type, conn, triggered_by="api"
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return RunPlanResponse(run_id=run_id, solver_duration_ms=duration_ms)
raise HTTPException(status_code=422, detail=str(e)) from e
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"],
)

View 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))

View 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:3014: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)

View 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

View 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)

View File

@@ -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}")
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(
site_id, db
@@ -430,9 +428,6 @@ async def run_rolling_replan(
correction_factor, correction_log = await compute_correction_factor(site_id, now, 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)
@@ -477,7 +472,13 @@ async def run_rolling_replan(
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)."""
pt = plan_type.lower().strip()
if pt == "daily":
@@ -671,10 +672,10 @@ async def _load_site_context(site_id: int, db):
site_id,
)
if soc_pct is None:
soc_wh = reserve_wh
soc_wh = uc * 0.5
else:
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(
"""
@@ -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_b.power_w, 0) AS pv_b_forecast_w,
COALESCE(cbi.power_w, 500) AS load_baseline_w,
-- EV připojení z aktuálního stavu nabíječek
(ev1.status NOT IN ('available', 'unavailable')) AS ev1_connected,
(ev2.status NOT IN ('available', 'unavailable')) AS ev2_connected
-- EV připojení z poslední telemetrie nabíječek (bez řádku = nepřipojeno)
(COALESCE(ev1.status, 'available') NOT IN ('available', 'unavailable')) AS ev1_connected,
(COALESCE(ev2.status, 'available') NOT IN ('available', 'unavailable')) AS ev2_connected
FROM ems.vw_site_effective_price ep
-- FVE pole A forecast
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"]),
)
)
if not out:
raise RuntimeError(
"No planning slots available check market prices and horizon settings"
)
return out

View 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())

View 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))

View File

@@ -73,8 +73,34 @@ SELECT create_hypertable(
-- ============================================================
-- Kompresní politiky pro staré chunky
-- 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_ev_charger', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_compression_policy('ems.telemetry_heat_pump', INTERVAL '30 days', if_not_exists => TRUE);

View File

@@ -1,7 +1,12 @@
-- =============================================================
-- V003__seed_site_home01.sql
-- 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',
'Hlavní objekt',
'Europe/Prague',
NULL, -- TODO: doplnit GPS
NULL, -- TODO: doplnit GPS
49.24466967511591,
17.40658656876068,
true,
'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)
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';
-- TODO: doplnit skutečnou IP adresy 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)
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';
-- TODO: doplnit IP a unit_id
-- 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)
@@ -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)
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';
-- TODO: doplnit IP Loxone
-- ============================================================
-- SÍŤOVÉ PŘIPOJENÍ
@@ -126,12 +128,12 @@ INSERT INTO ems.asset_pv_array (site_id, inverter_id, code, name, nominal_power_
SELECT
s.id, inv.id, 'pv-a', 'FVE pole A',
10000, -- 10 kWp
NULL, -- TODO: doplnit azimut (0=jih)
NULL, -- TODO: doplnit sklon (stupně)
184,
35, -- sklon odhad; upřesnit dle střechy
NULL,
1.0,
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
JOIN ems.asset_inverter inv ON inv.site_id = s.id AND inv.code = 'deye-main'
WHERE s.code = 'home-01';
@@ -141,8 +143,8 @@ INSERT INTO ems.asset_pv_array (site_id, inverter_id, code, name, nominal_power_
SELECT
s.id, inv.id, 'pv-b', 'FVE pole B (ongrid)',
10000,
NULL, -- TODO: doplnit azimut
NULL, -- TODO: doplnit sklon
184,
35,
NULL,
1.0,
false,
@@ -187,15 +189,15 @@ INSERT INTO ems.asset_heat_pump (
tuv_temp_sensor_ref, schedulable, notes
)
SELECT
s.id, 'hp-samsung', 'Samsung', NULL, -- TODO: doplnit model
s.id, 'hp-samsung', 'Samsung', 'EHS Mono (placeholder)',
ep.id,
NULL, -- TODO: doplnit jmenovitý výkon W
NULL, -- TODO: doplnit COP rated
12000, -- jmenovitý topný výkon W upřesnit z datasheetu
3.20, -- COP @ 7 °C upřesnit
7.0, -- referenční teplota A7/W35
30, 15,
NULL, -- TODO: doplnit objem zásobníku
200, -- objem TUV zásobníku (l) upřesnit
45, 60, 55,
NULL, -- TODO: doplnit odkaz na teplotní čidlo
'TODO: Loxone / Modbus čidlo TUV',
true,
'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

View 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í.';

View 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 V001V008)
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.';

View 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);

View 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.';

View File

@@ -4,6 +4,28 @@
-- 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)
CREATE OR REPLACE VIEW ems.vw_site_status AS
SELECT

View 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;

View File

@@ -47,7 +47,7 @@ services:
PGRST_DB_URI: postgres://${DB_USER}:${DB_PASSWORD}@db:5432/ems
PGRST_DB_SCHEMA: 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_SERVER_PORT: 3000
PGRST_OPENAPI_SERVER_PROXY_URI: http://localhost/rest
@@ -81,7 +81,7 @@ services:
LOXONE_USER: ${LOXONE_USER:-}
LOXONE_PASSWORD: ${LOXONE_PASSWORD:-}
POSTGREST_JWT_SECRET: ${POSTGREST_JWT_SECRET}
POSTGREST_ANON_ROLE: ${POSTGREST_ANON_ROLE:-ems_user}
POSTGREST_ANON_ROLE: ${POSTGREST_ANON_ROLE:-ems_anon}
ports:
- "127.0.0.1:8000:8000"
volumes:

View File

@@ -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.
- [ ] **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.
@@ -36,3 +36,9 @@ Tento soubor slouží jako živý seznam věcí které je potřeba rozhodnout p
- [ ] Sezónní korekce predikce spotřeby
- [ ] Mobile app / PWA notifikace
- [ ] 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`.

View File

@@ -17,7 +17,7 @@ server {
text/plain;
location /api/ {
proxy_pass http://backend:8000/;
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -10,7 +10,8 @@
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.0"
"recharts": "^2.15.0",
"sonner": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.14",
@@ -1311,6 +1312,70 @@
"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": {
"version": "4.2.2",
"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"
}
},
"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": {
"version": "1.2.1",
"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",
"@tybys/wasm-util": "^0.10.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": {
@@ -4469,6 +4600,12 @@
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -3,8 +3,8 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"dev": "node scripts/run-dev.mjs",
"build": "node scripts/run-build.mjs",
"preview": "vite preview"
},
"dependencies": {
@@ -13,7 +13,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.0",
"sonner": "^1.7.1"
"sonner": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.14",

View 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()

View 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'])

View 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)

View 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)

View 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)

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Toaster } from 'sonner'
import Planning from './Planning'
import Planning from './pages/Planning'
import { Dashboard } from './pages/Dashboard'
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'
}`}
>
Plán
Plánování
</button>
<button
type="button"

View File

@@ -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)}
</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"></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) ?? '—'} </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"></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>
)
}

View File

@@ -1,5 +1,6 @@
import axios, { type AxiosInstance } from 'axios'
import type { FullStatusResponse } from '../types/fullStatus'
import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan'
const client: AxiosInstance = axios.create({
@@ -14,6 +15,25 @@ export async function getBackendHealth(): Promise<unknown> {
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 = {
mode: string
notes: string | null
@@ -53,4 +73,86 @@ export async function postRunPlan(
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 }

View File

@@ -8,6 +8,7 @@ import {
Thermometer,
Wrench,
X,
type LucideIcon,
} from 'lucide-react'
import axios from 'axios'
import { useCallback, useMemo, useState } from 'react'
@@ -22,7 +23,7 @@ type ModeDef = {
description: string
ev: boolean
hp: boolean
Icon: typeof Bot
Icon: LucideIcon
}
const MODES: ModeDef[] = [

View File

@@ -8,24 +8,40 @@ const POLL_MS = 30_000
export function useAuditDailyToday(siteId: number | null) {
const [row, setRow] = useState<AuditDailyRow | null>(null)
const [ready, setReady] = useState(false)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
if (siteId == null) {
setRow(null)
setError(null)
setReady(true)
return
}
try {
const rows = await getJson<AuditDailyRow[]>('/vw_audit_daily', {
site_id: `eq.${siteId}`,
order: 'day_local.desc',
limit: '45',
})
const today = pragueCalendarDay()
const hit = Array.isArray(rows) ? rows.find((r) => instantPragueDay(r.day_local) === today) : undefined
setRow(hit ?? null)
let primary = await getJson<AuditDailyRow[]>('/vw_audit_daily', {
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 {
setRow(null)
setError('Denní souhrn auditu se nepodařil načíst')
} finally {
setReady(true)
}
@@ -40,6 +56,8 @@ export function useAuditDailyToday(siteId: number | null) {
return {
daily: row,
ready,
error,
hasDaily: row != null && (row.interval_count ?? 0) > 0,
reload: load,
}
}

View 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 }
}

View 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 }
}

View 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 }
}

View File

@@ -7,13 +7,16 @@ const POLL_MS = 5_000
export function useSiteStatus() {
const [row, setRow] = useState<SiteStatusRow | null>(null)
const [ready, setReady] = useState(false)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
try {
const rows = await getJson<SiteStatusRow[]>('/vw_site_status')
setRow(Array.isArray(rows) && rows.length > 0 ? rows[0]! : null)
setError(null)
} catch {
setRow(null)
setError('Stav lokality se nepodařilo načíst')
} finally {
setReady(true)
}
@@ -35,6 +38,7 @@ export function useSiteStatus() {
return {
site: row,
ready,
error,
/** Máme řádek lokality a alespoň jednu telemetrickou hodnotu (jinak skeleton). */
hasLiveData: row != null && hasTelemetry,
reload: load,

View File

@@ -23,10 +23,12 @@ export type TelemetryChartPoint = {
export function useTelemetryToday(siteId: number | null) {
const [points, setPoints] = useState<TelemetryChartPoint[]>([])
const [ready, setReady] = useState(false)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
if (siteId == null) {
setPoints([])
setError(null)
setReady(true)
return
}
@@ -37,6 +39,7 @@ export function useTelemetryToday(siteId: number | null) {
})
if (!Array.isArray(rows) || rows.length === 0) {
setPoints([])
setError(null)
return
}
const mapped: TelemetryChartPoint[] = rows.map((r) => {
@@ -55,8 +58,10 @@ export function useTelemetryToday(siteId: number | null) {
}
})
setPoints(mapped)
setError(null)
} catch {
setPoints([])
setError('Hodinová data auditu se nepodařila načíst')
} finally {
setReady(true)
}
@@ -68,5 +73,5 @@ export function useTelemetryToday(siteId: number | null) {
return () => window.clearInterval(id)
}, [load])
return { points, ready, hasChartData: points.length > 0 }
return { points, ready, error, hasChartData: points.length > 0, reload: load }
}

View File

@@ -1,10 +1,31 @@
import { Battery, Sun, Zap } from 'lucide-react'
import { PowerFlowCard } from '../components/PowerFlowCard'
import { SocGauge } from '../components/SocGauge'
import { TelemetryChart } from '../components/TelemetryChart'
import { useState } from 'react'
import { Sun, Battery, Zap, Home, ChevronDown, ChevronUp } from 'lucide-react'
import {
Area,
Bar,
CartesianGrid,
Cell,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { useAuditDailyToday } from '../hooks/useAuditDailyToday'
import { useCurrentPlan } from '../hooks/useCurrentPlan'
import { useFullStatus } from '../hooks/useFullStatus'
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 {
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č`
}
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 {
const c = (code ?? '').toUpperCase()
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'
}
function batteryStyles(powerW: number | null | undefined): { border: string; icon: string } {
if (powerW == null || Number.isNaN(powerW)) {
return { border: 'border-l-slate-600', icon: 'text-slate-400' }
}
if (powerW >= 0) {
return { border: 'border-l-emerald-500', icon: 'text-emerald-400' }
}
return { border: 'border-l-orange-500', icon: 'text-orange-400' }
function formatTelemetryAgo(iso: string | null | undefined): string {
if (iso == null) return '—'
const diffMin = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000)
if (diffMin <= 0) return 'právě teď'
if (diffMin === 1) return 'před 1 minutou'
if (diffMin >= 2 && diffMin <= 4) return `před ${diffMin} minutami`
return `před ${diffMin} minutami`
}
function gridStyles(powerW: number | null | undefined): { border: string; icon: string } {
if (powerW == null || Number.isNaN(powerW)) {
return { border: 'border-l-slate-600', icon: 'text-slate-400' }
}
if (powerW >= 0) {
return { border: 'border-l-red-500', icon: 'text-red-400' }
}
return { border: 'border-l-emerald-500', icon: 'text-emerald-400' }
function floorToSlotUtc(ms: number): number {
const slot = 15 * 60 * 1000
return Math.floor(ms / slot) * slot
}
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 (
<div className="mb-4">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500">{kicker}</p>
<h2 className="text-lg font-semibold text-slate-100">{title}</h2>
<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="mb-1 font-medium text-slate-200">{label}</p>
<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>
)
}
function CardSkeleton() {
return <div className="h-[88px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
function SemicircleSocGauge({ socPercent }: { socPercent: string | number | null | undefined }) {
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 (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p>
<div className="flex flex-col items-center pt-2">
<div className="relative h-[120px] w-[220px]">
<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>
)
}
function StatSkeleton() {
return <div className="h-[76px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
function MetricSkeleton() {
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() {
const { site, ready: siteReady, hasLiveData } = useSiteStatus()
const { site, ready: siteReady, error: siteError, hasLiveData, reload: reloadSite } = useSiteStatus()
const siteId = site?.site_id ?? null
const { points, ready: telemetryReady, hasChartData } = useTelemetryToday(siteId)
const { daily, ready: auditReady, hasDaily } = useAuditDailyToday(siteId)
const { fullStatus } = useFullStatus(siteId)
const [alertsOpen, setAlertsOpen] = useState(false)
const liveSkeleton = !siteReady || !hasLiveData
const chartSkeleton = !telemetryReady || !hasChartData
const econSkeleton = !auditReady || !hasDaily
const {
points,
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 bat = batteryStyles(site?.battery_power_w ?? null)
const grd = gridStyles(site?.grid_power_w ?? null)
const fetchError = siteError ?? chartError ?? auditError ?? planError
const retryAll = () => {
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 (
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
<div className="mx-auto max-w-7xl space-y-10">
<header className="flex flex-col gap-4 border-b border-slate-800/80 pb-6 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-white">EMS Platform</h1>
<p className="mt-1 text-sm text-slate-400">Přehled lokality a auditu</p>
<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-8">
{fetchError ? (
<div
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"
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>
{!siteReady ? (
<div className="h-10 w-56 animate-pulse rounded-lg bg-slate-800/80" />
) : site ? (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-slate-400">{site.site_name}</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-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}
) : null}
<header className="border-b border-slate-800/80 pb-6">
<h1 className="text-2xl font-bold tracking-tight text-white">EMS Platform</h1>
<p className="mt-1 text-sm text-slate-400">Přehled lokality, auditu a plánu</p>
</header>
{/* Horní metriky */}
<section>
<SectionTitle kicker="Živě" title="Aktuální stav" />
{liveSkeleton ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<CardSkeleton />
<CardSkeleton />
<div className="flex min-h-[88px] items-center justify-center rounded-xl border border-slate-800 bg-slate-900/40">
<div className="h-36 w-36 animate-pulse rounded-full bg-slate-800/80" />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{metricsLoading ? (
<>
<MetricSkeleton />
<MetricSkeleton />
<MetricSkeleton />
<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>
<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 className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<PowerFlowCard label="FVE" powerW={site?.pv_power_w} icon={Sun} borderClass="border-l-amber-400" iconClass="text-amber-400" />
<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>
)}
) : metricsLoading ? (
<div className="mt-4 h-5 w-full max-w-md animate-pulse rounded bg-slate-800/80" />
) : null}
</section>
<section>
<SectionTitle kicker="Dnes" title="Průběh výkonů (hodinový průměr)" />
<TelemetryChart points={points} loading={chartSkeleton} />
{/* Graf + denní souhrn */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<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>
{/* Plán 4 h */}
<section>
<SectionTitle kicker="Dnes" title="Ekonomika auditu" />
{econSkeleton ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatSkeleton />
<StatSkeleton />
<StatSkeleton />
<StatSkeleton />
</div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Nejbližší plán (4 hodiny)
</h2>
{planLoading ? (
<BlockSkeleton className="h-16 w-full" />
) : planSlots.length === 0 ? (
<p className="text-sm text-slate-500">Plán zatím není k dispozici</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatBlock label="Import" value={fmtEnergy(daily?.import_kwh)} />
<StatBlock label="Export" value={fmtEnergy(daily?.export_kwh)} />
<StatBlock label="FVE výroba" value={fmtEnergy(daily?.pv_kwh)} />
<StatBlock label="Náklady / příjem (audit)" value={fmtMoney(daily?.actual_cost_czk)} />
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div className="flex gap-1">
{planSlots.map((slot, i) => (
<div key={`${slot.interval_start}-${i}`} className="min-w-0 flex-1 group relative">
<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>
)}
</section>

View 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>: {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)}`
: `${Math.abs(summary.total_expected_cost_czk).toFixed(2)}`}
</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"></th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Náklady </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>
)
}

View File

@@ -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 { ModeSelector } from '../components/ModeSelector'
import { useEVSessions } from '../hooks/useEVSessions'
import { useSiteStatus } from '../hooks/useSiteStatus'
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() {
const { site, ready, reload } = useSiteStatus()
const siteId = site?.site_id ?? null
const { sessions, ready: evReady, error: evError, reload: reloadEv } = useEVSessions(siteId)
return (
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
@@ -31,7 +211,8 @@ export function Settings() {
<section>
<SectionTitle kicker="Řízení" title="Provozní režim" />
<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>
<ModeSelector
siteId={siteId}
@@ -45,52 +226,31 @@ export function Settings() {
</section>
<section>
<SectionTitle kicker="EV" title="Deadline nabíjení (připravuje se)" />
<p className="mb-4 text-sm text-slate-500">
Zatím pouze rozhraní; napojení na API a session přijde v další iteraci.
<SectionTitle kicker="EV" title="Deadline nabíjení" />
<p className="mb-4 max-w-3xl text-sm text-slate-400">
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>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
<p className="text-sm font-medium text-slate-200">Tesla</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"
{siteId === null ? (
<p className="text-sm text-slate-500">Načítám lokalitu</p>
) : !evReady ? (
<p className="text-sm text-slate-500">Načítám EV session</p>
) : (
<>
{evError ? <p className="mb-3 text-sm text-amber-600/90">{evError}</p> : null}
<div className="grid gap-4 md:grid-cols-2">
{CHARGER_SLOTS.map((slot) => (
<EvChargerCard
key={slot.code}
siteId={siteId}
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 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>
</div>
</div>

View 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[]
}

View File

@@ -43,4 +43,6 @@ export type CurrentPlanResponse = {
export type RunPlanResponse = {
run_id: number
solver_duration_ms: number
horizon_start: string
horizon_end: string
}

View File

@@ -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 { defineConfig } from 'vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
outDir: 'dist',
assetsDir: 'assets',
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/rest': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rest/, ''),
const __dirname = dirname(fileURLToPath(import.meta.url))
const oxideVendored = [
join(__dirname, 'vendor', 'tailwindcss-oxide.linux-x64-gnu.node'),
join(__dirname, 'vendor', 'tailwindcss-oxide.linux-x64-musl.node'),
]
for (const p of oxideVendored) {
if (existsSync(p)) {
process.env.NAPI_RS_NATIVE_LIBRARY_PATH = p
break
}
}
export default defineConfig(async () => {
const { default: tailwindcss } = await import('@tailwindcss/vite')
return {
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/, ''),
},
},
},
},
}
})