Files
ems/docs/04-modules/control.md
Dusan Vojacek fd06811753
All checks were successful
deploy / deploy (push) Successful in 25s
test / smoke-test (push) Successful in 6s
tune microcycling
2026-04-13 00:49:36 +02:00

11 KiB
Raw Blame History

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

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ě.

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

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 závisí na fyzickém režimu z get_deye_modeSELL zapisuje ekonomickou rezervu (reserve_soc_percent), PASSIVE a neaktivní řádky 36 provozní minimum (min_soc_percent). Viz modbus-registers.md.

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)

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)

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_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é)

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