Initial commit

Made-with: Cursor
This commit is contained in:
Dusan Vojacek
2026-03-20 13:27:37 +01:00
commit 8b4af663d8
77 changed files with 13337 additions and 0 deletions

254
docs/04-modules/control.md Normal file
View File

@@ -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)) # 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?)