tune microcycling
All checks were successful
deploy / deploy (push) Successful in 25s
test / smoke-test (push) Successful in 6s

This commit is contained in:
Dusan Vojacek
2026-04-13 00:49:36 +02:00
parent 3b33594354
commit fd06811753
10 changed files with 587 additions and 62 deletions

View File

@@ -62,8 +62,9 @@ DEYE_REGISTER_NAMES: dict[int, str] = {
108: "max_charge_a (max nabíjecí proud baterie)",
109: "max_discharge_a (max vybíjecí proud baterie)",
141: "energy_mode (0, EMS nemění)",
142: "limit_control (0=selling first, 1=zero export built-in CT)",
142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)",
143: "export_limit_w (max export do sítě)",
145: "solar_sell (0=disabled, 1=enabled)",
178: "grid_peak_shaving_switch (SELL=32 bit4-5=10, PASSIVE/CHARGE=48 bit4-5=11)",
148: "time_point_1_time",
149: "time_point_2_time",
@@ -152,6 +153,7 @@ class InverterConfig:
deye_last_system_time_sync_at: datetime | None = None
deye_last_tou_inactive_write_prague_date: date | None = None
deye_tou_inactive_signature: str | None = None
deye_zero_export_mode: int = 1
def _prague_minute_start_utc() -> datetime:
@@ -972,6 +974,7 @@ async def _load_inverter_config(
ai.deye_tou_inactive_signature,
ai.deye_register_max_charge_a,
ai.deye_register_max_discharge_a,
COALESCE(ai.deye_zero_export_mode, 1) AS deye_zero_export_mode,
LEAST(
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w),
ai.max_battery_charge_w
@@ -1047,6 +1050,7 @@ async def _load_inverter_config(
"deye_last_tou_inactive_write_prague_date"
],
deye_tou_inactive_signature=row["deye_tou_inactive_signature"],
deye_zero_export_mode=int(row["deye_zero_export_mode"]),
)
@@ -1266,15 +1270,15 @@ def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int:
def get_deye_mode(setpoints: ControlSetpoints) -> str:
"""
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
Solver: záporný grid_setpoint_w = export; kladný výrazný + nabíjení = CHARGE ze sítě.
battery_w=None (SELF_SUSTAIN) → bat_w považuj za 0 → typicky PASSIVE při grid_setpoint_w=0.
SELL only when battery actively discharges for grid export (bat_w < -500
AND grid_w < -200). Pass-through (PV → grid, battery idle) stays PASSIVE
with reg 108 = 0 + reg 145 = 1 (solar sell).
battery_w=None (SELF_SUSTAIN) → bat_w considered 0 → PASSIVE.
"""
grid_w = int(setpoints.grid_setpoint_w or 0)
if setpoints.battery_w is None:
bat_w = 0
else:
bat_w = int(setpoints.battery_w)
if grid_w < -200:
bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
if bat_w < -500 and grid_w < -200:
return "SELL"
if bat_w > 500 and grid_w > 200:
return "CHARGE"
@@ -1339,18 +1343,20 @@ async def write_inverter_setpoints(
deye_mode = get_deye_mode(setpoints_now)
bat_w = int(raw_bat) if raw_bat is not None else 0
if setpoints_now.lock_battery:
charge_a = 0
discharge_a = 0
elif deye_mode == "CHARGE":
battery_w = int(raw_bat) if raw_bat is not None else 0
charge_a = battery_watts_to_amps(battery_w, eff_ca)
charge_a = battery_watts_to_amps(bat_w, eff_ca)
discharge_a = 0
else:
charge_a = int(eff_ca)
charge_a = int(eff_ca) if bat_w > 0 else 0
discharge_a = int(eff_da)
selling_mode = 0 if deye_mode == "SELL" else 1
zero_exp_mode = int(inv.deye_zero_export_mode or 1)
selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode
solar_sell = 1
export_limit = export_lim
reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE
@@ -1358,8 +1364,7 @@ async def write_inverter_setpoints(
f"[control] site={site_id} fyzický režim Deye: {deye_mode} | "
f"battery_w={raw_bat!r} grid_w={grid_w} | "
f"charge_a={charge_a} discharge_a={discharge_a} | "
f"reg142={'0=SELL' if deye_mode == 'SELL' else '1=ZERO_EXP'} "
f"reg178={reg178_val}"
f"reg142={selling_mode} reg145={solar_sell} reg178={reg178_val}"
)
now_cet, time_rows = _deye_system_time_register_rows()
@@ -1430,20 +1435,23 @@ async def write_inverter_setpoints(
(108, "", charge_a),
(109, "", discharge_a),
(141, "energy_mode (0)", 0),
(142, "limit_control (0=selling, 1=zero_export)", selling_mode),
(178, "grid_peak_shaving_switch", reg178_val),
(142, "limit_control", selling_mode),
(143, "", export_limit),
(145, "solar_sell", solar_sell),
(178, "grid_peak_shaving_switch", reg178_val),
]
)
logger.info(
"[control] %s: deye_mode=%s charge=%sA discharge=%sA limit_control=%s export=%sW "
"time_point1=%s time_point2=%s soc_telemetry=%s%% (batt=%r grid=%sW)",
"[control] %s: deye_mode=%s charge=%sA discharge=%sA "
"reg142=%s reg145=%s export=%sW "
"tp1=%s tp2=%s soc=%s%% (batt=%r grid=%sW)",
inv.code,
deye_mode,
charge_a,
discharge_a,
selling_mode,
solar_sell,
export_limit,
hh_cur,
hh_nxt,
@@ -1541,7 +1549,7 @@ 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).
Živé čtení holding registrů Deye 108, 109, 141145, 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.
"""
@@ -1555,11 +1563,12 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict
try:
async with client.batch(uid) as mb:
b108 = await mb.read_holding_registers(108, 2)
b141 = await mb.read_holding_registers(141, 3)
b141 = await mb.read_holding_registers(141, 5)
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]
r145 = b141[4]
r178 = r178[0]
r191 = r191[0]
except Exception:
@@ -1572,6 +1581,7 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict
"reg141_energy_mode": int(r141),
"reg142_limit_control": int(r142),
"reg143_export_limit_w": int(r143),
"reg145_solar_sell": int(r145),
"reg178_peak_shaving_switch": int(r178),
"reg191_peak_shaving_w": int(r191),
"read_at": read_at.isoformat(),

View File

@@ -149,6 +149,93 @@ def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]:
return (loc.weekday() + 1) % 7, loc.hour
# ============================================================
# Slot pre-selection (anti-micro-cycling)
# ============================================================
def _select_charge_slots(
slots: list["PlanningSlot"],
battery,
current_soc_wh: float,
) -> set[int]:
"""
Pre-select which slots are eligible for battery charging.
Only the X cheapest sell-price PV-surplus slots are selected,
enough to fill the battery with a configurable buffer.
Returns set of slot indices. Empty set = no restriction.
"""
charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0)
if charge_buf <= 0:
return set(range(len(slots)))
energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh)
if energy_to_fill <= 0:
return set()
candidates: list[tuple[int, float, float]] = []
for t, s in enumerate(slots):
pv_surplus = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w)
if pv_surplus <= 0:
continue
charge_w = min(float(battery.max_charge_power_w), float(pv_surplus))
charge_wh = charge_w * float(battery.charge_efficiency) * INTERVAL_H
candidates.append((t, float(s.sell_price), charge_wh))
candidates.sort(key=lambda x: x[1])
selected: set[int] = set()
cumulative = 0.0
target = energy_to_fill * charge_buf
for t, _price, wh in candidates:
if cumulative >= target:
break
selected.add(t)
cumulative += wh
if cumulative < energy_to_fill:
selected = set(c[0] for c in candidates)
return selected
def _select_discharge_export_slots(
slots: list["PlanningSlot"],
battery,
) -> set[int]:
"""
Pre-select which slots may use battery energy for grid export.
Only the Y most expensive sell-price slots are selected,
enough to empty the exportable portion of the battery with a buffer.
Returns set of slot indices. Empty set = no restriction.
"""
discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
if discharge_buf <= 0:
return set(range(len(slots)))
exportable = float(battery.soc_max_wh) - float(battery.min_soc_wh)
if exportable <= 0:
return set()
candidates = [(t, float(s.sell_price)) for t, s in enumerate(slots)]
candidates.sort(key=lambda x: x[1], reverse=True)
energy_per_slot = (
float(battery.max_discharge_power_w)
* float(battery.discharge_efficiency)
* INTERVAL_H
)
target = exportable * discharge_buf
selected: set[int] = set()
cumulative = 0.0
for t, _price in candidates:
if cumulative >= target:
break
selected.add(t)
cumulative += energy_per_slot
return selected
# ============================================================
# Datové třídy (lze nahradit pydantic modely)
# ============================================================
@@ -448,11 +535,24 @@ def solve_dispatch(
if price_failsafe_active:
for t in range(T):
# Fail-safe aplikujeme po slotech: v predikovaných cenách zakážeme pouze export.
# Baterie se má dál normálně používat pro interní spotřebu (nabíjení/vybíjení do domu).
if slots[t].is_predicted_price:
prob += ge[t] == 0
# Slot pre-selection: omezení nabíjení a discharge-exportu na vybrané sloty
if om == "AUTO":
charge_slots = _select_charge_slots(slots, battery, current_soc_wh)
discharge_export_slots = _select_discharge_export_slots(slots, battery)
for t in range(T):
if t not in charge_slots:
prob += bc[t] == 0
if t not in discharge_export_slots:
s = slots[t]
ev_total_t = pulp.lpSum(
ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV)
)
prob += bd[t] <= s.load_baseline_w + ev_total_t + hp[t]
# Deadline constraints pro EV
for e, session in enumerate(ev_sessions):
if session and session.target_deadline and session.energy_needed_wh > 0:
@@ -795,6 +895,8 @@ async def _load_site_context(site_id: int, db):
ab.charge_efficiency,
ab.discharge_efficiency,
ab.degradation_cost_czk_kwh,
ab.charge_slot_buffer,
ab.discharge_slot_buffer,
LEAST(
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w),
COALESCE(
@@ -856,6 +958,8 @@ async def _load_site_context(site_id: int, db):
degradation_cost_czk_kwh=float(brow["degradation_cost_czk_kwh"]),
max_charge_power_w=ec_i,
max_discharge_power_w=ed_i,
charge_slot_buffer=float(brow["charge_slot_buffer"]) if brow["charge_slot_buffer"] is not None else 0,
discharge_slot_buffer=float(brow["discharge_slot_buffer"]) if brow["discharge_slot_buffer"] is not None else 0,
)
hrow = await db.fetchrow(