baterie pri sell neklesne pod 20% ale pri normalnim provozu muze jit az k 10%, mame tak rezervu a neohrozime si nahly propad procent battery packu

This commit is contained in:
Dusan Vojacek
2026-04-03 21:51:34 +02:00
parent 182d5a37e1
commit af761f0ff7
14 changed files with 659 additions and 173 deletions

View File

@@ -83,7 +83,9 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → přepnutí na **SELF_SUSTAIN** (`fn_set_mode`, `system:mismatch`) + **Discord** alert, pokud je `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`. 17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → přepnutí na **SELF_SUSTAIN** (`fn_set_mode`, `system:mismatch`) + **Discord** alert, pokud je `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`.
18. **Deye zápis registrů 60499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah. **Fyzické režimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True`**0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`, 36 na **23:59**); **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas:** **6264** při každém `control_export` (Europe/Prague). **SELL:** `grid_setpoint_w` < 200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `docs/04-modules/operating-modes.md`. 18. **Deye zápis registrů 60499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzické režimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True`**0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`); bloky **36** neaktivní **2355** (ne 23:59 kvůli firmware), zápis **nejednou častěji než 1× denně** (Europe/Prague) + při změně podpisu (`deye_tou_inactive_signature`: `HHMM|min_soc|reserve_soc|tp_discharge_w`, V028 meta + V029 komentář); **reg 166+** u TP: **SELL** = `reserve_soc_percent`, **PASSIVE** / řádky **36** = `min_soc_percent`. **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas 6264:** jen když se oproti poslednímu zápisu posunula **minuta** (Europe/Prague). **SELL:** `grid_setpoint_w` < 200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `docs/04-modules/operating-modes.md`.
19. **Baterie export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 1112 %, migrace V029 + komentář sloupce).
--- ---

View File

