third version before modbus cleanup
This commit is contained in:
@@ -37,10 +37,16 @@ async def fill_audit_for_completed_intervals(site_id: int, db) -> None:
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
slot = row["slot"]
|
||||
await db.execute(
|
||||
"SELECT ems.fn_fill_audit_interval($1, $2)",
|
||||
site_id,
|
||||
row["slot"],
|
||||
slot,
|
||||
)
|
||||
await db.execute(
|
||||
"SELECT ems.fn_fill_baseline_load_forecast_accuracy($1, $2)",
|
||||
site_id,
|
||||
slot,
|
||||
)
|
||||
|
||||
if rows:
|
||||
|
||||
@@ -25,6 +25,10 @@ BATT_VOLTAGE_V = 51.2
|
||||
REG178_SELL = 0b00100000 # 32, grid peak shaving disable
|
||||
REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
|
||||
|
||||
# Neaktivní TOU bloky (3–6): „konec dne“ — Deye často 23:59 (2359) neuloží a vrátí např. 2355,
|
||||
# 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_REGISTER_NAMES: dict[int, str] = {
|
||||
108: "max_charge_a (max nabíjecí proud baterie)",
|
||||
109: "max_discharge_a (max vybíjecí proud baterie)",
|
||||
@@ -97,6 +101,7 @@ class InverterConfig:
|
||||
max_battery_charge_w: int | None
|
||||
max_battery_discharge_w: 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
|
||||
@@ -195,13 +200,14 @@ async def execute_modbus_commands(
|
||||
)
|
||||
if cmd is None:
|
||||
continue
|
||||
client = await get_modbus_client(
|
||||
cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])
|
||||
)
|
||||
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"])]
|
||||
int(cmd["register"]),
|
||||
[int(cmd["value_to_write"])],
|
||||
unit,
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
@@ -231,7 +237,7 @@ async def execute_modbus_commands(
|
||||
e,
|
||||
)
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
client._client = None # force reconnect
|
||||
await client.force_disconnect()
|
||||
else:
|
||||
await db.execute(
|
||||
"""
|
||||
@@ -290,30 +296,31 @@ async def verify_modbus_commands(
|
||||
continue
|
||||
|
||||
try:
|
||||
client = await get_modbus_client(
|
||||
cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])
|
||||
)
|
||||
actual = await client.read_register(int(cmd["register"]))
|
||||
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, verified_at=now(),
|
||||
status=CASE WHEN $1=$2 THEN 'verified' ELSE 'mismatch' END
|
||||
WHERE id=$3
|
||||
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,
|
||||
int(cmd["value_to_write"]),
|
||||
actual_i,
|
||||
expected_i,
|
||||
cmd_id,
|
||||
)
|
||||
|
||||
if actual != int(cmd["value_to_write"]):
|
||||
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"]),
|
||||
cmd["value_to_write"],
|
||||
actual,
|
||||
expected_i,
|
||||
actual_i,
|
||||
)
|
||||
row_ac = await db.fetchrow(
|
||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
@@ -323,8 +330,8 @@ async def verify_modbus_commands(
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
cmd["register_name"] or "",
|
||||
int(cmd["value_to_write"]),
|
||||
actual,
|
||||
expected_i,
|
||||
actual_i,
|
||||
attempts,
|
||||
)
|
||||
|
||||
@@ -356,8 +363,8 @@ async def verify_modbus_commands(
|
||||
site["code"],
|
||||
(
|
||||
f"Modbus mismatch: {cmd['asset_code']} "
|
||||
f"0x{cmd['register']:04X} expected={cmd['value_to_write']} "
|
||||
f"actual={actual}"
|
||||
f"0x{cmd['register']:04X} expected={expected_i} "
|
||||
f"actual={actual_i}"
|
||||
),
|
||||
)
|
||||
all_ok = False
|
||||
@@ -367,7 +374,7 @@ async def verify_modbus_commands(
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
actual,
|
||||
actual_i,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[cmd %s] verify read failed: %s", cmd_id, e)
|
||||
@@ -436,6 +443,7 @@ async def _load_inverter_config(
|
||||
ai.max_battery_charge_w,
|
||||
ai.max_battery_discharge_w,
|
||||
ab.reserve_soc_percent,
|
||||
ab.max_soc_percent,
|
||||
ab.usable_capacity_wh,
|
||||
LEAST(
|
||||
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w),
|
||||
@@ -489,6 +497,9 @@ async def _load_inverter_config(
|
||||
reserve_soc_percent=int(row["reserve_soc_percent"])
|
||||
if row["reserve_soc_percent"] is not None
|
||||
else None,
|
||||
max_soc_percent=int(row["max_soc_percent"])
|
||||
if row["max_soc_percent"] is not None
|
||||
else None,
|
||||
usable_capacity_wh=int(row["usable_capacity_wh"])
|
||||
if row["usable_capacity_wh"] is not None
|
||||
else None,
|
||||
@@ -729,7 +740,8 @@ def _deye_tou_params(
|
||||
if deye_mode == "CHARGE":
|
||||
raw_bat = setpoints.battery_w
|
||||
battery_w = int(raw_bat) if raw_bat is not None else 0
|
||||
target_soc = min(95, setpoints.target_soc_pct or 80)
|
||||
cap = int(inv.max_soc_percent) if inv.max_soc_percent is not None else 95
|
||||
target_soc = max(10, min(95, cap))
|
||||
tp_charge_w = battery_watts_to_amps(battery_w, inv.max_charge_a) * int(BATT_VOLTAGE_V)
|
||||
return tp_charge_w, target_soc, True
|
||||
return tp_discharge_w, reserve_soc, False
|
||||
@@ -798,7 +810,7 @@ async def write_inverter_setpoints(
|
||||
for idx in range(2, 6):
|
||||
registers.extend(
|
||||
_deye_time_point_rows(
|
||||
idx, 2359, tp_discharge_w, reserve_soc, False
|
||||
idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, reserve_soc, False
|
||||
)
|
||||
)
|
||||
|
||||
@@ -857,21 +869,26 @@ async def write_inverter_setpoints(
|
||||
async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]:
|
||||
"""
|
||||
Živé čtení holding registrů Deye 108, 109, 141, 142, 143, 178, 191 (stejné TCP spojení jako telemetrie/export).
|
||||
Vše pod jedním mutexem + sdružené FC3 bloky — mezi jednotlivými read_register dřív telemetrie
|
||||
střídavě brala lock a RS485 brány házely cizí transaction_id / I/O timeouty.
|
||||
"""
|
||||
inv = await _load_inverter_config(site_id, db)
|
||||
if inv is None:
|
||||
raise ValueError("no controllable Modbus inverter for site")
|
||||
|
||||
client = await get_modbus_client(inv.host, inv.port, inv.unit_id)
|
||||
uid = int(inv.unit_id)
|
||||
client = await get_modbus_client(inv.host, inv.port)
|
||||
read_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
r108 = await client.read_register(108)
|
||||
r109 = await client.read_register(109)
|
||||
r141 = await client.read_register(141)
|
||||
r142 = await client.read_register(142)
|
||||
r143 = await client.read_register(143)
|
||||
r178 = await client.read_register(178)
|
||||
r191 = await client.read_register(191)
|
||||
async with client.batch(uid) as mb:
|
||||
b108 = await mb.read_holding_registers(108, 2)
|
||||
b141 = await mb.read_holding_registers(141, 3)
|
||||
r178 = await mb.read_holding_registers(178, 1)
|
||||
r191 = await mb.read_holding_registers(191, 1)
|
||||
r108, r109 = b108[0], b108[1]
|
||||
r141, r142, r143 = b141[0], b141[1], b141[2]
|
||||
r178 = r178[0]
|
||||
r191 = r191[0]
|
||||
except Exception:
|
||||
logger.exception("read_deye_registers_live site=%s failed", site_id)
|
||||
raise
|
||||
|
||||
@@ -3,46 +3,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from pymodbus.client import AsyncModbusTcpClient
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
_FCNTL = True
|
||||
except ImportError:
|
||||
fcntl = None # type: ignore[assignment]
|
||||
_FCNTL = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_flock_warned = False
|
||||
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
||||
_DEFAULT_LOCK_DIR = _BACKEND_ROOT / ".ems-modbus-locks"
|
||||
|
||||
|
||||
def _gateway_lock_path(host: str, port: int) -> Path:
|
||||
# Výchozí = backend/.ems-modbus-locks (v Dockeru /app → mount ./backend), aby flock sdílel
|
||||
# hostitel + kontejner při dvou backend procesech na stejné bráně; přepiš EMS_MODBUS_LOCK_DIR.
|
||||
base = Path(os.getenv("EMS_MODBUS_LOCK_DIR", str(_DEFAULT_LOCK_DIR)))
|
||||
h = hashlib.sha256(f"{host.strip()}:{int(port)}".encode()).hexdigest()[:20]
|
||||
return base / f"{h}.lock"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _gateway_exclusive(host: str, port: int):
|
||||
"""
|
||||
Jedna RS485 linka přes levné TCP↔serial brány nesmí obsluhovat dva procesy najednou —
|
||||
odpovědi pak mají cizí transaction_id (např. 22 vs 54000). flock serializuje napříč PID.
|
||||
Vypnout: EMS_MODBUS_DISABLE_FLOCK=1 (nebo neexistující fcntl, např. Windows).
|
||||
"""
|
||||
global _flock_warned
|
||||
port_i = int(port)
|
||||
host_s = host.strip()
|
||||
if (
|
||||
not _FCNTL
|
||||
or os.getenv("EMS_MODBUS_DISABLE_FLOCK", "").lower() in ("1", "true", "yes")
|
||||
):
|
||||
if not _FCNTL and not _flock_warned:
|
||||
logger.warning(
|
||||
"Modbus: fcntl nedostupný — meziprocesová serializace na bránu %s:%s "
|
||||
"neaktivní (riziko kolizí při dvou masterech)",
|
||||
host_s,
|
||||
port_i,
|
||||
)
|
||||
_flock_warned = True
|
||||
yield
|
||||
return
|
||||
|
||||
path = _gateway_lock_path(host_s, port_i)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(path, "a+b") # noqa: SIM115
|
||||
try:
|
||||
await asyncio.to_thread(fcntl.flock, f.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
await asyncio.to_thread(fcntl.flock, f.fileno(), fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
f.close()
|
||||
|
||||
|
||||
class ModbusBatch:
|
||||
"""Více read/write pod jedním držením locku (žádný jiný task na stejném klientovi mezi nimi)."""
|
||||
|
||||
def __init__(self, owner: PersistentModbusClient) -> None:
|
||||
def __init__(self, owner: PersistentModbusClient, device_id: int) -> None:
|
||||
self._o = owner
|
||||
self._device_id = device_id
|
||||
|
||||
async def read_register(self, address: int) -> int:
|
||||
return await self._o._read_register_locked(address)
|
||||
return await self._o._read_register_locked(address, self._device_id)
|
||||
|
||||
async def read_register_signed(self, address: int) -> int:
|
||||
raw = await self.read_register(address)
|
||||
return raw - 65536 if raw > 32767 else raw
|
||||
|
||||
async def read_holding_registers(self, address: int, count: int) -> list[int]:
|
||||
"""Jedna PDU (FC3) pro po sobě jdoucí registry — méně kolizí na RS485 bránách."""
|
||||
return await self._o._read_holding_registers_locked(
|
||||
address, count, self._device_id
|
||||
)
|
||||
|
||||
async def write_register(self, address: int, value: int) -> bool:
|
||||
return await self._o._write_register_locked(address, value)
|
||||
return await self._o._write_register_locked(address, value, self._device_id)
|
||||
|
||||
async def write_registers(self, address: int, values: list[int]) -> bool:
|
||||
return await self._o._write_registers_locked(address, values)
|
||||
return await self._o._write_registers_locked(address, values, self._device_id)
|
||||
|
||||
|
||||
class PersistentModbusClient:
|
||||
"""
|
||||
Jedno persistentní TCP spojení na převodník.
|
||||
Jedno persistentní TCP spojení na převodník (host:port).
|
||||
Unit ID se předává u každé operace — serial servery často nezvládají
|
||||
paralelní TCP sockety na stejnou bránu (pližící se transaction_id mismatch).
|
||||
Serializuje všechny požadavky přes asyncio.Lock().
|
||||
Automaticky reconnectuje při výpadku.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int, device_id: int = 1) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.device_id = device_id
|
||||
def __init__(self, host: str, port: int) -> None:
|
||||
self.host = host.strip()
|
||||
self.port = int(port)
|
||||
self._client: AsyncModbusTcpClient | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@@ -52,12 +123,12 @@ class PersistentModbusClient:
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
logger.info("Modbus connecting %s:%s dev=%s", self.host, self.port, self.device_id)
|
||||
logger.info("Modbus connecting %s:%s", self.host, self.port)
|
||||
self._client = AsyncModbusTcpClient(
|
||||
self.host,
|
||||
port=self.port,
|
||||
timeout=5,
|
||||
retries=2,
|
||||
timeout=8,
|
||||
retries=3,
|
||||
)
|
||||
await self._client.connect()
|
||||
if not self._client.connected:
|
||||
@@ -66,13 +137,13 @@ class PersistentModbusClient:
|
||||
raise ConnectionError(f"Cannot connect Modbus {self.host}:{self.port}")
|
||||
logger.info("Modbus connected %s:%s", self.host, self.port)
|
||||
|
||||
async def _read_register_locked(self, address: int) -> int:
|
||||
async def _read_register_locked(self, address: int, device_id: int) -> int:
|
||||
if self._client is None or not self._client.connected:
|
||||
await self._ensure_connected()
|
||||
assert self._client is not None
|
||||
try:
|
||||
r = await self._client.read_holding_registers(
|
||||
address, count=1, device_id=self.device_id
|
||||
address, count=1, device_id=int(device_id)
|
||||
)
|
||||
if r.isError() or not getattr(r, "registers", None):
|
||||
raise OSError(f"Read error 0x{address:04X}: {r!r}")
|
||||
@@ -83,14 +154,43 @@ class PersistentModbusClient:
|
||||
self._client = None
|
||||
raise
|
||||
|
||||
async def _write_registers_locked(self, address: int, values: list[int]) -> bool:
|
||||
async def _read_holding_registers_locked(
|
||||
self, address: int, count: int, device_id: int
|
||||
) -> list[int]:
|
||||
if count < 1 or count > 125:
|
||||
raise ValueError(f"invalid holding register count: {count}")
|
||||
if self._client is None or not self._client.connected:
|
||||
await self._ensure_connected()
|
||||
assert self._client is not None
|
||||
try:
|
||||
r = await self._client.read_holding_registers(
|
||||
address, count=count, device_id=int(device_id)
|
||||
)
|
||||
if r.isError() or not getattr(r, "registers", None):
|
||||
raise OSError(f"Read error 0x{address:04X} x{count}: {r!r}")
|
||||
if len(r.registers) != count:
|
||||
raise OSError(
|
||||
f"Read 0x{address:04X}: expected {count} regs, got {len(r.registers)}"
|
||||
)
|
||||
return [int(x) for x in r.registers]
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Modbus read 0x%04X count=%s failed: %s", address, count, e
|
||||
)
|
||||
self._client.close()
|
||||
self._client = None
|
||||
raise
|
||||
|
||||
async def _write_registers_locked(
|
||||
self, address: int, values: list[int], device_id: int
|
||||
) -> bool:
|
||||
if self._client is None or not self._client.connected:
|
||||
await self._ensure_connected()
|
||||
assert self._client is not None
|
||||
try:
|
||||
clamped = [max(0, min(65535, int(v))) for v in values]
|
||||
r = await self._client.write_registers(
|
||||
address, clamped, device_id=self.device_id
|
||||
address, clamped, device_id=int(device_id)
|
||||
)
|
||||
if r.isError():
|
||||
raise OSError(f"Write error 0x{address:04X}={clamped}: {r!r}")
|
||||
@@ -103,13 +203,17 @@ class PersistentModbusClient:
|
||||
self._client = None
|
||||
raise
|
||||
|
||||
async def _write_register_locked(self, address: int, value: int) -> bool:
|
||||
async def _write_register_locked(
|
||||
self, address: int, value: int, device_id: int
|
||||
) -> bool:
|
||||
if self._client is None or not self._client.connected:
|
||||
await self._ensure_connected()
|
||||
assert self._client is not None
|
||||
try:
|
||||
v = max(0, min(65535, int(value)))
|
||||
r = await self._client.write_register(address, v, device_id=self.device_id)
|
||||
r = await self._client.write_register(
|
||||
address, v, device_id=int(device_id)
|
||||
)
|
||||
if r.isError():
|
||||
raise OSError(f"Write error 0x{address:04X}={v}: {r!r}")
|
||||
return True
|
||||
@@ -119,32 +223,46 @@ class PersistentModbusClient:
|
||||
self._client = None
|
||||
raise
|
||||
|
||||
async def read_register(self, address: int) -> int:
|
||||
async with self._lock:
|
||||
await self._ensure_connected()
|
||||
return await self._read_register_locked(address)
|
||||
async def read_register(self, address: int, device_id: int = 1) -> int:
|
||||
async with _gateway_exclusive(self.host, self.port):
|
||||
async with self._lock:
|
||||
await self._ensure_connected()
|
||||
return await self._read_register_locked(address, device_id)
|
||||
|
||||
async def read_register_signed(self, address: int) -> int:
|
||||
raw = await self.read_register(address)
|
||||
async def read_register_signed(self, address: int, device_id: int = 1) -> int:
|
||||
raw = await self.read_register(address, device_id)
|
||||
return raw - 65536 if raw > 32767 else raw
|
||||
|
||||
async def write_register(self, address: int, value: int) -> bool:
|
||||
async with self._lock:
|
||||
await self._ensure_connected()
|
||||
return await self._write_register_locked(address, value)
|
||||
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:
|
||||
await self._ensure_connected()
|
||||
return await self._write_register_locked(address, value, device_id)
|
||||
|
||||
async def write_registers(self, address: int, values: list[int]) -> bool:
|
||||
async def write_registers(
|
||||
self, address: int, values: list[int], device_id: int = 1
|
||||
) -> bool:
|
||||
"""FC 0x10 – povinné pro Deye registry 60–499 (jeden i více registrů)."""
|
||||
async with self._lock:
|
||||
await self._ensure_connected()
|
||||
return await self._write_registers_locked(address, values)
|
||||
async with _gateway_exclusive(self.host, self.port):
|
||||
async with self._lock:
|
||||
await self._ensure_connected()
|
||||
return await self._write_registers_locked(address, values, device_id)
|
||||
|
||||
async def force_disconnect(self) -> None:
|
||||
"""Uzavře socket pod lockem (např. před retry po chybě)."""
|
||||
async with _gateway_exclusive(self.host, self.port):
|
||||
async with self._lock:
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def batch(self) -> AsyncIterator[ModbusBatch]:
|
||||
async def batch(self, device_id: int = 1) -> AsyncIterator[ModbusBatch]:
|
||||
"""Drží lock pro více po sobě jdoucích operací (telemetrie vs. control na stejné bráně)."""
|
||||
async with self._lock:
|
||||
await self._ensure_connected()
|
||||
yield ModbusBatch(self)
|
||||
async with _gateway_exclusive(self.host, self.port):
|
||||
async with self._lock:
|
||||
await self._ensure_connected()
|
||||
yield ModbusBatch(self, int(device_id))
|
||||
|
||||
def close(self) -> None:
|
||||
if self._client is not None:
|
||||
@@ -156,11 +274,10 @@ _clients: dict[str, PersistentModbusClient] = {}
|
||||
_registry_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def get_modbus_client(
|
||||
host: str, port: int, device_id: int = 1
|
||||
) -> PersistentModbusClient:
|
||||
key = f"{host}:{port}:{device_id}"
|
||||
async def get_modbus_client(host: str, port: int) -> PersistentModbusClient:
|
||||
"""Jedno TCP spojení na převodník podle host:port (unit ID u jednotlivých volání)."""
|
||||
key = f"{host.strip()}:{int(port)}"
|
||||
async with _registry_lock:
|
||||
if key not in _clients:
|
||||
_clients[key] = PersistentModbusClient(host, port, device_id)
|
||||
_clients[key] = PersistentModbusClient(host.strip(), port)
|
||||
return _clients[key]
|
||||
|
||||
@@ -36,6 +36,9 @@ CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
|
||||
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
|
||||
# Útlum korekce: čím dál od aktuálního času, tím méně korigujeme forecast
|
||||
CORRECTION_DECAY_SLOTS = 16 # po 16 slotech (4h) klesne korekce na 0
|
||||
# Dynamická ekonomická podlaha (MILP w_arb): lookahead FVE energie v dalších slotech
|
||||
ARB_LOOKAHEAD_SLOTS = 32 # 8 h při INTERVAL_H=0.25
|
||||
ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0..1)
|
||||
|
||||
_PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
@@ -83,6 +86,34 @@ def _pv_coverage_ratio(slots: list["PlanningSlot"], battery, hours: int = 24) ->
|
||||
return max(0.0, min(1.0, pv_kwh / batt_kwh))
|
||||
|
||||
|
||||
def _dynamic_arb_floor_wh_series(
|
||||
slots: list["PlanningSlot"],
|
||||
min_soc_wh: float,
|
||||
arb_base_wh: float,
|
||||
usable_wh: float,
|
||||
) -> list[float]:
|
||||
"""
|
||||
Časově proměnná ekonomická podlaha Wh pro MILP (nad min_soc_wh).
|
||||
Hodně očekávané FVE energie v dalších ARB_LOOKAHEAD_SLOTS → podlaha klesá k min_soc_wh;
|
||||
málo slunce → zůstává u arb_base_wh (typicky reserve z DB).
|
||||
"""
|
||||
T = len(slots)
|
||||
if T == 0:
|
||||
return []
|
||||
e_ref = max(1.0, ARB_FLOOR_E_REF_FRAC * float(usable_wh))
|
||||
spread = max(0.0, float(arb_base_wh) - float(min_soc_wh))
|
||||
out: list[float] = []
|
||||
for t in range(T):
|
||||
e_pv_wh = 0.0
|
||||
for k in range(t, min(T, t + ARB_LOOKAHEAD_SLOTS)):
|
||||
s = slots[k]
|
||||
e_pv_wh += max(0, s.pv_a_forecast_w + s.pv_b_forecast_w) * INTERVAL_H
|
||||
f = min(1.0, e_pv_wh / e_ref) if e_ref > 1e-9 else 1.0
|
||||
arb_t = float(min_soc_wh) + (1.0 - f) * spread
|
||||
out.append(arb_t)
|
||||
return out
|
||||
|
||||
|
||||
def _soc_security_profile(slots: list["PlanningSlot"], battery) -> tuple[float, float]:
|
||||
"""
|
||||
Při nízkém očekávaném slunci drží solver vyšší SoC buffer:
|
||||
@@ -282,12 +313,25 @@ def solve_dispatch(
|
||||
|
||||
prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize)
|
||||
|
||||
min_soc_wh = float(getattr(battery, "min_soc_wh", battery.reserve_soc_wh))
|
||||
arb_base_wh = max(
|
||||
float(getattr(battery, "arb_floor_wh", battery.reserve_soc_wh)),
|
||||
min_soc_wh,
|
||||
)
|
||||
if getattr(battery, "disable_dynamic_arb_floor", False):
|
||||
arb_floor_series = [arb_base_wh] * T
|
||||
else:
|
||||
arb_floor_series = _dynamic_arb_floor_wh_series(
|
||||
slots, min_soc_wh, arb_base_wh, float(battery.usable_capacity_wh)
|
||||
)
|
||||
|
||||
# --- Proměnné ---
|
||||
gi = [pulp.LpVariable(f"gi_{t}", 0, grid.max_import_power_w) for t in range(T)]
|
||||
ge = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w) for t in range(T)]
|
||||
bc = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_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}", battery.reserve_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)]
|
||||
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)
|
||||
@@ -346,14 +390,26 @@ def solve_dispatch(
|
||||
if s.sell_price < 0:
|
||||
prob += ge[t] == 0
|
||||
|
||||
# Záporná nákupní cena → cap import na reálnou spotřebu
|
||||
# Záporná nákupní cena → cap import (baseline domu + akumulace + řízené zátěže)
|
||||
if s.buy_price < 0:
|
||||
prob += gi[t] <= (
|
||||
battery.max_charge_power_w
|
||||
s.load_baseline_w
|
||||
+ battery.max_charge_power_w
|
||||
+ sum(v.max_charge_power_w for v in vehicles)
|
||||
+ heat_pump.rated_heating_power_w
|
||||
)
|
||||
|
||||
soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1]
|
||||
arb_t = arb_floor_series[t]
|
||||
prob += soc_prev_expr >= (arb_t - (arb_t - min_soc_wh) * (1 - w_arb[t]))
|
||||
prob += bd[t] <= (
|
||||
s.load_baseline_w
|
||||
+ ev_total_t
|
||||
+ hp[t]
|
||||
+ bc[t]
|
||||
+ battery.max_discharge_power_w * w_arb[t]
|
||||
)
|
||||
|
||||
# EV – limity a připojení
|
||||
for e in range(EV):
|
||||
connected = (
|
||||
@@ -519,6 +575,7 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
||||
price_failsafe_active=price_failsafe_active,
|
||||
)
|
||||
|
||||
slot_inputs = _build_slot_inputs(slots, slots)
|
||||
run_id = await _save_planning_run(
|
||||
site_id,
|
||||
results,
|
||||
@@ -531,6 +588,7 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
||||
duration_ms=duration_ms,
|
||||
correction=1.0,
|
||||
db=db,
|
||||
slot_inputs=slot_inputs,
|
||||
)
|
||||
logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms")
|
||||
return run_id, duration_ms
|
||||
@@ -589,6 +647,7 @@ async def run_rolling_replan(
|
||||
correction_factor, correction_log = await compute_correction_factor(site_id, now, db)
|
||||
|
||||
slots = await _load_slots(site_id, replan_from, horizon_to, db)
|
||||
slots_before_pv_correction = list(slots)
|
||||
critical_slots = int(36 / INTERVAL_H)
|
||||
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price)
|
||||
price_failsafe_active = missing_ote_count > 0
|
||||
@@ -610,6 +669,7 @@ async def run_rolling_replan(
|
||||
price_failsafe_active=price_failsafe_active,
|
||||
)
|
||||
|
||||
slot_inputs = _build_slot_inputs(slots_before_pv_correction, slots)
|
||||
run_id = await _save_planning_run(
|
||||
site_id,
|
||||
results,
|
||||
@@ -622,6 +682,7 @@ async def run_rolling_replan(
|
||||
duration_ms=duration_ms,
|
||||
correction=correction_factor,
|
||||
db=db,
|
||||
slot_inputs=slot_inputs,
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
@@ -718,6 +779,7 @@ async def _load_site_context(site_id: int, db):
|
||||
brow = await db.fetchrow(
|
||||
"""
|
||||
SELECT ab.usable_capacity_wh,
|
||||
ab.min_soc_percent,
|
||||
ab.reserve_soc_percent,
|
||||
ab.max_soc_percent,
|
||||
ab.charge_efficiency,
|
||||
@@ -770,11 +832,14 @@ async def _load_site_context(site_id: int, db):
|
||||
)
|
||||
|
||||
uc = float(brow["usable_capacity_wh"])
|
||||
reserve_wh = float(brow["reserve_soc_percent"]) / 100.0 * uc
|
||||
min_soc_wh = float(brow["min_soc_percent"]) / 100.0 * uc
|
||||
arb_floor_wh = float(brow["reserve_soc_percent"]) / 100.0 * uc
|
||||
soc_max_wh = float(brow["max_soc_percent"]) / 100.0 * uc
|
||||
battery = SimpleNamespace(
|
||||
usable_capacity_wh=uc,
|
||||
reserve_soc_wh=reserve_wh,
|
||||
min_soc_wh=min_soc_wh,
|
||||
arb_floor_wh=arb_floor_wh,
|
||||
reserve_soc_wh=arb_floor_wh,
|
||||
soc_max_wh=soc_max_wh,
|
||||
charge_efficiency=float(brow["charge_efficiency"]),
|
||||
discharge_efficiency=float(brow["discharge_efficiency"]),
|
||||
@@ -894,7 +959,7 @@ async def _load_site_context(site_id: int, db):
|
||||
soc_wh = uc * 0.5
|
||||
else:
|
||||
soc_wh = float(soc_pct) / 100.0 * uc
|
||||
soc_wh = max(reserve_wh, min(soc_wh, soc_max_wh))
|
||||
soc_wh = max(min_soc_wh, min(soc_wh, soc_max_wh))
|
||||
|
||||
tuv = await db.fetchval(
|
||||
"""
|
||||
@@ -1032,12 +1097,36 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
|
||||
return out
|
||||
|
||||
|
||||
def _build_slot_inputs(
|
||||
slots_raw_pv: list[PlanningSlot],
|
||||
slots_solver: list[PlanningSlot],
|
||||
) -> list[tuple[int, int, int, int, int]]:
|
||||
"""(load_baseline_w, pv_a_raw, pv_b_raw, pv_a_solver, pv_b_solver) pro každý slot."""
|
||||
if len(slots_raw_pv) != len(slots_solver):
|
||||
raise ValueError("slots_raw_pv and slots_solver length mismatch")
|
||||
out: list[tuple[int, int, int, int, int]] = []
|
||||
for raw, sol in zip(slots_raw_pv, slots_solver):
|
||||
out.append(
|
||||
(
|
||||
int(raw.load_baseline_w),
|
||||
int(raw.pv_a_forecast_w),
|
||||
int(raw.pv_b_forecast_w),
|
||||
int(sol.pv_a_forecast_w),
|
||||
int(sol.pv_b_forecast_w),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def _save_planning_run(
|
||||
site_id, results, horizon_from, horizon_to,
|
||||
run_type, triggered_by, replan_from,
|
||||
soc_wh, duration_ms, correction, db
|
||||
soc_wh, duration_ms, correction, db,
|
||||
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
|
||||
) -> int:
|
||||
"""Uloží výsledky solveru jako nový planning_run, deaktivuje předchozí."""
|
||||
if slot_inputs is not None and len(slot_inputs) != len(results):
|
||||
raise ValueError("slot_inputs and results length mismatch")
|
||||
run_id = await db.fetchval("""
|
||||
INSERT INTO ems.planning_run
|
||||
(site_id, horizon_start, horizon_end, status,
|
||||
@@ -1050,28 +1139,88 @@ async def _save_planning_run(
|
||||
soc_wh, duration_ms, correction)
|
||||
|
||||
# Bulk insert výsledků
|
||||
await db.executemany("""
|
||||
INSERT INTO ems.planning_interval
|
||||
(run_id, interval_start,
|
||||
battery_setpoint_w, battery_soc_target_pct,
|
||||
grid_setpoint_w,
|
||||
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
|
||||
heat_pump_enabled, heat_pump_setpoint_w,
|
||||
pv_a_curtailed_w, expected_cost_czk,
|
||||
effective_buy_price, effective_sell_price,
|
||||
is_predicted_price)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
""", [
|
||||
(run_id, r.interval_start,
|
||||
r.battery_setpoint_w, r.battery_soc_target,
|
||||
r.grid_setpoint_w,
|
||||
r.ev1_setpoint_w, r.ev2_setpoint_w, r.ev1_via_bat_w, r.ev2_via_bat_w,
|
||||
r.heat_pump_enabled, r.heat_pump_setpoint_w,
|
||||
r.pv_a_curtailed_w, r.expected_cost_czk,
|
||||
r.effective_buy_price, r.effective_sell_price,
|
||||
r.is_predicted_price)
|
||||
for r in results
|
||||
])
|
||||
if slot_inputs is not None:
|
||||
rows_pi = [
|
||||
(
|
||||
run_id,
|
||||
r.interval_start,
|
||||
r.battery_setpoint_w,
|
||||
r.battery_soc_target,
|
||||
r.grid_setpoint_w,
|
||||
r.ev1_setpoint_w,
|
||||
r.ev2_setpoint_w,
|
||||
r.ev1_via_bat_w,
|
||||
r.ev2_via_bat_w,
|
||||
r.heat_pump_enabled,
|
||||
r.heat_pump_setpoint_w,
|
||||
r.pv_a_curtailed_w,
|
||||
r.expected_cost_czk,
|
||||
r.effective_buy_price,
|
||||
r.effective_sell_price,
|
||||
r.is_predicted_price,
|
||||
si[0],
|
||||
si[1],
|
||||
si[2],
|
||||
si[3],
|
||||
si[4],
|
||||
)
|
||||
for r, si in zip(results, slot_inputs)
|
||||
]
|
||||
await db.executemany(
|
||||
"""
|
||||
INSERT INTO ems.planning_interval
|
||||
(run_id, interval_start,
|
||||
battery_setpoint_w, battery_soc_target_pct,
|
||||
grid_setpoint_w,
|
||||
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
|
||||
heat_pump_enabled, heat_pump_setpoint_w,
|
||||
pv_a_curtailed_w, expected_cost_czk,
|
||||
effective_buy_price, effective_sell_price,
|
||||
is_predicted_price,
|
||||
load_baseline_w,
|
||||
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
|
||||
pv_a_forecast_solver_w, pv_b_forecast_solver_w)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
|
||||
$17,$18,$19,$20,$21)
|
||||
""",
|
||||
rows_pi,
|
||||
)
|
||||
else:
|
||||
await db.executemany(
|
||||
"""
|
||||
INSERT INTO ems.planning_interval
|
||||
(run_id, interval_start,
|
||||
battery_setpoint_w, battery_soc_target_pct,
|
||||
grid_setpoint_w,
|
||||
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
|
||||
heat_pump_enabled, heat_pump_setpoint_w,
|
||||
pv_a_curtailed_w, expected_cost_czk,
|
||||
effective_buy_price, effective_sell_price,
|
||||
is_predicted_price)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
""",
|
||||
[
|
||||
(
|
||||
run_id,
|
||||
r.interval_start,
|
||||
r.battery_setpoint_w,
|
||||
r.battery_soc_target,
|
||||
r.grid_setpoint_w,
|
||||
r.ev1_setpoint_w,
|
||||
r.ev2_setpoint_w,
|
||||
r.ev1_via_bat_w,
|
||||
r.ev2_via_bat_w,
|
||||
r.heat_pump_enabled,
|
||||
r.heat_pump_setpoint_w,
|
||||
r.pv_a_curtailed_w,
|
||||
r.expected_cost_czk,
|
||||
r.effective_buy_price,
|
||||
r.effective_sell_price,
|
||||
r.is_predicted_price,
|
||||
)
|
||||
for r in results
|
||||
],
|
||||
)
|
||||
|
||||
# Aktivovat nový plán, supersede předchozí
|
||||
await db.execute("""
|
||||
|
||||
@@ -47,8 +47,8 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
port = int(row["port"] or 502)
|
||||
unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
|
||||
try:
|
||||
client = await get_modbus_client(host, port, unit_id)
|
||||
async with client.batch() as mb:
|
||||
client = await get_modbus_client(host, port)
|
||||
async with client.batch(unit_id) as mb:
|
||||
run_state = await mb.read_register(DEYE_REG_RUN_STATE)
|
||||
battery_soc = await mb.read_register(DEYE_REG_BATTERY_SOC)
|
||||
battery_power = await mb.read_register_signed(DEYE_REG_BATTERY_POWER_FLOW)
|
||||
|
||||
Reference in New Issue
Block a user