diff --git a/CLAUDE.md b/CLAUDE.md index 8764e56..0b8255f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. -18. **Deye zápis registrů 60–499:** 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`, 3–6 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:** **62–64** 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ů 60–499:** 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 **3–6** 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 **3–6** = `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 62–64:** 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 11–12 %, migrace V029 + komentář sloupce). --- diff --git a/backend/services/control_exporter.py b/backend/services/control_exporter.py index a615c79..809060b 100644 --- a/backend/services/control_exporter.py +++ b/backend/services/control_exporter.py @@ -5,9 +5,10 @@ from __future__ import annotations import asyncio import logging import os +from collections import defaultdict from dataclasses import dataclass from typing import Any -from datetime import datetime, timezone +from datetime import date, datetime, timezone from zoneinfo import ZoneInfo import asyncpg @@ -18,6 +19,8 @@ from services.modbus_client import get_modbus_client 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) 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). DEYE_TOU_INACTIVE_HHMM = 2355 +# Registry TOU řádků 3–6 (slot index 2…5): 150–153, 156–159, … — 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] = { 108: "max_charge_a (max nabíjecí proud baterie)", 109: "max_discharge_a (max vybíjecí proud baterie)", @@ -100,11 +113,73 @@ class InverterConfig: no_export: bool max_battery_charge_w: int | None max_battery_discharge_w: int | None + min_soc_percent: int | None reserve_soc_percent: int | None max_soc_percent: int | None usable_capacity_wh: int | None max_charge_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 62–64 (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 @@ -181,81 +256,115 @@ async def create_modbus_commands( 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( command_ids: list[int], db: asyncpg.Connection, ) -> 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'. Vrátí True pokud všechny příkazy uspěly. """ MAX_RETRIES = 3 RETRY_DELAY = 0.5 - all_ok = True + rows: 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 None: - continue - unit = int(cmd["device_unit_id"]) - client = await get_modbus_client(cmd["device_host"], int(cmd["device_port"])) - for attempt in range(MAX_RETRIES): - try: - await client.write_registers( - int(cmd["register"]), - [int(cmd["value_to_write"])], - unit, - ) - await db.execute( - """ - UPDATE ems.modbus_command - SET status='written', value_written=$1, written_at=now(), - attempt_count=attempt_count+1, error_msg=NULL - WHERE id=$2 - """, - int(cmd["value_to_write"]), - cmd_id, - ) - logger.info( - "[cmd %s] %s 0x%04X=%s OK (attempt %s)", - cmd_id, - cmd["asset_code"], - int(cmd["register"]), - int(cmd["value_to_write"]), - attempt + 1, - ) - break - except Exception as e: - if attempt < MAX_RETRIES - 1: - logger.warning( - "[cmd %s] attempt %s failed: %s, retrying...", - cmd_id, - attempt + 1, - e, - ) - await asyncio.sleep(RETRY_DELAY) - await client.force_disconnect() - else: - await db.execute( - """ - UPDATE ems.modbus_command - SET status='failed', error_msg=$1, - attempt_count=attempt_count+1 - WHERE id=$2 - """, - str(e), - cmd_id, - ) - logger.error( - "[cmd %s] all %s attempts failed: %s", - cmd_id, - MAX_RETRIES, - e, - ) - all_ok = False + if cmd is not None: + rows.append(cmd) + + 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): + try: + await client.write_registers(start_reg, values, unit) + for cmd, val in zip(run, values): + cid = int(cmd["id"]) + await db.execute( + """ + UPDATE ems.modbus_command + SET status='written', value_written=$1, written_at=now(), + attempt_count=attempt_count+1, error_msg=NULL + WHERE id=$2 + """, + val, + cid, + ) + logger.info( + "[cmd %s] %s 0x%04X=%s OK batch@%s (attempt %s)", + cid, + cmd["asset_code"], + int(cmd["register"]), + val, + start_reg, + attempt + 1, + ) + break + except Exception as e: + if attempt < MAX_RETRIES - 1: + logger.warning( + "Modbus batch write 0x%04X count=%s attempt %s failed: %s, retrying...", + start_reg, + len(values), + attempt + 1, + e, + ) + await asyncio.sleep(RETRY_DELAY) + await client.force_disconnect() + else: + for cmd in run: + await db.execute( + """ + UPDATE ems.modbus_command + SET status='failed', error_msg=$1, + attempt_count=attempt_count+1 + WHERE id=$2 + """, + str(e), + int(cmd["id"]), + ) + logger.error( + "Modbus batch 0x%04X count=%s all %s attempts failed: %s", + start_reg, + len(values), + MAX_RETRIES, + e, + ) + all_ok = False return all_ok @@ -279,7 +388,7 @@ async def verify_modbus_commands( site_id: int, ) -> 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. """ from services.notification_service import ( @@ -287,98 +396,131 @@ async def verify_modbus_commands( notify_self_sustain_activated, ) - all_ok = True + async def _apply_verify_result(cmd: asyncpg.Record, actual_i: int) -> bool: + """Vrátí True při shodě, False při mismatch (a obslouží retry / SELF_SUSTAIN).""" + cmd_id = int(cmd["id"]) + expected_i = int(cmd["value_to_write"]) + await db.execute( + """ + UPDATE ems.modbus_command + SET value_verified=$1::int, verified_at=now(), + status=CASE WHEN $1::int = $2::int THEN 'verified' ELSE 'mismatch' END + WHERE id=$3::int + """, + actual_i, + expected_i, + cmd_id, + ) + + if actual_i != expected_i: + logger.error( + "[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s", + cmd_id, + cmd["asset_code"], + int(cmd["register"]), + expected_i, + actual_i, + ) + row_ac = await db.fetchrow( + "SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id + ) + attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0 + await notify_modbus_mismatch( + cmd["asset_code"], + int(cmd["register"]), + cmd["register_name"] or "", + expected_i, + actual_i, + attempts, + ) + + if attempts < 3: + await db.execute( + "UPDATE ems.modbus_command SET status='retrying' WHERE id=$1", + cmd_id, + ) + await execute_modbus_commands([cmd_id], db) + await verify_modbus_commands([cmd_id], db, site_id) + else: + logger.critical( + "[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN", + cmd_id, + ) + site = await db.fetchrow( + "SELECT code FROM ems.site WHERE id=$1", site_id + ) + await _switch_to_self_sustain( + site_id, + db, + reason=( + f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} " + f"reg 0x{cmd['register']:04X}" + ), + ) + if site: + await notify_self_sustain_activated( + site["code"], + ( + f"Modbus mismatch: {cmd['asset_code']} " + f"0x{cmd['register']:04X} expected={expected_i} " + f"actual={actual_i}" + ), + ) + return False + + logger.info( + "[cmd %s] verified OK: %s 0x%04X=%s", + cmd_id, + cmd["asset_code"], + int(cmd["register"]), + 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 None or cmd["status"] != "written": - continue + if cmd is not None and cmd["status"] == "written": + cmds.append(cmd) - 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"]) - await db.execute( - """ - UPDATE ems.modbus_command - SET value_verified=$1::int, verified_at=now(), - status=CASE WHEN $1::int = $2::int THEN 'verified' ELSE 'mismatch' END - WHERE id=$3::int - """, - actual_i, - expected_i, - cmd_id, - ) + if not cmds: + return True - if actual_i != expected_i: + 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: logger.error( - "[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s", - cmd_id, - cmd["asset_code"], - int(cmd["register"]), - expected_i, - actual_i, + "verify batch read 0x%04X count=%s failed: %s", start_reg, n, e ) - row_ac = await db.fetchrow( - "SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id - ) - attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0 - await notify_modbus_mismatch( - cmd["asset_code"], - int(cmd["register"]), - cmd["register_name"] or "", - expected_i, - actual_i, - attempts, - ) - - if attempts < 3: - await db.execute( - "UPDATE ems.modbus_command SET status='retrying' WHERE id=$1", - cmd_id, - ) - await execute_modbus_commands([cmd_id], db) - await verify_modbus_commands([cmd_id], db, site_id) - else: - logger.critical( - "[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN", - cmd_id, - ) - site = await db.fetchrow( - "SELECT code FROM ems.site WHERE id=$1", site_id - ) - await _switch_to_self_sustain( - site_id, - db, - reason=( - f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} " - f"reg 0x{cmd['register']:04X}" - ), - ) - if site: - await notify_self_sustain_activated( - site["code"], - ( - f"Modbus mismatch: {cmd['asset_code']} " - f"0x{cmd['register']:04X} expected={expected_i} " - f"actual={actual_i}" - ), - ) all_ok = False - else: - logger.info( - "[cmd %s] verified OK: %s 0x%04X=%s", - cmd_id, - cmd["asset_code"], - int(cmd["register"]), - actual_i, + continue + if len(values) != n: + logger.error( + "verify read 0x%04X: expected %s regs, got %s", + start_reg, + n, + len(values), ) - except Exception as e: - logger.error("[cmd %s] verify read failed: %s", cmd_id, e) - all_ok = False + 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 return all_ok @@ -442,9 +584,13 @@ async def _load_inverter_config( sgc.no_export, ai.max_battery_charge_w, ai.max_battery_discharge_w, + ab.min_soc_percent, ab.reserve_soc_percent, ab.max_soc_percent, 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( COALESCE(ab.bms_max_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"]) if row["max_battery_discharge_w"] is not 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"]) if row["reserve_soc_percent"] is not None else None, @@ -505,6 +654,11 @@ async def _load_inverter_config( else None, max_charge_a=max_charge_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)) +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: """ 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. 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) 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: - return tp_discharge_w, reserve_soc, False + return tp_discharge_w, tou_min, False deye_mode = get_deye_mode(setpoints) if deye_mode == "CHARGE": raw_bat = setpoints.battery_w @@ -744,7 +915,9 @@ def _deye_tou_params( target_soc = max(10, min(95, cap)) 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_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( @@ -762,9 +935,10 @@ 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) - reserve_soc = inv.reserve_soc_percent or 20 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) try: 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() - 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 62–64 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 hh_cur = current_slot_hhmm() @@ -807,11 +989,24 @@ async def write_inverter_setpoints( registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1)) registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2)) - for idx in range(2, 6): - registers.extend( - _deye_time_point_rows( - idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, reserve_soc, False + 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): + registers.extend( + _deye_time_point_rows( + idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, tou_min_pct, False + ) ) + else: + logger.debug( + "Deye TOU rows 3–6 skipped (already written today, signature unchanged)" ) registers.extend( @@ -841,6 +1036,53 @@ async def write_inverter_setpoints( 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( site_id, planning_run_id, @@ -857,6 +1099,30 @@ async def write_inverter_setpoints( if not await execute_modbus_commands(cmd_ids, db): return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)" 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: return f"FAIL inverter: {inv.code}: {e}" diff --git a/backend/services/modbus_client.py b/backend/services/modbus_client.py index 3eac006..d049de3 100644 --- a/backend/services/modbus_client.py +++ b/backend/services/modbus_client.py @@ -233,6 +233,17 @@ class PersistentModbusClient: raw = await self.read_register(address, device_id) 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 with _gateway_exclusive(self.host, self.port): async with self._lock: diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 87efa2d..d82d7c0 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -31,6 +31,8 @@ SLOT_WEIGHT_MEDIUM = 0.7 # 36–72h SLOT_WEIGHT_LOW = 0.4 # 72–96h CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A 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_MIN_CLAMP = 0.5 # spodní 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)] 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)] + 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)] 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) @@ -410,6 +413,13 @@ def solve_dispatch( + 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í for e in range(EV): connected = ( diff --git a/backend/tests/test_control_exporter_tou.py b/backend/tests/test_control_exporter_tou.py new file mode 100644 index 0000000..369857f --- /dev/null +++ b/backend/tests/test_control_exporter_tou.py @@ -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() diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 7186108..734d402 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -210,6 +210,54 @@ class PlanningDispatchMilpTests(unittest.TestCase): ) 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__": unittest.main() diff --git a/db/migration/V028__deye_modbus_export_meta.sql b/db/migration/V028__deye_modbus_export_meta.sql new file mode 100644 index 0000000..94da703 --- /dev/null +++ b/db/migration/V028__deye_modbus_export_meta.sql @@ -0,0 +1,15 @@ +-- Metadata pro řidší zápis Deye: čas 62–64 (max 1× za minutu Prahy), TOU 3–6 (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 62–64). Při exportu ve stejné minutě se 62–64 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ů 3–6 (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 3–6 zapíše okamžitě i mimo denní cyklus.'; diff --git a/db/migration/V029__battery_export_tou_semantics.sql b/db/migration/V029__battery_export_tou_semantics.sql new file mode 100644 index 0000000..1773bdb --- /dev/null +++ b/db/migration/V029__battery_export_tou_semantics.sql @@ -0,0 +1,14 @@ +-- EMS: provozní význam min_soc (paralelní packy), home-01 bump, popis podpisu TOU 3–6 + +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 11–12 % 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ů 3–6: HHMM|min_soc_pct|reserve_soc_pct|tp_discharge_w (Europe/Prague metadata); při změně se řádky zapíší znovu.'; diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 64289a0..eb41618 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -110,8 +110,8 @@ CREATE TABLE asset_battery ( inverter_id INT REFERENCES asset_inverter(id), code TEXT NOT NULL, usable_capacity_wh INT NOT NULL, -- 64000 - min_soc_percent NUMERIC(5,2) DEFAULT 10, -- absolutní podlaha LP - reserve_soc_percent NUMERIC(5,2) DEFAULT 20, -- ekonomická podlaha (export/arbitráž) + min_soc_percent NUMERIC(5,2) DEFAULT 10, -- provozní spodní mez LP + clamp; u paralelních packů často 11–12 + 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, charge_efficiency NUMERIC(5,4) DEFAULT 0.95, discharge_efficiency NUMERIC(5,4) DEFAULT 0.95, diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index a77e5c7..cffb51e 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -109,6 +109,8 @@ def apply_overrides(plan, overrides) -> Setpoints: ## 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 **3–6** provozní minimum (`min_soc_percent`). Viz [`modbus-registers.md`](modbus-registers.md). + ```python async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db): inverters = await db.fetch( diff --git a/docs/04-modules/modbus-command-journal.md b/docs/04-modules/modbus-command-journal.md index 812d0db..ed552e9 100644 --- a/docs/04-modules/modbus-command-journal.md +++ b/docs/04-modules/modbus-command-journal.md @@ -30,7 +30,9 @@ Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_swi ## Střídač (Deye) -`write_inverter_setpoints` přidá do journalu mimo **62–64** (čas) a **time pointy 148–177** 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 **62–64** (čas — ne každý export) a **time pointy 148–177** (bloky 3–6 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 diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index f775fe5..47395e5 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -23,7 +23,7 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi | 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**. | -`control_exporter.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal + verifikace) po řadě: **62–64** (čas), **time points 148–177**, **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ř. 62–64, 148–159, 166–177, 108–109, 141–142 podle toho, co je ve frontě). Pořadí v journalu: **62–64** (čas, viz níže), **time points 148–177** (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) @@ -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 -Deye má 6 časových bloků. EMS přepisuje **bloky 1–2** při každém `control_export` (cron např. :14, :29, :44, :59). +Deye má 6 časových bloků. EMS přepisuje **bloky 1–2** (TOU index 0–1) při každém `control_export`. **Bloky 3–6** (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 62–64). 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 1–2** 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 | | 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 | -| 3–6 | **23:55** (2355) | — | Neaktivní (rezerva); ne 23:59 — firmware Deye často 2359 neuloží → verify mismatch | `reserve_soc` (DB) | NE | +| 3–6 | **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 1–2 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 1–2) -| Režim | Výkon (W) | SOC min | Grid charge | -|-------|-----------|---------|-------------| -| **PASSIVE** | `max_discharge_a × 51,2` | rezerva z DB | NE | -| **SELL** | `max_discharge_a × 51,2` | rezerva z DB | NE | +| Režim | Výkon (W) | SOC min (reg 166+) | Grid charge | +|-------|-----------|---------------------|-------------| +| **PASSIVE** | `max_discharge_a × 51,2` | **`min_soc_percent`** 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 | -Bloky 3–6 zůstávají na **23:59** s pasivním profilem (`reserve_soc`, grid charge = NE). +Bloky 3–6 používají čas **2355** a stejnou **SOC** hodnotu jako PASSIVE (`min_soc_percent`, grid charge = NE). ### Synchronizace času -Registry **62–64** se při každém `control_export` nastaví na aktuální čas v **Europe/Prague**: +Registry **62–64** 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 **63:** `den << 8 | hodina` - 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 62–64, denní TOU 3–6 + meta sloupce na `asset_inverter`). ### Mapování registrů (time point *i*, i = 0…5) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index bc4992a..1a01287 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -13,8 +13,9 @@ - **Runtime guard v exportu setpointů:** - při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování. - **Ekonomika baterie:** - - `min_soc_percent` = tvrdá spodní mez SoC v LP (typicky 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), + - `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 **11–12 %**; migrace **V029** + komentář v DB, u `home-01` cílený UPDATE z 10 %), + - `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)`). - **PV-aware nejistota:** - 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 ```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 11–12 %) + +# 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. ``` diff --git a/docs/06-open-questions.md b/docs/06-open-questions.md index 5cbfa94..d70bfe6 100644 --- a/docs/06-open-questions.md +++ b/docs/06-open-questions.md @@ -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) +- [ ] **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. - [ ] **Pole B (ongridový)** – Zahrnout do auditu jako "neřízená výroba"? Nebo ignorovat úplně? Komplikuje audit ale zpřesňuje ho.