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:
@@ -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ů 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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ů 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] = {
|
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 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
|
@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 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
|
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 3–6 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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ SLOT_WEIGHT_MEDIUM = 0.7 # 36–72h
|
|||||||
SLOT_WEIGHT_LOW = 0.4 # 72–96h
|
SLOT_WEIGHT_LOW = 0.4 # 72–96h
|
||||||
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 = (
|
||||||
|
|||||||
108
backend/tests/test_control_exporter_tou.py
Normal file
108
backend/tests/test_control_exporter_tou.py
Normal 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()
|
||||||
@@ -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()
|
||||||
|
|||||||
15
db/migration/V028__deye_modbus_export_meta.sql
Normal file
15
db/migration/V028__deye_modbus_export_meta.sql
Normal file
@@ -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.';
|
||||||
14
db/migration/V029__battery_export_tou_semantics.sql
Normal file
14
db/migration/V029__battery_export_tou_semantics.sql
Normal file
@@ -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.';
|
||||||
@@ -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, -- provozní spodní mez LP + clamp; u paralelních packů často 11–12
|
||||||
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,
|
||||||
|
|||||||
@@ -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 **3–6** 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(
|
||||||
|
|||||||
@@ -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 **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
|
## APScheduler
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| 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**. |
|
| 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)
|
### 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 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.
|
**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 |
|
| 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 |
|
||||||
| 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`).
|
**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)
|
### Fyzické režimy Deye – parametry jednoho time pointu (bloky 1–2)
|
||||||
|
|
||||||
| 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 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
|
### 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 **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 62–64, denní TOU 3–6 + meta sloupce na `asset_inverter`).
|
||||||
|
|
||||||
### Mapování registrů (time point *i*, i = 0…5)
|
### Mapování registrů (time point *i*, i = 0…5)
|
||||||
|
|
||||||
|
|||||||
@@ -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 **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 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 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.
|
# Měkký buffer na konci 24h dál přes soc_deficit_24h.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user