# 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_mode` z 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, `grid_export_limit = 0`, vybíjení baterie do sítě vynulováno (`battery_w = max(0, …)`). **Tvrdý ban** (`export_ban` → reg 145 = 0 + MI cut-off reg 178) **jen při záporné vykupní** — `export_mode = NONE` s kladnou cenou pole B neodstavuje (mikroinvertory se absorbují do baterie/zátěže, případný přetok se prodá; BA81 2026-06-12 cutoff při sell +1.36 zahazoval výrobu) |
| **`_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

```python
# 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`](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)

**Exekuční pravidlo (2026-06-06):** pokud plán zakazuje vývoz (`export_ban`, typicky záporná vykupní + `grid_setpoint_w ≥ 0`), exporter zapne cut-off **i když** solver uložil `deye_gen_cutoff_enabled = false` — v LP může být PV B modelované jen do domu, ale mikroinvertory na GEN portu bez cut-off fyzicky exportují do sítě. Implementace: `deye_mi_export_cutoff_want_enabled()` v `deye_helpers.py`, volá `write_inverter_setpoints` v `inverter.py`; `_passive_no_export_guard` nastaví flag v `ControlSetpoints`.

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`](modbus-registers.md) (reg 178).

### PV A curtailment — zápis reg 340 (max solar power)

- **Implementace:** `backend/services/control/exporter_monolith.py` — `export_setpoints` načte cap v `_load_inverter_config` (`ems.fn_inverter_pv_a_max_w(ai.id)`), `_build_setpoints` v režimu **AUTO** dopočítá `ControlSetpoints.pv_a_allowed_w`, `write_inverter_setpoints` zařadí **reg 340**, pokud je `fn_site_has_active_green_bonus_pv` aktivní, cap > 0 a `pv_a_allowed_w` je vyplněné.
- **Data:** `pv_a_forecast_solver_w` / `pv_a_curtailed_w` z aktivního `planning_interval`; cap = `fn_inverter_pv_a_max_w` (`deye_reg340_max_solar_w` na `asset_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 < 0` a `effective_sell_price < 0` a `pv_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`](modbus-command-journal.md).

#### Ověření po nasazení (smoke)

1. `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**.
2. Dočasně zvýšit `nominal_power_wp` na controllable PV A → po dalším běhu exportu řádek v `ems.modbus_command` pro register **340** → po verify jobu stav **`verified`**.
3. Živé čtení: `read_deye_registers_live` vrací **`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`](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`](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`](modbus-command-journal.md).

```python
# 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`.

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

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

```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_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_W`
> atd.) je nutné vytvořit při konfiguraci Loxone projektu.

---

## Konfigurace (env proměnné)

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