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.
This commit is contained in:
2
.idea/data_source_mapping.xml
generated
2
.idea/data_source_mapping.xml
generated
@@ -1,8 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourcePerFileMappings">
|
||||
<file url="file://$PROJECT_DIR$/../ems-cursor-db-pracovni/debug-forecast.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/../ems-cursor-db-pracovni/porovnani-view-status.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/migration/V009__postgrest_roles.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/db/views/R__z_postgrest_ems_anon_grants.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
<file url="file://$PROJECT_DIR$/scripts/analysis/ote_arbitrage_proxy.sql" value="26ebef46-ff23-475b-8adc-082723263a02" />
|
||||
</component>
|
||||
</project>
|
||||
2
.idea/sqldialects.xml
generated
2
.idea/sqldialects.xml
generated
@@ -2,7 +2,5 @@
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/../ems-cursor-db-pracovni/porovnani-view-status.sql" dialect="PostgreSQL" />
|
||||
<file url="file://$PROJECT_DIR$/db/migration/V009__postgrest_roles.sql" dialect="PostgreSQL" />
|
||||
<file url="file://$PROJECT_DIR$/db/views/R__z_postgrest_ems_anon_grants.sql" dialect="PostgreSQL" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user