Add export plan guard to block Deye export against plan.
Some checks failed
CI and deploy / migration-check (push) Failing after 39s
CI and deploy / deploy (push) Has been skipped

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 <cursoragent@cursor.com>
This commit is contained in:
Dusan Vojacek
2026-05-29 00:14:52 +02:00
parent 620a557a89
commit a7dff75e58
8 changed files with 230 additions and 4 deletions

View File

@@ -59,6 +59,7 @@ from services.control.repository import (
) )
from services.control.setpoints import ( from services.control.setpoints import (
_DictRecord, _DictRecord,
_apply_export_plan_guard,
_apply_price_failsafe_guard, _apply_price_failsafe_guard,
_build_setpoints, _build_setpoints,
_clamp_deye_tou_soc_pct, _clamp_deye_tou_soc_pct,

View File

@@ -19,7 +19,11 @@ from services.control.repository import (
_fetch_plan_row_for_slot_offset, _fetch_plan_row_for_slot_offset,
_load_inverter_config, _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 from services.signal_service import enqueue_site_signals
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -94,8 +98,10 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
) )
sp_next = sp_now sp_next = sp_now
else: 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) sp_now = _apply_price_failsafe_guard(site_id, mode, pi_now, sp_now)
if sp_next is not None: 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) sp_next = _apply_price_failsafe_guard(site_id, mode, pi_next, sp_next)
planning_run_id = await db.fetchval( planning_run_id = await db.fetchval(

View File

@@ -223,6 +223,71 @@ def _build_setpoints(
return None 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( def _apply_price_failsafe_guard(
site_id: int, site_id: int,
mode: OperatingModeInfo, mode: OperatingModeInfo,

View File

@@ -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()

View File

@@ -24,7 +24,8 @@ as $fn$
s.interval_start, s.interval_start,
ai.actual_grid_power_w, ai.actual_grid_power_w,
ai.deviation_grid_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 from slots s
inner join ems.audit_interval ai inner join ems.audit_interval ai
on ai.site_id = p_site_id on ai.site_id = p_site_id
@@ -41,6 +42,12 @@ as $fn$
b.deviation_grid_w, b.deviation_grid_w,
case case
when b.plan_grid_w is null or b.deviation_grid_w is null then null::text 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 when b.plan_grid_w < -2000 and coalesce(b.actual_grid_power_w, 0) > 2500
then 'GRID_IMPORT_VS_EXPORT_PLAN' then 'GRID_IMPORT_VS_EXPORT_PLAN'
when b.plan_grid_w <> 0 when b.plan_grid_w <> 0
@@ -60,6 +67,22 @@ as $fn$
end as reason_code, end as reason_code,
case case
when b.plan_grid_w is null or b.deviation_grid_w is null then null::text 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 when b.plan_grid_w < -2000 and coalesce(b.actual_grid_power_w, 0) > 2500
then format( then format(
'plán síť %s W vs skutečnost %s W (plán vývoz, skutečnost silný odběr)', '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$; $fn$;
comment on function ems.fn_plan_actual_slot_guard_site(int, timestamptz) is 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( create or replace function ems.fn_plan_actual_slot_guard_all_active(
p_now timestamptz default now() p_now timestamptz default now()

View File

@@ -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 ## Logika exportu
```python ```python

View File

@@ -60,7 +60,7 @@ pro **reg 178** (spolu s peak shaving bity 45).
| Job | Frekvence | Popis | | 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`. | | `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 &lt; 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). Plná tabulka jobů je v [`lifespan.py`](../../backend/app/lifespan.py).

View File

@@ -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 &lt; 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&lt;0 + termika + bazén ## 2026-05-28 — Dokumentace strategie sell&lt;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, v32v35, návrh v36+, TČ/TUV podle typu dne, bazén, UI curtail/reg 340, roadmap, SQL ověření. **Soubor:** [`docs/04-modules/planning-neg-sell-strategy.md`](04-modules/planning-neg-sell-strategy.md) — cíle, slovník, časová osa dne, v32v35, návrh v36+, TČ/TUV podle typu dne, bazén, UI curtail/reg 340, roadmap, SQL ověření.