diff --git a/backend/services/control/deye_helpers.py b/backend/services/control/deye_helpers.py index b3078f1..b14aae5 100644 --- a/backend/services/control/deye_helpers.py +++ b/backend/services/control/deye_helpers.py @@ -93,6 +93,27 @@ def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool: ) +def deye_mi_export_cutoff_want_enabled( + *, + gen_microinverter_cutoff_enabled: bool, + deye_gen_cutoff_enabled: bool, + export_ban: bool, + deye_mode: str, +) -> bool: + """ + True = zapnout MI export cut-off (reg 178 bits 0–1 = 11b). + + Plán může mít z_gen_cutoff=0 (PV B jen do domu v LP), ale bez cut-off na GEN portu + mikroinvertory fyzicky exportují do sítě — při export_ban (záporná vykupní, grid≥0) + cut-off vynutit i bez solver flagu. + """ + if not gen_microinverter_cutoff_enabled: + return False + if deye_mode == "SELL": + return False + return bool(deye_gen_cutoff_enabled) or bool(export_ban) + + def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool: """True = po 3x mismatch přepnout lokalitu do SELF_SUSTAIN (kritický registr).""" return int(reg) in DEYE_CRITICAL_REGS_SELF_SUSTAIN diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 78c0daa..6209a8a 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -31,6 +31,7 @@ from services.control.deye_helpers import ( battery_watts_to_amps, compute_pv_a_reg340_max_solar_w, current_slot_hhmm, + deye_mi_export_cutoff_want_enabled, deye_reg_triggers_self_sustain_after_verify_exhaust, # noqa: F401 - re-export next_slot_hhmm, watts_to_amps, diff --git a/backend/services/control/inverter.py b/backend/services/control/inverter.py index 424b534..7f62dae 100644 --- a/backend/services/control/inverter.py +++ b/backend/services/control/inverter.py @@ -24,6 +24,7 @@ from services.control.deye_helpers import ( REG178_VERIFY_MASK_COMBINED, _DEYE_INACTIVE_TOU_REGISTERS, _deye_should_skip_time_sync_after_read, + deye_mi_export_cutoff_want_enabled, _prague_minute_start_utc, current_slot_hhmm, next_slot_hhmm, @@ -194,7 +195,12 @@ async def write_inverter_setpoints( current_178 = int(r178[0]) peak_bits = int(reg178_val) & int(REG178_VERIFY_MASK) if inv.deye_gen_microinverter_cutoff_enabled: - want_cutoff = bool(setpoints_now.deye_gen_cutoff_enabled) and deye_mode != "SELL" + want_cutoff = deye_mi_export_cutoff_want_enabled( + gen_microinverter_cutoff_enabled=True, + deye_gen_cutoff_enabled=bool(setpoints_now.deye_gen_cutoff_enabled), + export_ban=bool(setpoints_now.export_ban), + deye_mode=deye_mode, + ) mi_bits = REG178_MI_EXPORT_ENABLE if want_cutoff else REG178_MI_EXPORT_DISABLE else: mi_bits = int(current_178) & int(REG178_MI_EXPORT_MASK) diff --git a/backend/services/control/setpoints.py b/backend/services/control/setpoints.py index e0b1ce1..10cb4a7 100644 --- a/backend/services/control/setpoints.py +++ b/backend/services/control/setpoints.py @@ -249,7 +249,7 @@ def _passive_no_export_guard(sp: ControlSetpoints) -> ControlSetpoints: deye_physical_mode="PASSIVE", export_mode="NONE", export_ban=True, - deye_gen_cutoff_enabled=sp.deye_gen_cutoff_enabled, + deye_gen_cutoff_enabled=True, effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh, pv_a_allowed_w=sp.pv_a_allowed_w, lock_battery=sp.lock_battery, diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index fd7514c..de375e3 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -71,7 +71,7 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0 PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0 PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0 -PLANNER_BUILD_TAG = "2026-06-06-charge-slot-budget-v1" +PLANNER_BUILD_TAG = "2026-06-06-ba81-gen-cutoff-exec-v1" SOLVER_RELAX_STEPS: tuple[str, ...] = ( "strict", "relaxed_expensive_import", @@ -3879,6 +3879,8 @@ def solve_dispatch( prob += ge[t] == 0 prob += ge_pv[t] == 0 prob += ge_bat[t] == 0 + if z_gen_cutoff is not None and float(s.sell_price) < 0.0: + prob += z_gen_cutoff[t] == 1 # PV A: měkký tlak curtail (NEG_SELL_CURTAIL při buy<0), ne tvrdé bc_pv=0 # (s polem B a bilancí může být bc_pv=0 nutné pro řešitelnost krátkých okének). @@ -3967,6 +3969,13 @@ def solve_dispatch( ) prob += ge_pv[t] <= float(s.pv_b_forecast_w) * w_pv_b_vent + # GEN/MI cut-off ON když LP zakazuje vývoz — bez cut-off únik PV B na GEN portu do sítě. + if z_gen_cutoff is not None: + if block_neg_sell_export_t or purchase_fixed_pre: + prob += z_gen_cutoff[t] == 1 + elif block_pv_export_neg_sell: + prob += z_gen_cutoff[t] == 1 + soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1] arb_t = arb_floor_series[t] soc_low_t = soc_panel_min[t] diff --git a/backend/tests/test_control_export_plan_guard.py b/backend/tests/test_control_export_plan_guard.py index e414ab7..3489774 100644 --- a/backend/tests/test_control_export_plan_guard.py +++ b/backend/tests/test_control_export_plan_guard.py @@ -59,6 +59,7 @@ class ExportPlanGuardTests(unittest.TestCase): self.assertEqual(out.grid_export_limit, 0) self.assertGreaterEqual(out.grid_setpoint_w, 0) self.assertEqual(out.export_mode, "NONE") + self.assertTrue(out.deye_gen_cutoff_enabled) def test_export_mode_none_with_non_negative_grid(self) -> None: sp = _sp(grid_setpoint_w=0, battery_w=-5000, export_mode="BATTERY_SELL") diff --git a/backend/tests/test_control_exporter_tou.py b/backend/tests/test_control_exporter_tou.py index c8a6e99..33861f6 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_mi_export_cutoff_want_enabled, deye_reg_triggers_self_sustain_after_verify_exhaust, get_deye_mode, ) @@ -114,6 +115,36 @@ class DeyeTouParamsTests(unittest.TestCase): ) self.assertEqual(get_deye_mode(sp), "PASSIVE") + def test_mi_export_cutoff_on_export_ban_without_plan_flag(self) -> None: + self.assertTrue( + deye_mi_export_cutoff_want_enabled( + gen_microinverter_cutoff_enabled=True, + deye_gen_cutoff_enabled=False, + export_ban=True, + deye_mode="PASSIVE", + ) + ) + + def test_mi_export_cutoff_off_when_sell_mode(self) -> None: + self.assertFalse( + deye_mi_export_cutoff_want_enabled( + gen_microinverter_cutoff_enabled=True, + deye_gen_cutoff_enabled=True, + export_ban=True, + deye_mode="SELL", + ) + ) + + def test_mi_export_cutoff_off_without_feature_flag(self) -> None: + self.assertFalse( + deye_mi_export_cutoff_want_enabled( + gen_microinverter_cutoff_enabled=False, + deye_gen_cutoff_enabled=True, + export_ban=True, + deye_mode="PASSIVE", + ) + ) + def test_build_setpoints_uses_explicit_export_limit(self) -> None: mode = OperatingModeInfo( mode_code="AUTO", diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 1bf09de..6060c48 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -2208,6 +2208,10 @@ class NegativeSellPvChargeTests(unittest.TestCase): for r in results: self.assertGreaterEqual(r.battery_setpoint_w, 0, "neg sell má nabíjet") self.assertGreaterEqual(r.grid_setpoint_w, 0, "neg sell bez exportu do sítě") + self.assertTrue( + r.deye_gen_cutoff_enabled, + "neg sell bez exportu → GEN cut-off ON (exekuce reg 178)", + ) def test_ba81_fixed_purchase_nt_vt_buy_spread_neg_sell_no_export(self) -> None: """BA81: NT/VT buy v horizontu (rozptyl >0,25) — záporný sell stále bez exportu.""" diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index d10442b..fb737b5 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -153,6 +153,8 @@ registru **178** (v některých manuálech/UI uváděno jako “register 179” - `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). diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 88f610f..ade4628 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -430,7 +430,8 @@ kde: - (případně) explicitní `no_export` politika, pokud je v kontextu dostupná Mimo tyto případy je `z_gen_cutoff[t]` vynucené na `0`. - Cut-off je v účelové funkci **penalizované** (za „zahozenou“ GEN výrobu), aby se zapínalo jen jako poslední možnost. - - Výstup se ukládá do `planning_interval.deye_gen_cutoff_enabled` (nullable) a exporter pak nastaví bity reg 178. + - **Tvrdé vynucení `z_gen_cutoff[t]=1`** (tag **`2026-06-06-ba81-gen-cutoff-exec-v1`**) když LP zakazuje vývoz při `sell<0`: fixní tarif (`purchase_fixed_pre`), `block_export_on_negative_sell`, nebo `block_pv_export_neg_sell`; stejně při souběhu `buy<0` a `sell<0`. Bez toho plán ukazoval cut-off OFF, ale MI na GEN portu exportovaly (audit BA81 6. 6. 2026 ~08:00). + - Výstup se ukládá do `planning_interval.deye_gen_cutoff_enabled` (nullable) a exporter pak nastaví bity reg 178 (viz [`control.md`](control.md) — cut-off i při `export_ban` bez solver flagu). **Scope / bezpečnost:** proměnná i flag existují jen na lokalitách, kde je zapnutý `asset_inverter.deye_gen_microinverter_cutoff_enabled` (tj. kde je GEN port s mikroinvertory reálně zapojen). Jinde se nic neřeší ani nezobrazuje. diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 8beecfc..75347d3 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,25 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-06-06 — BA81 GEN cut-off exekuce při sell<0 (Branch 4) + +**Problém:** Audit BA81 6. 6. 2026 (07:45–08:30, `sell<0`): plán `grid_setpoint_w=0`, `deye_gen_cutoff_enabled=false`, ale **`actual_grid_export_wh` > 0** a **`flow_pv_to_grid_wh` > 0** (~0,8–1 kW). Reg **145** (`export_ban`) nestačí — mikroinvertory na GEN portu exportují, dokud reg **178** bits 0–1 ≠ cut-off ON. + +**Příčina:** Solver nechal `z_gen_cutoff=0` (PV B jen do domu v bilanci); exporter zapínal MI cut-off jen z plánového flagu, ne z `export_ban`. + +**Oprava (tag `2026-06-06-ba81-gen-cutoff-exec-v1`):** +- **LP:** `z_gen_cutoff[t]==1` při `sell<0` a zakázaném vývozu (fixní tarif, `block_export_on_negative_sell`, `block_pv_export_neg_sell`, nebo `buy<0`+`sell<0`). +- **Exekuce:** `deye_mi_export_cutoff_want_enabled()` — cut-off ON při `export_ban` nebo plánovém flagu; `_passive_no_export_guard` nastaví `deye_gen_cutoff_enabled=True`. + +**Soubory:** `planning_engine.py`, `deye_helpers.py`, `inverter.py`, `setpoints.py`. + +**Ověření:** +- `pytest backend/tests/test_planning_dispatch_milp.py -k "fixed_tariff_neg_sell or gen_cutoff"` +- `pytest backend/tests/test_control_exporter_tou.py backend/tests/test_control_export_plan_guard.py -k "mi_export or neg_sell"` +- MCP po deployi (BA81, `sell<0`): `deye_gen_cutoff_enabled=true`, `actual_grid_export_wh≈0`; `modbus_command` reg **178** s MI bits = 3 nebo verify skip jen pokud už cut-off ON. + +--- + ## 2026-06-06 — Future neg-buy večerní export (v64, Branch 2) **Problém:** home-01 run 23784 při **`relaxed_neg_prep_window`**: `evening_push_hard_suppressed`, prázdné **`neg_evening_push_slots`**, **`pos_sell_pre_neg_buy_ts`** blokoval `ge_bat` ve večerní špičce, terminal SoC shadow price držel ~80 % SoC + import @ ~5 Kč.