From a7dff75e586de8b173ad3412a3af0372ed63c1ea Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 29 May 2026 00:14:52 +0200 Subject: [PATCH] Add export plan guard to block Deye export against plan. Force PASSIVE/no-export when sell is negative or export_mode is NONE, and alert NEG_SELL_EXPORT in plan_actual_slot_guard when export still occurs. Co-authored-by: Cursor --- backend/services/control/exporter_monolith.py | 1 + backend/services/control/orchestrator.py | 8 +- backend/services/control/setpoints.py | 65 +++++++++++ .../tests/test_control_export_plan_guard.py | 106 ++++++++++++++++++ .../R__076_fn_plan_actual_slot_guard.sql | 27 ++++- docs/04-modules/control.md | 15 +++ docs/04-modules/modbus-command-journal.md | 2 +- docs/planning-changelog.md | 10 ++ 8 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_control_export_plan_guard.py diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 4d341fc..78c0daa 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -59,6 +59,7 @@ from services.control.repository import ( ) from services.control.setpoints import ( _DictRecord, + _apply_export_plan_guard, _apply_price_failsafe_guard, _build_setpoints, _clamp_deye_tou_soc_pct, diff --git a/backend/services/control/orchestrator.py b/backend/services/control/orchestrator.py index 7cd7eec..d06f9bb 100644 --- a/backend/services/control/orchestrator.py +++ b/backend/services/control/orchestrator.py @@ -19,7 +19,11 @@ from services.control.repository import ( _fetch_plan_row_for_slot_offset, _load_inverter_config, ) -from services.control.setpoints import _apply_price_failsafe_guard, _build_setpoints +from services.control.setpoints import ( + _apply_export_plan_guard, + _apply_price_failsafe_guard, + _build_setpoints, +) from services.signal_service import enqueue_site_signals logger = logging.getLogger(__name__) @@ -94,8 +98,10 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None: ) sp_next = sp_now else: + sp_now = _apply_export_plan_guard(site_id, mode, pi_now, sp_now) sp_now = _apply_price_failsafe_guard(site_id, mode, pi_now, sp_now) if sp_next is not None: + sp_next = _apply_export_plan_guard(site_id, mode, pi_next, sp_next) sp_next = _apply_price_failsafe_guard(site_id, mode, pi_next, sp_next) planning_run_id = await db.fetchval( diff --git a/backend/services/control/setpoints.py b/backend/services/control/setpoints.py index f285aed..d86c63c 100644 --- a/backend/services/control/setpoints.py +++ b/backend/services/control/setpoints.py @@ -223,6 +223,71 @@ 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).""" + bat = int(sp.battery_w or 0) + if bat < 0: + bat = 0 + return ControlSetpoints( + battery_w=bat, + grid_export_limit=0, + ev1_current_a=sp.ev1_current_a, + ev2_current_a=sp.ev2_current_a, + heat_pump_enable=sp.heat_pump_enable, + grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)), + ev1_power_w=sp.ev1_power_w, + ev2_power_w=sp.ev2_power_w, + target_soc_pct=sp.target_soc_pct, + deye_physical_mode="PASSIVE", + export_mode="NONE", + export_ban=True, + deye_gen_cutoff_enabled=sp.deye_gen_cutoff_enabled, + 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, + self_sustain_local_use=sp.self_sustain_local_use, + ) + + +def _apply_export_plan_guard( + site_id: int, + mode: OperatingModeInfo, + pi: Any | None, + sp: ControlSetpoints, +) -> ControlSetpoints: + """ + Exekuční pojistka: plán zakazuje vývoz (záporná vykupní nebo export_mode NONE), + ale Deye může zůstat v SELL — vynutit PASSIVE a export_ban před zápisem Modbus. + """ + if mode.mode_code != "AUTO" or pi is None: + return sp + + sell_raw = pi.get("effective_sell_price") + sell_f: float | None = ( + float(sell_raw) if sell_raw is not None else sp.effective_sell_price_czk_kwh + ) + export_mode_raw = pi.get("export_mode") + export_mode = ( + str(export_mode_raw).strip().upper() + if export_mode_raw is not None + else (sp.export_mode or "") + ) + grid_sp = int(pi.get("grid_setpoint_w") or sp.grid_setpoint_w or 0) + + neg_sell = sell_f is not None and float(sell_f) < 0 + plan_no_export = export_mode == "NONE" and grid_sp >= 0 + if not neg_sell and not plan_no_export: + return sp + + reason = "neg_sell" if neg_sell else "export_mode_none" + logger.warning( + "control export site=%s: AUTO export plan guard (%s) -> PASSIVE no-export", + site_id, + reason, + ) + return _passive_no_export_guard(sp) + + def _apply_price_failsafe_guard( site_id: int, mode: OperatingModeInfo, diff --git a/backend/tests/test_control_export_plan_guard.py b/backend/tests/test_control_export_plan_guard.py new file mode 100644 index 0000000..e414ab7 --- /dev/null +++ b/backend/tests/test_control_export_plan_guard.py @@ -0,0 +1,106 @@ +"""Exekuční pojistka exportu podle plánu (Plan 3).""" + +from __future__ import annotations + +import unittest + +from services.control.exporter_monolith import ( + ControlSetpoints, + _apply_export_plan_guard, + get_deye_mode, +) +from services.control.models import OperatingModeInfo +from services.control.setpoints import _DictRecord + + +def _auto_mode() -> OperatingModeInfo: + return OperatingModeInfo( + mode_code="AUTO", + battery_mode="AUTO", + grid_mode="AUTO", + ev_enabled=True, + heat_pump_enabled_def=True, + loxone_mode_value=0, + ) + + +def _sp(**kwargs: object) -> ControlSetpoints: + base = dict( + battery_w=0, + 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="SELL", + export_mode="BATTERY_SELL", + export_ban=False, + ) + base.update(kwargs) + return ControlSetpoints(**base) # type: ignore[arg-type] + + +class ExportPlanGuardTests(unittest.TestCase): + def test_neg_sell_forces_passive_no_export(self) -> None: + sp = _sp() + pi = _DictRecord( + { + "grid_setpoint_w": -8000, + "effective_sell_price": -0.5, + "export_mode": "NONE", + } + ) + out = _apply_export_plan_guard(1, _auto_mode(), pi, sp) + self.assertEqual(get_deye_mode(out), "PASSIVE") + self.assertTrue(out.export_ban) + self.assertEqual(out.grid_export_limit, 0) + self.assertGreaterEqual(out.grid_setpoint_w, 0) + self.assertEqual(out.export_mode, "NONE") + + def test_export_mode_none_with_non_negative_grid(self) -> None: + sp = _sp(grid_setpoint_w=0, battery_w=-5000, export_mode="BATTERY_SELL") + 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.assertEqual(get_deye_mode(out), "PASSIVE") + self.assertEqual(out.battery_w, 0) + self.assertTrue(out.export_ban) + + def test_profitable_export_unchanged(self) -> None: + sp = _sp() + pi = _DictRecord( + { + "grid_setpoint_w": -8000, + "effective_sell_price": 9.5, + "export_mode": "BATTERY_SELL", + } + ) + out = _apply_export_plan_guard(1, _auto_mode(), pi, sp) + self.assertIs(out, sp) + self.assertEqual(get_deye_mode(out), "SELL") + + def test_non_auto_mode_skipped(self) -> None: + sp = _sp() + pi = _DictRecord({"effective_sell_price": -1.0, "export_mode": "NONE"}) + mode = OperatingModeInfo( + mode_code="SELF_SUSTAIN", + battery_mode="PASSIVE", + grid_mode="PASSIVE", + ev_enabled=False, + heat_pump_enabled_def=False, + loxone_mode_value=1, + ) + out = _apply_export_plan_guard(1, mode, pi, sp) + self.assertIs(out, sp) + + +if __name__ == "__main__": + unittest.main() diff --git a/db/routines/R__076_fn_plan_actual_slot_guard.sql b/db/routines/R__076_fn_plan_actual_slot_guard.sql index ef3e609..f2b8790 100644 --- a/db/routines/R__076_fn_plan_actual_slot_guard.sql +++ b/db/routines/R__076_fn_plan_actual_slot_guard.sql @@ -24,7 +24,8 @@ as $fn$ s.interval_start, ai.actual_grid_power_w, ai.deviation_grid_w, - pi.grid_setpoint_w as plan_grid_w + pi.grid_setpoint_w as plan_grid_w, + pi.effective_sell_price as plan_sell_czk from slots s inner join ems.audit_interval ai on ai.site_id = p_site_id @@ -41,6 +42,12 @@ as $fn$ b.deviation_grid_w, case when b.plan_grid_w is null or b.deviation_grid_w is null then null::text + when coalesce( + b.plan_sell_czk, + ems.fn_effective_sell_price(p_site_id, b.interval_start) + ) < 0 + and coalesce(b.actual_grid_power_w, 0) < -4000 + then 'NEG_SELL_EXPORT' when b.plan_grid_w < -2000 and coalesce(b.actual_grid_power_w, 0) > 2500 then 'GRID_IMPORT_VS_EXPORT_PLAN' when b.plan_grid_w <> 0 @@ -60,6 +67,22 @@ as $fn$ end as reason_code, case when b.plan_grid_w is null or b.deviation_grid_w is null then null::text + when coalesce( + b.plan_sell_czk, + ems.fn_effective_sell_price(p_site_id, b.interval_start) + ) < 0 + and coalesce(b.actual_grid_power_w, 0) < -4000 + then format( + 'záporná vykupní %s Kč/kWh, skutečnost síť %s W (vývoz nad práh 4 kW)', + round( + coalesce( + b.plan_sell_czk, + ems.fn_effective_sell_price(p_site_id, b.interval_start) + )::numeric, + 4 + ), + coalesce(b.actual_grid_power_w, 0) + ) when b.plan_grid_w < -2000 and coalesce(b.actual_grid_power_w, 0) > 2500 then format( 'plán síť %s W vs skutečnost %s W (plán vývoz, skutečnost silný odběr)', @@ -154,7 +177,7 @@ as $fn$ $fn$; comment on function ems.fn_plan_actual_slot_guard_site(int, timestamptz) is -'Poslední 2 uzavřené 15min sloty: fatální odchylka síť plán vs. audit → insert plan_fatal_deviation_sent (dedup); vrátí JSON s alerts k odeslání na Discord.'; +'Poslední 2 uzavřené 15min sloty: fatální odchylka síť plán vs. audit (včetně NEG_SELL_EXPORT při sell<0 a vývozu >4 kW) → insert plan_fatal_deviation_sent (dedup); JSON alerts pro Discord.'; create or replace function ems.fn_plan_actual_slot_guard_all_active( p_now timestamptz default now() diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index 7fc6047..d10442b 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -58,6 +58,21 @@ Ověření: logy backendu kolem pokusu **nebo** `select id,status,created_at fro --- +## 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, `export_ban`, `grid_export_limit = 0`, vybíjení baterie do sítě vynulováno (`battery_w = max(0, …)`), `deye_physical_mode = PASSIVE` | +| **`_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 diff --git a/docs/04-modules/modbus-command-journal.md b/docs/04-modules/modbus-command-journal.md index fac9aeb..00e6816 100644 --- a/docs/04-modules/modbus-command-journal.md +++ b/docs/04-modules/modbus-command-journal.md @@ -60,7 +60,7 @@ pro **reg 178** (spolu s peak shaving bity 4–5). | Job | Frekvence | Popis | |-----|-----------|--------| | `verify_modbus` | každé **2 min** | Pro každou aktivní site vybere `written` příkazy s `written_at` v posledních **20 min** a zavolá `verify_modbus_commands`. | -| `plan_actual_slot_guard` | **:05, :20, :35, :50** (po `audit_filler`) | `ems.fn_plan_actual_slot_guard_all_active` (+ `plan_actual_slot_guard.py` jen Discord): poslední 2 uzavřené 15min sloty — fatální odchylka **plán vs. audit síť** → **Discord** (`critical`), dedup přes `ems.plan_fatal_deviation_sent`. | +| `plan_actual_slot_guard` | **:05, :20, :35, :50** (po `audit_filler`) | `ems.fn_plan_actual_slot_guard_all_active` (+ `plan_actual_slot_guard.py` jen Discord): poslední 2 uzavřené 15min sloty — fatální odchylka **plán vs. audit síť** → **Discord** (`critical`), dedup přes `ems.plan_fatal_deviation_sent`. Kódy: `GRID_SIGN_MISMATCH`, `GRID_EXPORT_SPIKE`, **`NEG_SELL_EXPORT`** (`sell < 0` a skutečný vývoz < −4 kW), `GRID_LARGE_DEVIATION`, … Exekuční pojistka proti opakovanému vývozu: [`control.md`](control.md) — `_apply_export_plan_guard`. | Plná tabulka jobů je v [`lifespan.py`](../../backend/app/lifespan.py). diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 171c21e..2729784 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,16 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-29 — Exekuční pojistka exportu (Plan 3) + +**Problém:** Plán `export_mode = NONE` nebo záporná vykupní, ale Deye zůstává v **SELL** → skutečný vývoz ~12 kW (zpoždění přepnutí režimu). + +**Změna:** `_apply_export_plan_guard` v `setpoints.py` (volá `orchestrator.export_setpoints` před `_apply_price_failsafe_guard`): při `sell < 0` nebo (`export_mode = NONE` a `grid_setpoint_w ≥ 0`) vynutí PASSIVE, `export_ban`, `grid_export_limit = 0`, vynulování vybíjení v plánu (`battery_w ≥ 0`). SQL guard **`NEG_SELL_EXPORT`** v `R__076_fn_plan_actual_slot_guard.sql` (`sell < 0` a vývoz < −4 kW). + +**Soubory:** `backend/services/control/setpoints.py`, `orchestrator.py`, `db/routines/R__076_fn_plan_actual_slot_guard.sql`, `backend/tests/test_control_export_plan_guard.py`, `docs/04-modules/control.md`, `docs/04-modules/modbus-command-journal.md`. + +**Ověření:** `pytest backend/tests/test_control_export_plan_guard.py`; po incidentu Discord s `reason_code = NEG_SELL_EXPORT`. + ## 2026-05-28 — Dokumentace strategie sell<0 + termika + bazén **Soubor:** [`docs/04-modules/planning-neg-sell-strategy.md`](04-modules/planning-neg-sell-strategy.md) — cíle, slovník, časová osa dne, v32–v35, návrh v36+, TČ/TUV podle typu dne, bazén, UI curtail/reg 340, roadmap, SQL ověření.