Branch 4: BA81 GEN cutoff audit + exekuce při sell<0
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-06-06 22:36:27 +02:00
parent a7879f1141
commit 0f7dc6ed94
11 changed files with 99 additions and 4 deletions

View File

@@ -93,6 +93,27 @@ def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool:
)
def deye_mi_export_cutoff_want_enabled(
*,
gen_microinverter_cutoff_enabled: bool,
deye_gen_cutoff_enabled: bool,
export_ban: bool,
deye_mode: str,
) -> bool:
"""
True = zapnout MI export cut-off (reg 178 bits 01 = 11b).
Plán může mít z_gen_cutoff=0 (PV B jen do domu v LP), ale bez cut-off na GEN portu
mikroinvertory fyzicky exportují do sítě — při export_ban (záporná vykupní, grid≥0)
cut-off vynutit i bez solver flagu.
"""
if not gen_microinverter_cutoff_enabled:
return False
if deye_mode == "SELL":
return False
return bool(deye_gen_cutoff_enabled) or bool(export_ban)
def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool:
"""True = po 3x mismatch přepnout lokalitu do SELF_SUSTAIN (kritický registr)."""
return int(reg) in DEYE_CRITICAL_REGS_SELF_SUSTAIN

View File

@@ -31,6 +31,7 @@ from services.control.deye_helpers import (
battery_watts_to_amps,
compute_pv_a_reg340_max_solar_w,
current_slot_hhmm,
deye_mi_export_cutoff_want_enabled,
deye_reg_triggers_self_sustain_after_verify_exhaust, # noqa: F401 - re-export
next_slot_hhmm,
watts_to_amps,

View File

@@ -24,6 +24,7 @@ from services.control.deye_helpers import (
REG178_VERIFY_MASK_COMBINED,
_DEYE_INACTIVE_TOU_REGISTERS,
_deye_should_skip_time_sync_after_read,
deye_mi_export_cutoff_want_enabled,
_prague_minute_start_utc,
current_slot_hhmm,
next_slot_hhmm,
@@ -194,7 +195,12 @@ async def write_inverter_setpoints(
current_178 = int(r178[0])
peak_bits = int(reg178_val) & int(REG178_VERIFY_MASK)
if inv.deye_gen_microinverter_cutoff_enabled:
want_cutoff = bool(setpoints_now.deye_gen_cutoff_enabled) and deye_mode != "SELL"
want_cutoff = deye_mi_export_cutoff_want_enabled(
gen_microinverter_cutoff_enabled=True,
deye_gen_cutoff_enabled=bool(setpoints_now.deye_gen_cutoff_enabled),
export_ban=bool(setpoints_now.export_ban),
deye_mode=deye_mode,
)
mi_bits = REG178_MI_EXPORT_ENABLE if want_cutoff else REG178_MI_EXPORT_DISABLE
else:
mi_bits = int(current_178) & int(REG178_MI_EXPORT_MASK)

View File

@@ -249,7 +249,7 @@ def _passive_no_export_guard(sp: ControlSetpoints) -> ControlSetpoints:
deye_physical_mode="PASSIVE",
export_mode="NONE",
export_ban=True,
deye_gen_cutoff_enabled=sp.deye_gen_cutoff_enabled,
deye_gen_cutoff_enabled=True,
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,

View File

@@ -71,7 +71,7 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
PLANNER_BUILD_TAG = "2026-06-06-charge-slot-budget-v1"
PLANNER_BUILD_TAG = "2026-06-06-ba81-gen-cutoff-exec-v1"
SOLVER_RELAX_STEPS: tuple[str, ...] = (
"strict",
"relaxed_expensive_import",
@@ -3879,6 +3879,8 @@ def solve_dispatch(
prob += ge[t] == 0
prob += ge_pv[t] == 0
prob += ge_bat[t] == 0
if z_gen_cutoff is not None and float(s.sell_price) < 0.0:
prob += z_gen_cutoff[t] == 1
# PV A: měkký tlak curtail (NEG_SELL_CURTAIL při buy<0), ne tvrdé bc_pv=0
# (s polem B a bilancí může být bc_pv=0 nutné pro řešitelnost krátkých okének).
@@ -3967,6 +3969,13 @@ def solve_dispatch(
)
prob += ge_pv[t] <= float(s.pv_b_forecast_w) * w_pv_b_vent
# GEN/MI cut-off ON když LP zakazuje vývoz — bez cut-off únik PV B na GEN portu do sítě.
if z_gen_cutoff is not None:
if block_neg_sell_export_t or purchase_fixed_pre:
prob += z_gen_cutoff[t] == 1
elif block_pv_export_neg_sell:
prob += z_gen_cutoff[t] == 1
soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1]
arb_t = arb_floor_series[t]
soc_low_t = soc_panel_min[t]

View File

@@ -59,6 +59,7 @@ class ExportPlanGuardTests(unittest.TestCase):
self.assertEqual(out.grid_export_limit, 0)
self.assertGreaterEqual(out.grid_setpoint_w, 0)
self.assertEqual(out.export_mode, "NONE")
self.assertTrue(out.deye_gen_cutoff_enabled)
def test_export_mode_none_with_non_negative_grid(self) -> None:
sp = _sp(grid_setpoint_w=0, battery_w=-5000, export_mode="BATTERY_SELL")

View File

@@ -11,6 +11,7 @@ from services.control.exporter_monolith import (
_deye_reg178_verify_with_double_read,
_deye_tou_params,
_deye_tou_power_verify_match,
deye_mi_export_cutoff_want_enabled,
deye_reg_triggers_self_sustain_after_verify_exhaust,
get_deye_mode,
)
@@ -114,6 +115,36 @@ class DeyeTouParamsTests(unittest.TestCase):
)
self.assertEqual(get_deye_mode(sp), "PASSIVE")
def test_mi_export_cutoff_on_export_ban_without_plan_flag(self) -> None:
self.assertTrue(
deye_mi_export_cutoff_want_enabled(
gen_microinverter_cutoff_enabled=True,
deye_gen_cutoff_enabled=False,
export_ban=True,
deye_mode="PASSIVE",
)
)
def test_mi_export_cutoff_off_when_sell_mode(self) -> None:
self.assertFalse(
deye_mi_export_cutoff_want_enabled(
gen_microinverter_cutoff_enabled=True,
deye_gen_cutoff_enabled=True,
export_ban=True,
deye_mode="SELL",
)
)
def test_mi_export_cutoff_off_without_feature_flag(self) -> None:
self.assertFalse(
deye_mi_export_cutoff_want_enabled(
gen_microinverter_cutoff_enabled=False,
deye_gen_cutoff_enabled=True,
export_ban=True,
deye_mode="PASSIVE",
)
)
def test_build_setpoints_uses_explicit_export_limit(self) -> None:
mode = OperatingModeInfo(
mode_code="AUTO",

View File

@@ -2208,6 +2208,10 @@ class NegativeSellPvChargeTests(unittest.TestCase):
for r in results:
self.assertGreaterEqual(r.battery_setpoint_w, 0, "neg sell má nabíjet")
self.assertGreaterEqual(r.grid_setpoint_w, 0, "neg sell bez exportu do sítě")
self.assertTrue(
r.deye_gen_cutoff_enabled,
"neg sell bez exportu → GEN cut-off ON (exekuce reg 178)",
)
def test_ba81_fixed_purchase_nt_vt_buy_spread_neg_sell_no_export(self) -> None:
"""BA81: NT/VT buy v horizontu (rozptyl >0,25) — záporný sell stále bez exportu."""