From 897b95f72891573196488797da51de846ad7b92b Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 20 Mar 2026 14:30:03 +0100 Subject: [PATCH] x --- .env.example | 4 +- .gitignore | 2 + README.md | 54 ++ backend/app/config.py | 2 +- backend/app/db_json.py | 35 + backend/app/routers/full_status.py | 268 +++++++ backend/app/routers/plan.py | 246 +++---- backend/services/audit_filler.py | 47 ++ backend/services/control_exporter.py | 425 +++++++++++ backend/services/forecast_service.py | 247 +++++++ backend/services/heartbeat_service.py | 70 ++ backend/services/planning_engine.py | 27 +- backend/services/price_importer.py | 180 +++++ backend/services/telemetry_collector.py | 321 ++++++++ db/migration/V002__timescale_hypertables.sql | 26 + db/migration/V003__seed_site_home01.sql | 34 +- db/migration/V008__asset_inverter_active.sql | 5 + db/migration/V009__postgrest_roles.sql | 26 + db/migration/V010__indexes.sql | 40 + db/migration/V011__indexes_and_aggregates.sql | 58 ++ db/views/R__vw_operating_mode.sql | 22 + db/views/R__z_postgrest_ems_anon_grants.sql | 13 + docker-compose.yml | 4 +- docs/06-open-questions.md | 8 +- frontend/nginx.conf | 2 +- frontend/package-lock.json | 139 +++- frontend/package.json | 6 +- frontend/scripts/ensure-native-bindings.mjs | 62 ++ frontend/scripts/run-build-inner.mjs | 14 + frontend/scripts/run-build.mjs | 21 + frontend/scripts/run-dev-inner.mjs | 17 + frontend/scripts/run-dev.mjs | 13 + frontend/src/App.tsx | 4 +- frontend/src/Planning.tsx | 457 ------------ frontend/src/api/backend.ts | 102 +++ frontend/src/components/ModeSelector.tsx | 3 +- frontend/src/hooks/useAuditDailyToday.ts | 32 +- frontend/src/hooks/useCurrentPlan.ts | 47 ++ frontend/src/hooks/useEVSessions.ts | 38 + frontend/src/hooks/useFullStatus.ts | 39 + frontend/src/hooks/useSiteStatus.ts | 4 + frontend/src/hooks/useTelemetryToday.ts | 7 +- frontend/src/pages/Dashboard.tsx | 673 ++++++++++++++--- frontend/src/pages/Planning.tsx | 687 ++++++++++++++++++ frontend/src/pages/Settings.tsx | 248 +++++-- frontend/src/types/fullStatus.ts | 41 ++ frontend/src/types/plan.ts | 2 + frontend/vite.config.ts | 54 +- 48 files changed, 4034 insertions(+), 842 deletions(-) create mode 100644 README.md create mode 100644 backend/app/db_json.py create mode 100644 backend/app/routers/full_status.py create mode 100644 backend/services/audit_filler.py create mode 100644 backend/services/control_exporter.py create mode 100644 backend/services/forecast_service.py create mode 100644 backend/services/heartbeat_service.py create mode 100644 backend/services/price_importer.py create mode 100644 backend/services/telemetry_collector.py create mode 100644 db/migration/V008__asset_inverter_active.sql create mode 100644 db/migration/V009__postgrest_roles.sql create mode 100644 db/migration/V010__indexes.sql create mode 100644 db/migration/V011__indexes_and_aggregates.sql create mode 100644 db/views/R__z_postgrest_ems_anon_grants.sql create mode 100644 frontend/scripts/ensure-native-bindings.mjs create mode 100644 frontend/scripts/run-build-inner.mjs create mode 100644 frontend/scripts/run-build.mjs create mode 100644 frontend/scripts/run-dev-inner.mjs create mode 100644 frontend/scripts/run-dev.mjs delete mode 100644 frontend/src/Planning.tsx create mode 100644 frontend/src/hooks/useCurrentPlan.ts create mode 100644 frontend/src/hooks/useEVSessions.ts create mode 100644 frontend/src/hooks/useFullStatus.ts create mode 100644 frontend/src/pages/Planning.tsx create mode 100644 frontend/src/types/fullStatus.ts diff --git a/.env.example b/.env.example index 222f905..281c9f5 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 8b02d68..474ee3e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ venv/ node_modules/ dist/ *.tsbuildinfo +frontend/vendor/ +frontend/scripts/.native-tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ca93a3 --- /dev/null +++ b/README.md @@ -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). diff --git a/backend/app/config.py b/backend/app/config.py index f13358a..1c50b7c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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", diff --git a/backend/app/db_json.py b/backend/app/db_json.py new file mode 100644 index 0000000..2469c11 --- /dev/null +++ b/backend/app/db_json.py @@ -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 diff --git a/backend/app/routers/full_status.py b/backend/app/routers/full_status.py new file mode 100644 index 0000000..0bbf706 --- /dev/null +++ b/backend/app/routers/full_status.py @@ -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, + } diff --git a/backend/app/routers/plan.py b/backend/app/routers/plan.py index e5ba750..a1b2e3f 100644 --- a/backend/app/routers/plan.py +++ b/backend/app/routers/plan.py @@ -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"], + ) diff --git a/backend/services/audit_filler.py b/backend/services/audit_filler.py new file mode 100644 index 0000000..fd72015 --- /dev/null +++ b/backend/services/audit_filler.py @@ -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)) diff --git a/backend/services/control_exporter.py b/backend/services/control_exporter.py new file mode 100644 index 0000000..4ff3efd --- /dev/null +++ b/backend/services/control_exporter.py @@ -0,0 +1,425 @@ +"""Export plánovaných setpointů na Modbus (Deye, EV, TČ) a HTTP do Loxone.""" + +from __future__ import annotations + +import asyncio +import logging +import os +from dataclasses import dataclass +from datetime import datetime, timezone +import asyncpg +import httpx + +from app.config import get_settings +from services.telemetry_collector import ModbusDevice + +logger = logging.getLogger(__name__) + + +def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int: + if not power_w or power_w <= 0: + return 0 + return min(32, max(0, int(power_w / (phases * voltage)))) + + +@dataclass +class ControlSetpoints: + battery_w: int | None + grid_export_limit: int + ev1_current_a: int + ev2_current_a: int + heat_pump_enable: bool + grid_setpoint_w: int + ev1_power_w: int + ev2_power_w: int + + +@dataclass +class OperatingModeInfo: + mode_code: str + battery_mode: str + grid_mode: str + ev_enabled: bool + heat_pump_enabled_def: bool + loxone_mode_value: int + + +def _clamp_u16(value: int) -> int: + return max(0, min(65535, int(value))) + + +async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> OperatingModeInfo | None: + sql = """ + SELECT som.mode_code, omd.battery_mode, omd.grid_mode, + omd.ev_enabled, omd.heat_pump_enabled, omd.loxone_mode_value, + som.valid_until + FROM ems.site_operating_mode som + JOIN ems.operating_mode_def omd ON omd.code = som.mode_code + WHERE som.site_id = $1 + """ + row = await db.fetchrow(sql, site_id) + if row is None: + return None + vu = row["valid_until"] + if vu is not None: + now_utc = datetime.now(timezone.utc) + if vu.tzinfo is None: + vu = vu.replace(tzinfo=timezone.utc) + if vu <= now_utc: + await db.execute("SELECT ems.fn_expire_modes()") + row = await db.fetchrow(sql, site_id) + if row is None: + return None + return OperatingModeInfo( + mode_code=row["mode_code"], + battery_mode=row["battery_mode"], + grid_mode=row["grid_mode"], + ev_enabled=bool(row["ev_enabled"]), + heat_pump_enabled_def=bool(row["heat_pump_enabled"]), + loxone_mode_value=int(row["loxone_mode_value"]), + ) + + +async def _fetch_current_slot_plan_row(site_id: int, db: asyncpg.Connection) -> asyncpg.Record | None: + """Řádek plánu pro následující 15min slot (export ~1 min před hranicí, např. 14:29 → 14:30–14:45).""" + return await db.fetchrow( + """ + SELECT pi.* FROM ems.planning_interval pi + JOIN ems.planning_run pr ON pr.id = pi.run_id + WHERE pr.site_id = $1 AND pr.status = 'active' + AND pi.interval_start = ( + SELECT MIN(pi2.interval_start) FROM ems.planning_interval pi2 + JOIN ems.planning_run pr2 ON pr2.id = pi2.run_id + WHERE pr2.site_id = $1 AND pr2.status = 'active' + AND pi2.interval_start >= date_trunc('hour', now()) + + INTERVAL '15 min' * FLOOR(EXTRACT(MINUTE FROM now()) / 15) + + INTERVAL '15 minutes' + ) + LIMIT 1 + """, + site_id, + ) + + +async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int: + v = await db.fetchval( + """ + SELECT ai.max_charge_power_w + FROM ems.asset_inverter ai + WHERE ai.site_id = $1 AND ai.controllable = true AND ai.active = true + ORDER BY ai.id + LIMIT 1 + """, + site_id, + ) + if v is None: + return 0 + return int(v) + + +def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> ControlSetpoints | None: + code = mode.mode_code + if code == "MANUAL": + return None + + if code == "AUTO": + if pi is None: + return None + grid_sp = int(pi["grid_setpoint_w"] or 0) + ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0 + ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0 + hp_en = bool(pi["heat_pump_enabled"]) + return ControlSetpoints( + battery_w=int(pi["battery_setpoint_w"] or 0), + grid_export_limit=abs(min(grid_sp, 0)), + ev1_current_a=watts_to_amps(ev1_w, phases=3), + ev2_current_a=watts_to_amps(ev2_w, phases=1), + heat_pump_enable=hp_en, + grid_setpoint_w=grid_sp, + ev1_power_w=ev1_w, + ev2_power_w=ev2_w, + ) + + if code == "SELF_SUSTAIN": + return ControlSetpoints( + battery_w=None, + grid_export_limit=0, + ev1_current_a=0, + ev2_current_a=0, + heat_pump_enable=False, + grid_setpoint_w=0, + ev1_power_w=0, + ev2_power_w=0, + ) + + if code == "CHARGE_CHEAP": + # max_charge doplníme v export_setpoints z DB + return ControlSetpoints( + battery_w=0, + grid_export_limit=0, + ev1_current_a=0, + ev2_current_a=0, + heat_pump_enable=False, + grid_setpoint_w=0, + ev1_power_w=0, + ev2_power_w=0, + ) + + if code == "PRESERVE": + return ControlSetpoints( + battery_w=0, + grid_export_limit=0, + ev1_current_a=0, + ev2_current_a=0, + heat_pump_enable=False, + grid_setpoint_w=0, + ev1_power_w=0, + ev2_power_w=0, + ) + + logger.warning("Unknown mode_code %s for site export, skipping", code) + return None + + +async def write_inverter_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str: + if setpoints.battery_w is None: + return "OK inverter: skipped (battery_w=None, Deye unchanged)" + + rows = await db.fetch( + """ + SELECT ai.code, se.host, se.port, se.unit_id + FROM ems.asset_inverter ai + JOIN ems.site_endpoint se ON se.id = ai.endpoint_id + WHERE ai.site_id = $1 + AND ai.controllable = true + AND ai.active = true + AND se.enabled = true + AND se.endpoint_type = 'modbus_tcp' + """, + site_id, + ) + if not rows: + return "FAIL inverter: no controllable Modbus endpoint" + + bw = setpoints.battery_w + gex = _clamp_u16(setpoints.grid_export_limit) + chg = _clamp_u16(bw) if bw >= 0 else 0 + dis = _clamp_u16(abs(bw)) if bw < 0 else 0 + + errors: list[str] = [] + for row in rows: + code = row["code"] + host = row["host"] + port = int(row["port"] or 502) + unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1) + dev = ModbusDevice(host, port, unit_id, f"inverter-write:{code}") + try: + if bw >= 0: + ok1 = await dev.write_register(0x00F3, chg) + ok2 = await dev.write_register(0x00F4, 0) + else: + ok1 = await dev.write_register(0x00F3, 0) + ok2 = await dev.write_register(0x00F4, dis) + ok3 = await dev.write_register(0x00F6, gex) + if not (ok1 and ok2 and ok3): + errors.append(f"{code}: Modbus write failed") + except Exception as e: + errors.append(f"{code}: {e}") + finally: + await dev.close() + + if errors: + return "FAIL inverter: " + "; ".join(errors) + return f"OK inverter: batt_w={bw} export_limit_w={gex}" + + +def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int: + c = (charger_code or "").strip().lower() + if c == "ev-charger-1": + a = sp.ev1_current_a + elif c == "ev-charger-2": + a = sp.ev2_current_a + elif c.endswith("-1") or c == "ev1": + a = sp.ev1_current_a + elif c.endswith("-2") or c == "ev2": + a = sp.ev2_current_a + else: + a = 0 + if a < 6: + a = 0 + return a + + +async def write_ev_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str: + rows = await db.fetch( + """ + SELECT ec.code, se.host, se.port, se.unit_id + FROM ems.asset_ev_charger ec + JOIN ems.site_endpoint se ON se.id = ec.endpoint_id + WHERE ec.site_id = $1 + AND ec.schedulable = true + AND se.enabled = true + AND se.endpoint_type = 'modbus_tcp' + ORDER BY ec.code + """, + site_id, + ) + if not rows: + return "OK EV: no schedulable chargers" + + for row in rows: + code = row["code"] + current_a = _current_limit_for_charger(code, setpoints) + logger.info( + "EV setpoint [%s]: %sA (TODO: Modbus registers)", + code, + current_a, + ) + return f"OK EV: logged {len(rows)} charger(s) (Modbus TODO)" + + +async def write_heat_pump_setpoint(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str: + rows = await db.fetch( + """ + SELECT hp.code, se.host, se.port, se.unit_id + FROM ems.asset_heat_pump hp + JOIN ems.site_endpoint se ON se.id = hp.endpoint_id + WHERE hp.site_id = $1 + AND hp.schedulable = true + AND se.enabled = true + AND se.endpoint_type = 'modbus_tcp' + """, + site_id, + ) + if not rows: + return "OK heat pump: no schedulable unit" + for row in rows: + logger.info( + "HP setpoint [%s]: enable=%s (TODO: Modbus registers)", + row["code"], + setpoints.heat_pump_enable, + ) + return "OK heat pump: logged (Modbus TODO)" + + +async def send_loxone_setpoints( + site_id: int, + setpoints: ControlSetpoints, + mode: OperatingModeInfo, + db: asyncpg.Connection, +) -> str: + endpoint = await db.fetchrow( + """ + SELECT host, port, protocol + FROM ems.site_endpoint + WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true + ORDER BY id + LIMIT 1 + """, + site_id, + ) + if not endpoint: + return "OK Loxone: no endpoint, skipped" + + proto = (endpoint["protocol"] or "http").lower() + if proto not in ("http", "https"): + proto = "http" + host = endpoint["host"] + port = int(endpoint["port"] or (443 if proto == "https" else 80)) + base = f"{proto}://{host}:{port}/dev/sps/io" + + settings = get_settings() + user = settings.loxone_user or os.getenv("LOXONE_USER") or "" + password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or "" + auth = (user, password) if user else None + + batt_display = 0 if setpoints.battery_w is None else int(setpoints.battery_w) + + paths: list[tuple[str, int]] = [ + (f"{base}/EMS_Mode/{mode.loxone_mode_value}", mode.loxone_mode_value), + (f"{base}/EMS_Battery_Setpoint_W/{batt_display}", batt_display), + (f"{base}/EMS_Grid_Setpoint_W/{setpoints.grid_setpoint_w}", setpoints.grid_setpoint_w), + (f"{base}/EMS_EV1_Power_W/{setpoints.ev1_power_w}", setpoints.ev1_power_w), + (f"{base}/EMS_EV2_Power_W/{setpoints.ev2_power_w}", setpoints.ev2_power_w), + (f"{base}/EMS_HeatPump_Enable/{1 if setpoints.heat_pump_enable else 0}", 1 if setpoints.heat_pump_enable else 0), + ] + + errs: list[str] = [] + try: + async with httpx.AsyncClient(timeout=5.0) as client: + for url, _ in paths: + try: + r = await client.get(url, auth=auth) + r.raise_for_status() + except Exception as e: + errs.append(f"{url!s}: {e}") + except Exception as e: + return f"FAIL Loxone: client {e}" + + if errs: + return "FAIL Loxone: " + "; ".join(errs[:3]) + return "OK Loxone: all virtual inputs updated" + + +async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None: + mode = await _fetch_operating_mode(site_id, db) + if mode is None: + logger.warning("control export site=%s: no operating mode row", site_id) + return + + if mode.mode_code == "MANUAL": + logger.info("control export site=%s: MANUAL, skip writes", site_id) + return + + pi = await _fetch_current_slot_plan_row(site_id, db) + sp = _build_setpoints(mode, pi) + + if mode.mode_code == "AUTO" and sp is None: + if pi is None: + logger.warning( + "control export site=%s: AUTO but no planning_interval for current slot, skip", + site_id, + ) + return + + if sp is None: + logger.warning( + "control export site=%s: no setpoints for mode %s, skip", + site_id, + mode.mode_code, + ) + return + + if mode.mode_code == "CHARGE_CHEAP": + max_ch = await _fetch_max_charge_power_w(site_id, db) + sp = ControlSetpoints( + battery_w=max_ch, + grid_export_limit=0, + ev1_current_a=0, + ev2_current_a=0, + heat_pump_enable=False, + grid_setpoint_w=0, + ev1_power_w=0, + ev2_power_w=0, + ) + + results = list( + zip( + ("inverter", "ev", "heat_pump", "loxone"), + await asyncio.gather( + write_inverter_setpoints(site_id, sp, db), + write_ev_setpoints(site_id, sp, db), + write_heat_pump_setpoint(site_id, sp, db), + send_loxone_setpoints(site_id, sp, mode, db), + return_exceptions=True, + ), + ) + ) + + for name, res in results: + if isinstance(res, Exception): + logger.error("control export site=%s %s: FAIL %s", site_id, name, res) + elif isinstance(res, str) and res.startswith("FAIL"): + logger.error("control export site=%s %s: %s", site_id, name, res) + else: + logger.info("control export site=%s %s: %s", site_id, name, res) diff --git a/backend/services/forecast_service.py b/backend/services/forecast_service.py new file mode 100644 index 0000000..a6e2ec0 --- /dev/null +++ b/backend/services/forecast_service.py @@ -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 diff --git a/backend/services/heartbeat_service.py b/backend/services/heartbeat_service.py new file mode 100644 index 0000000..dac5469 --- /dev/null +++ b/backend/services/heartbeat_service.py @@ -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) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 130084d..0845610 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -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 diff --git a/backend/services/price_importer.py b/backend/services/price_importer.py new file mode 100644 index 0000000..4b97b6d --- /dev/null +++ b/backend/services/price_importer.py @@ -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()) diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py new file mode 100644 index 0000000..0a5fb8c --- /dev/null +++ b/backend/services/telemetry_collector.py @@ -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)) diff --git a/db/migration/V002__timescale_hypertables.sql b/db/migration/V002__timescale_hypertables.sql index 6e227e5..30b5cf1 100644 --- a/db/migration/V002__timescale_hypertables.sql +++ b/db/migration/V002__timescale_hypertables.sql @@ -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); diff --git a/db/migration/V003__seed_site_home01.sql b/db/migration/V003__seed_site_home01.sql index dbbb3c7..51d3a4e 100644 --- a/db/migration/V003__seed_site_home01.sql +++ b/db/migration/V003__seed_site_home01.sql @@ -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 diff --git a/db/migration/V008__asset_inverter_active.sql b/db/migration/V008__asset_inverter_active.sql new file mode 100644 index 0000000..a46404d --- /dev/null +++ b/db/migration/V008__asset_inverter_active.sql @@ -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í.'; diff --git a/db/migration/V009__postgrest_roles.sql b/db/migration/V009__postgrest_roles.sql new file mode 100644 index 0000000..b72c787 --- /dev/null +++ b/db/migration/V009__postgrest_roles.sql @@ -0,0 +1,26 @@ +-- Role pro PostgREST anonymní přístup (read-only). +-- GRANT na views je v db/views/R__z_postgrest_ems_anon_grants.sql (Flyway je aplikuje až po R__vw_*). + +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'ems_anon') THEN + CREATE ROLE ems_anon NOLOGIN; + END IF; +END $$; + +GRANT USAGE ON SCHEMA ems TO ems_anon; + +-- Read-only na tabulky (existují po V001–V008) +GRANT SELECT ON ems.market_interval_price TO ems_anon; +GRANT SELECT ON ems.planning_run TO ems_anon; +GRANT SELECT ON ems.planning_interval TO ems_anon; +GRANT SELECT ON ems.forecast_pv_interval TO ems_anon; +GRANT SELECT ON ems.forecast_pv_run TO ems_anon; +GRANT SELECT ON ems.operating_mode_def TO ems_anon; +GRANT SELECT ON ems.site_operating_mode TO ems_anon; +GRANT SELECT ON ems.site_operating_mode_log TO ems_anon; +GRANT SELECT ON ems.ev_session TO ems_anon; +GRANT SELECT ON ems.asset_vehicle TO ems_anon; + +COMMENT ON ROLE ems_anon IS +'Anonymní role pro PostgREST. Read-only přístup na views a vybrané tabulky. +Zápisy jdou výhradně přes FastAPI backend který má vlastní DB connection.'; diff --git a/db/migration/V010__indexes.sql b/db/migration/V010__indexes.sql new file mode 100644 index 0000000..35beb1e --- /dev/null +++ b/db/migration/V010__indexes.sql @@ -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); diff --git a/db/migration/V011__indexes_and_aggregates.sql b/db/migration/V011__indexes_and_aggregates.sql new file mode 100644 index 0000000..cf7a113 --- /dev/null +++ b/db/migration/V011__indexes_and_aggregates.sql @@ -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.'; diff --git a/db/views/R__vw_operating_mode.sql b/db/views/R__vw_operating_mode.sql index 74a7a99..72b4d40 100644 --- a/db/views/R__vw_operating_mode.sql +++ b/db/views/R__vw_operating_mode.sql @@ -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 diff --git a/db/views/R__z_postgrest_ems_anon_grants.sql b/db/views/R__z_postgrest_ems_anon_grants.sql new file mode 100644 index 0000000..588b523 --- /dev/null +++ b/db/views/R__z_postgrest_ems_anon_grants.sql @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml index 579a99a..a21ed6d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/06-open-questions.md b/docs/06-open-questions.md index 6778fc9..5cbfa94 100644 --- a/docs/06-open-questions.md +++ b/docs/06-open-questions.md @@ -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`. diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 6166627..79a4fac 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5d9023..bd40992 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index fce82d8..49b8f23 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/scripts/ensure-native-bindings.mjs b/frontend/scripts/ensure-native-bindings.mjs new file mode 100644 index 0000000..eaa5017 --- /dev/null +++ b/frontend/scripts/ensure-native-bindings.mjs @@ -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() diff --git a/frontend/scripts/run-build-inner.mjs b/frontend/scripts/run-build-inner.mjs new file mode 100644 index 0000000..fd6130b --- /dev/null +++ b/frontend/scripts/run-build-inner.mjs @@ -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']) diff --git a/frontend/scripts/run-build.mjs b/frontend/scripts/run-build.mjs new file mode 100644 index 0000000..c1209cb --- /dev/null +++ b/frontend/scripts/run-build.mjs @@ -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) diff --git a/frontend/scripts/run-dev-inner.mjs b/frontend/scripts/run-dev-inner.mjs new file mode 100644 index 0000000..537ab78 --- /dev/null +++ b/frontend/scripts/run-dev-inner.mjs @@ -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) diff --git a/frontend/scripts/run-dev.mjs b/frontend/scripts/run-dev.mjs new file mode 100644 index 0000000..dea6a8c --- /dev/null +++ b/frontend/scripts/run-dev.mjs @@ -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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dc5babb..014797b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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í - - )} - {summary && run && ( -
-
-
Očekávané náklady (celkem)
-
- {summary.total_expected_cost_czk.toFixed(2)} Kč -
-
-
-
Curtailment A
-
- {summary.total_pv_curtailed_kwh.toFixed(3)} kWh -
-
-
-
Sloty nabíjení
-
{summary.charge_slots}
-
-
-
Sloty vybíjení
-
{summary.discharge_slots}
-
-
-
Sloty exportu
-
{summary.export_slots}
-
-
- )} - - - {/* Sekce 2 */} -
-

