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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
106
backend/tests/test_control_export_plan_guard.py
Normal file
106
backend/tests/test_control_export_plan_guard.py
Normal 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()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ pro **reg 178** (spolu s peak shaving bity 4–5).
|
|||||||
| 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 < −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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## 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í.
|
**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í.
|
||||||
|
|||||||
Reference in New Issue
Block a user