From a1aa6acf6189b902c1420162c93f9ffd74ee75c5 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 19 Apr 2026 12:10:37 +0200 Subject: [PATCH] Add support for inverter current caps in site configuration - Introduced `InverterModbusCurrentCapsBody` model for updating max charge and discharge currents. - Updated SQL queries to utilize `COALESCE` for effective current limits. - Modified relevant tests to reflect changes in battery current handling. - Added new SQL migration for `deye_register_max_current_a` columns in the database. --- .idea/data_source_mapping.xml | 2 + .idea/sqldialects.xml | 2 - backend/app/routers/site_configuration.py | 80 ++++++++++++++++++ backend/services/control_exporter.py | 81 ++++++++----------- backend/tests/test_control_exporter_tou.py | 2 +- .../V044__deye_register_max_current_a.sql | 4 +- docs/04-modules/modbus-registers.md | 6 +- 7 files changed, 121 insertions(+), 56 deletions(-) diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml index ae5efeb..6f15fb2 100644 --- a/.idea/data_source_mapping.xml +++ b/.idea/data_source_mapping.xml @@ -1,8 +1,10 @@ + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml index 35532b9..d577deb 100644 --- a/.idea/sqldialects.xml +++ b/.idea/sqldialects.xml @@ -2,7 +2,5 @@ - - \ No newline at end of file diff --git a/backend/app/routers/site_configuration.py b/backend/app/routers/site_configuration.py index 1550755..2ba4c7f 100644 --- a/backend/app/routers/site_configuration.py +++ b/backend/app/routers/site_configuration.py @@ -7,12 +7,24 @@ from typing import Annotated, Any import asyncpg from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field from app.db_json import record_to_dict from app.deps import get_pg_pool router = APIRouter(prefix="/sites/{site_id}", tags=["sites"]) + +class InverterModbusCurrentCapsBody(BaseModel): + """Tvrdý strop proudu pro zápis Deye reg 108/109 (A); NULL ve JSONu = smaž strop v DB.""" + + deye_register_max_charge_a: int | None = Field( + default=None, ge=0, le=640, description="None při vynechání klíče = nezměnit; explicitní null = smazat strop" + ) + deye_register_max_discharge_a: int | None = Field( + default=None, ge=0, le=640, description="Jako u nabíjení" + ) + _DEYE_KEYS = frozenset( { "deye_last_system_time_sync_at", @@ -246,3 +258,71 @@ async def get_site_configuration( "active_plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None, }, } + + +@router.patch("/inverters/{inverter_id}/modbus-current-caps") +async def patch_inverter_modbus_current_caps( + site_id: int, + inverter_id: int, + body: InverterModbusCurrentCapsBody, + pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], +) -> dict[str, Any]: + """ + Nastavení `deye_register_max_charge_a` / `deye_register_max_discharge_a` na `ems.asset_inverter`. + Hodnoty se uplatní v dotazu `_load_inverter_config` jako `COALESCE(strop_A, FLOOR(…z_kW))` pro reg 108/109. + """ + updates = body.model_dump(exclude_unset=True) + if not updates: + raise HTTPException( + status_code=400, + detail="Send at least one of: deye_register_max_charge_a, deye_register_max_discharge_a", + ) + async with pool.acquire() as conn: + owner = await conn.fetchval( + """ + SELECT id FROM ems.asset_inverter + WHERE id = $1 AND site_id = $2 + """, + inverter_id, + site_id, + ) + if owner is None: + raise HTTPException(status_code=404, detail="Inverter not found for this site") + + sets: list[str] = [] + args: list[Any] = [] + n = 1 + if "deye_register_max_charge_a" in updates: + sets.append(f"deye_register_max_charge_a = ${n}") + args.append(updates["deye_register_max_charge_a"]) + n += 1 + if "deye_register_max_discharge_a" in updates: + sets.append(f"deye_register_max_discharge_a = ${n}") + args.append(updates["deye_register_max_discharge_a"]) + n += 1 + + args.extend([inverter_id, site_id]) + await conn.execute( + f""" + UPDATE ems.asset_inverter + SET {", ".join(sets)} + WHERE id = ${n} AND site_id = ${n + 1} + """, + *args, + ) + row = await conn.fetchrow( + """ + SELECT id, code, deye_register_max_charge_a, deye_register_max_discharge_a + FROM ems.asset_inverter + WHERE id = $1 AND site_id = $2 + """, + inverter_id, + site_id, + ) + assert row is not None + return { + "inverter_id": int(row["id"]), + "code": row["code"], + "deye_register_max_charge_a": row["deye_register_max_charge_a"], + "deye_register_max_discharge_a": row["deye_register_max_discharge_a"], + } diff --git a/backend/services/control_exporter.py b/backend/services/control_exporter.py index b475c01..ce487c7 100644 --- a/backend/services/control_exporter.py +++ b/backend/services/control_exporter.py @@ -93,19 +93,12 @@ def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> i def battery_watts_to_amps(power_w: int, max_amps: int) -> int: - """Proud z |výkonu| baterie; max_amps výhradně z DB (_load_inverter_config).""" - return min(max(0, max_amps), max(0, round(abs(power_w) / BATT_VOLTAGE_V))) + """Proud z |výkonu| baterie; max_amps z DB (už COALESCE se stropy v SQL). - -def _effective_battery_current_caps(inv: InverterConfig) -> tuple[int, int]: - """Efektivní stropy pro reg 108/109 po zohlednění volitelných Deye limitů v DB.""" - ca = int(inv.max_charge_a) - da = int(inv.max_discharge_a) - if inv.deye_register_max_charge_a is not None: - ca = min(ca, int(inv.deye_register_max_charge_a)) - if inv.deye_register_max_discharge_a is not None: - da = min(da, int(inv.deye_register_max_discharge_a)) - return ca, da + int(|W|/51.2) — u kladných hodnot stejné jako floor bez importu math. + """ + derived = int(abs(power_w) / BATT_VOLTAGE_V) + return min(max(0, max_amps), max(0, derived)) def current_slot_hhmm() -> int: @@ -147,8 +140,6 @@ class InverterConfig: usable_capacity_wh: int | None max_charge_a: int max_discharge_a: int - deye_register_max_charge_a: int | None = None - deye_register_max_discharge_a: int | None = None deye_last_system_time_sync_minute: datetime | None = None deye_last_system_time_sync_at: datetime | None = None deye_last_tou_inactive_write_prague_date: date | None = None @@ -972,17 +963,25 @@ async def _load_inverter_config( ai.deye_last_system_time_sync_at, ai.deye_last_tou_inactive_write_prague_date, ai.deye_tou_inactive_signature, - ai.deye_register_max_charge_a, - ai.deye_register_max_discharge_a, COALESCE(ai.deye_zero_export_mode, 1) AS deye_zero_export_mode, - LEAST( - COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w), - ai.max_battery_charge_w - ) / 51.2 AS max_charge_a, - LEAST( - COALESCE(ab.bms_max_discharge_w, ai.max_battery_discharge_w), - ai.max_battery_discharge_w - ) / 51.2 AS max_discharge_a + COALESCE( + ai.deye_register_max_charge_a, + FLOOR( + LEAST( + COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w), + ai.max_battery_charge_w + )::numeric / 51.2 + )::int + ) AS max_charge_a, + COALESCE( + ai.deye_register_max_discharge_a, + FLOOR( + LEAST( + COALESCE(ab.bms_max_discharge_w, ai.max_battery_discharge_w), + ai.max_battery_discharge_w + )::numeric / 51.2 + )::int + ) AS max_discharge_a FROM ems.asset_inverter ai JOIN ems.site_endpoint se ON se.id = ai.endpoint_id JOIN ems.asset_battery ab ON ab.inverter_id = ai.id @@ -1038,12 +1037,6 @@ async def _load_inverter_config( else None, max_charge_a=max_charge_a, max_discharge_a=max_discharge_a, - deye_register_max_charge_a=int(row["deye_register_max_charge_a"]) - if row["deye_register_max_charge_a"] is not None - else None, - deye_register_max_discharge_a=int(row["deye_register_max_discharge_a"]) - if row["deye_register_max_discharge_a"] is not None - else None, deye_last_system_time_sync_minute=row["deye_last_system_time_sync_minute"], deye_last_system_time_sync_at=row["deye_last_system_time_sync_at"], deye_last_tou_inactive_write_prague_date=row[ @@ -1288,17 +1281,12 @@ def get_deye_mode(setpoints: ControlSetpoints) -> str: def _deye_tou_params( setpoints: ControlSetpoints, inv: InverterConfig, - *, - max_charge_a_cap: int | None = None, - max_discharge_a_cap: int | None = None, ) -> tuple[int, int, bool]: """ Parametry jednoho Deye time pointu: výkon W, SOC min %, grid_charge. Musí odpovídat logice get_deye_mode / lock_battery v write_inverter_setpoints. """ - md = int(max_discharge_a_cap) if max_discharge_a_cap is not None else int(inv.max_discharge_a) - mc = int(max_charge_a_cap) if max_charge_a_cap is not None else int(inv.max_charge_a) - max_batt_w_discharge = int(md * BATT_VOLTAGE_V) + max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge tou_min = _deye_tou_min_soc_pct(inv) tou_reserve = _deye_tou_reserve_soc_pct(inv) @@ -1310,7 +1298,9 @@ def _deye_tou_params( battery_w = int(raw_bat) if raw_bat is not None else 0 cap = int(inv.max_soc_percent) if inv.max_soc_percent is not None else 95 target_soc = max(10, min(95, cap)) - tp_charge_w = battery_watts_to_amps(battery_w, mc) * int(BATT_VOLTAGE_V) + tp_charge_w = ( + battery_watts_to_amps(battery_w, int(inv.max_charge_a)) * int(BATT_VOLTAGE_V) + ) return tp_charge_w, target_soc, True if deye_mode == "SELL": return tp_discharge_w, tou_reserve, False @@ -1332,8 +1322,7 @@ async def write_inverter_setpoints( 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) - eff_ca, eff_da = _effective_battery_current_caps(inv) - max_batt_w_discharge = int(eff_da * BATT_VOLTAGE_V) + 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) tou_reserve_pct = _deye_tou_reserve_soc_pct(inv) @@ -1348,11 +1337,11 @@ async def write_inverter_setpoints( charge_a = 0 discharge_a = 0 elif deye_mode == "CHARGE": - charge_a = battery_watts_to_amps(bat_w, eff_ca) + charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a) discharge_a = 0 else: - charge_a = int(eff_ca) if bat_w > 0 else 0 - discharge_a = int(eff_da) + charge_a = int(inv.max_charge_a) if bat_w > 0 else 0 + discharge_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 @@ -1401,12 +1390,8 @@ async def write_inverter_setpoints( sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now hh_cur = current_slot_hhmm() hh_nxt = next_slot_hhmm() - p1, s1, g1 = _deye_tou_params( - setpoints_now, inv, max_charge_a_cap=eff_ca, max_discharge_a_cap=eff_da - ) - p2, s2, g2 = _deye_tou_params( - sp_tp2, inv, max_charge_a_cap=eff_ca, max_discharge_a_cap=eff_da - ) + p1, s1, g1 = _deye_tou_params(setpoints_now, inv) + p2, s2, g2 = _deye_tou_params(sp_tp2, inv) registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1)) registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2)) diff --git a/backend/tests/test_control_exporter_tou.py b/backend/tests/test_control_exporter_tou.py index 369857f..79f91b2 100644 --- a/backend/tests/test_control_exporter_tou.py +++ b/backend/tests/test_control_exporter_tou.py @@ -36,7 +36,7 @@ def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterC class DeyeTouParamsTests(unittest.TestCase): def test_sell_uses_reserve_soc(self) -> None: sp = ControlSetpoints( - battery_w=0, + battery_w=-600, grid_export_limit=5000, ev1_current_a=0, ev2_current_a=0, diff --git a/db/migration/V044__deye_register_max_current_a.sql b/db/migration/V044__deye_register_max_current_a.sql index dfa14f6..3c0c7e6 100644 --- a/db/migration/V044__deye_register_max_current_a.sql +++ b/db/migration/V044__deye_register_max_current_a.sql @@ -4,6 +4,6 @@ ALTER TABLE ems.asset_inverter ADD COLUMN IF NOT EXISTS deye_register_max_discharge_a INT NULL; COMMENT ON COLUMN ems.asset_inverter.deye_register_max_charge_a IS - 'Optional cap for holding reg 108 (A); NULL = use only LEAST(W)/51.2 derived max.'; + 'Optional A for reg 108; EMS uses COALESCE(this, FLOOR(LEAST(W)/51.2)) in _load_inverter_config.'; COMMENT ON COLUMN ems.asset_inverter.deye_register_max_discharge_a IS - 'Optional cap for holding reg 109 (A); NULL = use only derived max.'; + 'Optional A for reg 109; EMS uses COALESCE(this, FLOOR(LEAST(W)/51.2)) in _load_inverter_config.'; diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index df55030..47eb5d7 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -12,8 +12,8 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi | Reg | Název | Rozsah | Jednotka | Použití v EMS | |-----|-------|--------|----------|---------------| -| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit nabíjení baterie; horní mez není napříč modely stejná (nižší výkonové řady mívají jiný strop než např. SUN-20K). Volitelně **`asset_inverter.deye_register_max_charge_a`**: tvrdý strop v **A** pro zápis do registru (firmware může oříznout pod hodnotou odvozenou z W/51,2, např. 351→350). | -| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit vybíjení baterie; viz výše. Obdobně **`deye_register_max_discharge_a`**. | +| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | EMS počítá proud v **SQL**: `COALESCE(deye_register_max_charge_a, FLOOR(LEAST(W)/51.2))` — sloupec stropu v **A** je volitelný (NULL = jen odvod z kW); při vyplnění např. 350 při W→351 A se použije 350. | +| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Stejně: `COALESCE(deye_register_max_discharge_a, FLOOR(LEAST(W)/51.2))`. | | 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě | | 130 | Grid charge enable | 0/1 | — | 1 = povolit nabíjení ze sítě | | 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) | @@ -94,7 +94,7 @@ Hodnota registru 142 v non-SELL režimech závisí na fyzické instalaci. Ulože **Varování:** Záměna způsobí chybné měření – pokud site nemá CT a nastaví se „to CT" (2), střídač nevidí skutečný odběr. Naopak pokud má CT ale nastaví se „to load" (1), zátěže mimo load port (např. wallbox) nebudou vidět. -Limity `max_charge_a` / `max_discharge_a` (odvozené z W a BMS) a volitelné stropy **`deye_register_max_charge_a` / `deye_register_max_discharge_a`** pocházejí z DB (`_load_inverter_config`, migrace **V044**). `max_export_power_w` / reg 143 také z DB. +Efektivní **`max_charge_a` / `max_discharge_a`** pro řízení načítá `_load_inverter_config` z DB jedním výrazem **COALESCE(strop v A, FLOOR z W))** (migrace **V044** + dotaz v `control_exporter.py`). `max_export_power_w` / reg 143 také z DB. ## Time Points – řízení podle fyzického režimu