Faze 0A: battery guard carve-out — neblokovat import na nabiti pri zaporne cene

_apply_export_plan_guard / _build_setpoints: kdyz slot CHARGE / importuje na
nabiti baterie (grid_sp>0 & bat>0), guard vrati sp beze zmeny a export_ban se
nenastavi. Opravuje, ze se baterie nedobila v zapornych cenach (CHARGE+17kW
prekloplen na PASSIVE -> Deye nenabijel ze site). Diagnoza: agent a599eecc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-13 22:40:18 +02:00
parent 6e89b044f5
commit 521a3653d3
4 changed files with 276 additions and 2 deletions

View File

@@ -125,11 +125,20 @@ def _build_setpoints(
export_limit = 0
elif export_limit <= 0 and grid_sp < 0:
export_limit = abs(grid_sp)
bat_w = int(pi["battery_setpoint_w"] or 0)
# Záporný výkup sám o sobě neblokuje export, pokud plán export explicitně žádá.
export_ban = sell_f is not None and float(sell_f) < 0 and grid_sp >= 0
# A nesmí blokovat ani IMPORT na nabití baterie (CHARGE / grid>0 & bat>0) —
# jinak MI cut-off (178) / 145=0 zbytečně odstaví pole B a Deye nenabije
# ze sítě v záporných cenách (bug 2026-06-13). §6 blokuje jen export.
is_grid_charge = pm == "CHARGE" or (grid_sp > 0 and bat_w > 0)
export_ban = (
sell_f is not None
and float(sell_f) < 0
and grid_sp >= 0
and not is_grid_charge
)
gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled")
gen_cutoff = bool(gen_cutoff_raw) if gen_cutoff_raw is not None else False
bat_w = int(pi["battery_setpoint_w"] or 0)
pv_a_allowed: int | None = None
if bool(reg340_pv_a_control_enabled) and int(pv_a_cap_w) > 0:
forecast = int(pi.get("pv_a_forecast_solver_w") or 0)
@@ -294,6 +303,16 @@ def _apply_export_plan_guard(
)
grid_sp = int(pi.get("grid_setpoint_w") or sp.grid_setpoint_w or 0)
# Carve-out: nabíjecí / importní slot NENÍ export. Guard řeší jen zákaz
# exportu při sell<0 — když plán importuje na nabití baterie (CHARGE, nebo
# grid_sp>0 & bat_sp>0), překlopení na PASSIVE by zařízlo grid charge
# (bug 2026-06-13: baterie se nedobila v záporných cenách). §6 zakazuje
# jen export, ne import (§7).
pm = str(pi.get("deye_physical_mode") or "").strip().upper()
bat_sp = int(pi.get("battery_setpoint_w") or 0)
if pm == "CHARGE" or (grid_sp > 0 and bat_sp > 0):
return sp
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:

View File

@@ -111,6 +111,29 @@ class ExportPlanGuardTests(unittest.TestCase):
self.assertIs(out, sp)
self.assertEqual(get_deye_mode(out), "SELL")
def test_neg_sell_grid_charge_not_blocked(self) -> None:
# Záporný sell + IMPORT na nabití baterie (CHARGE / grid>0 & bat>0):
# guard NESMÍ překlopit na PASSIVE — jinak Deye nenabije ze sítě
# v záporných cenách (bug 2026-06-13).
sp = _sp(
grid_setpoint_w=17000,
battery_w=17000,
deye_physical_mode="CHARGE",
export_mode="NONE",
)
pi = _DictRecord(
{
"grid_setpoint_w": 17000,
"battery_setpoint_w": 17000,
"deye_physical_mode": "CHARGE",
"effective_sell_price": -1.2,
"export_mode": "NONE",
}
)
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
self.assertIs(out, sp)
self.assertEqual(get_deye_mode(out), "CHARGE")
def test_non_auto_mode_skipped(self) -> None:
sp = _sp()
pi = _DictRecord({"effective_sell_price": -1.0, "export_mode": "NONE"})