third version before modbus cleanup

This commit is contained in:
Dusan Vojacek
2026-04-03 16:03:06 +02:00
parent 9f4126946d
commit 182d5a37e1
18 changed files with 846 additions and 128 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ __pycache__/
.ruff_cache/ .ruff_cache/
venv/ venv/
.venv/ .venv/
backend/.ems-modbus-locks/
node_modules/ node_modules/
dist/ dist/
*.tsbuildinfo *.tsbuildinfo

View File

@@ -151,7 +151,8 @@ async def get_site_status_full(
reserve_row = await conn.fetchrow( reserve_row = await conn.fetchrow(
""" """
SELECT MIN(reserve_soc_percent)::float AS reserve_soc SELECT MIN(reserve_soc_percent)::float AS reserve_soc,
MIN(min_soc_percent)::float AS min_soc
FROM ems.asset_battery FROM ems.asset_battery
WHERE site_id = $1 WHERE site_id = $1
""", """,
@@ -173,7 +174,10 @@ async def get_site_status_full(
if run_row: if run_row:
int_rows = await conn.fetch( int_rows = await conn.fetch(
""" """
SELECT interval_start, battery_setpoint_w SELECT interval_start, battery_setpoint_w,
load_baseline_w,
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
pv_a_forecast_solver_w, pv_b_forecast_solver_w
FROM ems.planning_interval FROM ems.planning_interval
WHERE run_id = $1 WHERE run_id = $1
ORDER BY interval_start ORDER BY interval_start
@@ -243,6 +247,7 @@ async def get_site_status_full(
mode_code = (mode_row["mode_code"] if mode_row else None) or "" mode_code = (mode_row["mode_code"] if mode_row else None) or ""
reserve_soc = float(reserve_row["reserve_soc"]) if reserve_row and reserve_row["reserve_soc"] is not None else None reserve_soc = float(reserve_row["reserve_soc"]) if reserve_row and reserve_row["reserve_soc"] is not None else None
min_soc = float(reserve_row["min_soc"]) if reserve_row and reserve_row["min_soc"] is not None else None
soc = float(inv_row["battery_soc_percent"]) if inv_row and inv_row["battery_soc_percent"] is not None else None soc = float(inv_row["battery_soc_percent"]) if inv_row and inv_row["battery_soc_percent"] is not None else None
alerts: list[dict[str, str]] = [] alerts: list[dict[str, str]] = []
@@ -265,8 +270,10 @@ async def get_site_status_full(
if mode_code.upper() == "MANUAL": if mode_code.upper() == "MANUAL":
add_alert("warn", "Systém v manuálním režimu") add_alert("warn", "Systém v manuálním režimu")
if reserve_soc is not None and soc is not None and soc < reserve_soc: if min_soc is not None and soc is not None and soc < min_soc:
add_alert("error", "SoC baterie pod rezervou") add_alert("error", "SoC baterie pod minimálním limitem")
elif reserve_soc is not None and soc is not None and soc < reserve_soc:
add_alert("warn", "SoC pod ekonomickou rezervou (arbitrážní podlaha)")
if hb_age is None or hb_age > HEARTBEAT_STALE_SEC: if hb_age is None or hb_age > HEARTBEAT_STALE_SEC:
add_alert("error", "EMS heartbeat výpadek") add_alert("error", "EMS heartbeat výpadek")
@@ -300,6 +307,7 @@ def _infrastructure_notification_items(
has_plan: bool, has_plan: bool,
tomorrow_slots: int, tomorrow_slots: int,
mode_code: str, mode_code: str,
min_soc: float | None,
reserve_soc: float | None, reserve_soc: float | None,
soc: float | None, soc: float | None,
inv_age: int | None, inv_age: int | None,
@@ -354,8 +362,20 @@ def _infrastructure_notification_items(
if mode_code.upper() == "MANUAL": if mode_code.upper() == "MANUAL":
push("mode_manual", "info", "Manuální režim", "Automatická optimalizace je vypnutá.") push("mode_manual", "info", "Manuální režim", "Automatická optimalizace je vypnutá.")
if reserve_soc is not None and soc is not None and soc < reserve_soc: if min_soc is not None and soc is not None and soc < min_soc:
push("soc_reserve", "error", "SoC pod rezervou", "Nabití baterie je pod nastavenou bezpečnostní rezervou.") push(
"soc_min",
"error",
"SoC pod minimem",
"SoC je pod absolutním minimem z konfigurace baterie.",
)
elif reserve_soc is not None and soc is not None and soc < reserve_soc:
push(
"soc_reserve",
"warning",
"SoC pod ekonomickou rezervou",
"SoC je pod arbitrážní podlahou plánovač může v tomto pásmu omezovat export.",
)
if hb_age is None or hb_age > HEARTBEAT_STALE_SEC: if hb_age is None or hb_age > HEARTBEAT_STALE_SEC:
push("heartbeat", "error", "EMS heartbeat", "Služba EMS nehlásí pravidelný heartbeat.") push("heartbeat", "error", "EMS heartbeat", "Služba EMS nehlásí pravidelný heartbeat.")
@@ -402,7 +422,8 @@ async def get_site_notifications(
) )
reserve_row = await conn.fetchrow( reserve_row = await conn.fetchrow(
""" """
SELECT MIN(reserve_soc_percent)::float AS reserve_soc SELECT MIN(reserve_soc_percent)::float AS reserve_soc,
MIN(min_soc_percent)::float AS min_soc
FROM ems.asset_battery FROM ems.asset_battery
WHERE site_id = $1 WHERE site_id = $1
""", """,
@@ -512,6 +533,11 @@ async def get_site_notifications(
if reserve_row and reserve_row["reserve_soc"] is not None if reserve_row and reserve_row["reserve_soc"] is not None
else None else None
) )
min_soc = (
float(reserve_row["min_soc"])
if reserve_row and reserve_row["min_soc"] is not None
else None
)
soc = ( soc = (
float(inv_row["battery_soc_percent"]) float(inv_row["battery_soc_percent"])
if inv_row and inv_row["battery_soc_percent"] is not None if inv_row and inv_row["battery_soc_percent"] is not None
@@ -524,6 +550,7 @@ async def get_site_notifications(
has_plan=has_plan, has_plan=has_plan,
tomorrow_slots=int(tomorrow_slots or 0), tomorrow_slots=int(tomorrow_slots or 0),
mode_code=mode_code, mode_code=mode_code,
min_soc=min_soc,
reserve_soc=reserve_soc, reserve_soc=reserve_soc,
soc=soc, soc=soc,
inv_age=inv_age, inv_age=inv_age,

View File

@@ -37,10 +37,16 @@ async def fill_audit_for_completed_intervals(site_id: int, db) -> None:
) )
for row in rows: for row in rows:
slot = row["slot"]
await db.execute( await db.execute(
"SELECT ems.fn_fill_audit_interval($1, $2)", "SELECT ems.fn_fill_audit_interval($1, $2)",
site_id, site_id,
row["slot"], slot,
)
await db.execute(
"SELECT ems.fn_fill_baseline_load_forecast_accuracy($1, $2)",
site_id,
slot,
) )
if rows: if rows:

View File

@@ -25,6 +25,10 @@ BATT_VOLTAGE_V = 51.2
REG178_SELL = 0b00100000 # 32, grid peak shaving disable REG178_SELL = 0b00100000 # 32, grid peak shaving disable
REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE) REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
# Neaktivní TOU bloky (36): „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] = { 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)",
@@ -97,6 +101,7 @@ class InverterConfig:
max_battery_charge_w: int | None max_battery_charge_w: int | None
max_battery_discharge_w: int | None max_battery_discharge_w: int | None
reserve_soc_percent: int | None reserve_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
@@ -195,13 +200,14 @@ async def execute_modbus_commands(
) )
if cmd is None: if cmd is None:
continue continue
client = await get_modbus_client( unit = int(cmd["device_unit_id"])
cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]) client = await get_modbus_client(cmd["device_host"], int(cmd["device_port"]))
)
for attempt in range(MAX_RETRIES): for attempt in range(MAX_RETRIES):
try: try:
await client.write_registers( await client.write_registers(
int(cmd["register"]), [int(cmd["value_to_write"])] int(cmd["register"]),
[int(cmd["value_to_write"])],
unit,
) )
await db.execute( await db.execute(
""" """
@@ -231,7 +237,7 @@ async def execute_modbus_commands(
e, e,
) )
await asyncio.sleep(RETRY_DELAY) await asyncio.sleep(RETRY_DELAY)
client._client = None # force reconnect await client.force_disconnect()
else: else:
await db.execute( await db.execute(
""" """
@@ -290,30 +296,31 @@ async def verify_modbus_commands(
continue continue
try: try:
client = await get_modbus_client( unit = int(cmd["device_unit_id"])
cmd["device_host"], int(cmd["device_port"]), 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 = await client.read_register(int(cmd["register"])) actual_i = int(actual)
expected_i = int(cmd["value_to_write"])
await db.execute( await db.execute(
""" """
UPDATE ems.modbus_command UPDATE ems.modbus_command
SET value_verified=$1, verified_at=now(), SET value_verified=$1::int, verified_at=now(),
status=CASE WHEN $1=$2 THEN 'verified' ELSE 'mismatch' END status=CASE WHEN $1::int = $2::int THEN 'verified' ELSE 'mismatch' END
WHERE id=$3 WHERE id=$3::int
""", """,
actual, actual_i,
int(cmd["value_to_write"]), expected_i,
cmd_id, cmd_id,
) )
if actual != int(cmd["value_to_write"]): if actual_i != expected_i:
logger.error( logger.error(
"[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s", "[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s",
cmd_id, cmd_id,
cmd["asset_code"], cmd["asset_code"],
int(cmd["register"]), int(cmd["register"]),
cmd["value_to_write"], expected_i,
actual, actual_i,
) )
row_ac = await db.fetchrow( row_ac = await db.fetchrow(
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id "SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
@@ -323,8 +330,8 @@ async def verify_modbus_commands(
cmd["asset_code"], cmd["asset_code"],
int(cmd["register"]), int(cmd["register"]),
cmd["register_name"] or "", cmd["register_name"] or "",
int(cmd["value_to_write"]), expected_i,
actual, actual_i,
attempts, attempts,
) )
@@ -356,8 +363,8 @@ async def verify_modbus_commands(
site["code"], site["code"],
( (
f"Modbus mismatch: {cmd['asset_code']} " f"Modbus mismatch: {cmd['asset_code']} "
f"0x{cmd['register']:04X} expected={cmd['value_to_write']} " f"0x{cmd['register']:04X} expected={expected_i} "
f"actual={actual}" f"actual={actual_i}"
), ),
) )
all_ok = False all_ok = False
@@ -367,7 +374,7 @@ async def verify_modbus_commands(
cmd_id, cmd_id,
cmd["asset_code"], cmd["asset_code"],
int(cmd["register"]), int(cmd["register"]),
actual, actual_i,
) )
except Exception as e: except Exception as e:
logger.error("[cmd %s] verify read failed: %s", cmd_id, 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_charge_w,
ai.max_battery_discharge_w, ai.max_battery_discharge_w,
ab.reserve_soc_percent, ab.reserve_soc_percent,
ab.max_soc_percent,
ab.usable_capacity_wh, ab.usable_capacity_wh,
LEAST( LEAST(
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w), 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"]) 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,
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"]) usable_capacity_wh=int(row["usable_capacity_wh"])
if row["usable_capacity_wh"] is not None if row["usable_capacity_wh"] is not None
else None, else None,
@@ -729,7 +740,8 @@ def _deye_tou_params(
if deye_mode == "CHARGE": if deye_mode == "CHARGE":
raw_bat = setpoints.battery_w raw_bat = setpoints.battery_w
battery_w = int(raw_bat) if raw_bat is not None else 0 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) 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 return tp_discharge_w, reserve_soc, False
@@ -798,7 +810,7 @@ async def write_inverter_setpoints(
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, 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]: 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). Ž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) inv = await _load_inverter_config(site_id, db)
if inv is None: if inv is None:
raise ValueError("no controllable Modbus inverter for site") 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) read_at = datetime.now(timezone.utc)
try: try:
r108 = await client.read_register(108) async with client.batch(uid) as mb:
r109 = await client.read_register(109) b108 = await mb.read_holding_registers(108, 2)
r141 = await client.read_register(141) b141 = await mb.read_holding_registers(141, 3)
r142 = await client.read_register(142) r178 = await mb.read_holding_registers(178, 1)
r143 = await client.read_register(143) r191 = await mb.read_holding_registers(191, 1)
r178 = await client.read_register(178) r108, r109 = b108[0], b108[1]
r191 = await client.read_register(191) r141, r142, r143 = b141[0], b141[1], b141[2]
r178 = r178[0]
r191 = r191[0]
except Exception: except Exception:
logger.exception("read_deye_registers_live site=%s failed", site_id) logger.exception("read_deye_registers_live site=%s failed", site_id)
raise raise

View File

@@ -3,46 +3,117 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import logging import logging
import os
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from pymodbus.client import AsyncModbusTcpClient from pymodbus.client import AsyncModbusTcpClient
try:
import fcntl
_FCNTL = True
except ImportError:
fcntl = None # type: ignore[assignment]
_FCNTL = False
logger = logging.getLogger(__name__) 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: class ModbusBatch:
"""Více read/write pod jedním držením locku (žádný jiný task na stejném klientovi mezi nimi).""" """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._o = owner
self._device_id = device_id
async def read_register(self, address: int) -> int: 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: async def read_register_signed(self, address: int) -> int:
raw = await self.read_register(address) raw = await self.read_register(address)
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) -> 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: 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: 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: 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(). 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: def __init__(self, host: str, port: int) -> None:
self.host = host self.host = host.strip()
self.port = port self.port = int(port)
self.device_id = device_id
self._client: AsyncModbusTcpClient | None = None self._client: AsyncModbusTcpClient | None = None
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
@@ -52,12 +123,12 @@ class PersistentModbusClient:
if self._client is not None: if self._client is not None:
self._client.close() self._client.close()
self._client = None 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._client = AsyncModbusTcpClient(
self.host, self.host,
port=self.port, port=self.port,
timeout=5, timeout=8,
retries=2, retries=3,
) )
await self._client.connect() await self._client.connect()
if not self._client.connected: if not self._client.connected:
@@ -66,13 +137,13 @@ class PersistentModbusClient:
raise ConnectionError(f"Cannot connect Modbus {self.host}:{self.port}") raise ConnectionError(f"Cannot connect Modbus {self.host}:{self.port}")
logger.info("Modbus connected %s:%s", 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: if self._client is None or not self._client.connected:
await self._ensure_connected() await self._ensure_connected()
assert self._client is not None assert self._client is not None
try: try:
r = await self._client.read_holding_registers( 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): if r.isError() or not getattr(r, "registers", None):
raise OSError(f"Read error 0x{address:04X}: {r!r}") raise OSError(f"Read error 0x{address:04X}: {r!r}")
@@ -83,14 +154,43 @@ class PersistentModbusClient:
self._client = None self._client = None
raise 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: if self._client is None or not self._client.connected:
await self._ensure_connected() await self._ensure_connected()
assert self._client is not None assert self._client is not None
try: try:
clamped = [max(0, min(65535, int(v))) for v in values] clamped = [max(0, min(65535, int(v))) for v in values]
r = await self._client.write_registers( r = await self._client.write_registers(
address, clamped, device_id=self.device_id address, clamped, device_id=int(device_id)
) )
if r.isError(): if r.isError():
raise OSError(f"Write error 0x{address:04X}={clamped}: {r!r}") raise OSError(f"Write error 0x{address:04X}={clamped}: {r!r}")
@@ -103,13 +203,17 @@ class PersistentModbusClient:
self._client = None self._client = None
raise 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: if self._client is None or not self._client.connected:
await self._ensure_connected() await self._ensure_connected()
assert self._client is not None assert self._client is not None
try: try:
v = max(0, min(65535, int(value))) 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(): if r.isError():
raise OSError(f"Write error 0x{address:04X}={v}: {r!r}") raise OSError(f"Write error 0x{address:04X}={v}: {r!r}")
return True return True
@@ -119,32 +223,46 @@ class PersistentModbusClient:
self._client = None self._client = None
raise raise
async def read_register(self, address: int) -> int: async def read_register(self, address: int, device_id: int = 1) -> int:
async with self._lock: async with _gateway_exclusive(self.host, self.port):
await self._ensure_connected() async with self._lock:
return await self._read_register_locked(address) await self._ensure_connected()
return await self._read_register_locked(address, device_id)
async def read_register_signed(self, address: int) -> int: async def read_register_signed(self, address: int, device_id: int = 1) -> int:
raw = await self.read_register(address) 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 write_register(self, address: int, value: int) -> bool: async def write_register(self, address: int, value: int, device_id: int = 1) -> bool:
async with self._lock: async with _gateway_exclusive(self.host, self.port):
await self._ensure_connected() async with self._lock:
return await self._write_register_locked(address, value) 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 60499 (jeden i více registrů).""" """FC 0x10 povinné pro Deye registry 60499 (jeden i více registrů)."""
async with self._lock: async with _gateway_exclusive(self.host, self.port):
await self._ensure_connected() async with self._lock:
return await self._write_registers_locked(address, values) 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 @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ě).""" """Drží lock pro více po sobě jdoucích operací (telemetrie vs. control na stejné bráně)."""
async with self._lock: async with _gateway_exclusive(self.host, self.port):
await self._ensure_connected() async with self._lock:
yield ModbusBatch(self) await self._ensure_connected()
yield ModbusBatch(self, int(device_id))
def close(self) -> None: def close(self) -> None:
if self._client is not None: if self._client is not None:
@@ -156,11 +274,10 @@ _clients: dict[str, PersistentModbusClient] = {}
_registry_lock = asyncio.Lock() _registry_lock = asyncio.Lock()
async def get_modbus_client( async def get_modbus_client(host: str, port: int) -> PersistentModbusClient:
host: str, port: int, device_id: int = 1 """Jedno TCP spojení na převodník podle host:port (unit ID u jednotlivých volání)."""
) -> PersistentModbusClient: key = f"{host.strip()}:{int(port)}"
key = f"{host}:{port}:{device_id}"
async with _registry_lock: async with _registry_lock:
if key not in _clients: if key not in _clients:
_clients[key] = PersistentModbusClient(host, port, device_id) _clients[key] = PersistentModbusClient(host.strip(), port)
return _clients[key] return _clients[key]

View File

@@ -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 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 # Ú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 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") _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)) 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]: 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: 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) 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é --- # --- Proměnné ---
gi = [pulp.LpVariable(f"gi_{t}", 0, grid.max_import_power_w) for t in range(T)] 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)] 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)] 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)] 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)] 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)
@@ -346,14 +390,26 @@ def solve_dispatch(
if s.sell_price < 0: if s.sell_price < 0:
prob += ge[t] == 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: if s.buy_price < 0:
prob += gi[t] <= ( 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) + sum(v.max_charge_power_w for v in vehicles)
+ heat_pump.rated_heating_power_w + 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í # EV limity a připojení
for e in range(EV): for e in range(EV):
connected = ( 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, price_failsafe_active=price_failsafe_active,
) )
slot_inputs = _build_slot_inputs(slots, slots)
run_id = await _save_planning_run( run_id = await _save_planning_run(
site_id, site_id,
results, results,
@@ -531,6 +588,7 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
duration_ms=duration_ms, duration_ms=duration_ms,
correction=1.0, correction=1.0,
db=db, db=db,
slot_inputs=slot_inputs,
) )
logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms") logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms")
return run_id, duration_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) correction_factor, correction_log = await compute_correction_factor(site_id, now, db)
slots = await _load_slots(site_id, replan_from, horizon_to, db) slots = await _load_slots(site_id, replan_from, horizon_to, db)
slots_before_pv_correction = list(slots)
critical_slots = int(36 / INTERVAL_H) critical_slots = int(36 / INTERVAL_H)
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price) missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price)
price_failsafe_active = missing_ote_count > 0 price_failsafe_active = missing_ote_count > 0
@@ -610,6 +669,7 @@ async def run_rolling_replan(
price_failsafe_active=price_failsafe_active, price_failsafe_active=price_failsafe_active,
) )
slot_inputs = _build_slot_inputs(slots_before_pv_correction, slots)
run_id = await _save_planning_run( run_id = await _save_planning_run(
site_id, site_id,
results, results,
@@ -622,6 +682,7 @@ async def run_rolling_replan(
duration_ms=duration_ms, duration_ms=duration_ms,
correction=correction_factor, correction=correction_factor,
db=db, db=db,
slot_inputs=slot_inputs,
) )
await db.execute( await db.execute(
@@ -718,6 +779,7 @@ async def _load_site_context(site_id: int, db):
brow = await db.fetchrow( brow = await db.fetchrow(
""" """
SELECT ab.usable_capacity_wh, SELECT ab.usable_capacity_wh,
ab.min_soc_percent,
ab.reserve_soc_percent, ab.reserve_soc_percent,
ab.max_soc_percent, ab.max_soc_percent,
ab.charge_efficiency, ab.charge_efficiency,
@@ -770,11 +832,14 @@ async def _load_site_context(site_id: int, db):
) )
uc = float(brow["usable_capacity_wh"]) 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 soc_max_wh = float(brow["max_soc_percent"]) / 100.0 * uc
battery = SimpleNamespace( battery = SimpleNamespace(
usable_capacity_wh=uc, 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, soc_max_wh=soc_max_wh,
charge_efficiency=float(brow["charge_efficiency"]), charge_efficiency=float(brow["charge_efficiency"]),
discharge_efficiency=float(brow["discharge_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 soc_wh = uc * 0.5
else: else:
soc_wh = float(soc_pct) / 100.0 * uc 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( tuv = await db.fetchval(
""" """
@@ -1032,12 +1097,36 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
return out 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( async def _save_planning_run(
site_id, results, horizon_from, horizon_to, site_id, results, horizon_from, horizon_to,
run_type, triggered_by, replan_from, 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: ) -> int:
"""Uloží výsledky solveru jako nový planning_run, deaktivuje předchozí.""" """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(""" run_id = await db.fetchval("""
INSERT INTO ems.planning_run INSERT INTO ems.planning_run
(site_id, horizon_start, horizon_end, status, (site_id, horizon_start, horizon_end, status,
@@ -1050,28 +1139,88 @@ async def _save_planning_run(
soc_wh, duration_ms, correction) soc_wh, duration_ms, correction)
# Bulk insert výsledků # Bulk insert výsledků
await db.executemany(""" if slot_inputs is not None:
INSERT INTO ems.planning_interval rows_pi = [
(run_id, interval_start, (
battery_setpoint_w, battery_soc_target_pct, run_id,
grid_setpoint_w, r.interval_start,
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w, r.battery_setpoint_w,
heat_pump_enabled, heat_pump_setpoint_w, r.battery_soc_target,
pv_a_curtailed_w, expected_cost_czk, r.grid_setpoint_w,
effective_buy_price, effective_sell_price, r.ev1_setpoint_w,
is_predicted_price) r.ev2_setpoint_w,
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) r.ev1_via_bat_w,
""", [ r.ev2_via_bat_w,
(run_id, r.interval_start, r.heat_pump_enabled,
r.battery_setpoint_w, r.battery_soc_target, r.heat_pump_setpoint_w,
r.grid_setpoint_w, r.pv_a_curtailed_w,
r.ev1_setpoint_w, r.ev2_setpoint_w, r.ev1_via_bat_w, r.ev2_via_bat_w, r.expected_cost_czk,
r.heat_pump_enabled, r.heat_pump_setpoint_w, r.effective_buy_price,
r.pv_a_curtailed_w, r.expected_cost_czk, r.effective_sell_price,
r.effective_buy_price, r.effective_sell_price, r.is_predicted_price,
r.is_predicted_price) si[0],
for r in results 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í # Aktivovat nový plán, supersede předchozí
await db.execute(""" await db.execute("""

View File

@@ -47,8 +47,8 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
port = int(row["port"] or 502) port = int(row["port"] or 502)
unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1) unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
try: try:
client = await get_modbus_client(host, port, unit_id) client = await get_modbus_client(host, port)
async with client.batch() as mb: async with client.batch(unit_id) as mb:
run_state = await mb.read_register(DEYE_REG_RUN_STATE) run_state = await mb.read_register(DEYE_REG_RUN_STATE)
battery_soc = await mb.read_register(DEYE_REG_BATTERY_SOC) battery_soc = await mb.read_register(DEYE_REG_BATTERY_SOC)
battery_power = await mb.read_register_signed(DEYE_REG_BATTERY_POWER_FLOW) battery_power = await mb.read_register_signed(DEYE_REG_BATTERY_POWER_FLOW)

View File

@@ -0,0 +1,215 @@
"""MILP dispatch: dvouúrovňové SoC a záporná nákupní cena (bez DB)."""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from services.planning_engine import (
PlanningSlot,
_dynamic_arb_floor_wh_series,
solve_dispatch,
)
def _slot(
*,
load: int = 2000,
buy: float = 3.0,
sell: float = 3.0,
pv_a: int = 0,
pv_b: int = 0,
) -> PlanningSlot:
return PlanningSlot(
interval_start=datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=pv_b,
load_baseline_w=load,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
def _battery(
*,
uc_wh: float = 100_000.0,
min_pct: float = 10.0,
arb_pct: float = 20.0,
max_pct: float = 95.0,
) -> SimpleNamespace:
uc = uc_wh
min_wh = min_pct / 100.0 * uc
arb_wh = arb_pct / 100.0 * uc
return SimpleNamespace(
usable_capacity_wh=uc,
min_soc_wh=min_wh,
arb_floor_wh=arb_wh,
reserve_soc_wh=arb_wh,
soc_max_wh=max_pct / 100.0 * uc,
charge_efficiency=0.95,
discharge_efficiency=0.95,
degradation_cost_czk_kwh=0.15,
max_charge_power_w=10_000,
max_discharge_power_w=10_000,
)
class DynamicArbFloorTests(unittest.TestCase):
def test_more_pv_ahead_lowers_floor(self) -> None:
"""Čím víc FVE ve lookahead, tím nižší ekonomická podlaha v prvním slotu."""
min_w = 1_000.0
base_w = 2_000.0
uc = 10_000.0
s0 = _slot()
s_low_pv = replace_slot(s0, pv_a=100, pv_b=0)
s_high_pv = replace_slot(s0, pv_a=50_000, pv_b=0)
ser_low = _dynamic_arb_floor_wh_series([s_low_pv] * 40, min_w, base_w, uc)
ser_high = _dynamic_arb_floor_wh_series([s_high_pv] * 40, min_w, base_w, uc)
self.assertLess(ser_high[0], ser_low[0])
self.assertGreaterEqual(ser_low[0], min_w)
self.assertLessEqual(ser_low[0], base_w)
def replace_slot(
s: PlanningSlot,
*,
pv_a: int | None = None,
pv_b: int | None = None,
load: int | None = None,
) -> PlanningSlot:
return PlanningSlot(
interval_start=s.interval_start,
buy_price=s.buy_price,
sell_price=s.sell_price,
pv_a_forecast_w=pv_a if pv_a is not None else s.pv_a_forecast_w,
pv_b_forecast_w=pv_b if pv_b is not None else s.pv_b_forecast_w,
load_baseline_w=load if load is not None else s.load_baseline_w,
ev1_connected=s.ev1_connected,
ev2_connected=s.ev2_connected,
is_predicted_price=s.is_predicted_price,
)
class PlanningDispatchMilpTests(unittest.TestCase):
def test_two_tier_soc_solves_optimal(self) -> None:
slots = [_slot()]
battery = _battery()
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=15_000, max_export_power_w=15_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.15 * 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,
)
self.assertGreaterEqual(ms, 0)
self.assertEqual(len(results), 1)
def test_deep_discharge_allows_covering_load_only(self) -> None:
slots = [
_slot(load=3000, buy=1.0, sell=6.0, pv_a=0, pv_b=0),
_slot(load=3000, buy=1.0, sell=6.0, pv_a=0, pv_b=0),
]
battery = _battery(uc_wh=50_000.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=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(
max_charge_power_w=11_000,
battery_capacity_kwh=50.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=11_000,
battery_capacity_kwh=50.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.12 * 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,
)
self.assertEqual(len(results), 2)
def test_negative_buy_price_allows_import_for_baseline(self) -> None:
slots = [_slot(load=6000, buy=-0.5, sell=2.0)]
battery = _battery()
hp = SimpleNamespace(
rated_heating_power_w=8000,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=25_000, max_export_power_w=15_000)
vehicles = [
SimpleNamespace(
max_charge_power_w=11_000,
battery_capacity_kwh=50.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=11_000,
battery_capacity_kwh=50.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.5 * 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,
)
self.assertGreaterEqual(results[0].grid_setpoint_w, 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,52 @@
-- EMS: two-tier SoC semantics (DB comments), restore arbitrage floor after V026 tuning,
-- planning_interval solver inputs, baseline load forecast accuracy tracking.
-- Semantics: min_soc = absolute LP floor; reserve_soc = economic / export discharge floor
COMMENT ON COLUMN ems.asset_battery.min_soc_percent IS
'Minimální SoC v % tvrdá spodní mez v LP (ochrana packu / BMS).';
COMMENT ON COLUMN ems.asset_battery.reserve_soc_percent IS
'Ekonomická podlaha v %: pod ní solver neplánuje „nadbytečné“ vybíjení související s exportem (MILP); nadřazuje se min_soc_percent.';
-- Obnovit rezervu 20 % tam, kde V026 sladila ekonomiku na reserve=10 společně s degradací 0.15
UPDATE ems.asset_battery
SET reserve_soc_percent = 20.00
WHERE reserve_soc_percent = 10.00
AND degradation_cost_czk_kwh = 0.1500;
ALTER TABLE ems.planning_interval
ADD COLUMN IF NOT EXISTS load_baseline_w INT,
ADD COLUMN IF NOT EXISTS pv_a_forecast_raw_w INT,
ADD COLUMN IF NOT EXISTS pv_b_forecast_raw_w INT,
ADD COLUMN IF NOT EXISTS pv_a_forecast_solver_w INT,
ADD COLUMN IF NOT EXISTS pv_b_forecast_solver_w INT;
COMMENT ON COLUMN ems.planning_interval.load_baseline_w IS
'Bazální spotřeba (W) vstupující do LP pro tento slot (stats DOW+hodina / fallback).';
COMMENT ON COLUMN ems.planning_interval.pv_a_forecast_raw_w IS
'FVE pole A výkon z DB před rolling korekcí forecastu.';
COMMENT ON COLUMN ems.planning_interval.pv_b_forecast_raw_w IS
'FVE pole B výkon z DB před rolling korekcí forecastu.';
COMMENT ON COLUMN ems.planning_interval.pv_a_forecast_solver_w IS
'FVE pole A výkon po korekci (vstup do solve_dispatch).';
COMMENT ON COLUMN ems.planning_interval.pv_b_forecast_solver_w IS
'FVE pole B výkon po korekci (vstup do solve_dispatch).';
CREATE TABLE IF NOT EXISTS ems.baseline_load_forecast_accuracy (
site_id INT NOT NULL REFERENCES ems.site(id),
interval_start TIMESTAMPTZ NOT NULL,
planning_run_id INT NOT NULL REFERENCES ems.planning_run(id),
forecast_baseline_w INT,
actual_baseline_w INT,
error_w INT,
error_pct NUMERIC(8,4),
lead_time_hours NUMERIC(6,2),
filled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (site_id, interval_start)
);
COMMENT ON TABLE ems.baseline_load_forecast_accuracy IS
'Zpětná kontrola: plánovaný bazální výkon vs. skutečnost (load - EV - TČ za 15min z auditu).';
COMMENT ON COLUMN ems.baseline_load_forecast_accuracy.forecast_baseline_w IS 'Vstup z planning_interval při uložení plánu.';
COMMENT ON COLUMN ems.baseline_load_forecast_accuracy.actual_baseline_w IS 'Skutečný bazál W (shodná definice jako fn_update_baseline_stats).';
COMMENT ON COLUMN ems.baseline_load_forecast_accuracy.lead_time_hours IS 'Hodiny mezi created_at plánu a začátkem intervalu.';

View File

@@ -0,0 +1,106 @@
-- Doplní jeden řádek baseline_load_forecast_accuracy po vyplnění auditu (stejný interval).
CREATE OR REPLACE FUNCTION ems.fn_fill_baseline_load_forecast_accuracy(
p_site_id INT,
p_interval_start TIMESTAMPTZ
)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_run_id INT;
v_forecast INT;
v_actual_load INT;
v_actual_ev INT;
v_actual_hp INT;
v_actual_baseline INT;
v_err INT;
v_pct NUMERIC(8,4);
v_lead NUMERIC(6,2);
v_created TIMESTAMPTZ;
BEGIN
SELECT
ai.planning_run_id,
ai.actual_load_power_w,
ai.actual_ev_power_w,
ai.actual_heat_pump_power_w
INTO v_run_id, v_actual_load, v_actual_ev, v_actual_hp
FROM ems.audit_interval ai
WHERE ai.site_id = p_site_id
AND ai.interval_start = p_interval_start;
IF v_run_id IS NULL THEN
RETURN;
END IF;
SELECT pi.load_baseline_w
INTO v_forecast
FROM ems.planning_interval pi
WHERE pi.run_id = v_run_id
AND pi.interval_start = p_interval_start;
IF v_forecast IS NULL THEN
RETURN;
END IF;
IF v_actual_load IS NULL THEN
RETURN;
END IF;
v_actual_baseline := GREATEST(0,
v_actual_load
- COALESCE(v_actual_ev, 0)
- COALESCE(v_actual_hp, 0)
);
v_err := v_forecast - v_actual_baseline;
IF v_actual_baseline > 0 THEN
v_pct := (v_err::NUMERIC / v_actual_baseline) * 100.0;
ELSE
v_pct := NULL;
END IF;
SELECT pr.created_at INTO v_created
FROM ems.planning_run pr
WHERE pr.id = v_run_id;
IF v_created IS NOT NULL THEN
v_lead := EXTRACT(EPOCH FROM (p_interval_start - v_created)) / 3600.0;
ELSE
v_lead := NULL;
END IF;
INSERT INTO ems.baseline_load_forecast_accuracy (
site_id,
interval_start,
planning_run_id,
forecast_baseline_w,
actual_baseline_w,
error_w,
error_pct,
lead_time_hours,
filled_at
) VALUES (
p_site_id,
p_interval_start,
v_run_id,
v_forecast,
v_actual_baseline,
v_err,
v_pct,
v_lead,
now()
)
ON CONFLICT (site_id, interval_start) DO UPDATE SET
planning_run_id = EXCLUDED.planning_run_id,
forecast_baseline_w = EXCLUDED.forecast_baseline_w,
actual_baseline_w = EXCLUDED.actual_baseline_w,
error_w = EXCLUDED.error_w,
error_pct = EXCLUDED.error_pct,
lead_time_hours = EXCLUDED.lead_time_hours,
filled_at = EXCLUDED.filled_at;
END;
$$;
COMMENT ON FUNCTION ems.fn_fill_baseline_load_forecast_accuracy IS
'Po fn_fill_audit_interval: uloží odchylku plánovaného load_baseline vs. skutečný bazál z auditu.';

View File

@@ -0,0 +1,12 @@
CREATE OR REPLACE VIEW ems.vw_baseline_load_forecast_accuracy_daily AS
SELECT
site_id,
date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague') AS day_prague,
COUNT(*) AS slot_count,
AVG(ABS(error_w))::NUMERIC(12,2) AS mae_w,
AVG(error_pct) FILTER (WHERE error_pct IS NOT NULL)::NUMERIC(8,4) AS avg_error_pct
FROM ems.baseline_load_forecast_accuracy
GROUP BY site_id, date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague');
COMMENT ON VIEW ems.vw_baseline_load_forecast_accuracy_daily IS
'Denní souhrn přesnosti predikce bazální spotřeby (|chyba| v průměru W).';

View File

@@ -18,3 +18,5 @@ GRANT SELECT ON ems.vw_forecast_accuracy_daily TO ems_anon;
GRANT SELECT ON ems.consumption_baseline_stats TO ems_anon; GRANT SELECT ON ems.consumption_baseline_stats TO ems_anon;
GRANT SELECT ON ems.market_price_stats TO ems_anon; GRANT SELECT ON ems.market_price_stats TO ems_anon;
GRANT SELECT ON ems.tuv_usage_stats TO ems_anon; GRANT SELECT ON ems.tuv_usage_stats TO ems_anon;
GRANT SELECT ON ems.baseline_load_forecast_accuracy TO ems_anon;
GRANT SELECT ON ems.vw_baseline_load_forecast_accuracy_daily TO ems_anon;

View File

@@ -110,8 +110,8 @@ CREATE TABLE asset_battery (
inverter_id INT REFERENCES asset_inverter(id), inverter_id INT REFERENCES asset_inverter(id),
code TEXT NOT NULL, code TEXT NOT NULL,
usable_capacity_wh INT NOT NULL, -- 64000 usable_capacity_wh INT NOT NULL, -- 64000
min_soc_percent NUMERIC(5,2) DEFAULT 10, min_soc_percent NUMERIC(5,2) DEFAULT 10, -- absolutní podlaha LP
reserve_soc_percent NUMERIC(5,2) DEFAULT 20, -- rezerva pro výpadek reserve_soc_percent NUMERIC(5,2) DEFAULT 20, -- ekonomická podlaha (export/arbitráž)
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,
@@ -362,10 +362,15 @@ CREATE TABLE planning_interval (
expected_cost_czk NUMERIC(10,4), expected_cost_czk NUMERIC(10,4),
effective_buy_price NUMERIC(10,6), effective_buy_price NUMERIC(10,6),
effective_sell_price NUMERIC(10,6), effective_sell_price NUMERIC(10,6),
-- + sloupce z migrací (curtailment, EV1/2, predicted price, vstupy solveru):
-- load_baseline_w, pv_a_forecast_raw_w, pv_b_forecast_raw_w,
-- pv_a_forecast_solver_w, pv_b_forecast_solver_w
PRIMARY KEY (run_id, interval_start) PRIMARY KEY (run_id, interval_start)
); );
``` ```
Tabulka `baseline_load_forecast_accuracy` (migrace V027+) ukládá zpětně plánovaný bazál vs. skutečný bazál z auditu; plní `fn_fill_baseline_load_forecast_accuracy` po `fn_fill_audit_interval`.
--- ---
## Audit ## Audit

View File

@@ -42,7 +42,7 @@ bazální_w = load_power_w - ev_power_w - heat_pump_power_w
**Predikce do horizontu:** **`ems.fn_get_baseline_forecast(site_id, from, to)`** generuje 15min sloty (`generate_series`), pro každý slot najde řádek podle DOW+hodiny v Praze. **`forecast_w`** = uložený průměr; **`confidence_w`** = konzervativní odhad `avg + 0.5 * COALESCE(stddev, 100)`. Pokud pro slot neexistuje statistika, fallback **`forecast_w = 500` W** (málo nebo žádná historie; prakticky odpovídá situaci před ~4 týdny kvalitních dat v jednotlivých hodinách). Směrodatná odchylka je v DB k dispozici pro budoucí použití v solveru (fáze 2). **Predikce do horizontu:** **`ems.fn_get_baseline_forecast(site_id, from, to)`** generuje 15min sloty (`generate_series`), pro každý slot najde řádek podle DOW+hodiny v Praze. **`forecast_w`** = uložený průměr; **`confidence_w`** = konzervativní odhad `avg + 0.5 * COALESCE(stddev, 100)`. Pokud pro slot neexistuje statistika, fallback **`forecast_w = 500` W** (málo nebo žádná historie; prakticky odpovídá situaci před ~4 týdny kvalitních dat v jednotlivých hodinách). Směrodatná odchylka je v DB k dispozici pro budoucí použití v solveru (fáze 2).
**Solver (`planning_engine._load_slots`):** pro každý 15min interval efektivní ceny bere **`avg_power_w` z `consumption_baseline_stats`** podle DOW+hodiny slotu, jinak **500 W** nečte `consumption_baseline_interval`. **Solver (`planning_engine._load_slots`):** pro každý 15min interval efektivní ceny bere **`avg_power_w` z `consumption_baseline_stats`** podle DOW+hodiny slotu, jinak **500 W** nečte `consumption_baseline_interval`. Stejná hodnota se ukládá do **`planning_interval.load_baseline_w`** při každém běhu plánovače (přehled v UI / PostgREST). Odchylka vs. skutečnost: tabulka **`baseline_load_forecast_accuracy`**, plněno po auditu.
> **Poznámka:** TUV jako samostatný odečet zůstává otevřený bod, pokud není měřen zvlášť; aktuálně je TČ zahrnut v `heat_pump_power_w`. > **Poznámka:** TUV jako samostatný odečet zůstává otevřený bod, pokud není měřen zvlášť; aktuálně je TČ zahrnut v `heat_pump_power_w`.

View File

@@ -76,7 +76,7 @@ Deye má 6 časových bloků. EMS přepisuje **bloky 12** při každém `cont
|------|---------------------------|-------------|------|---------|-------------| |------|---------------------------|-------------|------|---------|-------------|
| 1 | **`current_slot_hhmm()`** začátek **probíhajícího** 15min slotu | `planning_interval` pro **aktuální** slot (`_fetch_plan_row_for_slot_offset(..., 0)`) | PASSIVE / SELL / CHARGE dle `_deye_tou_params` | viz tabulka níže | viz tabulka níže | | 1 | **`current_slot_hhmm()`** začátek **probíhajícího** 15min slotu | `planning_interval` pro **aktuální** slot (`_fetch_plan_row_for_slot_offset(..., 0)`) | PASSIVE / SELL / CHARGE dle `_deye_tou_params` | viz tabulka níže | viz tabulka níže |
| 2 | **`next_slot_hhmm()`** začátek **následujícího** 15min slotu | `planning_interval` pro **další** slot (`_fetch_plan_row_for_slot_offset(..., 1)`) | Přechod na další čtvrthodinu | viz tabulka níže | viz tabulka níže | | 2 | **`next_slot_hhmm()`** začátek **následujícího** 15min slotu | `planning_interval` pro **další** slot (`_fetch_plan_row_for_slot_offset(..., 1)`) | Přechod na další čtvrthodinu | viz tabulka níže | viz tabulka níže |
| 36 | 23:59 | — | Neaktivní (rezerva) | `reserve_soc` (DB) | NE | | 36 | **23:55** (2355) | — | Neaktivní (rezerva); ne 23:59 — firmware Deye často 2359 neuloží → verify mismatch | `reserve_soc` (DB) | NE |
**Registry 108 / 109 / 142 / 178 / 143** odpovídají **aktuálnímu** plánu (okamžitý výstup; `setpoints_now` v `write_inverter_setpoints`). TOU řádky 12 doplňují stejnou logiku pro časové segmenty (`_deye_tou_params`). **Registry 108 / 109 / 142 / 178 / 143** odpovídají **aktuálnímu** plánu (okamžitý výstup; `setpoints_now` v `write_inverter_setpoints`). TOU řádky 12 doplňují stejnou logiku pro časové segmenty (`_deye_tou_params`).

View File

@@ -13,14 +13,19 @@
- **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:**
- `reserve_soc_percent` naladěn na 10 %, - `min_soc_percent` = tvrdá spodní mez SoC v LP (typicky 10 %),
- `degradation_cost_czk_kwh` naladěn na 0.1500, - `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),
- penalizace cyklu je 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,
- při slabém slunci je plán ochotnější držet energii v baterii. - při slabém slunci je plán ochotnější držet energii v baterii.
- **SoC buffer bez hard pravidel:** - **SoC buffer:**
- místo explicitních pravidel se používá ekonomická penalizace deficitu vůči bezpečnostnímu SoC cíli na konci 24h horizontu. - měkký cíl na konci 24h přes `_soc_security_profile` + tvrdé dvouúrovňové pravidlo výše.
- **Dynamická ekonomická podlaha (fáze 2):**
- `_dynamic_arb_floor_wh_series`: podle součtu FVE výkonu v dalších ~8 h (`ARB_LOOKAHEAD_SLOTS`) se `arb_floor_wh[t]` posouvá mezi `min_soc_wh` a rezervou z DB silné očekávané slunce ji sníží (ráno / po obloze); vynutit konstantní chování lze `battery.disable_dynamic_arb_floor=True` jen pro testy / ladění.
- **Záporná nákupní cena:**
- horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu).
- **Uložené vstupy plánu** (`planning_interval`): `load_baseline_w`, `pv_*_forecast_raw_w`, `pv_*_forecast_solver_w` pro UI a audit.
Solver optimalizuje celý horizont (typicky 36h) najednou, čímž přirozeně zvládá: Solver optimalizuje celý horizont (typicky 36h) najednou, čímž přirozeně zvládá:
- pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie) - pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie)
@@ -179,11 +184,11 @@ 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 soc_min_wh <= soc[t] <= soc_max_wh # min_soc_percent z DB (např. 10 %)
# Rezerva pro výpadek sítě nikdy nesahat # Ekonomická podlaha (reserve_soc_percent, např. 20 %): binární w_arb[t] v MILP
soc_reserve_wh = battery.reserve_soc_percent / 100 * battery.usable_capacity_wh # pod touto hranicí je bd omezeno na load+EV+TČ+bc (žádné „nadbytečné“ vybíjení pro export z baterie).
soc[t] >= soc_reserve_wh # za normálních podmínek # Měkký buffer na konci 24h dál přes soc_deficit_24h.
``` ```
### Limity výkonu ### Limity výkonu
@@ -266,7 +271,7 @@ def solve_dispatch(
batt_charge = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_power_w) for t in range(T)] batt_charge = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_power_w) for t in range(T)]
batt_discharge = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)] batt_discharge = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)]
soc = [pulp.LpVariable(f"soc_{t}", soc = [pulp.LpVariable(f"soc_{t}",
battery.reserve_soc_wh, battery.min_soc_wh,
battery.soc_max_wh) for t in range(T)] battery.soc_max_wh) for t in range(T)]
curtail_a = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)] curtail_a = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)]
ev_charge = [pulp.LpVariable(f"ev_{t}", 0, ev_max_total_w) for t in range(T)] ev_charge = [pulp.LpVariable(f"ev_{t}", 0, ev_max_total_w) for t in range(T)]

View File

@@ -1,6 +1,7 @@
import type { ChartArea } from 'chart.js' import type { ChartArea } from 'chart.js'
import { Activity, Battery, ChevronDown, ChevronUp, Sun, Zap } from 'lucide-react' import { Activity, Battery, ChevronDown, ChevronUp, Sun, Zap } from 'lucide-react'
import { memo, useCallback, useEffect, useState } from 'react' import { memo, useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { EnergyChart } from '../components/charts/EnergyChart' import { EnergyChart } from '../components/charts/EnergyChart'
import { ForecastPanel } from '../components/charts/ForecastPanel' import { ForecastPanel } from '../components/charts/ForecastPanel'
@@ -57,6 +58,7 @@ function MetricSkeleton() {
} }
export function Dashboard() { export function Dashboard() {
const navigate = useNavigate()
const { site: siteRow, ready: siteReady, error: siteErr } = useSiteStatus() const { site: siteRow, ready: siteReady, error: siteErr } = useSiteStatus()
const siteId = siteRow?.site_id ?? null const siteId = siteRow?.site_id ?? null
const data = useDashboardData(siteId) const data = useDashboardData(siteId)
@@ -172,7 +174,7 @@ export function Dashboard() {
activatedAt={modeActivatedAt} activatedAt={modeActivatedAt}
nextReplanIn={nextReplanIn} nextReplanIn={nextReplanIn}
onReplan={handleReplan} onReplan={handleReplan}
onModeChange={() => {}} onModeChange={() => navigate('/settings')}
/> />
) : null} ) : null}

View File

@@ -6,4 +6,6 @@ declare module 'react-router-dom' {
export function Route(props: Record<string, unknown>): JSX.Element | null export function Route(props: Record<string, unknown>): JSX.Element | null
export function Outlet(): JSX.Element | null export function Outlet(): JSX.Element | null
export function NavLink(props: Record<string, unknown>): JSX.Element export function NavLink(props: Record<string, unknown>): JSX.Element
/** Zjednodušená deklarace celý modul je zde ručně kvůli buildu bez @types balíčku. */
export function useNavigate(): (to: string) => void
} }