solver nastavuje stavy deye
This commit is contained in:
@@ -32,7 +32,7 @@ DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24
|
||||
# Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md)
|
||||
BATT_VOLTAGE_V = 51.2
|
||||
|
||||
# Reg 143 ve SELL: strop exportu = min(site max, |grid_setpoint|), dolní podlahová konstanta kvůli nule.
|
||||
# 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)
|
||||
@@ -340,6 +340,8 @@ class ControlSetpoints:
|
||||
ev1_power_w: int
|
||||
ev2_power_w: int
|
||||
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
|
||||
#: 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á)
|
||||
@@ -1220,6 +1222,8 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
hp_en = bool(pi["heat_pump_enabled"])
|
||||
tgt = pi["battery_soc_target_pct"]
|
||||
target_soc = int(round(float(tgt))) if tgt is not None else None
|
||||
pm_raw = pi.get("deye_physical_mode")
|
||||
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
|
||||
return ControlSetpoints(
|
||||
@@ -1232,6 +1236,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
ev1_power_w=ev1_w,
|
||||
ev2_power_w=ev2_w,
|
||||
target_soc_pct=target_soc,
|
||||
deye_physical_mode=pm,
|
||||
effective_sell_price_czk_kwh=sell_f,
|
||||
)
|
||||
|
||||
@@ -1373,31 +1378,52 @@ def _deye_passive_tou_battery_soc_pct(
|
||||
return _clamp_deye_tou_soc_pct_hi(DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT, hi=100)
|
||||
|
||||
|
||||
def _deye_zero_export_amps_for_passive(
|
||||
grid_w: int,
|
||||
bat_w: int,
|
||||
max_charge_a: int,
|
||||
max_discharge_a: int,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
PASSIVE (zero export k CT/zátěži, reg. 142 dle DB): výchozí plné 108/109.
|
||||
|
||||
- Export v plánu (grid_w < 0) a žádné plánované vybíjení (bat_w >= 0): **108 = 0** — nepřebírat
|
||||
přebytek FVE do baterie, ať může jít přetok do sítě.
|
||||
- Import v plánu (grid_w > 0) a žádné plánované nabíjení (bat_w <= 0): **109 = 0** — nevybíjet
|
||||
baterii, odběr ze sítě.
|
||||
"""
|
||||
if grid_w < 0 and bat_w >= 0:
|
||||
return 0, max_discharge_a
|
||||
if grid_w > 0 and bat_w <= 0:
|
||||
return max_charge_a, 0
|
||||
return max_charge_a, max_discharge_a
|
||||
|
||||
|
||||
def get_deye_mode(setpoints: ControlSetpoints) -> str:
|
||||
"""
|
||||
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
|
||||
|
||||
Pravidlo držet jednoduché (viz ``docs/04-modules/operating-modes.md``):
|
||||
Primárně explicitně z plánu (`setpoints.deye_physical_mode`), fallback jen ze znamének (viz
|
||||
``docs/04-modules/operating-modes.md``):
|
||||
|
||||
- **SELL** — jen když plán explicitně počítá s **vybíjením baterie do sítě** ve smyslu:
|
||||
záporný export z portu sítě a zároveň **|battery_w| ≥ |grid_setpoint_w|** (výdej z
|
||||
baterie není menší než plánovaný čistý export). Pak Deye „selling first“ (reg. 142=0).
|
||||
- **CHARGE** — ``battery_w`` > 0 **a** ``grid_setpoint_w`` > 0 (nabíjení ze sítě + import v plánu).
|
||||
- **SELL** — ``grid_setpoint_w`` < 0 **a** ``battery_w`` < 0 (export + vybíjení baterie v plánu).
|
||||
- **PASSIVE** (ZERO) — vše ostatní; reg. **108/109** dle ``_deye_zero_export_amps_for_passive``.
|
||||
|
||||
- **PASSIVE** — všude jinde: reg. **108** / **109** na **max. proud** invertoru; přetok FVE / chování
|
||||
vůči zátěži drží **142** dle ``deye_zero_export_mode``, **TOU výkon** z plánu, **145** solar sell.
|
||||
|
||||
- **CHARGE** — nabíjení ze sítě (``battery_w`` > 500 a ``grid_setpoint_w`` > 200).
|
||||
|
||||
``battery_w=None`` (SELF_SUSTAIN) → bat_w 0 → PASSIVE zde; **108/109 max** stejně jako u běžného
|
||||
PASSIVE v ``write_inverter_setpoints`` (viz ``self_sustain_local_use`` pro TOU SOC).
|
||||
``battery_w=None`` (SELF_SUSTAIN) → bat_w 0 → typicky PASSIVE; v ``write_inverter_setpoints`` má
|
||||
SELF_SUSTAIN vlastní větev (108/109 max).
|
||||
"""
|
||||
pm = (setpoints.deye_physical_mode or "").strip().upper()
|
||||
if pm in {"PASSIVE", "SELL", "CHARGE"}:
|
||||
return pm
|
||||
|
||||
grid_w = int(setpoints.grid_setpoint_w or 0)
|
||||
bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
|
||||
|
||||
if bat_w > 500 and grid_w > 200:
|
||||
if bat_w > 0 and grid_w > 0:
|
||||
return "CHARGE"
|
||||
|
||||
if grid_w < 0 and bat_w < 0 and abs(bat_w) >= abs(grid_w):
|
||||
if grid_w < 0 and bat_w < 0:
|
||||
return "SELL"
|
||||
|
||||
return "PASSIVE"
|
||||
@@ -1475,18 +1501,19 @@ async def write_inverter_setpoints(
|
||||
charge_a = int(inv.max_charge_a)
|
||||
discharge_a = int(inv.max_discharge_a)
|
||||
else:
|
||||
# PASSIVE (AUTO): plný strop 108/109 — stejná idea jako SELF_SUSTAIN.
|
||||
# Dříve škálování podle |battery_w| z LP usekávalo fyzický výkon baterie (např. 23 A při
|
||||
# ~1,2 kW plánu) a velká akumulace pak neuměla rychle doplnit síť při nárazové zátěži.
|
||||
# Ekonomiku a směr toku drží TOU časové body (výkon W / SOC %) + režim 142/178, ne reg. 108/109.
|
||||
charge_a = int(inv.max_charge_a)
|
||||
discharge_a = int(inv.max_discharge_a)
|
||||
# PASSIVE (ZERO): výchozí plné 108/109; u přetoku FVE do sítě nebo importu bez baterie viz helper.
|
||||
charge_a, discharge_a = _deye_zero_export_amps_for_passive(
|
||||
grid_w,
|
||||
bat_w,
|
||||
int(inv.max_charge_a),
|
||||
int(inv.max_discharge_a),
|
||||
)
|
||||
|
||||
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
|
||||
export_limit = export_lim
|
||||
if deye_mode == "SELL" and grid_w < -200:
|
||||
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
|
||||
|
||||
@@ -1875,15 +1902,15 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
|
||||
|
||||
if mode.mode_code == "CHARGE_CHEAP":
|
||||
max_ch = await _fetch_max_charge_power_w(site_id, db)
|
||||
# Kladný grid_setpoint_w > 200 → fyzický CHARGE (nabíjení ze sítě), viz get_deye_mode
|
||||
grid_for_charge = max(300, max_ch)
|
||||
# Oba setpointy kladné → get_deye_mode CHARGE; min. 1 W, aby režim nebyl PASSIVE při nulové DB.
|
||||
pw = max(1, int(max_ch))
|
||||
sp_now = ControlSetpoints(
|
||||
battery_w=max_ch,
|
||||
battery_w=pw,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=grid_for_charge,
|
||||
grid_setpoint_w=pw,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
|
||||
@@ -184,6 +184,9 @@ 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
|
||||
#: 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
|
||||
ev1_setpoint_w: Optional[int]
|
||||
ev2_setpoint_w: Optional[int]
|
||||
ev1_via_bat_w: int
|
||||
@@ -541,6 +544,14 @@ def solve_dispatch(
|
||||
grid_w = round(pulp.value(gi[t]) - pulp.value(ge[t]))
|
||||
soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1)
|
||||
|
||||
# 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í.
|
||||
deye_mode = "PASSIVE"
|
||||
if batt_w < 0 and grid_w < 0:
|
||||
deye_mode = "SELL"
|
||||
elif batt_w > 0 and grid_w > 0:
|
||||
deye_mode = "CHARGE"
|
||||
|
||||
cost = (
|
||||
pulp.value(gi[t]) * slots[t].buy_price * INTERVAL_H / 1000
|
||||
- pulp.value(ge[t]) * slots[t].sell_price * INTERVAL_H / 1000
|
||||
@@ -551,6 +562,7 @@ def solve_dispatch(
|
||||
battery_setpoint_w = batt_w,
|
||||
battery_soc_target = soc_pct,
|
||||
grid_setpoint_w = grid_w,
|
||||
deye_physical_mode = deye_mode,
|
||||
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]))
|
||||
@@ -982,6 +994,7 @@ 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,
|
||||
"deye_physical_mode": r.deye_physical_mode,
|
||||
"ev1_setpoint_w": r.ev1_setpoint_w,
|
||||
"ev2_setpoint_w": r.ev2_setpoint_w,
|
||||
"ev1_via_bat_w": r.ev1_via_bat_w,
|
||||
|
||||
@@ -11,6 +11,7 @@ from services.control.exporter_monolith import (
|
||||
_deye_reg178_verify_with_double_read,
|
||||
_deye_tou_params,
|
||||
_deye_tou_power_verify_match,
|
||||
_deye_zero_export_amps_for_passive,
|
||||
deye_reg_triggers_self_sustain_after_verify_exhaust,
|
||||
get_deye_mode,
|
||||
)
|
||||
@@ -62,7 +63,7 @@ class ModbusVerifyPolicyTests(unittest.TestCase):
|
||||
|
||||
class DeyeTouParamsTests(unittest.TestCase):
|
||||
def test_sell_uses_reserve_soc(self) -> None:
|
||||
"""SELL: plánovaný výdej baterie alesvěň tak velký jako plánovaný export (|bat| ≥ |grid|)."""
|
||||
"""SELL: záporný grid_setpoint_w i battery_w → selling first; TOU SOC = reserve."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=-8000,
|
||||
grid_export_limit=8000,
|
||||
@@ -79,8 +80,23 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
self.assertFalse(g)
|
||||
self.assertEqual(s, 20)
|
||||
|
||||
def test_pv_led_export_with_small_battery_is_passive(self) -> None:
|
||||
"""Regrese site 25A 17:30: |bat| < |grid| → PASSIVE (FVE přetok, ne „vylít baterku“)."""
|
||||
def test_explicit_deye_physical_mode_from_plan_overrides_detection(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=-8000,
|
||||
grid_export_limit=8000,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=-8000,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
deye_physical_mode="PASSIVE",
|
||||
)
|
||||
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(
|
||||
battery_w=-733,
|
||||
grid_export_limit=1294,
|
||||
@@ -92,10 +108,10 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
self.assertEqual(get_deye_mode(sp), "SELL")
|
||||
|
||||
def test_large_export_small_battery_is_passive(self) -> None:
|
||||
"""Export v plánu větší než výdej z baterie → PASSIVE."""
|
||||
def test_large_export_small_battery_is_sell(self) -> None:
|
||||
"""I když |bat| < |grid| — stále SELL při obou záporných setpointech."""
|
||||
sp = ControlSetpoints(
|
||||
battery_w=-1500,
|
||||
grid_export_limit=8000,
|
||||
@@ -107,7 +123,7 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||
self.assertEqual(get_deye_mode(sp), "SELL")
|
||||
|
||||
def test_passive_uses_min_soc(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
@@ -181,6 +197,36 @@ class DeyeTouParamsTests(unittest.TestCase):
|
||||
self.assertTrue(g)
|
||||
self.assertEqual(s, 95)
|
||||
|
||||
def test_charge_any_positive_pair_without_w_threshold(self) -> None:
|
||||
sp = ControlSetpoints(
|
||||
battery_w=50,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=80,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=50,
|
||||
)
|
||||
self.assertEqual(get_deye_mode(sp), "CHARGE")
|
||||
|
||||
def test_zero_export_amps_fve_overflow(self) -> None:
|
||||
c, d = _deye_zero_export_amps_for_passive(-1000, 0, 100, 90)
|
||||
self.assertEqual(c, 0)
|
||||
self.assertEqual(d, 90)
|
||||
|
||||
def test_zero_export_amps_import_hold_discharge(self) -> None:
|
||||
c, d = _deye_zero_export_amps_for_passive(500, 0, 100, 90)
|
||||
self.assertEqual(c, 100)
|
||||
self.assertEqual(d, 0)
|
||||
|
||||
def test_zero_export_amps_full_when_discharge_with_export(self) -> None:
|
||||
"""Export + plánované vybíjení → plné proudy (SELL řeší režim 142 zvlášť)."""
|
||||
c, d = _deye_zero_export_amps_for_passive(-2000, -500, 100, 90)
|
||||
self.assertEqual(c, 100)
|
||||
self.assertEqual(d, 90)
|
||||
|
||||
def test_self_sustain_tou_stays_min_soc_even_if_sell_negative(self) -> None:
|
||||
"""SELF_SUSTAIN: nízké TOU (min_soc), ne 100 % z negativní vykupní — LP se nepoužívá."""
|
||||
sp = ControlSetpoints(
|
||||
|
||||
Reference in New Issue
Block a user