diff --git a/backend/services/control/setpoints.py b/backend/services/control/setpoints.py index 10cb4a7..bc5c932 100644 --- a/backend/services/control/setpoints.py +++ b/backend/services/control/setpoints.py @@ -231,8 +231,18 @@ def _build_setpoints( return None -def _passive_no_export_guard(sp: ControlSetpoints) -> ControlSetpoints: - """PASSIVE, žádný vývoz do sítě; vybíjení baterie do sítě vynulováno (reg 109 přes export_ban).""" +def _passive_no_export_guard( + sp: ControlSetpoints, *, hard_ban: bool = True +) -> ControlSetpoints: + """ + PASSIVE, žádný vývoz do sítě z plánu (143=0, grid_setpoint>=0, baterie nevybíjí do sítě). + + ``hard_ban=True`` (záporná vykupní): navíc export_ban (145=0) a MI cut-off na GEN + portu (reg 178) — přebytek pole B NESMÍ do sítě. + ``hard_ban=False`` (kladná vykupní, plán jen nechce exportovat baterii/stringy): + mikroinvertory NEodstavovat — jejich výroba se absorbuje do baterie/zátěže a + případný fyzický přetok se při kladné ceně prodá (cut-off by výrobu zahodil). + """ bat = int(sp.battery_w or 0) if bat < 0: bat = 0 @@ -248,8 +258,8 @@ def _passive_no_export_guard(sp: ControlSetpoints) -> ControlSetpoints: target_soc_pct=sp.target_soc_pct, deye_physical_mode="PASSIVE", export_mode="NONE", - export_ban=True, - deye_gen_cutoff_enabled=True, + export_ban=bool(sp.export_ban) or hard_ban, + deye_gen_cutoff_enabled=bool(sp.deye_gen_cutoff_enabled) or hard_ban, 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, @@ -293,7 +303,9 @@ def _apply_export_plan_guard( site_id, reason, ) - return _passive_no_export_guard(sp) + # MI cut-off / 145=0 jen při záporné vykupní; export_mode NONE s kladnou cenou + # nesmí odstavit pole B (BA81 2026-06-12: cutoff při sell +1.36 → výroba MI zahozena). + return _passive_no_export_guard(sp, hard_ban=neg_sell) def _apply_price_failsafe_guard( diff --git a/backend/tests/test_control_export_plan_guard.py b/backend/tests/test_control_export_plan_guard.py index 3489774..5b3de60 100644 --- a/backend/tests/test_control_export_plan_guard.py +++ b/backend/tests/test_control_export_plan_guard.py @@ -73,7 +73,30 @@ class ExportPlanGuardTests(unittest.TestCase): out = _apply_export_plan_guard(1, _auto_mode(), pi, sp) self.assertEqual(get_deye_mode(out), "PASSIVE") self.assertEqual(out.battery_w, 0) - self.assertTrue(out.export_ban) + self.assertEqual(out.grid_export_limit, 0) + # Kladná vykupní: žádný tvrdý ban — MI (pole B) se NEodstavuje, 145 zůstává 1 + # (BA81 2026-06-12: cutoff při sell +1.36 zahazoval výrobu mikroinvertorů). + self.assertFalse(out.export_ban) + self.assertFalse(out.deye_gen_cutoff_enabled) + + def test_export_mode_none_positive_sell_respects_plan_cutoff(self) -> None: + # Plán explicitně chce cut-off (z_gen_cutoff) -> guard ho nesmí shodit. + sp = _sp( + grid_setpoint_w=0, + battery_w=2000, + export_mode="NONE", + deye_physical_mode="PASSIVE", + deye_gen_cutoff_enabled=True, + ) + pi = _DictRecord( + { + "grid_setpoint_w": 0, + "effective_sell_price": 2.5, + "export_mode": "NONE", + } + ) + out = _apply_export_plan_guard(1, _auto_mode(), pi, sp) + self.assertTrue(out.deye_gen_cutoff_enabled) def test_profitable_export_unchanged(self) -> None: sp = _sp() diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index fb737b5..4ad9ea0 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -64,7 +64,7 @@ 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, `export_ban`, `grid_export_limit = 0`, vybíjení baterie do sítě vynulováno (`battery_w = max(0, …)`), `deye_physical_mode = PASSIVE` | +| **`_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`.