diff --git a/CLAUDE.md b/CLAUDE.md index b6304c4..2215486 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st - Pět hodnot `mode_code` v `ems.site_operating_mode`: **AUTO**, **SELF_SUSTAIN**, **CHARGE_CHEAP**, **PRESERVE**, **MANUAL**. - Režim se načítá v `planning_engine._load_site_context()`; **dodatečné LP constraints** podle režimu jsou v **`solve_dispatch()`** (žádný export / limit importu / zákaz nabíjení nebo vybíjení baterie podle módu). -- **Fyzické režimy Deye** (PASSIVE / SELL / CHARGE) se odvozují v `control_exporter.get_deye_mode` a zapisují v `write_inverter_setpoints`. +- **Fyzické režimy Deye** (PASSIVE / SELL / CHARGE) se odvozují v `exporter_monolith.get_deye_mode` a zapisují v `write_inverter_setpoints`. - **`lock_battery=True`** u `ControlSetpoints` (PRESERVE): registry **108/109 = 0** – Deye baterii nepoužívá. Výjimka oproti obecnému pravidlu max A ve PASSIVE/SELL. 12. **`forecast_pv_run` a `forecast_pv_interval` se NESMÍ mazat** – historické běhy zůstávají v DB pro tracking přesnosti (`forecast_accuracy`, `fn_fill_forecast_accuracy`). @@ -106,7 +106,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st 17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord` → `fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **62–64** (čas) → po 3 neúspěšných ověřeních **bez** změny režimu, kritický **Discord** (`notify_modbus_clock_verify_exhausted`). **Obecně:** při jakékoli změně `mode_code` z Pythonu (`POST /api/v1/sites/{id}/mode`, mismatch → SELF_SUSTAIN, `fn_expire_modes`) lze Discord zapnout přes `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`. -18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Režimy:** `get_deye_mode` → **SELL** jen při **\|battery_w\| ≥ \|grid_setpoint_w\|** a obou záporných (záměr výdeje baterie do sítě); **CHARGE** při `battery_w` > 500 a `grid_setpoint_w` > 200; jinak **PASSIVE**. **PASSIVE (AUTO):** reg. **108** i **109** na **max. proud z DB** (plný rozsah baterie); jemnější výkon drží **TOU** z plánu. **SELL:** 108=0, 109=max, **143** omezeno podle `|grid_setpoint_w|`; **142/178/145/TOU** jako v `write_inverter_setpoints`. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 62–64**, bloky TOU **1–2** vs **3–6**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**. +18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **108/109** dle `_deye_zero_export_amps_for_passive`; **TOU** z plánu. **SELL:** 108=0, 109=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 62–64**, bloky TOU **1–2** vs **3–6**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**. 19. **Baterie – export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 11–12 %, migrace V029 + komentář sloupce). diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 305573d..7291762 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -32,7 +32,7 @@ DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24 # Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md) BATT_VOLTAGE_V = 51.2 -# Reg 143 ve SELL: strop exportu = min(site max, |grid_setpoint|), dolní podlahová konstanta kvůli nule. +# Reg 143 ve SELL: min(|grid_setpoint_w|, …) nesmí klesnout pod tuto podlahu (W) — kvůli chování firmware, ne mapování režimu. REG143_SELL_CAP_MIN_W = 200 # Reg 178 – pevné hodnoty (bit4–5); bez read-modify-write (kolize s Loxone / transaction ID) @@ -340,6 +340,8 @@ class ControlSetpoints: ev1_power_w: int ev2_power_w: int target_soc_pct: int | None = None + #: Explicitní fyzický režim z plánu (PASSIVE/SELL/CHARGE). Pokud je vyplněn, má přednost před detekcí ze znamének. + deye_physical_mode: str | None = None #: Efektivní vykupní cena slotu (Kč/kWh z plánu); pro TOU řízení priorit baterie vs. přetok effective_sell_price_czk_kwh: float | None = None #: True = reg 108/109 na 0 (PRESERVE – Deye baterii nepoužívá) @@ -1220,6 +1222,8 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont hp_en = bool(pi["heat_pump_enabled"]) tgt = pi["battery_soc_target_pct"] target_soc = int(round(float(tgt))) if tgt is not None else None + pm_raw = pi.get("deye_physical_mode") + pm: str | None = str(pm_raw).strip().upper() if pm_raw is not None else None sell_raw = pi.get("effective_sell_price") sell_f: float | None = float(sell_raw) if sell_raw is not None else None return ControlSetpoints( @@ -1232,6 +1236,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont ev1_power_w=ev1_w, ev2_power_w=ev2_w, target_soc_pct=target_soc, + deye_physical_mode=pm, effective_sell_price_czk_kwh=sell_f, ) @@ -1373,31 +1378,52 @@ def _deye_passive_tou_battery_soc_pct( return _clamp_deye_tou_soc_pct_hi(DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT, hi=100) +def _deye_zero_export_amps_for_passive( + grid_w: int, + bat_w: int, + max_charge_a: int, + max_discharge_a: int, +) -> tuple[int, int]: + """ + PASSIVE (zero export k CT/zátěži, reg. 142 dle DB): výchozí plné 108/109. + + - Export v plánu (grid_w < 0) a žádné plánované vybíjení (bat_w >= 0): **108 = 0** — nepřebírat + přebytek FVE do baterie, ať může jít přetok do sítě. + - Import v plánu (grid_w > 0) a žádné plánované nabíjení (bat_w <= 0): **109 = 0** — nevybíjet + baterii, odběr ze sítě. + """ + if grid_w < 0 and bat_w >= 0: + return 0, max_discharge_a + if grid_w > 0 and bat_w <= 0: + return max_charge_a, 0 + return max_charge_a, max_discharge_a + + def get_deye_mode(setpoints: ControlSetpoints) -> str: """ Fyzický režim Deye: SELL | CHARGE | PASSIVE. - Pravidlo držet jednoduché (viz ``docs/04-modules/operating-modes.md``): + Primárně explicitně z plánu (`setpoints.deye_physical_mode`), fallback jen ze znamének (viz + ``docs/04-modules/operating-modes.md``): - - **SELL** — jen když plán explicitně počítá s **vybíjením baterie do sítě** ve smyslu: - záporný export z portu sítě a zároveň **|battery_w| ≥ |grid_setpoint_w|** (výdej z - baterie není menší než plánovaný čistý export). Pak Deye „selling first“ (reg. 142=0). + - **CHARGE** — ``battery_w`` > 0 **a** ``grid_setpoint_w`` > 0 (nabíjení ze sítě + import v plánu). + - **SELL** — ``grid_setpoint_w`` < 0 **a** ``battery_w`` < 0 (export + vybíjení baterie v plánu). + - **PASSIVE** (ZERO) — vše ostatní; reg. **108/109** dle ``_deye_zero_export_amps_for_passive``. - - **PASSIVE** — všude jinde: reg. **108** / **109** na **max. proud** invertoru; přetok FVE / chování - vůči zátěži drží **142** dle ``deye_zero_export_mode``, **TOU výkon** z plánu, **145** solar sell. - - - **CHARGE** — nabíjení ze sítě (``battery_w`` > 500 a ``grid_setpoint_w`` > 200). - - ``battery_w=None`` (SELF_SUSTAIN) → bat_w 0 → PASSIVE zde; **108/109 max** stejně jako u běžného - PASSIVE v ``write_inverter_setpoints`` (viz ``self_sustain_local_use`` pro TOU SOC). + ``battery_w=None`` (SELF_SUSTAIN) → bat_w 0 → typicky PASSIVE; v ``write_inverter_setpoints`` má + SELF_SUSTAIN vlastní větev (108/109 max). """ + pm = (setpoints.deye_physical_mode or "").strip().upper() + if pm in {"PASSIVE", "SELL", "CHARGE"}: + return pm + grid_w = int(setpoints.grid_setpoint_w or 0) bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w) - if bat_w > 500 and grid_w > 200: + if bat_w > 0 and grid_w > 0: return "CHARGE" - if grid_w < 0 and bat_w < 0 and abs(bat_w) >= abs(grid_w): + if grid_w < 0 and bat_w < 0: return "SELL" return "PASSIVE" @@ -1475,18 +1501,19 @@ async def write_inverter_setpoints( charge_a = int(inv.max_charge_a) discharge_a = int(inv.max_discharge_a) else: - # PASSIVE (AUTO): plný strop 108/109 — stejná idea jako SELF_SUSTAIN. - # Dříve škálování podle |battery_w| z LP usekávalo fyzický výkon baterie (např. 23 A při - # ~1,2 kW plánu) a velká akumulace pak neuměla rychle doplnit síť při nárazové zátěži. - # Ekonomiku a směr toku drží TOU časové body (výkon W / SOC %) + režim 142/178, ne reg. 108/109. - charge_a = int(inv.max_charge_a) - discharge_a = int(inv.max_discharge_a) + # PASSIVE (ZERO): výchozí plné 108/109; u přetoku FVE do sítě nebo importu bez baterie viz helper. + charge_a, discharge_a = _deye_zero_export_amps_for_passive( + grid_w, + bat_w, + int(inv.max_charge_a), + int(inv.max_discharge_a), + ) zero_exp_mode = int(inv.deye_zero_export_mode or 1) selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode solar_sell = 1 export_limit = export_lim - if deye_mode == "SELL" and grid_w < -200: + if deye_mode == "SELL" and grid_w < 0: export_limit = min(export_lim, max(REG143_SELL_CAP_MIN_W, abs(grid_w))) reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE @@ -1875,15 +1902,15 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None: if mode.mode_code == "CHARGE_CHEAP": max_ch = await _fetch_max_charge_power_w(site_id, db) - # Kladný grid_setpoint_w > 200 → fyzický CHARGE (nabíjení ze sítě), viz get_deye_mode - grid_for_charge = max(300, max_ch) + # Oba setpointy kladné → get_deye_mode CHARGE; min. 1 W, aby režim nebyl PASSIVE při nulové DB. + pw = max(1, int(max_ch)) sp_now = ControlSetpoints( - battery_w=max_ch, + battery_w=pw, grid_export_limit=0, ev1_current_a=0, ev2_current_a=0, heat_pump_enable=False, - grid_setpoint_w=grid_for_charge, + grid_setpoint_w=pw, ev1_power_w=0, ev2_power_w=0, target_soc_pct=None, diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 0bb1db3..1aadcb2 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -184,6 +184,9 @@ class DispatchResult: battery_setpoint_w: int # kladné = nabíjení, záporné = vybíjení battery_soc_target: float # % SoC na konci intervalu grid_setpoint_w: int # kladné = import, záporné = export + #: Explicitní fyzický režim Deye pro control exporter (PASSIVE / SELL / CHARGE). + #: Cíl: odstranit heuristiky z exporteru a nést záměr přímo v plánu. + deye_physical_mode: str ev1_setpoint_w: Optional[int] ev2_setpoint_w: Optional[int] ev1_via_bat_w: int @@ -541,6 +544,14 @@ def solve_dispatch( grid_w = round(pulp.value(gi[t]) - pulp.value(ge[t])) soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1) + # Primární klasifikace fyzického režimu pro Deye: explicitně do plánu (Variant A). + # Default PASSIVE; SELL při export+vybíjení; CHARGE při import+nabíjení. + deye_mode = "PASSIVE" + if batt_w < 0 and grid_w < 0: + deye_mode = "SELL" + elif batt_w > 0 and grid_w > 0: + deye_mode = "CHARGE" + cost = ( pulp.value(gi[t]) * slots[t].buy_price * INTERVAL_H / 1000 - pulp.value(ge[t]) * slots[t].sell_price * INTERVAL_H / 1000 @@ -551,6 +562,7 @@ def solve_dispatch( battery_setpoint_w = batt_w, battery_soc_target = soc_pct, grid_setpoint_w = grid_w, + deye_physical_mode = deye_mode, ev1_setpoint_w = round(pulp.value(ev_direct[0][t]) + pulp.value(ev_via_bat[0][t])) if slots[t].ev1_connected else None, ev2_setpoint_w = round(pulp.value(ev_direct[1][t]) + pulp.value(ev_via_bat[1][t])) @@ -982,6 +994,7 @@ async def _save_planning_run( "battery_setpoint_w": r.battery_setpoint_w, "battery_soc_target_pct": r.battery_soc_target, "grid_setpoint_w": r.grid_setpoint_w, + "deye_physical_mode": r.deye_physical_mode, "ev1_setpoint_w": r.ev1_setpoint_w, "ev2_setpoint_w": r.ev2_setpoint_w, "ev1_via_bat_w": r.ev1_via_bat_w, diff --git a/backend/tests/test_control_exporter_tou.py b/backend/tests/test_control_exporter_tou.py index e848362..5e2228e 100644 --- a/backend/tests/test_control_exporter_tou.py +++ b/backend/tests/test_control_exporter_tou.py @@ -11,6 +11,7 @@ from services.control.exporter_monolith import ( _deye_reg178_verify_with_double_read, _deye_tou_params, _deye_tou_power_verify_match, + _deye_zero_export_amps_for_passive, deye_reg_triggers_self_sustain_after_verify_exhaust, get_deye_mode, ) @@ -62,7 +63,7 @@ class ModbusVerifyPolicyTests(unittest.TestCase): class DeyeTouParamsTests(unittest.TestCase): def test_sell_uses_reserve_soc(self) -> None: - """SELL: plánovaný výdej baterie alesvěň tak velký jako plánovaný export (|bat| ≥ |grid|).""" + """SELL: záporný grid_setpoint_w i battery_w → selling first; TOU SOC = reserve.""" sp = ControlSetpoints( battery_w=-8000, grid_export_limit=8000, @@ -79,8 +80,23 @@ class DeyeTouParamsTests(unittest.TestCase): self.assertFalse(g) self.assertEqual(s, 20) - def test_pv_led_export_with_small_battery_is_passive(self) -> None: - """Regrese site 25A 17:30: |bat| < |grid| → PASSIVE (FVE přetok, ne „vylít baterku“).""" + def test_explicit_deye_physical_mode_from_plan_overrides_detection(self) -> None: + sp = ControlSetpoints( + battery_w=-8000, + grid_export_limit=8000, + ev1_current_a=0, + ev2_current_a=0, + heat_pump_enable=False, + grid_setpoint_w=-8000, + ev1_power_w=0, + ev2_power_w=0, + target_soc_pct=50, + deye_physical_mode="PASSIVE", + ) + self.assertEqual(get_deye_mode(sp), "PASSIVE") + + def test_pv_led_export_with_small_battery_is_sell(self) -> None: + """Obě záporné → SELL (bez porovnání |bat| vs |grid|).""" sp = ControlSetpoints( battery_w=-733, grid_export_limit=1294, @@ -92,10 +108,10 @@ class DeyeTouParamsTests(unittest.TestCase): ev2_power_w=0, target_soc_pct=50, ) - self.assertEqual(get_deye_mode(sp), "PASSIVE") + self.assertEqual(get_deye_mode(sp), "SELL") - def test_large_export_small_battery_is_passive(self) -> None: - """Export v plánu větší než výdej z baterie → PASSIVE.""" + def test_large_export_small_battery_is_sell(self) -> None: + """I když |bat| < |grid| — stále SELL při obou záporných setpointech.""" sp = ControlSetpoints( battery_w=-1500, grid_export_limit=8000, @@ -107,7 +123,7 @@ class DeyeTouParamsTests(unittest.TestCase): ev2_power_w=0, target_soc_pct=50, ) - self.assertEqual(get_deye_mode(sp), "PASSIVE") + self.assertEqual(get_deye_mode(sp), "SELL") def test_passive_uses_min_soc(self) -> None: sp = ControlSetpoints( @@ -181,6 +197,36 @@ class DeyeTouParamsTests(unittest.TestCase): self.assertTrue(g) self.assertEqual(s, 95) + def test_charge_any_positive_pair_without_w_threshold(self) -> None: + sp = ControlSetpoints( + battery_w=50, + grid_export_limit=0, + ev1_current_a=0, + ev2_current_a=0, + heat_pump_enable=False, + grid_setpoint_w=80, + ev1_power_w=0, + ev2_power_w=0, + target_soc_pct=50, + ) + self.assertEqual(get_deye_mode(sp), "CHARGE") + + def test_zero_export_amps_fve_overflow(self) -> None: + c, d = _deye_zero_export_amps_for_passive(-1000, 0, 100, 90) + self.assertEqual(c, 0) + self.assertEqual(d, 90) + + def test_zero_export_amps_import_hold_discharge(self) -> None: + c, d = _deye_zero_export_amps_for_passive(500, 0, 100, 90) + self.assertEqual(c, 100) + self.assertEqual(d, 0) + + def test_zero_export_amps_full_when_discharge_with_export(self) -> None: + """Export + plánované vybíjení → plné proudy (SELL řeší režim 142 zvlášť).""" + c, d = _deye_zero_export_amps_for_passive(-2000, -500, 100, 90) + self.assertEqual(c, 100) + self.assertEqual(d, 90) + def test_self_sustain_tou_stays_min_soc_even_if_sell_negative(self) -> None: """SELF_SUSTAIN: nízké TOU (min_soc), ne 100 % z negativní vykupní — LP se nepoužívá.""" sp = ControlSetpoints( diff --git a/db/migration/V053__planning_interval_deye_physical_mode.sql b/db/migration/V053__planning_interval_deye_physical_mode.sql new file mode 100644 index 0000000..bea4197 --- /dev/null +++ b/db/migration/V053__planning_interval_deye_physical_mode.sql @@ -0,0 +1,10 @@ +-- Explicitní fyzický režim Deye přímo v plánu (Variant A): +-- PASSIVE / SELL / CHARGE. Exporter pak nemusí heuristicky mapovat z wattů. + +ALTER TABLE ems.planning_interval + ADD COLUMN IF NOT EXISTS deye_physical_mode TEXT; + +COMMENT ON COLUMN ems.planning_interval.deye_physical_mode IS +'Explicitní fyzický režim Deye pro tento slot (PASSIVE / SELL / CHARGE). +Zdroj: planning_engine.solve_dispatch() (záměr slotu), použití: control exporter (get_deye_mode).'; + diff --git a/db/routines/R__037_fn_planning_run_commit.sql b/db/routines/R__037_fn_planning_run_commit.sql index 4c96005..606ce55 100644 --- a/db/routines/R__037_fn_planning_run_commit.sql +++ b/db/routines/R__037_fn_planning_run_commit.sql @@ -50,6 +50,7 @@ begin run_id, interval_start, battery_setpoint_w, battery_soc_target_pct, grid_setpoint_w, + deye_physical_mode, ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w, heat_pump_enabled, heat_pump_setpoint_w, pv_a_curtailed_w, expected_cost_czk, @@ -64,6 +65,7 @@ begin (r.value->>'battery_setpoint_w')::int, (r.value->>'battery_soc_target_pct')::numeric, (r.value->>'grid_setpoint_w')::int, + nullif(trim(r.value->>'deye_physical_mode'), ''), nullif(r.value->>'ev1_setpoint_w', '')::int, nullif(r.value->>'ev2_setpoint_w', '')::int, coalesce((r.value->>'ev1_via_bat_w')::int, 0), @@ -86,6 +88,7 @@ begin run_id, interval_start, battery_setpoint_w, battery_soc_target_pct, grid_setpoint_w, + deye_physical_mode, ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w, heat_pump_enabled, heat_pump_setpoint_w, pv_a_curtailed_w, expected_cost_czk, @@ -97,6 +100,7 @@ begin (r.value->>'battery_setpoint_w')::int, (r.value->>'battery_soc_target_pct')::numeric, (r.value->>'grid_setpoint_w')::int, + nullif(trim(r.value->>'deye_physical_mode'), ''), nullif(r.value->>'ev1_setpoint_w', '')::int, nullif(r.value->>'ev2_setpoint_w', '')::int, coalesce((r.value->>'ev1_via_bat_w')::int, 0), diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index 9574e1a..ed1b3b7 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -111,30 +111,30 @@ def apply_overrides(plan, overrides) -> Setpoints: **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*). -### Fyzický režim (`get_deye_mode`) +### 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 **a** **\|battery_w\| ≥ \|grid_setpoint_w\|** — plán počítá s výdejem z baterie do sítě alesvěň tak velkým jako plánovaný čistý export. | -| **CHARGE** | `battery_w` > 500 **a** `grid_setpoint_w` > 200 (nabíjení ze sítě) | -| **PASSIVE** | vše ostatní | +| **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** dle `_deye_zero_export_amps_for_passive` (viz `operating-modes.md`) | -**PASSIVE** (AUTO, včetně FVE přetoku do sítě): reg. **108** i **109** na **maximum z DB** (plný proudový rozsah baterie); jemnější výkon drží **TOU časové body** z plánu. Reg. **145** = 1 (solar sell), reg. **142** = `deye_zero_export_mode`. +**PASSIVE** (AUTO, ZERO): výchozí **108** i **109** = maximum z DB; u exportu bez vybíjení **108 = 0**, u importu bez nabíjení **109 = 0** (`_deye_zero_export_amps_for_passive`). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **145** (solar sell): v kódu vždy **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*). -**SELF_SUSTAIN** (záložní režim po Modbus mismatch apod.) zůstává **PASSIVE** z hlediska `get_deye_mode`; **108/109** jsou stejně **max z DB** jako u AUTO PASSIVE. Rozdíl je **`self_sustain_local_use=True`**: **TOU SOC** se drží na **`min_soc_percent`** (typicky 12 %) a `battery_w=None`, aby střídač prioritizoval lokální buffer při zero-export, ne ekonomiku LP. +**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 | Pass-through / PASSIVE | SELL (battery-led) | Self-consumption | +| Registr | Charge | PASSIVE (ZERO) | SELL | Self-consumption | |---|---|---|---|---| -| **108** (charge A) | škálo dle `battery_w` | **max z DB** | **0** | **max z DB** | -| **109** (discharge A) | **0** | **max z DB** | **max z DB** | **max z DB** | +| **108** (charge A) | škálo dle `battery_w` | max / **0** (FVE přetok) | **0** | dle varianty | +| **109** (discharge A) | **0** | max / **0** (import, držet bat.) | **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 | `min(max_site, max(200, \|grid_setpoint_w\|))` | max z DB | | **145** (solar sell) | 1 | 1 | 1 | 1 | | **178** (peak shaving) | 48 | 48 | **32** | 48 | -Sloupce **Pass-through / PASSIVE** (AUTO) a **Self-consumption** (typicky SELF_SUSTAIN / záloha) mají u **108/109** stejně **max z DB**; liší se hlavně **TOU SOC** a `battery_w` (viz výše). +U **AUTO PASSIVE** závisí **108/109** na znaménkách plánu (viz `operating-modes.md`). **SELF_SUSTAIN** drží oba **max z DB**; liší se **TOU SOC** a `battery_w`. 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). diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index e3aa602..482ccd4 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -12,19 +12,19 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi | Reg | Název | Rozsah | Jednotka | Použití v EMS | |-----|-------|--------|----------|---------------| -| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Strop **A** z DB (`COALESCE(deye_register_max_charge_a, odvod z kW)` + clamp **350 A**). Ve **PASSIVE** (AUTO) `write_inverter_setpoints` zapisuje **vždy plný strop** — škálování podle malého `battery_w` z LP se **nepoužívá** (TOU výkon drží jemnější signál). Režim **CHARGE** stále odvádí proud z plánovaného výkonu přes `battery_watts_to_amps`. | -| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Obdobně jako 108; ve **PASSIVE** plný strop, **SELL** plný vybíjecí proud, **CHARGE** typicky 0. | +| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Strop **A** z DB (`COALESCE(deye_register_max_charge_a, odvod z kW)` + clamp **350 A**). Ve **PASSIVE** (AUTO) podle `_deye_zero_export_amps_for_passive`: výchozí **max**, u exportu v plánu bez vybíjení **0**. **CHARGE:** proud z `battery_w` přes `battery_watts_to_amps`. **SELL:** **0**. | +| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Ve **PASSIVE** (AUTO): výchozí **max**, u importu bez nabíjení **0**; **SELL** max vybíjení; **CHARGE** typicky **0**. | | 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě. Firmware automaticky sníží reálný proud tak, aby `load + battery_charge` nepřekročil velikost jističe — proto LP v `planning_engine.py` plánuje `gi[t]` až **do `max_import_power_w + BMS_max_charge`**, aby uměl využít cenově nejlepší 15min okna pro nabíjení na plný BMS strop (viz `planning.md` sekce „Plánovací strop gi[t] vs. fyzický jistič"). Fyzické dodržení jističe drží reg 128 + firmware. | | 130 | Grid charge enable | 0/1 | — | 1 = povolit nabíjení ze sítě | | 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) | | 142 | Limit control (System work mode) | 0/1/2 | — | **0** = selling first, **1** = zero export to load, **2** = zero export to CT. Hodnota v non-SELL režimech pochází z `asset_inverter.deye_zero_export_mode` (závisí na instalaci – viz tabulka níže). V režimu SELL vždy **0**. | -| 145 | Solar sell | 0/1 | — | **0** = disabled (omezí FVE aby neexportoval), **1** = enabled. EMS vždy zapisuje **1**. Při reg 108 = 0 (baterie se nenabíjí) a solar sell = 1 přebytky FVE tečou do sítě. | +| 145 | Solar sell | 0/1 | — | **0** = disabled (přebytek FVE na **straně měniče** se nesmí vést do sítě — curtailment vůči síti), **1** = enabled. Platí jen pro **FVE pod kontrolou Deye** (`controllable = true`); druhá pole (např. **pv-b** u home-01) EMS tímto registerem neřídí. EMS dnes **vždy zapisuje 1**; při 108 = 0 a 145 = 1 přebytky z řiditelného stringu typicky tečou do sítě (viz pass-through níže). | | 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w` | | 178 | Grid peak shaving switch | bitmask | — | EMS zapisuje **pevnou** hodnotu (bez read-modify-write kvůli kolizím s paralelním čtením z Loxone): **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE** a **CHARGE**. | | 190 | GEN peak shaving | 0–16000 | 1 W | Peak shaving na GEN portu | | 191 | Grid peak shaving power | 0–16000 | 1 W | **EMS NEZAPISUJE** – nastavit **manuálně v SolarmanApp**. Hodnota určuje výkon peak shavingu v **W**. | -`control_exporter.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal; jeden řádek na registr) a **`execute_modbus_commands`** odesílá **souvislé bloky jedním FC 0x10** (např. 62–64, 148–159, 166–177, 108–109, 141–143, 145 podle toho, co je ve frontě). Pořadí v journalu: **62–64** (čas, viz níže), **time points 148–177** (jen řádky zařazené do daného běhu), **108, 109, 141, 142, 143, 145, 178**. Popisné názvy v DB bere `DEYE_REGISTER_NAMES`. **Reg 191** EMS nezapisuje. +`exporter_monolith.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal; jeden řádek na registr) a **`execute_modbus_commands`** odesílá **souvislé bloky jedním FC 0x10** (např. 62–64, 148–159, 166–177, 108–109, 141–143, 145 podle toho, co je ve frontě). Pořadí v journalu: **62–64** (čas, viz níže), **time points 148–177** (jen řádky zařazené do daného běhu), **108, 109, 141, 142, 143, 145, 178**. Popisné názvy v DB bere `DEYE_REGISTER_NAMES`. **Reg 191** EMS nezapisuje. ### Reg 191 (výkon grid peak shaving) @@ -44,19 +44,19 @@ EMS **nezapisuje** read-modify-write (paralelní čtení jinými klienty může Provozní režimy EMS (AUTO, SELF_SUSTAIN, SELL, …) se mapují na **tři fyzické režimy** střídače: **PASSIVE**, **SELL**, **CHARGE**. Solver navíc rozlišuje **čtyři typy slotů** – každý typ určuje specifickou kombinaci registrů. -### Detekce fyzického režimu (`get_deye_mode` v `control_exporter.py`) +### Detekce fyzického režimu (`get_deye_mode` v `exporter_monolith.py`) -Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivní plán / politika EMS), ne z telemetrie. +Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivní plán / politika EMS), ne z telemetrie. **Bez wattových prahů** — jen znaménka. | Režim | Podmínka | |-------|----------| -| **SELL** | `grid_setpoint_w` < 0 **a** `battery_w` < 0 **a** **\|battery_w\| ≥ \|grid_setpoint_w\|** (výdej z baterie alesvěň tak velký jako plánovaný export) | -| **CHARGE** | `battery_w` > 500 **a** `grid_setpoint_w` > 200 | +| **SELL** | `grid_setpoint_w` < 0 **a** `battery_w` < 0 | +| **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 | | **PASSIVE** | vše ostatní (včetně pass-through, self-consumption, SELF_SUSTAIN, IDLE, …) | -Režim **CHARGE_CHEAP** v EMS nastaví `grid_setpoint_w` tak, aby platila podmínka importu (> 200 W), jinak by fyzicky zůstal PASSIVE. +Režim **CHARGE_CHEAP** nastaví oba setpointy na stejný kladný výkon (min. 1 W), aby byl výsledek **CHARGE**. -**Důležité:** **SELL** jen pro záměr **vylít baterku do sítě** (viz `operating-modes.md`, *Keep it simple*). FVE přetok / malý doplněk z baterie vůči většímu exportu zůstává **PASSIVE** (reg. **108/109** škálované podle plánu). +**PASSIVE (ZERO):** reg. **108/109** podle `_deye_zero_export_amps_for_passive` — při exportu v plánu bez vybíjení je **108 = 0** (přetok FVE); při importu bez nabíjení je **109 = 0** (držet baterii). Jinak oba max (AUTO). Detail: `operating-modes.md`. ### Provozní režim EMS SELF_SUSTAIN @@ -70,12 +70,12 @@ Z hlediska `get_deye_mode` je **SELF_SUSTAIN** stále **PASSIVE** (`battery_w` z Solver předvybírá sloty pro nabíjení a export-vybíjení (`_select_charge_slots`, `_select_discharge_export_slots`). Nabíjení: vždy povoleno v slotech s PV-surplus; zbytek rozpočtu (`charge_slot_buffer × (soc_max − current_soc) − PV přínos`) doplněn nejlevnějšími sloty podle **`buy_price`** (nákupní cena ze sítě). Export-vybíjení: top-N slotů podle nejvyšší **`sell_price`**. Výsledné setpointy pak určují typ slotu: -| | **Charge** | **Pass-through** | **Battery→grid (SELL)** | **Self-consumption** | +| | **Charge** | **Pass-through / FVE přetok** | **Battery→grid (SELL)** | **Self-consumption** | |---|---|---|---|---| -| **Kdy** | Solver: `bat_w > 0` | Solver: typicky export z FVE; `\|bat\| < \|grid\|` při exportu | `grid_w < 0`, `bat_w < 0`, `\|bat\| ≥ \|grid\|` | Noc / PV < spotřeba | -| **Deye mode** | PASSIVE | PASSIVE | SELL | PASSIVE | -| **108** charge A | škálo dle `bat_w` | škálo / **0** | **0** | **0** | -| **109** discharge A | **0** | škálo dle `\|bat_w\|` | **max** | škálo dle `\|bat_w\|` | +| **Kdy** | `bat_w > 0`, `grid_w > 0` | typicky `grid_w < 0`, `bat_w ≥ 0` | `grid_w < 0`, `bat_w < 0` | import, `bat_w ≤ 0` či mix | +| **Deye mode** | CHARGE | PASSIVE | SELL | PASSIVE | +| **108** charge A | dle `bat_w` | **0** při exportu bez vybíjení | **0** | max nebo **0** dle varianty | +| **109** discharge A | **0** | max | **max** | **0** při importu bez nabíjení, jinak max | | **142** limit control | `deye_zero_export_mode` (1 nebo 2) | `deye_zero_export_mode` (1 nebo 2) | **0** (selling first) | `deye_zero_export_mode` (1 nebo 2) | | **145** solar sell | **1** (enabled) | **1** (enabled) | **1** (enabled) | **1** (enabled) | | **178** peak shaving | 48 (PASSIVE) | 48 (PASSIVE) | **32** (SELL) | 48 (PASSIVE) | diff --git a/docs/04-modules/operating-modes.md b/docs/04-modules/operating-modes.md index d4deb11..831310b 100644 --- a/docs/04-modules/operating-modes.md +++ b/docs/04-modules/operating-modes.md @@ -2,9 +2,10 @@ ## Keep it simple -- **Méně heuristik a pevných wattových práhů v řízení** — každá magická konstanta je místo, kde se rodí neshody s plánem a ekonomikou. -- **SELL na Deye** používej jen tam, kde produktově opravdu chceme režim „**vylít baterii do sítě**“ (selling first). Vše ostatní patří do **PASSIVE**: **108/109** dávají **plný proudový rozsah** baterie, směr toku a ekonomiku drží **142** + **TOU** z plánu a instalace. -- Novou logiku vždy ověřit proti **reálnému řádku plánu** (audit / `planning_interval`) a typické chybě „plán říká kW, měnič jede na MW“. +- **Žádné wattové prahy pro výběr SELL / CHARGE** — mapování z MILP setpointů je čistě ze **znamének** `battery_setpoint_w` a `grid_setpoint_w` (viz `get_deye_mode` v `exporter_monolith.py`). +- **ZERO (PASSIVE)** = zero export k CT/zátěži (**142** = `deye_zero_export_mode`), s **plnými proudy 108/109** jen ve výchozím stavu; pro přetok FVE do sítě nebo odběr ze sítě bez vybíjení baterie se jeden z proudů **vynuluje** (`_deye_zero_export_amps_for_passive`). +- **SELL** = plánovaný export **i** plánované vybíjení (oba záporné) → **142** = selling first, **178** = vypnutý grid peak shaving (32); po návratu do ZERO/CHARGE zase **178** = 48. +- Novou logiku vždy ověřit proti **reálnému řádku plánu** (audit / `planning_interval`). ## Přehled @@ -16,17 +17,55 @@ | PRESERVE | no_charge, no_discharge | PASSIVE | lock (0/0) | | MANUAL | solver neběží | EMS nezapisuje | — | -Implementace: omezení LP v `planning_engine.solve_dispatch()` podle `mode_code` z `ems.site_operating_mode`; zápis Deye v `control_exporter.write_inverter_setpoints()` (včetně `lock_battery` u PRESERVE). +Implementace: -## Fyzické režimy Deye (výstup control_exporteru) +- **EMS provozní režim** (`AUTO`, `SELF_SUSTAIN`, …): jediný zdroj v DB `ems.site_operating_mode.mode_code` + větev v `_build_setpoints` / `export_setpoints` (např. `CHARGE_CHEAP` přepíše setpointy před zápisem — stále jedna funkce exportu). +- **Deye fyzický režim** (`PASSIVE` / `CHARGE` / `SELL`): jediný zdroj **`get_deye_mode`** (`exporter_monolith.py`); zápis v `write_inverter_setpoints()`. +- Omezení LP v `planning_engine.solve_dispatch()` podle `mode_code`; zápis Deye včetně `lock_battery` u PRESERVE. -Detekce v `get_deye_mode` (`battery_w` = `battery_setpoint_w` z plánu, záporné = vybíjení; `grid_setpoint_w` záporné = export do sítě): +### Odkud jsou `battery_setpoint_w` a `grid_setpoint_w` (AUTO) -- **CHARGE:** `battery_w` > 500 **a** `grid_setpoint_w` > 200 → nabíjení ze sítě; reg. **142** dle CHARGE větve v exporteru, **178** = 48. -- **SELL:** `grid_setpoint_w` < 0 **a** `battery_w` < 0 **a** `|battery_w| ≥ |grid_setpoint_w|` → záměr **vybíjet baterii do sítě** (selling first); reg. **142** = 0, **178** = 32, **108** = 0, **109** = max, reg. **143** omezen podle `|grid_setpoint_w|` (viz `control_exporter.py`). -- **PASSIVE:** vše ostatní — **reg. 108** a **109** na **plný strop** z konfigurace střídače (jako SELF_SUSTAIN); jemné řízení výkonu/SOC jde přes **TOU časové body** z plánu, **142** = `deye_zero_export_mode`, **145** = 1, **178** = 48. +Nejde o samostatný „tip“ z predikce FVE, který by exporter náhodně přetáhl do SELL nebo CHARGE. -**SELF_SUSTAIN:** `battery_w = None` ⇒ v `get_deye_mode` jako 0 ⇒ typicky **PASSIVE**; v `write_inverter_setpoints` při `self_sustain_local_use=True` → **108 i 109 = max**, reg. **142** = zero export dle DB, TOU SOC = **`min_soc_percent`**. **PRESERVE:** `lock_battery` → **108 = 0**, **109 = 0**. +1. **Zdroj dat:** pro režim **AUTO** exporter načte aktivní řádek **`ems.planning_interval`** pro aktuální 15min slot (`_fetch_plan_row_for_slot_offset` → `_build_setpoints` v `exporter_monolith.py`). +2. **Kdo je naplnil:** sloupce pocházejí z výstupu **`planning_engine.solve_dispatch()`** — MILP nad bilanční rovnicí za slot (základní značky v kódu: `gi[t]` ≥ 0 import ze sítě, `ge[t]` ≥ 0 export ze sítě, `bc[t]` / `bd[t]` nabíjení / vybíjení baterie). Uložené hodnoty odpovídají **`grid_setpoint_w = round(gi[t] - ge[t])`** a **`battery_setpoint_w = round(bc[t] - bd[t])`** (viz sestavení `DispatchResult` a zápis plánu). +3. **Fyzika u elektroměru:** v jednom slotu model pracuje s **čistým** tokem ze sítě jako rozdílem `gi` a `ge`; predikce PV a spotřeba vstupují do **bilance a omezení** řešiče, ne jako náhradní logika mapování na Deye. +4. **Role `get_deye_mode`:** pouze **přeloží** už hotový plán na kombinaci registrů (PASSIVE / CHARGE / SELL). Očekávání provozu (např. kdy přesně má být výdej baterie do sítě vs. přetok FVE) má držet **LP a výběr slotů** (`allow_charge`, `allow_discharge_export`, …), ne dodatečné wattové heuristiky v exporteru. + +## Fyzické režimy Deye (výstup control exporteru) + +**Jediné místo** pro klasifikaci **Deye** `PASSIVE` | `CHARGE` | `SELL` z MILP setpointů je **`get_deye_mode`** v `exporter_monolith.py`. + +Značení: `battery_w` = `battery_setpoint_w` (kladné = nabíjení, záporné = vybíjení); `grid_setpoint_w` (kladné = import, záporné = export). + +| Režim | Podmínka z plánu | 108 / 109 (zkráceně) | 142 | 178 | +|--------|------------------|----------------------|-----|-----| +| **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 | dle plánu nabíjení / 0 vybíjení | větev CHARGE | 48 | +| **SELL** | `battery_w` < 0 **a** `grid_setpoint_w` < 0 | 0 nabíjení / max vybíjení | 0 (selling first) | **32** (peak shaving off) | +| **PASSIVE (ZERO)** | vše ostatní | viz tabulka ZERO níže | `deye_zero_export_mode` | 48 | + +### ZERO: výchozí a dvě varianty proudu (reg. 108 / 109) + +Všechny řádky předpokládají **142** = zero export (ne SELL). + +| Situace | Podmínka (plán) | Reg. 108 (max charge A) | Reg. 109 (max discharge A) | +|---------|-----------------|-------------------------|----------------------------| +| Výchozí | ostatní případy PASSIVE | max | max | +| Přetok FVE do sítě | `grid_setpoint_w` < 0 **a** `battery_w` ≥ 0 | **0** | max | +| Držet baterii, brát ze sítě | `grid_setpoint_w` > 0 **a** `battery_w` ≤ 0 | max | **0** | + +Nabíjení ze sítě s vysokým cílovým SoC v TOU řeší větev **CHARGE** (grid charge v time pointech), ne tato tabulka. + +### ZERO a „zakázaný export FVE do sítě“ (jen řiditelná pole) + +**Reg. 145 (solar sell)** na Deye je přepínač typu **enabled / disabled** pro **přebytek FVE na straně měniče** (počítá se vůči režimu **142** zero export a stavu **108** — viz `modbus-registers.md`, pass-through krok za krokem). + +- **Pouze to, co EMS umí přes Deye Modbus ovlivnit** — typicky **FVE pole řízené střídačem** (`asset_pv_array.controllable = true`, u referenční lokality **home-01** např. string za Deye). +- **Pole mimo tento kanál** (např. **pv-b** u **home-01**, `controllable = false`, často samostatný ongrid GEN) **reg. 145 neovládá**; jejich výkon jde do plánovače jako `pv_b_forecast_w`, ale curtailment / solar sell logika Deye se jich netýká. + +**Implementace dnes:** exporter vždy zapisuje **145 = 1** (solar sell enabled). Tvrdé vypnutí přebytku řiditelného FVE do sítě přes **145 = 0** z politik (`no_export`, `BLOCK_EXPORT`, …) je v plánu — viz **`docs/05-todo.md`** (sekce *Deye řízení – rozšíření*). + +**SELF_SUSTAIN:** `battery_w = None` ⇒ v `get_deye_mode` jako 0 ⇒ **PASSIVE**; v `write_inverter_setpoints` při `self_sustain_local_use=True` → **108 i 109 = max** (bez variant ZERO výše), reg. **142** dle DB, TOU SOC = **`min_soc_percent`**. **PRESERVE:** `lock_battery` → **108 = 0**, **109 = 0**. ## EMS politiky (nejsou fyzické stavy Deye) diff --git a/docs/05-todo.md b/docs/05-todo.md index 0cf3968..fcb3691 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -82,13 +82,23 @@ Potřebné pro reálný, stabilní provoz; lze část EMS otestovat bez nich (na --- +## Deye řízení – rozšíření + +| Popis | Kde | Kdo | +|-------|-----|-----| +| **Reg. 145 (solar sell)** z politiky: při `no_export` / `BLOCK_EXPORT` (a obdobně) zapisovat **145 = 0**, aby šlo tvrdě zakázat přetok **řiditelného** FVE na Deye (`asset_pv_array.controllable = true`); dnes exporter vždy **1**. Vazba na instalaci: `docs/04-modules/operating-modes.md` (ZERO a reg. 145). | `exporter_monolith.write_inverter_setpoints` (+ vstupy z `InverterConfig` / `site_grid_connection`) | programátor | +| **Testy reg. 145** vůči journalu (`ems.modbus_command`): očekávaná hodnota při zákazu exportu vs. běžný provoz. | `backend/tests/`; `docs/04-modules/modbus-command-journal.md` | programátor | +| **Dvě FVE pole:** UI / provozní poznámka, že **145 = 0** neomezuje **pv-b** (ongrid); celkový export lokality může z pole B dál „unikat“. | `docs/04-modules/operating-modes.md`; `planning.md` (pv_a / pv_b) | majitel + programátor | + +--- + ## Fáze 2 – rozšíření | Popis | Kde | Kdo | |-------|-----|-----| | **Tesla API:** Tessie vs přímé API. | `docs/04-modules/ev-charging.md` ř. 280 | majitel + programátor | | **UI** pro deadline a target SoC před odjezdem. | `docs/04-modules/ev-charging.md` ř. 283 | programátor | -| **Notifikace** při nesplnitelném deadline nabíjení. | `docs/04-modules/ev-charging.md` ř. 284; `docs/04-modules/operating-modes.md` ř. 132 (stale heartbeat) | programátor | +| **Notifikace** při nesplnitelném deadline nabíjení. | `docs/04-modules/ev-charging.md` ř. 284; `docs/04-modules/operating-modes.md` (sekce *Otevřené body* – stale heartbeat) | programátor | | Ověřit **round-trip účinnost** baterie a **odhad SoC Zoe** z energie session na reálných datech. | `docs/04-modules/ev-charging.md` ř. 282, 285 | programátor | | **Kalibrace COP** modelu TČ na 4–6 týdnů historie. | `docs/04-modules/heat-pump.md` ř. 105 | programátor | | **pvlib** vs jednoduchý model FVE; **Solcast** jako alternativa k Open-Meteo. | `docs/04-modules/forecast.md` ř. 178, 180; `docs/06-open-questions.md` ř. 34 | programátor |