baterie pri sell neklesne pod 20% ale pri normalnim provozu muze jit az k 10%, mame tak rezervu a neohrozime si nahly propad procent battery packu

This commit is contained in:
Dusan Vojacek
2026-04-03 21:51:34 +02:00
parent 182d5a37e1
commit af761f0ff7
14 changed files with 659 additions and 173 deletions

View File

@@ -5,9 +5,10 @@ from __future__ import annotations
import asyncio
import logging
import os
from collections import defaultdict
from dataclasses import dataclass
from typing import Any
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
import asyncpg
@@ -18,6 +19,8 @@ from services.modbus_client import get_modbus_client
logger = logging.getLogger(__name__)
PRAGUE_TZ = ZoneInfo("Europe/Prague")
# Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md)
BATT_VOLTAGE_V = 51.2
@@ -29,6 +32,16 @@ REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
# verify pak hlásí mismatch. 23:55 je na zařízeních stabilní (viz HHMM jako desítkové číslo).
DEYE_TOU_INACTIVE_HHMM = 2355
# Registry TOU řádků 36 (slot index 2…5): 150153, 156159, … — pro detekci skutečného zápisu po filtru „unchanged“.
_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset(
[
150, 151, 152, 153,
156, 157, 158, 159,
168, 169, 170, 171,
174, 175, 176, 177,
]
)
DEYE_REGISTER_NAMES: dict[int, str] = {
108: "max_charge_a (max nabíjecí proud baterie)",
109: "max_discharge_a (max vybíjecí proud baterie)",
@@ -100,11 +113,73 @@ class InverterConfig:
no_export: bool
max_battery_charge_w: int | None
max_battery_discharge_w: int | None
min_soc_percent: int | None
reserve_soc_percent: int | None
max_soc_percent: int | None
usable_capacity_wh: int | None
max_charge_a: int
max_discharge_a: int
deye_last_system_time_sync_minute: datetime | None = None
deye_last_tou_inactive_write_prague_date: date | None = None
deye_tou_inactive_signature: str | None = None
def _prague_minute_start_utc() -> datetime:
"""UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague."""
p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
return p.astimezone(timezone.utc)
def _deye_skip_time_registers(inv: InverterConfig) -> bool:
"""True = neposílat 6264 (stejná pražská minuta jako u posledního úspěšného zápisu)."""
last = inv.deye_last_system_time_sync_minute
if last is None:
return False
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
return last.astimezone(timezone.utc) == _prague_minute_start_utc()
async def _fetch_last_verified_inverter_registers(
site_id: int, inverter_asset_id: int, db: asyncpg.Connection
) -> dict[int, int]:
"""
Poslední hodnota na zařízení podle journalu (jen status verified).
Slouží k přeskočení duplicitního zápisu stejné hodnoty.
"""
rows = await db.fetch(
"""
SELECT DISTINCT ON (register)
register,
value_verified
FROM ems.modbus_command
WHERE site_id = $1
AND asset_type = 'inverter'
AND asset_id = $2
AND status = 'verified'
AND value_verified IS NOT NULL
ORDER BY register, verified_at DESC NULLS LAST, id DESC
""",
site_id,
inverter_asset_id,
)
return {int(r["register"]): int(r["value_verified"]) for r in rows}
def _drop_registers_matching_last_verified(
registers: list[tuple[int, str, int]],
last_verified: dict[int, int],
) -> tuple[list[tuple[int, str, int]], list[int]]:
"""Vynechá položky s hodnotou shodnou s posledním ověřeným stavem; vrátí (nový seznam, vynechané reg)."""
out: list[tuple[int, str, int]] = []
skipped: list[int] = []
for reg, meta, val in registers:
lv = last_verified.get(int(reg))
if lv is not None and lv == int(val):
skipped.append(int(reg))
continue
out.append((reg, meta, val))
return out, skipped
@dataclass
@@ -181,81 +256,115 @@ async def create_modbus_commands(
return ids
def _modbus_command_contiguous_runs(cmds: list[asyncpg.Record]) -> list[list[asyncpg.Record]]:
"""Seřadí podle adresy registru a rozdělí na souvislé úseky pro FC 0x10 / FC 3."""
if not cmds:
return []
sorted_cmds = sorted(cmds, key=lambda c: int(c["register"]))
runs: list[list[asyncpg.Record]] = []
cur: list[asyncpg.Record] = [sorted_cmds[0]]
for c in sorted_cmds[1:]:
if int(c["register"]) == int(cur[-1]["register"]) + 1:
cur.append(c)
else:
runs.append(cur)
cur = [c]
runs.append(cur)
return runs
async def execute_modbus_commands(
command_ids: list[int],
db: asyncpg.Connection,
) -> bool:
"""
Zapíše příkazy z modbus_command do zařízení.
Zapíše příkazy z modbus_command do zařízení (FC 0x10 po souvislých blocích).
Aktualizuje status na 'written' nebo 'failed'.
Vrátí True pokud všechny příkazy uspěly.
"""
MAX_RETRIES = 3
RETRY_DELAY = 0.5
all_ok = True
rows: list[asyncpg.Record] = []
for cmd_id in command_ids:
cmd = await db.fetchrow(
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
)
if cmd is None:
continue
unit = int(cmd["device_unit_id"])
client = await get_modbus_client(cmd["device_host"], int(cmd["device_port"]))
for attempt in range(MAX_RETRIES):
try:
await client.write_registers(
int(cmd["register"]),
[int(cmd["value_to_write"])],
unit,
)
await db.execute(
"""
UPDATE ems.modbus_command
SET status='written', value_written=$1, written_at=now(),
attempt_count=attempt_count+1, error_msg=NULL
WHERE id=$2
""",
int(cmd["value_to_write"]),
cmd_id,
)
logger.info(
"[cmd %s] %s 0x%04X=%s OK (attempt %s)",
cmd_id,
cmd["asset_code"],
int(cmd["register"]),
int(cmd["value_to_write"]),
attempt + 1,
)
break
except Exception as e:
if attempt < MAX_RETRIES - 1:
logger.warning(
"[cmd %s] attempt %s failed: %s, retrying...",
cmd_id,
attempt + 1,
e,
)
await asyncio.sleep(RETRY_DELAY)
await client.force_disconnect()
else:
await db.execute(
"""
UPDATE ems.modbus_command
SET status='failed', error_msg=$1,
attempt_count=attempt_count+1
WHERE id=$2
""",
str(e),
cmd_id,
)
logger.error(
"[cmd %s] all %s attempts failed: %s",
cmd_id,
MAX_RETRIES,
e,
)
all_ok = False
if cmd is not None:
rows.append(cmd)
if not rows:
return True
by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list)
for cmd in rows:
by_gw[
(cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]))
].append(cmd)
all_ok = True
for (host, port, unit), group in by_gw.items():
client = await get_modbus_client(host, port)
for run in _modbus_command_contiguous_runs(group):
start_reg = int(run[0]["register"])
values = [int(c["value_to_write"]) for c in run]
ids_run = [int(c["id"]) for c in run]
for attempt in range(MAX_RETRIES):
try:
await client.write_registers(start_reg, values, unit)
for cmd, val in zip(run, values):
cid = int(cmd["id"])
await db.execute(
"""
UPDATE ems.modbus_command
SET status='written', value_written=$1, written_at=now(),
attempt_count=attempt_count+1, error_msg=NULL
WHERE id=$2
""",
val,
cid,
)
logger.info(
"[cmd %s] %s 0x%04X=%s OK batch@%s (attempt %s)",
cid,
cmd["asset_code"],
int(cmd["register"]),
val,
start_reg,
attempt + 1,
)
break
except Exception as e:
if attempt < MAX_RETRIES - 1:
logger.warning(
"Modbus batch write 0x%04X count=%s attempt %s failed: %s, retrying...",
start_reg,
len(values),
attempt + 1,
e,
)
await asyncio.sleep(RETRY_DELAY)
await client.force_disconnect()
else:
for cmd in run:
await db.execute(
"""
UPDATE ems.modbus_command
SET status='failed', error_msg=$1,
attempt_count=attempt_count+1
WHERE id=$2
""",
str(e),
int(cmd["id"]),
)
logger.error(
"Modbus batch 0x%04X count=%s all %s attempts failed: %s",
start_reg,
len(values),
MAX_RETRIES,
e,
)
all_ok = False
return all_ok
@@ -279,7 +388,7 @@ async def verify_modbus_commands(
site_id: int,
) -> bool:
"""
Přečte registry zpět a porovná s value_to_write.
Přečte registry zpět (FC 3 po souvislých blocích) a porovná s value_to_write.
Při mismatch: retry → SELF_SUSTAIN + Discord.
"""
from services.notification_service import (
@@ -287,98 +396,131 @@ async def verify_modbus_commands(
notify_self_sustain_activated,
)
all_ok = True
async def _apply_verify_result(cmd: asyncpg.Record, actual_i: int) -> bool:
"""Vrátí True při shodě, False při mismatch (a obslouží retry / SELF_SUSTAIN)."""
cmd_id = int(cmd["id"])
expected_i = int(cmd["value_to_write"])
await db.execute(
"""
UPDATE ems.modbus_command
SET value_verified=$1::int, verified_at=now(),
status=CASE WHEN $1::int = $2::int THEN 'verified' ELSE 'mismatch' END
WHERE id=$3::int
""",
actual_i,
expected_i,
cmd_id,
)
if actual_i != expected_i:
logger.error(
"[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s",
cmd_id,
cmd["asset_code"],
int(cmd["register"]),
expected_i,
actual_i,
)
row_ac = await db.fetchrow(
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
)
attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0
await notify_modbus_mismatch(
cmd["asset_code"],
int(cmd["register"]),
cmd["register_name"] or "",
expected_i,
actual_i,
attempts,
)
if attempts < 3:
await db.execute(
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
cmd_id,
)
await execute_modbus_commands([cmd_id], db)
await verify_modbus_commands([cmd_id], db, site_id)
else:
logger.critical(
"[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN",
cmd_id,
)
site = await db.fetchrow(
"SELECT code FROM ems.site WHERE id=$1", site_id
)
await _switch_to_self_sustain(
site_id,
db,
reason=(
f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} "
f"reg 0x{cmd['register']:04X}"
),
)
if site:
await notify_self_sustain_activated(
site["code"],
(
f"Modbus mismatch: {cmd['asset_code']} "
f"0x{cmd['register']:04X} expected={expected_i} "
f"actual={actual_i}"
),
)
return False
logger.info(
"[cmd %s] verified OK: %s 0x%04X=%s",
cmd_id,
cmd["asset_code"],
int(cmd["register"]),
actual_i,
)
return True
cmds: list[asyncpg.Record] = []
for cmd_id in command_ids:
cmd = await db.fetchrow(
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
)
if cmd is None or cmd["status"] != "written":
continue
if cmd is not None and cmd["status"] == "written":
cmds.append(cmd)
try:
unit = int(cmd["device_unit_id"])
client = await get_modbus_client(cmd["device_host"], int(cmd["device_port"]))
actual = await client.read_register(int(cmd["register"]), unit)
actual_i = int(actual)
expected_i = int(cmd["value_to_write"])
await db.execute(
"""
UPDATE ems.modbus_command
SET value_verified=$1::int, verified_at=now(),
status=CASE WHEN $1::int = $2::int THEN 'verified' ELSE 'mismatch' END
WHERE id=$3::int
""",
actual_i,
expected_i,
cmd_id,
)
if not cmds:
return True
if actual_i != expected_i:
by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list)
for cmd in cmds:
by_gw[
(cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]))
].append(cmd)
all_ok = True
for (host, port, unit), group in by_gw.items():
client = await get_modbus_client(host, port)
for run in _modbus_command_contiguous_runs(group):
start_reg = int(run[0]["register"])
n = len(run)
try:
values = await client.read_holding_registers(start_reg, n, unit)
except Exception as e:
logger.error(
"[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s",
cmd_id,
cmd["asset_code"],
int(cmd["register"]),
expected_i,
actual_i,
"verify batch read 0x%04X count=%s failed: %s", start_reg, n, e
)
row_ac = await db.fetchrow(
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
)
attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0
await notify_modbus_mismatch(
cmd["asset_code"],
int(cmd["register"]),
cmd["register_name"] or "",
expected_i,
actual_i,
attempts,
)
if attempts < 3:
await db.execute(
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
cmd_id,
)
await execute_modbus_commands([cmd_id], db)
await verify_modbus_commands([cmd_id], db, site_id)
else:
logger.critical(
"[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN",
cmd_id,
)
site = await db.fetchrow(
"SELECT code FROM ems.site WHERE id=$1", site_id
)
await _switch_to_self_sustain(
site_id,
db,
reason=(
f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} "
f"reg 0x{cmd['register']:04X}"
),
)
if site:
await notify_self_sustain_activated(
site["code"],
(
f"Modbus mismatch: {cmd['asset_code']} "
f"0x{cmd['register']:04X} expected={expected_i} "
f"actual={actual_i}"
),
)
all_ok = False
else:
logger.info(
"[cmd %s] verified OK: %s 0x%04X=%s",
cmd_id,
cmd["asset_code"],
int(cmd["register"]),
actual_i,
continue
if len(values) != n:
logger.error(
"verify read 0x%04X: expected %s regs, got %s",
start_reg,
n,
len(values),
)
except Exception as e:
logger.error("[cmd %s] verify read failed: %s", cmd_id, e)
all_ok = False
all_ok = False
continue
for cmd, actual in zip(run, values):
matched = await _apply_verify_result(cmd, int(actual))
if not matched:
all_ok = False
return all_ok
@@ -442,9 +584,13 @@ async def _load_inverter_config(
sgc.no_export,
ai.max_battery_charge_w,
ai.max_battery_discharge_w,
ab.min_soc_percent,
ab.reserve_soc_percent,
ab.max_soc_percent,
ab.usable_capacity_wh,
ai.deye_last_system_time_sync_minute,
ai.deye_last_tou_inactive_write_prague_date,
ai.deye_tou_inactive_signature,
LEAST(
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w),
ai.max_battery_charge_w
@@ -494,6 +640,9 @@ async def _load_inverter_config(
max_battery_discharge_w=int(row["max_battery_discharge_w"])
if row["max_battery_discharge_w"] is not None
else None,
min_soc_percent=int(round(float(row["min_soc_percent"])))
if row["min_soc_percent"] is not None
else None,
reserve_soc_percent=int(row["reserve_soc_percent"])
if row["reserve_soc_percent"] is not None
else None,
@@ -505,6 +654,11 @@ async def _load_inverter_config(
else None,
max_charge_a=max_charge_a,
max_discharge_a=max_discharge_a,
deye_last_system_time_sync_minute=row["deye_last_system_time_sync_minute"],
deye_last_tou_inactive_write_prague_date=row[
"deye_last_tou_inactive_write_prague_date"
],
deye_tou_inactive_signature=row["deye_tou_inactive_signature"],
)
@@ -705,6 +859,22 @@ def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> in
return max(0, int(max_export_power_w or 0))
def _clamp_deye_tou_soc_pct(pct: int) -> int:
return max(5, min(95, pct))
def _deye_tou_min_soc_pct(inv: InverterConfig) -> int:
if inv.min_soc_percent is not None:
return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent))
return 10
def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int:
if inv.reserve_soc_percent is not None:
return _clamp_deye_tou_soc_pct(int(inv.reserve_soc_percent))
return 20
def get_deye_mode(setpoints: ControlSetpoints) -> str:
"""
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
@@ -731,11 +901,12 @@ def _deye_tou_params(
Parametry jednoho Deye time pointu: výkon W, SOC min %, grid_charge.
Musí odpovídat logice get_deye_mode / lock_battery v write_inverter_setpoints.
"""
reserve_soc = inv.reserve_soc_percent or 20
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge
tou_min = _deye_tou_min_soc_pct(inv)
tou_reserve = _deye_tou_reserve_soc_pct(inv)
if setpoints.lock_battery:
return tp_discharge_w, reserve_soc, False
return tp_discharge_w, tou_min, False
deye_mode = get_deye_mode(setpoints)
if deye_mode == "CHARGE":
raw_bat = setpoints.battery_w
@@ -744,7 +915,9 @@ def _deye_tou_params(
target_soc = max(10, min(95, cap))
tp_charge_w = battery_watts_to_amps(battery_w, inv.max_charge_a) * int(BATT_VOLTAGE_V)
return tp_charge_w, target_soc, True
return tp_discharge_w, reserve_soc, False
if deye_mode == "SELL":
return tp_discharge_w, tou_reserve, False
return tp_discharge_w, tou_min, False
async def write_inverter_setpoints(
@@ -762,9 +935,10 @@ async def write_inverter_setpoints(
grid_w = int(setpoints_now.grid_setpoint_w or 0)
no_export = inv.no_export
export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w)
reserve_soc = inv.reserve_soc_percent or 20
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge
tou_min_pct = _deye_tou_min_soc_pct(inv)
tou_reserve_pct = _deye_tou_reserve_soc_pct(inv)
try:
soc_telemetry = await _get_current_soc(site_id, db)
@@ -795,9 +969,17 @@ async def write_inverter_setpoints(
)
now_cet, time_rows = _deye_system_time_register_rows()
logger.info("Deye time synced: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S"))
skip_time = _deye_skip_time_registers(inv)
if skip_time:
logger.info(
"Deye clock 6264 skipped (same Prague minute as last sync): %s CET",
now_cet.strftime("%Y-%m-%d %H:%M:%S"),
)
else:
logger.info("Deye time will sync: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S"))
registers: list[tuple[int, str, int]] = list(time_rows)
registers: list[tuple[int, str, int]] = [] if skip_time else list(time_rows)
time_rows_were_scheduled = not skip_time
sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now
hh_cur = current_slot_hhmm()
@@ -807,11 +989,24 @@ async def write_inverter_setpoints(
registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1))
registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2))
for idx in range(2, 6):
registers.extend(
_deye_time_point_rows(
idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, reserve_soc, False
prague_date = datetime.now(PRAGUE_TZ).date()
inactive_sig = (
f"{DEYE_TOU_INACTIVE_HHMM}|{tou_min_pct}|{tou_reserve_pct}|{tp_discharge_w}"
)
need_inactive_tou = (
inv.deye_last_tou_inactive_write_prague_date != prague_date
or inv.deye_tou_inactive_signature != inactive_sig
)
if need_inactive_tou:
for idx in range(2, 6):
registers.extend(
_deye_time_point_rows(
idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, tou_min_pct, False
)
)
else:
logger.debug(
"Deye TOU rows 36 skipped (already written today, signature unchanged)"
)
registers.extend(
@@ -841,6 +1036,53 @@ async def write_inverter_setpoints(
grid_w,
)
last_verified = await _fetch_last_verified_inverter_registers(site_id, inv.id, db)
registers, skipped_unchanged = _drop_registers_matching_last_verified(
registers, last_verified
)
if skipped_unchanged:
logger.info(
"[control] %s: skip %s registers (value equals last verified): %s",
inv.code,
len(skipped_unchanged),
skipped_unchanged[:24],
)
if not registers:
logger.info(
"[control] %s: all Deye holding regs match last verified, no Modbus write",
inv.code,
)
if need_inactive_tou:
await db.execute(
"""
UPDATE ems.asset_inverter
SET deye_last_tou_inactive_write_prague_date = $1,
deye_tou_inactive_signature = $2
WHERE id = $3
""",
prague_date,
inactive_sig,
inv.id,
)
if time_rows_were_scheduled:
await db.execute(
"""
UPDATE ems.asset_inverter
SET deye_last_system_time_sync_minute = $1
WHERE id = $2
""",
_prague_minute_start_utc(),
inv.id,
)
return (
f"OK inverter: batt_w={raw_bat!r} (no changes vs last verified Modbus snapshot)"
)
will_write_time = any(int(r) in (62, 63, 64) for r, _, _ in registers)
will_write_inactive = any(
int(r) in _DEYE_INACTIVE_TOU_REGISTERS for r, _, _ in registers
)
cmd_ids = await create_modbus_commands(
site_id,
planning_run_id,
@@ -857,6 +1099,30 @@ async def write_inverter_setpoints(
if not await execute_modbus_commands(cmd_ids, db):
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
logger.info("[control] Inverter %s journal write OK", inv.code)
minute_utc = _prague_minute_start_utc()
if will_write_time:
await db.execute(
"""
UPDATE ems.asset_inverter
SET deye_last_system_time_sync_minute = $1
WHERE id = $2
""",
minute_utc,
inv.id,
)
if need_inactive_tou or will_write_inactive:
await db.execute(
"""
UPDATE ems.asset_inverter
SET deye_last_tou_inactive_write_prague_date = $1,
deye_tou_inactive_signature = $2
WHERE id = $3
""",
prague_date,
inactive_sig,
inv.id,
)
except Exception as e:
return f"FAIL inverter: {inv.code}: {e}"

View File

@@ -233,6 +233,17 @@ class PersistentModbusClient:
raw = await self.read_register(address, device_id)
return raw - 65536 if raw > 32767 else raw
async def read_holding_registers(
self, address: int, count: int, device_id: int = 1
) -> list[int]:
"""FC 0x03 souvislé holding registry (ověřování po blocích)."""
async with _gateway_exclusive(self.host, self.port):
async with self._lock:
await self._ensure_connected()
return await self._read_holding_registers_locked(
address, count, device_id
)
async def write_register(self, address: int, value: int, device_id: int = 1) -> bool:
async with _gateway_exclusive(self.host, self.port):
async with self._lock:

View File

@@ -31,6 +31,8 @@ SLOT_WEIGHT_MEDIUM = 0.7 # 3672h
SLOT_WEIGHT_LOW = 0.4 # 7296h
CURTAILMENT_PENALTY = 0.001 # Kč/Wh malá penalizace za omezení FVE pole A
SOLVER_TIME_LIMIT = 10 # sekund
# MILP: jakýkoli významný výkon exportu ge (W) ⇒ koncové soc[t] ≥ arb_base_wh (rezerva z DB)
GE_MIN_EXPORT_W = 1.0
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
@@ -332,6 +334,7 @@ def solve_dispatch(
bd = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)]
soc = [pulp.LpVariable(f"soc_{t}", min_soc_wh, battery.soc_max_wh) for t in range(T)]
w_arb = [pulp.LpVariable(f"w_arb_{t}", cat=pulp.LpBinary) for t in range(T)]
z_export = [pulp.LpVariable(f"z_export_{t}", cat=pulp.LpBinary) for t in range(T)]
ca = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)]
hp = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)]
soc_deficit_24h = pulp.LpVariable("soc_deficit_24h", 0, battery.usable_capacity_wh)
@@ -410,6 +413,13 @@ def solve_dispatch(
+ battery.max_discharge_power_w * w_arb[t]
)
# Významný export ⇒ koncové SoC ≥ ekonomická rezerva (arb_base_wh), ne dynamická arb_floor_series
m_ge = float(grid.max_export_power_w)
m_soc_bigm = float(battery.usable_capacity_wh)
prob += ge[t] <= m_ge * z_export[t]
prob += ge[t] >= GE_MIN_EXPORT_W * z_export[t]
prob += soc[t] >= arb_base_wh - m_soc_bigm * (1 - z_export[t])
# EV limity a připojení
for e in range(EV):
connected = (