From 44a06b6288a67350709dfa06baf4a7f725a5215d Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Thu, 21 May 2026 10:02:19 +0200 Subject: [PATCH] fix ranniho neprodeje do site --- backend/services/control/inverter.py | 14 +++++- backend/services/control/models.py | 2 + backend/services/control/setpoints.py | 27 ++++++++++++ backend/tests/test_control_exporter_tou.py | 43 ++++++++++++++++++- .../V078__planning_interval_export.sql | 10 +++++ db/routines/R__037_fn_planning_run_commit.sql | 8 ++++ docs/04-modules/control.md | 6 +++ docs/04-modules/modbus-registers.md | 2 +- frontend/src/pages/Planning.tsx | 16 ++++++- 9 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 db/migration/V078__planning_interval_export.sql diff --git a/backend/services/control/inverter.py b/backend/services/control/inverter.py index 10ef893..0fdbcee 100644 --- a/backend/services/control/inverter.py +++ b/backend/services/control/inverter.py @@ -37,6 +37,7 @@ from services.control.modbus_journal import ( from services.control.models import ControlSetpoints from services.control.repository import _get_current_soc, _load_inverter_config from services.control.setpoints import ( + _deye_reg142_limit_control, _deye_reg143_export_w, _deye_system_time_register_rows, _deye_time_point_rows, @@ -66,7 +67,10 @@ async def write_inverter_setpoints( raw_bat = setpoints_now.battery_w grid_w = int(setpoints_now.grid_setpoint_w or 0) no_export = inv.no_export - export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w) + export_lim_hw = _deye_reg143_export_w(no_export, inv.max_export_power_w) + export_lim = export_lim_hw + if int(setpoints_now.grid_export_limit or 0) > 0: + export_lim = min(export_lim_hw, int(setpoints_now.grid_export_limit)) max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge tou_min_pct = _deye_tou_min_soc_pct(inv) @@ -88,7 +92,13 @@ 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 + selling_mode = _deye_reg142_limit_control( + deye_mode=deye_mode, + grid_w=grid_w, + export_ban=bool(setpoints_now.export_ban), + export_mode=setpoints_now.export_mode, + zero_export_mode=zero_exp_mode, + ) solar_sell = 0 if (setpoints_now.export_ban and deye_mode != "SELL") else 1 export_limit = export_lim reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE diff --git a/backend/services/control/models.py b/backend/services/control/models.py index 0657cb3..35308a0 100644 --- a/backend/services/control/models.py +++ b/backend/services/control/models.py @@ -50,6 +50,8 @@ class ControlSetpoints: target_soc_pct: int | None = None #: Explicitní fyzický režim z plánu (PASSIVE/SELL/CHARGE). deye_physical_mode: str | None = None + #: Záměr exportu z LP: NONE / PV_SURPLUS / BATTERY_SELL. + export_mode: str | None = None #: True = zákaz exportu (BLOCK_EXPORT) pro daný slot. export_ban: bool = False #: True = odpojit GEN port (MI export cutoff) v tomto slotu dle plánu (reg 178 bits0-1). diff --git a/backend/services/control/setpoints.py b/backend/services/control/setpoints.py index d56eea0..b747401 100644 --- a/backend/services/control/setpoints.py +++ b/backend/services/control/setpoints.py @@ -96,6 +96,8 @@ def _build_setpoints( export_mode = str(export_mode_raw).strip().upper() if export_mode_raw is not None else None if export_mode == "NONE": export_limit = 0 + elif export_limit <= 0 and grid_sp < 0: + export_limit = abs(grid_sp) # 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 gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled") @@ -127,6 +129,7 @@ def _build_setpoints( ev2_power_w=ev2_w, target_soc_pct=target_soc, deye_physical_mode=pm, + export_mode=export_mode, export_ban=bool(export_ban), deye_gen_cutoff_enabled=bool(gen_cutoff), effective_sell_price_czk_kwh=sell_f, @@ -214,6 +217,30 @@ def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> in return max(0, int(max_export_power_w or 0)) +def _deye_reg142_limit_control( + *, + deye_mode: str, + grid_w: int, + export_ban: bool, + export_mode: str | None, + zero_export_mode: int, +) -> int: + """ + Reg 142: 0 = selling first, 1/2 = zero export (load / CT). + Plán s exportem (záporný grid_setpoint, bez export_ban) musí povolit prodej FVE do sítě + i v PASSIVE — jinak CT instalace (deye_zero_export_mode=2) drží přebytek v baterii. + """ + if deye_mode == "SELL": + return 0 + em = (export_mode or "").strip().upper() + if export_ban or em == "NONE" or grid_w >= 0: + return int(zero_export_mode) + if em in {"PV_SURPLUS", "BATTERY_SELL"}: + return 0 + # starší řádky bez export_mode: záporný grid_setpoint = export záměr + return 0 + + def _clamp_deye_tou_soc_pct(pct: int) -> int: return max(5, min(95, pct)) diff --git a/backend/tests/test_control_exporter_tou.py b/backend/tests/test_control_exporter_tou.py index 8b8cab3..1203783 100644 --- a/backend/tests/test_control_exporter_tou.py +++ b/backend/tests/test_control_exporter_tou.py @@ -15,7 +15,11 @@ from services.control.exporter_monolith import ( get_deye_mode, ) from services.control.models import OperatingModeInfo -from services.control.setpoints import _build_setpoints, _deye_zero_export_amps_for_passive +from services.control.setpoints import ( + _build_setpoints, + _deye_reg142_limit_control, + _deye_zero_export_amps_for_passive, +) def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterConfig: @@ -111,6 +115,43 @@ class DeyeTouParamsTests(unittest.TestCase): ) self.assertEqual(get_deye_mode(sp), "PASSIVE") + def test_reg142_pv_surplus_on_ct_site_uses_selling_first(self) -> None: + """KV1/BA81: PASSIVE + plánovaný export FVE nesmí nechat reg142=2 (zero export CT).""" + self.assertEqual( + _deye_reg142_limit_control( + deye_mode="PASSIVE", + grid_w=-3000, + export_ban=False, + export_mode="PV_SURPLUS", + zero_export_mode=2, + ), + 0, + ) + + def test_reg142_no_export_when_export_ban(self) -> None: + self.assertEqual( + _deye_reg142_limit_control( + deye_mode="PASSIVE", + grid_w=-1000, + export_ban=True, + export_mode="PV_SURPLUS", + zero_export_mode=2, + ), + 2, + ) + + def test_reg142_legacy_negative_grid_without_export_mode(self) -> None: + self.assertEqual( + _deye_reg142_limit_control( + deye_mode="PASSIVE", + grid_w=-2500, + export_ban=False, + export_mode=None, + zero_export_mode=2, + ), + 0, + ) + def test_build_setpoints_uses_explicit_export_limit(self) -> None: mode = OperatingModeInfo( mode_code="AUTO", diff --git a/db/migration/V078__planning_interval_export.sql b/db/migration/V078__planning_interval_export.sql new file mode 100644 index 0000000..4ff6c8c --- /dev/null +++ b/db/migration/V078__planning_interval_export.sql @@ -0,0 +1,10 @@ +-- export_mode / export_limit_w z LP — potřeba pro control exporter (reg 142/143) + +alter table ems.planning_interval + add column if not exists export_mode text, + add column if not exists export_limit_w int; + +comment on column ems.planning_interval.export_mode is + 'Záměr exportu z solveru: NONE / PV_SURPLUS / BATTERY_SELL.'; +comment on column ems.planning_interval.export_limit_w is + 'Tvrdý limit exportu do sítě (W) v slotu; 0 = bez exportu.'; diff --git a/db/routines/R__037_fn_planning_run_commit.sql b/db/routines/R__037_fn_planning_run_commit.sql index 438c536..14a00a3 100644 --- a/db/routines/R__037_fn_planning_run_commit.sql +++ b/db/routines/R__037_fn_planning_run_commit.sql @@ -57,6 +57,8 @@ begin run_id, interval_start, battery_setpoint_w, battery_soc_target_pct, grid_setpoint_w, + export_mode, + export_limit_w, deye_physical_mode, deye_gen_cutoff_enabled, ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w, @@ -73,6 +75,8 @@ begin (r.value->>'battery_setpoint_w')::int, (r.value->>'battery_soc_target_pct')::numeric, (r.value->>'grid_setpoint_w')::int, + nullif(trim(r.value->>'export_mode'), ''), + nullif(r.value->>'export_limit_w', '')::int, nullif(trim(r.value->>'deye_physical_mode'), ''), (r.value->>'deye_gen_cutoff_enabled')::boolean, nullif(r.value->>'ev1_setpoint_w', '')::int, @@ -97,6 +101,8 @@ begin run_id, interval_start, battery_setpoint_w, battery_soc_target_pct, grid_setpoint_w, + export_mode, + export_limit_w, deye_physical_mode, deye_gen_cutoff_enabled, ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w, @@ -110,6 +116,8 @@ begin (r.value->>'battery_setpoint_w')::int, (r.value->>'battery_soc_target_pct')::numeric, (r.value->>'grid_setpoint_w')::int, + nullif(trim(r.value->>'export_mode'), ''), + nullif(r.value->>'export_limit_w', '')::int, nullif(trim(r.value->>'deye_physical_mode'), ''), (r.value->>'deye_gen_cutoff_enabled')::boolean, nullif(r.value->>'ev1_setpoint_w', '')::int, diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index bb282f4..82fe680 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -10,6 +10,12 @@ - Odešle potvrzovací setpointy do Loxone přes HTTP (Loxone jako exekutor fallback logiky) - Loguje každý write pro audit +### `export_mode` / `export_limit_w` (V078+) + +Solver ukládá do `planning_interval` záměr exportu (`NONE` / `PV_SURPLUS` / `BATTERY_SELL`) a cap `export_limit_w`. Control exporter podle toho nastaví **reg 142** a **reg 143** — u CT instalací (`deye_zero_export_mode = 2`) musí být při plánovaném exportu FVE **reg 142 = 0** (selling first), jinak invertor drží přebytek v bateri místo v síti. + +Implementace: `setpoints._deye_reg142_limit_control`, `inverter.write_inverter_setpoints`. Ověření: log `reg142=0` při `export_mode=PV_SURPLUS`, audit `flow_pv_to_grid_wh` > 0. + --- ## Architektura řízení diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 6049bf1..c3eada5 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -97,7 +97,7 @@ Solver předvybírá sloty pro nabíjení a export-vybíjení (`_select_charge_s | **Deye mode** | CHARGE | PASSIVE | SELL | PASSIVE | | **108** charge A | dle `bat_w` | **0** při exportu bez vybíjení | **0** | max nebo **0** dle varianty | | **109** discharge A | **0** | max | **max** | **0** při importu bez nabíjení, jinak max | -| **142** limit control | `deye_zero_export_mode` (1 nebo 2) | `deye_zero_export_mode` (1 nebo 2) | **0** (selling first) | `deye_zero_export_mode` (1 nebo 2) | +| **142** limit control | `deye_zero_export_mode` (1 nebo 2) | **0** (selling first) pokud plán exportuje (`export_mode` PV_SURPLUS / záporný `grid_setpoint_w`, bez `export_ban`); jinak `deye_zero_export_mode` | **0** (selling first) | `deye_zero_export_mode` (1 nebo 2) | | **145** solar sell | **1** (enabled) | **1** (enabled) | **1** (enabled) | **1** (enabled) | | **178** peak shaving | 48 (PASSIVE) | 48 (PASSIVE) | **32** (SELL) | 48 (PASSIVE) | | **143** export limit | max export W z DB | max export W z DB | max export W z DB | max export W z DB | diff --git a/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx index 017cdfc..c95e2b6 100644 --- a/frontend/src/pages/Planning.tsx +++ b/frontend/src/pages/Planning.tsx @@ -342,7 +342,9 @@ function deyeModeBadge(i: PlanningIntervalDto): { label: string; klass: string; } let variant = 'max/max' - if (grid_w < 0 && battery_w >= 0) { + if (exportMode === 'PV_SURPLUS' && grid_w < 0) { + variant = 'FVE→síť' + } else if (grid_w < 0 && battery_w >= 0) { variant = exportMode === 'PV_SURPLUS' ? 'FVE→síť' : 'export' } else if (grid_w > 0 && battery_w <= 0) variant = 'držet bat. (109=0)' @@ -394,6 +396,7 @@ type ChartRow = { pv_a_w: number battery_soc_target_pct: number | null battery_setpoint_w: number + grid_setpoint_w: number compare_battery_setpoint_w?: number | null effective_buy_price: number | null raw: PlanningIntervalDto @@ -731,6 +734,7 @@ export default function Planning() { pv_a_w: pvChartFveW(i, nowMs), battery_soc_target_pct: i.battery_soc_target_pct, battery_setpoint_w: i.battery_setpoint_w ?? 0, + grid_setpoint_w: i.grid_setpoint_w ?? 0, compare_battery_setpoint_w: compareMap.get(i.interval_start)?.battery_setpoint_w ?? null, effective_buy_price: i.effective_buy_price, raw: i, @@ -1213,6 +1217,16 @@ export default function Planning() { /> ))} +