306 lines
13 KiB
Markdown
306 lines
13 KiB
Markdown
# 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` v `exporter_monolith.py`)
|
||
|
||
| Fyzický režim | Podmínka z `ControlSetpoints` |
|
||
|---|---|
|
||
| **SELL** | `grid_setpoint_w` < 0 **a** `battery_w` < 0 |
|
||
| **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*).
|
||
|
||
**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`.
|
||
|
||
### Klíčové registry podle typu slotu
|
||
|
||
| Registr | Charge | PASSIVE (ZERO) | SELL | Self-consumption |
|
||
|---|---|---|---|---|
|
||
| **108** (charge A) | škálo dle `battery_w` | max / **0** (FVE přetok) | **0** | dle varianty |
|
||
| **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 |
|
||
| **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**; liší se **TOU SOC** a `battery_w`.
|
||
|
||
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
|
||
```
|
||
|
||
---
|
||
|
||
## Discord notifikace
|
||
|
||
Discord notifikace jsou volitelné a routované per-site:
|
||
|
||
- `ems.site.discord_webhook_daily_url` – denní zprávy (např. ranní ekonomický report)
|
||
- `ems.site.discord_webhook_error_url` – error/critical alerty (mismatch, fatal plan vs actual, clock verify exhausted, …)
|
||
|
||
Fallback: pokud per-site webhook není vyplněný, použije se env `DISCORD_WEBHOOK_URL`.
|
||
|
||
---
|
||
|
||
## 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?)
|