- Graf (24 h) -

- {!chartRows.length ? ( -

Žádná data pro graf v horizontu 24 h.

- ) : ( -
- - { - const p = state?.activePayload?.[0]?.payload as ChartRow | undefined - if (p?.raw) setSlotDetail(p.raw) - }} - > - - - - - { - if (name === 'Cena nákup') return [`${value.toFixed(3)} Kč/kWh`, name] - return [`${value.toFixed(2)} kW`, name] - }} - /> - - - - - - - - -
- )} - {slotDetail && ( -
-
- - Slot {formatLocal(slotDetail.interval_start)} - - -
-
-
Nákup / prodej
-
- {slotDetail.effective_buy_price?.toFixed(4) ?? '—'} /{' '} - {slotDetail.effective_sell_price?.toFixed(4) ?? '—'} -
-
FVE (A+B)
-
{slotDetail.pv_forecast_total_w ?? '—'} W
-
Baseline
-
{slotDetail.load_baseline_w ?? '—'} W
-
Baterie
-
{slotDetail.battery_setpoint_w ?? '—'} W
-
SoC cíl
-
- {slotDetail.battery_soc_target_pct != null - ? `${slotDetail.battery_soc_target_pct}%` - : '—'} -
-
Síť
-
{slotDetail.grid_setpoint_w ?? '—'} W
-
EV1 / EV2
-
- {slotDetail.ev1_setpoint_w ?? '—'} / {slotDetail.ev2_setpoint_w ?? '—'} W -
-
-
{slotDetail.heat_pump_enabled ? 'Zapnuto' : 'Vypnuto'}
-
Curtailment A
-
{slotDetail.pv_a_curtailed_w ?? 0} W
-
Náklady slotu
-
{slotDetail.expected_cost_czk?.toFixed(4) ?? '—'} Kč
-
-
- )} -
- - {/* Sekce 3 */} -
-

