diff --git a/docs/01-overview.md b/docs/01-overview.md index 833c638..c3ca1c0 100644 --- a/docs/01-overview.md +++ b/docs/01-overview.md @@ -41,7 +41,7 @@ Systém přebírá rozhodovací logiku od Loxone a stává se „mozkem" – pl | Vrstva | Technologie | |---|---| -| DB | PostgreSQL 16 + TimescaleDB | +| DB | PostgreSQL 18 + TimescaleDB | | API / BFF | PostgREST (automatické REST z DB schématu) | | Backend logika | Python (FastAPI) – plánovač, sběr dat, integrace | | Frontend | React + TypeScript | diff --git a/docs/02-architecture.md b/docs/02-architecture.md index cce852d..f8b0cff 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -12,12 +12,12 @@ ┌─────────────▼───────────────────────────────┐ │ PostgREST │ │ Auto-REST API z PostgreSQL schématu ems │ -│ Read: views, tabulky │ -│ Write: insert/update přes API │ +│ Read-only: views, vybrané tabulky │ +│ Write operace jdou přes FastAPI │ └─────────────┬───────────────────────────────┘ │ SQL ┌─────────────▼───────────────────────────────┐ -│ PostgreSQL 16 + TimescaleDB │ +│ PostgreSQL 18 + TimescaleDB │ │ Schéma: ems │ │ Funkce, views, hypertables │ └─────────────┬───────────────────────────────┘ @@ -26,13 +26,19 @@ │ FastAPI (Python) │ │ – Scheduled tasks (APScheduler) │ │ – telemetry_collector (každých 60s) │ -│ – price_importer (13:30, 14:00, 00:05) │ +│ – heartbeat (každých 60s) │ +│ – price_importer (13:25, 13/14:12, │ +│ 13/14:45, 14:00, │ +│ 00:05) │ │ – forecast_service (každé 2h, minute 05)│ │ – planning_engine (denně 15:00) │ +│ – rolling_replan (každých 15min) │ │ – control_exporter (každých 15min) │ │ – audit_filler (každých 15min) │ +│ – forecast_accuracy (:02,:17,:32,:47) │ │ – plan_actual_slot_guard (:05,:20,:35,:50) │ │ – verify_modbus (každé 2 min) │ +│ – signal_outbound (každých 15s) │ └──────┬──────────────────────────┬────────────┘ │ Modbus TCP │ HTTP ┌──────▼──────┐ ┌───────▼────────────┐ @@ -59,7 +65,7 @@ FastAPI endpointy pro dashboard a konfiguraci preferují **jedno volání** `sel | Komponenta | Technologie | Port | Popis | |---|---|---|---| -| `db` | PostgreSQL 16 + TimescaleDB | 5432 | Datová vrstva | +| `db` | PostgreSQL 18 + 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 | @@ -88,7 +94,8 @@ ems-platform/ R__019_fn_fill_audit_interval.sql R__073_fn_health_site_jobs_mode_bundle.sql (historicky) R__fn_plan_day.sql – primární plánování je PuLP v Pythonu - R__fn_create_planning_run.sql + R__037_fn_planning_run_commit.sql + R__063_fn_load_planning_slots_full.sql views/ R__061_vw_site_effective_price.sql R__058_vw_latest_telemetry.sql @@ -114,7 +121,7 @@ ems-platform/ telemetry_collector.py price_importer.py forecast_service.py - planning_engine.py ← volá ems.fn_create_planning_run() + planning_engine.py ← PuLP solver, ukládá přes ems.fn_planning_run_commit() control_exporter.py audit_filler.py modbus/ @@ -173,9 +180,10 @@ ems-platform/ Zařízení → Waveshare → Modbus TCP → telemetry_collector → PostgreSQL ``` -### Denní plánování (15:00) +### Denní plánování (15:00) a rolling replan ``` -PostgreSQL (ceny + forecast) → fn_create_planning_run() → planning_interval +PostgreSQL (ceny + forecast + telemetrie) → planning_engine (PuLP) + → fn_planning_run_commit() → planning_interval ``` ### Operátorské manuální akce (UI) @@ -200,6 +208,10 @@ Browser → PostgREST (čtení views/tabulek, filtr site_id dle výběru v UI) Browser → FastAPI (seznam lokalit /me/sites, triggery: replanning, import cen, …) ``` +PostgREST je v aktuální produkční konfiguraci určený pro čtení (`ems_anon` má +`SELECT` na vybrané views/tabulky). Zápisy a operátorské akce se provádí přes +FastAPI, které používá vlastní DB connection. + --- ## Deployment: single-site (výchozí) diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 3ccc01e..d7ae516 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -22,6 +22,8 @@ CREATE TABLE site ( code TEXT UNIQUE NOT NULL, -- např. 'home-01' name TEXT NOT NULL, timezone TEXT NOT NULL DEFAULT 'Europe/Prague', + latitude NUMERIC(9,6), -- Open-Meteo / pvlib + longitude NUMERIC(9,6), -- Open-Meteo / pvlib active BOOLEAN NOT NULL DEFAULT true, notes TEXT, created_at TIMESTAMPTZ DEFAULT now() @@ -39,6 +41,7 @@ CREATE TABLE site_endpoint ( host TEXT NOT NULL, port INT, protocol TEXT, -- 'modbus_tcp', 'http', 'https' + unit_id INT, -- Modbus Unit ID pro modbus_tcp auth_reference TEXT, -- odkaz na secret / env proměnnou enabled BOOLEAN DEFAULT true, notes TEXT @@ -119,6 +122,11 @@ CREATE TABLE asset_battery ( 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 + -- pozdější migrace přidávají plánovací tunables: + -- charge_slot_buffer, discharge_slot_buffer, + -- planner_max_soc_percent, planner_discharge_floor_percent, + -- planner_extreme_buy_threshold_czk_kwh, + -- planner_terminal_soc_value_factor ); ``` @@ -135,7 +143,7 @@ CREATE TABLE asset_pv_array ( code TEXT NOT NULL, name TEXT, nominal_power_wp INT NOT NULL, -- 10000 - azimuth_deg NUMERIC(6,2), -- 0=S, 90=Z, -90=V + azimuth_deg NUMERIC(6,2), -- kompasově/pvlib: 0=N, 90=E, 180=S, 270=W tilt_deg NUMERIC(5,2), module_count INT, shading_factor NUMERIC(4,3) DEFAULT 1.0, @@ -169,22 +177,30 @@ CREATE TABLE asset_ev_charger ( ); ``` -### `asset_flexible_device` -Generická tabulka pro ostatní flexibilní spotřebiče (TUV, tepelné čerpadlo, ...). +### `asset_heat_pump` +Tepelné čerpadlo / TUV. Aktuální implementace má samostatnou tabulku místo +historického generického `asset_flexible_device`. ```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 +CREATE TABLE asset_heat_pump ( + id SERIAL PRIMARY KEY, + site_id INT REFERENCES site(id), + code TEXT NOT NULL, + manufacturer TEXT, + model TEXT, + endpoint_id INT REFERENCES 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 ); ``` @@ -424,7 +440,9 @@ CREATE TABLE consumption_baseline_interval ( ``` ### 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`. +Flexibilní spotřeba se neukládá souhrnně – odvozuje se ze součtu +`telemetry_ev_charger` + `telemetry_heat_pump` / plánovaných setpointů. +Plánovaná flexibilní spotřeba je součástí `planning_interval`. --- diff --git a/docs/04-modules/consumption.md b/docs/04-modules/consumption.md index 9e0df7f..d61a6d3 100644 --- a/docs/04-modules/consumption.md +++ b/docs/04-modules/consumption.md @@ -116,7 +116,8 @@ Operativní predikce je v **`fn_get_baseline_forecast`** a v přímém dotazu v **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` +- Aktuální výkon: zatím není reálně čten z TČ; placeholder telemetrie a plánování + používají parametry z `asset_heat_pump`, hlavně `rated_heating_power_w` **Plánování:** - TUV se ohřívá v době přebytku FVE nebo levného spotu diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index 81f8c49..189e115 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -5,8 +5,8 @@ - Č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 +- EV nabíječky a tepelné čerpadlo zatím pouze vyhodnotí a zaloguje; konkrétní + Modbus registry jsou TODO - Odešle potvrzovací setpointy do Loxone přes HTTP (Loxone jako exekutor fallback logiky) - Loguje každý write pro audit @@ -19,10 +19,10 @@ 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) + ├── TODO/log → Teltonika EV nabíječka 1 + ├── TODO/log → Teltonika EV nabíječka 2 + ├── TODO/log → Samsung TČ + └── HTTP GET → Loxone Virtual Inputs (informační setpointy) ``` **Loxone role:** Loxone dostává setpointy jako informaci a jako fallback ochranu. @@ -34,7 +34,7 @@ Rozhodovací logika je v EMS, ne v Loxone. | Trigger | Čas | Popis | |---|---|---| -| Scheduled | každých 15min (xx:00, xx:15, xx:30, xx:45) | Standardní export na začátku intervalu | +| Scheduled | každých 15min (`xx:14`, `xx:29`, `xx:44`, `xx:59`) | Export těsně před dalším slotem | | 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 | @@ -51,6 +51,8 @@ Ověření: logy backendu kolem pokusu **nebo** `select id,status,created_at fro ## Logika exportu ```python +# Zjednodušený historický náčrt. Aktuální implementace používá +# ems.fn_planning_interval_at_offset(), ControlSetpoints a Deye journal. async def export_setpoints_for_interval(site_id: int, interval_start: datetime, db): """ Načte plánované setpointy pro daný interval, aplikuje overrides @@ -84,14 +86,11 @@ async def export_setpoints_for_interval(site_id: int, interval_start: datetime, 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 - ) + # 3. Zapsat Deye, zalogovat EV/TČ TODO, poslat Loxone + await write_inverter_setpoints(site_id, setpoints, db) + await write_ev_setpoints(site_id, setpoints, db) # TODO registry + await write_heat_pump_setpoint(site_id, setpoints, db) # TODO registry + await send_loxone_setpoints(site_id, setpoints, mode, db) def apply_overrides(plan, overrides) -> Setpoints: @@ -153,7 +152,7 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg | **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 | | **PASSIVE (ZERO)** | vše ostatní — reg. **108/109** dle `_deye_zero_export_amps_for_passive` (viz `operating-modes.md`) | -**PASSIVE** (AUTO, ZERO): výchozí **108** i **109** = maximum z DB; u exportu bez vybíjení **108 = 0**, u importu bez nabíjení **109 = 0** (`_deye_zero_export_amps_for_passive`). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **145** (solar sell): v kódu vždy **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*). +**PASSIVE** (AUTO, ZERO): výchozí **108** i **109** = maximum z DB; u exportu bez vybíjení **108 = 0**, u importu bez nabíjení **109 = 0** (`_deye_zero_export_amps_for_passive`). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **145** (solar sell): **0** při `export_ban` mimo SELL, jinak **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*). **SELF_SUSTAIN** zůstává **PASSIVE** v `get_deye_mode`; **108/109** jsou vždy **max z DB** (bez variant ZERO). Rozdíl je **`self_sustain_local_use=True`**: **TOU SOC** = **`min_soc_percent`**, `battery_w=None`. @@ -165,7 +164,7 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg | **109** (discharge A) | **0** | max / **0** (import, držet bat.) | **max z DB** | dle varianty | | **142** (limit control) | `deye_zero_export_mode` | `deye_zero_export_mode` | **0** (selling first) | `deye_zero_export_mode` | | **143** (export cap) | max z DB | max z DB | `min(max_site, max(200, \|grid_setpoint_w\|))` | max z DB | -| **145** (solar sell) | 1 | 1 | 1 | 1 | +| **145** (solar sell) | 1 / 0 při `export_ban` | 1 / 0 při `export_ban` | 1 | 1 / 0 při `export_ban` | | **178** (peak shaving) | 48 | 48 | **32** | 48 | U **AUTO PASSIVE** závisí **108/109** na znaménkách plánu (viz `operating-modes.md`). **SELF_SUSTAIN** drží oba **max z DB**; **TOU SOC** ve všech PASSIVE větvích je **`min_soc_percent`** (viz `_deye_passive_tou_battery_soc_pct`). Liší se především **`battery_w`** a mapování **108/109**. @@ -184,6 +183,9 @@ Po zápisu na Modbus se hodnoty ověřují v `verify_modbus_commands` (`control_ Při přechodu **SELF_SUSTAIN → AUTO** (`run_fn_set_mode_with_discord`) se na pozadí spustí **rolling replan**, aby aktivní plán odpovídal plné optimalizaci. Viz [`modbus-command-journal.md`](modbus-command-journal.md). ```python +# Historický pseudokód. Aktuální Deye implementace používá journal +# ems.modbus_command a FC 0x10 (`write_registers`) nad registry +# 108/109/141/142/143/145/178/340 + TOU bloky. async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db): inverters = await db.fetch( "SELECT ai.*, se.host, se.port, se.unit_id " @@ -215,6 +217,10 @@ async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db): ## Zápis do Teltonika EV nabíječek (Modbus) +Aktuální implementace registry zatím nezapisuje. Funkce `write_ev_setpoints` +načte schedulable nabíječky, spočítá proud podle `ev1/ev2_setpoint_w` a jen ho +zaloguje jako `Modbus TODO`. + ```python async def write_ev_charger_setpoints(site_id: int, setpoints: Setpoints, db): chargers = await db.fetch( @@ -250,6 +256,10 @@ async def write_ev_charger_setpoints(site_id: int, setpoints: Setpoints, db): ## Zápis do Samsung TČ (Modbus) +Aktuální implementace registry zatím nezapisuje. Funkce +`write_heat_pump_setpoint` načte schedulable TČ a zaloguje požadované +`heat_pump_enable` jako `Modbus TODO`. + ```python async def write_heat_pump_setpoints(site_id: int, setpoints: Setpoints, db): heat_pumps = await db.fetch( @@ -297,20 +307,22 @@ async def write_loxone_setpoints(site_id: int, setpoints: Setpoints, db): # 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}") + await session.get(f"{base_url}/EMS_Mode/{mode.loxone_mode_value}") + await session.get(f"{base_url}/EMS_Battery_Setpoint_W/{battery_w}") + await session.get(f"{base_url}/EMS_Grid_Setpoint_W/{grid_w}") + await session.get(f"{base_url}/EMS_EV1_Power_W/{ev1_power_w}") + await session.get(f"{base_url}/EMS_EV2_Power_W/{ev2_power_w}") + await session.get(f"{base_url}/EMS_HeatPump_Enable/{heat_pump_enable}") ``` -> Virtual Input jména v Loxone (`EMS_BatterySetpoint` atd.) je nutné vytvořit při konfiguraci Loxone projektu. +> Virtual Input jména v Loxone (`EMS_Battery_Setpoint_W`, `EMS_Grid_Setpoint_W` +> 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 @@ -331,9 +343,8 @@ Fallback: pokud per-site webhook není vyplněný, použije se env `DISCORD_WEBH ## 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 +- [ ] Loxone Virtual Input jména z tohoto dokumentu 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/forecast.md b/docs/04-modules/forecast.md index c771d01..5771361 100644 --- a/docs/04-modules/forecast.md +++ b/docs/04-modules/forecast.md @@ -5,7 +5,9 @@ - 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 +- Predikce se spouští každé 2 hodiny v `:05` a ručně přes API. Plánovač používá + poslední dostupné uložené forecasty; forecast nespouští implicitně před každým + plánovacím během. --- @@ -13,12 +15,15 @@ | 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ě** | +| A | 10 kWp | 184° | 22° | Deye 20kW | řídíme | +| B | 10 kWp | 184° | 35° | Ongridový | autonomní, predikujeme jako samostatné pole | -> **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. +> **Aktuální implementace:** Forecast služba počítá všechna FVE pole lokality, +> která mají vyplněný `azimuth_deg` a `tilt_deg`; plánovač pracuje odděleně s +> `pv_a_forecast_w` i `pv_b_forecast_w`. -> Azimuty a sklony je nutné doplnit při konfiguraci lokality do `asset_pv_array`. +> Azimut je uložen v kompasové / pvlib konvenci: `0=N`, `90=E`, `180=S`, +> `270=W`. --- @@ -36,10 +41,9 @@ 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 + &minutely_15=direct_normal_irradiance,diffuse_radiation,shortwave_radiation,temperature_2m + &timezone=auto + &forecast_days=7 ``` **Záložní / budoucí: Solcast** @@ -51,34 +55,28 @@ GET https://api.open-meteo.com/v1/forecast ## Výpočet výkonu z irradiance -Jednoduchý fyzikální model (dostatečný pro plánování): +Implementace používá `pvlib` a model POA irradiance `haydavies`: ```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) +poa_global = pvlib.irradiance.get_total_irradiance( + surface_tilt=tilt_deg, + surface_azimuth=azimuth_deg, # 0=N, 90=E, 180=S, 270=W + solar_zenith=solar_pos["apparent_zenith"], + solar_azimuth=solar_pos["azimuth"], + dni=dni, + ghi=ghi, + dhi=dhi, + dni_extra=dni_extra, + model="haydavies", +)["poa_global"].fillna(0).clip(lower=0) - # 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)) +area_m2 = nominal_power_wp / (1000.0 * 0.20) +power_w = (poa_global * area_m2 * 0.20 * shading_factor).clip( + lower=0, + upper=nominal_power_wp * 1.1, +) ``` -> **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 @@ -107,15 +105,15 @@ def calculate_pv_power( ## Logika běhu predikce ```python -def run_forecast(site_id: int, horizon_days: int = 2): +def run_forecast(site_id: int): site = db.get_site(site_id) - arrays = db.get_pv_arrays(site_id, controllable=True) + arrays = db.get_pv_arrays_with_azimuth_and_tilt(site_id) 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 + forecast_days=clamp(OPEN_METEO_FORECAST_DAYS, 2, 16) ) # 2. Vytvořit forecast_pv_run @@ -147,8 +145,7 @@ def run_forecast(site_id: int, horizon_days: int = 2): temp_c=slot.temperature_2m )) - db.upsert_forecast_intervals(intervals) - db.update_forecast_run_status(run.id, "ok") + db.insert_forecast_intervals(intervals) ``` --- @@ -204,23 +201,20 @@ Hromadně: **`ems.fn_pv_forecast_sync_reference_days(site_id, p_days_local date[ ```env OPEN_METEO_API_URL=https://api.open-meteo.com/v1/forecast OPEN_METEO_FORECAST_DAYS=7 -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ů +- Zatím není samostatný `/health/forecast` endpoint. +- Stav se kontroluje přes logy běhu `scheduled_forecast_refresh`, přes forecast API + a přes obecné health endpointy. - 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 +- [ ] Ověřit přesný azimut a sklon obou FVE polí proti skutečné instalaci - [ ] Solcast jako alternativa v budoucnu – `forecast_source` to umožňuje bez DB změn diff --git a/docs/04-modules/telemetry.md b/docs/04-modules/telemetry.md index 2b10a28..0b617cd 100644 --- a/docs/04-modules/telemetry.md +++ b/docs/04-modules/telemetry.md @@ -2,7 +2,9 @@ ## 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 +- Čte data ze střídače Deye přes Modbus TCP +- EV nabíječky Teltonika a tepelné čerpadlo Samsung mají zatím placeholder + vzorky; konkrétní registry jsou TODO - 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í @@ -18,15 +20,15 @@ Samostatná Python služba. Běží jako smyčka, nezávislá na FastAPI. | 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 | | +| Teltonika EV nabíječka 1 | 60 s | zatím placeholder `available`, 0 W | +| Teltonika EV nabíječka 2 | 60 s | zatím placeholder `available`, 0 W | +| Samsung tepelné čerpadlo | 60 s | zatím placeholder hodnoty | ### 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 +- Kód zatím nedrží počítadlo po sobě jdoucích chyb podle zařízení; chyby se logují + při jednotlivých poll pokusech - Data se neinterpolují – chybějící minuty zůstanou prázdné (audit to pozná) --- @@ -43,7 +45,7 @@ Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1). | 514 (0x0202) | uint16 | Dnešní nabití baterie | Wh | `batt_charge_today_wh` | | 515 (0x0203) | uint16 | Dnešní vybití baterie | Wh | `batt_discharge_today_wh` | | 588 (0x024C) | uint16 | Battery SoC | % | `battery_soc_percent` | -| 590 (0x024E) | int16 | Tok výkonu baterie | W | signed: **+ vybíjení, − nabíjení** | +| 590 (0x024E) | int16 | Tok výkonu baterie | W | signed z Deye; v DB `battery_power_w` platí **+ nabíjení, − vybíjení** | | 625 (0x0271) | int16 | Výkon sítě | W | signed: **+ import, − export** | | 653 (0x028D) | uint16 | Celková spotřeba | W | `load_power_w` | | 667 (0x029B) | int16 | Výkon GEN portu (FVE pole B) | W (signed) | `gen_port_power_w`; záporné při zpětném toku / bez výroby — **číst signed** | @@ -60,6 +62,13 @@ Komunikace: Modbus TCP, Unit ID dle DIP přepínače na střídači (typicky 1). **Zápis setpointů (plánování → Deye):** +Aktuální řízení Deye je popsané v [`control.md`](control.md) a +[`modbus-registers.md`](modbus-registers.md). Nepoužívá starý `write_register` +model, ale journal `ems.modbus_command` a FC 0x10 (`write_registers`) pro +registry 108/109/141/142/143/145/178/340 + TOU bloky. + +Historická orientační mapa níže neplatí jako implementační kontrakt: + | Registr (hex) | Typ | Popis | Hodnota | |---|---|---|---| | 0x00F3 | Write Single | Battery charge power limit | W | @@ -114,14 +123,15 @@ Komunikace: Modbus TCP přes Waveshare. ## Kód telemetrie (Python) -Implementace: `backend/services/telemetry_collector.py` — `poll_inverter()` používá konstanty `DEYE_REG_*` a třídu `ModbusDevice`; hlavní smyčka je `run_telemetry_loop` / `run_telemetry_loop_wrapper`. +Implementace: `backend/services/telemetry_collector.py` — `poll_inverter()` používá konstanty `DEYE_REG_*` a sdíleného Modbus klienta z `services.modbus_client`; hlavní smyčka je `run_telemetry_loop` / `run_telemetry_loop_wrapper`. --- ## 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). +Prováděna PostgreSQL funkcí `ems.fn_fill_audit_interval()` a navazujícími +funkcemi pro baseline/accuracy. Spouští ji Python APScheduler: audit filler v +minutách `:01,:16,:31,:46`, forecast accuracy v `:02,:17,:32,:47`. ```sql -- Příklad agregace telemetrie na 15min průměr @@ -161,12 +171,13 @@ Nad `ems.telemetry_inverter` běží dva **continuous aggregate** (TimescaleDB); ```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 ``` +`TELEMETRY_POLL_INTERVAL_SEC` a chybové prahy zatím nejsou v kódu používány; +smyčka běží každých 60 s přímo v `run_telemetry_loop_wrapper`. + --- ## Otevřené body diff --git a/docs/05-todo.md b/docs/05-todo.md index be7c138..98ab211 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -45,12 +45,12 @@ Věci bez kterých nelze bezpečně napojit fyzická zařízení, spustit smyslu | 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) | +| Ověřit **GPS** (`latitude`, `longitude`) pro lokalitu `home-01` proti skutečné instalaci – v seedu už jsou vyplněné hodnoty, ale forecast na nich stojí. | `db/migration/V003__seed_site_home01.sql` (`INSERT INTO ems.site`) | majitel (ověření souřadnic) → programátor | | 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 | +| Ověřit **azimut a sklon** FVE polí A a B proti skutečné instalaci. Seed aktuálně obsahuje `pv-a` 184°/22° a `pv-b` 184°/35° v kompasové konvenci `0=N, 90=E, 180=S, 270=W`. | `db/migration/V003__seed_site_home01.sql` (`asset_pv_array` seed); `docs/04-modules/forecast.md` | 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** | +| **Doplnit přesné Modbus registry** pro Teltonika a Samsung a držet je jako TODO v kódu. Deye čtení a hlavní Deye řízení už jsou implementované přes registry popsané v `modbus-registers.md`; stále ověřit fyzické hodnoty pro konkrétní instalaci. | `docs/04-modules/telemetry.md`; `docs/04-modules/heat-pump.md`; `docs/04-modules/control.md`; `docs/loxone-integration.md`; `backend/services/telemetry_collector.py`; `backend/services/control/exporter_monolith.py` | 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 sazbu zeleného bonusu** do `asset_pv_array.green_bonus_czk_kwh` pro `pv-b` (aktuální placeholder: **7.135** Kč/kWh – ověřit ze smlouvy s EG.D). | `db/migration/V017__green_bonus.sql` (seed `pv-b`) | majitel (smlouva) → programátor | | Doplnit **`green_bonus_meter_code`** (EAN zeleného elektroměru) pro `pv-b` v `asset_pv_array`. | `db/migration/V017__green_bonus.sql` / přímá úprava DB | majitel → programátor | diff --git a/docs/06-open-questions.md b/docs/06-open-questions.md index 2a38844..db0fa78 100644 --- a/docs/06-open-questions.md +++ b/docs/06-open-questions.md @@ -10,9 +10,9 @@ Tento soubor slouží jako živý seznam věcí které je potřeba rozhodnout p - [ ] **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`. +- [ ] **Azimut a sklon FVE polí** – Ověřit hodnoty v seedu pro home-01 (`pv-a` 184°/22°, `pv-b` 184°/35°). Nutné pro `forecast_service.py`; konvence je kompasově/pvlib `0=N, 90=E, 180=S, 270=W`. -- [ ] **GPS souřadnice lokality home-01** – Nutné pro Open-Meteo API (lat/lon). +- [ ] **GPS souřadnice lokality home-01** – V seedu jsou vyplněné; ověřit proti skutečné instalaci, protože jsou vstupem pro Open-Meteo API. --- @@ -20,7 +20,7 @@ Tento soubor slouží jako živý seznam věcí které je potřeba rozhodnout p - [ ] **Dvě úrovně min SoC v DB** – Dnes jedno `min_soc_percent` (provozní podlaha pro LP i TOU PASSIVE). Budoucí oddělení „tvrdé BMS minimum“ vs „plánovací minimum“ by vyžadovalo nový sloupec nebo politiku per site. -- [ ] **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. +- [ ] **TUV výkon** – Je TUV výkon měřitelný zvlášť nebo jen ON/OFF? Pokud jen ON/OFF, použijeme `asset_heat_pump.rated_heating_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.