From 406b6a7f8fdd0af2dceb7e668f3446807a2c46ef Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 12 Jun 2026 20:40:11 +0200 Subject: [PATCH] =?UTF-8?q?HARD=20LIMIT=20exportu=20jako=20tvrd=C3=A9=20pr?= =?UTF-8?q?avidlo=20=C2=A74.19=20+=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Překročení rezervovaného exportu na fakturačním elektroměru (home-01 13.5 kW) = pokuta v řádu desítek tisíc Kč/kW. Invariant: reg 143 (svorky) <= max_export_power_w (ulice) VŽDY; feed-forward navyšování o měřenou spotřebu mezi střídačem a CT ZAKÁZÁNO (výpadek spotřeby = přestřelení ulice). Návrh feed-forwardu z 2026-06-12 večer zavržen před implementací na pokyn uživatele. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 4 ++- backend/tests/test_export_hard_limit.py | 35 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_export_hard_limit.py diff --git a/CLAUDE.md b/CLAUDE.md index d03d763..709079d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,9 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st 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):** **`export_mode=PV_SURPLUS`** → **108=0**, **109=max**, **142**=`deye_zero_export_mode` (ne selling first); jinak **108/109** dle `deye_battery_charge_discharge_amps` / `_deye_zero_export_amps_for_passive` (import bez vybíjení → **109=0**); **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10–100 z DB), **SELL** = **`reserve_soc_percent`** (`_deye_passive_tou_battery_soc_pct`, `_deye_tou_params`). **SELL:** reg **108** EMS **nezapisuje** (selling first = **142**), **109**=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (strop z `deye_reg340_max_solar_w`, typ. 32k home-01 / 65k jinde, ne součet Wp; min `deye_reg340_min_solar_w`, home-01 400); hodnota z plánu / curtailu (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **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). +19. **HARD LIMIT exportu na fakturačním elektroměru — NIKDY nepřekročit.** Překročení rezervovaného exportního výkonu (home-01: 13.5 kW) byť o desetiny kW = smluvní pokuta v řádu desítek tisíc Kč za kW. Jediný bezpečný invariant: **reg 143 (limit na svorkách střídače) <= max_export_power_w (limit ulice) VŽDY** — v nejhorším případě (spotřeba mezi střídačem a CT odpadne) je ulice rovna svorkám. **ZAKÁZÁNO** jakékoli feed-forward navyšování terminálového limitu o měřenou spotřebu (výpadek spotřeby = přestřelení ulice). Vyšší vytěžení smí přinést jedině interní regulace střídače proti CT (firmware smyčka), nikdy náš software s 1min telemetrií a 15min ticky. + +20. **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/tests/test_export_hard_limit.py b/backend/tests/test_export_hard_limit.py new file mode 100644 index 0000000..9098b72 --- /dev/null +++ b/backend/tests/test_export_hard_limit.py @@ -0,0 +1,35 @@ +"""HARD LIMIT exportu (CLAUDE.md §4.19): reg 143 nikdy nad limit ulice. + +Pokuta v řádu desítek tisíc Kč za každou kW překročení rezervovaného +exportního výkonu na fakturačním elektroměru. Terminálový limit (reg 143) +nesmí přesáhnout max_export_power_w za žádných okolností — žádný +feed-forward o měřenou spotřebu mezi střídačem a CT. +""" + +from __future__ import annotations + +import unittest + +from services.control.setpoints import _deye_reg143_export_w + + +class ExportHardLimitTests(unittest.TestCase): + def test_reg143_never_exceeds_street_limit(self) -> None: + street_limit = 13_500 + self.assertLessEqual( + _deye_reg143_export_w(False, street_limit), street_limit + ) + + def test_no_export_is_zero(self) -> None: + self.assertEqual(_deye_reg143_export_w(True, 13_500), 0) + + def test_plan_export_limit_caps_not_raises(self) -> None: + # vzor z write_inverter_setpoints: export_lim = min(hw, plan) — plán + # smí limit jen SNÍŽIT, nikdy zvýšit + hw = _deye_reg143_export_w(False, 13_500) + plan_limit = 20_000 + self.assertLessEqual(min(hw, plan_limit), 13_500) + + +if __name__ == "__main__": + unittest.main()