From e8eb867a2a56bc9760a16750801435b60097590c Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 3 May 2026 22:24:35 +0200 Subject: [PATCH] refactor export limit semantics --- backend/services/control/deye_helpers.py | 3 -- backend/services/control/exporter_monolith.py | 1 - backend/services/control/inverter.py | 3 -- backend/services/control/models.py | 1 + backend/services/control/setpoints.py | 8 +++- backend/services/planning_engine.py | 10 +++++ backend/services/signal_service.py | 4 ++ backend/tests/test_control_exporter_tou.py | 26 ++++++++++++ backend/tests/test_planning_dispatch_milp.py | 41 +++++++++++++++++++ docs/04-modules/control.md | 6 +-- docs/04-modules/modbus-registers.md | 2 +- docs/04-modules/operating-modes.md | 3 ++ docs/04-modules/planning.md | 3 ++ frontend/src/components/ControlPanel.tsx | 6 +++ frontend/src/pages/Planning.tsx | 30 +++++++++++--- frontend/src/types/plan.ts | 4 ++ 16 files changed, 134 insertions(+), 17 deletions(-) diff --git a/backend/services/control/deye_helpers.py b/backend/services/control/deye_helpers.py index b1463fa..cea7ba7 100644 --- a/backend/services/control/deye_helpers.py +++ b/backend/services/control/deye_helpers.py @@ -19,9 +19,6 @@ DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24 # Deye LV baterie: převod výkon -> proud pro registry 108/109. BATT_VOLTAGE_V = 51.2 -# Reg 143 ve SELL: min(|grid_setpoint_w|, ...) nesmí klesnout pod tuto podlahu (W). -REG143_SELL_CAP_MIN_W = 200 - # Reg 178 - bitové pole: bity 4-5 (peak shaving switch) a bity 0-1 (MI export cutoff). REG178_SELL = 0b00100000 REG178_PASSIVE = 0b00110000 diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 0377e41..375b38d 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -13,7 +13,6 @@ from services.control.deye_helpers import ( DEYE_TOU_INACTIVE_HHMM, DEYE_TOU_POWER_REGS, PRAGUE_TZ, - REG143_SELL_CAP_MIN_W, REG178_MI_EXPORT_DISABLE, REG178_MI_EXPORT_ENABLE, REG178_MI_EXPORT_MASK, diff --git a/backend/services/control/inverter.py b/backend/services/control/inverter.py index 5d0252b..be43a9f 100644 --- a/backend/services/control/inverter.py +++ b/backend/services/control/inverter.py @@ -15,7 +15,6 @@ from services.control.deye_helpers import ( DEYE_CLOCK_RESYNC_INTERVAL_HOURS, DEYE_TOU_INACTIVE_HHMM, PRAGUE_TZ, - REG143_SELL_CAP_MIN_W, REG178_MI_EXPORT_DISABLE, REG178_MI_EXPORT_ENABLE, REG178_MI_EXPORT_MASK, @@ -103,8 +102,6 @@ async def write_inverter_setpoints( selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode 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))) reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE logger.info( diff --git a/backend/services/control/models.py b/backend/services/control/models.py index 7b57276..0657cb3 100644 --- a/backend/services/control/models.py +++ b/backend/services/control/models.py @@ -39,6 +39,7 @@ class InverterConfig: @dataclass class ControlSetpoints: battery_w: int | None + #: Tvrdý limit exportu do sítě v daném slotu (W), ne forecastová cílová hodnota. grid_export_limit: int ev1_current_a: int ev2_current_a: int diff --git a/backend/services/control/setpoints.py b/backend/services/control/setpoints.py index 134ba0e..8073f9e 100644 --- a/backend/services/control/setpoints.py +++ b/backend/services/control/setpoints.py @@ -81,6 +81,8 @@ def _build_setpoints( if pi is None: return None grid_sp = int(pi["grid_setpoint_w"] or 0) + export_limit_raw = pi.get("export_limit_w") + export_limit = int(export_limit_raw) if export_limit_raw is not None else abs(min(grid_sp, 0)) ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0 ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0 hp_en = bool(pi["heat_pump_enabled"]) @@ -90,6 +92,10 @@ def _build_setpoints( 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_mode_raw = pi.get("export_mode") + export_mode = str(export_mode_raw).strip().upper() if export_mode_raw is not None else None + if export_mode == "NONE": + export_limit = 0 # 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") @@ -112,7 +118,7 @@ def _build_setpoints( pv_a_allowed = 0 return ControlSetpoints( battery_w=int(pi["battery_setpoint_w"] or 0), - grid_export_limit=abs(min(grid_sp, 0)), + grid_export_limit=max(0, export_limit), ev1_current_a=watts_to_amps(ev1_w, phases=3), ev2_current_a=watts_to_amps(ev2_w, phases=1), heat_pump_enable=hp_en, diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 55d15f8..0fadb22 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -319,6 +319,8 @@ class DispatchResult: battery_setpoint_w: int # kladné = nabíjení, záporné = vybíjení battery_soc_target: float # % SoC na konci intervalu grid_setpoint_w: int # kladné = import, záporné = export + export_limit_w: int # tvrdý limit exportu do sítě; 0 = bez exportu + export_mode: str # NONE / PV_SURPLUS / BATTERY_SELL #: 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 @@ -851,6 +853,10 @@ def solve_dispatch( batt_w = round(pulp.value(bc[t]) - pulp.value(bd[t])) grid_w = round(pulp.value(gi[t]) - pulp.value(ge[t])) soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1) + export_limit_w = int(grid.max_export_power_w) if grid_w < 0 else 0 + export_mode = "NONE" + if grid_w < 0: + export_mode = "BATTERY_SELL" if batt_w < 0 else "PV_SURPLUS" # Primární klasifikace fyzického režimu pro Deye: explicitně do plánu (Variant A). # Default PASSIVE; SELL při export+vybíjení; CHARGE při import+nabíjení. @@ -874,6 +880,8 @@ def solve_dispatch( battery_setpoint_w = batt_w, battery_soc_target = soc_pct, grid_setpoint_w = grid_w, + export_limit_w = export_limit_w, + export_mode = export_mode, 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])) @@ -1319,6 +1327,8 @@ async def _save_planning_run( "battery_setpoint_w": r.battery_setpoint_w, "battery_soc_target_pct": r.battery_soc_target, "grid_setpoint_w": r.grid_setpoint_w, + "export_limit_w": r.export_limit_w, + "export_mode": r.export_mode, "deye_physical_mode": r.deye_physical_mode, "deye_gen_cutoff_enabled": r.deye_gen_cutoff_enabled, "ev1_setpoint_w": r.ev1_setpoint_w, diff --git a/backend/services/signal_service.py b/backend/services/signal_service.py index e1f593c..2c9877f 100644 --- a/backend/services/signal_service.py +++ b/backend/services/signal_service.py @@ -178,6 +178,10 @@ async def compute_export_ban_active(site_id: int, conn: asyncpg.Connection) -> b if bool(pi.get("is_predicted_price")): return True + export_mode = str(pi.get("export_mode") or "").upper() + if export_mode in ("PV_SURPLUS", "BATTERY_SELL"): + return False + sell_raw = pi.get("effective_sell_price") grid_sp = int(pi.get("grid_setpoint_w") or 0) if sell_raw is None: diff --git a/backend/tests/test_control_exporter_tou.py b/backend/tests/test_control_exporter_tou.py index e8eb208..a011fb6 100644 --- a/backend/tests/test_control_exporter_tou.py +++ b/backend/tests/test_control_exporter_tou.py @@ -15,6 +15,8 @@ from services.control.exporter_monolith import ( deye_reg_triggers_self_sustain_after_verify_exhaust, get_deye_mode, ) +from services.control.models import OperatingModeInfo +from services.control.setpoints import _build_setpoints def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterConfig: @@ -110,6 +112,30 @@ class DeyeTouParamsTests(unittest.TestCase): ) self.assertEqual(get_deye_mode(sp), "PASSIVE") + def test_build_setpoints_uses_explicit_export_limit(self) -> None: + mode = OperatingModeInfo( + mode_code="AUTO", + battery_mode="AUTO", + grid_mode="AUTO", + ev_enabled=False, + heat_pump_enabled_def=False, + loxone_mode_value=1, + ) + pi = { + "battery_setpoint_w": 0, + "grid_setpoint_w": -3000, + "export_limit_w": 13_500, + "export_mode": "PV_SURPLUS", + "ev1_setpoint_w": 0, + "ev2_setpoint_w": 0, + "heat_pump_enabled": False, + "battery_soc_target_pct": 50, + "effective_sell_price": 1.0, + } + sp = _build_setpoints(mode, pi) + self.assertIsNotNone(sp) + self.assertEqual(sp.grid_export_limit, 13_500) + 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/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 2304238..6ff505c 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -254,6 +254,47 @@ class PlanningDispatchMilpTests(unittest.TestCase): self.assertEqual(int(results[0].pv_a_curtailed_w), 5000) self.assertGreaterEqual(int(results[0].grid_setpoint_w), 0) + def test_pv_surplus_export_uses_hard_export_cap(self) -> None: + slots = [ + _slot(load=0, buy=3.0, sell=2.5, pv_a=20_000, pv_b=0), + ] + battery = _battery() + hp = SimpleNamespace( + rated_heating_power_w=0, + tuv_min_temp_c=45.0, + tuv_target_temp_c=55.0, + ) + grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=13_500) + vehicles = [ + SimpleNamespace( + max_charge_power_w=0, + battery_capacity_kwh=1.0, + default_target_soc_pct=80.0, + ), + SimpleNamespace( + max_charge_power_w=0, + battery_capacity_kwh=1.0, + default_target_soc_pct=80.0, + ), + ] + soc0 = battery.soc_max_wh + results, _ms = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + soc0, + 50.0, + tuv_delta_stats=None, + operating_mode="AUTO", + ) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].export_mode, "PV_SURPLUS") + self.assertEqual(results[0].export_limit_w, 13_500) + self.assertGreater(results[0].pv_a_curtailed_w, 0) + def test_two_tier_soc_solves_optimal(self) -> None: slots = [_slot()] battery = _battery() diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index 189e115..12fe0f3 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -152,7 +152,7 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg | **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 | | **PASSIVE (ZERO)** | vše ostatní — reg. **108/109** dle `_deye_zero_export_amps_for_passive` (viz `operating-modes.md`) | -**PASSIVE** (AUTO, ZERO): výchozí **108** i **109** = maximum z DB; u exportu bez vybíjení **108 = 0**, u importu bez nabíjení **109 = 0** (`_deye_zero_export_amps_for_passive`). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **145** (solar sell): **0** při `export_ban` mimo SELL, jinak **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*). +**PASSIVE** (AUTO, ZERO): výchozí **108** i **109** = maximum z DB; u exportu bez vybíjení **108 = 0**, u importu bez nabíjení **109 = 0** (`_deye_zero_export_amps_for_passive`). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **143** je tvrdý limit exportu z lokality/invertoru (ne forecast). Reg. **145** (solar sell): **0** při `export_ban` mimo SELL, jinak **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*). **SELF_SUSTAIN** zůstává **PASSIVE** v `get_deye_mode`; **108/109** jsou vždy **max z DB** (bez variant ZERO). Rozdíl je **`self_sustain_local_use=True`**: **TOU SOC** = **`min_soc_percent`**, `battery_w=None`. @@ -163,7 +163,7 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg | **108** (charge A) | škálo dle `battery_w` | max / **0** (FVE přetok) | **0** | dle varianty | | **109** (discharge A) | **0** | max / **0** (import, držet bat.) | **max z DB** | dle varianty | | **142** (limit control) | `deye_zero_export_mode` | `deye_zero_export_mode` | **0** (selling first) | `deye_zero_export_mode` | -| **143** (export cap) | max z DB | max z DB | `min(max_site, max(200, \|grid_setpoint_w\|))` | max z DB | +| **143** (export cap) | max z DB | max z DB | max z DB (tvrdý limit, bez forecast heuristiky) | max z DB | | **145** (solar sell) | 1 / 0 při `export_ban` | 1 / 0 při `export_ban` | 1 | 1 / 0 při `export_ban` | | **178** (peak shaving) | 48 | 48 | **32** | 48 | @@ -207,7 +207,7 @@ async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db): slave=inv.unit_id) # Export limit - export_limit = max(0, -setpoints.grid_setpoint_w) if setpoints.grid_setpoint_w < 0 else 0 + export_limit = setpoints.grid_export_limit await client.write_register(0x00F6, export_limit, slave=inv.unit_id) logger.info(f"Inverter {inv.code} setpoints written: batt={setpoints.battery_setpoint_w}W") diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 80594f6..43aed87 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -20,7 +20,7 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi | 142 | Limit control (System work mode) | 0/1/2 | — | **0** = selling first, **1** = zero export to load, **2** = zero export to CT. Hodnota v non-SELL režimech pochází z `asset_inverter.deye_zero_export_mode` (závisí na instalaci – viz tabulka níže). V režimu SELL vždy **0**. | | 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). | | 340 | Max solar power | 0 … cap (W) | 1 W | Strop výkonu DC PV řízeného střídačem (pole A). EMS zapisuje jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` (zelený bonus na PV poli) **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0`. Hodnota z aktivního `planning_interval`: bez curtailmentu = cap; s `pv_a_curtailed_w > 0` = `clamp(0, cap, pv_a_forecast_solver_w − pv_a_curtailed_w)`. **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. | -| 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` | +| 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`. EMS ji neodvozuje z forecastu ani z `grid_setpoint_w`; pro exportní sloty je to tvrdý site/inverter cap. | | 178 | Control board special 1 | bitmask | — | Bitové pole pro více funkcí. **EMS používá:** (a) bits **4–5** pro peak shaving switch: **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE/CHARGE**. (b) **BA81:** bits **0–1** pro „MI export to Grid cutoff“ (AC coupling / GEN): **2** = disable (cutoff OFF), **3** = enable (cutoff ON). EMS zapisuje jako **read-modify-write** (zachová ostatní bity). V některých manuálech/UI je to označené jako „register 179“ (1-based). | | 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**. | diff --git a/docs/04-modules/operating-modes.md b/docs/04-modules/operating-modes.md index 784f675..765ba16 100644 --- a/docs/04-modules/operating-modes.md +++ b/docs/04-modules/operating-modes.md @@ -3,6 +3,7 @@ ## Keep it simple - **Žádné wattové prahy pro výběr SELL / CHARGE** — mapování z MILP setpointů je čistě ze **znamének** `battery_setpoint_w` a `grid_setpoint_w` (viz `get_deye_mode` v `exporter_monolith.py`). +- **Přetok FVE do sítě** se neodvozuje z forecastového capu: plán nese explicitní `export_limit_w` jako tvrdý limit lokality / invertoru, ne jako tipované maximum z předpovědi. - **ZERO (PASSIVE)** = zero export k CT/zátěži (**142** = `deye_zero_export_mode`), s **plnými proudy 108/109** jen ve výchozím stavu; pro přetok FVE do sítě nebo odběr ze sítě bez vybíjení baterie se jeden z proudů **vynuluje** (`_deye_zero_export_amps_for_passive`). - **SELL** = plánovaný export **i** plánované vybíjení (oba záporné) → **142** = selling first, **178** = vypnutý grid peak shaving (32); po návratu do ZERO/CHARGE zase **178** = 48. - Novou logiku vždy ověřit proti **reálnému řádku plánu** (audit / `planning_interval`). @@ -54,6 +55,8 @@ Všechny řádky předpokládají **142** = zero export (ne SELL). | Přetok FVE do sítě | `grid_setpoint_w` < 0 **a** `battery_w` ≥ 0 | **0** | max | | Držet baterii, brát ze sítě | `grid_setpoint_w` > 0 **a** `battery_w` ≤ 0 | max | **0** | +V obou exportních případech platí, že `export_limit_w` je **tvrdý site/inverter cap**. Nejde o forecastový odhad exportu, takže se může pustit plný přetok v rámci distribučního limitu. + Nabíjení ze sítě s vysokým cílovým SoC v TOU řeší větev **CHARGE** (grid charge v time pointech), ne tato tabulka. ### ZERO a „zakázaný export FVE do sítě“ (jen řiditelná pole) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 963e030..65c62af 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -30,6 +30,7 @@ - **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí — - **`asset_inverter.deye_gen_microinverter_cutoff_enabled`** (`deye-main`) — spojeno s MILP binárkami GEN cut-off (BA81), - **nebo** **`ems.site_grid_connection.block_export_on_negative_sell`** (migrace **V074**, default **false**) — bez GEN registrů na Deye; vhodné např. pro **KV1** (fixní nákup, bez nutnosti vést výkon neriťitelného pole B do sítě). **home-01** nech **false**, jinak může být horizont při přebytku z pole B a plné/nedostupné baterii **infeasible** (solver export potřebuje jako fyzikální ventil tam, kde FVE B nelze štípnout ani odpojit modelem bez GEN řádku). +- **Export bez forecastového capu:** solver ukládá explicitní `planning_interval.export_limit_w` jako tvrdý site/inverter limit a `planning_interval.export_mode` (`NONE` / `PV_SURPLUS` / `BATTERY_SELL`). Exportér z plánu neodvozuje žádný forecastový strop exportu. - **Uložené vstupy plánu** (`planning_interval`): `load_baseline_w`, `pv_*_forecast_raw_w`, `pv_*_forecast_solver_w` pro UI a audit. - **Více FVE polí s různou orientací:** `planning_engine._load_slots` sčítá predikovaný výkon za 15min přes **všechna** `asset_pv_array` dané lokality — `pv_a_forecast_w` = součet řádků s `controllable = true`, `pv_b_forecast_w` = součet s `controllable = false`. Pro každé pole a slot se bere **nejnovější** `forecast_pv_run` (`ORDER BY created_at DESC`, `DISTINCT ON (pv_array_id)`). Curtailment v LP zůstává **jedno** agregované `pv_a` (součet řiditelných polí); per-string curtailment by vyžadovalo rozšíření modelu. - **Kalibrace PV forecastu (delta profil):** tabulka `ems.site_pv_forecast_calibration` drží per `site_id` mimo jiné `delta_learn_min_ts` (dolní mez řádků z `forecast_accuracy` pro učení delty), volitelně `pv_curtailment_policy_effective_from` a přepsání parametrů (`top_n_days`, `half_life_days`, …; **V076** navíc `reference_day_weight_mult` pro „připnuté“ dny níže). **`ems.site_pv_forecast_reference_day`** (**V076**) umožňuje zvýšit váhu konkrétních kalendářních dnů (datum ve `site.timezone` jako u časování slotů) při agregaci δ z `forecast_accuracy` (`fn_pv_forecast_delta_profile`); hromadný zápis **`ems.fn_pv_forecast_sync_reference_days`**, detail **`docs/04-modules/forecast.md`**. `ems.fn_fill_forecast_accuracy` nastavuje `learning_eligible` / `learning_exclude_reason` (sloty před cutoffem, nebo se škrcením / gen cut-off / záznamem v `ems.cutoff_switch_log` po účinnosti policy se z učení vyřadí; u škrcení zůstává `actual_power_w` NULL). Telemetrie: `ems.telemetry_inverter.is_export_limited` nebo `pv_derating_flags <> 0` v okně 15min → stejné vyloučení (`telemetry_derating`). `ems.fn_pv_forecast_delta_profile` vrací `deltas_by_array` i součtové `deltas`; `ems.fn_load_planning_slots_full` aplikuje stejnou **per-pole** korekci jako UI (`fn_forecast_pv_slots_range_corrected`); pokud v JSON profilu chybí `deltas_by_array`, použije se souhrnné `deltas` rozpuštěné podle podílu výkonu pole na slotu (solver má tak stále použitou korekci i bez per-pole JSON). @@ -421,6 +422,8 @@ def solve_dispatch( battery_setpoint_w = round(pulp.value(batt_charge[t]) - pulp.value(batt_discharge[t])), battery_soc_target = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1), grid_setpoint_w = round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])), + export_limit_w = int(grid.max_export_power_w) if round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])) < 0 else 0, + export_mode = "BATTERY_SELL" if round(pulp.value(batt_charge[t]) - pulp.value(batt_discharge[t])) < 0 and round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])) < 0 else ("PV_SURPLUS" if round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])) < 0 else "NONE"), ev_charge_power_w = round(pulp.value(ev_charge[t])), heat_pump_enabled = hp_enabled, heat_pump_setpoint_w = hp_power, diff --git a/frontend/src/components/ControlPanel.tsx b/frontend/src/components/ControlPanel.tsx index cba0027..f5b8121 100644 --- a/frontend/src/components/ControlPanel.tsx +++ b/frontend/src/components/ControlPanel.tsx @@ -84,6 +84,12 @@ const LiveRegistersSection = memo( valueText={live?.reg142_limit_control != null ? String(live.reg142_limit_control) : undefined} /> + 0 ? ` | cap ${fmtKw(exportLimitW)}` : '' + return `SELL | ⬇ ${fmtKw(tpPowerW)}${cap} | reg142=0 reg178=32` } if (pm === 'CHARGE') { return `CHARGE | ⬆ ${fmtKw(Math.max(0, battery_w))} | grid=yes | SOC→${targetSoc}%` } // PASSIVE (ZERO): doplň informaci o variantě 108/109 podle pravidel (bez wattových prahů). - if (grid_w < 0 && battery_w >= 0) return 'PASSIVE | FVE→síť (108=0)' + if (grid_w < 0 && battery_w >= 0) { + const cap = exportLimitW > 0 ? ` | cap ${fmtKw(exportLimitW)}` : '' + return `PASSIVE | FVE→síť${cap} (108=0)` + } if (grid_w > 0 && battery_w <= 0) return 'PASSIVE | držet bat. (109=0)' return 'PASSIVE | max/max' } @@ -369,12 +376,15 @@ function deyeModeBadge(i: PlanningIntervalDto): { label: string; klass: string; const m = (i.deye_physical_mode ?? 'PASSIVE').toString().trim().toUpperCase() const battery_w = i.battery_setpoint_w ?? 0 const grid_w = i.grid_setpoint_w ?? 0 + const exportLimitW = i.export_limit_w ?? 0 + const exportMode = (i.export_mode ?? 'NONE').toString().trim().toUpperCase() + const cap = exportLimitW > 0 ? `; hard cap ${formatPlanPowerW(exportLimitW)}` : '' if (m === 'SELL') { return { label: 'SELL', klass: 'bg-orange-500/15 text-orange-200 ring-1 ring-orange-500/35', - title: 'SELL (selling first): reg142=0, reg178=32 (grid peak shaving off)', + title: `SELL (selling first): reg142=0, reg178=32 (grid peak shaving off)${cap}`, } } if (m === 'CHARGE') { @@ -386,12 +396,14 @@ function deyeModeBadge(i: PlanningIntervalDto): { label: string; klass: string; } let variant = 'max/max' - if (grid_w < 0 && battery_w >= 0) variant = 'FVE→síť (108=0)' + 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)' return { label: 'PASSIVE', klass: 'bg-slate-600/40 text-slate-200 ring-1 ring-slate-500/30', - title: `PASSIVE (ZERO): ${variant}; reg142=deye_zero_export_mode; reg178=48`, + title: `PASSIVE (ZERO): ${variant}${cap}; reg142=deye_zero_export_mode; reg178=48`, } } @@ -539,6 +551,8 @@ function PlanTooltip({ const fveStr = formatPlanPowerW(p.pv_a_w) const fveDisplay = fveStr === '—' ? '—' : fveStr.includes('kW') ? fveStr : `${fveStr} W` const soc = p.battery_soc_target_pct + const exportLimit = i.export_limit_w + const exportMode = i.export_mode ?? 'NONE' return (
{formatLocal(i.interval_start)}
@@ -556,6 +570,12 @@ function PlanTooltip({ {sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'}
FVE (korig. předpověď / audit): {fveDisplay}
+ {exportMode !== 'NONE' ? ( +
+ Export: {exportMode} + {exportLimit != null ? ` · limit ${formatPlanPowerW(exportLimit)}` : ''} +
+ ) : null}
SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}
Dům: {i.load_baseline_w ?? '—'} W
Baterie: {i.battery_setpoint_w ?? '—'} W
diff --git a/frontend/src/types/plan.ts b/frontend/src/types/plan.ts index 157624e..cfb223a 100644 --- a/frontend/src/types/plan.ts +++ b/frontend/src/types/plan.ts @@ -15,6 +15,10 @@ export type PlanningIntervalDto = { battery_setpoint_w: number | null battery_soc_target_pct: number | null grid_setpoint_w: number | null + /** Tvrdý limit exportu do sítě v daném slotu (W); 0 = bez exportu. */ + export_limit_w?: number | null + /** Záměr exportu: NONE / PV_SURPLUS / BATTERY_SELL. */ + export_mode?: 'NONE' | 'PV_SURPLUS' | 'BATTERY_SELL' | 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). */ -- 2.49.1