# 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) **Princip:** držet mapování plán → Deye **jednoduché**; detail a zdůvodnění v [`operating-modes.md`](operating-modes.md) (sekce *Keep it simple*). ### Fyzický režim (`get_deye_mode`) | Fyzický režim | Podmínka z `ControlSetpoints` | |---|---| | **SELL** | `grid_setpoint_w` < 0 **a** `battery_w` < 0 **a** **\|battery_w\| ≥ \|grid_setpoint_w\|** — plán počítá s výdejem z baterie do sítě alesvěň tak velkým jako plánovaný čistý export. | | **CHARGE** | `battery_w` > 500 **a** `grid_setpoint_w` > 200 (nabíjení ze sítě) | | **PASSIVE** | vše ostatní | **PASSIVE** (včetně FVE přetoku do sítě): reg. **108/109** škálované podle `battery_w` z plánu, reg. **145** = 1 (solar sell), reg. **142** = `deye_zero_export_mode`. **SELF_SUSTAIN** (záložní režim po Modbus mismatch apod.) zůstává **PASSIVE** z hlediska `get_deye_mode`, ale `write_inverter_setpoints` nastaví **reg. 108 i 109 na maximum z DB** (`self_sustain_local_use=True` v `ControlSetpoints`), **reg. 142** na `asset_inverter.deye_zero_export_mode` (1 = zero export to load, 2 = zero export to CT) a **TOU SOC** na **`min_soc_percent`** (typicky 12 %), aby střídač maximalizoval využití baterie lokálně místo zákazu nabíjení při `battery_w=None`. ### Klíčové registry podle typu slotu | Registr | Charge | Pass-through / PASSIVE | SELL (battery-led) | Self-consumption | |---|---|---|---|---| | **108** (charge A) | škálo dle `battery_w` | škálo dle `battery_w` (>0) / **0** | **0** | **0** | | **109** (discharge A) | **0** | škálo dle `\|battery_w\|` (<0) / **0** | **max z DB** | škálo dle `\|battery_w\|` | | **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 | | **178** (peak shaving) | 48 | 48 | **32** | 48 | Při provozním režimu **SELF_SUSTAIN** je v PASSIVE **108 = max** i **109 = max** (viz odstavec výše), nikoli hodnoty ve sloupci „Pass-through“. Hodnota `deye_zero_export_mode` (1 = zero export to load, 2 = zero export to CT) pochází z `asset_inverter.deye_zero_export_mode` a závisí na fyzické instalaci (přítomnost CT). Detail v [`modbus-registers.md`](modbus-registers.md). **TOU (time points, reg. 166+):** SOC závisí na fyzickém režimu z `get_deye_mode` — **SELL** zapisuje ekonomickou rezervu (`reserve_soc_percent`), **PASSIVE** a neaktivní řádky **3–6** provozní minimum (`min_soc_percent`). Viz [`modbus-registers.md`](modbus-registers.md). ### Verifikace zápisů (journal) a SELF_SUSTAIN Po zápisu na Modbus se hodnoty ověřují v `verify_modbus_commands` (`control_exporter.py`). Po **3 neúspěšných** cyklech zápis+verify: - **Kritické registry** (**108, 109, 142, 143, 145**) → přepnutí lokality do **SELF_SUSTAIN** (`system:mismatch`). - **Ostatní** (včetně **178** a **TOU power W 154–159** po vyčerpání soft pravidel) → zůstane **AUTO** (nebo aktuální režim), řádek journalu **`mismatch`**, Discord upozornění. 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 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?)