implmemtace cuttoff genportu
This commit is contained in:
@@ -44,6 +44,14 @@ DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT = 100
|
||||
# Verify: jen bity 4–5 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone
|
||||
REG178_VERIFY_MASK = 0x0030
|
||||
|
||||
# Reg 179 – Control board special 1: bits 0–1 ovládají MI export cutoff (AC coupling / GEN).
|
||||
REG179_MI_EXPORT_MASK = 0x0003
|
||||
REG179_MI_EXPORT_DISABLE = 0b10
|
||||
REG179_MI_EXPORT_ENABLE = 0b11
|
||||
|
||||
def _deye_reg179_verify_match(expected_i: int, actual_i: int) -> bool:
|
||||
return (int(expected_i) & REG179_MI_EXPORT_MASK) == (int(actual_i) & REG179_MI_EXPORT_MASK)
|
||||
|
||||
# Po 3 neúspěšných verify pokusech → SELF_SUSTAIN jen u těchto registrech (bezpečnost / export).
|
||||
# 62–64 řeší toleranční bundle (nemění režim). 178 a TOU power W jsou „soft“ — jen log + Discord.
|
||||
DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145})
|
||||
@@ -113,6 +121,7 @@ DEYE_REGISTER_NAMES: dict[int, str] = {
|
||||
143: "export_limit_w (max export do sítě)",
|
||||
145: "solar_sell (0=disabled, 1=enabled)",
|
||||
178: "grid_peak_shaving_switch (SELL=32 bit4-5=10, PASSIVE/CHARGE=48 bit4-5=11)",
|
||||
179: "control_board_special_1 (bits0-1: MI export cutoff disable=2 enable=3)",
|
||||
148: "time_point_1_time",
|
||||
149: "time_point_2_time",
|
||||
154: "time_point_1_power_w",
|
||||
@@ -192,6 +201,7 @@ class InverterConfig:
|
||||
deye_last_tou_inactive_write_prague_date: date | None = None
|
||||
deye_tou_inactive_signature: str | None = None
|
||||
deye_zero_export_mode: int = 1
|
||||
deye_gen_microinverter_cutoff_enabled: bool = False
|
||||
|
||||
|
||||
def _prague_minute_start_utc() -> datetime:
|
||||
@@ -342,6 +352,11 @@ class ControlSetpoints:
|
||||
target_soc_pct: int | None = None
|
||||
#: Explicitní fyzický režim z plánu (PASSIVE/SELL/CHARGE). Pokud je vyplněn, má přednost před detekcí ze znamének.
|
||||
deye_physical_mode: str | None = None
|
||||
#: True = zákaz exportu (BLOCK_EXPORT) pro daný slot: např. při efektivní vykupní ceně < 0.
|
||||
export_ban: bool = False
|
||||
#: True = odpojit GEN port (MI export cutoff) v tomto slotu dle plánu (reg 179 bits0-1).
|
||||
#: None/False = neodpojovat.
|
||||
deye_gen_cutoff_enabled: bool = False
|
||||
#: Efektivní vykupní cena slotu (Kč/kWh z plánu); pro TOU řízení priorit baterie vs. přetok
|
||||
effective_sell_price_czk_kwh: float | None = None
|
||||
#: True = reg 108/109 na 0 (PRESERVE – Deye baterii nepoužívá)
|
||||
@@ -798,6 +813,8 @@ async def verify_modbus_commands(
|
||||
first_178,
|
||||
second_178,
|
||||
)
|
||||
if reg == 179:
|
||||
matches = _deye_reg179_verify_match(expected_i, actual_i)
|
||||
if not matches and reg in DEYE_TOU_POWER_REGS and inv_cfg is not None:
|
||||
matches = _deye_tou_power_verify_match(expected_i, actual_i, inv_cfg)
|
||||
|
||||
@@ -821,7 +838,11 @@ async def verify_modbus_commands(
|
||||
reg,
|
||||
expected_i,
|
||||
actual_i,
|
||||
" (reg178 mask 0x%04X)" % REG178_VERIFY_MASK if reg == 178 else "",
|
||||
(
|
||||
" (reg178 mask 0x%04X)" % REG178_VERIFY_MASK
|
||||
if reg == 178
|
||||
else (" (reg179 mask 0x%04X)" % REG179_MI_EXPORT_MASK if reg == 179 else "")
|
||||
),
|
||||
)
|
||||
row_ac = await db.fetchrow(
|
||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
@@ -1047,6 +1068,7 @@ async def _load_inverter_config(
|
||||
ai.deye_last_tou_inactive_write_prague_date,
|
||||
ai.deye_tou_inactive_signature,
|
||||
COALESCE(ai.deye_zero_export_mode, 1) AS deye_zero_export_mode,
|
||||
COALESCE(ai.deye_gen_microinverter_cutoff_enabled, false) AS deye_gen_microinverter_cutoff_enabled,
|
||||
COALESCE(
|
||||
ai.deye_register_max_charge_a,
|
||||
FLOOR(
|
||||
@@ -1130,6 +1152,7 @@ async def _load_inverter_config(
|
||||
],
|
||||
deye_tou_inactive_signature=row["deye_tou_inactive_signature"],
|
||||
deye_zero_export_mode=int(row["deye_zero_export_mode"]),
|
||||
deye_gen_microinverter_cutoff_enabled=bool(row["deye_gen_microinverter_cutoff_enabled"] or False),
|
||||
)
|
||||
|
||||
|
||||
@@ -1226,6 +1249,9 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
pm: str | None = str(pm_raw).strip().upper() if pm_raw is not None else None
|
||||
sell_raw = pi.get("effective_sell_price")
|
||||
sell_f: float | None = float(sell_raw) if sell_raw is not None else None
|
||||
export_ban = sell_f is not None and float(sell_f) < 0
|
||||
gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled")
|
||||
gen_cutoff = bool(gen_cutoff_raw) if gen_cutoff_raw is not None else False
|
||||
return ControlSetpoints(
|
||||
battery_w=int(pi["battery_setpoint_w"] or 0),
|
||||
grid_export_limit=abs(min(grid_sp, 0)),
|
||||
@@ -1237,6 +1263,8 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
ev2_power_w=ev2_w,
|
||||
target_soc_pct=target_soc,
|
||||
deye_physical_mode=pm,
|
||||
export_ban=bool(export_ban),
|
||||
deye_gen_cutoff_enabled=bool(gen_cutoff),
|
||||
effective_sell_price_czk_kwh=sell_f,
|
||||
)
|
||||
|
||||
@@ -1511,7 +1539,7 @@ async def write_inverter_setpoints(
|
||||
|
||||
zero_exp_mode = int(inv.deye_zero_export_mode or 1)
|
||||
selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode
|
||||
solar_sell = 1
|
||||
solar_sell = 0 if (setpoints_now.export_ban and deye_mode != "SELL") else 1
|
||||
export_limit = export_lim
|
||||
if deye_mode == "SELL" and grid_w < 0:
|
||||
export_limit = min(export_lim, max(REG143_SELL_CAP_MIN_W, abs(grid_w)))
|
||||
@@ -1595,6 +1623,35 @@ async def write_inverter_setpoints(
|
||||
]
|
||||
)
|
||||
|
||||
if inv.deye_gen_microinverter_cutoff_enabled:
|
||||
want_cutoff = bool(setpoints_now.deye_gen_cutoff_enabled) and deye_mode != "SELL"
|
||||
target_bits = (
|
||||
REG179_MI_EXPORT_DISABLE if want_cutoff else REG179_MI_EXPORT_ENABLE
|
||||
)
|
||||
try:
|
||||
mb179 = await get_modbus_client(inv.host, inv.port)
|
||||
r179 = await mb179.read_holding_registers(179, 1, unit)
|
||||
if r179 and len(r179) >= 1:
|
||||
current_179 = int(r179[0])
|
||||
new_179 = (current_179 & ~REG179_MI_EXPORT_MASK) | int(target_bits)
|
||||
registers.append((179, "control_board_special_1", new_179))
|
||||
logger.info(
|
||||
"[control] %s: reg179 MI cutoff %s (old=%s new=%s mask=0x%04X)",
|
||||
inv.code,
|
||||
"ON" if want_cutoff else "OFF",
|
||||
current_179,
|
||||
new_179,
|
||||
REG179_MI_EXPORT_MASK,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[control] %s: reg179 read returned %s values, skip cutoff write",
|
||||
inv.code,
|
||||
len(r179) if r179 is not None else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[control] %s: reg179 cutoff RMW failed: %s", inv.code, e)
|
||||
|
||||
logger.info(
|
||||
"[control] %s: deye_mode=%s charge=%sA discharge=%sA "
|
||||
"reg142=%s reg145=%s export=%sW "
|
||||
|
||||
@@ -187,6 +187,9 @@ class DispatchResult:
|
||||
#: Explicitní fyzický režim Deye pro control exporter (PASSIVE / SELL / CHARGE).
|
||||
#: Cíl: odstranit heuristiky z exporteru a nést záměr přímo v plánu.
|
||||
deye_physical_mode: str
|
||||
#: True = v daném slotu odpojit GEN port (MI export cutoff) přes reg 179 bits0–1.
|
||||
#: None = lokalita tuto funkci nemá / nepoužívá.
|
||||
deye_gen_cutoff_enabled: bool | None
|
||||
ev1_setpoint_w: Optional[int]
|
||||
ev2_setpoint_w: Optional[int]
|
||||
ev1_via_bat_w: int
|
||||
@@ -346,6 +349,14 @@ def solve_dispatch(
|
||||
hp = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)]
|
||||
soc_deficit_24h = pulp.LpVariable("soc_deficit_24h", 0, battery.usable_capacity_wh)
|
||||
|
||||
# GEN port cut-off (BA81): binární proměnná pouze pokud je feature povolená v konfiguraci site/invertoru.
|
||||
gen_cutoff_enabled = bool(getattr(grid, "deye_gen_microinverter_cutoff_enabled", False))
|
||||
z_gen_cutoff = (
|
||||
[pulp.LpVariable(f"z_gen_cutoff_{t}", cat=pulp.LpBinary) for t in range(T)]
|
||||
if gen_cutoff_enabled
|
||||
else None
|
||||
)
|
||||
|
||||
# EV proměnné per vozidlo
|
||||
ev_direct = [[pulp.LpVariable(f"evd_{e}_{t}", 0,
|
||||
min(vehicles[e].max_charge_power_w, grid.max_import_power_w))
|
||||
@@ -391,8 +402,13 @@ def solve_dispatch(
|
||||
ev_total_t = pulp.lpSum(ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV))
|
||||
|
||||
# Energetická bilance
|
||||
pv_b_effective = (
|
||||
float(s.pv_b_forecast_w) * (1 - z_gen_cutoff[t])
|
||||
if z_gen_cutoff is not None
|
||||
else float(s.pv_b_forecast_w)
|
||||
)
|
||||
prob += (
|
||||
pv_a_net + s.pv_b_forecast_w + gi[t] + bd[t]
|
||||
pv_a_net + pv_b_effective + gi[t] + bd[t]
|
||||
== s.load_baseline_w + ev_total_t + hp[t] + bc[t] + ge[t]
|
||||
)
|
||||
|
||||
@@ -410,6 +426,9 @@ def solve_dispatch(
|
||||
# Záporná prodejní cena → zakázat export
|
||||
if s.sell_price < 0:
|
||||
prob += ge[t] == 0
|
||||
# GEN cut-off používáme jen jako nástroj pro BLOCK_EXPORT (sell < 0).
|
||||
if z_gen_cutoff is not None and s.sell_price >= 0:
|
||||
prob += z_gen_cutoff[t] == 0
|
||||
|
||||
# Záporná nákupní cena → cap import (baseline domu + akumulace + řízené zátěže)
|
||||
if s.buy_price < 0:
|
||||
@@ -552,6 +571,10 @@ def solve_dispatch(
|
||||
elif batt_w > 0 and grid_w > 0:
|
||||
deye_mode = "CHARGE"
|
||||
|
||||
deye_gen_cutoff = None
|
||||
if z_gen_cutoff is not None:
|
||||
deye_gen_cutoff = bool(round(float(pulp.value(z_gen_cutoff[t]) or 0)))
|
||||
|
||||
cost = (
|
||||
pulp.value(gi[t]) * slots[t].buy_price * INTERVAL_H / 1000
|
||||
- pulp.value(ge[t]) * slots[t].sell_price * INTERVAL_H / 1000
|
||||
@@ -563,6 +586,7 @@ def solve_dispatch(
|
||||
battery_soc_target = soc_pct,
|
||||
grid_setpoint_w = grid_w,
|
||||
deye_physical_mode = deye_mode,
|
||||
deye_gen_cutoff_enabled = deye_gen_cutoff,
|
||||
ev1_setpoint_w = round(pulp.value(ev_direct[0][t]) + pulp.value(ev_via_bat[0][t]))
|
||||
if slots[t].ev1_connected else None,
|
||||
ev2_setpoint_w = round(pulp.value(ev_direct[1][t]) + pulp.value(ev_via_bat[1][t]))
|
||||
@@ -847,6 +871,7 @@ async def _load_site_context(site_id: int, db):
|
||||
grid = SimpleNamespace(
|
||||
max_import_power_w=int(g["max_import_power_w"]),
|
||||
max_export_power_w=int(g["max_export_power_w"]),
|
||||
deye_gen_microinverter_cutoff_enabled=bool(g.get("deye_gen_microinverter_cutoff_enabled") or False),
|
||||
)
|
||||
|
||||
vehicles: list[SimpleNamespace] = []
|
||||
@@ -995,6 +1020,7 @@ async def _save_planning_run(
|
||||
"battery_soc_target_pct": r.battery_soc_target,
|
||||
"grid_setpoint_w": r.grid_setpoint_w,
|
||||
"deye_physical_mode": r.deye_physical_mode,
|
||||
"deye_gen_cutoff_enabled": r.deye_gen_cutoff_enabled,
|
||||
"ev1_setpoint_w": r.ev1_setpoint_w,
|
||||
"ev2_setpoint_w": r.ev2_setpoint_w,
|
||||
"ev1_via_bat_w": r.ev1_via_bat_w,
|
||||
|
||||
@@ -9,6 +9,7 @@ from services.control.exporter_monolith import (
|
||||
ControlSetpoints,
|
||||
InverterConfig,
|
||||
_deye_reg178_verify_with_double_read,
|
||||
_deye_reg179_verify_match,
|
||||
_deye_tou_params,
|
||||
_deye_tou_power_verify_match,
|
||||
_deye_zero_export_amps_for_passive,
|
||||
@@ -54,6 +55,11 @@ class ModbusVerifyPolicyTests(unittest.TestCase):
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(v, 48)
|
||||
|
||||
def test_reg179_verify_match_only_bits_0_1(self) -> None:
|
||||
# expected=3 (enable), actual can have other bits set but bits0-1 must match
|
||||
self.assertTrue(_deye_reg179_verify_match(3, 0xFFFB))
|
||||
self.assertFalse(_deye_reg179_verify_match(3, 0xFFFA)) # bits0-1=2
|
||||
|
||||
def test_reg178_not_critical_for_self_sustain(self) -> None:
|
||||
self.assertFalse(deye_reg_triggers_self_sustain_after_verify_exhaust(178))
|
||||
|
||||
@@ -95,6 +101,21 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
|
||||
def test_export_ban_does_not_change_deye_mode(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
export_ban=True,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
|
||||
def test_pv_led_export_with_small_battery_is_sell(self) -> None:
|
||||
"""Obě záporné → SELL (bez porovnání |bat| vs |grid|)."""
|
||||
sp = ControlSetpoints(
|
||||
|
||||
Reference in New Issue
Block a user