Force PASSIVE/no-export when sell is negative or export_mode is NONE, and alert NEG_SELL_EXPORT in plan_actual_slot_guard when export still occurs. Co-authored-by: Cursor <cursoragent@cursor.com>
19 KiB
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
- 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
export_mode / export_limit_w (V078+)
Solver ukládá záměr exportu (NONE / PV_SURPLUS / BATTERY_SELL) a cap export_limit_w. U PV_SURPLUS (přetok FVE, ne prodej z baterie):
- reg 142 =
deye_zero_export_modez DB (KV1/BA81 typicky 2 — zero export k CT/zátěži; ne selling first) - reg 108 = 0, reg 109 = max — baterie se přes limit nabíjení neplní, přebytek jde do sítě (145 = 1)
- reg 143 z
export_limit_w/ site cap
Implementace: setpoints._is_passive_pv_surplus_export, deye_battery_charge_discharge_amps. Ověření: log reg142=2, charge_a=0 při export_mode=PV_SURPLUS.
Architektura řízení
DB (planning_interval + site_override)
↓
control_exporter.py (každých 15min nebo on-demand)
├── Modbus write → Deye (baterie, grid limit)
├── 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. Rozhodovací logika je v EMS, ne v Loxone.
Spouštění
| Trigger | Čas | Popis |
|---|---|---|
| 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 |
Ruční přepočet plánu a HTTP 504
POST …/sites/{site_id}/plan/run (backend/app/routers/plan.py) po uložení běhu volá export_setpoints (zápisy Deye / EV / TČ / Loxone). To může trvat dlouho než samotný solver (řád sekund).
Kontejner frontend (nginx před SPA) měl v location /api/ výchozí proxy_read_timeout 60 s → uživatel viděl 504 Gateway Timeout, zatímco backend mohl stačit plán zapsat. V repu jsou na /api/ nastavené delší proxy_*_timeout (180 s) (frontend/nginx.conf). Pokud EMS servíruješ přes vlastní reverzní proxy (např. Caddy na hostu), nastav tam rovněž dostatečné čekání na upstream.
Ověření: logy backendu kolem pokusu nebo select id,status,created_at from ems.planning_run where site_id=? order by created_at desc limit 3 — nový řádek může vzniknout i přes 504 v prohlížeči.
Exekuční pojistky exportu (AUTO)
Po _build_setpoints, před zápisem Modbus (orchestrator.export_setpoints):
| Guard | Podmínka | Efekt |
|---|---|---|
_apply_export_plan_guard |
effective_sell_price < 0 nebo (export_mode = NONE a grid_setpoint_w ≥ 0) |
PASSIVE, export_ban, grid_export_limit = 0, vybíjení baterie do sítě vynulováno (battery_w = max(0, …)), deye_physical_mode = PASSIVE |
_apply_price_failsafe_guard |
is_predicted_price = true |
PASSIVE, všechny výkonové setpointy 0, žádný export |
Implementace: backend/services/control/setpoints.py. Ověření: pytest backend/tests/test_control_export_plan_guard.py.
Poznámka: PV B (nekontrolovatelné pole) může při záporné vykupní stále fyzicky exportovat — pojistka řídí Deye (baterie + řízené FVE A), ne mikroinvertory na GEN bez cut-off.
Logika exportu
# 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
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 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:
"""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 (sekce Keep it simple).
BA81: GEN port cut-off (mikroinvertory na GEN) přes reg 178 (0-based)
U instalací typu BA81 (AC coupling / mikroinvertory na GEN portu) může solver uložit do plánu flag
planning_interval.deye_gen_cutoff_enabled (true/false). Pokud je na střídači zapnutý feature flag
asset_inverter.deye_gen_microinverter_cutoff_enabled = true, exporter provede read-modify-write
registru 178 (v některých manuálech/UI uváděno jako “register 179” – 1-based):
deye_gen_cutoff_enabled = true→ reg 178 bits 0–1 = 3 (11b, enable = cut-off ON / export blokován)deye_gen_cutoff_enabled = false→ reg 178 bits 0–1 = 2 (10b, disable = cut-off OFF / export povolen)
Zápisy se ukládají do ems.modbus_command a ověřují v verify_modbus_commands (porovnává se pouze maska
bits 0–1). Detail registrů: modbus-registers.md (reg 178).
PV A curtailment — zápis reg 340 (max solar power)
- Implementace:
backend/services/control/exporter_monolith.py—export_setpointsnačte cap v_load_inverter_config(ems.fn_inverter_pv_a_max_w(ai.id)),_build_setpointsv režimu AUTO dopočítáControlSetpoints.pv_a_allowed_w,write_inverter_setpointszařadí reg 340, pokud jefn_site_has_active_green_bonus_pvaktivní, cap > 0 apv_a_allowed_wje vyplněné. - Data:
pv_a_forecast_solver_w/pv_a_curtailed_wz aktivníhoplanning_interval; cap =fn_inverter_pv_a_max_w(deye_reg340_max_solar_wnaasset_inverter, home-01 32 kW, ostatní 65 kW); min =deye_reg340_min_solar_w(home-01 400 W). - Policy PV A off (jen na site se zeleným bonusem na PV): pokud
ems.fn_site_has_active_green_bonus_pv(site_id)a v aktuálním slotu zároveňeffective_buy_price < 0aeffective_sell_price < 0apv_b_forecast_solver_w > 0(PV B vyrábí), exporter nastavípv_a_allowed_w = 0(reg 340) i když je forecast PV A nulový — cílem je držet headroom v baterii pro PV B / další záporný nákup. - Bez zápisu reg 340:
plan_skips_deye_reg340_write— žádný export z plánu,battery_setpoint_w ≤ 0,pv_a_curtailed_w = 0→pv_a_allowed_w = None(invertor řídí pole A sám). Ověření:pytest backend/tests/test_control_exporter_reg340.py. - Verify: reg 340 není kritický → po 3× mismatch verify bez přepnutí do SELF_SUSTAIN (stejně jako reg 178); viz
modbus-command-journal.md.
Ověření po nasazení (smoke)
select ems.fn_inverter_pv_a_max_w(<id kontrolovatelného deye-main invertoru>);— při 0 na PV A (např. odpojené pole,nominal_power_wp = 0) EMS reg 340 nezapisuje.- Dočasně zvýšit
nominal_power_wpna controllable PV A → po dalším běhu exportu řádek vems.modbus_commandpro register 340 → po verify jobu stavverified. - Živé čtení:
read_deye_registers_livevracíreg340_max_solar_power_w.
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 z deye_battery_charge_discharge_amps() v setpoints.py (volá write_inverter_setpoints) |
PASSIVE (AUTO, ZERO): export_mode = PV_SURPLUS → 108 = 0, 109 = max, 142 = deye_zero_export_mode (selling first jen u SELL z baterie). export_mode = NONE a battery_w > 0 (nabíjení z FVE, záporná vykupní) → 108 = max. Reg. 145: 0 při export_ban, jinak 1. Reg. 143 = tvrdý cap z plánu/lokality.
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 (export bez nabíjení) / max při PASSIVE + battery_w>0 (FVE do baterie až po strop) |
nezapisuje EMS | dle varianty |
| 109 (discharge A) | 0 | max / 0 (import, držet bat.) / max při PASSIVE + battery_w>0 |
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 | max z DB (tvrdý limit, bez forecast heuristiky) | max z DB |
| 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.
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.
TOU (time points, reg. 166+): SOC podle get_deye_mode — CHARGE: max_soc_percent z DB (clamp 10–100), SELL: reserve_soc_percent, PASSIVE + neaktivní řádky 3–6: min_soc_percent. Viz 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, 340 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.
# 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 "
"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 = setpoints.grid_export_limit
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)
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.
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)
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.
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
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_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_Battery_Setpoint_W,EMS_Grid_Setpoint_Watd.) je nutné vytvořit při konfiguraci Loxone projektu.
Konfigurace (env proměnné)
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 Teltonika (current limit, enable)
- Doplnit Modbus write registry Samsung TČ (enable, target temp)
- 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?)