From b8515f30dfaef6ebdbbadb90f687512975dbe2ac Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Mon, 20 Apr 2026 10:41:10 +0200 Subject: [PATCH] implmemtace cuttoff genportu --- backend/services/control/exporter_monolith.py | 61 ++++++++++++++++++- backend/services/planning_engine.py | 28 ++++++++- backend/tests/test_control_exporter_tou.py | 21 +++++++ ...54__deye_gen_microinverter_cutoff_flag.sql | 9 +++ ...1_enable_deye_gen_microinverter_cutoff.sql | 10 +++ ...nning_interval_deye_gen_cutoff_enabled.sql | 10 +++ db/routines/R__037_fn_planning_run_commit.sql | 4 ++ .../R__039_fn_planning_site_context.sql | 12 +++- docs/04-modules/modbus-registers.md | 7 +++ docs/04-modules/operating-modes.md | 10 +++ docs/04-modules/planning.md | 40 ++++++++++++ docs/05-todo.md | 1 + docs/new-site-setup-template.md | 6 ++ frontend/src/pages/Planning.tsx | 49 ++++++++++++++- frontend/src/types/plan.ts | 2 + 15 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 db/migration/V054__deye_gen_microinverter_cutoff_flag.sql create mode 100644 db/migration/V055__ba81_enable_deye_gen_microinverter_cutoff.sql create mode 100644 db/migration/V056__planning_interval_deye_gen_cutoff_enabled.sql diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 7291762..357f32a 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -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 " diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 1aadcb2..6f88a91 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -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, diff --git a/backend/tests/test_control_exporter_tou.py b/backend/tests/test_control_exporter_tou.py index 5e2228e..ef34b3f 100644 --- a/backend/tests/test_control_exporter_tou.py +++ b/backend/tests/test_control_exporter_tou.py @@ -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( diff --git a/db/migration/V054__deye_gen_microinverter_cutoff_flag.sql b/db/migration/V054__deye_gen_microinverter_cutoff_flag.sql new file mode 100644 index 0000000..96aff4d --- /dev/null +++ b/db/migration/V054__deye_gen_microinverter_cutoff_flag.sql @@ -0,0 +1,9 @@ +-- Feature flag: řízení microinverter export cutoff přes Deye Modbus (GEN / AC coupling). +-- Použito pro instalace typu BA81, kde při BLOCK_EXPORT (sell_price < 0) musíme odpojit / zakázat export z MI na GEN portu. + +alter table ems.asset_inverter + add column if not exists deye_gen_microinverter_cutoff_enabled boolean not null default false; + +comment on column ems.asset_inverter.deye_gen_microinverter_cutoff_enabled is +'Pokud true, EMS při BLOCK_EXPORT přepíná Deye reg 179 (Control board special 1) bits0–1 pro MI export cutoff na GEN portu.'; + diff --git a/db/migration/V055__ba81_enable_deye_gen_microinverter_cutoff.sql b/db/migration/V055__ba81_enable_deye_gen_microinverter_cutoff.sql new file mode 100644 index 0000000..9a34204 --- /dev/null +++ b/db/migration/V055__ba81_enable_deye_gen_microinverter_cutoff.sql @@ -0,0 +1,10 @@ +-- BA81: při BLOCK_EXPORT (sell_price < 0) je potřeba aktivovat „MI export to Grid cutoff“. +-- EMS to řeší přes Deye reg 179 bits 0–1 (masked RMW) pouze když je tento feature flag zapnutý. + +update ems.asset_inverter ai +set deye_gen_microinverter_cutoff_enabled = true +from ems.site s +where s.id = ai.site_id + and s.code = 'BA81' + and ai.code = 'deye-main'; + diff --git a/db/migration/V056__planning_interval_deye_gen_cutoff_enabled.sql b/db/migration/V056__planning_interval_deye_gen_cutoff_enabled.sql new file mode 100644 index 0000000..f21a0b5 --- /dev/null +++ b/db/migration/V056__planning_interval_deye_gen_cutoff_enabled.sql @@ -0,0 +1,10 @@ +-- Explicitní flag pro řízení odpojení GEN portu (mikroinvertory / AC coupling) v daném slotu. +-- Použito hlavně u BA81: při záporné výkupní ceně a očekávaném přebytku nechceme exportovat, takže solver může zvolit cut-off. + +alter table ems.planning_interval + add column if not exists deye_gen_cutoff_enabled boolean; + +comment on column ems.planning_interval.deye_gen_cutoff_enabled is +'True = v daném slotu odpojit GEN port (MI export cutoff) přes Deye reg 179 bits0–1. +NULL = lokalita / instalace GEN cut-off nepoužívá nebo flag není relevantní.'; + diff --git a/db/routines/R__037_fn_planning_run_commit.sql b/db/routines/R__037_fn_planning_run_commit.sql index 606ce55..ddaa1e7 100644 --- a/db/routines/R__037_fn_planning_run_commit.sql +++ b/db/routines/R__037_fn_planning_run_commit.sql @@ -51,6 +51,7 @@ begin battery_setpoint_w, battery_soc_target_pct, grid_setpoint_w, deye_physical_mode, + deye_gen_cutoff_enabled, ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w, heat_pump_enabled, heat_pump_setpoint_w, pv_a_curtailed_w, expected_cost_czk, @@ -66,6 +67,7 @@ begin (r.value->>'battery_soc_target_pct')::numeric, (r.value->>'grid_setpoint_w')::int, nullif(trim(r.value->>'deye_physical_mode'), ''), + (r.value->>'deye_gen_cutoff_enabled')::boolean, nullif(r.value->>'ev1_setpoint_w', '')::int, nullif(r.value->>'ev2_setpoint_w', '')::int, coalesce((r.value->>'ev1_via_bat_w')::int, 0), @@ -89,6 +91,7 @@ begin battery_setpoint_w, battery_soc_target_pct, grid_setpoint_w, deye_physical_mode, + deye_gen_cutoff_enabled, ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w, heat_pump_enabled, heat_pump_setpoint_w, pv_a_curtailed_w, expected_cost_czk, @@ -101,6 +104,7 @@ begin (r.value->>'battery_soc_target_pct')::numeric, (r.value->>'grid_setpoint_w')::int, nullif(trim(r.value->>'deye_physical_mode'), ''), + (r.value->>'deye_gen_cutoff_enabled')::boolean, nullif(r.value->>'ev1_setpoint_w', '')::int, nullif(r.value->>'ev2_setpoint_w', '')::int, coalesce((r.value->>'ev1_via_bat_w')::int, 0), diff --git a/db/routines/R__039_fn_planning_site_context.sql b/db/routines/R__039_fn_planning_site_context.sql index 996e7af..589a991 100644 --- a/db/routines/R__039_fn_planning_site_context.sql +++ b/db/routines/R__039_fn_planning_site_context.sql @@ -102,7 +102,17 @@ begin select jsonb_build_object( 'max_import_power_w', sgc.max_import_power_w, - 'max_export_power_w', sgc.max_export_power_w + 'max_export_power_w', sgc.max_export_power_w, + 'deye_gen_microinverter_cutoff_enabled', coalesce( + ( + select ai.deye_gen_microinverter_cutoff_enabled + from ems.asset_inverter ai + where ai.site_id = p_site_id + and ai.code = 'deye-main' + limit 1 + ), + false + ) ) into v_grid from ems.site_grid_connection sgc diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 482ccd4..f1870b3 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -21,6 +21,7 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi | 145 | Solar sell | 0/1 | — | **0** = disabled (přebytek FVE na **straně měniče** se nesmí vést do sítě — curtailment vůči síti), **1** = enabled. Platí jen pro **FVE pod kontrolou Deye** (`controllable = true`); druhá pole (např. **pv-b** u home-01) EMS tímto registerem neřídí. EMS dnes **vždy zapisuje 1**; při 108 = 0 a 145 = 1 přebytky z řiditelného stringu typicky tečou do sítě (viz pass-through níže). | | 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w` | | 178 | Grid peak shaving switch | bitmask | — | EMS zapisuje **pevnou** hodnotu (bez read-modify-write kvůli kolizím s paralelním čtením z Loxone): **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE** a **CHARGE**. | +| 179 | Control board special 1 | bitmask | — | **BA81:** bits **0–1** ovládají „MI export to Grid cutoff“ (AC coupling / GEN): **2** (`10b`) = disable (cutoff ON), **3** (`11b`) = enable. EMS zapisuje **masked RMW** (zachová ostatní bity) jen pokud `asset_inverter.deye_gen_microinverter_cutoff_enabled = true`. | | 190 | GEN peak shaving | 0–16000 | 1 W | Peak shaving na GEN portu | | 191 | Grid peak shaving power | 0–16000 | 1 W | **EMS NEZAPISUJE** – nastavit **manuálně v SolarmanApp**. Hodnota určuje výkon peak shavingu v **W**. | @@ -58,6 +59,12 @@ Režim **CHARGE_CHEAP** nastaví oba setpointy na stejný kladný výkon (min. 1 **PASSIVE (ZERO):** reg. **108/109** podle `_deye_zero_export_amps_for_passive` — při exportu v plánu bez vybíjení je **108 = 0** (přetok FVE); při importu bez nabíjení je **109 = 0** (držet baterii). Jinak oba max (AUTO). Detail: `operating-modes.md`. +### BA81: GEN port cut-off (reg 179) z plánu + +Pro instalace s AC coupling na GEN portu (mikroinvertory) může solver uložit do `planning_interval` flag **`deye_gen_cutoff_enabled`**.\n +- `true` → exporter nastaví reg **179** bits0–1 na **2** (`10b`, disable = cut-off ON)\n+- `false` → exporter nastaví bits0–1 na **3** (`11b`, enable = cut-off OFF)\n+\n+Zápis je **masked read-modify-write** (zachová ostatní bity reg. 179). Ověření v journalu (`verify_modbus_commands`) porovnává jen bits0–1 maskou `0x0003`.\n+ +**Pozn.:** Flag se v solveru vůbec nevytváří ani neukládá tam, kde není povolen feature `asset_inverter.deye_gen_microinverter_cutoff_enabled` – takové lokality ho nemají ani v UI. + ### Provozní režim EMS SELF_SUSTAIN Z hlediska `get_deye_mode` je **SELF_SUSTAIN** stále **PASSIVE** (`battery_w` z LP je `None`). Exportér ale nastaví `ControlSetpoints.self_sustain_local_use=True` a v `write_inverter_setpoints`: diff --git a/docs/04-modules/operating-modes.md b/docs/04-modules/operating-modes.md index 831310b..ec14a81 100644 --- a/docs/04-modules/operating-modes.md +++ b/docs/04-modules/operating-modes.md @@ -65,6 +65,16 @@ Nabíjení ze sítě s vysokým cílovým SoC v TOU řeší větev **CHARGE** (g **Implementace dnes:** exporter vždy zapisuje **145 = 1** (solar sell enabled). Tvrdé vypnutí přebytku řiditelného FVE do sítě přes **145 = 0** z politik (`no_export`, `BLOCK_EXPORT`, …) je v plánu — viz **`docs/05-todo.md`** (sekce *Deye řízení – rozšíření*). +**Implementace (BLOCK_EXPORT):** při `effective_sell_price < 0` (slot z plánu) EMS drží fyzicky stále **PASSIVE**, ale zapne **zákaz exportu přebytků** pro řiditelnou FVE: +- **reg 145 = 0** (solar sell disabled) mimo SELL +- **BA81:** navíc přes **reg 179** (bits0–1) aktivuje „MI export to Grid cutoff“ pro mikroinvertory na GEN portu (jen pokud je `asset_inverter.deye_gen_microinverter_cutoff_enabled = true`). +Týká se jen výroby, kterou Deye umí ovlivnit; **pv-b / ongrid GEN** u home-01 tímto neomezíš. + +#### PV1/PV2 vs. GEN port (důležité pro BLOCK_EXPORT) + +- **PV1/PV2 (hlavní stringy na DC vstupu Deye)**: výkon je v režimu zero-export **řiditelný** (střídač umí výrobu stáhnout až k nule, pokud není odběr a baterie už nemůže nabíjet). Při BLOCK_EXPORT tedy dává smysl „zakázat export“ přes **reg 145 = 0** – Deye zamezí přetokům z těchto stringů.\n+- **GEN port (AC coupling / mikroinvertory / ongrid GEN)**: výkon **nelze plynule omezovat**. Pole vyrábí „co dá slunce“ a pokud ho **nespotřebuje dům + EV/TČ + baterie**, přebytek fyzicky teče do sítě.\n+ - U instalací typu **BA81** je proto k dispozici jen **tvrdý cut-off** (reg 179 bits0–1).\n+ - U **malé baterie** (např. BA81 ~6 kW max charge a navíc při vysokém SoC ještě méně) může při plném osvitu často nastat přebytek i při BLOCK_EXPORT – a bez cut-off by šel do sítě.\n+ - Naopak při **malém osvitu / velké spotřebě** jsou „každé watty z GEN“ užitečné (jít do domu/baterie) a cut-off by zbytečně zahodil výrobu.\n+ +Z toho plyne: **cut-off GEN portu je smysluplné řídit podle očekávaného přebytku**, ne jen podle „sell < 0“. Detail návrhu implementace je v `docs/04-modules/planning.md` (sekce o GEN portu a export banu). + **SELF_SUSTAIN:** `battery_w = None` ⇒ v `get_deye_mode` jako 0 ⇒ **PASSIVE**; v `write_inverter_setpoints` při `self_sustain_local_use=True` → **108 i 109 = max** (bez variant ZERO výše), reg. **142** dle DB, TOU SOC = **`min_soc_percent`**. **PRESERVE:** `lock_battery` → **108 = 0**, **109 = 0**. ## EMS politiky (nejsou fyzické stavy Deye) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index d021e45..e6281df 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -48,6 +48,10 @@ Solver optimalizuje celý horizont (typicky do konce známých OTE dat, strop z - Při záporné prodejní ceně má nejvyšší prioritu ukládání (baterie → EV → TČ) - Solver nikdy neexportuje výrobu pole B pokud je prodejní cena záporná +> Poznámka: výše platí pro **home-01** (pv-b jako ongrid GEN se zeleným bonusem), kde pole B **nechceme curtailovat**. +> U instalací typu **BA81** je na GEN portu typicky **AC coupling (mikroinvertory)** bez bonusu – výkon nelze plynule škrtit, +> ale lze ho **tvrdě odpojit (cut-off)** přes Deye reg **179** (viz `modbus-registers.md`). To je samostatná logika níže. + ### Export / import limity (home-01) - Max export do sítě: **13.5 kW** (smlouva s distributorem) - Max import ze sítě: dle `site_grid_connection.max_import_power_w` @@ -236,6 +240,42 @@ if sell_price[t] < 0: # (export stejně zakázán výše) a solver automaticky uloží přebytek. ``` +### BA81 / GEN port (mikroinvertory): kdy dává smysl „Grid export cut-off“ + +Kontext (instalace typu BA81): +- **PV1/PV2** (DC stringy na Deye) jsou **řiditelné** – při zákazu exportu je Deye umí stáhnout až k nule. +- **GEN port** (AC coupling / mikroinvertory) **řiditelný výkonově není** – vyrábí „co dá slunce“. +Při `sell_price < 0` tedy nastává problém: +- baterie má **omezený nabíjecí výkon** (např. BA81 cca **6 kW**) a navíc při vysokém SoC má reálně menší „přijímací schopnost“, +- pokud výroba na GEN portu převýší okamžitou spotřebu + možný charge do baterie, zbytek fyzicky teče do sítě (nechtěný export za zápornou cenu). + +Řešení na hardware úrovni: +- **Deye reg 179 bits0–1** („MI export to Grid cutoff“) umožní GEN port **tvrdě odpojit**. + +#### Správné rozhodovací pravidlo (záměr) + +Cut-off nechceme spínat „vždy když sell<0“, protože při zataženu / malé výrobě jsou i malé watty z GEN užitečné. + +Chceme spínat pouze tehdy, když je v daném slotu očekávaný **přebytek z GEN**, který není kam dát: +\[ +pv\_gen\_w \;>\; load\_w \;+\; batt\_charge\_cap\_w \;+\; flexible\_load\_w +\] + +kde: +- `pv_gen_w` ≈ `pv_b_forecast_solver_w` (GEN/mikroinvertory) +- `batt_charge_cap_w` = min(`battery.max_charge_power_w`, \((soc_{max}-soc)_{wh} / 0.25h\)) – tj. výkonově omezené a SoC-headroom omezené +- `flexible_load_w` = plánované EV/TČ setpointy v daném slotu (pokud jsou připojené / povolené) + +#### Doporučená implementace v EMS (aby se to chovalo správně) + +- **Správně (v solveru / plánu)**: řešit cut-off přímo v LP binární proměnnou `z_gen_cutoff[t]` (0/1), která modeluje, zda je GEN port odpojen. + - Efektivní výkon z GEN do bilance: `pv_b_effective[t] = pv_b_forecast_w * (1 - z_gen_cutoff[t])` + - Solver nechá GEN připojený vždy, když je výkon užitečný (sníží import / nabije baterii / pokryje zátěž). + - Při `sell_price < 0` a zároveň hrozícím přebytku (ge je zakázané) solver může zvolit `z_gen_cutoff[t]=1` (cut-off) jako poslední možnost. + - Výstup se ukládá do `planning_interval.deye_gen_cutoff_enabled` (nullable) a exporter pak jen provede reg 179. + +**Scope / bezpečnost:** proměnná i flag existují jen na lokalitách, kde je zapnutý `asset_inverter.deye_gen_microinverter_cutoff_enabled` (tj. kde je GEN port s mikroinvertory reálně zapojen). Jinde se nic neřeší ani nezobrazuje. + ### Záporná nákupní cena – nabíjet ze sítě je výhodné ```python # Pokud buy_price[t] < 0, grid_import[t] je příjem → solver automaticky maximalizuje import. diff --git a/docs/05-todo.md b/docs/05-todo.md index fcb3691..97207be 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -89,6 +89,7 @@ Potřebné pro reálný, stabilní provoz; lze část EMS otestovat bez nich (na | **Reg. 145 (solar sell)** z politiky: při `no_export` / `BLOCK_EXPORT` (a obdobně) zapisovat **145 = 0**, aby šlo tvrdě zakázat přetok **řiditelného** FVE na Deye (`asset_pv_array.controllable = true`); dnes exporter vždy **1**. Vazba na instalaci: `docs/04-modules/operating-modes.md` (ZERO a reg. 145). | `exporter_monolith.write_inverter_setpoints` (+ vstupy z `InverterConfig` / `site_grid_connection`) | programátor | | **Testy reg. 145** vůči journalu (`ems.modbus_command`): očekávaná hodnota při zákazu exportu vs. běžný provoz. | `backend/tests/`; `docs/04-modules/modbus-command-journal.md` | programátor | | **Dvě FVE pole:** UI / provozní poznámka, že **145 = 0** neomezuje **pv-b** (ongrid); celkový export lokality může z pole B dál „unikat“. | `docs/04-modules/operating-modes.md`; `planning.md` (pv_a / pv_b) | majitel + programátor | +| **BA81:** přepínat „MI export to Grid cutoff“ pro GEN port při BLOCK_EXPORT přes **reg 179 bits0–1** (masked RMW) pod feature flagem `asset_inverter.deye_gen_microinverter_cutoff_enabled`. | `exporter_monolith.write_inverter_setpoints`; `docs/04-modules/modbus-registers.md` | programátor | --- diff --git a/docs/new-site-setup-template.md b/docs/new-site-setup-template.md index db01eee..f131686 100644 --- a/docs/new-site-setup-template.md +++ b/docs/new-site-setup-template.md @@ -43,6 +43,12 @@ Použij jako checklist při přidávání dalšího objektu do EMS. Odkazy: dato | ☐ | `ems.asset_heat_pump` | Volitelné; TČ parametry, TUV | | ☐ | `ems.asset_vehicle` | Až **dva** záznamy na site (EV1/EV2 sloty ve solveru), pokud řešíš nabíjení | +### Poznámka: BLOCK_EXPORT a instalace s mikroinvertory na GEN portu (BA81 typ) + +Pokud má lokalita **mikroinvertory / AC coupling na GEN portu** a potřebuješ při **záporné výkupní ceně** (BLOCK_EXPORT) **tvrdě zakázat export**, nestačí jen `reg 145 = 0` (solar sell) – ten se týká primárně řiditelného PV za Deye. + +- Zapni feature flag na `ems.asset_inverter` (řádek `deye-main`): **`deye_gen_microinverter_cutoff_enabled = true`**.\n+- EMS pak při `effective_sell_price < 0` přepíná **Deye reg 179 bits 0–1** („MI export to Grid cutoff“) masked RMW.\n+- Detail registrů: `docs/04-modules/modbus-registers.md` (reg 145 a 179) a `docs/04-modules/operating-modes.md` (BLOCK_EXPORT). + --- ## 5. Provoz backendu (joby) diff --git a/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx index c835957..9de5098 100644 --- a/frontend/src/pages/Planning.tsx +++ b/frontend/src/pages/Planning.tsx @@ -158,6 +158,10 @@ function buildPlanTableRows(visibleSlots: PlanningIntervalDto[]): PlanTableRow[] return rows } +function hasGenCutoff(slots: PlanningIntervalDto[]): boolean { + return slots.some((s) => s.deye_gen_cutoff_enabled != null) +} + function horizonToggleClass(active: boolean): string { return active ? 'border-cyan-600 bg-cyan-950/50 text-cyan-100' @@ -339,6 +343,27 @@ function deyeModeBadge(i: PlanningIntervalDto): { label: string; klass: string; } } +function genCutoffBadge(i: PlanningIntervalDto): { show: boolean; label: string; klass: string; title: string } { + // Nevizualizovat na site bez GEN cut-off (null/undefined ve všech slotech). + if (i.deye_gen_cutoff_enabled == null) { + return { show: false, label: '', klass: '', title: '' } + } + if (i.deye_gen_cutoff_enabled === true) { + return { + show: true, + label: 'GEN CUT', + klass: 'bg-red-500/15 text-red-200 ring-1 ring-red-500/35', + title: 'GEN port cut-off (BA81): reg179 bits0-1=2 (MI export cutoff ON)', + } + } + return { + show: true, + label: 'GEN OK', + klass: 'bg-slate-700/30 text-slate-400 ring-1 ring-slate-600/30', + title: 'GEN port připojen (cut-off OFF)', + } +} + function tableRowClass( i: PlanningIntervalDto, selected: boolean, @@ -658,6 +683,7 @@ export default function Planning() { }, [chartMergedSlots, nowMs, chartHorizonH, slotFloorMs]) const planTableRows = useMemo(() => buildPlanTableRows(visibleSlots), [visibleSlots]) + const showGenCut = useMemo(() => hasGenCutoff(visibleSlots), [visibleSlots]) const xTicks = useMemo(() => { if (!chartIntervals.length) return undefined @@ -1079,6 +1105,11 @@ export default function Planning() { Deye setpoint + {showGenCut ? ( + + GEN + + ) : null} SoC % FVE W Dům W @@ -1105,7 +1136,7 @@ export default function Planning() { key={`sum-${row.dayKey}`} className="border-b border-slate-700/90 bg-slate-800/70 text-slate-200" > - + {row.dateLabel} · FVE celkem{' '} @@ -1165,6 +1196,22 @@ export default function Planning() { + {showGenCut ? ( + + {(() => { + const g = genCutoffBadge(i) + if (!g.show) return + return ( + + {g.label} + + ) + })()} + + ) : null} {i.battery_soc_target_pct != null ? `${i.battery_soc_target_pct.toFixed(1)}` diff --git a/frontend/src/types/plan.ts b/frontend/src/types/plan.ts index 3d17b6a..157624e 100644 --- a/frontend/src/types/plan.ts +++ b/frontend/src/types/plan.ts @@ -17,6 +17,8 @@ export type PlanningIntervalDto = { grid_setpoint_w: number | null /** Explicitní fyzický režim Deye pro slot (PASSIVE/SELL/CHARGE). */ deye_physical_mode?: 'PASSIVE' | 'SELL' | 'CHARGE' | null + /** True = solver plánuje odpojit GEN port (MI export cutoff) v tomto slotu (BA81). */ + deye_gen_cutoff_enabled?: boolean | null ev1_setpoint_w: number | null ev2_setpoint_w: number | null ev_charge_power_w?: number | null