@@ -5,9 +5,10 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os import os
from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from datetime import datetime, timezone from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import asyncpg import asyncpg
@@ -18,6 +19,8 @@ from services.modbus_client import get_modbus_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PRAGUE_TZ = ZoneInfo("Europe/Prague")
# Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md) # Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md)
BATT_VOLTAGE_V = 51.2 BATT_VOLTAGE_V = 51.2
@@ -29,6 +32,16 @@ REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
# verify pak hlásí mismatch. 23:55 je na zařízeních stabilní (viz HHMM jako desítkové číslo). # verify pak hlásí mismatch. 23:55 je na zařízeních stabilní (viz HHMM jako desítkové číslo).
DEYE_TOU_INACTIVE_HHMM = 2355 DEYE_TOU_INACTIVE_HHMM = 2355
# Registry TOU řádků 36 (slot index 2…5): 150153, 156159, … — pro detekci skutečného zápisu po filtru „unchanged“.
_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset(
[
150, 151, 152, 153,
156, 157, 158, 159,
168, 169, 170, 171,
174, 175, 176, 177,
]
)
DEYE_REGISTER_NAMES: dict[int, str] = { DEYE_REGISTER_NAMES: dict[int, str] = {
108: "max_charge_a (max nabíjecí proud baterie)", 108: "max_charge_a (max nabíjecí proud baterie)",
109: "max_discharge_a (max vybíjecí proud baterie)", 109: "max_discharge_a (max vybíjecí proud baterie)",
@@ -100,11 +113,73 @@ class InverterConfig:
no_export: bool no_export: bool
max_battery_charge_w: int | None max_battery_charge_w: int | None
max_battery_discharge_w: int | None max_battery_discharge_w: int | None
min_soc_percent: int | None
reserve_soc_percent: int | None reserve_soc_percent: int | None
max_soc_percent: int | None max_soc_percent: int | None
usable_capacity_wh: int | None usable_capacity_wh: int | None
max_charge_a: int max_charge_a: int
max_discharge_a: int max_discharge_a: int
deye_last_system_time_sync_minute: datetime | None = None
deye_last_tou_inactive_write_prague_date: date | None = None
deye_tou_inactive_signature: str | None = None
def _prague_minute_start_utc() -> datetime:
"""UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague."""
p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
return p.astimezone(timezone.utc)
def _deye_skip_time_registers(inv: InverterConfig) -> bool:
"""True = neposílat 6264 (stejná pražská minuta jako u posledního úspěšného zápisu)."""
last = inv.deye_last_system_time_sync_minute
if last is None:
return False
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
return last.astimezone(timezone.utc) == _prague_minute_start_utc()
async def _fetch_last_verified_inverter_registers(
site_id: int, inverter_asset_id: int, db: asyncpg.Connection
) -> dict[int, int]:
"""
Poslední hodnota na zařízení podle journalu (jen status verified).
Slouží k přeskočení duplicitního zápisu stejné hodnoty.
"""
rows = await db.fetch(
"""
SELECT DISTINCT ON (register)
register,
value_verified
FROM ems.modbus_command
WHERE site_id = $1
AND asset_type = 'inverter'
AND asset_id = $2
AND status = 'verified'
AND value_verified IS NOT NULL
ORDER BY register, verified_at DESC NULLS LAST, id DESC
""",
site_id,
inverter_asset_id,
)
return {int(r["register"]): int(r["value_verified"]) for r in rows}
def _drop_registers_matching_last_verified(
registers: list[tuple[int, str, int]],
last_verified: dict[int, int],
) -> tuple[list[tuple[int, str, int]], list[int]]:
"""Vynechá položky s hodnotou shodnou s posledním ověřeným stavem; vrátí (nový seznam, vynechané reg)."""
out: list[tuple[int, str, int]] = []
skipped: list[int] = []
for reg, meta, val in registers:
lv = last_verified.get(int(reg))
if lv is not None and lv == int(val):
skipped.append(int(reg))
continue
out.append((reg, meta, val))
return out, skipped
@dataclass @dataclass
@@ -181,34 +256,64 @@ async def create_modbus_commands(
return ids return ids
def _modbus_command_contiguous_runs(cmds: list[asyncpg.Record]) -> list[list[asyncpg.Record]]:
"""Seřadí podle adresy registru a rozdělí na souvislé úseky pro FC 0x10 / FC 3."""
if not cmds:
return []
sorted_cmds = sorted(cmds, key=lambda c: int(c["register"]))
runs: list[list[asyncpg.Record]] = []
cur: list[asyncpg.Record] = [sorted_cmds[0]]
for c in sorted_cmds[1:]:
if int(c["register"]) == int(cur[-1]["register"]) + 1:
cur.append(c)
else:
runs.append(cur)
cur = [c]
runs.append(cur)
return runs
async def execute_modbus_commands( async def execute_modbus_commands(
command_ids: list[int], command_ids: list[int],
db: asyncpg.Connection, db: asyncpg.Connection,
) -> bool: ) -> bool:
""" """
Zapíše příkazy z modbus_command do zařízení. Zapíše příkazy z modbus_command do zařízení (FC 0x10 po souvislých blocích).
Aktualizuje status na 'written' nebo 'failed'. Aktualizuje status na 'written' nebo 'failed'.
Vrátí True pokud všechny příkazy uspěly. Vrátí True pokud všechny příkazy uspěly.
""" """
MAX_RETRIES = 3 MAX_RETRIES = 3
RETRY_DELAY = 0.5 RETRY_DELAY = 0.5
all_ok = True rows: list[asyncpg.Record] = []
for cmd_id in command_ids: for cmd_id in command_ids:
cmd = await db.fetchrow( cmd = await db.fetchrow(
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id "SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
) )
if cmd is None: if cmd is not None:
continue rows.append(cmd)
unit = int(cmd["device_unit_id"])
client = await get_modbus_client(cmd["device_host"], int(cmd["device_port"])) if not rows:
return True
by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list)
for cmd in rows:
by_gw[
(cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]))
].append(cmd)
all_ok = True
for (host, port, unit), group in by_gw.items():
client = await get_modbus_client(host, port)
for run in _modbus_command_contiguous_runs(group):
start_reg = int(run[0]["register"])
values = [int(c["value_to_write"]) for c in run]
ids_run = [int(c["id"]) for c in run]
for attempt in range(MAX_RETRIES): for attempt in range(MAX_RETRIES):
try: try:
await client.write_registers( await client.write_registers(start_reg, values, unit)
int(cmd["register"]), for cmd, val in zip(run, values):
[int(cmd["value_to_write"])], cid = int(cmd["id"])
unit,
)
await db.execute( await db.execute(
""" """
UPDATE ems.modbus_command UPDATE ems.modbus_command
@@ -216,29 +321,32 @@ async def execute_modbus_commands(
attempt_count=attempt_count+1, error_msg=NULL attempt_count=attempt_count+1, error_msg=NULL
WHERE id=$2 WHERE id=$2
""", """,
int(cmd["value_to_write"]), val,
cmd_id, cid,
) )
logger.info( logger.info(
"[cmd %s] %s 0x%04X=%s OK (attempt %s)", "[cmd %s] %s 0x%04X=%s OK batch@%s (attempt %s)",
cmd_id, cid,
cmd["asset_code"], cmd["asset_code"],
int(cmd["register"]), int(cmd["register"]),
int(cmd["value_to_write"]), val,
start_reg,
attempt + 1, attempt + 1,
) )
break break
except Exception as e: except Exception as e:
if attempt < MAX_RETRIES - 1: if attempt < MAX_RETRIES - 1:
logger.warning( logger.warning(
"[cmd %s] attempt %s failed: %s, retrying...", "Modbus batch write 0x%04X count=%s attempt %s failed: %s, retrying...",
cmd_id, start_reg,
len(values),
attempt + 1, attempt + 1,
e, e,
) )
await asyncio.sleep(RETRY_DELAY) await asyncio.sleep(RETRY_DELAY)
await client.force_disconnect() await client.force_disconnect()
else: else:
for cmd in run:
await db.execute( await db.execute(
""" """
UPDATE ems.modbus_command UPDATE ems.modbus_command
@@ -247,11 +355,12 @@ async def execute_modbus_commands(
WHERE id=$2 WHERE id=$2
""", """,
str(e), str(e),
cmd_id, int(cmd["id"]),
) )
logger.error( logger.error(
"[cmd %s] all %s attempts failed: %s", "Modbus batch 0x%04X count=%s all %s attempts failed: %s",
cmd_id, start_reg,
len(values),
MAX_RETRIES, MAX_RETRIES,
e, e,
) )
@@ -279,7 +388,7 @@ async def verify_modbus_commands(
site_id: int, site_id: int,
) -> bool: ) -> bool:
""" """
Přečte registry zpět a porovná s value_to_write. Přečte registry zpět (FC 3 po souvislých blocích) a porovná s value_to_write.
Při mismatch: retry → SELF_SUSTAIN + Discord. Při mismatch: retry → SELF_SUSTAIN + Discord.
""" """
from services.notification_service import ( from services.notification_service import (
@@ -287,19 +396,9 @@ async def verify_modbus_commands(
notify_self_sustain_activated, notify_self_sustain_activated,
) )
all_ok = True async def _apply_verify_result(cmd: asyncpg.Record, actual_i: int) -> bool:
for cmd_id in command_ids: """Vrátí True při shodě, False při mismatch (a obslouží retry / SELF_SUSTAIN)."""
cmd = await db.fetchrow( cmd_id = int(cmd["id"])
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
)
if cmd is None or cmd["status"] != "written":
continue
try:
unit = int(cmd["device_unit_id"])
client = await get_modbus_client(cmd["device_host"], int(cmd["device_port"]))
actual = await client.read_register(int(cmd["register"]), unit)
actual_i = int(actual)
expected_i = int(cmd["value_to_write"]) expected_i = int(cmd["value_to_write"])
await db.execute( await db.execute(
""" """
@@ -367,8 +466,8 @@ async def verify_modbus_commands(
f"actual={actual_i}" f"actual={actual_i}"
), ),
) )
all_ok = False return False
else:
logger.info( logger.info(
"[cmd %s] verified OK: %s 0x%04X=%s", "[cmd %s] verified OK: %s 0x%04X=%s",
cmd_id, cmd_id,
@@ -376,8 +475,51 @@ async def verify_modbus_commands(
int(cmd["register"]), int(cmd["register"]),
actual_i, actual_i,
) )
return True
cmds: list[asyncpg.Record] = []
for cmd_id in command_ids:
cmd = await db.fetchrow(
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
)
if cmd is not None and cmd["status"] == "written":
cmds.append(cmd)
if not cmds:
return True
by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list)
for cmd in cmds:
by_gw[
(cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]))
].append(cmd)
all_ok = True
for (host, port, unit), group in by_gw.items():
client = await get_modbus_client(host, port)
for run in _modbus_command_contiguous_runs(group):
start_reg = int(run[0]["register"])
n = len(run)
try:
values = await client.read_holding_registers(start_reg, n, unit)
except Exception as e: except Exception as e:
logger.error("[cmd %s] verify read failed: %s", cmd_id, e) logger.error(
"verify batch read 0x%04X count=%s failed: %s", start_reg, n, e
)
all_ok = False
continue
if len(values) != n:
logger.error(
"verify read 0x%04X: expected %s regs, got %s",
start_reg,
n,
len(values),
)
all_ok = False
continue
for cmd, actual in zip(run, values):
matched = await _apply_verify_result(cmd, int(actual))
if not matched:
all_ok = False all_ok = False
return all_ok return all_ok
@@ -442,9 +584,13 @@ async def _load_inverter_config(
sgc.no_export, sgc.no_export,
ai.max_battery_charge_w, ai.max_battery_charge_w,
ai.max_battery_discharge_w, ai.max_battery_discharge_w,
ab.min_soc_percent,
ab.reserve_soc_percent, ab.reserve_soc_percent,
ab.max_soc_percent, ab.max_soc_percent,
ab.usable_capacity_wh, ab.usable_capacity_wh,
ai.deye_last_system_time_sync_minute,
ai.deye_last_tou_inactive_write_prague_date,
ai.deye_tou_inactive_signature,
LEAST( LEAST(
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w), COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w),
ai.max_battery_charge_w ai.max_battery_charge_w
@@ -494,6 +640,9 @@ async def _load_inverter_config(
max_battery_discharge_w=int(row["max_battery_discharge_w"]) max_battery_discharge_w=int(row["max_battery_discharge_w"])
if row["max_battery_discharge_w"] is not None if row["max_battery_discharge_w"] is not None
else None, else None,
min_soc_percent=int(round(float(row["min_soc_percent"])))
if row["min_soc_percent"] is not None
else None,
reserve_soc_percent=int(row["reserve_soc_percent"]) reserve_soc_percent=int(row["reserve_soc_percent"])
if row["reserve_soc_percent"] is not None if row["reserve_soc_percent"] is not None
else None, else None,
@@ -505,6 +654,11 @@ async def _load_inverter_config(
else None, else None,
max_charge_a=max_charge_a, max_charge_a=max_charge_a,
max_discharge_a=max_discharge_a, max_discharge_a=max_discharge_a,
deye_last_system_time_sync_minute=row["deye_last_system_time_sync_minute"],
deye_last_tou_inactive_write_prague_date=row[
"deye_last_tou_inactive_write_prague_date"
],
deye_tou_inactive_signature=row["deye_tou_inactive_signature"],
) )
@@ -705,6 +859,22 @@ 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)) return max(0, int(max_export_power_w or 0))
def _clamp_deye_tou_soc_pct(pct: int) -> int:
return max(5, min(95, pct))
def _deye_tou_min_soc_pct(inv: InverterConfig) -> int:
if inv.min_soc_percent is not None:
return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent))
return 10
def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int:
if inv.reserve_soc_percent is not None:
return _clamp_deye_tou_soc_pct(int(inv.reserve_soc_percent))
return 20
def get_deye_mode(setpoints: ControlSetpoints) -> str: def get_deye_mode(setpoints: ControlSetpoints) -> str:
""" """
Fyzický režim Deye: SELL | CHARGE | PASSIVE. Fyzický režim Deye: SELL | CHARGE | PASSIVE.
@@ -731,11 +901,12 @@ def _deye_tou_params(
Parametry jednoho Deye time pointu: výkon W, SOC min %, grid_charge. 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. Musí odpovídat logice get_deye_mode / lock_battery v write_inverter_setpoints.
""" """
reserve_soc = inv.reserve_soc_percent or 20
max_batt_w_discharge = int(inv.max_discharge_a * 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 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)
if setpoints.lock_battery: if setpoints.lock_battery:
return tp_discharge_w, reserve_soc, False return tp_discharge_w, tou_min, False
deye_mode = get_deye_mode(setpoints) deye_mode = get_deye_mode(setpoints)
if deye_mode == "CHARGE": if deye_mode == "CHARGE":
raw_bat = setpoints.battery_w raw_bat = setpoints.battery_w
@@ -744,7 +915,9 @@ def _deye_tou_params(
target_soc = max(10, min(95, cap)) target_soc = max(10, min(95, cap))
tp_charge_w = battery_watts_to_amps(battery_w, inv.max_charge_a) * int(BATT_VOLTAGE_V) tp_charge_w = battery_watts_to_amps(battery_w, inv.max_charge_a) * int(BATT_VOLTAGE_V)
return tp_charge_w, target_soc, True return tp_charge_w, target_soc, True
return tp_discharge_w, reserve_soc, False if deye_mode == "SELL":
return tp_discharge_w, tou_reserve, False
return tp_discharge_w, tou_min, False
async def write_inverter_setpoints( async def write_inverter_setpoints(
@@ -762,9 +935,10 @@ async def write_inverter_setpoints(
grid_w = int(setpoints_now.grid_setpoint_w or 0) grid_w = int(setpoints_now.grid_setpoint_w or 0)
no_export = inv.no_export no_export = inv.no_export
export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w) export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w)
reserve_soc = inv.reserve_soc_percent or 20
max_batt_w_discharge = int(inv.max_discharge_a * 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 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)
try: try:
soc_telemetry = await _get_current_soc(site_id, db) soc_telemetry = await _get_current_soc(site_id, db)
@@ -795,9 +969,17 @@ async def write_inverter_setpoints(
) )
now_cet, time_rows = _deye_system_time_register_rows() now_cet, time_rows = _deye_system_time_register_rows()
logger.info("Deye time synced: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S")) skip_time = _deye_skip_time_registers(inv)
if skip_time:
logger.info(
"Deye clock 6264 skipped (same Prague minute as last sync): %s CET",
now_cet.strftime("%Y-%m-%d %H:%M:%S"),
)
else:
logger.info("Deye time will sync: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S"))
registers: list[tuple[int, str, int]] = list(time_rows) registers: list[tuple[int, str, int]] = [] if skip_time else list(time_rows)
time_rows_were_scheduled = not skip_time
sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now
hh_cur = current_slot_hhmm() hh_cur = current_slot_hhmm()
@@ -807,12 +989,25 @@ async def write_inverter_setpoints(
registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1)) registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1))
registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2)) registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2))
prague_date = datetime.now(PRAGUE_TZ).date()
inactive_sig = (
f"{DEYE_TOU_INACTIVE_HHMM}|{tou_min_pct}|{tou_reserve_pct}|{tp_discharge_w}"
)
need_inactive_tou = (
inv.deye_last_tou_inactive_write_prague_date != prague_date
or inv.deye_tou_inactive_signature != inactive_sig
)
if need_inactive_tou:
for idx in range(2, 6): for idx in range(2, 6):
registers.extend( registers.extend(
_deye_time_point_rows( _deye_time_point_rows(
idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, reserve_soc, False idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, tou_min_pct, False
) )
) )
else:
logger.debug(
"Deye TOU rows 36 skipped (already written today, signature unchanged)"
)
registers.extend( registers.extend(
[ [
@@ -841,6 +1036,53 @@ async def write_inverter_setpoints(
grid_w, grid_w,
) )
last_verified = await _fetch_last_verified_inverter_registers(site_id, inv.id, db)
registers, skipped_unchanged = _drop_registers_matching_last_verified(
registers, last_verified
)
if skipped_unchanged:
logger.info(
"[control] %s: skip %s registers (value equals last verified): %s",
inv.code,
len(skipped_unchanged),
skipped_unchanged[:24],
)
if not registers:
logger.info(
"[control] %s: all Deye holding regs match last verified, no Modbus write",
inv.code,
)
if need_inactive_tou:
await db.execute(
"""
UPDATE ems.asset_inverter
SET deye_last_tou_inactive_write_prague_date = $1,
deye_tou_inactive_signature = $2
WHERE id = $3
""",
prague_date,
inactive_sig,
inv.id,
)
if time_rows_were_scheduled:
await db.execute(
"""
UPDATE ems.asset_inverter
SET deye_last_system_time_sync_minute = $1
WHERE id = $2
""",
_prague_minute_start_utc(),
inv.id,
)
return (
f"OK inverter: batt_w={raw_bat!r} (no changes vs last verified Modbus snapshot)"
)
will_write_time = any(int(r) in (62, 63, 64) for r, _, _ in registers)
will_write_inactive = any(
int(r) in _DEYE_INACTIVE_TOU_REGISTERS for r, _, _ in registers
)
cmd_ids = await create_modbus_commands( cmd_ids = await create_modbus_commands(
site_id, site_id,
planning_run_id, planning_run_id,
@@ -857,6 +1099,30 @@ async def write_inverter_setpoints(
if not await execute_modbus_commands(cmd_ids, db): if not await execute_modbus_commands(cmd_ids, db):
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)" return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
logger.info("[control] Inverter %s journal write OK", inv.code) logger.info("[control] Inverter %s journal write OK", inv.code)
minute_utc = _prague_minute_start_utc()
if will_write_time:
await db.execute(
"""
UPDATE ems.asset_inverter
SET deye_last_system_time_sync_minute = $1
WHERE id = $2
""",
minute_utc,
inv.id,
)
if need_inactive_tou or will_write_inactive:
await db.execute(
"""
UPDATE ems.asset_inverter
SET deye_last_tou_inactive_write_prague_date = $1,
deye_tou_inactive_signature = $2
WHERE id = $3
""",
prague_date,
inactive_sig,
inv.id,
)
except Exception as e: except Exception as e:
return f"FAIL inverter: {inv.code}: {e}" return f"FAIL inverter: {inv.code}: {e}"

View File

@@ -233,6 +233,17 @@ class PersistentModbusClient:
raw = await self.read_register(address, device_id) raw = await self.read_register(address, device_id)
return raw - 65536 if raw > 32767 else raw return raw - 65536 if raw > 32767 else raw
async def read_holding_registers(
self, address: int, count: int, device_id: int = 1
) -> list[int]:
"""FC 0x03 souvislé holding registry (ověřování po blocích)."""
async with _gateway_exclusive(self.host, self.port):
async with self._lock:
await self._ensure_connected()
return await self._read_holding_registers_locked(
address, count, device_id
)
async def write_register(self, address: int, value: int, device_id: int = 1) -> bool: async def write_register(self, address: int, value: int, device_id: int = 1) -> bool:
async with _gateway_exclusive(self.host, self.port): async with _gateway_exclusive(self.host, self.port):
async with self._lock: async with self._lock:

View File

@@ -31,6 +31,8 @@ SLOT_WEIGHT_MEDIUM = 0.7 # 3672h
SLOT_WEIGHT_LOW = 0.4 # 7296h SLOT_WEIGHT_LOW = 0.4 # 7296h
CURTAILMENT_PENALTY = 0.001 # Kč/Wh malá penalizace za omezení FVE pole A CURTAILMENT_PENALTY = 0.001 # Kč/Wh malá penalizace za omezení FVE pole A
SOLVER_TIME_LIMIT = 10 # sekund SOLVER_TIME_LIMIT = 10 # sekund
# MILP: jakýkoli významný výkon exportu ge (W) ⇒ koncové soc[t] ≥ arb_base_wh (rezerva z DB)
GE_MIN_EXPORT_W = 1.0
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
@@ -332,6 +334,7 @@ def solve_dispatch(
bd = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)] bd = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)]
soc = [pulp.LpVariable(f"soc_{t}", min_soc_wh, battery.soc_max_wh) for t in range(T)] soc = [pulp.LpVariable(f"soc_{t}", min_soc_wh, battery.soc_max_wh) for t in range(T)]
w_arb = [pulp.LpVariable(f"w_arb_{t}", cat=pulp.LpBinary) for t in range(T)] w_arb = [pulp.LpVariable(f"w_arb_{t}", cat=pulp.LpBinary) for t in range(T)]
z_export = [pulp.LpVariable(f"z_export_{t}", cat=pulp.LpBinary) for t in range(T)]
ca = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)] ca = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)]
hp = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)] hp = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)]
soc_deficit_24h = pulp.LpVariable("soc_deficit_24h", 0, battery.usable_capacity_wh) soc_deficit_24h = pulp.LpVariable("soc_deficit_24h", 0, battery.usable_capacity_wh)
@@ -410,6 +413,13 @@ def solve_dispatch(
+ battery.max_discharge_power_w * w_arb[t] + battery.max_discharge_power_w * w_arb[t]
) )
# Významný export ⇒ koncové SoC ≥ ekonomická rezerva (arb_base_wh), ne dynamická arb_floor_series
m_ge = float(grid.max_export_power_w)
m_soc_bigm = float(battery.usable_capacity_wh)
prob += ge[t] <= m_ge * z_export[t]
prob += ge[t] >= GE_MIN_EXPORT_W * z_export[t]
prob += soc[t] >= arb_base_wh - m_soc_bigm * (1 - z_export[t])
# EV limity a připojení # EV limity a připojení
for e in range(EV): for e in range(EV):
connected = ( connected = (

View File

@@ -0,0 +1,108 @@
"""Deye TOU SOC % podle fyzického režimu (SELL vs PASSIVE)."""
from __future__ import annotations
import unittest
from services.control_exporter import (
ControlSetpoints,
InverterConfig,
_deye_tou_params,
get_deye_mode,
)
def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterConfig:
return InverterConfig(
id=1,
code="deye-main",
host="127.0.0.1",
port=502,
unit_id=1,
max_export_power_w=13_500,
max_import_power_w=13_500,
no_export=False,
max_battery_charge_w=10_000,
max_battery_discharge_w=10_000,
min_soc_percent=min_soc,
reserve_soc_percent=reserve_soc,
max_soc_percent=95,
usable_capacity_wh=64_000,
max_charge_a=100,
max_discharge_a=100,
)
class DeyeTouParamsTests(unittest.TestCase):
def test_sell_uses_reserve_soc(self) -> None:
sp = ControlSetpoints(
battery_w=0,
grid_export_limit=5000,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=-500,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=50,
)
self.assertEqual(get_deye_mode(sp), "SELL")
p, s, g = _deye_tou_params(sp, _inv())
self.assertFalse(g)
self.assertEqual(s, 20)
def test_passive_uses_min_soc(self) -> None:
sp = ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
)
self.assertEqual(get_deye_mode(sp), "PASSIVE")
p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
self.assertFalse(g)
self.assertEqual(s, 12)
def test_charge_unchanged_grid_charge(self) -> None:
sp = ControlSetpoints(
battery_w=5000,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=5000,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=80,
)
self.assertEqual(get_deye_mode(sp), "CHARGE")
_p, s, g = _deye_tou_params(sp, _inv())
self.assertTrue(g)
self.assertEqual(s, 95)
def test_lock_battery_uses_min_soc(self) -> None:
sp = ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=-500,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
lock_battery=True,
)
p, s, g = _deye_tou_params(sp, _inv(min_soc=12))
self.assertEqual(p, 0)
self.assertFalse(g)
self.assertEqual(s, 12)
if __name__ == "__main__":
unittest.main()