- Tabulka (96 slotů / 24 h) -

-
- - - - - - - - - - - - - - - - - {intervals24h.map((i) => ( - - - - - - - - - - - - - ))} - -
ČasNákupProdejFVEBatSíťEV1EV2Náklady
- {formatLocalTime(i.interval_start)} - - {i.effective_buy_price?.toFixed(2) ?? '—'} - - {i.effective_sell_price?.toFixed(2) ?? '—'} - - {i.pv_forecast_total_w != null ? Math.round(i.pv_forecast_total_w) : '—'} - - {i.battery_setpoint_w ?? '—'} - {i.grid_setpoint_w ?? '—'}{i.ev1_setpoint_w ?? '—'}{i.ev2_setpoint_w ?? '—'}{i.heat_pump_enabled ? 'Ano' : 'Ne'} - {i.expected_cost_czk?.toFixed(2) ?? '—'} -
-
- {!intervals24h.length && !loading && ( -

Žádné řádky v 24h okně.

- )} -
- - ) -} diff --git a/frontend/src/api/backend.ts b/frontend/src/api/backend.ts index 58a0009..a4d6ba5 100644 --- a/frontend/src/api/backend.ts +++ b/frontend/src/api/backend.ts @@ -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 { 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 { + const { data } = await client.get('/health/detailed') + return data +} + +export async function getSiteStatusFull(siteId: number): Promise { + const { data } = await client.get(`/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 { + const { data } = await client.post( + `/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 { + const { data } = await client.post( + `/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 { + const { data } = await client.get( + `/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 { + const { data } = await client.patch( + `/sites/${siteId}/ev/sessions/${sessionId}`, + payload, + ) + return data +} + export { client as backendClient } diff --git a/frontend/src/components/ModeSelector.tsx b/frontend/src/components/ModeSelector.tsx index f7dee50..3a2a022 100644 --- a/frontend/src/components/ModeSelector.tsx +++ b/frontend/src/components/ModeSelector.tsx @@ -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[] = [ diff --git a/frontend/src/hooks/useAuditDailyToday.ts b/frontend/src/hooks/useAuditDailyToday.ts index 2457842..83a39a7 100644 --- a/frontend/src/hooks/useAuditDailyToday.ts +++ b/frontend/src/hooks/useAuditDailyToday.ts @@ -8,24 +8,40 @@ const POLL_MS = 30_000 export function useAuditDailyToday(siteId: number | null) { const [row, setRow] = useState(null) const [ready, setReady] = useState(false) + const [error, setError] = useState(null) const load = useCallback(async () => { if (siteId == null) { setRow(null) + setError(null) setReady(true) return } try { - const rows = await getJson('/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('/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('/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, } } diff --git a/frontend/src/hooks/useCurrentPlan.ts b/frontend/src/hooks/useCurrentPlan.ts new file mode 100644 index 0000000..f3e783d --- /dev/null +++ b/frontend/src/hooks/useCurrentPlan.ts @@ -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(EMPTY) + const [ready, setReady] = useState(false) + const [error, setError] = useState(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 } +} diff --git a/frontend/src/hooks/useEVSessions.ts b/frontend/src/hooks/useEVSessions.ts new file mode 100644 index 0000000..662cc83 --- /dev/null +++ b/frontend/src/hooks/useEVSessions.ts @@ -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([]) + const [ready, setReady] = useState(false) + const [error, setError] = useState(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 } +} diff --git a/frontend/src/hooks/useFullStatus.ts b/frontend/src/hooks/useFullStatus.ts new file mode 100644 index 0000000..dd20d56 --- /dev/null +++ b/frontend/src/hooks/useFullStatus.ts @@ -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(null) + const [ready, setReady] = useState(false) + const [error, setError] = useState(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 } +} diff --git a/frontend/src/hooks/useSiteStatus.ts b/frontend/src/hooks/useSiteStatus.ts index 4c913bb..6d20f44 100644 --- a/frontend/src/hooks/useSiteStatus.ts +++ b/frontend/src/hooks/useSiteStatus.ts @@ -7,13 +7,16 @@ const POLL_MS = 5_000 export function useSiteStatus() { const [row, setRow] = useState(null) const [ready, setReady] = useState(false) + const [error, setError] = useState(null) const load = useCallback(async () => { try { const rows = await getJson('/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, diff --git a/frontend/src/hooks/useTelemetryToday.ts b/frontend/src/hooks/useTelemetryToday.ts index ff52ce2..f363813 100644 --- a/frontend/src/hooks/useTelemetryToday.ts +++ b/frontend/src/hooks/useTelemetryToday.ts @@ -23,10 +23,12 @@ export type TelemetryChartPoint = { export function useTelemetryToday(siteId: number | null) { const [points, setPoints] = useState([]) const [ready, setReady] = useState(false) + const [error, setError] = useState(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 } } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 97ed4b2..0fa2e10 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 ( -
-

{kicker}

-

{title}

+
+

{label}

+
    + {payload.map((p) => ( +
  • + {p.name} + {typeof p.value === 'number' ? `${p.value.toFixed(2)} kW` : '—'} +
  • + ))} +
) } -function CardSkeleton() { - return
-} +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 ( -
-

{label}

-

{value}

+
+
+ + + {pct != null && ( + + )} + +
+ + {pct == null ? '—' : `${pct.toFixed(0)}`} + + % SoC +
+
) } -function StatSkeleton() { - return
+function MetricSkeleton() { + return
+} + +function BlockSkeleton({ className = '' }: { className?: string }) { + return
} 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 ( -
-
-
-
-

EMS Platform

-

Přehled lokality a auditu

+
+
+ {fetchError ? ( +
+

Chyba načítání dat

+
- {!siteReady ? ( -
- ) : site ? ( -
- {site.site_name} - - {site.active_mode ?? '—'} - {site.mode_name ? ` · ${site.mode_name}` : ''} - - - - - - EMS - -
- ) : null} + ) : null} + +
+

EMS Platform

+

Přehled lokality, auditu a plánu

+ {/* Horní metriky */}
- - {liveSkeleton ? ( -
- - -
-
+
+ {metricsLoading ? ( + <> + + + + + + ) : site == null ? ( +

Žádná lokalita ve vw_site_status.

+ ) : ( + <> +
+
+
+ +
+
+

FVE výroba

+

+ {hasLiveData ? fmtKw2(site.pv_power_w) : '—'}{' '} + + ☀️ + +

+
+
+
+ +
+
+
= 0 + ? 'text-emerald-400' + : 'text-orange-400' + : 'text-slate-400' + }`} + > + +
+
+

Baterie

+

+ {batPct == null ? '—' : `${batPct.toFixed(0)}%`} + {batSignedKw == null ? '' : ` / ${batSignedKw >= 0 ? '+' : ''}${batSignedKw.toFixed(2)} kW`} +

+
+
+
+
+
+
+ +
= 0 + ? 'border-l-red-500' + : 'border-l-emerald-500' + : 'border-l-slate-600' + }`} + > +
+
+ = 0 + ? 'text-red-400' + : 'text-emerald-400' + : 'text-slate-400' + }`} + aria-hidden + /> +
+
+

Síť

+

{gridLabel}

+

+ {gridW != null && !Number.isNaN(gridW) + ? gridW >= 0 + ? 'import' + : 'export' + : ''} +

+
+
+
+ +
+
+
+ +
+
+

Spotřeba

+

+ {hasLiveData ? fmtKw2(site.load_power_w) : '—'} +

+
+
+
+ + )} +
+ + {/* Status řádek */} + {!metricsLoading && site != null ? ( +
+
+ Aktivní režim: + + {site.active_mode ?? '—'} + {site.mode_name ? ` · ${site.mode_name}` : ''} + + + + + + EMS:{' '} + + {hbOnline ? 'online' : 'offline'} + + + + Poslední telemetrie:{' '} + {formatTelemetryAgo(site.telemetry_at)} +
- + + {hasMonitoringAlerts ? ( +
+ + {alertsOpen ? ( +
    + {monitoringAlerts.map((a, i) => ( +
  • + + {a.level === 'error' ? 'Chyba' : 'Varování'} + + {a.message} +
  • + ))} +
+ ) : null} +
+ ) : null}
- ) : ( -
- - - - -
- )} + ) : metricsLoading ? ( +
+ ) : null}
-
- - + {/* Graf + denní souhrn */} +
+
+ {chartLoading ? ( + + ) : !hasChartData ? ( +
+ Zatím žádná data pro dnešní den +
+ ) : ( +
+ + + + + + } /> + + + + {chartData.map((e, i) => ( + = 0 + ? '#22c55e' + : '#f97316' + } + /> + ))} + + + + +
+ )} +
+ +
+ {summaryLoading ? ( +
+ + + + + +
+ ) : ( +
+

Dnešní souhrn

+
    +
  • + FVE výroba + {fmtEnergy(daily?.pv_kwh)} +
  • +
  • + Import ze sítě + {fmtEnergy(daily?.import_kwh)} +
  • +
  • + Export do sítě + {fmtEnergy(daily?.export_kwh)} +
  • +
  • + Náklady / příjem + {(() => { + 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 {fmtMoney(daily?.actual_cost_czk)} + })()} +
  • +
+ {!hasDaily ? ( +

Pro dnešek zatím nejsou uzavřené intervaly auditu.

+ ) : null} + +
+ )} +
+ {/* Plán 4 h */}
- - {econSkeleton ? ( -
- - - - -
+

+ Nejbližší plán (4 hodiny) +

+ {planLoading ? ( + + ) : planSlots.length === 0 ? ( +

Plán zatím není k dispozici

) : ( -
- - - - +
+
+ {planSlots.map((slot, i) => ( +
+
+
+

{formatSlotLabel(slot.interval_start)}

+

+ cena:{' '} + {slot.effective_buy_price == null + ? '—' + : `${slot.effective_buy_price.toFixed(3)} Kč/kWh`} +

+

+ baterie: {fmtKw2(slot.battery_setpoint_w ?? undefined)} +

+

síť: {fmtKw2(slot.grid_setpoint_w ?? undefined)}

+
+
+ ))} +
+

16× 15 min · najet myší pro detail

)}
diff --git a/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx new file mode 100644 index 0000000..f41284d --- /dev/null +++ b/frontend/src/pages/Planning.tsx @@ -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 ( +
+ + + +
+ ) +} + +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 ( +
+
{formatLocal(i.interval_start)}
+
+
+ Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '} + {sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'} +
+
Baterie: {i.battery_setpoint_w ?? '—'} W
+
Síť: {i.grid_setpoint_w ?? '—'} W
+
TČ: {i.heat_pump_enabled ? 'zapnuto' : 'vypnuto'}
+
+ EV1: {i.ev1_setpoint_w ?? '—'} W · EV2: {i.ev2_setpoint_w ?? '—'} W +
+
+
+ ) +} + +export default function Planning() { + const { site, ready: siteReady } = useSiteStatus() + const siteId = site?.site_id ?? null + + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [replanning, setReplanning] = useState(false) + const [prepAction, setPrepAction] = useState(null) + const [selectedStart, setSelectedStart] = useState(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 ( +
+ Načítám lokalitu… +
+ ) + } + + if (siteId == null) { + return ( +
+ V PostgREST nebyla nalezena lokalita (vw_site_status). Nelze načíst plán. +
+ ) + } + + 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 ( +
+
+

Plánování

+

+ Aktuální LP plán a dalších 24 h od teď ({site?.site_name ?? 'lokalita'}) +

+
+ + {error && ( +
+ {error} +
+ )} + + {/* Sekce 1 */} +
+

+ Status aktivního plánu +

+ {loading && !run ? ( +
+ Načítám… +
+ ) : !run ? ( +
+

Žádný aktivní plán.

+ {showPrepActions && ( + void handleImportPrices()} + onForecast={() => void handleRunForecast()} + onInit={() => void handleInitializePlan()} + /> + )} +
+ ) : ( +
+
+
+ Vytvořeno: + {formatLocal(run.created_at)} + | + Typ: + + {run.run_type} + +
+
+ Horizont: + + {formatLocal(run.horizon_start)} → {formatLocal(run.horizon_end)} + +
+
+ Korekce FVE forecastu: + + {correctionPct != null ? ( + <> + {correctionUp ? ( + + ) : ( + + )} + {Number.isInteger(correctionPct) + ? correctionPct + : correctionPct.toLocaleString('cs-CZ', { maximumFractionDigits: 1 })}{' '} + % + + ) : ( + '—' + )} + +
+
+ Čas výpočtu solveru: + + {run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'} + +
+ {summary && ( +
+

Summary

+
+
+
+ {summary.total_expected_cost_czk >= 0 ? 'Celkové náklady' : 'Celkový příjem'} +
+
+ {summary.total_expected_cost_czk >= 0 + ? `${summary.total_expected_cost_czk.toFixed(2)} Kč` + : `${Math.abs(summary.total_expected_cost_czk).toFixed(2)} Kč`} +
+
+
+
kWh curtailmentu (A)
+
+ {summary.total_pv_curtailed_kwh.toLocaleString('cs-CZ', { + minimumFractionDigits: 3, + maximumFractionDigits: 3, + })}{' '} + kWh +
+
+
+
Sloty nabíjení / vybíjení / export
+
+ {summary.charge_slots} / {summary.discharge_slots} / {summary.export_slots} +
+
+
+
+ )} +
+
+ {showPrepActions && ( + void handleImportPrices()} + onForecast={() => void handleRunForecast()} + onInit={() => void handleInitializePlan()} + wrapClassName="flex flex-wrap justify-end gap-2" + /> + )} + +
+
+ )} +
+ + {/* Sekce 2 */} +
+

Graf plánu

+ {!chartRows.length ? ( +

Žádná data pro graf (24 h od teď, max. 96 slotů).

+ ) : ( +
+ + + + + + + + } /> + + + {chartRows.map((e) => ( + = 0 ? '#22c55e' : '#f97316'} + fillOpacity={0.85} + /> + ))} + + + + + +
+ )} +
+ + {/* Sekce 3 */} +
+

Tabulka slotů

+
+ + + + + + + + + + + + + + + + + {intervals24h.map((i) => { + const sel = selectedStart === i.interval_start + return ( + 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)}`} + > + + + + + + + + + + + + ) + })} + +
ČasCena kupCena prodBat. WSoC %Síť WEV1 WEV2 WNáklady Kč
+ {formatLocalTime(i.interval_start)} + + {i.effective_buy_price?.toFixed(3) ?? '—'} + + {i.effective_sell_price?.toFixed(3) ?? '—'} + {i.battery_setpoint_w ?? '—'} + {i.battery_soc_target_pct != null + ? `${i.battery_soc_target_pct.toFixed(1)}` + : '—'} + {i.grid_setpoint_w ?? '—'}{i.ev1_setpoint_w ?? '—'}{i.ev2_setpoint_w ?? '—'}{i.heat_pump_enabled ? 'on' : 'off'} + {i.expected_cost_czk?.toFixed(4) ?? '—'} +
+
+ {!intervals24h.length && !loading && ( +

Žádné řádky v 24h okně.

+ )} +
+
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 439f3e4..0d789ef 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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('') + 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 ( +
+ +

Nepřipojeno

+

{chargerLabel}

+
+ ) + } + + 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 ( +
+

Připojeno

+

{vehicleTitle(session)}

+

{chargerLabel}

+
void onSubmit(e)} className="mt-4 space-y-3"> +

+ Energie v session:{' '} + {kwh} kWh +

+
+ + +
+ +
+
+ ) +} + 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 (
@@ -31,7 +211,8 @@ export function Settings() {

- 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.

- -

- Zatím pouze rozhraní; napojení na API a session přijde v další iteraci. + +

+ Při připojení vozidla na wallbox se zobrazí aktivní session (dotaz každých 30 s). Cílový SoC a deadline se + ukládají do ev_session pro plánovač.

-
-
-

Tesla

-
-
-
-

Zoe

-
- - -
-
-
+ + )}
diff --git a/frontend/src/types/fullStatus.ts b/frontend/src/types/fullStatus.ts new file mode 100644 index 0000000..c384f22 --- /dev/null +++ b/frontend/src/types/fullStatus.ts @@ -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[] +} diff --git a/frontend/src/types/plan.ts b/frontend/src/types/plan.ts index 6272aa3..9724128 100644 --- a/frontend/src/types/plan.ts +++ b/frontend/src/types/plan.ts @@ -43,4 +43,6 @@ export type CurrentPlanResponse = { export type RunPlanResponse = { run_id: number solver_duration_ms: number + horizon_start: string + horizon_end: string } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5482a38..e5710c7 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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/, ''), + }, }, }, - }, + } })