fix cutoff a grid peak shaving register
This commit is contained in:
@@ -36,7 +36,8 @@ BATT_VOLTAGE_V = 51.2
|
||||
# Reg 143 ve SELL: min(|grid_setpoint_w|, …) nesmí klesnout pod tuto podlahu (W) — kvůli chování firmware, ne mapování režimu.
|
||||
REG143_SELL_CAP_MIN_W = 200
|
||||
|
||||
# Reg 178 – pevné hodnoty (bit4–5); bez read-modify-write (kolize s Loxone / transaction ID)
|
||||
# Reg 178 – bitové pole: používáme bity 4–5 (peak shaving switch) a bity 0–1 (MI export cutoff).
|
||||
# Ostatní bity zachovat → read-modify-write.
|
||||
REG178_SELL = 0b00100000 # 32, grid peak shaving disable
|
||||
REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
|
||||
# TOU reg 166+ ve PASSIVE při prioritě baterie: signál střídači „využij celý dostupný rozsah“,
|
||||
@@ -44,14 +45,11 @@ REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
|
||||
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)
|
||||
# Reg 178 bits 0–1: MI export cutoff (AC coupling / GEN).
|
||||
REG178_MI_EXPORT_MASK = 0x0003
|
||||
REG178_MI_EXPORT_DISABLE = 0b10
|
||||
REG178_MI_EXPORT_ENABLE = 0b11
|
||||
REG178_VERIFY_MASK_COMBINED = REG178_VERIFY_MASK | REG178_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.
|
||||
@@ -63,7 +61,9 @@ DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350
|
||||
|
||||
|
||||
def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool:
|
||||
return (int(expected_i) & REG178_VERIFY_MASK) == (int(actual_i) & REG178_VERIFY_MASK)
|
||||
return (int(expected_i) & REG178_VERIFY_MASK_COMBINED) == (
|
||||
int(actual_i) & REG178_VERIFY_MASK_COMBINED
|
||||
)
|
||||
|
||||
|
||||
def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool:
|
||||
@@ -121,8 +121,7 @@ DEYE_REGISTER_NAMES: dict[int, str] = {
|
||||
142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)",
|
||||
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)",
|
||||
178: "control_board_special_1 (bits0-1: MI export cutoff disable=2 enable=3; bits4-5 peak shaving 32/48)",
|
||||
148: "time_point_1_time",
|
||||
149: "time_point_2_time",
|
||||
154: "time_point_1_power_w",
|
||||
@@ -338,16 +337,6 @@ def _drop_registers_matching_last_verified(
|
||||
if int(reg) == 178 and _deye_reg178_verify_match(int(val), int(lv)):
|
||||
skipped.append(int(reg))
|
||||
continue
|
||||
# reg179: porovnáváme jen bits0–1 maskou 0x0003 (masked RMW zachovává ostatní bity).
|
||||
if int(reg) == 179 and _deye_reg179_verify_match(int(val), int(lv)):
|
||||
# GEN cutoff (BA81): chceme na zařízení dostat "clean" hodnotu 2/3.
|
||||
# Pokud minulý verified stav obsahuje jiné bity (např. 0xFFFE/0xFFFF),
|
||||
# maska sice sedí, ale firmware/UI nemusí cutoff aplikovat správně.
|
||||
# Proto reg179 skipneme jen tehdy, když je poslední verified hodnota už
|
||||
# skutečně 2 nebo 3 (tj. clean value), ne jen maskově ekvivalentní.
|
||||
if int(lv) in (REG179_MI_EXPORT_DISABLE, REG179_MI_EXPORT_ENABLE):
|
||||
skipped.append(int(reg))
|
||||
continue
|
||||
if int(lv) == int(val):
|
||||
skipped.append(int(reg))
|
||||
continue
|
||||
@@ -370,7 +359,7 @@ class ControlSetpoints:
|
||||
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).
|
||||
#: True = odpojit GEN port (MI export cutoff) v tomto slotu dle plánu (reg 178 bits0-1, 0-based).
|
||||
#: 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
|
||||
@@ -833,8 +822,6 @@ 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)
|
||||
|
||||
@@ -861,7 +848,7 @@ async def verify_modbus_commands(
|
||||
(
|
||||
" (reg178 mask 0x%04X)" % REG178_VERIFY_MASK
|
||||
if reg == 178
|
||||
else (" (reg179 mask 0x%04X)" % REG179_MI_EXPORT_MASK if reg == 179 else "")
|
||||
else ""
|
||||
),
|
||||
)
|
||||
row_ac = await db.fetchrow(
|
||||
@@ -1643,42 +1630,42 @@ async def write_inverter_setpoints(
|
||||
(142, "limit_control", selling_mode),
|
||||
(143, "", export_limit),
|
||||
(145, "solar_sell", solar_sell),
|
||||
(178, "grid_peak_shaving_switch", reg178_val),
|
||||
]
|
||||
)
|
||||
|
||||
if inv.deye_gen_microinverter_cutoff_enabled:
|
||||
# Deye UI semantics: "MI export cutoff ENABLE" means export to grid is blocked (GEN effectively cut off).
|
||||
# Therefore: want_cutoff=True -> ENABLE (3), want_cutoff=False -> DISABLE (2).
|
||||
want_cutoff = bool(setpoints_now.deye_gen_cutoff_enabled) and deye_mode != "SELL"
|
||||
target_bits = (
|
||||
REG179_MI_EXPORT_ENABLE if want_cutoff else REG179_MI_EXPORT_DISABLE
|
||||
# Reg 178: bitové pole. Nastavujeme bits4–5 (peak shaving) vždy; bits0–1 (MI export cutoff) jen pokud feature.
|
||||
# Ostatní bity musí zůstat zachované → read-modify-write.
|
||||
try:
|
||||
mb178 = await get_modbus_client(inv.host, inv.port)
|
||||
r178 = await mb178.read_holding_registers(178, 1, unit_id)
|
||||
if not r178 or len(r178) < 1:
|
||||
raise OSError(f"reg178 read returned {len(r178) if r178 is not None else None} values")
|
||||
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"
|
||||
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)
|
||||
|
||||
new_178 = (
|
||||
(int(current_178) & ~int(REG178_VERIFY_MASK_COMBINED))
|
||||
| int(peak_bits)
|
||||
| int(mi_bits)
|
||||
)
|
||||
try:
|
||||
mb179 = await get_modbus_client(inv.host, inv.port)
|
||||
r179 = await mb179.read_holding_registers(179, 1, unit_id)
|
||||
if r179 and len(r179) >= 1:
|
||||
current_179 = int(r179[0])
|
||||
# Deye firmware/UI u některých instalací neinterpretuje jen bits0–1 maskou,
|
||||
# ale očekává přímo hodnotu 2/3. Proto zapisujeme "clean" 2/3 (bez RMW),
|
||||
# aby se cutoff skutečně projevil i v UI.
|
||||
registers.append((179, "control_board_special_1", int(target_bits)))
|
||||
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,
|
||||
int(target_bits),
|
||||
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)
|
||||
registers.append((178, "control_board_special_1", int(new_178)))
|
||||
logger.info(
|
||||
"[control] %s: reg178 (control_board_special_1) old=%s new=%s peak_bits=0x%04X mi_bits=%s",
|
||||
inv.code,
|
||||
current_178,
|
||||
new_178,
|
||||
int(peak_bits),
|
||||
int(mi_bits),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[control] %s: reg178 RMW failed (skip reg178 write): %s", inv.code, e)
|
||||
|
||||
logger.info(
|
||||
"[control] %s: deye_mode=%s charge=%sA discharge=%sA "
|
||||
@@ -1803,13 +1790,11 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict
|
||||
b108 = await mb.read_holding_registers(108, 2)
|
||||
b141 = await mb.read_holding_registers(141, 5)
|
||||
r178 = await mb.read_holding_registers(178, 1)
|
||||
r179 = await mb.read_holding_registers(179, 1)
|
||||
r191 = await mb.read_holding_registers(191, 1)
|
||||
r108, r109 = b108[0], b108[1]
|
||||
r141, r142, r143 = b141[0], b141[1], b141[2]
|
||||
r145 = b141[4]
|
||||
r178 = r178[0]
|
||||
r179 = r179[0]
|
||||
r191 = r191[0]
|
||||
except Exception:
|
||||
logger.exception("read_deye_registers_live site=%s failed", site_id)
|
||||
@@ -1823,9 +1808,9 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict
|
||||
"reg143_export_limit_w": int(r143),
|
||||
"reg145_solar_sell": int(r145),
|
||||
"reg178_peak_shaving_switch": int(r178),
|
||||
"reg179_control_board_special_1": int(r179),
|
||||
"reg179_mi_export_cutoff_bits": int(r179) & int(REG179_MI_EXPORT_MASK),
|
||||
"reg179_mi_export_cutoff_is_on": (int(r179) & int(REG179_MI_EXPORT_MASK)) == int(REG179_MI_EXPORT_ENABLE),
|
||||
"reg178_control_board_special_1": int(r178),
|
||||
"reg178_mi_export_cutoff_bits": int(r178) & int(REG178_MI_EXPORT_MASK),
|
||||
"reg178_mi_export_cutoff_is_on": (int(r178) & int(REG178_MI_EXPORT_MASK)) == int(REG178_MI_EXPORT_ENABLE),
|
||||
"reg191_peak_shaving_w": int(r191),
|
||||
"read_at": read_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ 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.
|
||||
#: True = v daném slotu odpojit GEN port (MI export cutoff) přes reg 178 bits0–1 (0-based; v UI často jako "register 179").
|
||||
#: None = lokalita tuto funkci nemá / nepoužívá.
|
||||
deye_gen_cutoff_enabled: bool | None
|
||||
ev1_setpoint_w: Optional[int]
|
||||
@@ -689,7 +689,7 @@ def solve_dispatch(
|
||||
prob += bd[t] <= pulp.lpSum(ev_via_bat[e][t] for e in range(EV))
|
||||
# BA81 (GEN port microinverters): pokud máme k dispozici GEN cut-off, držíme skutečný
|
||||
# BLOCK_EXPORT jako hard constraint: export do sítě v okně se záporným prodejem je zakázaný.
|
||||
# Přebytek pak řeší curtail PV A / nabíjení / případně GEN cut-off (reg 179).
|
||||
# Přebytek pak řeší curtail PV A / nabíjení / případně GEN cut-off (reg 178 bits0–1).
|
||||
if z_gen_cutoff is not None:
|
||||
prob += ge[t] == 0
|
||||
|
||||
|
||||
@@ -28,9 +28,11 @@ DEYE_REG_GRID_EXPORT_TOTAL_LO = 524
|
||||
DEYE_REG_GRID_EXPORT_TOTAL_HI = 525
|
||||
DEYE_REG_PV1_POWER = 672
|
||||
DEYE_REG_PV2_POWER = 673
|
||||
# Solar sell (0 = přebytek řiditelné FVE nesmí do sítě) a GEN/MI cut-off (bits0–1 == 3 → cut-off ON); viz modbus-registers.md
|
||||
# Solar sell (0 = přebytek řiditelné FVE nesmí do sítě) a GEN/MI cut-off (reg178 bits0–1 == 3 → cut-off ON).
|
||||
# Pozn.: v některých manuálech/UI se uvádí "register 179" (1-based), ale Modbus adresa je 178 (0-based).
|
||||
# Viz modbus-registers.md.
|
||||
DEYE_REG_SOLAR_SELL = 145
|
||||
DEYE_REG_CONTROL_BOARD_SPECIAL1 = 179
|
||||
DEYE_REG_CONTROL_BOARD_SPECIAL1 = 178
|
||||
|
||||
|
||||
def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
|
||||
|
||||
@@ -9,7 +9,6 @@ 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,
|
||||
@@ -55,11 +54,6 @@ 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))
|
||||
|
||||
|
||||
@@ -20,27 +20,3 @@ def test_drop_registers_keeps_reg178_when_mask_differs():
|
||||
assert out == registers
|
||||
assert skipped == []
|
||||
|
||||
|
||||
def test_drop_registers_keeps_reg179_when_mask_matches_but_not_clean():
|
||||
registers = [(179, "control_board_special_1", 2)] # want cutoff ON (clean value)
|
||||
last_verified = {179: 0x1236} # bits0–1 still == 2, but not a clean 2/3 value
|
||||
out, skipped = _drop_registers_matching_last_verified(registers, last_verified)
|
||||
assert out == registers
|
||||
assert skipped == []
|
||||
|
||||
|
||||
def test_drop_registers_skips_reg179_when_clean_value_matches():
|
||||
registers = [(179, "control_board_special_1", 2)] # want cutoff ON (clean value)
|
||||
last_verified = {179: 2} # already clean cutoff ON
|
||||
out, skipped = _drop_registers_matching_last_verified(registers, last_verified)
|
||||
assert out == []
|
||||
assert skipped == [179]
|
||||
|
||||
|
||||
def test_drop_registers_keeps_reg179_when_mask_differs():
|
||||
registers = [(179, "control_board_special_1", 2)] # want cutoff ON
|
||||
last_verified = {179: 0x1237} # ...0111b => bits0–1 == 3 (cutoff OFF)
|
||||
out, skipped = _drop_registers_matching_last_verified(registers, last_verified)
|
||||
assert out == registers
|
||||
assert skipped == []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user