Files
ems/docs/04-modules/control.md
Dusan Vojacek f8e1eed127
All checks were successful
CI and deploy / migration-check (push) Successful in 6s
CI and deploy / deploy (push) Successful in 29s
fix rs485 s eror self_sustain
2026-04-19 15:29:58 +02:00

294 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
### Fyzický režim (`get_deye_mode`)
Solver rozlišuje **čtyři typy slotů**: **Charge**, **Pass-through**, **Discharge-export**, **Self-consumption**. Na úrovni Deye se mapují na tři fyzické režimy:
| Fyzický režim | Podmínka z `ControlSetpoints` |
|---|---|
| **SELL** | `battery_w` < 500 **a** `grid_setpoint_w` < 200 (záměrné vybíjení baterie do sítě) |
| **CHARGE** | `battery_w` > 500 **a** `grid_setpoint_w` > 200 (nabíjení ze sítě) |
| **PASSIVE** | vše ostatní (pass-through, self-consumption, SELF_SUSTAIN) |
**Pass-through** (PV → síť, baterie idle) zůstává **PASSIVE** — fyzicky se realizuje nastavením reg 108 = 0 (zákaz nabíjení) + reg 145 = 1 (solar sell), takže PV přebytky tečou do sítě.
**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 | Discharge-export | Self-consumption |
|---|---|---|---|---|
| **108** (charge A) | max z DB | **0** | 0 | **0** |
| **109** (discharge A) | max | max | max | max |
| **142** (limit control) | `deye_zero_export_mode` | `deye_zero_export_mode` | **0** (selling first) | `deye_zero_export_mode` |
| **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 **36** 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 154159** 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)) # 632A 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?)