tune microcycling
This commit is contained in:
@@ -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, 141–145, 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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user