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

@@ -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(