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`.
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 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ů 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] = {
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 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
@@ -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 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
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 36 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}"

View File

@@ -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:

View File

@@ -31,6 +31,8 @@ SLOT_WEIGHT_MEDIUM = 0.7 # 3672h
SLOT_WEIGHT_LOW = 0.4 # 7296h
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 = (

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)
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()

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),
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, -- provozspodní mez LP + clamp; u paralelních packů často 1112
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,

View File

@@ -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 **36** 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(

View File

@@ -30,7 +30,9 @@ Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_swi
## 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

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 |
| 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)
@@ -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 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.
@@ -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 |
| 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`).
@@ -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)
| 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 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
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 **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 6264, denní TOU 36 + meta sloupce na `asset_inverter`).
### Mapování registrů (time point *i*, i = 0…5)

View File

@@ -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 **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 `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 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.
```

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)
- [ ] **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.