View File

@@ -210,6 +210,54 @@ class PlanningDispatchMilpTests(unittest.TestCase):
) )
self.assertGreaterEqual(results[0].grid_setpoint_w, 0) self.assertGreaterEqual(results[0].grid_setpoint_w, 0)
def test_export_implies_end_soc_at_least_reserve(self) -> None:
"""Při ge >= 1 W musí koncové soc[t] >= arb_base_wh (rezerva z DB)."""
slots = [
_slot(load=500, buy=2.0, sell=8.0, pv_a=0, pv_b=0),
_slot(load=500, buy=2.0, sell=8.0, pv_a=0, pv_b=0),
]
battery = _battery(uc_wh=100_000.0, min_pct=10.0, arb_pct=20.0)
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=50_000, max_export_power_w=50_000)
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 = 0.22 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
price_failsafe_active=False,
)
reserve_pct = 20.0
for r in results:
if r.grid_setpoint_w < 0:
self.assertGreaterEqual(
r.battery_soc_target,
reserve_pct - 0.2,
msg="export slot must end at or above reserve SoC",
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,15 @@
-- Metadata pro řidší zápis Deye: čas 6264 (max 1× za minutu Prahy), TOU 36 (1× denně + při změně profilu)
ALTER TABLE ems.asset_inverter
ADD COLUMN IF NOT EXISTS deye_last_system_time_sync_minute TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS deye_last_tou_inactive_write_prague_date DATE,
ADD COLUMN IF NOT EXISTS deye_tou_inactive_signature TEXT;
COMMENT ON COLUMN ems.asset_inverter.deye_last_system_time_sync_minute IS
'UTC okamžik začátku minuty Europe/Prague, pro kterou byl naposledy EMS úspěšně zapsán čas invertoru (reg 6264). Při exportu ve stejné minutě se 6264 přeskočí.';
COMMENT ON COLUMN ems.asset_inverter.deye_last_tou_inactive_write_prague_date IS
'Kalendářní datum v Europe/Prague posledního zápisu neaktivních TOU řádků 36 (slot index 2…5).';
COMMENT ON COLUMN ems.asset_inverter.deye_tou_inactive_signature IS
'Podpis parametrů neaktivních řádků (HHMM|reserve_soc|tp_discharge_w); při změně se TOU 36 zapíše okamžitě i mimo denní cyklus.';

View File

@@ -0,0 +1,14 @@
-- EMS: provozní význam min_soc (paralelní packy), home-01 bump, popis podpisu TOU 36
COMMENT ON COLUMN ems.asset_battery.min_soc_percent IS
'Nejnižší SoC v % pro plán (LP) a runtime clamp telemetrie. U více paralelních stringů držet nad holým BMS minimem kvůli nevyvážení — doporučeno 1112 % místo těsných 10 %.';
UPDATE ems.asset_battery ab
SET min_soc_percent = 12.00
FROM ems.site s
WHERE ab.site_id = s.id
AND s.code = 'home-01'
AND ab.min_soc_percent = 10.00;
COMMENT ON COLUMN ems.asset_inverter.deye_tou_inactive_signature IS
'Podpis neaktivních TOU řádků 36: HHMM|min_soc_pct|reserve_soc_pct|tp_discharge_w (Europe/Prague metadata); při změně se řádky zapíší znovu.';

View File

@@ -110,8 +110,8 @@ CREATE TABLE asset_battery (
inverter_id INT REFERENCES asset_inverter(id), inverter_id INT REFERENCES asset_inverter(id),
code TEXT NOT NULL, code TEXT NOT NULL,
usable_capacity_wh INT NOT NULL, -- 64000 usable_capacity_wh INT NOT NULL, -- 64000
min_soc_percent NUMERIC(5,2) DEFAULT 10, -- absolutní podlaha LP min_soc_percent NUMERIC(5,2) DEFAULT 10, -- provozspodní mez LP + clamp; u paralelních packů často 1112
reserve_soc_percent NUMERIC(5,2) DEFAULT 20, -- ekonomická podlaha (export/arbitráž) reserve_soc_percent NUMERIC(5,2) DEFAULT 20, -- ekonomická podlaha; MILP export (ge≥1 W) drží soc[t] ≥ tato rezerva (arb_base_wh)
max_soc_percent NUMERIC(5,2) DEFAULT 95, max_soc_percent NUMERIC(5,2) DEFAULT 95,
charge_efficiency NUMERIC(5,4) DEFAULT 0.95, charge_efficiency NUMERIC(5,4) DEFAULT 0.95,
discharge_efficiency NUMERIC(5,4) DEFAULT 0.95, discharge_efficiency NUMERIC(5,4) DEFAULT 0.95,

View File

@@ -109,6 +109,8 @@ def apply_overrides(plan, overrides) -> Setpoints:
## Zápis do Deye (Modbus) ## Zápis do Deye (Modbus)
**TOU (time points, reg. 166+):** SOC závisí na fyzickém režimu z `get_deye_mode`**SELL** zapisuje ekonomickou rezervu (`reserve_soc_percent`), **PASSIVE** a neaktivní řádky **36** provozní minimum (`min_soc_percent`). Viz [`modbus-registers.md`](modbus-registers.md).
```python ```python
async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db): async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db):
inverters = await db.fetch( inverters = await db.fetch(

View File

@@ -30,7 +30,9 @@ Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_swi
## Střídač (Deye) ## Střídač (Deye)
`write_inverter_setpoints` přidá do journalu mimo **6264** (čas) a **time pointy 148177** také řádky pro **108** (max charge A), **109** (max discharge A), **141** (energy mode, vždy 0), **142** (limit control), **178** (pevné hodnoty 32 / 48 podle fyzického režimu, bez read-modify-write), **143** (export limit W). Každý řádek daného exportního běhu má vyplněný **`deye_physical_mode`** (**PASSIVE** / **SELL** / **CHARGE**) pro audit přepínání. **Reg 191** EMS nezapisuje (SolarmanApp). Převod výkonu baterie na proud: `battery_watts_to_amps` viz `modbus-registers.md`. Všechny zápisy journalu jdou přes **`write_registers`** (FC **0x10**), ne FC 0x06. Detail režimů a registrů: `docs/04-modules/modbus-registers.md`. `write_inverter_setpoints` přidá do journalu podle potřeby **6264** (čas — ne každý export) a **time pointy 148177** (bloky 36 typicky jednou denně; viz `modbus-registers.md`), dále **108**, **109**, **141**, **142**, **178**, **143**. Každý řádek daného exportního běhu má **`deye_physical_mode`** (**PASSIVE** / **SELL** / **CHARGE**). **Reg 191** EMS nezapisuje (SolarmanApp). Převod výkonu: `battery_watts_to_amps` v `modbus-registers.md`.
**Dávky:** `execute_modbus_commands` slučuje souvislé adresy do jednoho **`write_registers`** (FC **0x10**). `verify_modbus_commands` čte zpět po souvislých blocích (`read_holding_registers`, FC 0x03). Detail režimů: `modbus-registers.md`.
## APScheduler ## APScheduler

View File

@@ -23,7 +23,7 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi
| 190 | GEN peak shaving | 016000 | 1 W | Peak shaving na GEN portu | | 190 | GEN peak shaving | 016000 | 1 W | Peak shaving na GEN portu |
| 191 | Grid peak shaving power | 016000 | 1 W | **EMS NEZAPISUJE** nastavit **manuálně v SolarmanApp**. Hodnota určuje výkon peak shavingu v **W**. | | 191 | Grid peak shaving power | 016000 | 1 W | **EMS NEZAPISUJE** nastavit **manuálně v SolarmanApp**. Hodnota určuje výkon peak shavingu v **W**. |
`control_exporter.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal + verifikace) po řadě: **6264** (čas), **time points 148177**, **108, 109, 141, 142, 178, 143**. Popisné názvy registrů v DB bere `DEYE_REGISTER_NAMES` v `control_exporter.py`. **Reg 191** do journalu nepatří EMS ho nezapisuje. `control_exporter.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal; jeden řádek na registr) a **`execute_modbus_commands`** odesílá **souvislé bloky jedním FC 0x10** (např. 6264, 148159, 166177, 108109, 141142 podle toho, co je ve frontě). Pořadí v journalu: **6264** (čas, viz níže), **time points 148177** (jen řádky zařazené do daného běhu), **108, 109, 141, 142, 178, 143**. Popisné názvy v DB bere `DEYE_REGISTER_NAMES`. **Reg 191** EMS nezapisuje.
### Reg 191 (výkon grid peak shaving) ### Reg 191 (výkon grid peak shaving)
@@ -68,7 +68,7 @@ Všechny limity (`max_charge_a`, `max_discharge_a`, `max_export_power_w` / reg 1
## Time Points řízení podle fyzického režimu ## Time Points řízení podle fyzického režimu
Deye má 6 časových bloků. EMS přepisuje **bloky 12** při každém `control_export` (cron např. :14, :29, :44, :59). Deye má 6 časových bloků. EMS přepisuje **bloky 12** (TOU index 01) při každém `control_export`. **Bloky 36** (neaktivní výplň, čas **2355**) zapisuje **nejednou častěji než jednou za kalendářní den v Europe/Prague** a **okamžitě znovu**, pokud se změní **podpis** `deye_tou_inactive_signature` (`HHMM|min_soc|reserve_soc|tp_discharge_w`) — metadata v `asset_inverter` (V028 + V029 komentář).
**Výběr aktivního segmentu na invertoru:** platí poslední časový bod, jehož **HH:MM ≤ aktuálnímu času** na hodinách střídače (po synchronizaci 6264). Proto **nesmí** zůstat jako jediný „minulý“ bod např. **00:00** s pasivním profilem, zatímco profil s nabíjením ze sítě je až u budoucího času mezi půlnocí a tím budoucím časem by invertor celou dobu používal špatný segment. **Výběr aktivního segmentu na invertoru:** platí poslední časový bod, jehož **HH:MM ≤ aktuálnímu času** na hodinách střídače (po synchronizaci 6264). Proto **nesmí** zůstat jako jediný „minulý“ bod např. **00:00** s pasivním profilem, zatímco profil s nabíjením ze sítě je až u budoucího času mezi půlnocí a tím budoucím časem by invertor celou dobu používal špatný segment.
@@ -76,7 +76,7 @@ Deye má 6 časových bloků. EMS přepisuje **bloky 12** při každém `cont
|------|---------------------------|-------------|------|---------|-------------| |------|---------------------------|-------------|------|---------|-------------|
| 1 | **`current_slot_hhmm()`** začátek **probíhajícího** 15min slotu | `planning_interval` pro **aktuální** slot (`_fetch_plan_row_for_slot_offset(..., 0)`) | PASSIVE / SELL / CHARGE dle `_deye_tou_params` | viz tabulka níže | viz tabulka níže | | 1 | **`current_slot_hhmm()`** začátek **probíhajícího** 15min slotu | `planning_interval` pro **aktuální** slot (`_fetch_plan_row_for_slot_offset(..., 0)`) | PASSIVE / SELL / CHARGE dle `_deye_tou_params` | viz tabulka níže | viz tabulka níže |
| 2 | **`next_slot_hhmm()`** začátek **následujícího** 15min slotu | `planning_interval` pro **další** slot (`_fetch_plan_row_for_slot_offset(..., 1)`) | Přechod na další čtvrthodinu | viz tabulka níže | viz tabulka níže | | 2 | **`next_slot_hhmm()`** začátek **následujícího** 15min slotu | `planning_interval` pro **další** slot (`_fetch_plan_row_for_slot_offset(..., 1)`) | Přechod na další čtvrthodinu | viz tabulka níže | viz tabulka níže |
| 36 | **23:55** (2355) | — | Neaktivní (rezerva); ne 23:59 — firmware Deye často 2359 neuloží → verify mismatch | `reserve_soc` (DB) | NE | | 36 | **23:55** (2355) | — | Neaktivní (pasivní profil); ne 23:59 — firmware Deye často 2359 neuloží → verify mismatch | **`min_soc_percent`** (DB) | NE |
**Registry 108 / 109 / 142 / 178 / 143** odpovídají **aktuálnímu** plánu (okamžitý výstup; `setpoints_now` v `write_inverter_setpoints`). TOU řádky 12 doplňují stejnou logiku pro časové segmenty (`_deye_tou_params`). **Registry 108 / 109 / 142 / 178 / 143** odpovídají **aktuálnímu** plánu (okamžitý výstup; `setpoints_now` v `write_inverter_setpoints`). TOU řádky 12 doplňují stejnou logiku pro časové segmenty (`_deye_tou_params`).
@@ -84,23 +84,25 @@ Příklad v 14:18: blok 1 má čas **1415**, blok 2 čas **1430** mezi 14:15
### Fyzické režimy Deye parametry jednoho time pointu (bloky 12) ### Fyzické režimy Deye parametry jednoho time pointu (bloky 12)
| Režim | Výkon (W) | SOC min | Grid charge | | Režim | Výkon (W) | SOC min (reg 166+) | Grid charge |
|-------|-----------|---------|-------------| |-------|-----------|---------------------|-------------|
| **PASSIVE** | `max_discharge_a × 51,2` | rezerva z DB | NE | | **PASSIVE** | `max_discharge_a × 51,2` | **`min_soc_percent`** z DB | NE |
| **SELL** | `max_discharge_a × 51,2` | rezerva z DB | NE | | **SELL** | `max_discharge_a × 51,2` | **`reserve_soc_percent`** z DB | NE |
| **CHARGE** | `battery_watts_to_amps(battery_w, max_charge_a) × 51,2` | min(95, cíl SoC z plánu nebo 80) | ANO | | **CHARGE** | `battery_watts_to_amps(battery_w, max_charge_a) × 51,2` | min(95, cíl SoC z plánu nebo 80) | ANO |
Bloky 36 zůstávají na **23:59** s pasivním profilem (`reserve_soc`, grid charge = NE). Bloky 36 používají čas **2355** a stejnou **SOC** hodnotu jako PASSIVE (`min_soc_percent`, grid charge = NE).
### Synchronizace času ### Synchronizace času
Registry **6264** se při každém `control_export` nastaví na aktuální čas v **Europe/Prague**: Registry **6264** nastavují invertoru čas v **Europe/Prague**. **EMS je do fronty nezařadí**, pokud ještě neuplynula **nová kalendářní minuta** oproti poslednímu úspěšnému zápisu (sloupec `asset_inverter.deye_last_system_time_sync_minute`). Jinak platí:
- reg **62:** `(rok - 2000) << 8 | měsíc` - reg **62:** `(rok - 2000) << 8 | měsíc`
- reg **63:** `den << 8 | hodina` - reg **63:** `den << 8 | hodina`
- reg **64:** `minuta << 8 | sekunda` - reg **64:** `minuta << 8 | sekunda`
Zápis time pointů i systémového času prochází stejným **`modbus_command`** journal jako registry 108 / 109 / 141 / 142 / 178 / 143 (FC 0x10 po jednom registru). Zápis prochází journal jako každý jiný registr; na sběrnici jde souvislý blok **FC 0x10**.
**Před vytvořením journalu:** pokud je navrhovaná hodnota **shodná s posledním `verified`** záznamem daného registru v `modbus_command`, EMS **řádek nevytvoří** a na Modbus neposílá (žádný „X → X“ zápis jen kvůli periodickému exportu). Výjimky řeší stávající logika (nová minuta u 6264, denní TOU 36 + meta sloupce na `asset_inverter`).
### Mapování registrů (time point *i*, i = 0…5) ### Mapování registrů (time point *i*, i = 0…5)

View File

@@ -13,8 +13,9 @@
- **Runtime guard v exportu setpointů:** - **Runtime guard v exportu setpointů:**
- při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování. - při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování.
- **Ekonomika baterie:** - **Ekonomika baterie:**
- `min_soc_percent` = tvrdá spodní mez SoC v LP (typicky 10 %), - `min_soc_percent` = nejnižší SoC v LP a runtime clamp telemetrie; u **více paralelních stringů** držet **nad** holým BMS minimem (typicky **1112 %**; migrace **V029** + komentář v DB, u `home-01` cílený UPDATE z 10 %),
- `reserve_soc_percent` = ekonomická („arbitrážní“) podlaha pod ní MILP s binární proměnnou omezuje vybíjení tak, aby export z baterie nečerpal hluboké pásmo (typicky 20 %; migrace V027 může vrátit hodnotu po V026), - `reserve_soc_percent` = ekonomická („arbitrážní“) podlaha pod ní MILP s `w_arb` omezuje vybíjení podle začátku slotu a FVE lookahead (`arb_floor_series`; typicky 20 %),
- **Export ze site:** binárka `z_export[t]` pokud `grid_export ≥ 1` W, musí být **koncové** `soc[t] ≥ arb_base_wh` (fixní z DB, **ne** dynamicky snížená `arb_floor_series`),
- `degradation_cost_czk_kwh` (např. 0.15) / penalizace cyklu v objective symetrická (`0.5*(charge+discharge)`). - `degradation_cost_czk_kwh` (např. 0.15) / penalizace cyklu v objective symetrická (`0.5*(charge+discharge)`).
- **PV-aware nejistota:** - **PV-aware nejistota:**
- objective používá `pv_scarcity_factor` (0.65..1.0), odvozený z forecastu slunce, - objective používá `pv_scarcity_factor` (0.65..1.0), odvozený z forecastu slunce,
@@ -184,10 +185,13 @@ soc[0] == current_soc_wh # počáteční podmínka z telemetrie
### SoC limity ### SoC limity
```python ```python
soc_min_wh <= soc[t] <= soc_max_wh # min_soc_percent z DB (např. 10 %) soc_min_wh <= soc[t] <= soc_max_wh # min_soc_percent z DB (provozní podlaha, často 1112 %)
# Ekonomická podlaha (reserve_soc_percent): w_arb[t] + arb_floor_series[t]
# bd omezeno podle soc na začátku slotu (žádné „nadbytečné“ vybíjení z hlubokého pásma při exportu z AKU).
# Při grid_export[t] >= 1 W: soc[t] >= arb_base_wh (rezerva z DB, ne časová řada arb_floor).
# Ekonomická podlaha (reserve_soc_percent, např. 20 %): binární w_arb[t] v MILP
# pod touto hranicí je bd omezeno na load+EV+TČ+bc (žádné „nadbytečné“ vybíjení pro export z baterie).
# Měkký buffer na konci 24h dál přes soc_deficit_24h. # Měkký buffer na konci 24h dál přes soc_deficit_24h.
``` ```

View File

@@ -18,6 +18,8 @@ Tento soubor slouží jako živý seznam věcí které je potřeba rozhodnout p
## Důležité (neblokují, ale řeší se brzy) ## Důležité (neblokují, ale řeší se brzy)
- [ ] **Dvě úrovně min SoC v DB** Dnes jedno `min_soc_percent` (provozní podlaha pro LP i TOU PASSIVE). Budoucí oddělení „tvrdé BMS minimum“ vs „plánovací minimum“ by vyžadovalo nový sloupec nebo politiku per site.
- [ ] **TUV výkon** Je TUV výkon měřitelný zvlášť nebo jen ON/OFF? Pokud jen ON/OFF, použijeme `asset_flexible_device.max_power_w` jako aproximaci. - [ ] **TUV výkon** Je TUV výkon měřitelný zvlášť nebo jen ON/OFF? Pokud jen ON/OFF, použijeme `asset_flexible_device.max_power_w` jako aproximaci.
- [ ] **Pole B (ongridový)** Zahrnout do auditu jako "neřízená výroba"? Nebo ignorovat úplně? Komplikuje audit ale zpřesňuje ho. - [ ] **Pole B (ongridový)** Zahrnout do auditu jako "neřízená výroba"? Nebo ignorovat úplně? Komplikuje audit ale zpřesňuje ho.