From 8b4af663d8ba95e794c54688a93f62ea10d8a693 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 20 Mar 2026 13:27:37 +0100 Subject: [PATCH] Initial commit Made-with: Cursor --- .env.example | 34 + .gitignore | 12 + CLAUDE.md | 147 + backend/Dockerfile | 13 + backend/app/__init__.py | 1 + backend/app/config.py | 47 + backend/app/database.py | 48 + backend/app/deps.py | 19 + backend/app/main.py | 157 + backend/app/routers/__init__.py | 1 + backend/app/routers/plan.py | 237 + backend/requirements.txt | 14 + backend/services/__init__.py | 1 + backend/services/planning_engine.py | 817 ++++ db/migration/V001__init_schema.sql | 608 +++ db/migration/V002__timescale_hypertables.sql | 81 + db/migration/V003__seed_site_home01.sql | 203 + db/migration/V004__operating_modes.sql | 117 + db/migration/V005__planning_curtailment.sql | 51 + db/migration/V006__vehicles.sql | 119 + db/migration/V007__rolling_replanning.sql | 91 + db/routines/R__fn_cop_estimate.sql | 137 + db/routines/R__fn_effective_price.sql | 61 + db/routines/R__fn_fill_audit_interval.sql | 171 + db/routines/R__fn_set_mode.sql | 160 + db/views/R__vw_audit_summary.sql | 69 + db/views/R__vw_latest_telemetry.sql | 77 + db/views/R__vw_operating_mode.sql | 75 + db/views/R__vw_site_effective_price.sql | 42 + docker-compose.yml | 112 + docs/01-overview.md | 62 + docs/02-architecture.md | 217 + docs/03-data-model.md | 430 ++ docs/04-modules/consumption.md | 187 + docs/04-modules/control.md | 254 + docs/04-modules/ev-charging.md | 285 ++ docs/04-modules/forecast.md | 180 + docs/04-modules/heat-pump.md | 107 + docs/04-modules/market-prices.md | 128 + docs/04-modules/operating-modes.md | 132 + docs/04-modules/planning.md | 423 ++ docs/04-modules/telemetry.md | 216 + docs/05-todo.md | 98 + docs/06-open-questions.md | 38 + docs/loxone-integration.md | 263 + frontend/.dockerignore | 5 + frontend/Dockerfile | 16 + frontend/index.html | 12 + frontend/nginx.conf | 50 + frontend/package-lock.json | 4551 ++++++++++++++++++ frontend/package.json | 30 + frontend/src/App.tsx | 49 + frontend/src/Planning.tsx | 457 ++ frontend/src/api/backend.ts | 56 + frontend/src/api/postgrest.ts | 14 + frontend/src/components/ModeLog.tsx | 132 + frontend/src/components/ModeSelector.tsx | 269 ++ frontend/src/components/PowerFlowCard.tsx | 33 + frontend/src/components/PriceChart.tsx | 111 + frontend/src/components/SocGauge.tsx | 77 + frontend/src/components/TelemetryChart.tsx | 54 + frontend/src/hooks/useAuditDailyToday.ts | 45 + frontend/src/hooks/useSiteStatus.ts | 42 + frontend/src/hooks/useTelemetryToday.ts | 72 + frontend/src/index.css | 8 + frontend/src/lib/pragueDate.ts | 18 + frontend/src/main.tsx | 10 + frontend/src/pages/Dashboard.tsx | 176 + frontend/src/pages/Settings.tsx | 98 + frontend/src/types/ems.ts | 76 + frontend/src/types/plan.ts | 46 + frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.ts | 10 + frontend/tsconfig.app.json | 22 + frontend/tsconfig.json | 21 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 24 + 77 files changed, 13337 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/deps.py create mode 100644 backend/app/main.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/plan.py create mode 100644 backend/requirements.txt create mode 100644 backend/services/__init__.py create mode 100644 backend/services/planning_engine.py create mode 100644 db/migration/V001__init_schema.sql create mode 100644 db/migration/V002__timescale_hypertables.sql create mode 100644 db/migration/V003__seed_site_home01.sql create mode 100644 db/migration/V004__operating_modes.sql create mode 100644 db/migration/V005__planning_curtailment.sql create mode 100644 db/migration/V006__vehicles.sql create mode 100644 db/migration/V007__rolling_replanning.sql create mode 100644 db/routines/R__fn_cop_estimate.sql create mode 100644 db/routines/R__fn_effective_price.sql create mode 100644 db/routines/R__fn_fill_audit_interval.sql create mode 100644 db/routines/R__fn_set_mode.sql create mode 100644 db/views/R__vw_audit_summary.sql create mode 100644 db/views/R__vw_latest_telemetry.sql create mode 100644 db/views/R__vw_operating_mode.sql create mode 100644 db/views/R__vw_site_effective_price.sql create mode 100644 docker-compose.yml create mode 100644 docs/01-overview.md create mode 100644 docs/02-architecture.md create mode 100644 docs/03-data-model.md create mode 100644 docs/04-modules/consumption.md create mode 100644 docs/04-modules/control.md create mode 100644 docs/04-modules/ev-charging.md create mode 100644 docs/04-modules/forecast.md create mode 100644 docs/04-modules/heat-pump.md create mode 100644 docs/04-modules/market-prices.md create mode 100644 docs/04-modules/operating-modes.md create mode 100644 docs/04-modules/planning.md create mode 100644 docs/04-modules/telemetry.md create mode 100644 docs/05-todo.md create mode 100644 docs/06-open-questions.md create mode 100644 docs/loxone-integration.md create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/Planning.tsx create mode 100644 frontend/src/api/backend.ts create mode 100644 frontend/src/api/postgrest.ts create mode 100644 frontend/src/components/ModeLog.tsx create mode 100644 frontend/src/components/ModeSelector.tsx create mode 100644 frontend/src/components/PowerFlowCard.tsx create mode 100644 frontend/src/components/PriceChart.tsx create mode 100644 frontend/src/components/SocGauge.tsx create mode 100644 frontend/src/components/TelemetryChart.tsx create mode 100644 frontend/src/hooks/useAuditDailyToday.ts create mode 100644 frontend/src/hooks/useSiteStatus.ts create mode 100644 frontend/src/hooks/useTelemetryToday.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/pragueDate.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/types/ems.ts create mode 100644 frontend/src/types/plan.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..222f905 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# ============================================================ +# EMS Platform – environment proměnné +# Zkopírovat jako .env a doplnit hodnoty +# NIKDY necommitovat .env do gitu! +# ============================================================ + +# ---- PostgreSQL ---- +DB_USER=ems_user +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 + +# ---- OTE CZ import ---- +OTE_API_URL=https://www.ote-cr.cz/pubapi/v1/market-data/dam +EUR_CZK_RATE=25.0 # fallback kurz pokud ČNB API nedostupné + +# ---- Weather / Forecast ---- +OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast + +# ---- Loxone ---- +LOXONE_USER=admin +LOXONE_PASSWORD=change_me + +# ---- Telemetrie ---- +TELEMETRY_POLL_INTERVAL_SEC=60 + +# ---- Plánování ---- +PLANNING_HORIZON_HOURS=36 +PLANNING_HP_MAX_COST_CZK_KWH=3.0 # max Kč/kWh tepla pro spuštění TČ +PLANNING_CHEAP_PRICE_THRESHOLD=0.85 +PLANNING_EXPENSIVE_PRICE_THRESHOLD=1.15 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b02d68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +venv/ +.venv/ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..76ec8bf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,147 @@ +# CLAUDE.md – EMS Platform (Cursor Agent) + +Čti před každou implementační změnou. Stručná orientace; detail v `docs/` a SQL v `db/`. + +--- + +## 1. Co to je + +Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zátěž (EV, TČ) podle spotových cen OTE CZ a předpovědí; výstupy řídí zařízení (Modbus) a informuje Loxone jako exekutora. Referenční lokalita v seedu: `home-01` (Deye, baterie, 2× EV Teltonika, Samsung TČ). + +--- + +## 2. Technologický stack + +| Vrstva | Technologie | +|--------|-------------| +| DB | PostgreSQL 16 + TimescaleDB | +| Migrace | Flyway (`db/migration`, `db/routines`, `db/views`) | +| API | PostgREST (REST ze schématu `ems`) + FastAPI (logika, joby – plán v docs) | +| Frontend | React + TypeScript + Vite (očekáváno u kořene / Docker) | +| Pole / zařízení | Modbus TCP (`pymodbus`), HTTP (Loxone, případně API vozidel) | +| Solver | PuLP + HiGHS (`HiGHS_CMD`) | +| Runtime | Docker Compose | + +--- + +## 3. Adresářová struktura + +| Cesta | Účel | +|-------|------| +| `CLAUDE.md`, `.env.example`, `docker-compose.yml` | Kořen: pravidla, env šablona, compose | +| `docs/` | Produktová a technická specifikace (overview, architektura, datový model, integrace) | +| `docs/04-modules/` | Modulové specifikace (ceny, forecast, spotřeba, TČ, telemetrie, řízení, plánování, režimy, EV) | +| `docs/loxone-integration.md` | Loxone watchdog, heartbeat, role exekutora | +| `docs/06-open-questions.md` | Nedokončené rozhodnutí – doplňovat místo hádání | +| `db/migration/` | Flyway versioned migrace `V00x__*.sql` (schéma, seed, alter) | +| `db/routines/` | Repeatable SQL: funkce `ems.fn_*` | +| `db/views/` | Repeatable SQL: view `ems.vw_*` | +| `backend/services/` | Python služby (v repozitáři zatím hlavně plánování) | + +--- + +## 4. Pravidla – NIKDY neporušovat + +1. **15min logika pro plán/ceny/baseline/audit/forecast intervaly.** Časové řady v těchto doménách = 15min sloty. Telemetrie zařízení je 1min (hypertables) – agregace do 15min přes SQL/job, ne ukládat „hodinové“ řádky jako primární plánovací záznam. + +2. **Všechny doménové záznamy vázat na `site_id`** (telemetrie, plány, audit, konfigurace aktiv, session, …). Výjimka: `market_interval_price` je globální pro zdroj/trh; vazba na site je přes konfiguraci a view. + +3. **Raw ceny ≠ efektivní ceny.** `ems.market_interval_price` = bez marží. Efektivní nákup/prodej jen přes `ems.vw_site_effective_price` (join na platnou `site_market_config`). + +4. **Loxone = exekutor + autonomní fallback, ne optimalizátor.** Logika a plán v EMS. Watchdog v Loxone nesmí záviset na čtení DB (`site_heartbeat` je jen pro EMS UI/diagnostiku). + +5. **FVE pole B (`controllable = false`, typicky ongrid GEN) – žádný curtailment.** Curtailment jen pole A (Deye). Solver smí omezovat jen `pv_a`; pole B má zelený bonus (`site_market_config.green_bonus_*`, audit `pv_b_production_wh` / `green_bonus_czk`). + +6. **Záporná prodejní cena → `grid_export == 0`** v LP (hard constraint). + +7. **Záporná nákupní cena → omezit import** na realistický horní strop (viz `solve_dispatch` v `planning_engine.py` – nesmí „nekonečný“ import). + +8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků – dle docs). + +9. **Deye Modbus: čtení i zápis** (setpointy). RS485→Waveshare→TCP, knihovna `pymodbus`. + +10. **Přepínání provozního režimu** přes DB API / `ems.fn_set_mode` – držet konzistenci s `operating_mode_def` a Loxone `loxone_mode_value`. + +--- + +## 5. Schéma `ems` – tabulky (jedna věta) + +| Tabulka | Popis | +|---------|--------| +| `site` | Lokalita (časová zóna, GPS, aktivita). | +| `site_endpoint` | Endpointy: Modbus, Loxone HTTP, atd. | +| `site_market_config` | Marže, režimy cenění, zelený bonus; časová platnost. | +| `site_grid_connection` | Limity import/export, no_export, rezervovaný výkon. | +| `site_override` | Manuální přepisy nad plánem (JSON + platnost). | +| `site_operating_mode` | Aktuální provozní režim na site (1 řádek/site). | +| `site_operating_mode_log` | Historie přepnutí režimů. | +| `site_heartbeat` | Poslední EMS heartbeat pro dashboard (ne pro Loxone watchdog). | +| `operating_mode_def` | Číselník režimů (baterie/síť/EV/TČ, hodnota pro Loxone). | +| `asset_inverter` | Střídač (výkony, endpoint, zda řiditelný). | +| `asset_battery` | Baterie vázaná na střídač (SoC limity, účinnosti, degradace). | +| `asset_pv_array` | FVE pole (Wp, orientace, curtailable vs ne). | +| `asset_ev_charger` | Nabíječka EV (výkony, fáze, endpoint). | +| `asset_heat_pump` | TČ (výkon, COP ref, limity běhu, TUV parametry). | +| `asset_vehicle` | Vozidlo (kapacita, max AC výkon, default target SoC/deadline). | +| `market_interval_price` | Raw spot OTE (15min), bez marží. | +| `telemetry_inverter` | 1min telemetrie střídače (Timescale). | +| `telemetry_ev_charger` | 1min telemetrie nabíječky (Timescale). | +| `telemetry_heat_pump` | 1min telemetrie TČ (Timescale). | +| `forecast_pv_run` | Metadata běhu predikce FVE. | +| `forecast_pv_interval` | Predikovaný výkon FVE po 15min (Timescale). | +| `forecast_weather_interval` | Počasí 15min pro site (Timescale). | +| `forecast_correction_log` | Log korekcí forecastu vs skutečnost při rolling replanu. | +| `planning_run` | Jeden běh plánovače (daily/rolling/manual, stav, parametry solveru). | +| `planning_interval` | Výstup solveru po 15min (baterie, síť, EV, TČ, curtailment A). | +| `audit_interval` | Skutečnost vs plán po 15min (náklady, odchylky, bonus pole B). | +| `consumption_baseline_interval` | Bazální spotřeba actual/forecast 15min (Timescale). | +| `ev_session` | Nabíjecí session na WB (deadline, energie, náklady). | + +**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_latest_telemetry`, `vw_audit_summary`, `vw_operating_mode`; `fn_effective_price`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_set_mode`. + +--- + +## 6. Periodické úlohy backendu (APScheduler / smyčky) + +Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `planning_engine.py`. **V gitu je zatím rozpracovaný backend** – joby mají být v `backend/app/main.py` (zatím často chybí). + +| Úloha | Frekvence | Poznámka | +|-------|-----------|----------| +| `telemetry_collector` | každých **60 s** | Smyčka polling Modbus (Deye, EV×2, TČ) – viz `docs/04-modules/telemetry.md` | +| `price_importer` | **14:00** denně + **00:05** kontrola | `docs/04-modules/market-prices.md` (časy CET v dokumentaci) | +| `forecast_service` | **14:30** + **06:00** denně | `docs/04-modules/forecast.md` | +| `run_daily_plan` | **15:00** denně | `backend/services/planning_engine.py` (horizont 36 h) | +| `run_rolling_replan` | **každých 15 min** (`*/15`) | `planning_engine.py` – přepočet od aktuálního slotu | +| `control_exporter` | **každých 15 min** (slot boundary) | `docs/04-modules/control.md` | +| `audit_filler` / `fn_fill_audit_interval` | **každých 15 min** | `docs/02-architecture.md`, DB `fn_fill_audit_interval` | + +--- + +## 7. Kde hledat co + +| Chci… | Kam | +|-------|-----| +| Pochopit systém end-to-end | `docs/01-overview.md`, `docs/02-architecture.md` | +| Tabulky, vazby, jednotky | `docs/03-data-model.md` | +| OTE ceny, marže, efektivní cena | `docs/04-modules/market-prices.md`, `db/views/R__vw_site_effective_price.sql` | +| FVE forecast, počasí | `docs/04-modules/forecast.md` | +| Bazální spotřeba | `docs/04-modules/consumption.md` | +| TČ, COP, TUV | `docs/04-modules/heat-pump.md`, `db/routines/R__fn_cop_estimate.sql` | +| Modbus, telemetrie, agregace | `docs/04-modules/telemetry.md` | +| Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` | +| LP solver, rolling replan, korekce FVE | `docs/04-modules/planning.md`, `backend/services/planning_engine.py` | +| Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `R__fn_set_mode.sql` | +| EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` | +| Curtailment A, zelený bonus B | `db/migration/V005__planning_curtailment.sql` | +| Rolling plán, forecast log | `db/migration/V007__rolling_replanning.sql` | +| Audit 15min | `db/routines/R__fn_fill_audit_interval.sql`, `docs/04-modules/telemetry.md` | +| Nové sloupce / tabulky | nový `db/migration/V00x__*.sql` + případně `db/routines` / `db/views` | +| Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) | + +--- + +## Konvence (krátce) + +- Python: `snake_case`, type hints, Pydantic pro API modely. +- SQL: `snake_case`, explicitní FK; Flyway pořadí `V###__` / repeatable `R__`. +- Výkon **W**, energie **Wh**, ceny **Kč/kWh**; čas v DB **`TIMESTAMPTZ` (UTC)**. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..20a794f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..ab789ba --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# EMS Platform FastAPI application diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..f13358a --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,47 @@ +"""Application settings loaded from environment (see .env.example).""" + +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + db_host: str = Field(default="localhost") + db_port: int = Field(default=5432) + db_name: str = Field(default="ems") + db_user: str = Field(default="ems_user") + db_password: str = Field(default="") + database_url: str | None = Field(default=None) + + postgrest_jwt_secret: str = Field(default="") + postgrest_anon_role: str = Field(default="ems_user") + + ote_api_url: str = Field( + default="https://www.ote-cr.cz/pubapi/v1/market-data/dam", + ) + eur_czk_rate: float = Field(default=25.0) + + open_meteo_api_url: str = Field( + default="https://api.open-meteo.com/v1/forecast", + ) + + loxone_user: str = Field(default="") + loxone_password: str = Field(default="") + + telemetry_poll_interval_sec: int = Field(default=60) + planning_horizon_hours: int = Field(default=36) + planning_hp_max_cost_czk_kwh: float = Field(default=3.0) + planning_cheap_price_threshold: float = Field(default=0.85) + planning_expensive_price_threshold: float = Field(default=1.15) + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..5b3b205 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,48 @@ +"""asyncpg connection pool and DB access helpers.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import asyncpg + +from app.config import get_settings + +_pool: asyncpg.Pool | None = None + + +async def init_db_pool() -> None: + """Create global pool (call from FastAPI lifespan).""" + global _pool + if _pool is not None: + return + s = get_settings() + _pool = await asyncpg.create_pool( + host=s.db_host, + port=s.db_port, + user=s.db_user, + password=s.db_password, + database=s.db_name, + min_size=1, + max_size=10, + ) + + +async def close_db_pool() -> None: + global _pool + if _pool is not None: + await _pool.close() + _pool = None + + +def get_pool() -> asyncpg.Pool: + if _pool is None: + raise RuntimeError("DB pool not initialized; call init_db_pool() first") + return _pool + + +@asynccontextmanager +async def get_db() -> AsyncIterator[asyncpg.Connection]: + """Async context manager yielding a connection from the pool.""" + pool = get_pool() + async with pool.acquire() as conn: + yield conn diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..aa018b0 --- /dev/null +++ b/backend/app/deps.py @@ -0,0 +1,19 @@ +"""Sdílené FastAPI závislosti (DB pool).""" + +from __future__ import annotations + +import asyncpg +from fastapi import HTTPException + +_pg_pool: asyncpg.Pool | None = None + + +def set_pg_pool(pool: asyncpg.Pool | None) -> None: + global _pg_pool + _pg_pool = pool + + +async def get_pg_pool() -> asyncpg.Pool: + if _pg_pool is None: + raise HTTPException(status_code=503, detail="Database pool not ready") + return _pg_pool diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..b4c2db2 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,157 @@ +"""EMS FastAPI – health, provozní režimy, PostgREST doplňky.""" + +from __future__ import annotations + +import logging +import os +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from typing import Annotated + +import asyncpg +import httpx +from app.deps import set_pg_pool +from app.routers.plan import router as plan_router +from fastapi import Depends, FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +def _dsn() -> str: + host = os.getenv("DB_HOST", "localhost") + port = os.getenv("DB_PORT", "5432") + name = os.getenv("DB_NAME", "ems") + user = os.getenv("DB_USER", "ems_user") + password = os.getenv("DB_PASSWORD", "") + return f"postgresql://{user}:{password}@{host}:{port}/{name}" + + +pool: asyncpg.Pool | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global pool + pool = await asyncpg.create_pool(_dsn(), min_size=1, max_size=5) + set_pg_pool(pool) + yield + set_pg_pool(None) + if pool: + await pool.close() + pool = None + + +app = FastAPI(title="EMS Platform", lifespan=lifespan) + +app.include_router(plan_router, prefix="/api/v1") + +app.add_middleware( + CORSMiddleware, + allow_origins=os.getenv("CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(","), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +async def get_pool() -> asyncpg.Pool: + if pool is None: + raise HTTPException(status_code=503, detail="Database pool not ready") + return pool + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +class SetSiteModeBody(BaseModel): + mode: str = Field(..., min_length=1) + notes: str | None = None + valid_until: datetime | None = None + + +class SetSiteModeResponse(BaseModel): + success: bool + mode: str + activated_at: datetime + + +@app.post("/api/v1/sites/{site_id}/mode", response_model=SetSiteModeResponse) +async def set_site_mode( + site_id: int, + body: SetSiteModeBody, + db: Annotated[asyncpg.Pool, Depends(get_pool)], +) -> SetSiteModeResponse: + mode = body.mode.strip().upper() + allowed = {"AUTO", "SELF_SUSTAIN", "CHARGE_CHEAP", "PRESERVE", "MANUAL"} + if mode not in allowed: + raise HTTPException(status_code=400, detail=f"Unsupported mode: {body.mode}") + + async with db.acquire() as conn: + 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") + + try: + await conn.execute( + "SELECT ems.fn_set_mode($1, $2, $3, $4, $5)", + site_id, + mode, + "user:api", + body.valid_until, + body.notes, + ) + except asyncpg.PostgresError as e: + logger.warning("fn_set_mode failed: %s", e) + raise HTTPException(status_code=400, detail=str(e)) from e + + row = await conn.fetchrow( + """ + SELECT m.mode_code, m.activated_at, d.loxone_mode_value + 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, + ) + if row is None: + raise HTTPException(status_code=500, detail="Mode row missing after set") + + ep = await conn.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, + ) + + activated_at: datetime = row["activated_at"] + if activated_at.tzinfo is None: + activated_at = activated_at.replace(tzinfo=timezone.utc) + + loxone_val: int | None = row["loxone_mode_value"] + if ep and loxone_val is not None: + proto = (ep["protocol"] or "http").lower() + if proto not in ("http", "https"): + proto = "http" + host = ep["host"] + port = int(ep["port"] or (443 if proto == "https" else 80)) + base = f"{proto}://{host}:{port}" + url = f"{base}/dev/sps/io/EMS_Mode/{loxone_val}" + user = os.getenv("LOXONE_USER") or "" + password = os.getenv("LOXONE_PASSWORD") or "" + auth = (user, password) if user else None + try: + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.get(url, auth=auth) + r.raise_for_status() + except Exception as e: + logger.warning("Loxone EMS_Mode notify failed for site %s: %s", site_id, e) + + return SetSiteModeResponse(success=True, mode=row["mode_code"], activated_at=activated_at) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..c5cfac8 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +"""FastAPI routers.""" diff --git a/backend/app/routers/plan.py b/backend/app/routers/plan.py new file mode 100644 index 0000000..e5ba750 --- /dev/null +++ b/backend/app/routers/plan.py @@ -0,0 +1,237 @@ +"""REST API – aktivní plán a ruční přepočet.""" + +from datetime import datetime +from typing import Annotated, Any, Literal + +import asyncpg +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from app.deps import get_pg_pool +from services.planning_engine import 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 + + +class RunPlanResponse(BaseModel): + run_id: int + solver_duration_ms: int + + +def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut: + total_cost = 0.0 + curtailed_wh = 0.0 + charge_slots = 0 + discharge_slots = 0 + export_slots = 0 + for row in intervals: + ec = row.get("expected_cost_czk") + if ec is not None: + total_cost += float(ec) + c = row.get("pv_a_curtailed_w") or 0 + curtailed_wh += int(c) * 0.25 + b = row.get("battery_setpoint_w") + if b is not None: + if int(b) > 0: + charge_slots += 1 + elif int(b) < 0: + discharge_slots += 1 + 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, + ) + + +@router.get("/current", response_model=CurrentPlanResponse) +async def get_current_plan( + site_id: int, + pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], +) -> CurrentPlanResponse: + async with pool.acquire() as conn: + exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id) + if not exists: + 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 + LIMIT 1 + """, + site_id, + ) + if not run_row: + return CurrentPlanResponse(run=None, intervals=[], summary=None) + + 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 + """, + 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) + + +@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"), +) -> RunPlanResponse: + async with pool.acquire() as conn: + exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id) + if not exists: + raise HTTPException(status_code=404, detail="Site not found") + try: + run_id, duration_ms = await run_plan_api( + site_id, conn, plan_type=plan_type, 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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..ae5f5b1 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +asyncpg>=0.30.0 +python-dotenv>=1.0.0 +pydantic-settings>=2.6.0 +apscheduler>=3.10.4 +pymodbus>=3.8.0 +aiohttp>=3.11.0 +pulp>=2.9.0 +highspy>=1.7.0 +pvlib>=0.11.0 +pandas>=2.2.0 +numpy>=2.0.0 +httpx>=0.28.0 diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..16ea971 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ +# Background services diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py new file mode 100644 index 0000000..130084d --- /dev/null +++ b/backend/services/planning_engine.py @@ -0,0 +1,817 @@ +# backend/services/planning_engine.py +# +# EMS Platform – plánovací engine +# Obsahuje: hlavní denní plán + rolling 15min replan +# +# Spouštění (APScheduler v main.py): +# scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0) +# scheduler.add_job(run_rolling_replan, 'cron', minute='*/15') + +import time +import logging +from dataclasses import dataclass, replace +from datetime import datetime, timezone, timedelta +from types import SimpleNamespace +from typing import Optional + +import pulp +from pulp import HiGHS_CMD + +logger = logging.getLogger(__name__) + + +# ============================================================ +# Konstanty +# ============================================================ + +HORIZON_HOURS = 36 # horizont denního plánu +INTERVAL_H = 0.25 # 15 minut v hodinách +CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A +SOLVER_TIME_LIMIT = 10 # sekund +CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru +CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru +CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru +# Útlum korekce: čím dál od aktuálního času, tím méně korigujeme forecast +CORRECTION_DECAY_SLOTS = 16 # po 16 slotech (4h) klesne korekce na 0 + + +# ============================================================ +# Datové třídy (lze nahradit pydantic modely) +# ============================================================ + +@dataclass +class PlanningSlot: + interval_start: datetime + buy_price: float # Kč/kWh + sell_price: float # Kč/kWh + pv_a_forecast_w: int # W – pole A (řiditelné) + pv_b_forecast_w: int # W – pole B (zelený bonus, pevné) + load_baseline_w: int # W – predikce bazální spotřeby + ev1_connected: bool + ev2_connected: bool + + +@dataclass +class DispatchResult: + interval_start: datetime + battery_setpoint_w: int # kladné = nabíjení, záporné = vybíjení + battery_soc_target: float # % SoC na konci intervalu + grid_setpoint_w: int # kladné = import, záporné = export + ev1_setpoint_w: Optional[int] + ev2_setpoint_w: Optional[int] + ev1_via_bat_w: int + ev2_via_bat_w: int + heat_pump_enabled: bool + heat_pump_setpoint_w: int + pv_a_curtailed_w: int + expected_cost_czk: float + effective_buy_price: float + effective_sell_price: float + + +# ============================================================ +# Korekce forecastu na základě skutečné výroby +# ============================================================ + +async def compute_correction_factor( + site_id: int, + now: datetime, + db, + window_h: float = CORRECTION_WINDOW_H, +) -> tuple[float, dict]: + """ + Spočítá korekční faktor FVE forecastu z posledních window_h hodin. + + Vrátí (factor, log_data) kde factor je v rozsahu [CORRECTION_MIN_CLAMP, CORRECTION_MAX_CLAMP]. + factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný. + """ + window_start = now - timedelta(hours=window_h) + + # Skutečná výroba za okno (z telemetrie) + actual = await db.fetchval(""" + SELECT COALESCE(SUM(pv_power_w) * 0.25 / 1000.0, 0) -- kWh + FROM ems.telemetry_inverter + WHERE site_id = $1 + AND measured_at >= $2 AND measured_at < $3 + """, site_id, window_start, now) + + # Předpovídaná výroba za stejné okno (z nejnovějšího forecastu který platil tehdy) + forecast = await db.fetchval(""" + SELECT COALESCE(SUM(fpi.power_w) * 0.25 / 1000.0, 0) + FROM ems.forecast_pv_interval fpi + JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id + WHERE fpr.site_id = $1 + AND fpi.interval_start >= $2 AND fpi.interval_start < $3 + AND fpr.status = 'ok' + AND fpr.created_at = ( + SELECT MAX(fpr2.created_at) + FROM ems.forecast_pv_run fpr2 + WHERE fpr2.site_id = $1 AND fpr2.status = 'ok' + AND fpr2.created_at <= $2 + ) + """, site_id, window_start, now) + + log_data = { + "window_start": window_start, + "window_end": now, + "actual_pv_wh": actual * 1000, + "forecast_pv_wh": forecast * 1000, + } + + # Pokud forecast nebo actual jsou příliš malé (noc, <0.1 kWh) → žádná korekce + if forecast < 0.1 or actual < 0.05: + log_data["correction_factor"] = 1.0 + log_data["reason"] = "insufficient_data" + return 1.0, log_data + + raw_factor = actual / forecast + factor = max(CORRECTION_MIN_CLAMP, min(CORRECTION_MAX_CLAMP, raw_factor)) + + log_data["correction_factor"] = factor + log_data["raw_factor"] = raw_factor + return factor, log_data + + +def apply_forecast_correction( + slots: list[PlanningSlot], + now: datetime, + factor: float, + decay_slots: int = CORRECTION_DECAY_SLOTS, +) -> list[PlanningSlot]: + """ + Aplikuje korekční faktor na FVE forecast zbývajících slotů. + Korekce se lineárně utlumuje: na 1. slotu plná korekce, + na decay_slots-tém slotu žádná korekce. + + Příklad: factor=0.85, slot 0 → pv_a *= 0.85, slot 8 → pv_a *= 0.925, slot 16+ → žádná korekce + """ + corrected = [] + for i, slot in enumerate(slots): + if factor == 1.0 or i >= decay_slots: + corrected.append(slot) + continue + + # Lineární útlum: weight klesá od 1.0 (slot 0) do 0.0 (slot decay_slots) + weight = 1.0 - (i / decay_slots) + effective_factor = 1.0 + (factor - 1.0) * weight + + corrected.append( + replace( + slot, + pv_a_forecast_w=max(0, int(slot.pv_a_forecast_w * effective_factor)), + pv_b_forecast_w=max(0, int(slot.pv_b_forecast_w * effective_factor)), + ) + ) + + return corrected + + +# ============================================================ +# LP Solver +# ============================================================ + +def solve_dispatch( + slots: list[PlanningSlot], + battery, + heat_pump, + grid, + ev_sessions: list, # aktivní EV sessions [ev1_session, ev2_session] + vehicles: list, # [vehicle1, vehicle2] + current_soc_wh: float, + current_tuv_temp_c: float, +) -> tuple[list[DispatchResult], int]: + """ + LP solver pro dispatch optimalizaci. + Vrátí (výsledky, solver_duration_ms). + """ + T = len(slots) + EV = len(vehicles) # počet EV (typicky 2) + + EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency) + + prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize) + + # --- Proměnné --- + gi = [pulp.LpVariable(f"gi_{t}", 0, grid.max_import_power_w) for t in range(T)] + ge = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w) for t in range(T)] + bc = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_power_w) for t in range(T)] + bd = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)] + soc = [pulp.LpVariable(f"soc_{t}", battery.reserve_soc_wh, battery.soc_max_wh) for t in range(T)] + ca = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)] + hp = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)] + + # EV proměnné per vozidlo + ev_direct = [[pulp.LpVariable(f"evd_{e}_{t}", 0, + min(vehicles[e].max_charge_power_w, grid.max_import_power_w)) + for t in range(T)] for e in range(EV)] + ev_via_bat = [[pulp.LpVariable(f"evb_{e}_{t}", 0, + vehicles[e].max_charge_power_w) + for t in range(T)] for e in range(EV)] + + # --- Účelová funkce --- + prob += pulp.lpSum( + gi[t] * slots[t].buy_price * INTERVAL_H / 1000 + - ge[t] * slots[t].sell_price * INTERVAL_H / 1000 + + (bc[t] + bd[t]) * battery.degradation_cost_czk_kwh * INTERVAL_H / 1000 + + pulp.lpSum( + ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000 + + ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000 + for e in range(EV) + ) + + ca[t] * CURTAILMENT_PENALTY + for t in range(T) + ) + + # --- Omezení --- + for t in range(T): + s = slots[t] + pv_a_net = s.pv_a_forecast_w - ca[t] + + ev_total_t = pulp.lpSum(ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV)) + + # Energetická bilance + prob += ( + pv_a_net + s.pv_b_forecast_w + gi[t] + bd[t] + == s.load_baseline_w + ev_total_t + hp[t] + bc[t] + ge[t] + ) + + # SoC kontinuita + soc_prev = current_soc_wh if t == 0 else soc[t - 1] + prob += soc[t] == ( + soc_prev + + bc[t] * battery.charge_efficiency * INTERVAL_H + - bd[t] / battery.discharge_efficiency * INTERVAL_H + ) + + # ev_via_bat kryto z discharge + prob += pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t] + + # Záporná prodejní cena → zakázat export + if s.sell_price < 0: + prob += ge[t] == 0 + + # Záporná nákupní cena → cap import na reálnou spotřebu + if s.buy_price < 0: + prob += gi[t] <= ( + battery.max_charge_power_w + + sum(v.max_charge_power_w for v in vehicles) + + heat_pump.rated_heating_power_w + ) + + # EV – limity a připojení + for e in range(EV): + connected = ( + (e == 0 and s.ev1_connected) or + (e == 1 and s.ev2_connected) + ) + if not connected: + prob += ev_direct[e][t] == 0 + prob += ev_via_bat[e][t] == 0 + else: + prob += ev_direct[e][t] + ev_via_bat[e][t] <= vehicles[e].max_charge_power_w + + # Deadline constraints pro EV + for e, session in enumerate(ev_sessions): + if session and session.target_deadline and session.energy_needed_wh > 0: + t_dl = next( + (t for t, s in enumerate(slots) if s.interval_start >= session.target_deadline), + T - 1 + ) + prob += pulp.lpSum( + (ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H + for t in range(t_dl + 1) + if (e == 0 and slots[t].ev1_connected) or (e == 1 and slots[t].ev2_connected) + ) >= session.energy_needed_wh + + # Nouzový ohřev TUV + if current_tuv_temp_c < heat_pump.tuv_min_temp_c: + prob += hp[0] >= heat_pump.rated_heating_power_w * 0.8 + + # --- Řešení --- + t_start = time.monotonic() + solver = HiGHS_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT) + status = prob.solve(solver) + duration_ms = int((time.monotonic() - t_start) * 1000) + + if pulp.LpStatus[status] != 'Optimal': + raise RuntimeError(f"Solver: {pulp.LpStatus[status]}") + + # --- Post-processing --- + results = [] + for t in range(T): + hp_raw = pulp.value(hp[t]) + hp_on = hp_raw > heat_pump.rated_heating_power_w * 0.3 + batt_w = round(pulp.value(bc[t]) - pulp.value(bd[t])) + grid_w = round(pulp.value(gi[t]) - pulp.value(ge[t])) + soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1) + + cost = ( + pulp.value(gi[t]) * slots[t].buy_price * INTERVAL_H / 1000 + - pulp.value(ge[t]) * slots[t].sell_price * INTERVAL_H / 1000 + ) + + results.append(DispatchResult( + interval_start = slots[t].interval_start, + battery_setpoint_w = batt_w, + battery_soc_target = soc_pct, + grid_setpoint_w = grid_w, + ev1_setpoint_w = round(pulp.value(ev_direct[0][t]) + pulp.value(ev_via_bat[0][t])) + if slots[t].ev1_connected else None, + ev2_setpoint_w = round(pulp.value(ev_direct[1][t]) + pulp.value(ev_via_bat[1][t])) + if slots[t].ev2_connected else None, + ev1_via_bat_w = round(pulp.value(ev_via_bat[0][t])), + ev2_via_bat_w = round(pulp.value(ev_via_bat[1][t])), + heat_pump_enabled = hp_on, + heat_pump_setpoint_w = heat_pump.rated_heating_power_w if hp_on else 0, + pv_a_curtailed_w = round(pulp.value(ca[t])), + expected_cost_czk = round(cost, 4), + effective_buy_price = slots[t].buy_price, + effective_sell_price = slots[t].sell_price, + )) + + return results, duration_ms + + +# ============================================================ +# Denní plán (15:00) +# ============================================================ + +async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily") -> tuple[int, int]: + """ + Hlavní denní plánování. Spouštět v 15:00 po importu cen (14:00) + a aktualizaci forecastu (14:30). + Horizont: od začátku aktuálního 15min slotu do +36h. + """ + now = datetime.now(timezone.utc) + horizon_from = _current_slot_start(now) + horizon_to = horizon_from + timedelta(hours=HORIZON_HOURS) + + 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 + ) + + results, duration_ms = solve_dispatch( + slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp + ) + + run_id = await _save_planning_run( + site_id, + results, + horizon_from, + horizon_to, + run_type="daily", + triggered_by=triggered_by, + replan_from=None, + soc_wh=soc_wh, + duration_ms=duration_ms, + correction=1.0, + db=db, + ) + logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms") + return run_id, duration_ms + + +# ============================================================ +# Rolling replan (každých 15min) +# ============================================================ + +async def run_rolling_replan( + site_id: int, + db, + *, + triggered_by: str = "scheduler:rolling", + allow_skip: bool = True, +) -> tuple[Optional[int], Optional[int]]: + """ + Rolling replan každých 15 minut. + 1. Zjistí aktuální SoC baterie z telemetrie + 2. Spočítá korekční faktor FVE forecastu z poslední hodiny + 3. Aplikuje korekci na forecast zbytku dne (s útlumem) + 4. Spustí solver pro zbývající horizont aktivního plánu + 5. Uloží jako nový planning_run (aktivní plán se stane superseded) + + Pokud allow_skip=True (scheduler) a horizont je vyčerpaný → vrátí (None, None). + Pokud allow_skip=False (API) → spustí denní plán jako náhradu. + """ + now = datetime.now(timezone.utc) + replan_from = _current_slot_start(now) + + active_run = await db.fetchrow(""" + SELECT id, horizon_end FROM ems.planning_run + WHERE site_id = $1 AND status = 'active' + ORDER BY created_at DESC LIMIT 1 + """, site_id) + + if not active_run: + logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan") + return await run_daily_plan(site_id, db, triggered_by=triggered_by) + + horizon_to = active_run["horizon_end"] + + if (horizon_to - replan_from).total_seconds() < 1800: + if allow_skip: + logger.info(f"[site={site_id}] Rolling replan: horizon almost exhausted, skipping") + return None, None + logger.info(f"[site={site_id}] Rolling replan: horizon exhausted, running daily plan") + return await run_daily_plan(site_id, db, triggered_by=triggered_by) + + logger.info(f"[site={site_id}] Rolling replan from {replan_from} → {horizon_to}") + + battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp = await _load_site_context( + site_id, db + ) + + 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) + + results, duration_ms = solve_dispatch( + slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp + ) + + run_id = await _save_planning_run( + site_id, + results, + replan_from, + horizon_to, + run_type="rolling", + triggered_by=triggered_by, + replan_from=replan_from, + soc_wh=soc_wh, + duration_ms=duration_ms, + correction=correction_factor, + db=db, + ) + + await db.execute( + """ + INSERT INTO ems.forecast_correction_log + (site_id, window_start, window_end, actual_pv_wh, forecast_pv_wh, + correction_factor, applied_to_run_id) + VALUES ($1,$2,$3,$4,$5,$6,$7) + """, + site_id, + correction_log["window_start"], + correction_log["window_end"], + correction_log.get("actual_pv_wh"), + correction_log.get("forecast_pv_wh"), + correction_factor, + run_id, + ) + + logger.info( + f"[site={site_id}] Rolling replan done in {duration_ms} ms " + f"(correction={correction_factor:.3f})" + ) + return run_id, duration_ms + + +async def run_plan_api(site_id: int, db, plan_type: str, 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": + return await run_daily_plan(site_id, db, triggered_by=triggered_by) + if pt == "rolling": + rid, ms = await run_rolling_replan( + site_id, db, triggered_by=triggered_by, allow_skip=False + ) + if rid is None or ms is None: + raise RuntimeError("Rolling replan did not return a run") + return rid, ms + raise ValueError(f"Unknown plan_type: {plan_type!r} (use daily or rolling)") + + +# ============================================================ +# Pomocné funkce +# ============================================================ + +def _current_slot_start(dt: datetime) -> datetime: + """Zaokrouhlí čas dolů na začátek aktuálního 15min slotu.""" + minute = (dt.minute // 15) * 15 + return dt.replace(minute=minute, second=0, microsecond=0) + + +def _ev_session_ctx(row) -> Optional[SimpleNamespace]: + """Kontext deadline constraintu pro jedno EV (nebo None).""" + if row is None or row["target_deadline"] is None: + return None + cap_kwh = row["veh_cap_kwh"] + if cap_kwh is None: + return None + cap_wh = float(cap_kwh) * 1000.0 + tgt = row["target_soc_pct"] + if tgt is None: + tgt = row["default_target_soc_pct"] + if tgt is None: + return None + tgt_f = float(tgt) + soc0 = row["soc_at_connect_pct"] + if soc0 is None: + return None + needed_wh = (tgt_f - float(soc0)) / 100.0 * cap_wh + delivered = float(row["energy_delivered_wh"] or 0) + remaining = max(0.0, needed_wh - delivered) + if remaining <= 0: + return None + return SimpleNamespace( + target_deadline=row["target_deadline"], + energy_needed_wh=remaining, + ) + + +async def _load_site_context(site_id: int, db): + """ + Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC a TUV pro solver. + """ + brow = await db.fetchrow( + """ + SELECT bat.usable_capacity_wh, + bat.reserve_soc_percent, + bat.max_soc_percent, + bat.charge_efficiency, + bat.discharge_efficiency, + bat.degradation_cost_czk_kwh, + inv.max_charge_power_w, + inv.max_discharge_power_w + FROM ems.asset_battery bat + JOIN ems.asset_inverter inv ON inv.id = bat.inverter_id AND inv.site_id = bat.site_id + WHERE bat.site_id = $1 + ORDER BY bat.id + LIMIT 1 + """, + site_id, + ) + if brow is None: + raise RuntimeError(f"No asset_battery for site_id={site_id}") + + uc = float(brow["usable_capacity_wh"]) + reserve_wh = float(brow["reserve_soc_percent"]) / 100.0 * uc + soc_max_wh = float(brow["max_soc_percent"]) / 100.0 * uc + battery = SimpleNamespace( + usable_capacity_wh=uc, + reserve_soc_wh=reserve_wh, + soc_max_wh=soc_max_wh, + charge_efficiency=float(brow["charge_efficiency"]), + discharge_efficiency=float(brow["discharge_efficiency"]), + degradation_cost_czk_kwh=float(brow["degradation_cost_czk_kwh"]), + max_charge_power_w=int(brow["max_charge_power_w"]), + max_discharge_power_w=int(brow["max_discharge_power_w"]), + ) + + hrow = await db.fetchrow( + """ + SELECT COALESCE(rated_heating_power_w, 8000) AS rated_heating_power_w, + COALESCE(tuv_min_temp_c, 45) AS tuv_min_temp_c + FROM ems.asset_heat_pump + WHERE site_id = $1 + ORDER BY id + LIMIT 1 + """, + site_id, + ) + if hrow is None: + heat_pump = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=0.0) + else: + hp_w = int(hrow["rated_heating_power_w"]) + heat_pump = SimpleNamespace( + rated_heating_power_w=max(hp_w, 0), + tuv_min_temp_c=float(hrow["tuv_min_temp_c"]), + ) + + grow = await db.fetchrow( + """ + SELECT max_import_power_w, max_export_power_w + FROM ems.site_grid_connection + WHERE site_id = $1 + ORDER BY id + LIMIT 1 + """, + site_id, + ) + if grow is None: + raise RuntimeError(f"No site_grid_connection for site_id={site_id}") + grid = SimpleNamespace( + max_import_power_w=int(grow["max_import_power_w"]), + max_export_power_w=int(grow["max_export_power_w"]), + ) + + vrows = await db.fetch( + """ + SELECT v.battery_capacity_kwh, + v.max_charge_power_w, + v.default_target_soc_pct, + ch.code AS charger_code + FROM ems.asset_vehicle v + JOIN ems.asset_ev_charger ch ON ch.id = v.default_charger_id + WHERE v.site_id = $1 + AND ch.code IN ('ev-charger-1', 'ev-charger-2') + ORDER BY ch.code + """, + site_id, + ) + vehicles: list[SimpleNamespace] = [ + SimpleNamespace( + max_charge_power_w=int(r["max_charge_power_w"]), + battery_capacity_kwh=float(r["battery_capacity_kwh"]), + default_target_soc_pct=float(r["default_target_soc_pct"]), + ) + for r in vrows + ] + while len(vehicles) < 2: + vehicles.append( + SimpleNamespace( + max_charge_power_w=0, + battery_capacity_kwh=1.0, + default_target_soc_pct=80.0, + ) + ) + + srows = await db.fetch( + """ + SELECT es.target_deadline, + es.target_soc_pct, + es.soc_at_connect_pct, + es.energy_delivered_wh, + ch.code AS charger_code, + v.battery_capacity_kwh AS veh_cap_kwh, + v.default_target_soc_pct + FROM ems.ev_session es + JOIN ems.asset_ev_charger ch ON ch.id = es.charger_id + LEFT JOIN ems.asset_vehicle v ON v.id = es.vehicle_id + WHERE es.site_id = $1 + AND es.session_end IS NULL + """, + site_id, + ) + by_charger = {r["charger_code"]: r for r in srows} + ev_sessions = [ + _ev_session_ctx(by_charger.get("ev-charger-1")), + _ev_session_ctx(by_charger.get("ev-charger-2")), + ] + + soc_pct = await db.fetchval( + """ + SELECT battery_soc_percent + FROM ems.telemetry_inverter + WHERE site_id = $1 + ORDER BY measured_at DESC + LIMIT 1 + """, + site_id, + ) + if soc_pct is None: + soc_wh = reserve_wh + else: + soc_wh = float(soc_pct) / 100.0 * uc + soc_wh = max(reserve_wh, min(soc_wh, soc_max_wh)) + + tuv = await db.fetchval( + """ + SELECT tuv_tank_temp_c + FROM ems.telemetry_heat_pump + WHERE site_id = $1 + ORDER BY measured_at DESC + LIMIT 1 + """, + site_id, + ) + tuv_temp = float(tuv) if tuv is not None else 50.0 + + return battery, heat_pump, grid, vehicles, ev_sessions, soc_wh, tuv_temp + + +async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]: + """Načte 15min sloty s cenami, forecasty a stavem EV z DB.""" + rows = await db.fetch(""" + SELECT + ep.interval_start, + ep.effective_buy_price_czk_kwh AS buy_price, + ep.effective_sell_price_czk_kwh AS sell_price, + 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 + FROM ems.vw_site_effective_price ep + -- FVE pole A forecast + 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 = $1 AND apa.code = 'pv-a' + AND fpi.interval_start = ep.interval_start AND fpr.status = 'ok' + ORDER BY fpr.created_at DESC LIMIT 1 + ) fpi_a ON true + -- FVE pole B forecast + 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 = $1 AND apa.code = 'pv-b' + AND fpi.interval_start = ep.interval_start AND fpr.status = 'ok' + ORDER BY fpr.created_at DESC LIMIT 1 + ) fpi_b ON true + -- Bazální spotřeba + LEFT JOIN ems.consumption_baseline_interval cbi + ON cbi.site_id = $1 AND cbi.interval_start = ep.interval_start + AND cbi.data_type = 'forecast' + -- Stav EV nabíječek (aktuální, pro celý horizont stejný) + LEFT JOIN LATERAL ( + SELECT t.status + FROM ems.telemetry_ev_charger t + JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id + WHERE t.site_id = $1 AND ch.code = 'ev-charger-1' + ORDER BY t.measured_at DESC LIMIT 1 + ) ev1 ON true + LEFT JOIN LATERAL ( + SELECT t.status + FROM ems.telemetry_ev_charger t + JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id + WHERE t.site_id = $1 AND ch.code = 'ev-charger-2' + ORDER BY t.measured_at DESC LIMIT 1 + ) ev2 ON true + WHERE ep.site_id = $1 + AND ep.interval_start >= $2 AND ep.interval_start < $3 + ORDER BY ep.interval_start + """, site_id, from_dt, to_dt) + + out: list[PlanningSlot] = [] + for r in rows: + d = dict(r) + out.append( + PlanningSlot( + interval_start=d["interval_start"], + buy_price=float(d["buy_price"]), + sell_price=float(d["sell_price"]), + pv_a_forecast_w=int(d["pv_a_forecast_w"] or 0), + pv_b_forecast_w=int(d["pv_b_forecast_w"] or 0), + load_baseline_w=int(d["load_baseline_w"] or 0), + ev1_connected=bool(d["ev1_connected"]), + ev2_connected=bool(d["ev2_connected"]), + ) + ) + return out + + +async def _save_planning_run( + site_id, results, horizon_from, horizon_to, + run_type, triggered_by, replan_from, + soc_wh, duration_ms, correction, db +) -> int: + """Uloží výsledky solveru jako nový planning_run, deaktivuje předchozí.""" + run_id = await db.fetchval(""" + INSERT INTO ems.planning_run + (site_id, horizon_start, horizon_end, status, + run_type, triggered_by, replan_from, + soc_at_replan_wh, solver_duration_ms, forecast_correction_factor) + VALUES ($1,$2,$3,'draft',$4,$5,$6,$7,$8,$9) + RETURNING id + """, site_id, horizon_from, horizon_to, + run_type, triggered_by, replan_from, + soc_wh, duration_ms, correction) + + # Bulk insert výsledků + await db.executemany(""" + INSERT INTO ems.planning_interval + (run_id, interval_start, + battery_setpoint_w, battery_soc_target_pct, + grid_setpoint_w, + ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w, + heat_pump_enabled, heat_pump_setpoint_w, + pv_a_curtailed_w, expected_cost_czk, + effective_buy_price, effective_sell_price) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + """, [ + (run_id, r.interval_start, + r.battery_setpoint_w, r.battery_soc_target, + r.grid_setpoint_w, + r.ev1_setpoint_w, r.ev2_setpoint_w, r.ev1_via_bat_w, r.ev2_via_bat_w, + r.heat_pump_enabled, r.heat_pump_setpoint_w, + r.pv_a_curtailed_w, r.expected_cost_czk, + r.effective_buy_price, r.effective_sell_price) + for r in results + ]) + + # Aktivovat nový plán, supersede předchozí + await db.execute(""" + UPDATE ems.planning_run SET status = 'superseded' + WHERE site_id = $1 AND status = 'active' AND id <> $2 + """, site_id, run_id) + + await db.execute( + "UPDATE ems.planning_run SET status = 'active' WHERE id = $1", run_id + ) + + return run_id diff --git a/db/migration/V001__init_schema.sql b/db/migration/V001__init_schema.sql new file mode 100644 index 0000000..8f899d6 --- /dev/null +++ b/db/migration/V001__init_schema.sql @@ -0,0 +1,608 @@ +-- ============================================================= +-- V001__init_schema.sql +-- EMS Platform – inicializace schématu a všech tabulek +-- ============================================================= + +CREATE SCHEMA IF NOT EXISTS ems; + +-- ============================================================ +-- LOKALITY +-- ============================================================ + +CREATE TABLE ems.site ( + id SERIAL PRIMARY KEY, + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'Europe/Prague', + latitude NUMERIC(9,6), + longitude NUMERIC(9,6), + active BOOLEAN NOT NULL DEFAULT true, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE ems.site IS 'Lokalita / jeden objekt v systému EMS.'; +COMMENT ON COLUMN ems.site.id IS 'Primární klíč lokality.'; +COMMENT ON COLUMN ems.site.code IS 'Krátký unikátní kód lokality. Příklad: home-01.'; +COMMENT ON COLUMN ems.site.name IS 'Lidsky čitelný název lokality.'; +COMMENT ON COLUMN ems.site.timezone IS 'Časová zóna IANA. Výchozí Europe/Prague.'; +COMMENT ON COLUMN ems.site.latitude IS 'Zeměpisná šířka – vstup pro weather API a výpočty irradiance.'; +COMMENT ON COLUMN ems.site.longitude IS 'Zeměpisná délka – vstup pro weather API a výpočty irradiance.'; +COMMENT ON COLUMN ems.site.active IS 'Pokud false, lokalita se přeskočí při plánování a sběru dat.'; +COMMENT ON COLUMN ems.site.notes IS 'Volné poznámky.'; +COMMENT ON COLUMN ems.site.created_at IS 'Čas vytvoření záznamu.'; + +-- ------------------------------------------------------------ + +CREATE TABLE ems.site_endpoint ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + endpoint_type TEXT NOT NULL, + host TEXT NOT NULL, + port INT, + protocol TEXT, + unit_id INT, + auth_reference TEXT, + enabled BOOLEAN NOT NULL DEFAULT true, + notes TEXT +); + +COMMENT ON TABLE ems.site_endpoint IS 'Komunikační endpointy lokality. Jedna lokalita může mít více endpointů různých typů.'; +COMMENT ON COLUMN ems.site_endpoint.id IS 'Primární klíč endpointu.'; +COMMENT ON COLUMN ems.site_endpoint.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.site_endpoint.endpoint_type IS 'Typ endpointu: modbus_tcp, loxone_http, http_api.'; +COMMENT ON COLUMN ems.site_endpoint.host IS 'IP adresa nebo hostname cílového zařízení (Waveshare, Loxone).'; +COMMENT ON COLUMN ems.site_endpoint.port IS 'TCP port. Modbus TCP typicky 502, Loxone 80.'; +COMMENT ON COLUMN ems.site_endpoint.protocol IS 'Protokol: modbus_tcp, http, https.'; +COMMENT ON COLUMN ems.site_endpoint.unit_id IS 'Modbus Unit ID (slave address). Relevantní pro modbus_tcp.'; +COMMENT ON COLUMN ems.site_endpoint.auth_reference IS 'Název env proměnné nebo secret s přihlašovacími údaji.'; +COMMENT ON COLUMN ems.site_endpoint.enabled IS 'Pokud false, endpoint se nepoužívá.'; +COMMENT ON COLUMN ems.site_endpoint.notes IS 'Volné poznámky.'; + +-- ------------------------------------------------------------ + +CREATE TABLE ems.site_market_config ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + purchase_pricing_mode TEXT NOT NULL DEFAULT 'spot', + sale_pricing_mode TEXT NOT NULL DEFAULT 'spot', + buy_margin_fixed_czk NUMERIC(10,4) NOT NULL DEFAULT 0, + buy_margin_percent NUMERIC(6,4) NOT NULL DEFAULT 0, + sell_margin_fixed_czk NUMERIC(10,4) NOT NULL DEFAULT 0, + sell_margin_percent NUMERIC(6,4) NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'CZK', + valid_from TIMESTAMPTZ NOT NULL, + valid_to TIMESTAMPTZ, + notes TEXT +); + +COMMENT ON TABLE ems.site_market_config IS 'Obchodní konfigurace lokality s maržemi. valid_from/valid_to umožňuje historii změn.'; +COMMENT ON COLUMN ems.site_market_config.id IS 'Primární klíč.'; +COMMENT ON COLUMN ems.site_market_config.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.site_market_config.purchase_pricing_mode IS 'Režim nákupní ceny: spot, fixed, hybrid.'; +COMMENT ON COLUMN ems.site_market_config.sale_pricing_mode IS 'Režim prodejní ceny: spot, fixed, hybrid.'; +COMMENT ON COLUMN ems.site_market_config.buy_margin_fixed_czk IS 'Fixní nákupní marže Kč/kWh přičítaná k raw ceně.'; +COMMENT ON COLUMN ems.site_market_config.buy_margin_percent IS 'Procentní nákupní marže aplikovaná na raw cenu.'; +COMMENT ON COLUMN ems.site_market_config.sell_margin_fixed_czk IS 'Fixní prodejní marže Kč/kWh. Záporná = srážka z prodejní ceny.'; +COMMENT ON COLUMN ems.site_market_config.sell_margin_percent IS 'Procentní prodejní marže.'; +COMMENT ON COLUMN ems.site_market_config.currency IS 'Měna konfigurace.'; +COMMENT ON COLUMN ems.site_market_config.valid_from IS 'Začátek platnosti konfigurace.'; +COMMENT ON COLUMN ems.site_market_config.valid_to IS 'Konec platnosti. NULL = aktuálně platný záznam.'; +COMMENT ON COLUMN ems.site_market_config.notes IS 'Volné poznámky.'; + +-- ------------------------------------------------------------ + +CREATE TABLE ems.site_grid_connection ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id) UNIQUE, + max_import_power_w INT NOT NULL, + max_export_power_w INT NOT NULL DEFAULT 0, + no_export BOOLEAN NOT NULL DEFAULT false, + reserved_capacity_w INT NOT NULL DEFAULT 0, + notes TEXT +); + +COMMENT ON TABLE ems.site_grid_connection IS 'Síťová omezení připojení lokality k distribuční síti.'; +COMMENT ON COLUMN ems.site_grid_connection.id IS 'Primární klíč.'; +COMMENT ON COLUMN ems.site_grid_connection.site_id IS 'Vazba na lokalitu. Každá lokalita má nejvýše jeden záznam.'; +COMMENT ON COLUMN ems.site_grid_connection.max_import_power_w IS 'Maximální povolený odběr ze sítě v W dle jističe/smlouvy.'; +COMMENT ON COLUMN ems.site_grid_connection.max_export_power_w IS 'Maximální povolený export do sítě v W. 0 = export zakázán.'; +COMMENT ON COLUMN ems.site_grid_connection.no_export IS 'Pokud true, export do sítě je zakázán bez ohledu na max_export_power_w.'; +COMMENT ON COLUMN ems.site_grid_connection.reserved_capacity_w IS 'Výkon rezervovaný pro interní potřeby, odečítá se z dostupné kapacity.'; +COMMENT ON COLUMN ems.site_grid_connection.notes IS 'Volné poznámky.'; + +-- ============================================================ +-- AKTIVA +-- ============================================================ + +CREATE TABLE ems.asset_inverter ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + code TEXT NOT NULL, + manufacturer TEXT, + model TEXT, + endpoint_id INT REFERENCES ems.site_endpoint(id), + max_charge_power_w INT, + max_discharge_power_w INT, + max_export_power_w INT, + controllable BOOLEAN NOT NULL DEFAULT true, + notes TEXT +); + +COMMENT ON TABLE ems.asset_inverter IS 'Střídač / hybridní měnič na lokalitě.'; +COMMENT ON COLUMN ems.asset_inverter.id IS 'Primární klíč.'; +COMMENT ON COLUMN ems.asset_inverter.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.asset_inverter.code IS 'Kód aktiva, unikátní v rámci lokality.'; +COMMENT ON COLUMN ems.asset_inverter.manufacturer IS 'Výrobce. Příklad: Deye.'; +COMMENT ON COLUMN ems.asset_inverter.model IS 'Model. Příklad: SUN-20K-SG01LP1-EU.'; +COMMENT ON COLUMN ems.asset_inverter.endpoint_id IS 'Modbus TCP endpoint přes Waveshare.'; +COMMENT ON COLUMN ems.asset_inverter.max_charge_power_w IS 'Maximální nabíjecí výkon baterie v W.'; +COMMENT ON COLUMN ems.asset_inverter.max_discharge_power_w IS 'Maximální vybíjecí výkon baterie v W.'; +COMMENT ON COLUMN ems.asset_inverter.max_export_power_w IS 'Maximální výkon exportu do sítě v W.'; +COMMENT ON COLUMN ems.asset_inverter.controllable IS 'Pokud false, střídač není řízen EMS (ongridový na GEN portu).'; +COMMENT ON COLUMN ems.asset_inverter.notes IS 'Volné poznámky.'; + +-- ------------------------------------------------------------ + +CREATE TABLE ems.asset_battery ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + inverter_id INT NOT NULL REFERENCES ems.asset_inverter(id), + code TEXT NOT NULL, + usable_capacity_wh INT NOT NULL, + min_soc_percent NUMERIC(5,2) NOT NULL DEFAULT 10, + reserve_soc_percent NUMERIC(5,2) NOT NULL DEFAULT 20, + max_soc_percent NUMERIC(5,2) NOT NULL DEFAULT 95, + charge_efficiency NUMERIC(5,4) NOT NULL DEFAULT 0.95, + discharge_efficiency NUMERIC(5,4) NOT NULL DEFAULT 0.95, + degradation_cost_czk_kwh NUMERIC(8,4) NOT NULL DEFAULT 0.5 +); + +COMMENT ON TABLE ems.asset_battery IS 'Bateriový systém připojený ke střídači.'; +COMMENT ON COLUMN ems.asset_battery.id IS 'Primární klíč.'; +COMMENT ON COLUMN ems.asset_battery.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.asset_battery.inverter_id IS 'Střídač ke kterému je baterie připojena.'; +COMMENT ON COLUMN ems.asset_battery.code IS 'Kód aktiva.'; +COMMENT ON COLUMN ems.asset_battery.usable_capacity_wh IS 'Použitelná kapacita baterie v Wh bez rezerv výrobce.'; +COMMENT ON COLUMN ems.asset_battery.min_soc_percent IS 'Minimální SoC v % – absolutní spodní limit, nikdy nepřekročit.'; +COMMENT ON COLUMN ems.asset_battery.reserve_soc_percent IS 'Rezervní SoC v % – zachován pro výpadky sítě, nevyužívat v běžném plánu.'; +COMMENT ON COLUMN ems.asset_battery.max_soc_percent IS 'Maximální SoC v % – horní limit pro denní provoz.'; +COMMENT ON COLUMN ems.asset_battery.charge_efficiency IS 'Účinnost nabíjení (0–1). Typicky 0.95.'; +COMMENT ON COLUMN ems.asset_battery.discharge_efficiency IS 'Účinnost vybíjení (0–1). Typicky 0.95.'; +COMMENT ON COLUMN ems.asset_battery.degradation_cost_czk_kwh IS 'Odhadovaný náklad degradace v Kč/kWh cyklu. Vstupuje do optimalizace jako cena za použití baterie.'; + +-- ------------------------------------------------------------ + +CREATE TABLE ems.asset_pv_array ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + inverter_id INT REFERENCES ems.asset_inverter(id), + code TEXT NOT NULL, + name TEXT, + nominal_power_wp INT NOT NULL, + azimuth_deg NUMERIC(6,2), + tilt_deg NUMERIC(5,2), + module_count INT, + shading_factor NUMERIC(4,3) NOT NULL DEFAULT 1.0, + controllable BOOLEAN NOT NULL DEFAULT false, + notes TEXT +); + +COMMENT ON TABLE ems.asset_pv_array IS 'FVE pole. Každé pole jako samostatný záznam – klíčové pro přesnou predikci výroby.'; +COMMENT ON COLUMN ems.asset_pv_array.id IS 'Primární klíč.'; +COMMENT ON COLUMN ems.asset_pv_array.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.asset_pv_array.inverter_id IS 'Střídač ke kterému je pole připojeno.'; +COMMENT ON COLUMN ems.asset_pv_array.code IS 'Kód pole, unikátní v rámci lokality. Příklad: pv-a, pv-b.'; +COMMENT ON COLUMN ems.asset_pv_array.name IS 'Popis pole. Příklad: Jižní střecha.'; +COMMENT ON COLUMN ems.asset_pv_array.nominal_power_wp IS 'Nominální výkon pole v Wp (součet výkonů panelů).'; +COMMENT ON COLUMN ems.asset_pv_array.azimuth_deg IS 'Azimut panelů ve stupních. 0=jih, 90=západ, -90=východ.'; +COMMENT ON COLUMN ems.asset_pv_array.tilt_deg IS 'Sklon panelů od horizontály ve stupních.'; +COMMENT ON COLUMN ems.asset_pv_array.module_count IS 'Počet panelů v poli.'; +COMMENT ON COLUMN ems.asset_pv_array.shading_factor IS 'Koeficient stínění (0–1). 1.0 = bez stínění.'; +COMMENT ON COLUMN ems.asset_pv_array.controllable IS 'Pokud false, pole není řízeno EMS (ongridový autonomní střídač na GEN portu).'; +COMMENT ON COLUMN ems.asset_pv_array.notes IS 'Volné poznámky.'; + +-- ------------------------------------------------------------ + +CREATE TABLE ems.asset_ev_charger ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + code TEXT NOT NULL, + manufacturer TEXT, + model TEXT, + endpoint_id INT REFERENCES ems.site_endpoint(id), + max_power_w INT NOT NULL, + min_power_w INT NOT NULL DEFAULT 1380, + phases INT NOT NULL DEFAULT 3, + connector_count INT NOT NULL DEFAULT 1, + schedulable BOOLEAN NOT NULL DEFAULT true, + notes TEXT +); + +COMMENT ON TABLE ems.asset_ev_charger IS 'EV nabíjecí stanice. Komunikace přes Modbus TCP (Waveshare převodník).'; +COMMENT ON COLUMN ems.asset_ev_charger.id IS 'Primární klíč.'; +COMMENT ON COLUMN ems.asset_ev_charger.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.asset_ev_charger.code IS 'Kód aktiva, unikátní v rámci lokality.'; +COMMENT ON COLUMN ems.asset_ev_charger.manufacturer IS 'Výrobce nabíječky. Příklad: Teltonika.'; +COMMENT ON COLUMN ems.asset_ev_charger.model IS 'Model nabíječky. Příklad: TeltoCharge 22kW.'; +COMMENT ON COLUMN ems.asset_ev_charger.endpoint_id IS 'Modbus TCP endpoint přes Waveshare převodník.'; +COMMENT ON COLUMN ems.asset_ev_charger.max_power_w IS 'Maximální výkon nabíječky v W.'; +COMMENT ON COLUMN ems.asset_ev_charger.min_power_w IS 'Minimální výkon nabíjení v W. 1380 W = 6A jednofázové minimum IEC 61851.'; +COMMENT ON COLUMN ems.asset_ev_charger.phases IS 'Počet fází nabíjení.'; +COMMENT ON COLUMN ems.asset_ev_charger.connector_count IS 'Počet konektorů na nabíječce.'; +COMMENT ON COLUMN ems.asset_ev_charger.schedulable IS 'Pokud true, nabíječka je zapojená do plánování EMS.'; +COMMENT ON COLUMN ems.asset_ev_charger.notes IS 'Volné poznámky.'; + +-- ------------------------------------------------------------ + +CREATE TABLE ems.asset_heat_pump ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + code TEXT NOT NULL, + manufacturer TEXT, + model TEXT, + endpoint_id INT REFERENCES ems.site_endpoint(id), + rated_heating_power_w INT NOT NULL, + cop_rated NUMERIC(4,2), + cop_temp_reference_c NUMERIC(5,2), + min_run_duration_min INT NOT NULL DEFAULT 30, + min_stop_duration_min INT NOT NULL DEFAULT 15, + tuv_tank_volume_l INT, + tuv_min_temp_c NUMERIC(5,2) NOT NULL DEFAULT 45, + tuv_max_temp_c NUMERIC(5,2) NOT NULL DEFAULT 60, + tuv_target_temp_c NUMERIC(5,2) NOT NULL DEFAULT 55, + tuv_temp_sensor_ref TEXT, + schedulable BOOLEAN NOT NULL DEFAULT true, + notes TEXT +); + +COMMENT ON TABLE ems.asset_heat_pump IS 'Tepelné čerpadlo s Modbus řízením (Samsung). Řízeno na základě COP, venkovní teploty a stavu zásobníku TUV.'; +COMMENT ON COLUMN ems.asset_heat_pump.id IS 'Primární klíč.'; +COMMENT ON COLUMN ems.asset_heat_pump.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.asset_heat_pump.code IS 'Kód aktiva, unikátní v rámci lokality.'; +COMMENT ON COLUMN ems.asset_heat_pump.manufacturer IS 'Výrobce tepelného čerpadla. Příklad: Samsung.'; +COMMENT ON COLUMN ems.asset_heat_pump.model IS 'Model tepelného čerpadla.'; +COMMENT ON COLUMN ems.asset_heat_pump.endpoint_id IS 'Modbus TCP endpoint přes Waveshare převodník.'; +COMMENT ON COLUMN ems.asset_heat_pump.rated_heating_power_w IS 'Jmenovitý topný výkon v W při referenčních podmínkách.'; +COMMENT ON COLUMN ems.asset_heat_pump.cop_rated IS 'Jmenovitý COP při referenční teplotě cop_temp_reference_c.'; +COMMENT ON COLUMN ems.asset_heat_pump.cop_temp_reference_c IS 'Referenční venkovní teplota pro jmenovitý COP. Typicky 7°C (norma A7/W35).'; +COMMENT ON COLUMN ems.asset_heat_pump.min_run_duration_min IS 'Minimální nepřerušená doba běhu v minutách. Chrání kompresor před krátkými cykly.'; +COMMENT ON COLUMN ems.asset_heat_pump.min_stop_duration_min IS 'Minimální doba stání po vypnutí v minutách. Ochrana kompresoru při restartu.'; +COMMENT ON COLUMN ems.asset_heat_pump.tuv_tank_volume_l IS 'Objem zásobníku TUV v litrech. Slouží k výpočtu doby ohřevu.'; +COMMENT ON COLUMN ems.asset_heat_pump.tuv_min_temp_c IS 'Minimální teplota TUV zásobníku v °C. Pod touto hodnotou se ohřev spustí bez ohledu na cenu.'; +COMMENT ON COLUMN ems.asset_heat_pump.tuv_max_temp_c IS 'Maximální teplota TUV zásobníku v °C. Nad touto hodnotou se ohřev zastaví.'; +COMMENT ON COLUMN ems.asset_heat_pump.tuv_target_temp_c IS 'Cílová teplota TUV pro normální plánovaný provoz.'; +COMMENT ON COLUMN ems.asset_heat_pump.tuv_temp_sensor_ref IS 'Název nebo kód čidla teploty zásobníku (Modbus registr nebo Loxone Virtual Output).'; +COMMENT ON COLUMN ems.asset_heat_pump.schedulable IS 'Pokud true, tepelné čerpadlo je zapojeno do plánování EMS.'; +COMMENT ON COLUMN ems.asset_heat_pump.notes IS 'Volné poznámky.'; + +-- ============================================================ +-- TRŽNÍ DATA +-- ============================================================ + +CREATE TABLE ems.market_interval_price ( + market_source TEXT NOT NULL DEFAULT 'OTE_CZ', + interval_start TIMESTAMPTZ NOT NULL, + interval_end TIMESTAMPTZ NOT NULL, + buy_raw_price_czk_kwh NUMERIC(10,6) NOT NULL, + sell_raw_price_czk_kwh NUMERIC(10,6) NOT NULL, + currency TEXT NOT NULL DEFAULT 'CZK', + imported_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (market_source, interval_start) +); + +COMMENT ON TABLE ems.market_interval_price IS 'Raw spotové ceny elektřiny OTE CZ. Sdílené pro všechny lokality, bez marží. Granularita 15 minut.'; +COMMENT ON COLUMN ems.market_interval_price.market_source IS 'Zdroj tržních dat. Příklad: OTE_CZ.'; +COMMENT ON COLUMN ems.market_interval_price.interval_start IS 'Začátek 15minutového intervalu (UTC).'; +COMMENT ON COLUMN ems.market_interval_price.interval_end IS 'Konec 15minutového intervalu (UTC).'; +COMMENT ON COLUMN ems.market_interval_price.buy_raw_price_czk_kwh IS 'Raw nákupní cena v Kč/kWh bez marží.'; +COMMENT ON COLUMN ems.market_interval_price.sell_raw_price_czk_kwh IS 'Raw prodejní referenční cena v Kč/kWh bez marží. Pro OTE CZ = DAM cena.'; +COMMENT ON COLUMN ems.market_interval_price.currency IS 'Měna cen záznamu.'; +COMMENT ON COLUMN ems.market_interval_price.imported_at IS 'Čas importu záznamu do DB. Slouží pro audit importů.'; + +-- ============================================================ +-- TELEMETRIE +-- ============================================================ + +CREATE TABLE ems.telemetry_inverter ( + site_id INT NOT NULL REFERENCES ems.site(id), + inverter_id INT NOT NULL REFERENCES ems.asset_inverter(id), + measured_at TIMESTAMPTZ NOT NULL, + pv_power_w INT, + battery_soc_percent NUMERIC(5,2), + battery_power_w INT, + battery_voltage_v NUMERIC(7,3), + grid_power_w INT, + grid_voltage_v NUMERIC(7,3), + load_power_w INT, + inverter_temp_c NUMERIC(5,2), + operating_mode TEXT, + fault_code INT, + PRIMARY KEY (inverter_id, measured_at) +); + +COMMENT ON TABLE ems.telemetry_inverter IS 'Telemetrie ze střídače Deye čtená přes Modbus TCP. 1min granularita. TimescaleDB hypertable.'; +COMMENT ON COLUMN ems.telemetry_inverter.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.telemetry_inverter.inverter_id IS 'Vazba na střídač.'; +COMMENT ON COLUMN ems.telemetry_inverter.measured_at IS 'Čas měření (UTC).'; +COMMENT ON COLUMN ems.telemetry_inverter.pv_power_w IS 'Celkový okamžitý výkon FVE v W (součet všech stringů čtených tímto střídačem, včetně GEN portu).'; +COMMENT ON COLUMN ems.telemetry_inverter.battery_soc_percent IS 'Aktuální stav nabití baterie v %.'; +COMMENT ON COLUMN ems.telemetry_inverter.battery_power_w IS 'Výkon baterie v W. Kladné = nabíjení, záporné = vybíjení.'; +COMMENT ON COLUMN ems.telemetry_inverter.battery_voltage_v IS 'Napětí bateriového systému v V.'; +COMMENT ON COLUMN ems.telemetry_inverter.grid_power_w IS 'Výkon přenosu se sítí v W. Kladné = import ze sítě, záporné = export do sítě.'; +COMMENT ON COLUMN ems.telemetry_inverter.grid_voltage_v IS 'Napětí sítě v V.'; +COMMENT ON COLUMN ems.telemetry_inverter.load_power_w IS 'Celková spotřeba objektu v W (vše za AC výstupem střídače, včetně EV a TUV).'; +COMMENT ON COLUMN ems.telemetry_inverter.inverter_temp_c IS 'Teplota střídače v °C.'; +COMMENT ON COLUMN ems.telemetry_inverter.operating_mode IS 'Provozní režim dle Modbus registru (raw hodnota pro ladění).'; +COMMENT ON COLUMN ems.telemetry_inverter.fault_code IS 'Kód chyby z Modbus registru. 0 = bez chyby.'; + +-- ------------------------------------------------------------ + +CREATE TABLE ems.telemetry_ev_charger ( + site_id INT NOT NULL REFERENCES ems.site(id), + charger_id INT NOT NULL REFERENCES ems.asset_ev_charger(id), + measured_at TIMESTAMPTZ NOT NULL, + connector_id INT NOT NULL DEFAULT 1, + status TEXT, + power_w INT, + energy_kwh NUMERIC(10,3), + current_a NUMERIC(7,3), + voltage_v NUMERIC(7,3), + session_id TEXT, + error_code TEXT, + PRIMARY KEY (charger_id, connector_id, measured_at) +); + +COMMENT ON TABLE ems.telemetry_ev_charger IS 'Telemetrie EV nabíječky Teltonika čtená přes Modbus TCP (Waveshare). 1min granularita. TimescaleDB hypertable.'; +COMMENT ON COLUMN ems.telemetry_ev_charger.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.telemetry_ev_charger.charger_id IS 'Vazba na EV nabíječku.'; +COMMENT ON COLUMN ems.telemetry_ev_charger.measured_at IS 'Čas měření (UTC).'; +COMMENT ON COLUMN ems.telemetry_ev_charger.connector_id IS 'Číslo konektoru (1-based). Teltonika TeltoCharge má 1 konektor.'; +COMMENT ON COLUMN ems.telemetry_ev_charger.status IS 'Stav konektoru dle OCPP: available, preparing, charging, suspended_ev, suspended_evse, finishing, faulted.'; +COMMENT ON COLUMN ems.telemetry_ev_charger.power_w IS 'Aktuální nabíjecí výkon v W.'; +COMMENT ON COLUMN ems.telemetry_ev_charger.energy_kwh IS 'Kumulativní energie aktuální session v kWh. Resetuje se při nové session.'; +COMMENT ON COLUMN ems.telemetry_ev_charger.current_a IS 'Nabíjecí proud v A.'; +COMMENT ON COLUMN ems.telemetry_ev_charger.voltage_v IS 'Napětí v bodě připojení v V.'; +COMMENT ON COLUMN ems.telemetry_ev_charger.session_id IS 'Identifikátor aktuální nabíjecí session z Modbus registru.'; +COMMENT ON COLUMN ems.telemetry_ev_charger.error_code IS 'Kód chyby z Modbus registru nabíječky.'; + +-- ------------------------------------------------------------ + +CREATE TABLE ems.telemetry_heat_pump ( + site_id INT NOT NULL REFERENCES ems.site(id), + heat_pump_id INT NOT NULL REFERENCES ems.asset_heat_pump(id), + measured_at TIMESTAMPTZ NOT NULL, + outdoor_temp_c NUMERIC(5,2), + water_inlet_temp_c NUMERIC(5,2), + water_outlet_temp_c NUMERIC(5,2), + tuv_tank_temp_c NUMERIC(5,2), + power_w INT, + operating_mode TEXT, + cop_actual NUMERIC(4,2), + defrost_active BOOLEAN, + alarm_code INT, + PRIMARY KEY (heat_pump_id, measured_at) +); + +COMMENT ON TABLE ems.telemetry_heat_pump IS 'Telemetrie tepelného čerpadla Samsung čtená přes Modbus TCP (Waveshare). 1min granularita. TimescaleDB hypertable.'; +COMMENT ON COLUMN ems.telemetry_heat_pump.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.telemetry_heat_pump.heat_pump_id IS 'Vazba na tepelné čerpadlo.'; +COMMENT ON COLUMN ems.telemetry_heat_pump.measured_at IS 'Čas měření (UTC).'; +COMMENT ON COLUMN ems.telemetry_heat_pump.outdoor_temp_c IS 'Venkovní teplota naměřená čerpadlem v °C. Klíčový vstup pro výpočet COP a rozhodnutí o ohřevu.'; +COMMENT ON COLUMN ems.telemetry_heat_pump.water_inlet_temp_c IS 'Teplota vody na vstupu do výměníku čerpadla v °C.'; +COMMENT ON COLUMN ems.telemetry_heat_pump.water_outlet_temp_c IS 'Teplota vody na výstupu z čerpadla v °C.'; +COMMENT ON COLUMN ems.telemetry_heat_pump.tuv_tank_temp_c IS 'Teplota TUV zásobníku v °C. Klíčový vstup pro rozhodnutí kdy ohřívat.'; +COMMENT ON COLUMN ems.telemetry_heat_pump.power_w IS 'Aktuální příkon čerpadla v W.'; +COMMENT ON COLUMN ems.telemetry_heat_pump.operating_mode IS 'Provozní režim: heating, cooling, dhw (domestic hot water), standby.'; +COMMENT ON COLUMN ems.telemetry_heat_pump.cop_actual IS 'Aktuálně vypočtený COP pokud je dostupný z Modbus registru.'; +COMMENT ON COLUMN ems.telemetry_heat_pump.defrost_active IS 'Příznak aktivního odmrazovacího cyklu. Při defrostu je skutečný příkon vyšší.'; +COMMENT ON COLUMN ems.telemetry_heat_pump.alarm_code IS 'Kód alarmu z Modbus registru. 0 = bez alarmu.'; + +-- ============================================================ +-- PREDIKCE +-- ============================================================ + +CREATE TABLE ems.forecast_pv_run ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + pv_array_id INT REFERENCES ems.asset_pv_array(id), + forecast_source TEXT NOT NULL DEFAULT 'open_meteo', + model_params JSONB, + horizon_start TIMESTAMPTZ NOT NULL, + horizon_end TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + status TEXT NOT NULL DEFAULT 'ok' +); + +COMMENT ON TABLE ems.forecast_pv_run IS 'Metadata jednoho běhu predikce výroby FVE.'; +COMMENT ON COLUMN ems.forecast_pv_run.id IS 'Primární klíč.'; +COMMENT ON COLUMN ems.forecast_pv_run.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.forecast_pv_run.pv_array_id IS 'Konkrétní FVE pole. NULL = agregovaná predikce celé lokality.'; +COMMENT ON COLUMN ems.forecast_pv_run.forecast_source IS 'Zdroj meteorologických dat: open_meteo, solcast, manual.'; +COMMENT ON COLUMN ems.forecast_pv_run.model_params IS 'Parametry predikčního modelu použité při tomto běhu (JSON).'; +COMMENT ON COLUMN ems.forecast_pv_run.horizon_start IS 'Začátek predikčního horizontu.'; +COMMENT ON COLUMN ems.forecast_pv_run.horizon_end IS 'Konec predikčního horizontu.'; +COMMENT ON COLUMN ems.forecast_pv_run.created_at IS 'Čas spuštění predikce.'; +COMMENT ON COLUMN ems.forecast_pv_run.status IS 'Stav běhu: ok, partial, failed.'; + +-- ------------------------------------------------------------ + +CREATE TABLE ems.forecast_pv_interval ( + run_id INT NOT NULL REFERENCES ems.forecast_pv_run(id), + pv_array_id INT NOT NULL REFERENCES ems.asset_pv_array(id), + interval_start TIMESTAMPTZ NOT NULL, + power_w INT NOT NULL, + irradiance_wm2 NUMERIC(8,2), + temp_c NUMERIC(5,2), + PRIMARY KEY (run_id, pv_array_id, interval_start) +); + +COMMENT ON TABLE ems.forecast_pv_interval IS 'Predikovaný výkon FVE po 15min intervalech. TimescaleDB hypertable.'; +COMMENT ON COLUMN ems.forecast_pv_interval.run_id IS 'Vazba na běh predikce.'; +COMMENT ON COLUMN ems.forecast_pv_interval.pv_array_id IS 'Konkrétní FVE pole.'; +COMMENT ON COLUMN ems.forecast_pv_interval.interval_start IS 'Začátek 15min intervalu (UTC).'; +COMMENT ON COLUMN ems.forecast_pv_interval.power_w IS 'Predikovaný výkon FVE pole v W.'; +COMMENT ON COLUMN ems.forecast_pv_interval.irradiance_wm2 IS 'GHI irradiance ze weather service v W/m² použitá při výpočtu.'; +COMMENT ON COLUMN ems.forecast_pv_interval.temp_c IS 'Predikovaná venkovní teplota v °C použitá při výpočtu.'; + +-- ------------------------------------------------------------ + +CREATE TABLE ems.forecast_weather_interval ( + site_id INT NOT NULL REFERENCES ems.site(id), + forecast_source TEXT NOT NULL DEFAULT 'open_meteo', + interval_start TIMESTAMPTZ NOT NULL, + outdoor_temp_c NUMERIC(5,2), + irradiance_wm2 NUMERIC(8,2), + cloud_cover_pct NUMERIC(5,2), + wind_speed_ms NUMERIC(6,2), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (site_id, forecast_source, interval_start) +); + +COMMENT ON TABLE ems.forecast_weather_interval IS 'Predikce počasí per lokalita, 15min granularita. Sdílený vstup pro FVE predikci i COP odhad tepelného čerpadla.'; +COMMENT ON COLUMN ems.forecast_weather_interval.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.forecast_weather_interval.forecast_source IS 'Zdroj predikce počasí.'; +COMMENT ON COLUMN ems.forecast_weather_interval.interval_start IS 'Začátek 15min intervalu (UTC).'; +COMMENT ON COLUMN ems.forecast_weather_interval.outdoor_temp_c IS 'Predikovaná venkovní teplota v °C. Klíčový vstup pro COP odhad tepelného čerpadla.'; +COMMENT ON COLUMN ems.forecast_weather_interval.irradiance_wm2 IS 'Predikovaná irradiance GHI v W/m².'; +COMMENT ON COLUMN ems.forecast_weather_interval.cloud_cover_pct IS 'Predikovaná oblačnost v %.'; +COMMENT ON COLUMN ems.forecast_weather_interval.wind_speed_ms IS 'Predikovaná rychlost větru v m/s.'; +COMMENT ON COLUMN ems.forecast_weather_interval.created_at IS 'Čas importu predikce do DB.'; + +-- ============================================================ +-- PLÁNOVÁNÍ +-- ============================================================ + +CREATE TABLE ems.planning_run ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + horizon_start TIMESTAMPTZ NOT NULL, + horizon_end TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + status TEXT NOT NULL DEFAULT 'draft', + solver_params JSONB, + notes TEXT +); + +COMMENT ON TABLE ems.planning_run IS 'Jeden plánovací běh pro konkrétní lokalitu a horizont.'; +COMMENT ON COLUMN ems.planning_run.id IS 'Primární klíč.'; +COMMENT ON COLUMN ems.planning_run.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.planning_run.horizon_start IS 'Začátek plánovaného horizontu (typicky dnešní půlnoc).'; +COMMENT ON COLUMN ems.planning_run.horizon_end IS 'Konec plánovaného horizontu (typicky zítřejší půlnoc nebo +48h).'; +COMMENT ON COLUMN ems.planning_run.created_at IS 'Čas vytvoření plánu.'; +COMMENT ON COLUMN ems.planning_run.status IS 'Stav plánu: draft (sestavován), approved (schválen), active (aktuálně platný), superseded (nahrazen novějším).'; +COMMENT ON COLUMN ems.planning_run.solver_params IS 'Parametry optimalizačního solveru použité při tomto běhu (JSON).'; +COMMENT ON COLUMN ems.planning_run.notes IS 'Volné poznámky k plánu.'; + +-- ------------------------------------------------------------ + +CREATE TABLE ems.planning_interval ( + run_id INT NOT NULL REFERENCES ems.planning_run(id), + interval_start TIMESTAMPTZ NOT NULL, + battery_setpoint_w INT, + battery_soc_target_pct NUMERIC(5,2), + grid_setpoint_w INT, + ev_charge_power_w INT, + heat_pump_enabled BOOLEAN, + heat_pump_setpoint_w INT, + expected_cost_czk NUMERIC(10,4), + effective_buy_price NUMERIC(10,6), + effective_sell_price NUMERIC(10,6), + PRIMARY KEY (run_id, interval_start) +); + +COMMENT ON TABLE ems.planning_interval IS 'Výstup optimalizace – jeden řádek = jeden 15min slot plánu.'; +COMMENT ON COLUMN ems.planning_interval.run_id IS 'Vazba na plánovací běh.'; +COMMENT ON COLUMN ems.planning_interval.interval_start IS 'Začátek 15min intervalu (UTC).'; +COMMENT ON COLUMN ems.planning_interval.battery_setpoint_w IS 'Plánovaný výkon baterie v W. Kladné = nabíjení, záporné = vybíjení.'; +COMMENT ON COLUMN ems.planning_interval.battery_soc_target_pct IS 'Cílový SoC baterie na konci intervalu v %.'; +COMMENT ON COLUMN ems.planning_interval.grid_setpoint_w IS 'Plánovaný výkon se sítí v W. Kladné = import, záporné = export.'; +COMMENT ON COLUMN ems.planning_interval.ev_charge_power_w IS 'Plánovaný agregovaný výkon nabíjení EV v W (součet všech nabíječek na site).'; +COMMENT ON COLUMN ems.planning_interval.heat_pump_enabled IS 'Zda má tepelné čerpadlo v tomto intervalu běžet.'; +COMMENT ON COLUMN ems.planning_interval.heat_pump_setpoint_w IS 'Plánovaný výkon tepelného čerpadla v W.'; +COMMENT ON COLUMN ems.planning_interval.expected_cost_czk IS 'Očekávané náklady intervalu v Kč. Záporné = příjem z prodeje.'; +COMMENT ON COLUMN ems.planning_interval.effective_buy_price IS 'Efektivní nákupní cena použitá při plánování v Kč/kWh.'; +COMMENT ON COLUMN ems.planning_interval.effective_sell_price IS 'Efektivní prodejní cena použitá při plánování v Kč/kWh.'; + +-- ============================================================ +-- AUDIT +-- ============================================================ + +CREATE TABLE ems.audit_interval ( + site_id INT NOT NULL REFERENCES ems.site(id), + interval_start TIMESTAMPTZ NOT NULL, + planning_run_id INT REFERENCES ems.planning_run(id), + actual_pv_power_w INT, + actual_battery_power_w INT, + actual_grid_power_w INT, + actual_load_power_w INT, + actual_battery_soc_pct NUMERIC(5,2), + actual_ev_power_w INT, + actual_heat_pump_power_w INT, + actual_cost_czk NUMERIC(10,4), + deviation_grid_w INT, + deviation_cost_czk NUMERIC(10,4), + PRIMARY KEY (site_id, interval_start) +); + +COMMENT ON TABLE ems.audit_interval IS 'Skutečnost vs plán po 15min intervalech. Plněno zpětně funkcí ems.fn_fill_audit_interval().'; +COMMENT ON COLUMN ems.audit_interval.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.audit_interval.interval_start IS 'Začátek 15min intervalu (UTC).'; +COMMENT ON COLUMN ems.audit_interval.planning_run_id IS 'Plán který byl v tomto intervalu aktivní.'; +COMMENT ON COLUMN ems.audit_interval.actual_pv_power_w IS 'Skutečný výkon FVE v W (průměr za 15min z telemetrie).'; +COMMENT ON COLUMN ems.audit_interval.actual_battery_power_w IS 'Skutečný výkon baterie v W (průměr za 15min).'; +COMMENT ON COLUMN ems.audit_interval.actual_grid_power_w IS 'Skutečný výkon přenosu se sítí v W (průměr za 15min).'; +COMMENT ON COLUMN ems.audit_interval.actual_load_power_w IS 'Skutečná celková spotřeba v W (průměr za 15min).'; +COMMENT ON COLUMN ems.audit_interval.actual_battery_soc_pct IS 'Skutečný SoC baterie na konci intervalu v %.'; +COMMENT ON COLUMN ems.audit_interval.actual_ev_power_w IS 'Skutečný výkon nabíjení EV v W (suma všech nabíječek, průměr za 15min).'; +COMMENT ON COLUMN ems.audit_interval.actual_heat_pump_power_w IS 'Skutečný příkon tepelného čerpadla v W (průměr za 15min).'; +COMMENT ON COLUMN ems.audit_interval.actual_cost_czk IS 'Skutečné náklady intervalu v Kč vypočtené ze skutečného grid_power a efektivní ceny.'; +COMMENT ON COLUMN ems.audit_interval.deviation_grid_w IS 'Odchylka skutečný - plánovaný výkon sítě v W.'; +COMMENT ON COLUMN ems.audit_interval.deviation_cost_czk IS 'Odchylka skutečných nákladů od plánovaných v Kč.'; + +-- ============================================================ +-- SPOTŘEBA +-- ============================================================ + +CREATE TABLE ems.consumption_baseline_interval ( + site_id INT NOT NULL REFERENCES ems.site(id), + interval_start TIMESTAMPTZ NOT NULL, + data_type TEXT NOT NULL, + power_w INT NOT NULL, + source TEXT, + PRIMARY KEY (site_id, data_type, interval_start) +); + +COMMENT ON TABLE ems.consumption_baseline_interval IS 'Bazální (neflexibilní) spotřeba po 15min intervalech. Historická skutečnost i predikce.'; +COMMENT ON COLUMN ems.consumption_baseline_interval.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.consumption_baseline_interval.interval_start IS 'Začátek 15min intervalu (UTC).'; +COMMENT ON COLUMN ems.consumption_baseline_interval.data_type IS 'Typ záznamu: actual (historická skutečnost), forecast (predikce pro plánování).'; +COMMENT ON COLUMN ems.consumption_baseline_interval.power_w IS 'Bazální spotřeba v W = celková spotřeba mínus měřitelná flexibilní zařízení.'; +COMMENT ON COLUMN ems.consumption_baseline_interval.source IS 'Metoda výpočtu: measured (z telemetrie), model_v1 (statistický model).'; + +-- ============================================================ +-- OVERRIDE +-- ============================================================ + +CREATE TABLE ems.site_override ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + override_type TEXT NOT NULL, + value_json JSONB, + valid_from TIMESTAMPTZ NOT NULL, + valid_to TIMESTAMPTZ, + reason TEXT, + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE ems.site_override IS 'Manuální přepisy provozních stavů. Mají přednost před automatickým plánem.'; +COMMENT ON COLUMN ems.site_override.id IS 'Primární klíč.'; +COMMENT ON COLUMN ems.site_override.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.site_override.override_type IS 'Typ přepisu: force_charge, force_discharge, block_export, manual_setpoint, block_heat_pump.'; +COMMENT ON COLUMN ems.site_override.value_json IS 'Parametry přepisu jako JSON. Obsah závisí na override_type.'; +COMMENT ON COLUMN ems.site_override.valid_from IS 'Začátek platnosti přepisu.'; +COMMENT ON COLUMN ems.site_override.valid_to IS 'Konec platnosti. NULL = platí do odvolání.'; +COMMENT ON COLUMN ems.site_override.reason IS 'Důvod přepisu pro auditní účely.'; +COMMENT ON COLUMN ems.site_override.created_by IS 'Uživatel nebo systémová komponenta která přepis vytvořila.'; +COMMENT ON COLUMN ems.site_override.created_at IS 'Čas vytvoření záznamu.'; diff --git a/db/migration/V002__timescale_hypertables.sql b/db/migration/V002__timescale_hypertables.sql new file mode 100644 index 0000000..6e227e5 --- /dev/null +++ b/db/migration/V002__timescale_hypertables.sql @@ -0,0 +1,81 @@ +-- ============================================================= +-- V002__timescale_hypertables.sql +-- EMS Platform – vytvoření TimescaleDB hypertable pro časové série +-- Spouštět po V001 a po instalaci TimescaleDB extension +-- ============================================================= + +CREATE EXTENSION IF NOT EXISTS timescaledb; + +-- Telemetrie střídače – 1min záznamy, partitioning po 1 týdnu +SELECT create_hypertable( + 'ems.telemetry_inverter', + 'measured_at', + chunk_time_interval => INTERVAL '1 week', + if_not_exists => TRUE +); + +-- Telemetrie EV nabíječek +SELECT create_hypertable( + 'ems.telemetry_ev_charger', + 'measured_at', + chunk_time_interval => INTERVAL '1 week', + if_not_exists => TRUE +); + +-- Telemetrie tepelného čerpadla +SELECT create_hypertable( + 'ems.telemetry_heat_pump', + 'measured_at', + chunk_time_interval => INTERVAL '1 week', + if_not_exists => TRUE +); + +-- Spotové ceny – 15min záznamy, partitioning po 1 měsíci +SELECT create_hypertable( + 'ems.market_interval_price', + 'interval_start', + chunk_time_interval => INTERVAL '1 month', + if_not_exists => TRUE +); + +-- FVE predikce – 15min záznamy +SELECT create_hypertable( + 'ems.forecast_pv_interval', + 'interval_start', + chunk_time_interval => INTERVAL '1 month', + if_not_exists => TRUE +); + +-- Predikce počasí +SELECT create_hypertable( + 'ems.forecast_weather_interval', + 'interval_start', + chunk_time_interval => INTERVAL '1 month', + if_not_exists => TRUE +); + +-- Audit +SELECT create_hypertable( + 'ems.audit_interval', + 'interval_start', + chunk_time_interval => INTERVAL '1 month', + if_not_exists => TRUE +); + +-- Bazální spotřeba +SELECT create_hypertable( + 'ems.consumption_baseline_interval', + 'interval_start', + chunk_time_interval => INTERVAL '1 month', + if_not_exists => TRUE +); + +-- ============================================================ +-- Kompresní politiky pro staré chunky +-- Telemetrie starší 30 dní komprimovat (čtení stačí) +-- ============================================================ + +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); +SELECT add_compression_policy('ems.market_interval_price', INTERVAL '90 days', if_not_exists => TRUE); diff --git a/db/migration/V003__seed_site_home01.sql b/db/migration/V003__seed_site_home01.sql new file mode 100644 index 0000000..dbbb3c7 --- /dev/null +++ b/db/migration/V003__seed_site_home01.sql @@ -0,0 +1,203 @@ +-- ============================================================= +-- V003__seed_site_home01.sql +-- EMS Platform – seed data první lokality home-01 +-- Doplnit: latitude, longitude, IP adresy, azimuty FVE polí +-- ============================================================= + +-- ============================================================ +-- LOKALITA +-- ============================================================ + +INSERT INTO ems.site (code, name, timezone, latitude, longitude, active, notes) +VALUES ( + 'home-01', + 'Hlavní objekt', + 'Europe/Prague', + NULL, -- TODO: doplnit GPS + NULL, -- TODO: doplnit GPS + true, + 'První instalace. Deye 20kW + 64kWh baterie + 2x Teltonika EV + Samsung TČ.' +); + +-- ============================================================ +-- ENDPOINTY +-- ============================================================ + +-- Deye střídač přes Waveshare RS485→TCP +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) +SELECT id, 'modbus_tcp', '192.168.1.102', 502, 'modbus_tcp', 1, true, 'Waveshare pro Teltonika TeltoCharge #2.' +FROM ems.site WHERE code = 'home-01'; + +-- Samsung tepelné čerpadlo 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.103', 502, 'modbus_tcp', 1, true, 'Waveshare pro Samsung tepelné čerpadlo.' +FROM ems.site WHERE code = 'home-01'; + +-- Loxone Miniserver +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Í +-- ============================================================ + +INSERT INTO ems.site_grid_connection (site_id, max_import_power_w, max_export_power_w, no_export, reserved_capacity_w, notes) +SELECT id, 22000, 20000, false, 0, 'Třífázová přípojka. Limity upřesnit dle smlouvy s distributorem.' +FROM ems.site WHERE code = 'home-01'; + +-- ============================================================ +-- TRŽNÍ KONFIGURACE +-- ============================================================ + +INSERT INTO ems.site_market_config ( + site_id, purchase_pricing_mode, sale_pricing_mode, + buy_margin_fixed_czk, buy_margin_percent, + sell_margin_fixed_czk, sell_margin_percent, + currency, valid_from, valid_to, notes +) +SELECT + id, 'spot', 'spot', + 0.050, -- 5 haléřů/kWh nákupní marže (distribuce, poplatky) + 0, + -0.020, -- 2 haléře/kWh srážka z prodejní ceny + 0, + 'CZK', now(), NULL, 'Výchozí konfigurace. Upřesnit dle skutečné smlouvy.' +FROM ems.site WHERE code = 'home-01'; + +-- ============================================================ +-- AKTIVA – STŘÍDAČ +-- ============================================================ + +INSERT INTO ems.asset_inverter (site_id, code, manufacturer, model, endpoint_id, max_charge_power_w, max_discharge_power_w, max_export_power_w, controllable, notes) +SELECT + s.id, 'deye-main', 'Deye', 'SUN-20K-SG01LP1-EU', + ep.id, + 20000, 20000, 20000, + true, + 'Hlavní hybridní střídač 20kW LV. RS485 Modbus RTU přes Waveshare.' +FROM ems.site s +JOIN ems.site_endpoint ep ON ep.site_id = s.id AND ep.notes LIKE '%Deye%' +WHERE s.code = 'home-01'; + +-- Ongridový střídač na GEN portu (autonomní, neřídíme) +INSERT INTO ems.asset_inverter (site_id, code, manufacturer, model, endpoint_id, max_export_power_w, controllable, notes) +SELECT + id, 'ongrid-gen', NULL, NULL, NULL, 10000, false, + 'Ongridový střídač zapojený do GEN portu Deye. Autonomní provoz, EMS neřídí.' +FROM ems.site WHERE code = 'home-01'; + +-- ============================================================ +-- AKTIVA – BATERIE +-- ============================================================ + +INSERT INTO ems.asset_battery (site_id, inverter_id, code, usable_capacity_wh, min_soc_percent, reserve_soc_percent, max_soc_percent, charge_efficiency, discharge_efficiency, degradation_cost_czk_kwh) +SELECT + s.id, inv.id, 'bat-main', + 64000, -- 64 kWh + 10, -- min SoC 10 % + 20, -- rezerva 20 % pro výpadek sítě + 95, -- max SoC 95 % + 0.95, 0.95, + 0.50 -- Kč/kWh degradace, upřesnit dle záruky výrobce +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'; + +-- ============================================================ +-- AKTIVA – FVE POLE +-- ============================================================ + +-- Pole A – řízené Deye střídačem +INSERT INTO ems.asset_pv_array (site_id, inverter_id, code, name, nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor, controllable, notes) +SELECT + s.id, inv.id, 'pv-a', 'FVE pole A', + 10000, -- 10 kWp + NULL, -- TODO: doplnit azimut (0=jih) + NULL, -- TODO: doplnit sklon (stupně) + NULL, + 1.0, + true, + 'Hlavní FVE pole řízené Deye střídačem. Doplnit azimut a sklon.' +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'; + +-- Pole B – ongridový, autonomní +INSERT INTO ems.asset_pv_array (site_id, inverter_id, code, name, nominal_power_wp, azimuth_deg, tilt_deg, module_count, shading_factor, controllable, notes) +SELECT + s.id, inv.id, 'pv-b', 'FVE pole B (ongrid)', + 10000, + NULL, -- TODO: doplnit azimut + NULL, -- TODO: doplnit sklon + NULL, + 1.0, + false, + 'Ongridový střídač na GEN portu Deye. EMS neřídí, výkon se projeví v telemetrii Deye jako součást pv_power_w.' +FROM ems.site s +JOIN ems.asset_inverter inv ON inv.site_id = s.id AND inv.code = 'ongrid-gen' +WHERE s.code = 'home-01'; + +-- ============================================================ +-- AKTIVA – EV NABÍJEČKY +-- ============================================================ + +INSERT INTO ems.asset_ev_charger (site_id, code, manufacturer, model, endpoint_id, max_power_w, min_power_w, phases, connector_count, schedulable, notes) +SELECT + s.id, 'ev-charger-1', 'Teltonika', 'TeltoCharge 22kW', + ep.id, + 22000, 1380, 3, 1, true, + 'EV nabíječka č. 1. Modbus TCP přes Waveshare. Ověřit Modbus registry z dokumentace Teltonika.' +FROM ems.site s +JOIN ems.site_endpoint ep ON ep.site_id = s.id AND ep.notes LIKE '%TeltoCharge #1%' +WHERE s.code = 'home-01'; + +INSERT INTO ems.asset_ev_charger (site_id, code, manufacturer, model, endpoint_id, max_power_w, min_power_w, phases, connector_count, schedulable, notes) +SELECT + s.id, 'ev-charger-2', 'Teltonika', 'TeltoCharge 22kW', + ep.id, + 22000, 1380, 3, 1, true, + 'EV nabíječka č. 2. Modbus TCP přes Waveshare.' +FROM ems.site s +JOIN ems.site_endpoint ep ON ep.site_id = s.id AND ep.notes LIKE '%TeltoCharge #2%' +WHERE s.code = 'home-01'; + +-- ============================================================ +-- AKTIVA – TEPELNÉ ČERPADLO +-- ============================================================ + +INSERT INTO ems.asset_heat_pump ( + site_id, code, manufacturer, model, endpoint_id, + rated_heating_power_w, cop_rated, cop_temp_reference_c, + min_run_duration_min, min_stop_duration_min, + tuv_tank_volume_l, tuv_min_temp_c, tuv_max_temp_c, tuv_target_temp_c, + tuv_temp_sensor_ref, schedulable, notes +) +SELECT + s.id, 'hp-samsung', 'Samsung', NULL, -- TODO: doplnit model + ep.id, + NULL, -- TODO: doplnit jmenovitý výkon W + NULL, -- TODO: doplnit COP rated + 7.0, -- referenční teplota A7/W35 + 30, 15, + NULL, -- TODO: doplnit objem zásobníku + 45, 60, 55, + NULL, -- TODO: doplnit odkaz na teplotní čidlo + 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 +JOIN ems.site_endpoint ep ON ep.site_id = s.id AND ep.notes LIKE '%Samsung%' +WHERE s.code = 'home-01'; diff --git a/db/migration/V004__operating_modes.sql b/db/migration/V004__operating_modes.sql new file mode 100644 index 0000000..34a2939 --- /dev/null +++ b/db/migration/V004__operating_modes.sql @@ -0,0 +1,117 @@ +-- ============================================================= +-- V004__operating_modes.sql +-- EMS Platform – provozní režimy lokalit +-- ============================================================= + +-- ============================================================ +-- Číselník provozních režimů +-- ============================================================ + +CREATE TABLE ems.operating_mode_def ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + ev_enabled BOOLEAN NOT NULL DEFAULT false, + heat_pump_enabled BOOLEAN NOT NULL DEFAULT false, + battery_mode TEXT NOT NULL, -- 'plan', 'self_sustain', 'charge_max', 'hold', 'none' + grid_mode TEXT NOT NULL, -- 'plan', 'import_ok', 'no_import', 'no_export', 'none' + loxone_mode_value INT NOT NULL, -- integer posílaný do Loxone Virtual Input + is_autonomous BOOLEAN NOT NULL DEFAULT false, -- Loxone umí sám bez EMS setpointů + sort_order INT NOT NULL DEFAULT 50 +); + +COMMENT ON TABLE ems.operating_mode_def IS 'Číselník provozních režimů systému. Každý režim definuje chování baterie, sítě a flexibilních zařízení.'; +COMMENT ON COLUMN ems.operating_mode_def.code IS 'Unikátní kód režimu. Příklad: AUTO, SELF_SUSTAIN.'; +COMMENT ON COLUMN ems.operating_mode_def.name IS 'Lidsky čitelný název režimu pro UI.'; +COMMENT ON COLUMN ems.operating_mode_def.description IS 'Popis chování v daném režimu.'; +COMMENT ON COLUMN ems.operating_mode_def.ev_enabled IS 'Zda je povoleno nabíjení EV v tomto režimu.'; +COMMENT ON COLUMN ems.operating_mode_def.heat_pump_enabled IS 'Zda je tepelné čerpadlo povoleno v tomto režimu.'; +COMMENT ON COLUMN ems.operating_mode_def.battery_mode IS 'Chování baterie: plan=dle EMS plánu, self_sustain=vybíjí do domu, charge_max=max nabíjení, hold=drží SoC, none=žádné akce.'; +COMMENT ON COLUMN ems.operating_mode_def.grid_mode IS 'Chování vůči síti: plan=dle EMS, import_ok=import povolen, no_import=bez importu, no_export=bez exportu, none=žádné akce.'; +COMMENT ON COLUMN ems.operating_mode_def.loxone_mode_value IS 'Celočíselná hodnota posílaná do Loxone Virtual Input EMS_Mode. Loxone interně přepíná stavový stroj.'; +COMMENT ON COLUMN ems.operating_mode_def.is_autonomous IS 'Pokud true, Loxone zvládne tento režim bez průběžných setpointů od EMS (fallback bezpečný).'; +COMMENT ON COLUMN ems.operating_mode_def.sort_order IS 'Pořadí zobrazení v UI.'; + +INSERT INTO ems.operating_mode_def + (code, name, description, ev_enabled, heat_pump_enabled, battery_mode, grid_mode, loxone_mode_value, is_autonomous, sort_order) +VALUES + ('AUTO', 'Automatický', 'EMS řídí vše podle optimalizovaného plánu. Setpointy posílány každých 15 min.', + true, true, 'plan', 'plan', 1, false, 10), + + ('SELF_SUSTAIN', 'Soběstačný', 'Fallback bez EMS. FVE + baterie pokrývají spotřebu. Žádný import, žádný export, EV a TČ odstaveny.', + false, false, 'self_sustain', 'no_export', 2, true, 20), + + ('CHARGE_CHEAP', 'Nabíjení levnou cenou', 'Manuální: max nabíjení baterie ze sítě. Použít při ručně identifikované levné ceně nebo akci.', + false, false, 'charge_max', 'import_ok', 3, false, 30), + + ('PRESERVE', 'Ochrana baterie', 'Dovolená / servis. Baterie drží aktuální SoC, žádné akce. EV a TČ odstaveny.', + false, false, 'hold', 'import_ok', 4, true, 40), + + ('MANUAL', 'Manuální', 'Technický přepis. EMS ani Loxone logika neřídí střídač. Pouze pro servisní práce.', + false, false, 'none', 'none', 0, true, 50); + +-- ============================================================ +-- Aktivní provozní režim per lokalita +-- ============================================================ + +CREATE TABLE ems.site_operating_mode ( + site_id INT NOT NULL REFERENCES ems.site(id) PRIMARY KEY, + mode_code TEXT NOT NULL REFERENCES ems.operating_mode_def(code), + activated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + activated_by TEXT, + valid_until TIMESTAMPTZ, -- NULL = platí do ručního přepnutí + previous_mode TEXT REFERENCES ems.operating_mode_def(code), + notes TEXT +); + +COMMENT ON TABLE ems.site_operating_mode IS 'Aktuálně aktivní provozní režim per lokalita. Jeden řádek na lokalitu (upsert při přepnutí).'; +COMMENT ON COLUMN ems.site_operating_mode.site_id IS 'Vazba na lokalitu. PK – jedna lokalita má vždy právě jeden aktivní režim.'; +COMMENT ON COLUMN ems.site_operating_mode.mode_code IS 'Aktuálně aktivní režim. FK na operating_mode_def.'; +COMMENT ON COLUMN ems.site_operating_mode.activated_at IS 'Čas přepnutí do tohoto režimu.'; +COMMENT ON COLUMN ems.site_operating_mode.activated_by IS 'Kdo nebo co přepnulo režim: user:jan, system:watchdog, api, loxone.'; +COMMENT ON COLUMN ems.site_operating_mode.valid_until IS 'Automatické vypršení režimu. NULL = platí do ručního přepnutí.'; +COMMENT ON COLUMN ems.site_operating_mode.previous_mode IS 'Předchozí režim – pro rychlý návrat zpět.'; +COMMENT ON COLUMN ems.site_operating_mode.notes IS 'Volný komentář k přepnutí.'; + +-- ============================================================ +-- Historie přepnutí režimů (audit log) +-- ============================================================ + +CREATE TABLE ems.site_operating_mode_log ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + mode_code TEXT NOT NULL REFERENCES ems.operating_mode_def(code), + activated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deactivated_at TIMESTAMPTZ, + activated_by TEXT, + notes TEXT +); + +COMMENT ON TABLE ems.site_operating_mode_log IS 'Auditní log všech přepnutí provozních režimů per lokalita.'; +COMMENT ON COLUMN ems.site_operating_mode_log.id IS 'Primární klíč.'; +COMMENT ON COLUMN ems.site_operating_mode_log.site_id IS 'Vazba na lokalitu.'; +COMMENT ON COLUMN ems.site_operating_mode_log.mode_code IS 'Aktivovaný režim.'; +COMMENT ON COLUMN ems.site_operating_mode_log.activated_at IS 'Čas aktivace režimu.'; +COMMENT ON COLUMN ems.site_operating_mode_log.deactivated_at IS 'Čas deaktivace (přepnutí na jiný režim). NULL = stále aktivní.'; +COMMENT ON COLUMN ems.site_operating_mode_log.activated_by IS 'Zdroj přepnutí: user:jan, system:watchdog, system:ems_start.'; +COMMENT ON COLUMN ems.site_operating_mode_log.notes IS 'Volný komentář.'; + +-- ============================================================ +-- Heartbeat tabulka – pouze informační, pro EMS vlastní diagnostiku +-- POZOR: Loxone watchdog NEČTE tuto tabulku. +-- Loxone sleduje HTTP pulzy přímo (nezávisle na DB). +-- Tato tabulka slouží jen pro EMS dashboard a alerting. +-- ============================================================ + +CREATE TABLE ems.site_heartbeat ( + site_id INT NOT NULL REFERENCES ems.site(id) PRIMARY KEY, + last_seen TIMESTAMPTZ NOT NULL DEFAULT now(), + ems_version TEXT, + status TEXT NOT NULL DEFAULT 'ok' +); + +COMMENT ON TABLE ems.site_heartbeat IS 'Informační záznam posledního úspěšného heartbeat pulzu EMS per lokalita. Slouží pouze pro EMS dashboard a alerting – Loxone watchdog tuto tabulku nečte a nezávisí na ní. Fallback logika je implementována čistě v Loxone (viz docs/loxone-integration.md).'; +COMMENT ON COLUMN ems.site_heartbeat.site_id IS 'Vazba na lokalitu. PK.'; +COMMENT ON COLUMN ems.site_heartbeat.last_seen IS 'Čas kdy EMS backend naposledy úspěšně odeslal pulz do Loxone.'; +COMMENT ON COLUMN ems.site_heartbeat.ems_version IS 'Verze EMS backendu pro diagnostiku.'; +COMMENT ON COLUMN ems.site_heartbeat.status IS 'Stav EMS backendu: ok, degraded (částečný výpadek), error (kritická chyba).'; diff --git a/db/migration/V005__planning_curtailment.sql b/db/migration/V005__planning_curtailment.sql new file mode 100644 index 0000000..3e8b124 --- /dev/null +++ b/db/migration/V005__planning_curtailment.sql @@ -0,0 +1,51 @@ +-- ============================================================= +-- V005__planning_curtailment.sql +-- EMS Platform – rozšíření plánování o curtailment FVE pole A +-- a zelený bonus pole B +-- ============================================================= + +-- Přidat curtailment výsledek do planning_interval +ALTER TABLE ems.planning_interval + ADD COLUMN IF NOT EXISTS pv_a_curtailed_w INT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS +'Plánované omezení výroby FVE pole A v W rozhodnuté LP solverem. ' +'0 = žádné omezení výroby. Hodnota > 0 znamená že Deye dostane příkaz ' +'omezit Output Power Limit na (pv_a_forecast_w - pv_a_curtailed_w).'; + +-- Přidat zelený bonus do audit_interval pro správnou ekonomiku +ALTER TABLE ems.audit_interval + ADD COLUMN IF NOT EXISTS pv_b_production_wh NUMERIC(10,3), + ADD COLUMN IF NOT EXISTS green_bonus_czk NUMERIC(10,4); + +COMMENT ON COLUMN ems.audit_interval.pv_b_production_wh IS +'Skutečná výroba FVE pole B v Wh za 15min interval. ' +'Odvozena z telemetrie: celkový pv_power_w minus výroba pole A (pokud měřena odděleně). ' +'Slouží pro výpočet nároku na zelený bonus.'; + +COMMENT ON COLUMN ems.audit_interval.green_bonus_czk IS +'Příjem ze zeleného bonusu za výrobu pole B v Kč. ' +'Vypočteno jako pv_b_production_wh / 1000 * green_bonus_czk_kwh z site_market_config. ' +'Zahrnovat do celkových nákladů/příjmů lokality.'; + +-- Rozšíření site_market_config o zelený bonus +ALTER TABLE ems.site_market_config + ADD COLUMN IF NOT EXISTS green_bonus_czk_kwh NUMERIC(8,4) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS green_bonus_asset_code TEXT; + +COMMENT ON COLUMN ems.site_market_config.green_bonus_czk_kwh IS +'Výše zeleného bonusu (dotace) v Kč/kWh za vyrobenou elektřinu z FVE pole s dotací. ' +'Bonus se vztahuje vždy na výrobu bez ohledu na cenu nebo způsob využití energie.'; + +COMMENT ON COLUMN ems.site_market_config.green_bonus_asset_code IS +'Kód FVE pole (asset_pv_array.code) na které se zelený bonus vztahuje. ' +'Příklad: pv-b. NULL = bonus se nevztahuje na žádné konkrétní pole.'; + +-- Seed: doplnit zelený bonus pro home-01 +-- (hodnota bonusu bude upřesněna dle smlouvy s OTE/ERU) +UPDATE ems.site_market_config +SET + green_bonus_czk_kwh = 1.20, -- TODO: doplnit skutečnou výši bonusu ze smlouvy + green_bonus_asset_code = 'pv-b' +WHERE site_id = (SELECT id FROM ems.site WHERE code = 'home-01') + AND valid_to IS NULL; diff --git a/db/migration/V006__vehicles.sql b/db/migration/V006__vehicles.sql new file mode 100644 index 0000000..c745687 --- /dev/null +++ b/db/migration/V006__vehicles.sql @@ -0,0 +1,119 @@ +-- ============================================================= +-- V006__vehicles.sql +-- EMS Platform – vozidla a EV session tracking +-- ============================================================= + +-- ============================================================ +-- Vozidla +-- ============================================================ + +CREATE TABLE ems.asset_vehicle ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + code TEXT NOT NULL, + name TEXT, + make TEXT, -- 'Tesla', 'Renault' + model TEXT, -- 'Model Y', 'Zoe R135' + battery_capacity_kwh NUMERIC(6,2) NOT NULL, -- kapacita trakční baterie + max_charge_power_w INT NOT NULL, -- max přijímaný AC výkon vozidla + default_charger_id INT REFERENCES ems.asset_ev_charger(id), + api_type TEXT NOT NULL DEFAULT 'none', -- 'tesla', 'none' + api_reference TEXT, -- klíč do env/secrets pro API + default_target_soc_pct NUMERIC(5,2) NOT NULL DEFAULT 80, + default_deadline_hour INT NOT NULL DEFAULT 7, -- hodina rána + active BOOLEAN NOT NULL DEFAULT true, + UNIQUE (site_id, code) +); + +COMMENT ON TABLE ems.asset_vehicle IS 'Vozidla registrovaná v EMS. Každé vozidlo má výchozí cílový SoC a deadline pro plánování nabíjení.'; +COMMENT ON COLUMN ems.asset_vehicle.battery_capacity_kwh IS 'Celková kapacita trakční baterie vozidla v kWh. Tesla Model Y ~75 kWh, Renault Zoe R135 ~52 kWh.'; +COMMENT ON COLUMN ems.asset_vehicle.max_charge_power_w IS 'Maximální výkon který vozidlo přijme přes AC nabíjení. Může být nižší než výkon WB. Tesla MY ~11 000 W, Zoe R135 ~7 400 W.'; +COMMENT ON COLUMN ems.asset_vehicle.api_type IS 'Typ API pro čtení stavu vozidla. tesla = Tesla API (Tessie nebo přímé), none = žádné API.'; +COMMENT ON COLUMN ems.asset_vehicle.api_reference IS 'Název env proměnné nebo klíče v secrets kde je uložen API token/přihlášení. Např. TESLA_MY_TOKEN.'; +COMMENT ON COLUMN ems.asset_vehicle.default_target_soc_pct IS 'Výchozí cílový SoC pro deadline charging. Uživatel může přepsat v UI před odjezdem.'; +COMMENT ON COLUMN ems.asset_vehicle.default_deadline_hour IS 'Výchozí hodina rána do které musí být dosažen target SoC. 7 = 07:00.'; + +-- ============================================================ +-- EV session tracking +-- ============================================================ + +CREATE TABLE ems.ev_session ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + charger_id INT NOT NULL REFERENCES ems.asset_ev_charger(id), + vehicle_id INT REFERENCES ems.asset_vehicle(id), -- NULL pokud neznámé vozidlo + session_start TIMESTAMPTZ NOT NULL DEFAULT now(), + session_end TIMESTAMPTZ, + -- Stav při připojení (pokud znám) + soc_at_connect_pct NUMERIC(5,2), + -- Deadline požadavek + target_soc_pct NUMERIC(5,2), + target_deadline TIMESTAMPTZ, + -- Průběžný stav (aktualizován z telemetrie) + energy_delivered_wh NUMERIC(12,3) NOT NULL DEFAULT 0, + last_power_w INT, + -- Výsledek při odpojení + soc_at_disconnect_pct NUMERIC(5,2), + total_cost_czk NUMERIC(10,4), + deadline_met BOOLEAN, -- byl deadline splněn? + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE ems.ev_session IS 'Záznamy jednotlivých nabíjecích sessions EV. Session začíná připojením (status != available na WB) a končí odpojením.'; +COMMENT ON COLUMN ems.ev_session.vehicle_id IS 'Identifikace vozidla. Může být NULL pokud EMS neví které auto je připojeno (Zoe nemá API).'; +COMMENT ON COLUMN ems.ev_session.soc_at_connect_pct IS 'SoC baterie vozidla při připojení. Pro Tesla čteno z API, pro Zoe NULL (neznáme).'; +COMMENT ON COLUMN ems.ev_session.target_soc_pct IS 'Cílový SoC pro tuto session. Výchozí z asset_vehicle.default_target_soc_pct, uživatel může změnit v UI.'; +COMMENT ON COLUMN ems.ev_session.target_deadline IS 'Deadline pro dosažení target_soc_pct. Výchozí = dnešní/zítřejší default_deadline_hour.'; +COMMENT ON COLUMN ems.ev_session.energy_delivered_wh IS 'Kumulativní energie dodaná v této session v Wh. Čteno z WB Modbus (kWh counter). Slouží i pro odhad SoC u Zoe.'; +COMMENT ON COLUMN ems.ev_session.deadline_met IS 'True pokud byl target_soc_pct dosažen před target_deadline. NULL pokud session ještě probíhá.'; + +-- Hypertable pro session log (sessions jsou časová série) +-- Poznámka: ev_session není hypertable – sessions jsou krátké záznamy, ne telemetrie. +-- Index pro rychlé dotazy na aktivní session +CREATE INDEX idx_ev_session_active + ON ems.ev_session (charger_id, session_end) + WHERE session_end IS NULL; + +CREATE INDEX idx_ev_session_site_time + ON ems.ev_session (site_id, session_start DESC); + +-- ============================================================ +-- Seed – vozidla home-01 +-- ============================================================ + +INSERT INTO ems.asset_vehicle + (site_id, code, name, make, model, + battery_capacity_kwh, max_charge_power_w, + default_charger_id, api_type, + default_target_soc_pct, default_deadline_hour) +SELECT + s.id, + 'tesla-my', 'Tesla Model Y', 'Tesla', 'Model Y', + 75.0, + 11000, -- Tesla MY AC max ~11kW (3fáze × 16A × 230V) + ch1.id, + 'none', -- Tesla API fáze 2, zatím none + 80, -- 80% výchozí target – Tesla má velkou baterii + 7 -- do 7:00 ráno +FROM ems.site s +JOIN ems.asset_ev_charger ch1 ON ch1.site_id = s.id AND ch1.code = 'ev-charger-1' +WHERE s.code = 'home-01'; + +INSERT INTO ems.asset_vehicle + (site_id, code, name, make, model, + battery_capacity_kwh, max_charge_power_w, + default_charger_id, api_type, + default_target_soc_pct, default_deadline_hour) +SELECT + s.id, + 'zoe-r135', 'Renault Zoe R135', 'Renault', 'Zoe R135', + 52.0, + 7400, -- Zoe R135 max 7.4kW AC (jednofáze 32A nebo 3fáze nižší) + ch2.id, + 'none', -- Zoe nemá API + 90, -- 90% výchozí target – Zoe má menší baterii, kritičtější + 7 +FROM ems.site s +JOIN ems.asset_ev_charger ch2 ON ch2.site_id = s.id AND ch2.code = 'ev-charger-2' +WHERE s.code = 'home-01'; diff --git a/db/migration/V007__rolling_replanning.sql b/db/migration/V007__rolling_replanning.sql new file mode 100644 index 0000000..b487700 --- /dev/null +++ b/db/migration/V007__rolling_replanning.sql @@ -0,0 +1,91 @@ +-- ============================================================= +-- V007__rolling_replanning.sql +-- EMS Platform – rozšíření planning_run o rolling horizon typ +-- a forecast korekční faktory +-- ============================================================= + +-- ============================================================ +-- Rozšíření planning_run o typ a kontext replanningu +-- ============================================================ + +ALTER TABLE ems.planning_run + ADD COLUMN IF NOT EXISTS run_type TEXT NOT NULL DEFAULT 'daily', + ADD COLUMN IF NOT EXISTS triggered_by TEXT, + ADD COLUMN IF NOT EXISTS replan_from TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS soc_at_replan_wh NUMERIC(10,2), + ADD COLUMN IF NOT EXISTS solver_duration_ms INT, + ADD COLUMN IF NOT EXISTS forecast_correction_factor NUMERIC(6,4); + +COMMENT ON COLUMN ems.planning_run.run_type IS +'Typ plánovacího běhu: + daily = hlavní denní plán (15:00, horizont 36h) + rolling = průběžný replan každých 15min + manual = spuštěno ručně z UI nebo API'; + +COMMENT ON COLUMN ems.planning_run.triggered_by IS +'Co spustilo tento plánovací běh: + scheduler:daily, scheduler:rolling, user:jan, api, override_change'; + +COMMENT ON COLUMN ems.planning_run.replan_from IS +'Od kterého slotu byl plán přepočítán (pro rolling). NULL pro daily plán. +Sloty před replan_from jsou převzaty z předchozího aktivního plánu.'; + +COMMENT ON COLUMN ems.planning_run.soc_at_replan_wh IS +'Skutečný SoC baterie v Wh v okamžiku replanningu (z telemetrie). +Vstupní podmínka pro solver – zpřesňuje počáteční SoC oproti dennímu plánu.'; + +COMMENT ON COLUMN ems.planning_run.solver_duration_ms IS +'Čas výpočtu LP solveru v milisekundách. Pro monitoring výkonu.'; + +COMMENT ON COLUMN ems.planning_run.forecast_correction_factor IS +'Korekční faktor aplikovaný na FVE forecast při tomto replanningu. +Vypočten z poměru skutečné vs. předpovídané výroby za posledních 60 minut. +1.0 = žádná korekce, 0.8 = skutečnost byla 80% forecastu.'; + +-- ============================================================ +-- Rozšíření planning_interval o per-EV setpointy +-- (nahrazuje jeden agregovaný ev_charge_power_w) +-- ============================================================ + +ALTER TABLE ems.planning_interval + ADD COLUMN IF NOT EXISTS ev1_setpoint_w INT, + ADD COLUMN IF NOT EXISTS ev2_setpoint_w INT, + ADD COLUMN IF NOT EXISTS ev1_via_bat_w INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS ev2_via_bat_w INT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN ems.planning_interval.ev1_setpoint_w IS +'Plánovaný celkový výkon nabíjení EV nabíječky 1 (Tesla) v W. NULL = auto nepřipojeno.'; + +COMMENT ON COLUMN ems.planning_interval.ev2_setpoint_w IS +'Plánovaný celkový výkon nabíjení EV nabíječky 2 (Zoe) v W. NULL = auto nepřipojeno.'; + +COMMENT ON COLUMN ems.planning_interval.ev1_via_bat_w IS +'Část výkonu EV1 která jde přes baterii (round-trip ztráta). 0 = přímé napájení.'; + +COMMENT ON COLUMN ems.planning_interval.ev2_via_bat_w IS +'Část výkonu EV2 která jde přes baterii (round-trip ztráta). 0 = přímé napájení.'; + +-- ============================================================ +-- Tabulka forecast korekcí – pro analýzu přesnosti +-- ============================================================ + +CREATE TABLE IF NOT EXISTS ems.forecast_correction_log ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + logged_at TIMESTAMPTZ NOT NULL DEFAULT now(), + window_start TIMESTAMPTZ NOT NULL, -- začátek okna pro výpočet faktoru + window_end TIMESTAMPTZ NOT NULL, + actual_pv_wh NUMERIC(12,3), -- skutečná výroba za okno + forecast_pv_wh NUMERIC(12,3), -- předpovídaná výroba za okno + correction_factor NUMERIC(6,4), -- actual / forecast + applied_to_run_id INT REFERENCES ems.planning_run(id) +); + +COMMENT ON TABLE ems.forecast_correction_log IS +'Log výpočtu korekčních faktorů FVE forecastu. Každý rolling replan zaznamená +poměr skutečné vs. předpovídané výroby za posledních 60 minut. Slouží pro +analýzu přesnosti forecastu a ladění modelu.'; + +COMMENT ON COLUMN ems.forecast_correction_log.correction_factor IS +'Poměr actual_pv_wh / forecast_pv_wh. Hodnoty typicky 0.5–1.5. +Extrémní hodnoty (oblačnost, porucha) jsou odfiltrovány v kódu (clamp 0.5–1.5).'; diff --git a/db/routines/R__fn_cop_estimate.sql b/db/routines/R__fn_cop_estimate.sql new file mode 100644 index 0000000..44d60fc --- /dev/null +++ b/db/routines/R__fn_cop_estimate.sql @@ -0,0 +1,137 @@ +-- ============================================================= +-- R__fn_cop_estimate.sql +-- EMS Platform – odhad COP tepelného čerpadla dle venkovní teploty +-- Repeatable migration +-- ============================================================= + +CREATE OR REPLACE FUNCTION ems.fn_cop_estimate( + p_heat_pump_id INT, + p_outdoor_temp_c NUMERIC +) +RETURNS NUMERIC(4,2) +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + v_cop_rated NUMERIC; + v_cop_ref_temp NUMERIC; + v_cop_estimated NUMERIC; +BEGIN + -- Načíst referenční COP a teplotu z konfigurace čerpadla + SELECT cop_rated, cop_temp_reference_c + INTO v_cop_rated, v_cop_ref_temp + FROM ems.asset_heat_pump + WHERE id = p_heat_pump_id; + + IF v_cop_rated IS NULL OR v_cop_ref_temp IS NULL THEN + -- Fallback: obecný odhad pro vzduch-voda TČ bez konfigurace + -- Zdroj: přibližná lineární závislost COP na venkovní teplotě + -- COP ≈ 2.0 při -10°C, COP ≈ 4.5 při +15°C + v_cop_estimated := 2.0 + (p_outdoor_temp_c + 10.0) * (4.5 - 2.0) / 25.0; + ELSE + -- Lineární interpolace od referenčního bodu + -- COP klesá přibližně o 0.10 na každý stupeň poklesu venkovní teploty + -- Toto je zjednodušený model – zpřesnit dle skutečných dat z tepelky + v_cop_estimated := v_cop_rated + (p_outdoor_temp_c - v_cop_ref_temp) * 0.10; + END IF; + + -- Omezit na rozumné hodnoty (COP vzduch-voda reálně 1.5–6.0) + v_cop_estimated := GREATEST(1.5, LEAST(6.0, v_cop_estimated)); + + RETURN ROUND(v_cop_estimated, 2); +END; +$$; + +COMMENT ON FUNCTION ems.fn_cop_estimate(INT, NUMERIC) IS +'Odhadne COP tepelného čerpadla pro danou venkovní teplotu. +Používá lineární model od referenčního bodu (cop_rated při cop_temp_reference_c). +Výstup slouží k rozhodnutí zda je výhodné spustit TČ v daném intervalu. +Přesnost modelu zlepšit kalibrací na historická data (cop_actual z telemetrie). +Výsledek omezen na rozsah 1.5–6.0.'; + +-- ------------------------------------------------------------ + +CREATE OR REPLACE FUNCTION ems.fn_heat_pump_cost_per_kwh_heat( + p_heat_pump_id INT, + p_outdoor_temp_c NUMERIC, + p_buy_price_czk_kwh NUMERIC +) +RETURNS NUMERIC(8,4) +LANGUAGE sql +STABLE +AS $$ + -- Cena za 1 kWh tepla = cena elektřiny / COP + -- Čím vyšší COP, tím levnější teplo + SELECT ROUND( + p_buy_price_czk_kwh / NULLIF(ems.fn_cop_estimate(p_heat_pump_id, p_outdoor_temp_c), 0), + 4 + ); +$$; + +COMMENT ON FUNCTION ems.fn_heat_pump_cost_per_kwh_heat(INT, NUMERIC, NUMERIC) IS +'Vypočte efektivní cenu za 1 kWh dodaného tepla v Kč. +Vstup: ID tepelného čerpadla, venkovní teplota, nákupní cena elektřiny. +Výstup slouží k porovnání výhodnosti ohřevu v různých časových intervalech. +Nižší hodnota = výhodnější čas pro provoz TČ.'; + +-- ------------------------------------------------------------ + +CREATE OR REPLACE FUNCTION ems.fn_heat_pump_should_run( + p_heat_pump_id INT, + p_interval_start TIMESTAMPTZ, + p_outdoor_temp_c NUMERIC, + p_tuv_tank_temp_c NUMERIC, + p_buy_price_czk_kwh NUMERIC, + p_max_cost_threshold NUMERIC DEFAULT 3.0 -- Kč/kWh tepla – maximální akceptovatelná cena +) +RETURNS BOOLEAN +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + v_hp ems.asset_heat_pump%ROWTYPE; + v_cost_per_kwh NUMERIC; + v_override BOOLEAN; +BEGIN + SELECT * INTO v_hp FROM ems.asset_heat_pump WHERE id = p_heat_pump_id; + + -- Kontrola override (blokování TČ) + SELECT EXISTS( + SELECT 1 FROM ems.site_override + WHERE site_id = v_hp.site_id + AND override_type = 'block_heat_pump' + AND valid_from <= p_interval_start + AND (valid_to IS NULL OR valid_to > p_interval_start) + ) INTO v_override; + + IF v_override THEN + RETURN false; + END IF; + + -- Povinný ohřev: teplota pod minimem + IF p_tuv_tank_temp_c IS NOT NULL AND p_tuv_tank_temp_c < v_hp.tuv_min_temp_c THEN + RETURN true; + END IF; + + -- Zásobník je plný + IF p_tuv_tank_temp_c IS NOT NULL AND p_tuv_tank_temp_c >= v_hp.tuv_max_temp_c THEN + RETURN false; + END IF; + + -- Ekonomické rozhodnutí: spustit pokud cena tepla je pod prahem + v_cost_per_kwh := ems.fn_heat_pump_cost_per_kwh_heat( + p_heat_pump_id, p_outdoor_temp_c, p_buy_price_czk_kwh + ); + + RETURN v_cost_per_kwh <= p_max_cost_threshold; +END; +$$; + +COMMENT ON FUNCTION ems.fn_heat_pump_should_run(INT, TIMESTAMPTZ, NUMERIC, NUMERIC, NUMERIC, NUMERIC) IS +'Rozhodne zda má tepelné čerpadlo v daném intervalu běžet. +Logika priorit: +1. Pokud existuje override block_heat_pump → false. +2. Pokud teplota zásobníku pod tuv_min_temp_c → true (povinný ohřev). +3. Pokud zásobník nad tuv_max_temp_c → false. +4. Jinak ekonomické rozhodnutí: spustit pokud cena tepla ≤ p_max_cost_threshold Kč/kWh. +Výhodné časy jsou přes poledne v chladných měsících (vyšší venkovní teplota = lepší COP).'; diff --git a/db/routines/R__fn_effective_price.sql b/db/routines/R__fn_effective_price.sql new file mode 100644 index 0000000..7169f8a --- /dev/null +++ b/db/routines/R__fn_effective_price.sql @@ -0,0 +1,61 @@ +-- ============================================================= +-- R__fn_effective_price.sql +-- EMS Platform – funkce pro výpočet efektivní ceny per site +-- Repeatable migration – nasazuje se při každé změně +-- ============================================================= + +CREATE OR REPLACE FUNCTION ems.fn_effective_buy_price( + p_site_id INT, + p_interval_start TIMESTAMPTZ +) +RETURNS NUMERIC(10,6) +LANGUAGE sql +STABLE +AS $$ + SELECT + mip.buy_raw_price_czk_kwh + + smc.buy_margin_fixed_czk + + (mip.buy_raw_price_czk_kwh * smc.buy_margin_percent / 100.0) + FROM ems.market_interval_price mip + CROSS JOIN ems.site_market_config smc + WHERE mip.market_source = 'OTE_CZ' + AND mip.interval_start = p_interval_start + AND smc.site_id = p_site_id + AND smc.valid_from <= p_interval_start + AND (smc.valid_to IS NULL OR smc.valid_to > p_interval_start) + ORDER BY smc.valid_from DESC + LIMIT 1; +$$; + +COMMENT ON FUNCTION ems.fn_effective_buy_price(INT, TIMESTAMPTZ) IS +'Vrátí efektivní nákupní cenu elektřiny v Kč/kWh pro danou lokalitu a 15min interval. +Přičítá fixní a procentní nákupní marži dle aktuálně platné site_market_config.'; + +-- ------------------------------------------------------------ + +CREATE OR REPLACE FUNCTION ems.fn_effective_sell_price( + p_site_id INT, + p_interval_start TIMESTAMPTZ +) +RETURNS NUMERIC(10,6) +LANGUAGE sql +STABLE +AS $$ + SELECT + mip.sell_raw_price_czk_kwh + + smc.sell_margin_fixed_czk + + (mip.sell_raw_price_czk_kwh * smc.sell_margin_percent / 100.0) + FROM ems.market_interval_price mip + CROSS JOIN ems.site_market_config smc + WHERE mip.market_source = 'OTE_CZ' + AND mip.interval_start = p_interval_start + AND smc.site_id = p_site_id + AND smc.valid_from <= p_interval_start + AND (smc.valid_to IS NULL OR smc.valid_to > p_interval_start) + ORDER BY smc.valid_from DESC + LIMIT 1; +$$; + +COMMENT ON FUNCTION ems.fn_effective_sell_price(INT, TIMESTAMPTZ) IS +'Vrátí efektivní prodejní cenu elektřiny v Kč/kWh pro danou lokalitu a 15min interval. +Aplikuje fixní a procentní prodejní marži (záporná marže = srážka z prodejní ceny).'; diff --git a/db/routines/R__fn_fill_audit_interval.sql b/db/routines/R__fn_fill_audit_interval.sql new file mode 100644 index 0000000..34e3600 --- /dev/null +++ b/db/routines/R__fn_fill_audit_interval.sql @@ -0,0 +1,171 @@ +-- ============================================================= +-- R__fn_fill_audit_interval.sql +-- EMS Platform – plnění audit_interval ze skutečné telemetrie +-- Repeatable migration +-- ============================================================= + +CREATE OR REPLACE FUNCTION ems.fn_fill_audit_interval( + p_site_id INT, + p_interval_start TIMESTAMPTZ +) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + v_interval_end TIMESTAMPTZ := p_interval_start + INTERVAL '15 minutes'; + v_run_id INT; + v_avg_pv_power_w INT; + v_avg_battery_power_w INT; + v_avg_grid_power_w INT; + v_avg_load_power_w INT; + v_last_soc NUMERIC(5,2); + v_sum_ev_power_w INT; + v_avg_hp_power_w INT; + v_plan ems.planning_interval%ROWTYPE; + v_buy_price NUMERIC; + v_sell_price NUMERIC; + v_actual_cost NUMERIC := NULL; +BEGIN + -- Najít aktivní plán pro tento interval + SELECT pi.* INTO v_plan + FROM ems.planning_interval pi + JOIN ems.planning_run pr ON pr.id = pi.run_id + WHERE pr.site_id = p_site_id + AND pi.interval_start = p_interval_start + AND pr.status IN ('active', 'superseded') + ORDER BY pr.created_at DESC + LIMIT 1; + + v_run_id := v_plan.run_id; + + -- Agregovat telemetrii střídače (průměr za 15min; agregace bez GROUP BY vrací vždy 1 řádek) + SELECT + AVG(pv_power_w)::INT, + AVG(battery_power_w)::INT, + AVG(grid_power_w)::INT, + AVG(load_power_w)::INT, + LAST(battery_soc_percent, measured_at) + INTO + v_avg_pv_power_w, + v_avg_battery_power_w, + v_avg_grid_power_w, + v_avg_load_power_w, + v_last_soc + FROM ems.telemetry_inverter + WHERE site_id = p_site_id + AND measured_at >= p_interval_start + AND measured_at < v_interval_end; + + -- Agregovat EV nabíječky (součet průměrů po charger_id) + SELECT COALESCE(SUM(avg_power), 0)::INT + INTO v_sum_ev_power_w + FROM ( + SELECT AVG(power_w) AS avg_power + FROM ems.telemetry_ev_charger + WHERE site_id = p_site_id + AND measured_at >= p_interval_start + AND measured_at < v_interval_end + GROUP BY charger_id + ) sub; + + -- Agregovat tepelné čerpadlo + SELECT AVG(power_w)::INT + INTO v_avg_hp_power_w + FROM ems.telemetry_heat_pump + WHERE site_id = p_site_id + AND measured_at >= p_interval_start + AND measured_at < v_interval_end; + + -- Efektivní cena pro výpočet skutečných nákladů + v_buy_price := ems.fn_effective_buy_price(p_site_id, p_interval_start); + v_sell_price := ems.fn_effective_sell_price(p_site_id, p_interval_start); + + -- Skutečné náklady (kladný grid = nákup, záporný = prodej) + IF v_avg_grid_power_w IS NOT NULL THEN + v_actual_cost := (v_avg_grid_power_w::NUMERIC / 1000.0 / 4.0) + * CASE WHEN v_avg_grid_power_w >= 0 + THEN COALESCE(v_buy_price, 0) + ELSE COALESCE(v_sell_price, 0) END; + END IF; + + -- Upsert do audit_interval + INSERT INTO ems.audit_interval ( + site_id, interval_start, planning_run_id, + actual_pv_power_w, actual_battery_power_w, + actual_grid_power_w, actual_load_power_w, + actual_battery_soc_pct, + actual_ev_power_w, + actual_heat_pump_power_w, + actual_cost_czk, + deviation_grid_w, + deviation_cost_czk + ) VALUES ( + p_site_id, p_interval_start, v_run_id, + v_avg_pv_power_w, + v_avg_battery_power_w, + v_avg_grid_power_w, + v_avg_load_power_w, + v_last_soc, + v_sum_ev_power_w, + v_avg_hp_power_w, + ROUND(v_actual_cost, 4), + CASE WHEN v_plan.run_id IS NOT NULL + THEN v_avg_grid_power_w - v_plan.grid_setpoint_w + ELSE NULL END, + CASE WHEN v_plan.run_id IS NOT NULL + THEN ROUND(v_actual_cost - COALESCE(v_plan.expected_cost_czk, 0), 4) + ELSE NULL END + ) + ON CONFLICT (site_id, interval_start) DO UPDATE SET + planning_run_id = EXCLUDED.planning_run_id, + actual_pv_power_w = EXCLUDED.actual_pv_power_w, + actual_battery_power_w = EXCLUDED.actual_battery_power_w, + actual_grid_power_w = EXCLUDED.actual_grid_power_w, + actual_load_power_w = EXCLUDED.actual_load_power_w, + actual_battery_soc_pct = EXCLUDED.actual_battery_soc_pct, + actual_ev_power_w = EXCLUDED.actual_ev_power_w, + actual_heat_pump_power_w = EXCLUDED.actual_heat_pump_power_w, + actual_cost_czk = EXCLUDED.actual_cost_czk, + deviation_grid_w = EXCLUDED.deviation_grid_w, + deviation_cost_czk = EXCLUDED.deviation_cost_czk; +END; +$$; + +COMMENT ON FUNCTION ems.fn_fill_audit_interval(INT, TIMESTAMPTZ) IS +'Naplní nebo aktualizuje jeden řádek v audit_interval pro danou lokalitu a 15min interval. +Agreguje průměry z telemetrie (střídač, EV, TČ), porovná se skutečným plánem a spočítá odchylky. +Volat každých 15 minut pro interval který právě skončil.'; + +-- ============================================================ +-- Hromadné plnění auditu za historické období +-- ============================================================ + +CREATE OR REPLACE FUNCTION ems.fn_fill_audit_range( + p_site_id INT, + p_from TIMESTAMPTZ, + p_to TIMESTAMPTZ +) +RETURNS INT +LANGUAGE plpgsql +AS $$ +DECLARE + v_slot TIMESTAMPTZ; + v_count INT := 0; +BEGIN + v_slot := date_trunc('hour', p_from) + + INTERVAL '15 min' * FLOOR(EXTRACT(MINUTE FROM p_from) / 15); + + WHILE v_slot < p_to LOOP + PERFORM ems.fn_fill_audit_interval(p_site_id, v_slot); + v_slot := v_slot + INTERVAL '15 minutes'; + v_count := v_count + 1; + END LOOP; + + RETURN v_count; +END; +$$; + +COMMENT ON FUNCTION ems.fn_fill_audit_range(INT, TIMESTAMPTZ, TIMESTAMPTZ) IS +'Hromadně naplní audit_interval pro celé historické období. +Volá fn_fill_audit_interval pro každý 15min slot v rozsahu p_from–p_to. +Vrátí počet zpracovaných intervalů. Použít pro backfill po výpadku nebo prvním nasazení.'; diff --git a/db/routines/R__fn_set_mode.sql b/db/routines/R__fn_set_mode.sql new file mode 100644 index 0000000..4dfb976 --- /dev/null +++ b/db/routines/R__fn_set_mode.sql @@ -0,0 +1,160 @@ +-- ============================================================= +-- R__fn_set_mode.sql +-- EMS Platform – přepínání provozních režimů +-- Repeatable migration +-- ============================================================= + +CREATE OR REPLACE FUNCTION ems.fn_set_mode( + p_site_id INT, + p_mode_code TEXT, + p_activated_by TEXT DEFAULT 'system', + p_valid_until TIMESTAMPTZ DEFAULT NULL, + p_notes TEXT DEFAULT NULL +) +RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + v_current_mode TEXT; + v_mode_exists BOOLEAN; +BEGIN + -- Ověřit že režim existuje + SELECT EXISTS(SELECT 1 FROM ems.operating_mode_def WHERE code = p_mode_code) + INTO v_mode_exists; + + IF NOT v_mode_exists THEN + RAISE EXCEPTION 'Neznámý provozní režim: %', p_mode_code; + END IF; + + -- Zjistit aktuální režim (pro log a previous_mode) + SELECT mode_code INTO v_current_mode + FROM ems.site_operating_mode + WHERE site_id = p_site_id; + + -- Pokud se režim nemění, nic nedělat + IF v_current_mode = p_mode_code THEN + RETURN p_mode_code; + END IF; + + -- Uzavřít předchozí záznam v logu + UPDATE ems.site_operating_mode_log + SET deactivated_at = now() + WHERE site_id = p_site_id + AND deactivated_at IS NULL; + + -- Upsert aktivního režimu + INSERT INTO ems.site_operating_mode + (site_id, mode_code, activated_at, activated_by, valid_until, previous_mode, notes) + VALUES + (p_site_id, p_mode_code, now(), p_activated_by, p_valid_until, v_current_mode, p_notes) + ON CONFLICT (site_id) DO UPDATE SET + mode_code = EXCLUDED.mode_code, + activated_at = EXCLUDED.activated_at, + activated_by = EXCLUDED.activated_by, + valid_until = EXCLUDED.valid_until, + previous_mode = EXCLUDED.previous_mode, + notes = EXCLUDED.notes; + + -- Přidat záznam do logu + INSERT INTO ems.site_operating_mode_log + (site_id, mode_code, activated_at, activated_by, notes) + VALUES + (p_site_id, p_mode_code, now(), p_activated_by, p_notes); + + RETURN p_mode_code; +END; +$$; + +COMMENT ON FUNCTION ems.fn_set_mode(INT, TEXT, TEXT, TIMESTAMPTZ, TEXT) IS +'Přepne provozní režim lokality. Atomicky aktualizuje site_operating_mode a zapíše do audit logu. +Ignoruje přepnutí na stejný režim. Vyhodí výjimku pro neznámý kód režimu. +Příklad: SELECT ems.fn_set_mode(1, ''SELF_SUSTAIN'', ''user:jan'', NULL, ''Odjezd na dovolenou'');'; + +-- ============================================================ + +CREATE OR REPLACE FUNCTION ems.fn_restore_previous_mode( + p_site_id INT, + p_activated_by TEXT DEFAULT 'system' +) +RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + v_previous TEXT; +BEGIN + SELECT previous_mode INTO v_previous + FROM ems.site_operating_mode + WHERE site_id = p_site_id; + + IF v_previous IS NULL THEN + -- Fallback na AUTO pokud není předchozí režim + v_previous := 'AUTO'; + END IF; + + RETURN ems.fn_set_mode(p_site_id, v_previous, p_activated_by, NULL, 'Obnova předchozího režimu'); +END; +$$; + +COMMENT ON FUNCTION ems.fn_restore_previous_mode(INT, TEXT) IS +'Přepne lokalitu zpět na předchozí provozní režim (uložený v previous_mode). +Pokud předchozí režim neexistuje, přepne na AUTO. Používat po skončení dočasného přepisu.'; + +-- ============================================================ + +CREATE OR REPLACE FUNCTION ems.fn_expire_modes() +RETURNS INT +LANGUAGE plpgsql +AS $$ +DECLARE + v_count INT := 0; + v_rec RECORD; +BEGIN + -- Najít lokality kde vypršel valid_until a přepnout na AUTO + FOR v_rec IN + SELECT site_id, previous_mode + FROM ems.site_operating_mode + WHERE valid_until IS NOT NULL + AND valid_until <= now() + AND mode_code <> 'AUTO' + LOOP + PERFORM ems.fn_set_mode( + v_rec.site_id, + COALESCE(v_rec.previous_mode, 'AUTO'), + 'system:expiry', + NULL, + 'Automatické vypršení dočasného režimu' + ); + v_count := v_count + 1; + END LOOP; + + RETURN v_count; +END; +$$; + +COMMENT ON FUNCTION ems.fn_expire_modes() IS +'Zkontroluje všechny lokality s dočasným režimem (valid_until IS NOT NULL) a přepne zpět ty s prosahlým časem. +Volat každou minutu jako scheduled task. Vrátí počet přepnutých lokalit.'; + +-- ============================================================ + +CREATE OR REPLACE FUNCTION ems.fn_update_heartbeat( + p_site_id INT, + p_status TEXT DEFAULT 'ok', + p_ems_version TEXT DEFAULT NULL +) +RETURNS VOID +LANGUAGE sql +AS $$ + INSERT INTO ems.site_heartbeat (site_id, last_seen, status, ems_version) + VALUES (p_site_id, now(), p_status, p_ems_version) + ON CONFLICT (site_id) DO UPDATE SET + last_seen = now(), + status = EXCLUDED.status, + ems_version = COALESCE(EXCLUDED.ems_version, ems.site_heartbeat.ems_version); +$$; + +COMMENT ON FUNCTION ems.fn_update_heartbeat(INT, TEXT, TEXT) IS +'Aktualizuje informační heartbeat záznam EMS pro danou lokalitu. +Volat každou minutu z backend service po úspěšném odeslání pulzu do Loxone. +Slouží pouze pro EMS dashboard – Loxone watchdog nezávisí na této tabulce, +sleduje HTTP pulzy přímo a nezávisle na dostupnosti DB.'; diff --git a/db/views/R__vw_audit_summary.sql b/db/views/R__vw_audit_summary.sql new file mode 100644 index 0000000..7ad1e78 --- /dev/null +++ b/db/views/R__vw_audit_summary.sql @@ -0,0 +1,69 @@ +-- ============================================================= +-- R__vw_audit_summary.sql +-- EMS Platform – přehledové views pro audit a dashboard +-- Repeatable migration +-- ============================================================= + +-- Denní souhrn per lokalita +CREATE OR REPLACE VIEW ems.vw_audit_daily AS +SELECT + site_id, + date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague') AS day_local, + COUNT(*) AS interval_count, + -- Energie (kWh = W × 15min / 60 = W / 4 / 1000) + ROUND(SUM(GREATEST(actual_grid_power_w, 0))::NUMERIC / 4000, 3) AS import_kwh, + ROUND(SUM(ABS(LEAST(actual_grid_power_w, 0)))::NUMERIC / 4000, 3) AS export_kwh, + ROUND(SUM(GREATEST(actual_pv_power_w, 0))::NUMERIC / 4000, 3) AS pv_kwh, + ROUND(SUM(GREATEST(actual_load_power_w, 0))::NUMERIC / 4000, 3) AS load_kwh, + ROUND(SUM(GREATEST(actual_ev_power_w, 0))::NUMERIC / 4000, 3) AS ev_kwh, + ROUND(SUM(GREATEST(actual_heat_pump_power_w, 0))::NUMERIC / 4000, 3) AS hp_kwh, + -- Náklady + ROUND(SUM(actual_cost_czk), 2) AS actual_cost_czk, + ROUND(SUM(deviation_cost_czk), 2) AS total_deviation_czk, + -- Počet intervalů s velkými odchylkami (>1kW) + COUNT(*) FILTER (WHERE ABS(deviation_grid_w) > 1000) AS high_deviation_count +FROM ems.audit_interval +GROUP BY site_id, date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague'); + +COMMENT ON VIEW ems.vw_audit_daily IS +'Denní souhrn auditu per lokalita. Energie v kWh, náklady v Kč. +Používat pro dashboard denního přehledu a reporty.'; + +-- ============================================================ + +-- Týdenní souhrn +CREATE OR REPLACE VIEW ems.vw_audit_weekly AS +SELECT + site_id, + date_trunc('week', interval_start AT TIME ZONE 'Europe/Prague') AS week_local, + ROUND(SUM(GREATEST(actual_grid_power_w, 0))::NUMERIC / 4000, 1) AS import_kwh, + ROUND(SUM(ABS(LEAST(actual_grid_power_w, 0)))::NUMERIC / 4000, 1) AS export_kwh, + ROUND(SUM(GREATEST(actual_pv_power_w, 0))::NUMERIC / 4000, 1) AS pv_kwh, + ROUND(SUM(actual_cost_czk), 0) AS actual_cost_czk +FROM ems.audit_interval +GROUP BY site_id, date_trunc('week', interval_start AT TIME ZONE 'Europe/Prague'); + +COMMENT ON VIEW ems.vw_audit_weekly IS +'Týdenní souhrn auditu per lokalita.'; + +-- ============================================================ + +-- Aktuální den – hourly breakdown pro dashboard graf +CREATE OR REPLACE VIEW ems.vw_audit_today_hourly AS +SELECT + site_id, + date_trunc('hour', interval_start AT TIME ZONE 'Europe/Prague') AS hour_local, + ROUND(AVG(actual_pv_power_w)::NUMERIC / 1000, 2) AS avg_pv_kw, + ROUND(AVG(actual_battery_power_w)::NUMERIC / 1000, 2) AS avg_battery_kw, + ROUND(AVG(actual_grid_power_w)::NUMERIC / 1000, 2) AS avg_grid_kw, + ROUND(AVG(actual_load_power_w)::NUMERIC / 1000, 2) AS avg_load_kw, + ROUND(AVG(actual_battery_soc_pct), 1) AS avg_soc_pct, + ROUND(SUM(actual_cost_czk), 2) AS cost_czk +FROM ems.audit_interval +WHERE interval_start >= date_trunc('day', now() AT TIME ZONE 'Europe/Prague') + AND interval_start < date_trunc('day', now() AT TIME ZONE 'Europe/Prague') + INTERVAL '1 day' +GROUP BY site_id, date_trunc('hour', interval_start AT TIME ZONE 'Europe/Prague') +ORDER BY hour_local; + +COMMENT ON VIEW ems.vw_audit_today_hourly IS +'Hodinový přehled dnešního dne pro dashboard graf výkonů a nákladů.'; diff --git a/db/views/R__vw_latest_telemetry.sql b/db/views/R__vw_latest_telemetry.sql new file mode 100644 index 0000000..f900197 --- /dev/null +++ b/db/views/R__vw_latest_telemetry.sql @@ -0,0 +1,77 @@ +-- ============================================================= +-- R__vw_latest_telemetry.sql +-- EMS Platform – aktuální stav všech zařízení per lokalita +-- Repeatable migration +-- ============================================================= + +CREATE OR REPLACE VIEW ems.vw_latest_inverter AS +SELECT DISTINCT ON (t.inverter_id) + t.site_id, + t.inverter_id, + inv.code AS inverter_code, + t.measured_at, + t.pv_power_w, + t.battery_soc_percent, + t.battery_power_w, + t.grid_power_w, + t.load_power_w, + t.inverter_temp_c, + t.operating_mode, + t.fault_code, + now() - t.measured_at AS data_age +FROM ems.telemetry_inverter t +JOIN ems.asset_inverter inv ON inv.id = t.inverter_id +ORDER BY t.inverter_id, t.measured_at DESC; + +COMMENT ON VIEW ems.vw_latest_inverter IS +'Nejnovější telemetrická data pro každý střídač. Slouží pro real-time dashboard a health check.'; + +-- ------------------------------------------------------------ + +CREATE OR REPLACE VIEW ems.vw_latest_ev_charger AS +SELECT DISTINCT ON (t.charger_id, t.connector_id) + t.site_id, + t.charger_id, + ch.code AS charger_code, + t.connector_id, + t.measured_at, + t.status, + t.power_w, + t.energy_kwh, + t.current_a, + t.session_id, + t.error_code, + now() - t.measured_at AS data_age +FROM ems.telemetry_ev_charger t +JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id +ORDER BY t.charger_id, t.connector_id, t.measured_at DESC; + +COMMENT ON VIEW ems.vw_latest_ev_charger IS +'Nejnovější telemetrická data pro každý konektor EV nabíječky. Slouží pro dashboard a řízení nabíjení.'; + +-- ------------------------------------------------------------ + +CREATE OR REPLACE VIEW ems.vw_latest_heat_pump AS +SELECT DISTINCT ON (t.heat_pump_id) + t.site_id, + t.heat_pump_id, + hp.code AS heat_pump_code, + t.measured_at, + t.outdoor_temp_c, + t.tuv_tank_temp_c, + t.water_outlet_temp_c, + t.power_w, + t.operating_mode, + t.cop_actual, + t.defrost_active, + t.alarm_code, + -- Odhadovaný COP pro aktuální venkovní teplotu + ems.fn_cop_estimate(t.heat_pump_id, t.outdoor_temp_c) AS cop_estimated, + now() - t.measured_at AS data_age +FROM ems.telemetry_heat_pump t +JOIN ems.asset_heat_pump hp ON hp.id = t.heat_pump_id +ORDER BY t.heat_pump_id, t.measured_at DESC; + +COMMENT ON VIEW ems.vw_latest_heat_pump IS +'Nejnovější telemetrická data pro každé tepelné čerpadlo včetně odhadovaného COP. +Slouží pro real-time dashboard a rozhodovací logiku plánování.'; diff --git a/db/views/R__vw_operating_mode.sql b/db/views/R__vw_operating_mode.sql new file mode 100644 index 0000000..74a7a99 --- /dev/null +++ b/db/views/R__vw_operating_mode.sql @@ -0,0 +1,75 @@ +-- ============================================================= +-- R__vw_operating_mode.sql +-- EMS Platform – views pro provozní režimy a heartbeat +-- Repeatable migration +-- ============================================================= + +-- Aktuální stav všech lokalit (pro dashboard a PostgREST) +CREATE OR REPLACE VIEW ems.vw_site_status AS +SELECT + s.id AS site_id, + s.code AS site_code, + s.name AS site_name, + 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, + -- Heartbeat + hb.last_seen AS ems_last_seen, + hb.status AS ems_status, + EXTRACT(EPOCH FROM (now() - hb.last_seen))::INT AS ems_age_seconds, + -- Varování pokud EMS dlouho nepingoval + CASE + WHEN hb.last_seen IS NULL THEN 'never_seen' + WHEN now() - hb.last_seen > INTERVAL '5 minutes' THEN 'stale' + WHEN now() - hb.last_seen > INTERVAL '2 minutes' THEN 'delayed' + ELSE 'ok' + END AS ems_heartbeat_status, + -- Aktuální telemetrie (snapshot) + li.pv_power_w, + li.battery_soc_percent, + li.battery_power_w, + li.grid_power_w, + li.load_power_w, + li.measured_at AS telemetry_at +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 +LEFT JOIN ems.site_heartbeat hb ON hb.site_id = s.id +LEFT JOIN ems.vw_latest_inverter li ON li.site_id = s.id +WHERE s.active = true; + +COMMENT ON VIEW ems.vw_site_status IS +'Kompletní stavový přehled lokality: aktivní režim, heartbeat EMS, aktuální telemetrie. +Primární view pro dashboard a health check endpoint. Jeden řádek na aktivní lokalitu. +ems_heartbeat_status slouží pro EMS vlastní alerting – Loxone watchdog tuto tabulku nečte, +sleduje HTTP pulzy přímo (viz docs/loxone-integration.md).'; + +-- ============================================================ + +-- Log přepnutí režimů (pro UI historii) +CREATE OR REPLACE VIEW ems.vw_mode_log_recent AS +SELECT + l.id, + l.site_id, + s.code AS site_code, + l.mode_code, + d.name AS mode_name, + l.activated_at, + l.deactivated_at, + EXTRACT(EPOCH FROM COALESCE(l.deactivated_at, now()) - l.activated_at)::INT AS duration_sec, + l.activated_by, + l.notes +FROM ems.site_operating_mode_log l +JOIN ems.site s ON s.id = l.site_id +JOIN ems.operating_mode_def d ON d.code = l.mode_code +WHERE l.activated_at >= now() - INTERVAL '7 days' +ORDER BY l.activated_at DESC; + +COMMENT ON VIEW ems.vw_mode_log_recent IS +'Posledních 7 dní přepnutí provozních režimů. Slouží pro audit log v UI.'; diff --git a/db/views/R__vw_site_effective_price.sql b/db/views/R__vw_site_effective_price.sql new file mode 100644 index 0000000..3d05311 --- /dev/null +++ b/db/views/R__vw_site_effective_price.sql @@ -0,0 +1,42 @@ +-- ============================================================= +-- R__vw_site_effective_price.sql +-- EMS Platform – view efektivních cen per site +-- Repeatable migration +-- ============================================================= + +CREATE OR REPLACE VIEW ems.vw_site_effective_price AS +SELECT + smc.site_id, + mip.interval_start, + mip.interval_end, + mip.market_source, + -- Raw ceny + mip.buy_raw_price_czk_kwh, + mip.sell_raw_price_czk_kwh, + -- Marže + smc.buy_margin_fixed_czk, + smc.buy_margin_percent, + smc.sell_margin_fixed_czk, + smc.sell_margin_percent, + -- Efektivní ceny + ROUND( + mip.buy_raw_price_czk_kwh + + smc.buy_margin_fixed_czk + + (mip.buy_raw_price_czk_kwh * smc.buy_margin_percent / 100.0), + 6 + ) AS effective_buy_price_czk_kwh, + ROUND( + mip.sell_raw_price_czk_kwh + + smc.sell_margin_fixed_czk + + (mip.sell_raw_price_czk_kwh * smc.sell_margin_percent / 100.0), + 6 + ) AS effective_sell_price_czk_kwh +FROM ems.market_interval_price mip +CROSS JOIN ems.site_market_config smc +WHERE smc.valid_from <= mip.interval_start + AND (smc.valid_to IS NULL OR smc.valid_to > mip.interval_start); + +COMMENT ON VIEW ems.vw_site_effective_price IS +'Efektivní nákupní a prodejní ceny elektřiny per lokalita a 15min interval. +Dopočítává marže z site_market_config na raw ceny z market_interval_price. +Nezahrnuje data bez platné market_config. Používat pro plánování a audit.'; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..579a99a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,112 @@ +services: + + db: + image: timescale/timescaledb:latest-pg16 + restart: unless-stopped + environment: + POSTGRES_DB: ems + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - db_data:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ems"] + interval: 10s + timeout: 5s + retries: 5 + + flyway: + image: flyway/flyway:10 + depends_on: + db: + condition: service_healthy + environment: + FLYWAY_URL: jdbc:postgresql://db:5432/ems + FLYWAY_USER: ${DB_USER} + FLYWAY_PASSWORD: ${DB_PASSWORD} + FLYWAY_SCHEMAS: ems + FLYWAY_LOCATIONS: filesystem:/flyway/sql/migration,filesystem:/flyway/sql/routines,filesystem:/flyway/sql/views + FLYWAY_BASELINE_ON_MIGRATE: "false" + command: migrate + volumes: + - ./db/migration:/flyway/sql/migration + - ./db/routines:/flyway/sql/routines + - ./db/views:/flyway/sql/views + + postgrest: + image: postgrest/postgrest:v12.2.3 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + flyway: + condition: service_completed_successfully + environment: + 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_JWT_SECRET: ${POSTGREST_JWT_SECRET} + PGRST_SERVER_PORT: 3000 + PGRST_OPENAPI_SERVER_PROXY_URI: http://localhost/rest + ports: + - "127.0.0.1:3000:3000" + + backend: + build: ./backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + flyway: + condition: service_completed_successfully + env_file: + - .env + environment: + DB_HOST: db + DB_PORT: "5432" + POSTGRES_HOST: db + POSTGRES_PORT: "5432" + DB_NAME: ems + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + OTE_API_URL: ${OTE_API_URL:-https://www.ote-cr.cz/pubapi/v1/market-data/dam} + EUR_CZK_RATE: ${EUR_CZK_RATE:-25.0} + OPEN_METEO_API_URL: ${OPEN_METEO_API_URL:-https://api.open-meteo.com/v1/forecast} + TELEMETRY_POLL_INTERVAL_SEC: ${TELEMETRY_POLL_INTERVAL_SEC:-60} + PLANNING_HORIZON_HOURS: ${PLANNING_HORIZON_HOURS:-36} + PLANNING_HP_MAX_COST_CZK_KWH: ${PLANNING_HP_MAX_COST_CZK_KWH:-3.0} + LOXONE_USER: ${LOXONE_USER:-} + LOXONE_PASSWORD: ${LOXONE_PASSWORD:-} + POSTGREST_JWT_SECRET: ${POSTGREST_JWT_SECRET} + POSTGREST_ANON_ROLE: ${POSTGREST_ANON_ROLE:-ems_user} + ports: + - "127.0.0.1:8000:8000" + volumes: + - ./backend:/app + + frontend: + build: ./frontend + restart: unless-stopped + ports: + - "80:80" + depends_on: + - backend + - postgrest + +volumes: + db_data: + driver: local + +# Explicitní podsíť – vyhne se vyčerpání výchozích Docker address pools při mnoha projektech. +# Pokud 172.28.240.0/24 koliduje s jinou sítí, změň subnet/gateway. +networks: + default: + name: ems-cursor_net + driver: bridge + ipam: + config: + - subnet: 172.28.240.0/24 + gateway: 172.28.240.1 diff --git a/docs/01-overview.md b/docs/01-overview.md new file mode 100644 index 0000000..c5ba77c --- /dev/null +++ b/docs/01-overview.md @@ -0,0 +1,62 @@ +# EMS Platform – Overview + +## Co systém dělá + +Energy Management System (EMS) je multi-site platforma pro optimalizaci výroby, spotřeby a obchodování s energií na objektech vybavených FVE, baterií, flexibilními spotřebiči a přístupem ke spotovému trhu OTE CZ. + +Systém přebírá rozhodovací logiku od Loxone a stává se „mozkem" – plánuje, optimalizuje a posílá setpointy zpět do Loxone jako exekutoru. + +## Hlavní funkce + +- Sběr telemetrie ze střídače Deye přes Modbus/RS485 → Waveshare IP převodník +- Sběr dat z EV nabíječek Teltonika přes API +- Stahování spotových cen OTE CZ (15min granularita) +- Predikce výroby FVE (per pole, per azimut/sklon) +- Plánování provozu baterie, EV nabíjení, TUV na základě cen a predikce +- Export setpointů do Loxone přes HTTP Virtual Inputs +- Audit skutečnosti vs plánu +- Multi-site: jeden systém, více lokalit + +## Co systém není + +- Není SCADA – neprovádí real-time ochranné funkce (to dělá Loxone/střídač) +- Neřídí ongridový střídač (10kWp zapojený do GEN portu) – ten je autonomní +- Nenahrazuje Loxone jako exekutor lokální automatizace + +## Scope první instalace (site: home-01) + +| Komponenta | Detail | +|---|---| +| Střídač | Deye SUN-20K-SG01LP1-EU (20kW LV, hybridní) | +| Baterie | 64 kWh LV (připojená k Deye) | +| FVE pole A | ~10 kWp (řízené Deye) | +| FVE pole B | ~10 kWp (ongridový střídač → GEN port Deye, autonomní, neplánujeme řídit) | +| EV nabíječky | 2× Teltonika TeltoCharge 22kW | +| TUV | Tepelné čerpadlo / boiler (přes Loxone) | +| Komunikace střídač | RS485 → Waveshare WS-ETH (Modbus TCP) | +| Komunikace Loxone | HTTP Web Services / Virtual Inputs | +| Trh | OTE CZ, spotové ceny, 15min intervaly | + +## Technologický stack + +| Vrstva | Technologie | +|---|---| +| DB | PostgreSQL 16 + TimescaleDB | +| API / BFF | PostgREST (automatické REST z DB schématu) | +| Backend logika | Python (FastAPI) – plánovač, sběr dat, integrace | +| Frontend | React + TypeScript | +| Komunikace střídač | Python modbus-tcp klient | +| Kontejnerizace | Docker Compose | +| Migrace | Flyway nebo plain SQL skripty | + +## Časová granularita + +**Primární granularita celého systému je 15 minut.** + +- Spotové ceny: 15min intervaly +- Telemetrie: ukládána po 1min, agregována na 15min pro plánování +- Plánování: 15min sloty +- Setpointy pro Loxone: 15min +- Audit skutečnost vs plán: 15min + +Hodinové pohledy existují pouze jako agregovaná views nad 15min daty. diff --git a/docs/02-architecture.md b/docs/02-architecture.md new file mode 100644 index 0000000..d15d1d0 --- /dev/null +++ b/docs/02-architecture.md @@ -0,0 +1,217 @@ +# EMS Platform – Architektura + +## Vrstvy systému + +``` +┌─────────────────────────────────────────────┐ +│ React Frontend (Vite + TypeScript) │ +│ Dashboard, plány, telemetrie, overrides │ +└─────────────┬───────────────────────────────┘ + │ HTTP/REST +┌─────────────▼───────────────────────────────┐ +│ PostgREST │ +│ Auto-REST API z PostgreSQL schématu ems │ +│ Read: views, tabulky │ +│ Write: insert/update přes API │ +└─────────────┬───────────────────────────────┘ + │ SQL +┌─────────────▼───────────────────────────────┐ +│ PostgreSQL 16 + TimescaleDB │ +│ Schéma: ems │ +│ Funkce, views, hypertables │ +└─────────────┬───────────────────────────────┘ + │ +┌─────────────▼───────────────────────────────┐ +│ FastAPI (Python) │ +│ – Scheduled tasks (APScheduler) │ +│ – telemetry_collector (každých 60s) │ +│ – price_importer (denně 14:00) │ +│ – forecast_service (denně 14:30) │ +│ – planning_engine (denně 15:00) │ +│ – control_exporter (každých 15min) │ +│ – audit_filler (každých 15min) │ +└──────┬──────────────────────────┬────────────┘ + │ Modbus TCP │ HTTP +┌──────▼──────┐ ┌───────▼────────────┐ +│ Waveshare │ │ Loxone Miniserver │ +│ WS-ETH │ │ (setpoint přijímač)│ +└──────┬──────┘ └────────────────────┘ + │ RS485 +┌──────┼──────────────────────────────┐ +│ Deye SUN-20K │ Teltonika 2× │ Samsung TČ │ +└────────────────┴────────────────────┴──────────────┘ +``` + +--- + +## Komponenty + +| Komponenta | Technologie | Port | Popis | +|---|---|---|---| +| `db` | PostgreSQL 16 + TimescaleDB | 5432 | Datová vrstva | +| `postgrest` | PostgREST 12 | 3000 | Auto-REST API | +| `backend` | Python 3.12 / FastAPI | 8000 | Logika, scheduled tasks | +| `frontend` | React + Vite + TypeScript | 5173 (dev) / 80 (prod) | UI | + +--- + +## Adresář projektu + +``` +ems-platform/ + CLAUDE.md + docker-compose.yml + docker-compose.dev.yml + .env.example + .env ← gitignore! + + db/ + migration/ + V001__init_schema.sql + V002__timescale_hypertables.sql + V003__seed_site_home01.sql + routines/ + R__fn_effective_price.sql + R__fn_cop_estimate.sql + R__fn_baseline_consumption.sql + R__fn_fill_audit_interval.sql + R__fn_plan_day.sql + R__fn_create_planning_run.sql + views/ + R__vw_site_effective_price.sql + R__vw_latest_telemetry.sql + R__vw_actual_baseline.sql + R__vw_audit_summary.sql + R__vw_heat_pump_cop_history.sql + flyway.conf + + backend/ + Dockerfile + requirements.txt + app/ + main.py ← FastAPI app + scheduler setup + config.py ← settings z env + database.py ← asyncpg connection pool + services/ + telemetry_collector.py + price_importer.py + forecast_service.py + planning_engine.py ← volá ems.fn_create_planning_run() + control_exporter.py + audit_filler.py + modbus/ + deye_client.py + ev_charger_client.py + heat_pump_client.py + models/ + site.py + assets.py + setpoints.py + + frontend/ + Dockerfile + package.json + vite.config.ts + src/ + main.tsx + App.tsx + api/ + postgrest.ts ← PostgREST client + backend.ts ← FastAPI client + pages/ + Dashboard.tsx + Planning.tsx + Telemetry.tsx + Settings.tsx + components/ + PowerFlowChart.tsx + PriceChart.tsx + SocGauge.tsx + OverridePanel.tsx + + docs/ + 01-overview.md + 02-architecture.md ← tento soubor + 03-data-model.md + 04-modules/ + market-prices.md + forecast.md + consumption.md + heat-pump.md + telemetry.md + control.md + planning.md + 06-open-questions.md +``` + +--- + +## Komunikační toky + +### Sběr dat (každých 60s) +``` +Zařízení → Waveshare → Modbus TCP → telemetry_collector → PostgreSQL +``` + +### Denní plánování (15:00) +``` +PostgreSQL (ceny + forecast) → fn_create_planning_run() → planning_interval +``` + +### Export setpointů (každých 15min) +``` +PostgreSQL (planning_interval + overrides) → control_exporter + → Modbus TCP → Waveshare → Deye / Teltonika / Samsung + → HTTP → Loxone +``` + +### Frontend +``` +Browser → PostgREST (čtení views/tabulek) +Browser → FastAPI (triggery: replanning, override, manual export) +``` + +--- + +## Deployment: single-site (výchozí) + +Vše na jednom stroji (x86 mini PC nebo silnější RPi 5): + +``` +Docker Compose: + db (PostgreSQL + TimescaleDB) + postgrest (PostgREST) + backend (FastAPI + všechny services) + frontend (Nginx + React build) + flyway (migrace při startu) +``` + +### Minimální HW požadavky + +| Parametr | Minimum | Doporučeno | +|---|---|---| +| CPU | 2 jádra | 4 jádra (x86) | +| RAM | 4 GB | 8 GB | +| Storage | SSD 64 GB | NVMe 256 GB | +| OS | Debian 12 / Ubuntu 22.04 | Ubuntu 22.04 LTS | +| Síť | 100 Mbps | Gigabit Ethernet | + +> **Raspberry Pi 5 (8GB):** Použitelné pro single-site s SSD přes USB 3 nebo NVMe HAT. +> **Nedoporučovat microSD** – TimescaleDB zápisy microSD rychle opotřebují. + +--- + +## Flyway konfigurace + +```properties +# db/flyway.conf +flyway.url=jdbc:postgresql://db:5432/ems +flyway.user=${DB_USER} +flyway.password=${DB_PASSWORD} +flyway.schemas=ems +flyway.locations=filesystem:/flyway/migration,filesystem:/flyway/routines,filesystem:/flyway/views +flyway.validateOnMigrate=true +flyway.outOfOrder=false +``` + +Flyway se spouští jako jednorázový kontejner při `docker-compose up`. diff --git a/docs/03-data-model.md b/docs/03-data-model.md new file mode 100644 index 0000000..08a4f66 --- /dev/null +++ b/docs/03-data-model.md @@ -0,0 +1,430 @@ +# EMS Platform – Data Model + +## Principy + +- Vše je vztaženo ke `site` (lokalitě) +- Raw tržní data jsou sdílená, efektivní ceny se dopočítávají per site +- Telemetrie a plány mají vždy `site_id` + `interval_start` +- TimescaleDB hypertable pro časové série (telemetrie, ceny, plány) +- Primární časová granularita: **15 minut** + +--- + +## Konfigurace lokalit + +### `site` +Základní entita. Jedna instalace = jeden objekt. + +```sql +CREATE TABLE site ( + id SERIAL PRIMARY KEY, + code TEXT UNIQUE NOT NULL, -- např. 'home-01' + name TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'Europe/Prague', + active BOOLEAN NOT NULL DEFAULT true, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT now() +); +``` + +### `site_endpoint` +Komunikační endpointy – každá lokalita může mít více. + +```sql +CREATE TABLE site_endpoint ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES site(id), + endpoint_type TEXT NOT NULL, -- 'modbus_tcp', 'loxone_http', 'teltonika_api' + host TEXT NOT NULL, + port INT, + protocol TEXT, -- 'modbus_tcp', 'http', 'https' + auth_reference TEXT, -- odkaz na secret / env proměnnou + enabled BOOLEAN DEFAULT true, + notes TEXT +); +``` + +### `site_market_config` +Obchodní konfigurace s maržemi. Platnost od-do umožňuje historické sledování změn. + +```sql +CREATE TABLE site_market_config ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES site(id), + purchase_pricing_mode TEXT NOT NULL DEFAULT 'spot', -- 'spot', 'fixed', 'hybrid' + sale_pricing_mode TEXT NOT NULL DEFAULT 'spot', + buy_margin_fixed_czk NUMERIC(10,4) DEFAULT 0, -- Kč/kWh + buy_margin_percent NUMERIC(6,4) DEFAULT 0, + sell_margin_fixed_czk NUMERIC(10,4) DEFAULT 0, -- Kč/kWh (záporná = srážka) + sell_margin_percent NUMERIC(6,4) DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'CZK', + valid_from TIMESTAMPTZ NOT NULL, + valid_to TIMESTAMPTZ, -- NULL = aktuálně platný + notes TEXT +); +``` + +### `site_grid_connection` +Síťová omezení lokality. + +```sql +CREATE TABLE site_grid_connection ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES site(id) UNIQUE, + max_import_power_w INT NOT NULL, + max_export_power_w INT NOT NULL DEFAULT 0, + no_export BOOLEAN DEFAULT false, + reserved_capacity_w INT DEFAULT 0, + notes TEXT +); +``` + +--- + +## Aktiva + +### `asset_inverter` + +```sql +CREATE TABLE asset_inverter ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES site(id), + code TEXT NOT NULL, + manufacturer TEXT, -- 'Deye' + model TEXT, -- 'SUN-20K-SG01LP1-EU' + endpoint_id INT REFERENCES site_endpoint(id), + max_charge_power_w INT, + max_discharge_power_w INT, + max_export_power_w INT, + controllable BOOLEAN DEFAULT true, -- false = ongridový autonomní + notes TEXT +); +``` + +### `asset_battery` + +```sql +CREATE TABLE asset_battery ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES site(id), + inverter_id INT REFERENCES asset_inverter(id), + code TEXT NOT NULL, + usable_capacity_wh INT NOT NULL, -- 64000 + min_soc_percent NUMERIC(5,2) DEFAULT 10, + reserve_soc_percent NUMERIC(5,2) DEFAULT 20, -- rezerva pro výpadek + max_soc_percent NUMERIC(5,2) DEFAULT 95, + charge_efficiency NUMERIC(5,4) DEFAULT 0.95, + discharge_efficiency NUMERIC(5,4) DEFAULT 0.95, + degradation_cost_czk_kwh NUMERIC(8,4) DEFAULT 0.5 -- náklad na cyklus +); +``` + +### `asset_pv_array` +Každé FVE pole zvlášť – důležité pro predikci (azimut, sklon). + +```sql +CREATE TABLE asset_pv_array ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES site(id), + inverter_id INT REFERENCES asset_inverter(id), + code TEXT NOT NULL, + name TEXT, + nominal_power_wp INT NOT NULL, -- 10000 + azimuth_deg NUMERIC(6,2), -- 0=S, 90=Z, -90=V + tilt_deg NUMERIC(5,2), + module_count INT, + shading_factor NUMERIC(4,3) DEFAULT 1.0, + controllable BOOLEAN DEFAULT false, -- ongridový = false + notes TEXT +); +``` + +### `asset_ev_charger` +EV nabíječky. Na první instalaci jsou 2× Teltonika. + +```sql +CREATE TABLE asset_ev_charger ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES site(id), + code TEXT NOT NULL, + manufacturer TEXT, -- 'Teltonika' + model TEXT, -- 'TeltoCharge' + endpoint_id INT REFERENCES site_endpoint(id), + max_power_w INT NOT NULL, -- 22000 + min_power_w INT DEFAULT 1380, -- min pro jednofázové nabíjení + phases INT DEFAULT 3, + connector_count INT DEFAULT 1, + schedulable BOOLEAN DEFAULT true, + notes TEXT +); +``` + +### `asset_flexible_device` +Generická tabulka pro ostatní flexibilní spotřebiče (TUV, tepelné čerpadlo, ...). + +```sql +CREATE TABLE asset_flexible_device ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES site(id), + code TEXT NOT NULL, + device_type TEXT NOT NULL, -- 'tuv', 'heat_pump', 'pool', ... + control_mode TEXT DEFAULT 'loxone', -- jak se řídí + max_power_w INT, + min_power_w INT DEFAULT 0, + interruptible BOOLEAN DEFAULT true, + schedulable BOOLEAN DEFAULT true, + priority INT DEFAULT 50, -- 0=nejvyšší priorita + notes TEXT +); +``` + +--- + +## Tržní data + +### `market_interval_price` +Raw spotové ceny OTE CZ. Bez vazby na lokalitu – sdílené. +TimescaleDB hypertable. + +```sql +CREATE TABLE market_interval_price ( + market_source TEXT NOT NULL DEFAULT 'OTE_CZ', + interval_start TIMESTAMPTZ NOT NULL, + interval_end TIMESTAMPTZ NOT NULL, + buy_raw_price_czk_kwh NUMERIC(10,6), -- nákupní raw cena Kč/kWh + sell_raw_price_czk_kwh NUMERIC(10,6), -- prodejní raw cena (ref. cena) + currency TEXT DEFAULT 'CZK', + imported_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (market_source, interval_start) +); +-- SELECT create_hypertable('market_interval_price', 'interval_start'); +``` + +### View: `market_vw_site_effective_price` +Efektivní ceny per site – dopočítané z raw + marže. Neukládá se, počítá se za běhu. + +```sql +CREATE VIEW market_vw_site_effective_price AS +SELECT + smc.site_id, + mip.interval_start, + mip.interval_end, + mip.buy_raw_price_czk_kwh, + mip.sell_raw_price_czk_kwh, + -- efektivní nákupní cena + mip.buy_raw_price_czk_kwh + + smc.buy_margin_fixed_czk + + (mip.buy_raw_price_czk_kwh * smc.buy_margin_percent / 100) + AS effective_buy_price_czk_kwh, + -- efektivní prodejní cena + mip.sell_raw_price_czk_kwh + + smc.sell_margin_fixed_czk + + (mip.sell_raw_price_czk_kwh * smc.sell_margin_percent / 100) + AS effective_sell_price_czk_kwh +FROM market_interval_price mip +CROSS JOIN site_market_config smc +WHERE smc.valid_to IS NULL -- aktuálně platná konfigurace + OR now() BETWEEN smc.valid_from AND smc.valid_to; +``` + +--- + +## Telemetrie + +### `telemetry_inverter` +Raw měření ze střídače Deye (Modbus). 1min granularita, hypertable. + +```sql +CREATE TABLE telemetry_inverter ( + site_id INT REFERENCES site(id), + inverter_id INT REFERENCES asset_inverter(id), + measured_at TIMESTAMPTZ NOT NULL, + -- výroba + pv_power_w INT, -- celkový výkon FVE (oba stringy) + -- baterie + battery_soc_percent NUMERIC(5,2), + battery_power_w INT, -- kladné = nabíjení, záporné = vybíjení + battery_voltage_v NUMERIC(7,3), + -- síť + grid_power_w INT, -- kladné = import, záporné = export + grid_voltage_v NUMERIC(7,3), + -- spotřeba + load_power_w INT, -- celková spotřeba objektu + -- teploty + inverter_temp_c NUMERIC(5,2), + -- provozní stav + operating_mode TEXT, -- raw hodnota z Modbus registru + fault_code INT, + PRIMARY KEY (inverter_id, measured_at) +); +-- SELECT create_hypertable('telemetry_inverter', 'measured_at'); +``` + +### `telemetry_ev_charger` +Stav EV nabíječek. + +```sql +CREATE TABLE telemetry_ev_charger ( + site_id INT REFERENCES site(id), + charger_id INT REFERENCES asset_ev_charger(id), + measured_at TIMESTAMPTZ NOT NULL, + connector_id INT DEFAULT 1, + status TEXT, -- 'available', 'charging', 'faulted', ... + power_w INT, + energy_kwh NUMERIC(10,3), -- kumulativní + current_a NUMERIC(7,3), + voltage_v NUMERIC(7,3), + session_id TEXT, + PRIMARY KEY (charger_id, connector_id, measured_at) +); +-- SELECT create_hypertable('telemetry_ev_charger', 'measured_at'); +``` + +--- + +## Predikce výroby + +### `forecast_pv_run` +Každý běh predikce je jeden záznam (kdy se spustil, jaký model, jaké pole). + +```sql +CREATE TABLE forecast_pv_run ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES site(id), + pv_array_id INT REFERENCES asset_pv_array(id), -- NULL = celá lokalita + forecast_source TEXT NOT NULL, -- 'open_meteo', 'solcast', 'manual' + model_params JSONB, -- parametry modelu + horizon_start TIMESTAMPTZ NOT NULL, + horizon_end TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + status TEXT DEFAULT 'ok' -- 'ok', 'partial', 'failed' +); +``` + +### `forecast_pv_interval` +Samotná predikovaná data, 15min granularita. + +```sql +CREATE TABLE forecast_pv_interval ( + run_id INT REFERENCES forecast_pv_run(id), + pv_array_id INT REFERENCES asset_pv_array(id), + interval_start TIMESTAMPTZ NOT NULL, + power_w INT NOT NULL, -- predikovaný výkon + irradiance_wm2 NUMERIC(8,2), -- GHI ze weather service + temp_c NUMERIC(5,2), + PRIMARY KEY (run_id, pv_array_id, interval_start) +); +-- SELECT create_hypertable('forecast_pv_interval', 'interval_start'); +``` + +--- + +## Plánování + +### `planning_run` +Jeden plánovací běh per site. + +```sql +CREATE TABLE planning_run ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES site(id), + horizon_start TIMESTAMPTZ NOT NULL, + horizon_end TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + status TEXT DEFAULT 'draft', -- 'draft', 'approved', 'active', 'superseded' + solver_params JSONB, + notes TEXT +); +``` + +### `planning_interval` +Výstup plánování – jeden řádek = jeden 15min slot. + +```sql +CREATE TABLE planning_interval ( + run_id INT REFERENCES planning_run(id), + interval_start TIMESTAMPTZ NOT NULL, + -- baterie + battery_setpoint_w INT, -- kladné = nabíjení, záporné = vybíjení + battery_soc_target_pct NUMERIC(5,2), + -- grid + grid_setpoint_w INT, -- kladné = import, záporné = export + -- EV (agregát za všechny nabíječky na site) + ev_charge_power_w INT, + -- TUV + tuv_enabled BOOLEAN, + -- ekonomika + expected_cost_czk NUMERIC(10,4), + effective_buy_price NUMERIC(10,6), + effective_sell_price NUMERIC(10,6), + PRIMARY KEY (run_id, interval_start) +); +``` + +--- + +## Audit + +### `audit_interval` +Skutečnost vs plán, 15min granularita. Plněno zpětně po dostupnosti dat. + +```sql +CREATE TABLE audit_interval ( + site_id INT REFERENCES site(id), + interval_start TIMESTAMPTZ NOT NULL, + planning_run_id INT REFERENCES planning_run(id), + -- skutečnost (z telemetrie, průměr/agregát za 15min) + actual_pv_power_w INT, + actual_battery_power_w INT, + actual_grid_power_w INT, + actual_load_power_w INT, + actual_battery_soc NUMERIC(5,2), + -- odchylky + deviation_grid_w INT, -- actual - planned + actual_cost_czk NUMERIC(10,4), + PRIMARY KEY (site_id, interval_start) +); +-- SELECT create_hypertable('audit_interval', 'interval_start'); +``` + +--- + +## Spotřeba + +### `consumption_baseline_interval` +Bazální (neflexibilní) spotřeba – historická a predikovaná, 15min. + +```sql +CREATE TABLE consumption_baseline_interval ( + site_id INT REFERENCES site(id), + interval_start TIMESTAMPTZ NOT NULL, + data_type TEXT NOT NULL, -- 'actual', 'forecast' + power_w INT NOT NULL, + source TEXT, -- 'measured', 'model_v1', ... + PRIMARY KEY (site_id, data_type, interval_start) +); +``` + +### Flexibilní spotřebiče +Flexibilní spotřeba se neukládá souhrnně – odvozuje se ze součtu `telemetry_ev_charger` + stavů `asset_flexible_device` per interval. Plánovaná flexibilní spotřeba je součástí `planning_interval`. + +--- + +## Override + +### `site_override` +Manuální přepisy pro provozní stavy. + +```sql +CREATE TABLE site_override ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES site(id), + override_type TEXT NOT NULL, -- 'force_charge', 'force_discharge', 'block_export', 'manual_setpoint' + value_json JSONB, -- parametry přepisu + valid_from TIMESTAMPTZ NOT NULL, + valid_to TIMESTAMPTZ, + reason TEXT, + created_by TEXT, + created_at TIMESTAMPTZ DEFAULT now() +); +``` diff --git a/docs/04-modules/consumption.md b/docs/04-modules/consumption.md new file mode 100644 index 0000000..16775b7 --- /dev/null +++ b/docs/04-modules/consumption.md @@ -0,0 +1,187 @@ +# Modul: Consumption (Spotřeba) + +## Členění spotřeby + +Systém rozlišuje dva typy spotřeby: + +### 1. Bazální (neflexibilní) spotřeba +- Spotřeba kterou nelze odložit ani řídit +- Příklady: osvětlení, elektronika, vaření, cirkulační čerpadla +- **Zdroj:** měřená telemetrie ze střídače (`load_power_w` - suma flexibilní spotřeby) +- **Použití v plánování:** jako pevný vstup (musí být pokryta) + +### 2. Flexibilní spotřeba +- Spotřeba kterou lze časově přesunout nebo regulovat +- Příklady: nabíjení EV, ohřev TUV, tepelné čerpadlo (při přetopení zásobníku) +- **Zdroj:** telemetrie z konkrétních zařízení (EV nabíječky, stavové vstupy Loxone) +- **Použití v plánování:** jako optimalizovatelná proměnná + +--- + +## Jak se měří celková spotřeba + +Střídač Deye poskytuje přes Modbus registr `load_power_w` = celková okamžitá spotřeba objektu (vše za hlavním jističem na AC straně střídače). + +``` +load_power_w (Deye) = bazální_spotřeba + EV_nabíjení + TUV + ostatní flexibilní +``` + +### Odvození bazální spotřeby + +``` +bazální_w = load_power_w - sum(flexibilní zařízení aktuální výkon) +``` + +V praxi: +``` +bazální_w = load_power_w + - ev_charger_1_power_w + - ev_charger_2_power_w + - tuv_power_w (pokud je měřitelná zvlášť) +``` + +> **Předpoklad:** TUV výkon není přímo měřen, pouze víme že je ON/OFF (přes Loxone). Pokud je ON, odečítáme `asset_flexible_device.max_power_w`. Toto je zjednodušení – lze zpřesnit později podružným měřením. + +--- + +## Ukládání spotřeby + +### Real-time telemetrie +Celková spotřeba je součástí `telemetry_inverter.load_power_w` (1min záznamy). + +EV nabíječky mají vlastní tabulku `telemetry_ev_charger` s přesným výkonem. + +### Agregovaná spotřeba pro plánování +Tabulka `consumption_baseline_interval` ukládá 15min průměry bazální spotřeby: + +- `data_type = 'actual'` – historická skutečnost (zpětně dopočítáno z telemetrie) +- `data_type = 'forecast'` – predikce pro plánování + +--- + +## Predikce bazální spotřeby + +### Metoda: historický průměr + denní profil + +Jednoduchý model pro začátek: + +```python +def forecast_baseline_consumption(site_id: int, target_date: date): + """ + Predikce bazální spotřeby na základě průměru posledních N podobných dní. + Podobnost: stejný den v týdnu, přibližně stejná roční doba. + """ + lookback_weeks = 4 + day_of_week = target_date.weekday() + + # Stáhnout historické bazální hodnoty pro stejné dny v týdnu + historical = db.query(""" + SELECT interval_start, power_w + FROM consumption_baseline_interval + WHERE site_id = %s + AND data_type = 'actual' + AND EXTRACT(dow FROM interval_start) = %s + AND interval_start >= %s + ORDER BY interval_start + """, site_id, day_of_week, target_date - timedelta(weeks=lookback_weeks)) + + # Průměr per 15min slot + profile = aggregate_by_time_of_day(historical) # 96 hodnot (15min sloty) + return profile +``` + +--- + +## Flexibilní zařízení – detailní popis + +### EV nabíječky (Teltonika TeltoCharge 22kW) + +**Komunikace:** Teltonika poskytuje REST API a/nebo OCPP protokol. + +| Parametr | Hodnota | +|---|---| +| Max výkon | 22 000 W (třífázové) | +| Min výkon (1 fáze) | 1 380 W | +| Počet na home-01 | 2 | +| Protokol | OCPP 1.6 nebo Teltonika REST API | + +**Co systém řídí:** +- Povolení/zakázání nabíjení (smart charging on/off) +- Omezení výkonu (charge current limit v Amperech) +- Časový plán nabíjení (nastavit okno kdy smí nabíjet) + +**Telemetrie (stahuje se každou minutu):** +- stav konektoru (available / charging / faulted) +- aktuální výkon [W] +- kumulativní energie [kWh] +- proud [A], napětí [V] +- session ID + +**Plánování:** +- EV se nabíjí v době levné energie nebo přebytku FVE +- Respektuje požadavek uživatele: "nabitý na X % do Y hodin" +- Pokud není požadavek nastaven → nabíjí při přebytku nebo nejlevnějším spotu + +> **Otevřený bod:** Teltonika API vs OCPP – rozhodnout při první integraci. Doporučujeme OCPP pro standardizaci. + +--- + +### TUV / Tepelné čerpadlo + +**Komunikace:** přes Loxone (HTTP Virtual Input – zapnout/vypnout) + +**Co systém řídí:** +- Povolení ohřevu (Loxone přepne výstupní relé) +- Systém pošle setpoint do Loxone, Loxone provede + +**Telemetrie:** +- Stav ON/OFF (čteme z Loxone HTTP výstupu nebo Virtual Output stavu) +- Teplota zásobníku (pokud je čidlo v Loxone – doporučeno) +- Aktuální výkon: není přímo měřen, používáme `max_power_w` z `asset_flexible_device` + +**Plánování:** +- TUV se ohřívá v době přebytku FVE nebo levného spotu +- Minimální a maximální teplota zásobníku je respektována (pokud máme čidlo) +- Nouzová priorita: pokud teplota pod minimum → ohřát bez ohledu na cenu + +--- + +## Výpočet bazální spotřeby v auditu + +```sql +-- Agregovaná skutečná bazální spotřeba za 15min interval +CREATE VIEW consumption_vw_actual_baseline AS +SELECT + t.site_id, + time_bucket('15 minutes', t.measured_at) AS interval_start, + AVG( + t.load_power_w + - COALESCE(ev1.power_w, 0) + - COALESCE(ev2.power_w, 0) + -- TUV: odečíst max_power pokud byl v daném intervalu aktivní + ) AS baseline_power_w +FROM telemetry_inverter t +-- JOIN na EV telemetrii +GROUP BY t.site_id, time_bucket('15 minutes', t.measured_at); +``` + +--- + +## Konfigurace (env proměnné) + +```env +CONSUMPTION_FORECAST_LOOKBACK_WEEKS=4 +TELTONIKA_API_URL_1=http://192.168.x.x/api # charger 1 +TELTONIKA_API_URL_2=http://192.168.x.x/api # charger 2 +TELTONIKA_POLL_INTERVAL_SEC=60 +TUV_DEFAULT_POWER_W=2000 # fallback pokud není měřeno +``` + +--- + +## Otevřené body + +- [ ] Teltonika: OCPP vs REST API – rozhodnout před implementací +- [ ] TUV teplota zásobníku: přidat čidlo do Loxone pro přesnější řízení +- [ ] Bazální spotřeba: zpřesnit odečítání TUV výkonu (ON/OFF × čas vs pevný výkon) +- [ ] Sezónní korekce predikce spotřeby (léto vs zima) – fáze 2 diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md new file mode 100644 index 0000000..a77e5c7 --- /dev/null +++ b/docs/04-modules/control.md @@ -0,0 +1,254 @@ +# Modul: Control (Export setpointů) + +## Co modul dělá + +- Čte aktivní plán z DB pro daný 15min interval +- Zkontroluje override záznamy +- Zapíše setpointy do Deye přes Modbus TCP +- Zapíše setpointy EV nabíječek přes Modbus TCP +- Zapíše setpointy tepelného čerpadla přes Modbus TCP +- Odešle potvrzovací setpointy do Loxone přes HTTP (Loxone jako exekutor fallback logiky) +- Loguje každý write pro audit + +--- + +## Architektura řízení + +``` +DB (planning_interval + site_override) + ↓ +control_exporter.py (každých 15min nebo on-demand) + ├── Modbus write → Deye (baterie, grid limit) + ├── Modbus write → Teltonika EV nabíječka 1 + ├── Modbus write → Teltonika EV nabíječka 2 + ├── Modbus write → Samsung TČ + └── HTTP POST → Loxone Virtual Inputs (informační setpointy) +``` + +**Loxone role:** Loxone dostává setpointy jako informaci a jako fallback ochranu. +Rozhodovací logika je v EMS, ne v Loxone. + +--- + +## Spouštění + +| Trigger | Čas | Popis | +|---|---|---| +| Scheduled | každých 15min (xx:00, xx:15, xx:30, xx:45) | Standardní export na začátku intervalu | +| On-demand | po vytvoření nového plánu | Okamžitý export pokud plán překrývá aktuální čas | +| On-demand | po vytvoření override | Okamžitá aplikace přepisu | + +--- + +## Logika exportu + +```python +async def export_setpoints_for_interval(site_id: int, interval_start: datetime, db): + """ + Načte plánované setpointy pro daný interval, aplikuje overrides + a zapíše do všech zařízení. + """ + + # 1. Načíst aktivní plán + plan = 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 pi.interval_start = $2 + AND pr.status = 'active' + ORDER BY pr.created_at DESC + LIMIT 1 + """, site_id, interval_start) + + if not plan: + logger.warning(f"No active plan for site {site_id} at {interval_start}, skipping export") + return + + # 2. Načíst a aplikovat overrides + overrides = await db.fetch(""" + SELECT override_type, value_json + FROM ems.site_override + WHERE site_id = $1 + AND valid_from <= $2 + AND (valid_to IS NULL OR valid_to > $2) + """, site_id, interval_start) + + setpoints = apply_overrides(plan, overrides) + + # 3. Zapsat do zařízení (paralelně) + await asyncio.gather( + write_inverter_setpoints(site_id, setpoints, db), + write_ev_charger_setpoints(site_id, setpoints, db), + write_heat_pump_setpoints(site_id, setpoints, db), + write_loxone_setpoints(site_id, setpoints, db), + return_exceptions=True + ) + + +def apply_overrides(plan, overrides) -> Setpoints: + """Aplikuje override záznamy na plánované setpointy. Override má vždy přednost.""" + s = Setpoints.from_plan(plan) + + for ov in overrides: + if ov.override_type == 'force_charge': + s.battery_setpoint_w = ov.value_json.get('power_w', 20000) + elif ov.override_type == 'force_discharge': + s.battery_setpoint_w = -abs(ov.value_json.get('power_w', 20000)) + elif ov.override_type == 'block_export': + s.grid_setpoint_w = max(0, s.grid_setpoint_w) # jen import povolen + elif ov.override_type == 'block_heat_pump': + s.heat_pump_enabled = False + elif ov.override_type == 'manual_setpoint': + s = Setpoints(**ov.value_json) # plný manuální přepis + + return s +``` + +--- + +## Zápis do Deye (Modbus) + +```python +async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db): + inverters = await db.fetch( + "SELECT ai.*, 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", site_id + ) + + for inv in inverters: + async with AsyncModbusTcpClient(inv.host, port=inv.port) as client: + # Nabíjecí/vybíjecí výkon baterie + if setpoints.battery_setpoint_w >= 0: + await client.write_register(0x00F3, setpoints.battery_setpoint_w, + slave=inv.unit_id) # charge limit + await client.write_register(0x00F4, 0, slave=inv.unit_id) # discharge = 0 + else: + await client.write_register(0x00F3, 0, slave=inv.unit_id) + await client.write_register(0x00F4, abs(setpoints.battery_setpoint_w), + slave=inv.unit_id) + + # Export limit + export_limit = max(0, -setpoints.grid_setpoint_w) if setpoints.grid_setpoint_w < 0 else 0 + await client.write_register(0x00F6, export_limit, slave=inv.unit_id) + + logger.info(f"Inverter {inv.code} setpoints written: batt={setpoints.battery_setpoint_w}W") +``` + +--- + +## Zápis do Teltonika EV nabíječek (Modbus) + +```python +async def write_ev_charger_setpoints(site_id: int, setpoints: Setpoints, db): + chargers = await db.fetch( + "SELECT ac.*, se.host, se.port, se.unit_id " + "FROM ems.asset_ev_charger ac " + "JOIN ems.site_endpoint se ON se.id = ac.endpoint_id " + "WHERE ac.site_id = $1 AND ac.schedulable = true", site_id + ) + + # Rozdělit celkový EV výkon rovnoměrně mezi aktivní nabíječky + # (nebo dle stavu session – upřesnit) + active_chargers = [c for c in chargers] # TODO: filtrovat dle stavu session + power_per_charger = (setpoints.ev_charge_power_w or 0) // max(len(active_chargers), 1) + + for charger in active_chargers: + current_limit_a = power_per_charger // (charger.phases * 230) # W → A + current_limit_a = max(charger.min_power_w // (charger.phases * 230), + min(32, current_limit_a)) # 6–32A dle IEC 61851 + + async with AsyncModbusTcpClient(charger.host, port=charger.port) as client: + # Zápis limitu proudu (registr dle Teltonika dokumentace) + await client.write_register( + TBD_CURRENT_LIMIT_REGISTER, current_limit_a, slave=charger.unit_id + ) + # Povolení/zakázání nabíjení + enable = 1 if power_per_charger >= charger.min_power_w else 0 + await client.write_register( + TBD_ENABLE_REGISTER, enable, slave=charger.unit_id + ) +``` + +--- + +## Zápis do Samsung TČ (Modbus) + +```python +async def write_heat_pump_setpoints(site_id: int, setpoints: Setpoints, db): + heat_pumps = await db.fetch( + "SELECT ahp.*, se.host, se.port, se.unit_id " + "FROM ems.asset_heat_pump ahp " + "JOIN ems.site_endpoint se ON se.id = ahp.endpoint_id " + "WHERE ahp.site_id = $1 AND ahp.schedulable = true", site_id + ) + + for hp in heat_pumps: + async with AsyncModbusTcpClient(hp.host, port=hp.port) as client: + enable = 1 if setpoints.heat_pump_enabled else 0 + await client.write_register( + TBD_HP_ENABLE_REGISTER, enable, slave=hp.unit_id + ) + if setpoints.heat_pump_enabled and setpoints.heat_pump_setpoint_w: + # Nastavit cílovou teplotu TUV (pokud podporuje Modbus zápis) + await client.write_register( + TBD_HP_TARGET_TEMP_REGISTER, + int(hp.tuv_target_temp_c * 10), # 0.1°C jednotky + slave=hp.unit_id + ) +``` + +--- + +## Loxone HTTP Virtual Inputs + +Loxone dostává setpointy jako informaci. Slouží pro: +- Zobrazení v Loxone UI +- Fallback logiku v Loxone (pokud EMS nedostupné) +- Vizualizaci plánovaného stavu + +```python +async def write_loxone_setpoints(site_id: int, setpoints: Setpoints, db): + endpoint = await db.fetchrow( + "SELECT host, port, auth_reference FROM ems.site_endpoint " + "WHERE site_id = $1 AND endpoint_type = 'loxone_http'", site_id + ) + if not endpoint: + return + + base_url = f"http://{endpoint.host}:{endpoint.port}/dev/sps/io" + + # Loxone Virtual HTTP Input – každý setpoint = jeden HTTP GET/POST + # Formát: /dev/sps/io/{VirtualInputName}/{value} + async with aiohttp.ClientSession() as session: + await session.get(f"{base_url}/EMS_BatterySetpoint/{setpoints.battery_setpoint_w}") + await session.get(f"{base_url}/EMS_GridSetpoint/{setpoints.grid_setpoint_w or 0}") + await session.get(f"{base_url}/EMS_EVChargeTotal/{setpoints.ev_charge_power_w or 0}") + await session.get(f"{base_url}/EMS_HeatPumpEnable/{1 if setpoints.heat_pump_enabled else 0}") +``` + +> Virtual Input jména v Loxone (`EMS_BatterySetpoint` atd.) je nutné vytvořit při konfiguraci Loxone projektu. + +--- + +## Konfigurace (env proměnné) + +```env +CONTROL_EXPORT_LEAD_TIME_SEC=10 # kolik sekund před začátkem intervalu exportovat +CONTROL_MODBUS_TIMEOUT_SEC=5 +LOXONE_USER=admin # nebo přes auth_reference v site_endpoint +LOXONE_PASSWORD=secret +``` + +--- + +## Otevřené body + +- [ ] Doplnit Modbus write registry Deye (charge/discharge/export limit) +- [ ] Doplnit Modbus write registry Teltonika (current limit, enable) +- [ ] Doplnit Modbus write registry Samsung TČ (enable, target temp) +- [ ] Loxone Virtual Input jména – dohodnout a vytvořit v Loxone projektu +- [ ] Strategie rozdělení EV výkonu mezi 2 nabíječky (rovnoměrně vs dle stavu session) +- [ ] Co dělat při selhání zápisu do jednoho zařízení (rollback ostatních?) diff --git a/docs/04-modules/ev-charging.md b/docs/04-modules/ev-charging.md new file mode 100644 index 0000000..0ba178b --- /dev/null +++ b/docs/04-modules/ev-charging.md @@ -0,0 +1,285 @@ +# Modul: EV Nabíjení + +## Přehled vozidel na home-01 + +| Vozidlo | Nabíječka | Max výkon | Řízení | API | +|---|---|---|---|---| +| Tesla | ev-charger-1 (Teltonika 22kW) | 22 kW | WB proud limit + Tesla API | Zatím nerozhodnuto (Tessie nebo přímé) | +| Renault Zoe | ev-charger-2 (Teltonika 22kW) | 22 kW (Zoe max ~7-11kW) | WB proud limit (Zoe respektuje) | Žádné – Zoe jako fixní zátěž při připojení | + +--- + +## Klíčové principy + +### 1. Přímé FVE nabíjení preferováno před průchodem přes baterii + +Energie která jde FVE → baterie → EV má round-trip ztráty: +``` +η_round_trip = η_charge × η_discharge ≈ 0.95 × 0.95 ≈ 0.90 +``` + +Přímé napájení FVE → EV (nebo síť → EV) je ~10 % efektivnější. +Solver to vidí přes vyšší efektivní cenu energie procházející baterií (degradation_cost + round-trip loss). + +### 2. Deadline charging + +Každé vozidlo může mít nastaven: +- **cílový SoC** (%) +- **deadline** (do kdy musí být dosažen) + +Solver garantuje dosažení SoC do deadline jako hard constraint. +Ekonomická optimalizace probíhá v rámci tohoto omezení. + +### 3. Zoe – řízení přes WB proud limit + +Zoe respektuje maximální proud nastavený na WB (Teltonika Modbus). +Solver nastaví `current_limit_a` pro daný slot. +Zoe vždy nabíjí pokud je připojena a proud > 6A. + +Scheduler v Zoe se nepoužívá – WB proud limit je jediný řídicí prvek. + +### 4. Tesla – WB + volitelně Tesla API + +V první fázi stejný přístup jako Zoe – proud limit přes WB. +Tesla API (Tessie nebo přímé) přidáme ve fázi 2 pro: +- čtení aktuálního SoC bez dotazování WB +- čtení stavu připojení +- případné spuštění/zastavení nabíjení přímo v autě + +--- + +## DB rozšíření – EV session a deadline + +### Tabulka `ems.ev_session` + +```sql +CREATE TABLE ems.ev_session ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + charger_id INT NOT NULL REFERENCES ems.asset_ev_charger(id), + vehicle_id INT REFERENCES ems.asset_vehicle(id), + session_start TIMESTAMPTZ NOT NULL DEFAULT now(), + session_end TIMESTAMPTZ, + -- Stav při připojení + soc_at_connect_pct NUMERIC(5,2), + -- Deadline požadavek (nastavuje uživatel nebo API) + target_soc_pct NUMERIC(5,2), + target_deadline TIMESTAMPTZ, + -- Výsledek + soc_at_disconnect_pct NUMERIC(5,2), + energy_delivered_kwh NUMERIC(10,3), + cost_czk NUMERIC(10,4) +); +``` + +### Tabulka `ems.asset_vehicle` + +```sql +CREATE TABLE ems.asset_vehicle ( + id SERIAL PRIMARY KEY, + site_id INT NOT NULL REFERENCES ems.site(id), + code TEXT NOT NULL, + name TEXT, + make TEXT, -- 'Tesla', 'Renault' + model TEXT, -- 'Model Y', 'Zoe' + battery_capacity_kwh NUMERIC(6,2), -- Tesla ~75, Zoe ~52 + max_charge_power_w INT, -- max přijímaný výkon vozidla + default_charger_id INT REFERENCES ems.asset_ev_charger(id), + api_type TEXT, -- 'tesla', 'none' + api_reference TEXT, -- odkaz na credentials v env + default_target_soc_pct NUMERIC(5,2) DEFAULT 80, + default_deadline_hour INT DEFAULT 7 -- 7:00 ráno jako výchozí deadline +); +``` + +--- + +## Solver rozšíření – EV s round-trip a deadline + +### Nové proměnné pro každý slot t a každé EV e + +```python +ev_direct[e][t] # W – přímé napájení EV z FVE nebo sítě (bez průchodu baterií) +ev_via_bat[e][t] # W – napájení EV přes baterii (vyšší efektivní cena) + +# Celkový výkon EV (co jde do auta) +ev_charge[e][t] = ev_direct[e][t] + ev_via_bat[e][t] + +# Co ev_via_bat stojí energeticky navíc: +# ev_via_bat musí být "nakoupeno" z baterie s round-trip ztrátou +# solver to vidí přes účelovou funkci – viz níže +``` + +### Energetická bilance rozšířená o přímé EV + +```python +# Zdroje = Spotřeba +pv_a_net[t] + pv_b[t] + grid_import[t] + batt_discharge[t] + == load_baseline[t] + + Σ_e ev_direct[e][t] # přímá spotřeba EV + + Σ_e ev_via_bat[e][t] # EV přes baterii (z discharge) + + heat_pump[t] + + batt_charge[t] + + grid_export[t] + +# Vazba: ev_via_bat[e][t] musí pokrýt batt_discharge[t] +# (solver to vyřeší sám – discharge jde buď do ev_via_bat nebo do load) +``` + +### Účelová funkce – efektivní cena EV přes baterii + +```python +# Nabíjení přes baterii je dražší o round-trip ztrátu a degradaci: +EV_VIA_BAT_COST_FACTOR = 1.0 / (charge_efficiency * discharge_efficiency) +# ≈ 1.0 / (0.95 * 0.95) ≈ 1.108 + +# V objective function: ++ ev_via_bat[e][t] * buy_price[t] * EV_VIA_BAT_COST_FACTOR * H / 1000 ++ ev_direct[e][t] * buy_price[t] * H / 1000 # přímé – bez navýšení + +# Solver přirozeně preferuje přímé nabíjení kde je to možné +``` + +### Deadline constraint + +```python +# Pro každé EV e s nastaveným deadline: +if ev_session[e].target_deadline is not None: + + # Kolik energie ještě potřebujeme dodat + energy_needed_wh = ( + (ev_session[e].target_soc_pct - ev_session[e].current_soc_pct) + / 100.0 * vehicle[e].battery_capacity_kwh * 1000 + ) + + # Deadline slot index + t_deadline = slot_index_for(ev_session[e].target_deadline) + + # Hard constraint: součet dodané energie do deadline musí být >= potřebná + prob += pulp.lpSum( + ev_charge[e][t] * H # Wh za 15min slot + for t in range(t_deadline + 1) + if ev_connected[e][t] # jen sloty kdy je auto připojeno + ) >= energy_needed_wh + +# Zoe má tvrdší deadline (menší baterie, kritičtější) +# Tesla může mít měkčí deadline nebo vyšší flexibility okno +``` + +### Připojení EV – vstupní podmínka + +```python +# ev_connected[e][t] = True/False +# Pokud auto není připojeno → ev_charge[e][t] = 0 + +for t in range(T): + if not ev_connected[e][t]: + prob += ev_charge[e][t] == 0 + prob += ev_direct[e][t] == 0 + prob += ev_via_bat[e][t] == 0 +``` + +--- + +## Jak solver rozhoduje (příklady) + +### Přebytek FVE přes poledne, Zoe připojena, baterie poloprázdná + +``` +Solver volí: + ev_direct[zoe][t] = max(min(surplus_w, zoe_max_w), 0) ← přímé z FVE + batt_charge[t] = zbývající surplus ← do baterie až pak + +Protože přímé nabíjení Zoe je levnější než FVE → baterie → Zoe. +``` + +### Noc, Zoe má deadline 7:00 s SoC 20% (potřeba 30 kWh) + +``` +Solver: + - Rozloží nabíjení do nejlevnějších nočních slotů + - Garantuje dodání 30 kWh do 7:00 (hard constraint) + - Pokud jsou sloty se zápornou cenou → nabíjí naplno v těch slotech + - Vyhýbá se nabíjení přes baterii pokud není přebytek +``` + +### Tesla připojena, SoC 70%, deadline není nastaven + +``` +Solver: + - Tesla je "oportunistická" – nabíjí jen při přebytku FVE nebo levné ceně + - Bez deadline = měkká optimalizace, ne hard constraint + - Nastavit default_target_soc = 80% s default_deadline = zítra 7:00 + (konfigurovatelné v asset_vehicle) +``` + +--- + +## Zjištění stavu připojení + +### Teltonika WB (oba vozy) + +Modbus registr stavu konektoru (status): +- `available` = žádné auto +- `preparing` / `charging` = auto připojeno + +Polling každou minutu z `telemetry_ev_charger.status`. + +### Tesla API (fáze 2) + +Přes Tessie nebo přímé Tesla API: +- SoC baterie auta +- Stav připojení (plugged_in) +- Nabíjecí stav (charging / stopped) + +Uložit do `ev_session` při připojení/odpojení. + +### Renault Zoe + +Žádné API. Stav připojení čteme výhradně z WB Modbus (`status != 'available'`). +SoC Zoe neznáme přesně – použijeme energii dodanou v session (kumulativní kWh z WB). + +--- + +## Seed data – vozidla home-01 + +```sql +-- V006__vehicles.sql + +INSERT INTO ems.asset_vehicle + (site_id, code, name, make, model, battery_capacity_kwh, + max_charge_power_w, default_charger_id, api_type, + default_target_soc_pct, default_deadline_hour) +SELECT + s.id, 'tesla-my', 'Tesla Model Y', 'Tesla', 'Model Y', + 75.0, 11000, -- Tesla Model Y AC max ~11kW + ch.id, 'none', -- Tesla API fáze 2 + 80, 7 +FROM ems.site s +JOIN ems.asset_ev_charger ch ON ch.site_id = s.id AND ch.code = 'ev-charger-1' +WHERE s.code = 'home-01'; + +INSERT INTO ems.asset_vehicle + (site_id, code, name, make, model, battery_capacity_kwh, + max_charge_power_w, default_charger_id, api_type, + default_target_soc_pct, default_deadline_hour) +SELECT + s.id, 'zoe-r135', 'Renault Zoe R135', 'Renault', 'Zoe R135', + 52.0, 7400, -- Zoe max 7.4kW AC + ch.id, 'none', + 90, 7 -- Zoe: vyšší target SoC (menší baterie, kritičtější) +FROM ems.site s +JOIN ems.asset_ev_charger ch ON ch.site_id = s.id AND ch.code = 'ev-charger-2' +WHERE s.code = 'home-01'; +``` + +--- + +## Otevřené body + +- [ ] Tesla API: Tessie vs přímé API – rozhodnout ve fázi 2 +- [ ] Ověřit Zoe max nabíjecí výkon (7.4 kW nebo méně dle podmínek) +- [ ] Ověřit round-trip efficiency na reálných datech po prvních týdnech provozu +- [ ] UI pro nastavení deadline a target SoC uživatelem (před odjezdem) +- [ ] Notifikace pokud deadline nelze splnit (nedostatek kapacity WB nebo energie) +- [ ] Zoe SoC estimace z kumulativní energie session – přesnost ověřit diff --git a/docs/04-modules/forecast.md b/docs/04-modules/forecast.md new file mode 100644 index 0000000..022cf95 --- /dev/null +++ b/docs/04-modules/forecast.md @@ -0,0 +1,180 @@ +# Modul: Forecast (Predikce výroby FVE) + +## Co modul dělá + +- Stahuje meteorologická data (irradiance, teplota) pro každé FVE pole zvlášť +- Vypočítává predikovaný výkon v 15min intervalech +- Ukládá výsledek per `pv_array_id` + `run_id` +- Predikce se spouští denně a před každým plánovacím během + +--- + +## FVE pole na první instalaci (home-01) + +| Pole | Výkon | Azimut | Sklon | Střídač | Řízení | +|---|---|---|---|---|---| +| A | 10 kWp | TBD | TBD | Deye 20kW | řídíme | +| B | 10 kWp | TBD | TBD | Ongridový | autonomní, **nepredikujeme odděleně** | + +> **Předpoklad:** Pole B (ongridový) je zapojeno do GEN portu Deye. Jeho výkon se projeví v `pv_power_w` telemetrie jako součást celkového výkonu. Pro plánování modelujeme jen pole A. Pole B bereme jako šum / bonus který se projeví v auditu. + +> Azimuty a sklony je nutné doplnit při konfiguraci lokality do `asset_pv_array`. + +--- + +## Zdroj meteorologických dat + +**Primární: Open-Meteo (open-meteo.com)** + +- Zdarma pro nekomerční použití, API bez registrace +- Poskytuje GHI (Global Horizontal Irradiance), DNI, teplotu, oblačnost +- Historická data + forecast na 7–16 dní dopředu +- 15min granularita nativně ✓ + +**Endpoint:** +``` +GET https://api.open-meteo.com/v1/forecast + ?latitude={lat} + &longitude={lon} + &hourly=shortwave_radiation,temperature_2m + &minutely_15=shortwave_radiation,temperature_2m + &timezone=Europe/Prague + &forecast_days=3 +``` + +**Záložní / budoucí: Solcast** +- Přesnější pro FVE, ale placený +- Podporuje per-array predikci s azimutem a sklonem přímo +- Zatím neimplementujeme, architektura to umožňuje přes `forecast_source` + +--- + +## Výpočet výkonu z irradiance + +Jednoduchý fyzikální model (dostatečný pro plánování): + +```python +def calculate_pv_power( + irradiance_wm2: float, # GHI ze weather service + temp_c: float, + nominal_power_wp: int, + azimuth_deg: float, + tilt_deg: float, + shading_factor: float = 1.0, + temp_coeff: float = -0.004 # typicky -0.4%/°C pro křemík +) -> int: + # 1. Korekce na teplotu panelu + panel_temp = temp_c + 25 # zjednodušený NOCT model + temp_correction = 1 + temp_coeff * (panel_temp - 25) + + # 2. Korekce na azimut a sklon (zjednodušená, bez přesného GHI→POA) + # Přesnější model: pvlib knihovna (doporučeno pro produkci) + orientation_factor = cos_angle_of_incidence(azimuth_deg, tilt_deg) + + # 3. Výsledný výkon + power_w = (irradiance_wm2 / 1000) * nominal_power_wp * temp_correction * orientation_factor * shading_factor + + return max(0, int(power_w)) +``` + +> **Doporučení pro implementaci:** Použít knihovnu `pvlib` (Python) pro přesný POA irradiance výpočet z GHI + azimut + sklon. Je to standardní nástroj, dobře dokumentovaný. + +--- + +## Kdo spouští predikci + +**Python service: `forecast_service`** + +### Kdy se spouští + +| Trigger | Čas | Popis | +|---|---|---| +| Scheduled (cron) | každý den 14:30 CET | Po importu cen, před plánováním | +| Scheduled (cron) | každý den 06:00 CET | Aktualizace predikce na dnešní den | +| Před plánováním | automaticky | Plánovač zkontroluje čerstvost, spustí pokud starší než 2h | +| Manual trigger | na vyžádání | `POST /admin/run-forecast?site_id=1&date=YYYY-MM-DD` | + +--- + +## Logika běhu predikce + +```python +def run_forecast(site_id: int, horizon_days: int = 2): + site = db.get_site(site_id) + arrays = db.get_pv_arrays(site_id, controllable=True) + + for array in arrays: + # 1. Stáhnout meteorologická data + weather = open_meteo_client.fetch( + lat=site.lat, lon=site.lon, + start=today, end=today + horizon_days + ) + + # 2. Vytvořit forecast_pv_run + run = db.create_forecast_run( + site_id=site_id, + pv_array_id=array.id, + forecast_source="open_meteo", + horizon_start=today_00, + horizon_end=today_end + horizon_days + ) + + # 3. Vypočítat a uložit intervaly (15min) + intervals = [] + for slot in weather.slots_15min: + power = calculate_pv_power( + irradiance_wm2=slot.shortwave_radiation, + temp_c=slot.temperature_2m, + nominal_power_wp=array.nominal_power_wp, + azimuth_deg=array.azimuth_deg, + tilt_deg=array.tilt_deg, + shading_factor=array.shading_factor + ) + intervals.append(ForecastInterval( + run_id=run.id, + pv_array_id=array.id, + interval_start=slot.time, + power_w=power, + irradiance_wm2=slot.shortwave_radiation, + temp_c=slot.temperature_2m + )) + + db.upsert_forecast_intervals(intervals) + db.update_forecast_run_status(run.id, "ok") +``` + +--- + +## DB struktura + +Viz `03-data-model.md`: +- `forecast_pv_run` – každý běh predikce +- `forecast_pv_interval` – 15min výsledky per pole a běh + +--- + +## Konfigurace (env proměnné) + +```env +OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast +FORECAST_HORIZON_DAYS=3 +FORECAST_MAX_AGE_HOURS=2 # plánovač odmítne starší predikci +FORECAST_RETRY_COUNT=3 +``` + +--- + +## Monitoring + +- Alert pokud forecast pro dnešní den + zítřek není k dispozici do 15:00 +- Endpoint `GET /health/forecast?site_id=1&date=YYYY-MM-DD` → čerstvost a počet intervalů +- Log každého běhu (délka horizontu, počet intervalů, trvání, zdroj) + +--- + +## Otevřené body + +- [ ] Doplnit přesný azimut a sklon obou FVE polí při instalaci +- [ ] Rozhodnout: pvlib pro přesnější POA výpočet vs jednoduchý model – doporučujeme pvlib od začátku +- [ ] Pole B (ongridový) – zda vůbec modelovat nebo ignorovat v plánu a jen sledovat v auditu +- [ ] Solcast jako alternativa v budoucnu – `forecast_source` to umožňuje bez DB změn diff --git a/docs/04-modules/heat-pump.md b/docs/04-modules/heat-pump.md new file mode 100644 index 0000000..9634fef --- /dev/null +++ b/docs/04-modules/heat-pump.md @@ -0,0 +1,107 @@ +# Modul: Tepelné čerpadlo (Heat Pump) + +## Zařízení + +**Samsung tepelné čerpadlo** s Modbus modulem pro dálkové řízení. +Komunikace: Modbus RTU → Waveshare WS-ETH → Modbus TCP. +Loxone šablona k dispozici (reference pro Modbus registry). + +## Co systém řídí + +- Povolení/zakázání provozu (Modbus příkaz) +- Požadovaná teplota TUV zásobníku (Modbus setpoint) +- Plánování okna provozu na základě COP a ceny elektřiny + +## Co systém nečte (z Loxone šablony nebo Modbus registrů) + +- Venkovní teplota čerpadla (`outdoor_temp_c`) +- Teplota zásobníku TUV (`tuv_tank_temp_c`) +- Příkon (`power_w`) +- Provozní režim (`operating_mode`) +- Alarm kód (`alarm_code`) +- Stav odmrazování (`defrost_active`) + +--- + +## Logika řízení + +### Prioritní pravidla (v pořadí) + +1. **Override blokování** (`site_override.override_type = 'block_heat_pump'`) → TČ se nespouští +2. **Nouzový ohřev** – teplota zásobníku pod `tuv_min_temp_c` → spustit bez ohledu na cenu +3. **Zásobník plný** – teplota nad `tuv_max_temp_c` → neohřívat +4. **Ekonomické rozhodnutí** – spustit pokud cena tepla ≤ prahová hodnota + +Logika je implementována v PostgreSQL funkci `ems.fn_heat_pump_should_run()`. + +### Výpočet COP + +COP závisí primárně na **venkovní teplotě**: +- Vyšší venkovní teplota = lepší COP = levnější teplo +- V chladných měsících je přes poledne venkovní teplota nejvyšší → optimální čas pro ohřev TUV + +``` +COP(t_venkovní) ≈ COP_rated + (t_venkovní - t_reference) × 0.10 +``` + +Funkce: `ems.fn_cop_estimate(heat_pump_id, outdoor_temp_c)` + +Cena tepla = cena elektřiny / COP → funkce `ems.fn_heat_pump_cost_per_kwh_heat()` + +### Denní provozní okno + +Typický scénář v chladných měsících (říjen–březen): +- Přes poledne (11:00–14:00) je venkovní teplota nejvyšší → COP nejlepší +- Pokud je zároveň spot cena nízká nebo FVE přebytek → ideální okno +- TČ potřebuje cca 1–2 hodiny denně pro nahrání TUV zásobníku (závisí na objemu a teplotním rozdílu) + +Plánovací horizont: TČ se plánuje v rámci standardního 15min plánování stejně jako ostatní flexibilní spotřebiče. + +--- + +## Omezení kompresoru + +| Parametr | Hodnota | Důvod | +|---|---|---| +| `min_run_duration_min` | 30 min | Ochrana před krátkými cykly | +| `min_stop_duration_min` | 15 min | Vyrovnání tlaku před restartem | + +Plánování musí respektovat tato omezení – nevytvářet plán s kratšími ON/OFF cykly. + +--- + +## Modbus registry (doplnit z dokumentace Samsung) + +> Konkrétní registry doplnit z Loxone šablony a Samsung Modbus dokumentace. + +| Registr | Typ | Popis | +|---|---|---| +| TBD | Read | Venkovní teplota | +| TBD | Read | Teplota zásobníku TUV | +| TBD | Read | Příkon | +| TBD | Read | Provozní režim | +| TBD | Read | Alarm kód | +| TBD | Write | Povolení provozu (on/off) | +| TBD | Write | Požadovaná teplota TUV | + +--- + +## Integrace s Loxone + +Alternativní cesta (pokud Modbus přímý přístup není z nějakého důvodu vhodný): +- Loxone čte Modbus a vystavuje stav přes Virtual Outputs +- EMS posílá setpointy do Loxone přes HTTP Virtual Inputs +- Loxone přepisuje Modbus registry + +Pro začátek doporučujeme **přímý Modbus TCP** (přes Waveshare) bez Loxone prostředníka pro řídící příkazy, Loxone nechat jako fallback. + +--- + +## Otevřené body + +- [ ] Doplnit konkrétní Modbus registry ze Samsung dokumentace / Loxone šablony +- [ ] Doplnit model Samsung a jmenovitý výkon do seed dat +- [ ] Ověřit `min_run_duration_min` a `min_stop_duration_min` z dokumentace +- [ ] Kalibrovat COP model na reálná historická data po prvních 4–6 týdnech provozu +- [ ] Rozhodnout: přímý Modbus TCP nebo přes Loxone jako prostředník +- [ ] Doplnit objem zásobníku TUV pro výpočet doby ohřevu diff --git a/docs/04-modules/market-prices.md b/docs/04-modules/market-prices.md new file mode 100644 index 0000000..6656481 --- /dev/null +++ b/docs/04-modules/market-prices.md @@ -0,0 +1,128 @@ +# Modul: Market Prices (Spotové ceny OTE CZ) + +## Co modul dělá + +- Stahuje spotové ceny elektřiny z OTE CZ +- Ukládá raw data bez vazby na lokalitu (sdílená tabulka) +- Efektivní ceny (s marží) se dopočítávají per site přes view +- Granularita: **15 minut** nativně (OTE CZ publikuje po hodinách → konvertujeme na 15min replikací) + +--- + +## Zdroj dat: OTE CZ + +**URL:** `https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh` + +OTE CZ publikuje denní ceny zpravidla **den předem (D-1)** okolo 13:00–14:00 středoevropského času. + +### Formát dat OTE CZ + +OTE publikuje hodinové ceny v EUR/MWh. Konverzní kroky: +1. Stáhnout XML/JSON feed nebo scrape HTML tabulky +2. Převést EUR/MWh → CZK/kWh (kurz ČNB nebo fixní koeficient dle konfigurace) +3. Rozložit hodinový interval na 4× 15min sloty (stejná hodnota) +4. Uložit do `market_interval_price` + +### Alternativní API + +- **OTE XML feed:** `https://www.ote-cr.cz/pubapi/v1/market-data/dam?date=YYYY-MM-DD&market=DAM&type=FIN` +- Autentikace: nepotřebná pro veřejná data + +--- + +## Kdo stahuje data + +**Python service: `price_importer`** + +Samostatný modul (ne součást FastAPI, ale může být volán z ní jako task). + +### Kdy se spouští + +| Trigger | Čas | Popis | +|---|---|---| +| Scheduled (cron) | každý den 14:00 CET | Stažení cen na zítřek (D+1) | +| Scheduled (cron) | každý den 00:05 CET | Kontrola – ověření že dnešní data jsou v DB | +| Manual trigger | na vyžádání | API endpoint `POST /admin/import-prices?date=YYYY-MM-DD` | +| Retry | při chybě, 3× s backoffem | Automatický opakovaný pokus | + +### Logika importu + +```python +# Pseudologika importu (implementace v price_importer.py) + +def import_prices_for_date(date: date, source: str = "OTE_CZ"): + # 1. Zkontrolovat jestli data pro daný den už existují + existing = db.query("SELECT COUNT(*) FROM market_interval_price WHERE interval_start::date = %s AND market_source = %s", date, source) + if existing > 0 and not force_reimport: + log.info("Data already exist, skipping") + return + + # 2. Stáhnout z OTE API + raw_data = ote_client.fetch_dam_prices(date) # vrátí list hodinových cen v EUR/MWh + + # 3. Konvertovat EUR/MWh → CZK/kWh + eur_czk_rate = get_exchange_rate() # z konfigurace nebo ČNB API + czk_per_kwh = [(price_eur_mwh * eur_czk_rate) / 1000 for price in raw_data] + + # 4. Rozložit na 15min intervaly (1 hodina = 4 sloty se stejnou cenou) + intervals = expand_hourly_to_15min(czk_per_kwh, date) + + # 5. Upsert do DB (idempotentní) + db.upsert_many("market_interval_price", intervals, conflict_keys=["market_source", "interval_start"]) + log.info(f"Imported {len(intervals)} intervals for {date}") +``` + +--- + +## Struktura DB záznamu + +Viz `03-data-model.md` → tabulka `market_interval_price`. + +Klíčové body: +- `buy_raw_price_czk_kwh` a `sell_raw_price_czk_kwh` jsou **oddělené** +- Pro OTE CZ je v první verzi `sell_raw_price = buy_raw_price` (reference cena) +- `imported_at` slouží pro audit importů + +--- + +## Efektivní ceny per site + +Viz view `market_vw_site_effective_price` v `03-data-model.md`. + +Marže se konfigurují v `site_market_config`: + +| Parametr | Typ | Příklad | +|---|---|---| +| `buy_margin_fixed_czk` | Kč/kWh | 0.05 (5 haléřů/kWh) | +| `buy_margin_percent` | % | 2.5 | +| `sell_margin_fixed_czk` | Kč/kWh | -0.02 (srážka) | +| `sell_margin_percent` | % | 0 | + +--- + +## Konfigurace (env proměnné) + +```env +OTE_API_URL=https://www.ote-cr.cz/pubapi/v1/market-data/dam +OTE_IMPORT_HOUR=14 # hodina kdy se spouští denní import +EUR_CZK_RATE=25.0 # fallback kurz pokud ČNB API nedostupné +CNB_API_URL=https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml +PRICE_IMPORT_RETRY_COUNT=3 +PRICE_IMPORT_RETRY_BACKOFF_SEC=300 +``` + +--- + +## Monitoring a alerting + +- Alert pokud do 16:00 nejsou v DB ceny na zítřek +- Log každého importu (datum, počet intervalů, zdroj, trvání) +- Endpoint `GET /health/prices?date=YYYY-MM-DD` → vrátí počet importovaných intervalů + +--- + +## Otevřené body + +- [ ] Kurz EUR/CZK: fixní hodnota vs denní stahování z ČNB – rozhodnout před implementací +- [ ] OTE nabízí i intraday ceny – zatím neimplementujeme +- [ ] Sell price: OTE nemá oddělenou nákupní a prodejní raw cenu, obě = DAM cena; může se lišit u jiných zdrojů diff --git a/docs/04-modules/operating-modes.md b/docs/04-modules/operating-modes.md new file mode 100644 index 0000000..f6979e6 --- /dev/null +++ b/docs/04-modules/operating-modes.md @@ -0,0 +1,132 @@ +# Modul: Operating Modes (Provozní režimy) + +## Koncept + +EMS a Loxone komunikují přes **provozní režimy** – pojmenované stavy které mají smysl pro obě strany. + +EMS rozhoduje a přepíná režimy. Loxone dostane kód režimu jako číslo přes Virtual Input a ví jak se v daném režimu chovat **autonomně a nezávisle na EMS**. + +``` +EMS backend (každou minutu) + → HTTP GET /dev/sps/io/EMS_Heartbeat/1 ← pulz do Loxone + +EMS backend (při přepnutí režimu) + → ems.fn_set_mode(site_id, 'SELF_SUSTAIN') ← zapsat do DB + → HTTP GET /dev/sps/io/EMS_Mode/2 ← informovat Loxone + +Loxone (zcela nezávisle na EMS) + → sleduje přítomnost EMS_Heartbeat pulzů + → pokud 5min žádný pulz → sám přepne na SELF_SUSTAIN + → řídí střídač dle aktivního režimu bez čekání na setpointy +``` + +**Klíčový princip:** Loxone watchdog nečte DB. Sleduje pouze HTTP pulzy přímo. +Pokud padne celý server (RPi, Docker, síť) – Loxone to pozná sám a přepne bezpečný režim. + +Viz `docs/loxone-integration.md` pro kompletní popis Loxone implementace. + +--- + +## Přehled režimů + +| Kód | Loxone int | EV | TČ | Baterie | Síť | Loxone autonomní | +|---|---|---|---|---|---|---| +| `AUTO` | 1 | dle plánu | dle plánu | dle plánu | dle plánu | **ne** – čeká na setpointy | +| `SELF_SUSTAIN` | 2 | ❌ stop | ❌ stop | vybíjí do domu | bez exportu | **ano** | +| `CHARGE_CHEAP` | 3 | ❌ stop | ❌ stop | max nabíjení | import ok | **ne** – EMS posílá výkon | +| `PRESERVE` | 4 | ❌ stop | ❌ stop | drží SoC | import ok | **ano** | +| `MANUAL` | 0 | ❌ stop | ❌ stop | žádné akce | žádné akce | **ano** | + +### `AUTO` +Normální provoz. EMS posílá přesné setpointy W každých 15 minut. +Loxone je čistý exekutor – přijme číslo a zapíše do střídače. +Pokud setpoint nepřijde (výpadek EMS) → Loxone watchdog přepne na `SELF_SUSTAIN`. + +### `SELF_SUSTAIN` ← výchozí stav + fallback +Aktivuje se: +- automaticky watchdogem při výpadku EMS (5min bez pulzu) +- manuálně uživatelem z UI (dovolená, odchod z domu) +- při prvním startu systému (seed data) + +Loxone sám bez EMS: +- FVE pokrývá spotřebu +- baterie vybíjí do domu (ne do sítě) +- blokuje export do sítě +- zastavuje EV nabíjení a TČ + +### `CHARGE_CHEAP` +Manuální přepis. EMS posílá max charge setpoint. +Použít při levné ceně nebo přetoku FVE ze sousedství (pokud víš o levné ceně dopředu). + +### `PRESERVE` +Dovolená / servis. Loxone drží baterii na aktuálním SoC, žádné optimalizace. +Autonomní – Loxone nevyžaduje setpointy od EMS. + +### `MANUAL` +Technické práce. Žádná logika neřídí střídač. Pouze pro servis. + +--- + +## Přepínání z UI (React) + +``` +POST /api/sites/{site_id}/mode +{ + "mode": "SELF_SUSTAIN", + "valid_until": null, // nebo "2025-03-15T06:00:00+01:00" pro dočasný přepis + "notes": "Odjezd na dovolenou" +} +``` + +Backend při přepnutí: +1. Zavolá `ems.fn_set_mode(site_id, mode, 'user:'+username)` → zápis do DB + log +2. Okamžitě odešle HTTP do Loxone: `/dev/sps/io/EMS_Mode/{loxone_mode_value}` +3. Pokud `CHARGE_CHEAP` nebo návrat na `AUTO` → spustí replanning + +**Dočasný přepis s automatickým návratem:** +`fn_expire_modes()` běží každou minutu a přepíná zpět lokality s prosahlým `valid_until`. + +--- + +## EMS restart / reconnect + +Při startu backendu: +1. Přečíst z Loxone aktuální `EMS_Mode_Active` (Virtual Output) přes HTTP GET +2. Porovnat s `ems.site_operating_mode` v DB +3. Pokud Loxone přepnul na `SELF_SUSTAIN` během výpadku → logovat, informovat, spustit nový plán +4. Přepnout na `AUTO` a začít posílat setpointy + heartbeat pulzy + +--- + +## Heartbeat v DB – pouze informační + +Tabulka `ems.site_heartbeat` zaznamenává kdy EMS naposledy úspěšně odeslal pulz do Loxone. +Slouží pro EMS dashboard (`vw_site_status.ems_heartbeat_status`) a případný alerting. + +**Neplní funkci watchdogu** – to je čistě na Loxone straně. + +```python +# backend/services/control_exporter.py – každou minutu +async def send_heartbeat(site_id: int, loxone_endpoint, db): + try: + await loxone_http.get(f"/dev/sps/io/EMS_Heartbeat/1") + await db.execute( + "SELECT ems.fn_update_heartbeat($1, 'ok', $2)", + site_id, EMS_VERSION + ) + except Exception as e: + logger.error(f"Heartbeat failed for site {site_id}: {e}") + await db.execute( + "SELECT ems.fn_update_heartbeat($1, 'error', $2)", + site_id, EMS_VERSION + ) + # EMS nemůže nic dělat – Loxone watchdog to vyřeší sám +``` + +--- + +## Otevřené body + +- [ ] Ověřit Deye Modbus registry pro přepnutí Self-Consumption / Grid-First modu (pro SELF_SUSTAIN) +- [ ] Implementace Loxone watchdog – viz `docs/loxone-integration.md` +- [ ] Alert notifikace (email / push) pokud `ems_heartbeat_status = 'stale'` déle než 10 minut diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md new file mode 100644 index 0000000..6f1275a --- /dev/null +++ b/docs/04-modules/planning.md @@ -0,0 +1,423 @@ +# Modul: Planning (LP Optimalizace) + +## Přístup + +**PuLP + HiGHS solver** – lineární programování (LP) s uvolněním binárních proměnných. + +Solver optimalizuje celý horizont (typicky 36h) najednou, čímž přirozeně zvládá: +- pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie) +- kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu + +--- + +## Klíčové předpoklady a specifika home-01 + +### FVE pole A (10 kWp, řízené Deye) +- Curtailment povolen přes Modbus (Output Power Limit) +- Solver může omezit výrobu pokud export nevychází a není kam ukládat +- Curtailment má nulový přímý náklad, ale ztrátu příležitosti + +### FVE pole B (10 kWp, ongridový na GEN portu) +- **Nelze omezit ani řídit** +- Má **zelený bonus** (dotace za každé vyrobené kWh bez ohledu na cenu) +- Výroba pole B musí být vždy plně spotřebována nebo uložena +- Při záporné prodejní ceně má nejvyšší prioritu ukládání (baterie → EV → TČ) +- Solver nikdy neexportuje výrobu pole B pokud je prodejní cena záporná + +### Export / import limity (home-01) +- Max export do sítě: **13.5 kW** (smlouva s distributorem) +- Max import ze sítě: dle `site_grid_connection.max_import_power_w` +- Konfigurovatelné per site v DB + +--- + +## Energetická bilance (pro každý 15min slot t) + +``` +pv_a_actual[t] + pv_b[t] + grid_import[t] + battery_discharge[t] + = load_baseline[t] + + Σ_e (ev_direct[e][t] + ev_via_bat[e][t]) + + heat_pump[t] + + battery_charge[t] + grid_export[t] + pv_a_curtailed[t] +``` + +kde: +- `pv_a_actual[t]` = `pv_a_forecast[t] − pv_a_curtailed[t]` +- `pv_b[t]` = predikce pole B (pevná, nekontrolovatelná) +- `grid_import[t]`, `grid_export[t]` ≥ 0 (oddělené proměnné, ne signed) +- `ev_direct[e][t]` = přímé napájení EV e ze zdrojů (FVE, síť) – bez průchodu baterií +- `ev_via_bat[e][t]` = napájení EV e přes baterii (kryta z `battery_discharge[t]`) + +**Round-trip efektivita:** Přímé napájení EV je ~10 % levnější než přes baterii +(η_charge × η_discharge ≈ 0.95 × 0.95 ≈ 0.90). Solver to vidí v účelové funkci. + +--- + +## Proměnné solveru + +| Proměnná | Typ | Rozsah | Popis | +|---|---|---|---| +| `grid_import[t]` | kontinuální | 0 – max_import | Nákup ze sítě v W | +| `grid_export[t]` | kontinuální | 0 – max_export (13500) | Prodej do sítě v W | +| `battery_charge[t]` | kontinuální | 0 – max_charge | Nabíjení baterie v W | +| `battery_discharge[t]` | kontinuální | 0 – max_discharge | Vybíjení baterie v W | +| `soc[t]` | kontinuální | soc_min – soc_max | Stav nabití baterie v Wh | +| `pv_a_curtailed[t]` | kontinuální | 0 – pv_a_forecast[t] | Omezení výroby pole A v W | +| `ev_direct[e][t]` | kontinuální | 0 – min(ev_max, pv_surplus) | Přímé napájení EV e z FVE/sítě (bez průchodu baterií) | +| `ev_via_bat[e][t]` | kontinuální | 0 – ev_max | Napájení EV e přes baterii (s round-trip ztrátou) | +| `heat_pump[t]` | kontinuální | 0 – hp_rated | Výkon TČ v W (relaxováno z binární) | + +> **TČ relaxace:** TČ je v realitě ON/OFF (binární). Pro LP ho relaxujeme na spojitou proměnnou 0–rated_power. Post-processing pravidlo pak zaokrouhlí na ON/OFF a zkontroluje `min_run_duration`. V praxi výsledek LP vychází blízko binárnímu řešení. + +--- + +## Účelová funkce (minimalizace nákladů) + +```python +EV_ROUNDTRIP_FACTOR = 1.0 / (charge_efficiency * discharge_efficiency) # ≈ 1.108 + +minimize: + Σ_t [ + # Náklady na nákup ze sítě + grid_import[t] * buy_price[t] * interval_h + + # Příjem z prodeje (záporný náklad) + - grid_export[t] * sell_price[t] * interval_h + + # Náklad degradace baterie (nabíjení i vybíjení) + + (battery_charge[t] + battery_discharge[t]) * degradation_cost * interval_h + + # EV přímé napájení – standardní cena energie + + Σ_e ev_direct[e][t] * buy_price[t] * interval_h + + # EV přes baterii – navýšeno o round-trip ztrátu + degradaci + # Solver tak přirozeně preferuje přímé nabíjení nad průchodem baterií + + Σ_e ev_via_bat[e][t] * buy_price[t] * EV_ROUNDTRIP_FACTOR * interval_h + + # Malá penalizace curtailmentu pole A (preferujeme využití FVE) + + pv_a_curtailed[t] * CURTAILMENT_PENALTY + ] +``` + +kde `interval_h = 0.25` (15 min = 0.25 h), ceny v Kč/kWh, výkony ve W. + +--- + +## Omezení solveru + +### Energetická bilance +```python +pv_a_forecast[t] - pv_a_curtailed[t] + pv_b[t] + grid_import[t] + battery_discharge[t] + == load_baseline[t] + + Σ_e (ev_direct[e][t] + ev_via_bat[e][t]) + + heat_pump[t] + battery_charge[t] + grid_export[t] +``` + +### Vazba ev_via_bat na battery_discharge +```python +# ev_via_bat musí být kryto z vybíjení baterie +Σ_e ev_via_bat[e][t] <= battery_discharge[t] +``` + +### Limit výkonu EV per vozidlo +```python +# Celkový výkon do EV e nesmí překročit min(WB limit, vozidlo max) +ev_direct[e][t] + ev_via_bat[e][t] <= min(charger_max_w[e], vehicle_max_w[e]) + +# Pokud auto není připojeno → nula +if not ev_connected[e][t]: + ev_direct[e][t] == 0 + ev_via_bat[e][t] == 0 +``` + +### Deadline charging – hard constraint +```python +# Pro každé EV e s nastaveným deadline a known SoC: +if ev_session[e].target_deadline and ev_session[e].soc_at_connect_pct is not None: + energy_needed_wh = ( + (target_soc_pct - soc_at_connect_pct) / 100.0 + * vehicle_capacity_wh[e] + ) + t_deadline = slot_index(ev_session[e].target_deadline) + + pulp.lpSum( + (ev_direct[e][t] + ev_via_bat[e][t]) * interval_h + for t in range(t_deadline + 1) + if ev_connected[e][t] + ) >= energy_needed_wh + +# Pro Zoe (SoC neznámý) – deadline constraint na kumulativní dodanou energii: +# energy_needed = (default_target_soc - estimated_soc_from_session) * capacity +``` + +### SoC kontinuita +```python +soc[t] == soc[t-1] + + battery_charge[t] * charge_efficiency * interval_h + - battery_discharge[t] / discharge_efficiency * interval_h + +soc[0] == current_soc_wh # počáteční podmínka z telemetrie +``` + +### SoC limity +```python +soc_min_wh <= soc[t] <= soc_max_wh + +# Rezerva pro výpadek sítě – nikdy nesahat +soc_reserve_wh = battery.reserve_soc_percent / 100 * battery.usable_capacity_wh +soc[t] >= soc_reserve_wh # za normálních podmínek +``` + +### Limity výkonu +```python +0 <= battery_charge[t] <= battery.max_charge_power_w +0 <= battery_discharge[t] <= battery.max_discharge_power_w +0 <= grid_import[t] <= grid.max_import_power_w +0 <= grid_export[t] <= grid.max_export_power_w # = 13500 pro home-01 +0 <= pv_a_curtailed[t] <= pv_a_forecast[t] +0 <= ev_charge[t] <= ev_max_total_w +0 <= heat_pump[t] <= heat_pump.rated_heating_power_w +``` + +### Nelze současně nabíjet a vybíjet baterii +```python +# Přirozeně vyplyne z optimalizace díky degradation_cost. +# Pokud ne, přidat: battery_charge[t] * battery_discharge[t] == 0 +# (to by ale byl QP, ne LP – raději nechat degradation_cost dělat práci) +``` + +### Záporná prodejní cena – zákaz exportu +```python +if sell_price[t] < 0: + grid_export[t] == 0 # přidat jako constraint pro daný slot +``` + +### Záporná prodejní cena – pole B má prioritu v ukládání +```python +# Pokud sell_price[t] < 0, výroba pole B nesmí jít do exportu. +# Formulace: grid_export[t] <= grid_import[t] + battery_discharge[t] ... +# Jednodušeji: pokud sell_price < 0, přidat constraint grid_export[t] == 0 +# (export stejně zakázán výše) a solver automaticky uloží přebytek. +``` + +### Záporná nákupní cena – nabíjet ze sítě je výhodné +```python +# Pokud buy_price[t] < 0, grid_import[t] je příjem → solver automaticky maximalizuje import. +# Omezit maximálním výkonem baterie (aby to mělo smysl): +# grid_import[t] <= battery.max_charge_power_w + ev_max_total_w + heat_pump.rated_heating_power_w +# (nechceme kupovat víc než spotřebujeme / uložíme) +``` + +### TUV minimální teplota – nouzový ohřev vždy +```python +# Pokud aktuální teplota zásobníku < tuv_min_temp_c: +# heat_pump[t=0] >= heat_pump.rated_heating_power_w * 0.8 # minimálně 80% výkonu v prvním slotu +# Toto je tvrdé omezení nezávislé na ceně. +``` + +--- + +## Implementace (Python / PuLP) + +```python +# backend/services/planning_engine.py + +import pulp +from pulp import HiGHS_CMD + +def solve_dispatch( + site_id: int, + slots: list[PlanningSlot], # 15min sloty s cenami, forecasty + battery: AssetBattery, + heat_pump: AssetHeatPump, + grid: SiteGridConnection, + current_soc_wh: float, + current_tuv_temp_c: float, + ev_max_total_w: int, +) -> list[DispatchResult]: + + T = len(slots) + H = 0.25 # interval v hodinách + CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace aby solver preferoval využití + + prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize) + + # --- Proměnné --- + grid_import = [pulp.LpVariable(f"gi_{t}", 0, grid.max_import_power_w) for t in range(T)] + grid_export = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w) for t in range(T)] + batt_charge = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_power_w) for t in range(T)] + batt_discharge = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)] + soc = [pulp.LpVariable(f"soc_{t}", + battery.reserve_soc_wh, + battery.soc_max_wh) for t in range(T)] + curtail_a = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)] + ev_charge = [pulp.LpVariable(f"ev_{t}", 0, ev_max_total_w) for t in range(T)] + heat_pump_p = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)] + + # --- Účelová funkce --- + prob += pulp.lpSum( + grid_import[t] * slots[t].buy_price * H / 1000 # Kč (W→kW) + - grid_export[t] * slots[t].sell_price * H / 1000 + + (batt_charge[t] + batt_discharge[t]) * battery.degradation_cost_czk_kwh * H / 1000 + + curtail_a[t] * CURTAILMENT_PENALTY + for t in range(T) + ) + + # --- Omezení --- + for t in range(T): + s = slots[t] + pv_a_net = s.pv_a_forecast_w - curtail_a[t] + + # Energetická bilance + prob += ( + pv_a_net + s.pv_b_forecast_w + grid_import[t] + batt_discharge[t] + == s.load_baseline_w + ev_charge[t] + heat_pump_p[t] + batt_charge[t] + grid_export[t] + ) + + # SoC kontinuita + soc_prev = current_soc_wh if t == 0 else soc[t-1] + prob += soc[t] == ( + soc_prev + + batt_charge[t] * battery.charge_efficiency * H + - batt_discharge[t] / battery.discharge_efficiency * H + ) + + # Záporná prodejní cena → zakázat export + if s.sell_price < 0: + prob += grid_export[t] == 0 + + # Záporná nákupní cena → omezit import na to co reálně spotřebujeme/uložíme + if s.buy_price < 0: + prob += grid_import[t] <= ( + battery.max_charge_power_w + + ev_max_total_w + + heat_pump.rated_heating_power_w + ) + + # Nouzový ohřev TUV – pokud zásobník pod minimem + if current_tuv_temp_c < heat_pump.tuv_min_temp_c: + prob += heat_pump_p[0] >= heat_pump.rated_heating_power_w * 0.8 + + # --- Řešení --- + solver = HiGHS_CMD(msg=False, timeLimit=10) + status = prob.solve(solver) + + if pulp.LpStatus[status] != 'Optimal': + raise PlanningError(f"Solver nenašel optimální řešení: {pulp.LpStatus[status]}") + + # --- Post-processing TČ: relaxovaná → ON/OFF --- + results = [] + for t in range(T): + hp_raw = pulp.value(heat_pump_p[t]) + hp_enabled = hp_raw > heat_pump.rated_heating_power_w * 0.3 # threshold pro ON + hp_power = heat_pump.rated_heating_power_w if hp_enabled else 0 + + results.append(DispatchResult( + interval_start = slots[t].interval_start, + battery_setpoint_w = round(pulp.value(batt_charge[t]) - pulp.value(batt_discharge[t])), + battery_soc_target = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1), + grid_setpoint_w = round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])), + ev_charge_power_w = round(pulp.value(ev_charge[t])), + heat_pump_enabled = hp_enabled, + heat_pump_setpoint_w = hp_power, + pv_a_curtailed_w = round(pulp.value(curtail_a[t])), + expected_cost_czk = round( + pulp.value(grid_import[t]) * slots[t].buy_price * H / 1000 + - pulp.value(grid_export[t]) * slots[t].sell_price * H / 1000, + 4 + ), + effective_buy_price = slots[t].buy_price, + effective_sell_price = slots[t].sell_price, + )) + + return results +``` + +--- + +## Scénáře které solver řeší správně + +### Ráno – vysoká FVE předpověď, přes poledne záporná cena +``` +Solver ráno (vysoká cena): + → vybíjí baterii do sítě (prodej při high price) + → exportuje FVE přebytek + +Přes poledne (záporná nebo nízká cena): + → zakáže export (grid_export == 0) + → nabíjí baterii z FVE + ze sítě (dostane zaplaceno) + → spouští TČ a EV (spotřebovává levnou/zápornou energii) + → případně curtailuje pole A pokud je baterie plná a není kam ukládat +``` + +### Pole B + záporná cena +``` +Pole B vyrábí 10 kWp, sell_price < 0: + → grid_export == 0 (constraint) + → solver musí interně spotřebovat vše z pole B + → prioritně: nabíjení baterie, pak EV, pak TČ + → pokud nic nestačí → baterie je plná, EV nepřipojeno, TČ na max: + solver ukáže že zbývající výroba pole B nejde spotřebovat + → tuto situaci logovat (přebytek nevyužit, bonus přesto inkasován) +``` + +### Záporná nákupní cena (platíme za odběr) +``` + → solver maximalizuje grid_import (je to příjem) + → omezen na max_charge + ev_max + hp_rated (nechceme kupovat zbytečně) + → nabíjí baterii na maximum + → spouští EV a TČ naplno +``` + +--- + +## DB – rozšíření planning_interval + +Přidat sloupec `pv_a_curtailed_w` do tabulky: + +```sql +-- V005__planning_curtailment.sql +ALTER TABLE ems.planning_interval + ADD COLUMN pv_a_curtailed_w INT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS +'Plánované omezení výroby FVE pole A v W (curtailment). 0 = žádné omezení. ' +'Hodnota > 0 znamená že solver rozhodl omezit výrobu pole A přes Modbus.'; +``` + +--- + +## Konfigurace (env proměnné) + +```env +PLANNING_HORIZON_HOURS=36 +PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout +PLANNING_CURTAILMENT_PENALTY=0.001 # Kč/Wh penalizace za omezení FVE +PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu +PV_B_GREEN_BONUS_CZK_KWH=1.20 # zelený bonus Kč/kWh (informativní, do účelové funkce přidat pokud chceš) +``` + +> **Zelený bonus v účelové funkci:** Pokud chceš bonus explicitně zahrnout, přidat do objective function: +> `- pv_b[t] * GREEN_BONUS_CZK_KWH * H / 1000` jako konstantní příjem (pole B vždy vyrábí). +> Protože je to konstanta, neovlivní optimalizaci – ale správně zobrazí ekonomiku v auditu. + +--- + +## Závislosti (requirements.txt) + +``` +pulp>=2.8.0 +highspy>=1.7.0 # HiGHS Python binding (rychlejší než HiGHS_CMD) +``` + +> Preferovat `import highspy` přímý binding místo `HiGHS_CMD` shell volání – výrazně rychlejší. + +--- + +## Otevřené body + +- [ ] Post-processing min_run_duration pro TČ – po LP výsledku zkontrolovat a upravit krátké ON/OFF sekvence +- [ ] Zelený bonus zahrnout do auditního výpočtu nákladů (ne jen do objective) +- [ ] EV rozdělení výkonu mezi 2 nabíječky – zatím řešeno jako agregát +- [ ] Curtailment pole A – ověřit Modbus registr pro Output Power Limit na Deye SUN-20K +- [ ] Testovat solver na reálných datech – ověřit čas výpočtu pro 36h horizont (144 slotů) diff --git a/docs/04-modules/telemetry.md b/docs/04-modules/telemetry.md new file mode 100644 index 0000000..ef5c365 --- /dev/null +++ b/docs/04-modules/telemetry.md @@ -0,0 +1,216 @@ +# Modul: Telemetry (Sběr dat ze zařízení) + +## Co modul dělá + +- Čte data ze střídače Deye, EV nabíječek Teltonika a tepelného čerpadla Samsung přes Modbus TCP +- Ukládá surová měření do DB (1min granularita) +- Detekuje výpadky komunikace a loguje chyby +- Agreguje 1min data na 15min průměry pro spotřebu, audit a plánování + +--- + +## Komponenta: `telemetry_collector` (Python service) + +Samostatná Python služba. Běží jako smyčka, nezávislá na FastAPI. + +### Polling intervaly + +| Zařízení | Interval | Důvod | +|---|---|---| +| Deye střídač | 60 s | 1min granularita telemetrie | +| Teltonika EV nabíječka 1 | 60 s | | +| Teltonika EV nabíječka 2 | 60 s | | +| Samsung tepelné čerpadlo | 60 s | | + +### Chování při chybě + +- Chyba komunikace: záznam se nezapíše, chyba se loguje +- 3 po sobě jdoucí chyby = alert (log WARNING) +- 10 po sobě jdoucích chyb = log ERROR + pokus o reconnect +- Data se neinterpolují – chybějící minuty zůstanou prázdné (audit to pozná) + +--- + +## Deye SUN-20K – Modbus registry + +Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1). + +> Registry jsou specifické pro Deye SUN-20K-SG01LP1-EU. +> Finální hodnoty ověřit z Deye Modbus protokolu / Loxone šablony. + +| Registr (hex) | Typ | Popis | Jednotka | Přepočet | +|---|---|---|---|---| +| 0x0215 | Read Holding | PV celkový výkon | W | ×1 | +| 0x0103 | Read Holding | Battery SoC | % | ×1 | +| 0x0105 | Read Holding | Battery power | W | signed, kladné=nabíjení | +| 0x0101 | Read Holding | Battery voltage | 0.1V | ×0.1 | +| 0x0169 | Read Holding | Grid power | W | signed, kladné=import | +| 0x016F | Read Holding | Grid voltage L1 | 0.1V | ×0.1 | +| 0x0213 | Read Holding | Load power | W | ×1 | +| 0x0220 | Read Holding | Inverter temperature | 0.1°C | ×0.1 | +| 0x0168 | Read Holding | Operating mode | enum | viz tabulka módů | +| 0x0180 | Read Holding | Fault code | bitfield | 0=ok | + +**Zápis setpointů (plánování → Deye):** + +| Registr (hex) | Typ | Popis | Hodnota | +|---|---|---|---| +| 0x00F3 | Write Single | Battery charge power limit | W | +| 0x00F4 | Write Single | Battery discharge power limit | W | +| 0x00F6 | Write Single | Grid export power limit | W | +| 0x00F0 | Write Single | Work mode | enum (viz tabulka) | + +> **TODO:** Přesné registry doplnit z Deye SUN-20K Modbus protokolu PDF. +> Loxone šablona pro Deye je dobrý výchozí bod pro mapování registrů. + +--- + +## Teltonika TeltoCharge – Modbus registry + +Komunikace: Modbus TCP přes Waveshare, Unit ID = 1 (ověřit). + +> Registry doplnit z Teltonika TeltoCharge Modbus dokumentace / Loxone šablony. + +| Registr | Typ | Popis | Jednotka | +|---|---|---|---| +| TBD | Read | Stav konektoru (OCPP status enum) | enum | +| TBD | Read | Aktuální výkon | W | +| TBD | Read | Kumulativní energie session | Wh | +| TBD | Read | Proud L1/L2/L3 | 0.1A | +| TBD | Read | Napětí | 0.1V | +| TBD | Read | Session ID | uint | +| TBD | Read | Error code | uint | +| TBD | Write | Max proud (charge limit) | A (6–32A) | +| TBD | Write | Povolení nabíjení (on/off) | bool | + +--- + +## Samsung tepelné čerpadlo – Modbus registry + +Komunikace: Modbus TCP přes Waveshare. + +> Registry doplnit ze Samsung NASA Modbus dokumentace / Loxone šablony. + +| Registr | Typ | Popis | Jednotka | +|---|---|---|---| +| TBD | Read | Venkovní teplota | 0.1°C | +| TBD | Read | Teplota vody vstup | 0.1°C | +| TBD | Read | Teplota vody výstup | 0.1°C | +| TBD | Read | Teplota zásobníku TUV | 0.1°C | +| TBD | Read | Příkon | W | +| TBD | Read | Provozní režim | enum | +| TBD | Read | Alarm kód | uint | +| TBD | Read | Odmrazování aktivní | bool | +| TBD | Write | Povolení provozu | bool | +| TBD | Write | Požadovaná teplota TUV | °C | + +--- + +## Kód telemetrie (Python) + +```python +# backend/services/telemetry_collector.py + +import asyncio +from pymodbus.client import AsyncModbusTcpClient +from datetime import datetime, timezone + +async def poll_inverter(site_id: int, inverter: AssetInverter, endpoint: SiteEndpoint, db): + """Přečte všechny registry Deye a uloží záznam do telemetry_inverter.""" + async with AsyncModbusTcpClient(endpoint.host, port=endpoint.port) as client: + try: + # Čtení bloku registrů (optimalizovat jako jeden read multiple) + pv_power = await read_register(client, 0x0215, endpoint.unit_id) + batt_soc = await read_register(client, 0x0103, endpoint.unit_id) + batt_power = await read_register_signed(client, 0x0105, endpoint.unit_id) + batt_voltage = await read_register(client, 0x0101, endpoint.unit_id) / 10.0 + grid_power = await read_register_signed(client, 0x0169, endpoint.unit_id) + grid_voltage = await read_register(client, 0x016F, endpoint.unit_id) / 10.0 + load_power = await read_register(client, 0x0213, endpoint.unit_id) + inv_temp = await read_register(client, 0x0220, endpoint.unit_id) / 10.0 + op_mode = await read_register(client, 0x0168, endpoint.unit_id) + fault_code = await read_register(client, 0x0180, endpoint.unit_id) + + 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, inverter.id, datetime.now(timezone.utc), + pv_power, batt_soc, batt_power, batt_voltage, + grid_power, grid_voltage, load_power, + inv_temp, str(op_mode), fault_code + ) + + except Exception as e: + logger.warning(f"Inverter poll failed [{inverter.code}]: {e}") + raise + + +async def run_collector(db): + """Hlavní smyčka – každých 60s sbírá data ze všech aktivních zařízení.""" + while True: + start = asyncio.get_event_loop().time() + + sites = await db.fetch("SELECT id FROM ems.site WHERE active = true") + for site in sites: + await asyncio.gather( + poll_all_inverters(site.id, db), + poll_all_ev_chargers(site.id, db), + poll_all_heat_pumps(site.id, db), + return_exceptions=True # jeden výpadek nezastaví ostatní + ) + + elapsed = asyncio.get_event_loop().time() - start + await asyncio.sleep(max(0, 60 - elapsed)) +``` + +--- + +## Agregace 1min → 15min + +Prováděna PostgreSQL funkcí `ems.fn_fill_audit_interval()` a `ems.fn_fill_baseline_consumption()`. +Spouštěna každých 15 minut jako scheduled task (Python APScheduler nebo pg_cron). + +```sql +-- Příklad agregace telemetrie na 15min průměr +-- (součást fn_fill_audit_interval) +SELECT + site_id, + time_bucket('15 minutes', measured_at) AS interval_start, + AVG(pv_power_w)::INT AS avg_pv_power_w, + AVG(battery_power_w)::INT AS avg_battery_power_w, + AVG(grid_power_w)::INT AS avg_grid_power_w, + AVG(load_power_w)::INT AS avg_load_power_w, + LAST(battery_soc_percent, measured_at) AS last_soc_pct +FROM ems.telemetry_inverter +WHERE measured_at >= $1 AND measured_at < $1 + INTERVAL '15 minutes' + AND site_id = $2 +GROUP BY site_id, time_bucket('15 minutes', measured_at); +``` + +--- + +## Konfigurace (env proměnné) + +```env +TELEMETRY_POLL_INTERVAL_SEC=60 +TELEMETRY_ERROR_WARN_THRESHOLD=3 # počet chyb před WARNING logem +TELEMETRY_ERROR_RECONNECT_THRESHOLD=10 +MODBUS_CONNECT_TIMEOUT_SEC=5 +MODBUS_READ_TIMEOUT_SEC=3 +``` + +--- + +## Otevřené body + +- [ ] Doplnit přesné Modbus registry Deye z PDF protokolu +- [ ] Doplnit Modbus registry Teltonika z dokumentace / Loxone šablony +- [ ] Doplnit Modbus registry Samsung z dokumentace / Loxone šablony +- [ ] Ověřit Unit ID všech zařízení při instalaci +- [ ] Optimalizovat čtení Deye jako jeden `read_holding_registers` blok místo jednotlivých registrů diff --git a/docs/05-todo.md b/docs/05-todo.md new file mode 100644 index 0000000..f327412 --- /dev/null +++ b/docs/05-todo.md @@ -0,0 +1,98 @@ +# EMS – konsolidovaný seznam TODO + +Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulech a komentářů `TODO` / `TBD` / `[ ]` v repozitáři. Duplicitní témata jsou sloučena; u uvedených řádků jde o stav k poslední synchronizaci se soubory. + +**Role „kdo řeší“:** *majitel* = vlastník/provoz objektu a smluvní údaje; *programátor* = vývoj EMS; *Loxone programátor* = konfigurace Miniserveru a integrace v Loxone. + +--- + +## Blokující – nutné před prvním spuštěním + +Věci bez kterých nelze bezpečně napojit fyzická zařízení, spustit smysluplný forecast nebo dokončit kritická rozhodnutí před implementací řízení. + +| Popis | Kde | Kdo | +|-------|-----|-----| +| Doplnit **GPS** (`latitude`, `longitude`) pro lokalitu `home-01` – vstup Open-Meteo. | `db/migration/V003__seed_site_home01.sql` ř. 11–17 (`INSERT` + komentáře TODO); `docs/06-open-questions.md` ř. 15–16 | majitel (souřadnice) → programátor (úprava seedu/SQL) | +| Doplnit **skutečné IP** Waveshare (Deye), obou Teltonika WB, Samsung TČ a **Loxone**; ověřit **Modbus Unit ID** u zařízení. | `db/migration/V003__seed_site_home01.sql` ř. 27–30, 33–36, 39–41, 44–46, 49–52 (TODO komentáře); `docs/04-modules/telemetry.md` ř. 215 (ověření Unit ID) | majitel / instalatér (síť) → programátor (seed nebo `site_endpoint` v DB) | +| Doplnit **azimut a sklon** FVE polí A a B pro přesný výpočet predikce. | `db/migration/V003__seed_site_home01.sql` ř. 125–132, 140–146; `docs/06-open-questions.md` ř. 13–14; `docs/04-modules/forecast.md` ř. 16–17 (tabulka TBD), 177 | majitel / projektant FVE → programátor | +| Doplnit **model TČ**, **jmenovitý topný výkon (W)**, **COP rated**, **objem zásobníku TUV**, **odkaz na čidlo TUV** v seedu (`asset_heat_pump` má povinné numerické sloupce – bez platných hodnot nelze konzistentně plánovat / migrovat). | `db/migration/V003__seed_site_home01.sql` ř. 182–200 | majitel (datasheet) → programátor | +| **Rozhodnout Teltonika: OCPP 1.6 vs REST API** před implementací EV řízení a sběru. | `docs/06-open-questions.md` ř. 9–10; `docs/04-modules/consumption.md` ř. 184 | majitel + programátor | +| **Doplnit přesné Modbus registry** (čtení i zápis) pro Deye, Teltonika, Samsung – bez mapy registrů nejde napsat funkční `telemetry_collector` / `control_exporter`. | `docs/04-modules/telemetry.md` ř. 63, 76–105 (tabulky TBD), 212–214; `docs/04-modules/heat-pump.md` ř. 79–85, 102; `docs/04-modules/control.md` ř. 249–251; pseudokód `TBD_*_REGISTER` ř. 166–171, 192–197; `docs/loxone-integration.md` ř. 259–261 | majitel dodá PDF/šablony → programátor; část ověření s **Loxone programátor** | +| Ověřit **Modbus registr Output Power Limit** (curtailment pole A) na Deye SUN-20K. | `docs/04-modules/planning.md` ř. 422 | programátor (+ dokumentace od majitele) | +| Doplnit **skutečnou výši zeleného bonusu** (`green_bonus_czk_kwh`) dle smlouvy – aktuálně placeholder. | `db/migration/V005__planning_curtailment.sql` ř. 45–50 | majitel (smlouva) → programátor | + +--- + +## Fáze 1 – základní provoz + +Potřebné pro reálný, stabilní provoz; lze část EMS otestovat bez nich (např. jen DB, část solveru). + +| Popis | Kde | Kdo | +|-------|-----|-----| +| **Kurz EUR/CZK:** fixní env vs denní stahování (ČNB) – ovlivní import cen. | `docs/06-open-questions.md` ř. 11–12; `docs/04-modules/market-prices.md` ř. 126; `docs/04-modules/consumption.md` (související ekonomika) | majitel + programátor | +| **TUV výkon:** měřitelný příkon vs jen ON/OFF – dopad na baseline a plánování. | `docs/06-open-questions.md` ř. 21–22 | majitel + programátor | +| **Pole B (ongrid)** v auditu: sledovat neřízenou výrobu vs ignorovat. | `docs/06-open-questions.md` ř. 23–24; `docs/04-modules/forecast.md` ř. 179 | majitel + programátor | +| Filtrovat aktivní nabíječky **dle session** při zápisu setpointů (místo všech schedulable). | `docs/04-modules/control.md` ř. 153–155 (komentář TODO v pseudokódu) | programátor | +| Dohodnout **Loxone Virtual Input** názvy a vytvořit je v projektu (soulad s HTTP exportem). | `docs/04-modules/control.md` ř. 222–232, 252 | Loxone programátor + programátor | +| **Strategie rozdělení výkonu** mezi 2 nabíječky; chování při **selhání zápisu** jednoho zařízení (rollback?). | `docs/04-modules/control.md` ř. 253–254 | majitel + programátor | +| Ověřit **Watchdog / Timer** bloky v konkrétní verzi Loxone Config. | `docs/loxone-integration.md` ř. 258 | Loxone programátor | +| **Deye work mode** hodnoty (Self-Consumption, Grid-Charge, Backup) pro SELF_SUSTAIN / přepínání. | `docs/loxone-integration.md` ř. 259; `docs/04-modules/operating-modes.md` ř. 130 | programátor + dokumentace majitele | +| Dohodnout zdroj **SoC pro SELF_SUSTAIN** v Loxone (čtení ze střídače vs pevný práh). | `docs/loxone-integration.md` ř. 262 | majitel + Loxone programátor | +| **Přístup k logu** přepnutí watchdogu pro EMS po restartu. | `docs/loxone-integration.md` ř. 263 | Loxone programátor + programátor | +| Implementace **Loxone watchdog** dle integračního dokumentu. | `docs/04-modules/operating-modes.md` ř. 131; celý `docs/loxone-integration.md` | Loxone programátor + programátor | +| **Post-processing min_run/min_stop** TČ po výstupu LP (krátké ON/OFF). | `docs/04-modules/planning.md` ř. 419 | programátor | +| **Zelený bonus** započítat do **auditního** výpočtu nákladů, ne jen do optimalizace. | `docs/04-modules/planning.md` ř. 420 | programátor | +| **EV:** přesnější než agregát – sladit s `ev1_setpoint_w` / `ev2_setpoint_w` v DB a solveru. | `docs/04-modules/planning.md` ř. 421 | programátor | +| **Test solveru** na reálných datech (výkon pro 36h / 144 slotů). | `docs/04-modules/planning.md` ř. 423 | programátor | +| **Optimalizace čtení Deye** – jeden blok `read_holding_registers`. | `docs/04-modules/telemetry.md` ř. 216 | programátor | +| Ověřit **min_run_duration / min_stop_duration** TČ z dokumentace Samsung. | `docs/04-modules/heat-pump.md` ř. 104 | programátor | +| Doplnit **objem zásobníku TUV** pro výpočet doby ohřevu (nad rámec seedu). | `docs/04-modules/heat-pump.md` ř. 107 | majitel → programátor | +| **TUV čidlo v Loxone** pro přesnější řízení / baseline. | `docs/04-modules/consumption.md` ř. 185 | Loxone programátor + programátor | +| **Bazální spotřeba:** zpřesnit odečítání výkonu TČ/TUV (ON/OFF × čas vs pevný výkon). | `docs/04-modules/consumption.md` ř. 186 | majitel + programátor | +| **PostgREST autentizace** (JWT, RLS, …) před produkcí. | `docs/06-open-questions.md` ř. 25–26 | majitel + programátor | +| **Zálohování PostgreSQL** (pg_dump cron, replikace, …). | `docs/06-open-questions.md` ř. 27–28 | majitel + programátor | +| OTE: poznámka k **sell vs buy raw** u jiných zdrojů než OTE. | `docs/04-modules/market-prices.md` ř. 128 | programátor | +| Ověřit **Zoe max AC výkon** (7.4 kW vs podmínky instalace). | `docs/04-modules/ev-charging.md` ř. 281 | majitel + programátor | + +--- + +## Fáze 2 – rozšíření + +| Popis | Kde | Kdo | +|-------|-----|-----| +| **Tesla API:** Tessie vs přímé API. | `docs/04-modules/ev-charging.md` ř. 280 | majitel + programátor | +| **UI** pro deadline a target SoC před odjezdem. | `docs/04-modules/ev-charging.md` ř. 283 | programátor | +| **Notifikace** při nesplnitelném deadline nabíjení. | `docs/04-modules/ev-charging.md` ř. 284; `docs/04-modules/operating-modes.md` ř. 132 (stale heartbeat) | programátor | +| Ověřit **round-trip účinnost** baterie a **odhad SoC Zoe** z energie session na reálných datech. | `docs/04-modules/ev-charging.md` ř. 282, 285 | programátor | +| **Kalibrace COP** modelu TČ na 4–6 týdnů historie. | `docs/04-modules/heat-pump.md` ř. 105 | programátor | +| **pvlib** vs jednoduchý model FVE; **Solcast** jako alternativa k Open-Meteo. | `docs/04-modules/forecast.md` ř. 178, 180; `docs/06-open-questions.md` ř. 34 | programátor | +| **Intraday** OTE ceny. | `docs/06-open-questions.md` ř. 35; `docs/04-modules/market-prices.md` ř. 127 | programátor | +| **Sezónní korekce** predikce spotřeby. | `docs/06-open-questions.md` ř. 36; `docs/04-modules/consumption.md` ř. 187 | programátor | +| **Více lokalit** – UI a správa. | `docs/06-open-questions.md` ř. 33 | programátor | +| **Mobile / PWA notifikace.** | `docs/06-open-questions.md` ř. 37 | programátor | +| **Reporting** k dodavateli elektřiny. | `docs/06-open-questions.md` ř. 38 | majitel + programátor | + +--- + +## Architektonická rozhodnutí čekající na odpověď + +Otázky vyžadující rozhodnutí majitele systému (případně ve spolupráci s integrátory). + +| Popis | Kde | Kdo | +|-------|-----|-----| +| Teltonika **OCPP vs REST** (vliv na provoz, údržbu, bezpečnost). | `docs/06-open-questions.md` ř. 9–10 | majitel + programátor | +| **EUR/CZK** strategie (fix vs API). | `docs/06-open-questions.md` ř. 11–12; `docs/04-modules/market-prices.md` ř. 126 | majitel + programátor | +| **TUV** měření vs aproximace ON/OFF. | `docs/06-open-questions.md` ř. 21–22 | majitel + programátor | +| **Audit a plán:** jak nakládat s výrobou **pole B** a zeleným bonusem v reportingu. | `docs/06-open-questions.md` ř. 23–24; `docs/04-modules/forecast.md` ř. 179 | majitel + programátor | +| **PostgREST / API bezpečnost** pro produkci. | `docs/06-open-questions.md` ř. 25–26 | majitel + programátor | +| **Zálohy a DR** PostgreSQL. | `docs/06-open-questions.md` ř. 27–28 | majitel + programátor | +| **Přímý Modbus TCP k TČ** vs řízení přes Loxone jako prostředníka. | `docs/04-modules/heat-pump.md` ř. 106 | majitel + Loxone programátor + programátor | +| **pvlib vs jednoduchý** solární model – investice do přesnosti. | `docs/04-modules/forecast.md` ř. 178 | majitel + programátor | +| **Rollback / částečný selhání** zápisu setpointů napříč zařízeními. | `docs/04-modules/control.md` ř. 254 | majitel + programátor | +| **SoC zdroj** a prahy pro autonomní režimy v Loxone. | `docs/loxone-integration.md` ř. 262 | majitel + Loxone programátor | + +--- + +## Poznámka k údržbě + +Po vyřešení položky ji aktualizuj v **původním** souboru (smaž nebo přeškrtni `[ ]` / TODO) a zde v `05-todo.md` položku odstraň nebo přesuň do changelogu, ať zůstane jeden zdroj pravdy. diff --git a/docs/06-open-questions.md b/docs/06-open-questions.md new file mode 100644 index 0000000..6778fc9 --- /dev/null +++ b/docs/06-open-questions.md @@ -0,0 +1,38 @@ +# Otevřené otázky a nedořešené body + +Tento soubor slouží jako živý seznam věcí které je potřeba rozhodnout před nebo během implementace. + +--- + +## Kritické (blokují implementaci) + +- [ ] **Teltonika API vs OCPP** – Jaký protokol použít pro komunikaci s EV nabíječkami? OCPP 1.6 je standardní, Teltonika REST API je jednodušší. Rozhodnout před implementací `control.md` EV části. + +- [ ] **Kurz EUR/CZK** – Fixní hodnota v konfiguraci nebo denní stahování z ČNB API? Ovlivňuje `price_importer.py`. + +- [ ] **Azimut a sklon FVE polí** – Doplnit přesné hodnoty pro home-01 (pole A). Nutné pro `forecast_service.py`. + +- [ ] **GPS souřadnice lokality home-01** – Nutné pro Open-Meteo API (lat/lon). + +--- + +## Důležité (neblokují, ale řeší se brzy) + +- [ ] **TUV výkon** – Je TUV výkon měřitelný zvlášť nebo jen ON/OFF? Pokud jen ON/OFF, použijeme `asset_flexible_device.max_power_w` jako aproximaci. + +- [ ] **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. + +- [ ] **Backup a obnova** – Jak se zálohuje PostgreSQL? pg_dump cron? Replikace? Nutné pro produkci. + +--- + +## Fáze 2 (zatím neřešíme) + +- [ ] Více lokalit – multi-site UI a správa +- [ ] Solcast jako alternativa k Open-Meteo +- [ ] Intraday OTE ceny +- [ ] Sezónní korekce predikce spotřeby +- [ ] Mobile app / PWA notifikace +- [ ] Integrace s dodavatelem elektřiny pro automatický reporting diff --git a/docs/loxone-integration.md b/docs/loxone-integration.md new file mode 100644 index 0000000..43ae9ab --- /dev/null +++ b/docs/loxone-integration.md @@ -0,0 +1,263 @@ +# Loxone Integration – dokumentace pro programátora + +## Účel tohoto dokumentu + +Popis jak nakonfigurovat Loxone Miniserver pro spolupráci s EMS platformou. +Implementaci provede Loxone programátor dle tohoto zadání. + +**Klíčový princip:** Loxone je exekutor a bezpečnostní fallback. +Veškerá optimalizační logika je v EMS. Loxone: +- vykonává setpointy od EMS (v režimu AUTO) +- funguje zcela autonomně bez EMS (v ostatních režimech) +- sám detekuje výpadek EMS a přepne do bezpečného stavu + +--- + +## 1. Virtual Inputs (EMS → Loxone) + +Vytvořit jako **Virtual HTTP Input** v Loxone Config. +EMS posílá hodnoty přes HTTP GET: `/dev/sps/io/{název}/{hodnota}` + +| Název VI | Typ | Rozsah | Popis | +|---|---|---|---| +| `EMS_Heartbeat` | Digital pulse | 0/1 | Minutový pulz od EMS. Základ pro watchdog. | +| `EMS_Mode` | Analog | 0–4 | Aktivní provozní režim (viz tabulka režimů níže). | +| `EMS_Battery_Setpoint_W` | Analog | -20000 až +20000 | Setpoint baterie ve W. Kladné = nabíjení, záporné = vybíjení. Platí jen v AUTO. | +| `EMS_Grid_Setpoint_W` | Analog | -20000 až +20000 | Setpoint sítě ve W. Kladné = import, záporné = export. Platí jen v AUTO. | +| `EMS_EV1_Power_W` | Analog | 0–22000 | Povolený výkon nabíječky EV č. 1 ve W. 0 = zakázat nabíjení. | +| `EMS_EV2_Power_W` | Analog | 0–22000 | Povolený výkon nabíječky EV č. 2 ve W. 0 = zakázat nabíjení. | +| `EMS_HeatPump_Enable` | Digital | 0/1 | Povolení provozu tepelného čerpadla. 1 = povolen, 0 = zakázán. | + +> **Poznámka k setpointům:** `EMS_Battery_Setpoint_W` a `EMS_Grid_Setpoint_W` jsou informativní vstupy pro AUTO režim. Loxone je předá jako Modbus příkazy do střídače Deye. V ostatních režimech (SELF_SUSTAIN, PRESERVE, MANUAL) Loxone tyto hodnoty ignoruje a řídí se vlastní logikou. + +--- + +## 2. Virtual Outputs (Loxone → EMS čtení) + +Vytvořit jako **Virtual HTTP Output** nebo stav dostupný přes HTTP GET pro EMS backend. + +| Název VO | Typ | Popis | +|---|---|---| +| `EMS_Mode_Active` | Analog | Aktuálně aktivní režim v Loxone. EMS čte při startu pro zjištění stavu. | +| `EMS_Watchdog_Triggered` | Digital | 1 pokud watchdog přepnul na SELF_SUSTAIN bez příkazu od EMS. Pro diagnostiku. | + +--- + +## 3. Provozní režimy – kódování + +| Kód EMS | Loxone hodnota `EMS_Mode` | Název | +|---|---|---| +| `MANUAL` | 0 | Manuální – žádná logika | +| `AUTO` | 1 | Automatický – EMS řídí | +| `SELF_SUSTAIN` | 2 | Soběstačný – Loxone autonomní | +| `CHARGE_CHEAP` | 3 | Nabíjení levnou cenou | +| `PRESERVE` | 4 | Ochrana baterie | + +--- + +## 4. Watchdog – detekce výpadku EMS + +**Toto je nejdůležitější část Loxone implementace.** + +Watchdog musí fungovat čistě v Loxone, bez závislosti na DB nebo síti mimo lokální LAN. + +### Požadované chování + +``` +Pokud EMS_Heartbeat pulz nepřijde déle než 5 minut: + → nastavit EMS_Mode = 2 (SELF_SUSTAIN) + → nastavit EMS_Watchdog_Triggered = 1 + → logovat čas přepnutí (Loxone log) + +Pokud EMS_Heartbeat znovu přijde po výpadku: + → EMS_Watchdog_Triggered = 0 + → NEMĚNIT EMS_Mode zpět automaticky + (EMS si to přečte a rozhodne sám při restartu) +``` + +### Doporučená implementace v Loxone Config + +**Varianta A – Timer / Watchdog blok:** +- Použít blok `Watchdog` nebo `Timer` resetovaný příchozím pulzem `EMS_Heartbeat` +- Timeout: 300 sekund (5 minut) +- Při vypršení timeoutu: výstup spustí přepnutí do SELF_SUSTAIN + +**Varianta B – Pulse Counter + Time Trigger:** +- Počítat pulzy `EMS_Heartbeat` v 5min okně +- Pokud počet = 0 → přepnout režim + +> Výběr varianty závisí na dostupných blocích ve verzi Loxone Config. Programátor zvolí vhodnou implementaci. + +### Co Loxone watchdog NESMÍ dělat + +- Číst DB nebo jiný HTTP endpoint pro rozhodnutí o watchdogu +- Automaticky přepínat zpět na AUTO při obnovení heartbeatu +- Zasahovat do Modbus komunikace EMS↔střídač (EMS píše Modbus přímo) + +--- + +## 5. Stavový stroj režimů v Loxone + +Pro každý `EMS_Mode` hodnotu definovat chování: + +### Režim 1 – AUTO +``` +Střídač Deye: + - Battery charge limit = EMS_Battery_Setpoint_W (pokud kladné) + - Battery discharge limit = ABS(EMS_Battery_Setpoint_W) (pokud záporné) + - Grid export limit = ABS(EMS_Grid_Setpoint_W) (pokud záporné) + - Grid import limit = EMS_Grid_Setpoint_W (pokud kladné) + +EV nabíječka 1: + - Max power / current = EMS_EV1_Power_W (přepočet W → A: W / (fáze × 230)) + - Enable = 1 pokud EMS_EV1_Power_W > 1380, jinak 0 + +EV nabíječka 2: + - Stejná logika, EMS_EV2_Power_W + +Tepelné čerpadlo: + - Enable = EMS_HeatPump_Enable +``` + +### Režim 2 – SELF_SUSTAIN +``` +Střídač Deye: + - Přepnout do Self-Consumption / Battery Priority modu + (konkrétní Modbus registr/hodnota dle Deye dokumentace) + - Export do sítě: zakázat (export limit = 0 W) + - Import ze sítě: povolen jen při SoC pod min_soc_percent (nouzový) + - Baterie: vybíjí do zátěže, nenabíjí ze sítě + +EV nabíječky 1 + 2: + - Enable = 0 (zakázat nabíjení) + +Tepelné čerpadlo: + - Enable = 0 (odstavit) +``` + +### Režim 3 – CHARGE_CHEAP +``` +Střídač Deye: + - Přepnout do Grid Charge modu + - Battery charge limit = EMS_Battery_Setpoint_W (EMS posílá max výkon) + - Export do sítě: zakázat + +EV nabíječky: + - Enable = 0 + +Tepelné čerpadlo: + - Enable = 0 +``` + +### Režim 4 – PRESERVE +``` +Střídač Deye: + - Přepnout do Self-Consumption s omezeným nabíjením/vybíjením + - Battery: drží aktuální SoC (charge limit = 0, discharge limit = 0) + - Pokrývá spotřebu z FVE, zbytek ze sítě + +EV nabíječky: + - Enable = 0 + +Tepelné čerpadlo: + - Enable = 0 +``` + +### Režim 0 – MANUAL +``` +Všechny výstupy: žádné automatické zásahy +Střídač: nechat v aktuálním stavu +EV, TČ: Enable = 0 +Použít pouze pro servis a ladění +``` + +--- + +## 6. Komunikace Loxone → Deye (Modbus) + +Loxone komunikuje s Deye střídačem přes **Modbus TCP** (Loxone Modbus Extension nebo přímý TCP). + +> Konkrétní Modbus registry Deye SUN-20K doplnit z Deye protokolu PDF. +> Výchozí reference: Loxone šablona pro Deye (pokud existuje). + +### Klíčové registry pro zápis (orientační, ověřit z dokumentace) + +| Funkce | Registr | Typ | Poznámka | +|---|---|---|---| +| Battery charge power limit | 0x00F3 | Write Single | W | +| Battery discharge power limit | 0x00F4 | Write Single | W | +| Grid export power limit | 0x00F6 | Write Single | W | +| Work mode | 0x00F0 | Write Single | enum hodnoty dle Deye | + +### Klíčové registry pro čtení (telemetrie – pouze pokud Loxone čte, jinak EMS přes Waveshare) + +> **Doporučení:** Telemetrii čte EMS přímo přes Waveshare (Modbus TCP). Loxone čte jen to co potřebuje pro vlastní logiku (SoC pro SELF_SUSTAIN rozhodování). + +--- + +## 7. Komunikace Loxone → EV nabíječky (Teltonika) + +Loxone komunikuje s Teltonika TeltoCharge přes **Modbus TCP** (Waveshare převodník). + +> Konkrétní Modbus registry Teltonika TeltoCharge doplnit z dokumentace / Loxone šablony. + +### Minimální potřebné registry + +| Funkce | Popis | +|---|---| +| Enable charging | Povolení/zakázání nabíjení (digital) | +| Max current limit | Maximální proud v A (6–32A) | +| Connector status | Stav připojení (read) | + +--- + +## 8. Komunikace Loxone → Samsung TČ (Modbus) + +Loxone komunikuje se Samsung tepelným čerpadlem přes **Modbus TCP** (Waveshare převodník). + +> Registry doplnit ze Samsung NASA Modbus dokumentace / Loxone šablony. + +### Minimální potřebné registry + +| Funkce | Popis | +|---|---| +| Enable / Disable | Povolení provozu TČ (digital) | +| DHW target temp | Cílová teplota TUV zásobníku (write) | +| DHW tank temp | Aktuální teplota zásobníku (read) – pro watchdog nouzového ohřevu | + +### Loxone nouzový ohřev (nezávisle na EMS) + +Loxone musí implementovat vlastní minimální ochranu TUV zásobníku: +``` +Pokud teplota zásobníku < 40°C (absolutní minimum): + → spustit TČ bez ohledu na EMS_HeatPump_Enable a aktivní režim + → logovat jako nouzový ohřev +``` +Tato logika chrání zásobník i při výpadku EMS nebo přepnutí na SELF_SUSTAIN. + +--- + +## 9. Testovací scénáře pro programátora + +Po implementaci ověřit tyto scénáře: + +| # | Scénář | Očekávané chování | +|---|---|---| +| 1 | EMS odešle `EMS_Mode=1`, pak každou minutu `EMS_Heartbeat=1` | Loxone v AUTO, přeposílá setpointy do střídače | +| 2 | EMS přestane posílat heartbeat na 6 minut | Loxone přepne na SELF_SUSTAIN, EMS_Watchdog_Triggered=1 | +| 3 | EMS pošle heartbeat znovu po výpadku | Watchdog_Triggered=0, Mode zůstane SELF_SUSTAIN (EMS rozhodne) | +| 4 | EMS odešle `EMS_Mode=4` (PRESERVE) | Loxone drží baterii, žádné nabíjení/vybíjení | +| 5 | Teplota TUV klesne pod 40°C v SELF_SUSTAIN | Loxone spustí TČ nouzově | +| 6 | EMS odešle `EMS_Battery_Setpoint_W=-5000` (vybíjení) | Loxone nastaví discharge limit 5000W, charge limit 0W | +| 7 | EMS odešle `EMS_EV1_Power_W=0` | Loxone zakáže nabíjení nabíječky 1 | + +--- + +## 10. Otevřené body pro programátora + +- [ ] Ověřit dostupnost Watchdog / Timer bloku v instalované verzi Loxone Config +- [ ] Zjistit konkrétní Modbus work mode hodnoty pro Deye (Self-Consumption, Grid-Charge, Backup) +- [ ] Ověřit Modbus registry Teltonika z dodané šablony/dokumentace +- [ ] Ověřit Modbus registry Samsung TČ z dodané šablony/dokumentace +- [ ] Dohodnout jestli Loxone čte SoC ze střídače pro SELF_SUSTAIN logiku (nebo pevný threshold) +- [ ] Loxone log přepnutí watchdogu – jak přístupný pro EMS při restartu? diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..969a3da --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +*.md +.env* diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a6f8d6f --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +# Stage 1 – build static assets +FROM node:20-alpine AS builder +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Stage 2 – serve with nginx +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..26f1c53 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + EMS Platform + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..6166627 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,50 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_vary on; + gzip_min_length 256; + gzip_types + text/css + application/javascript + application/json + text/javascript + application/xml + application/xml+rss + text/plain; + + location /api/ { + proxy_pass http://backend:8000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /rest/ { + proxy_pass http://postgrest:3000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ^~ /assets/ { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location = /index.html { + add_header Cache-Control "no-cache"; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a5d9023 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4551 @@ +{ + "name": "ems-frontend", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "ems-frontend", + "dependencies": { + "axios": "^1.7.9", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^2.15.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.14", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "tailwindcss": "^4.0.14", + "typescript": "~5.6.3", + "vite": "^5.4.11" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "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", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + } + }, + "@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true + }, + "@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + } + }, + "@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "requires": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + } + }, + "@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true + }, + "@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "requires": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + } + }, + "@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true + }, + "@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true + }, + "@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "requires": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + } + }, + "@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "requires": { + "@babel/types": "^7.29.0" + } + }, + "@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==" + }, + "@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + } + }, + "@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + } + }, + "@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + } + }, + "@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "dev": true, + "optional": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "dev": true, + "optional": true + }, + "@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "requires": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "requires": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "dev": true, + "optional": true, + "requires": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + } + }, + "@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", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "dev": true, + "optional": true + }, + "@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "requires": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + } + }, + "@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "requires": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "requires": { + "@babel/types": "^7.28.2" + } + }, + "@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "requires": { + "@types/d3-color": "*" + } + }, + "@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "requires": { + "@types/d3-time": "*" + } + }, + "@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "requires": { + "@types/d3-path": "*" + } + }, + "@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, + "@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "requires": {} + }, + "@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "requires": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "requires": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "baseline-browser-mapping": { + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", + "dev": true + }, + "browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "requires": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + } + }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true + }, + "clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "requires": { + "internmap": "1 - 2" + } + }, + "d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==" + }, + "d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "requires": { + "d3-color": "1 - 3" + } + }, + "d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" + }, + "d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "requires": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + } + }, + "d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "requires": { + "d3-path": "^3.1.0" + } + }, + "d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "requires": { + "d3-array": "2 - 3" + } + }, + "d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "requires": { + "d3-time": "1 - 3" + } + }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true + }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, + "electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true + }, + "enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + } + }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "requires": { + "es-errors": "^1.3.0" + } + }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, + "esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==" + }, + "follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" + }, + "form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + } + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "requires": { + "has-symbols": "^1.0.3" + } + }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "requires": { + "function-bind": "^1.1.2" + } + }, + "internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==" + }, + "jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "requires": { + "detect-libc": "^2.0.3", + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "dev": true, + "optional": true + }, + "lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "dev": true, + "optional": true + }, + "lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "dev": true, + "optional": true + }, + "lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "dev": true, + "optional": true + }, + "lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "dev": true, + "optional": true + }, + "lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "dev": true, + "optional": true + }, + "lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "dev": true, + "optional": true + }, + "lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "dev": true, + "optional": true + }, + "lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "requires": {} + }, + "magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true + }, + "node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "requires": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + }, + "dependencies": { + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + } + } + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, + "react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "requires": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + } + }, + "react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, + "react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true + }, + "react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "requires": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + } + }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, + "recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "requires": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + } + }, + "recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "requires": { + "decimal.js-light": "^2.4.1" + } + }, + "rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@types/estree": "1.0.8", + "fsevents": "~2.3.2" + } + }, + "scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true + }, + "tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true + }, + "tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true + }, + "tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true + }, + "update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "requires": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + } + }, + "victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "requires": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "requires": { + "esbuild": "^0.21.3", + "fsevents": "~2.3.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + } + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fce82d8 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "ems-frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.9", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^2.15.0", + "sonner": "^1.7.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.14", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "tailwindcss": "^4.0.14", + "typescript": "~5.6.3", + "vite": "^5.4.11" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..dc5babb --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react' +import { Toaster } from 'sonner' +import Planning from './Planning' +import { Dashboard } from './pages/Dashboard' +import { Settings } from './pages/Settings' + +type Page = 'dashboard' | 'planning' | 'settings' + +export default function App() { + const [page, setPage] = useState('dashboard') + + return ( +
+ + {page === 'dashboard' ? : page === 'planning' ? : } + +
+ ) +} diff --git a/frontend/src/Planning.tsx b/frontend/src/Planning.tsx new file mode 100644 index 0000000..849370b --- /dev/null +++ b/frontend/src/Planning.tsx @@ -0,0 +1,457 @@ +import { Loader2, RefreshCw } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + Area, + CartesianGrid, + ComposedChart, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' + +import { getCurrentPlan, postRunPlan } from './api/backend' +import { useSiteStatus } from './hooks/useSiteStatus' +import type { CurrentPlanResponse, PlanningIntervalDto } from './types/plan' + +const TZ = 'Europe/Prague' + +function formatLocal(iso: string): string { + const d = new Date(iso) + return d.toLocaleString('cs-CZ', { + timeZone: TZ, + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +function formatLocalTime(iso: string): string { + return new Date(iso).toLocaleTimeString('cs-CZ', { + timeZone: TZ, + hour: '2-digit', + minute: '2-digit', + }) +} + +function slotStartUtcMs(iso: string): number { + return new Date(iso).getTime() +} + +function negPrice(i: PlanningIntervalDto): boolean { + const b = i.effective_buy_price + const s = i.effective_sell_price + return (b != null && b < 0) || (s != null && s < 0) +} + +function rowHighlight(i: PlanningIntervalDto): string { + if (negPrice(i)) return 'bg-red-950/45' + if ((i.pv_a_curtailed_w ?? 0) > 0) return 'bg-amber-950/35' + return '' +} + +type ChartRow = { + label: string + ts: number + pv_kw: number + baseline_kw: number + bat_charge_kw: number + bat_discharge_kw: number + price: number + raw: PlanningIntervalDto +} + +export default function Planning() { + const { site, ready: siteReady } = useSiteStatus() + const siteId = site?.site_id ?? null + + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [replanning, setReplanning] = useState(false) + const [slotDetail, setSlotDetail] = useState(null) + + const load = useCallback(async () => { + if (siteId == null) return + setLoading(true) + setError(null) + try { + const res = await getCurrentPlan(siteId) + setData(res) + } catch (e) { + setError(e instanceof Error ? e.message : 'Chyba načtení plánu') + setData(null) + } finally { + setLoading(false) + } + }, [siteId]) + + useEffect(() => { + if (siteId != null) void load() + }, [siteId, load]) + + const nowMs = Date.now() + const dayMs = 24 * 60 * 60 * 1000 + + const intervals24h = useMemo(() => { + if (!data?.intervals?.length) return [] + const end = nowMs + dayMs + return data.intervals + .filter((i) => { + const t = slotStartUtcMs(i.interval_start) + return t >= nowMs && t < end + }) + .slice(0, 96) + }, [data?.intervals, nowMs, dayMs]) + + const chartRows: ChartRow[] = useMemo(() => { + return intervals24h.map((i) => { + const bat = i.battery_setpoint_w ?? 0 + const pv = i.pv_forecast_total_w ?? 0 + const base = i.load_baseline_w ?? 0 + const price = i.effective_buy_price ?? 0 + return { + label: formatLocalTime(i.interval_start), + ts: slotStartUtcMs(i.interval_start), + pv_kw: pv / 1000, + baseline_kw: base / 1000, + bat_charge_kw: Math.max(0, bat) / 1000, + bat_discharge_kw: Math.max(0, -bat) / 1000, + price, + raw: i, + } + }) + }, [intervals24h]) + + async function onReplan() { + if (siteId == null) return + setReplanning(true) + setError(null) + try { + await postRunPlan(siteId, 'rolling') + await load() + } catch (e) { + setError(e instanceof Error ? e.message : 'Přepočet selhal') + } finally { + setReplanning(false) + } + } + + if (!siteReady) { + return ( +
+ 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 + + return ( +
+
+

Plánování

+

+ Aktuální LP plán a přehled dalších 24 hodin ({site?.site_name ?? 'lokalita'}) +

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

+ Aktuální plán +

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

Žádný aktivní plán v databázi.

+ ) : ( +
+
+
+
Vytvořen
+
{formatLocal(run.created_at)}
+
+
+
Typ
+
{run.run_type}
+
+
+
Korekce FVE
+
+ {run.forecast_correction_factor != null + ? run.forecast_correction_factor.toFixed(4) + : '—'} +
+
+
+
Čas solveru
+
+ {run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'} +
+
+
+ +
+ )} + {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 new file mode 100644 index 0000000..58a0009 --- /dev/null +++ b/frontend/src/api/backend.ts @@ -0,0 +1,56 @@ +import axios, { type AxiosInstance } from 'axios' + +import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan' + +const client: AxiosInstance = axios.create({ + baseURL: '/api/v1', + headers: { Accept: 'application/json' }, + timeout: 30_000, +}) + +/** Příklad: health / readiness až budou v FastAPI exponované. */ +export async function getBackendHealth(): Promise { + const { data } = await client.get('/health') + return data +} + +export type SetSiteModePayload = { + mode: string + notes: string | null + valid_until: string | null +} + +export type SetSiteModeResponse = { + success: boolean + mode: string + activated_at: string +} + +export async function postSiteMode( + siteId: number, + payload: SetSiteModePayload, +): Promise { + const { data } = await client.post(`/sites/${siteId}/mode`, payload) + return data +} + +export async function getCurrentPlan(siteId: number): Promise { + const { data } = await client.get(`/sites/${siteId}/plan/current`, { + timeout: 60_000, + }) + return data +} + +export async function postRunPlan( + siteId: number, + planType: 'daily' | 'rolling', +): Promise { + const { data } = await client.post( + `/sites/${siteId}/plan/run`, + null, + { params: { type: planType }, timeout: 120_000 }, + ) + return data +} + +export { client as backendClient } diff --git a/frontend/src/api/postgrest.ts b/frontend/src/api/postgrest.ts new file mode 100644 index 0000000..c423c0c --- /dev/null +++ b/frontend/src/api/postgrest.ts @@ -0,0 +1,14 @@ +import axios, { type AxiosInstance } from 'axios' + +const client: AxiosInstance = axios.create({ + baseURL: '/rest', + headers: { Accept: 'application/json' }, + timeout: 15_000, +}) + +export async function getJson(path: string, params?: Record): Promise { + const { data } = await client.get(path, { params }) + return data +} + +export { client as postgrestClient } diff --git a/frontend/src/components/ModeLog.tsx b/frontend/src/components/ModeLog.tsx new file mode 100644 index 0000000..c73240c --- /dev/null +++ b/frontend/src/components/ModeLog.tsx @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useState } from 'react' +import { getJson } from '../api/postgrest' +import type { ModeLogRecentRow } from '../types/ems' + +function modeBadgeClass(code: string): string { + const c = code.toUpperCase() + if (c === 'AUTO') return 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/35' + if (c === 'SELF_SUSTAIN') return 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/35' + if (c === 'CHARGE_CHEAP') return 'bg-violet-500/15 text-violet-200 ring-1 ring-violet-500/35' + if (c === 'PRESERVE') return 'bg-amber-500/15 text-amber-200 ring-1 ring-amber-500/35' + if (c === 'MANUAL') return 'bg-slate-600/50 text-slate-200 ring-1 ring-slate-500/40' + return 'bg-slate-700/60 text-slate-200 ring-1 ring-slate-600/50' +} + +function num(v: string | number | null | undefined): number { + if (v == null) return NaN + const n = typeof v === 'number' ? v : Number(v) + return n +} + +function formatDuration(sec: number): string { + if (!Number.isFinite(sec) || sec < 0) return '—' + const h = Math.floor(sec / 3600) + const m = Math.floor((sec % 3600) / 60) + const s = Math.floor(sec % 60) + if (h > 0) return `${h} h ${m} min` + if (m > 0) return `${m} min ${s > 0 ? `${s} s` : ''}`.trim() + return `${s} s` +} + +function fmtTime(iso: string): string { + try { + const d = new Date(iso) + return d.toLocaleString('cs-CZ', { dateStyle: 'short', timeStyle: 'medium' }) + } catch { + return iso + } +} + +type Props = { + siteId: number | null +} + +export function ModeLog({ siteId }: Props) { + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + if (siteId == null) { + setRows([]) + setLoading(false) + return + } + setLoading(true) + setError(null) + try { + const data = await getJson('/vw_mode_log_recent', { + site_id: `eq.${siteId}`, + order: 'activated_at.desc', + limit: '20', + }) + setRows(Array.isArray(data) ? data : []) + } catch (e) { + setError(String(e)) + setRows([]) + } finally { + setLoading(false) + } + }, [siteId]) + + useEffect(() => { + void load() + }, [load]) + + if (siteId == null) { + return

Vyberte nebo načtěte lokalitu.

+ } + + if (loading) { + return
+ } + + if (error) { + return

Nelze načíst log: {error}

+ } + + return ( +
+ + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((r) => ( + + + + + + + + )) + )} + +
ČasRežimTrváníKdoPoznámka
+ Žádné záznamy za posledních 7 dní. +
{fmtTime(r.activated_at)} + + {r.mode_code} + + {formatDuration(num(r.duration_sec))} + {r.activated_by ?? '—'} + + {r.notes?.trim() ? r.notes : '—'} +
+
+ ) +} diff --git a/frontend/src/components/ModeSelector.tsx b/frontend/src/components/ModeSelector.tsx new file mode 100644 index 0000000..f7dee50 --- /dev/null +++ b/frontend/src/components/ModeSelector.tsx @@ -0,0 +1,269 @@ +import { + BatteryCharging, + Bot, + Car, + Check, + Home, + Shield, + Thermometer, + Wrench, + X, +} from 'lucide-react' +import axios from 'axios' +import { useCallback, useMemo, useState } from 'react' +import { toast } from 'sonner' +import { postSiteMode } from '../api/backend' + +export type OperatingModeCode = 'AUTO' | 'SELF_SUSTAIN' | 'CHARGE_CHEAP' | 'PRESERVE' | 'MANUAL' + +type ModeDef = { + code: OperatingModeCode + title: string + description: string + ev: boolean + hp: boolean + Icon: typeof Bot +} + +const MODES: ModeDef[] = [ + { + code: 'AUTO', + title: 'AUTO', + description: 'EMS řídí FVE, baterii, EV a TČ podle plánu a cen.', + ev: true, + hp: true, + Icon: Bot, + }, + { + code: 'SELF_SUSTAIN', + title: 'SELF_SUSTAIN', + description: 'Autonomní domácí režim bez exportu; EV a TČ zastaveny.', + ev: false, + hp: false, + Icon: Home, + }, + { + code: 'CHARGE_CHEAP', + title: 'CHARGE_CHEAP', + description: 'Max. nabíjení baterie; EV a TČ vypnuty.', + ev: false, + hp: false, + Icon: BatteryCharging, + }, + { + code: 'PRESERVE', + title: 'PRESERVE', + description: 'Držení SoC; EV a TČ zastaveny (dovolená / servis).', + ev: false, + hp: false, + Icon: Shield, + }, + { + code: 'MANUAL', + title: 'MANUAL', + description: 'Servisní režim; žádné řízení z EMS.', + ev: false, + hp: false, + Icon: Wrench, + }, +] + +function modeBadgeRing(code: string): string { + const c = code.toUpperCase() + if (c === 'AUTO') return 'ring-emerald-500/50' + if (c === 'SELF_SUSTAIN') return 'ring-cyan-500/50' + if (c === 'CHARGE_CHEAP') return 'ring-violet-500/50' + if (c === 'PRESERVE') return 'ring-amber-500/50' + if (c === 'MANUAL') return 'ring-slate-500/50' + return 'ring-slate-600' +} + +type Props = { + siteId: number | null + currentMode: string | null | undefined + onModeApplied?: () => void +} + +export function ModeSelector({ siteId, currentMode, onModeApplied }: Props) { + const [pending, setPending] = useState(null) + const [notes, setNotes] = useState('') + const [validUntilLocal, setValidUntilLocal] = useState('') + const [optimisticMode, setOptimisticMode] = useState(null) + const [submitting, setSubmitting] = useState(false) + + const displayMode = optimisticMode ?? currentMode ?? null + const normalizedCurrent = (displayMode ?? '').toUpperCase() + + const closeModal = useCallback(() => { + setPending(null) + setNotes('') + setValidUntilLocal('') + }, []) + + const confirmSwitch = useCallback(async () => { + if (siteId == null || pending == null) return + const modeCode = pending + const notePayload = notes.trim() === '' ? null : notes.trim() + const valid_until = + validUntilLocal.trim() === '' ? null : new Date(validUntilLocal).toISOString() + setSubmitting(true) + setOptimisticMode(modeCode) + closeModal() + try { + await postSiteMode(siteId, { + mode: modeCode, + notes: notePayload, + valid_until, + }) + setOptimisticMode(null) + onModeApplied?.() + toast.success(`Režim ${modeCode} byl aktivován.`) + } catch (e: unknown) { + setOptimisticMode(null) + let msg = String(e) + if (axios.isAxiosError(e)) { + const d = e.response?.data as { detail?: unknown } | undefined + if (d?.detail != null) { + msg = Array.isArray(d.detail) ? d.detail.map((x) => JSON.stringify(x)).join('; ') : String(d.detail) + } else if (e.message) { + msg = e.message + } + } + toast.error('Přepnutí režimu se nezdařilo', { description: msg }) + } finally { + setSubmitting(false) + } + }, [siteId, pending, notes, validUntilLocal, closeModal, onModeApplied]) + + const openConfirm = useCallback( + (code: OperatingModeCode) => { + if (siteId == null) { + toast.error('Chybí lokalita (site_id).') + return + } + if (code === normalizedCurrent) return + setPending(code) + setNotes('') + setValidUntilLocal('') + }, + [siteId, normalizedCurrent], + ) + + const modalTitle = useMemo(() => { + if (!pending) return '' + const m = MODES.find((x) => x.code === pending) + return m?.title ?? pending + }, [pending]) + + return ( +
+
+ {MODES.map(({ code, title, description, ev, hp, Icon }) => { + const active = normalizedCurrent === code + return ( + + ) + })} +
+ + {pending ? ( +
{ + if (ev.target === ev.currentTarget) closeModal() + }} + > +
+

+ Přepnout na {modalTitle}? +

+

Změna se zapíše do DB a odešle se signál do Loxone (je-li endpoint).

+