Fix SoC balance on battery export and improve evening push (v39).
SoC continuity now deducts only bd (ge_bat was double-counted via energy balance), which stopped the plan from draining ~2× faster than BMS during evening BATTERY_SELL. Also ships dynamic evening push budget + rolling hysteresis (v38), drops unused fn_soc_tracking_bundle, and adds tests/docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -71,7 +71,7 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
|
||||
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
|
||||
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
|
||||
PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36g"
|
||||
PLANNER_BUILD_TAG = "2026-05-28-evening-export-soc-balance-v39"
|
||||
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
|
||||
NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0
|
||||
# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat).
|
||||
@@ -88,6 +88,9 @@ POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.30
|
||||
PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH = 0.25
|
||||
PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
EVENING_PEAK_SELL_EPS_CZK_KWH = 0.05
|
||||
# Rolling replan: držet evening_push_ts při malé změně peak sell / SoC.
|
||||
EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH = 0.5
|
||||
EVENING_PUSH_HYSTERESIS_SOC_PCT = 5.0
|
||||
# Noční výprodej baterie: večer (≥17h) + ráno do východu FVE (0–5h Prague), jedna špička přes půlnoc.
|
||||
NIGHT_EXPORT_EVENING_START_HOUR = 17
|
||||
NIGHT_EXPORT_MORNING_END_HOUR = 5
|
||||
@@ -1581,26 +1584,22 @@ def _evening_battery_export_push_indices(
|
||||
evening_start_hour: int = 17,
|
||||
) -> list[int]:
|
||||
"""
|
||||
Noční push: plný ge_bat na top sell sloty v nočním okně (≥17h + 0–5h do východu FVE).
|
||||
Noční push: plný ge_bat v tolika nejdražších profitable slotech, kolik unese Wh rozpočet.
|
||||
|
||||
Ne jeden slot — kolik slotů unese Wh rozpočet, seřazených sell desc.
|
||||
Peak sell je max v celém nočním úseku (přes půlnoc), ne per kalendářní den.
|
||||
Kandidáti = profitable ∩ noční okno (≥17h + 0–5h do východu FVE). Řazení sell desc;
|
||||
přidávat sloty dokud kumulované Wh ≤ push_budget (R__063: discharge_slot_buffer × SoC).
|
||||
per_slot_discharge_wh = max_discharge × účinnost × 0,25 h; volající předává
|
||||
min(discharge, export_cap × účinnost × 0,25 h) — home-01 export 13,5 kW ≈ 3,4 kWh/slot.
|
||||
"""
|
||||
_ = degrad_czk_kwh, evening_start_hour # kompatibilita volání
|
||||
if per_slot_discharge_wh <= 0.0:
|
||||
return []
|
||||
peak_ts = _evening_peak_export_indices(
|
||||
slots,
|
||||
degrad_czk_kwh=degrad_czk_kwh,
|
||||
evening_start_hour=evening_start_hour,
|
||||
)
|
||||
candidates = [t for t in peak_ts if t in profitable_export_ts]
|
||||
if not candidates:
|
||||
return []
|
||||
max_sell = max(float(slots[t].sell_price) for t in candidates)
|
||||
candidates = [
|
||||
t
|
||||
for t in candidates
|
||||
if float(slots[t].sell_price) >= max_sell - EVENING_PEAK_SELL_EPS_CZK_KWH
|
||||
for t, s in enumerate(slots)
|
||||
if _in_night_battery_export_window(s)
|
||||
and t in profitable_export_ts
|
||||
and float(s.sell_price) >= 0.0
|
||||
]
|
||||
if not candidates:
|
||||
return []
|
||||
@@ -1627,6 +1626,73 @@ def _evening_battery_export_push_indices(
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def _evening_night_peak_sell_czk(slots: list[PlanningSlot]) -> float:
|
||||
sells = [
|
||||
float(s.sell_price)
|
||||
for s in slots
|
||||
if _in_night_battery_export_window(s) and float(s.sell_price) >= 0.0
|
||||
]
|
||||
return max(sells) if sells else 0.0
|
||||
|
||||
|
||||
def _evening_push_peak_sell_czk(slots: list[PlanningSlot], push_ts: set[int]) -> float:
|
||||
if not push_ts:
|
||||
return 0.0
|
||||
return max(float(slots[t].sell_price) for t in push_ts)
|
||||
|
||||
|
||||
def _evening_push_ts_from_iso(slots: list[PlanningSlot], iso_slots: list[str]) -> set[int]:
|
||||
by_iso = {s.interval_start.isoformat(): t for t, s in enumerate(slots)}
|
||||
return {by_iso[iso] for iso in iso_slots if iso in by_iso}
|
||||
|
||||
|
||||
def _evening_push_hysteresis_active(
|
||||
*,
|
||||
prev_peak_sell_czk: float | None,
|
||||
new_peak_sell_czk: float,
|
||||
prev_soc_wh: float | None,
|
||||
current_soc_wh: float,
|
||||
usable_capacity_wh: float,
|
||||
) -> bool:
|
||||
if prev_peak_sell_czk is None:
|
||||
return False
|
||||
if abs(new_peak_sell_czk - float(prev_peak_sell_czk)) >= (
|
||||
EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH
|
||||
):
|
||||
return False
|
||||
if prev_soc_wh is not None and usable_capacity_wh > 1e-6:
|
||||
delta_pct = (
|
||||
abs(float(current_soc_wh) - float(prev_soc_wh))
|
||||
/ float(usable_capacity_wh)
|
||||
* 100.0
|
||||
)
|
||||
if delta_pct >= EVENING_PUSH_HYSTERESIS_SOC_PCT:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _evening_early_export_penalty_indices(
|
||||
slots: list[PlanningSlot],
|
||||
*,
|
||||
profitable_export_ts: set[int],
|
||||
discharge_export_slots: set[int],
|
||||
evening_push_ts: set[int],
|
||||
) -> set[int]:
|
||||
"""ge_bat=0 pro profitable noční sloty pod peak−eps mimo evening_push (v38: i po prvním push)."""
|
||||
out: set[int] = set()
|
||||
for t_ev, s_ev in enumerate(slots):
|
||||
if not _in_night_battery_export_window(s_ev):
|
||||
continue
|
||||
if t_ev not in profitable_export_ts or t_ev not in discharge_export_slots:
|
||||
continue
|
||||
if t_ev in evening_push_ts:
|
||||
continue
|
||||
peak_sell = _night_peak_sell_czk_kwh(slots, t_ev)
|
||||
if float(s_ev.sell_price) < peak_sell - EVENING_PEAK_SELL_EPS_CZK_KWH:
|
||||
out.add(t_ev)
|
||||
return out
|
||||
|
||||
|
||||
def _last_non_negative_sell_before_neg_buy(
|
||||
slots: list[PlanningSlot],
|
||||
first_neg_buy_idx: int | None,
|
||||
@@ -1772,6 +1838,7 @@ def solve_dispatch_two_pass(
|
||||
operating_mode: str = "AUTO",
|
||||
charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
|
||||
planner_version: str | None = None,
|
||||
evening_push_ts_override: Optional[set[int]] = None,
|
||||
) -> tuple[list["DispatchResult"], int, dict[str, Any]]:
|
||||
"""
|
||||
Dva průchody solve_dispatch: pass2 používá acquisition z váženého buy nabíjení v pass1.
|
||||
@@ -1789,6 +1856,7 @@ def solve_dispatch_two_pass(
|
||||
operating_mode=operating_mode,
|
||||
charge_commitment_prev_w=charge_commitment_prev_w,
|
||||
planner_version=planner_version,
|
||||
evening_push_ts_override=evening_push_ts_override,
|
||||
)
|
||||
acq1 = float(
|
||||
snap1.get("inputs", {}).get("charge_acquisition_buy_czk_kwh")
|
||||
@@ -1820,6 +1888,7 @@ def solve_dispatch_two_pass(
|
||||
operating_mode=operating_mode,
|
||||
charge_commitment_prev_w=charge_commitment_prev_w,
|
||||
planner_version=planner_version,
|
||||
evening_push_ts_override=evening_push_ts_override,
|
||||
)
|
||||
if isinstance(snap2.get("inputs"), dict):
|
||||
snap2["inputs"]["acquisition_pass1_czk_kwh"] = round(acq1, 6)
|
||||
@@ -1847,6 +1916,7 @@ def solve_dispatch(
|
||||
planner_version: str | None = None,
|
||||
relaxed_expensive_import: bool = False,
|
||||
relaxed_neg_buy_charge: bool = False,
|
||||
evening_push_ts_override: Optional[set[int]] = None,
|
||||
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
|
||||
"""
|
||||
LP solver pro dispatch optimalizaci.
|
||||
@@ -2203,6 +2273,7 @@ def solve_dispatch(
|
||||
profitable_export_ts_pre.add(_t)
|
||||
evening_push_ts: set[int] = set()
|
||||
evening_early_export_penalty_ts: set[int] = set()
|
||||
evening_push_hysteresis_retained = False
|
||||
if om == "AUTO":
|
||||
per_slot_discharge_wh_pre = max(
|
||||
float(battery.max_discharge_power_w)
|
||||
@@ -2210,8 +2281,13 @@ def solve_dispatch(
|
||||
* INTERVAL_H,
|
||||
0.0,
|
||||
)
|
||||
export_cap_push_w = _battery_export_cap_w(battery, grid)
|
||||
per_slot_push_wh_pre = min(
|
||||
per_slot_discharge_wh_pre,
|
||||
export_cap_push_w * float(battery.discharge_efficiency) * INTERVAL_H,
|
||||
)
|
||||
discharge_buf_pre = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
|
||||
evening_push_ts = set(
|
||||
computed_evening_push_ts = set(
|
||||
_evening_battery_export_push_indices(
|
||||
slots,
|
||||
profitable_export_ts=profitable_export_ts_pre,
|
||||
@@ -2219,26 +2295,21 @@ def solve_dispatch(
|
||||
current_soc_wh=float(current_soc_wh),
|
||||
min_soc_wh=float(min_soc_wh),
|
||||
soc_max_wh=float(battery.soc_max_wh),
|
||||
per_slot_discharge_wh=per_slot_discharge_wh_pre,
|
||||
per_slot_discharge_wh=per_slot_push_wh_pre,
|
||||
discharge_slot_buffer=discharge_buf_pre,
|
||||
)
|
||||
)
|
||||
# Zákaz ge_bat jen *před* prvním push slotem (ne po něm — jinak terminal SoC + load
|
||||
# drží energii pro 19–21 h bez prodeje, home-01 téměř neexportuje).
|
||||
first_evening_push_t = min(evening_push_ts) if evening_push_ts else None
|
||||
if first_evening_push_t is not None:
|
||||
for t_ev, s_ev in enumerate(slots):
|
||||
if not _in_night_battery_export_window(s_ev):
|
||||
continue
|
||||
if t_ev >= first_evening_push_t:
|
||||
continue
|
||||
if t_ev not in profitable_export_ts_pre or t_ev not in discharge_export_slots:
|
||||
continue
|
||||
if t_ev in evening_push_ts:
|
||||
continue
|
||||
peak_sell = _night_peak_sell_czk_kwh(slots, t_ev)
|
||||
if float(s_ev.sell_price) < peak_sell - EVENING_PEAK_SELL_EPS_CZK_KWH:
|
||||
evening_early_export_penalty_ts.add(t_ev)
|
||||
if evening_push_ts_override is not None:
|
||||
evening_push_ts = set(evening_push_ts_override)
|
||||
evening_push_hysteresis_retained = True
|
||||
else:
|
||||
evening_push_ts = computed_evening_push_ts
|
||||
evening_early_export_penalty_ts = _evening_early_export_penalty_indices(
|
||||
slots,
|
||||
profitable_export_ts=profitable_export_ts_pre,
|
||||
discharge_export_slots=discharge_export_slots,
|
||||
evening_push_ts=evening_push_ts,
|
||||
)
|
||||
last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy(
|
||||
slots, first_neg_buy_idx
|
||||
)
|
||||
@@ -2964,12 +3035,13 @@ def solve_dispatch(
|
||||
# Měkký breaker cap: gi_over[t] >= max(0, gi[t] - breaker).
|
||||
prob += gi_over[t] >= gi[t] - float(grid.max_import_power_w)
|
||||
|
||||
# SoC kontinuita (bd do domu i ge_bat do sítě vybíjí baterii)
|
||||
# SoC kontinuita: bd je v bilanci zdroj na AC sběrnici; při exportu z baterie už
|
||||
# obsahuje load + ge_bat (ge = ge_pv + ge_bat). ge_bat znovu neodečítat.
|
||||
soc_prev = current_soc_wh if t == 0 else soc[t - 1]
|
||||
prob += soc[t] == (
|
||||
soc_prev
|
||||
+ (bc_pv[t] + bc_gi[t]) * battery.charge_efficiency * INTERVAL_H
|
||||
- (bd[t] + ge_bat[t]) / battery.discharge_efficiency * INTERVAL_H
|
||||
- bd[t] / battery.discharge_efficiency * INTERVAL_H
|
||||
)
|
||||
|
||||
sv = safety_vars[t]
|
||||
@@ -3675,6 +3747,12 @@ def solve_dispatch(
|
||||
if neg_sell_phases_en
|
||||
else None
|
||||
),
|
||||
"evening_push": (
|
||||
t in evening_push_ts if om == "AUTO" else None
|
||||
),
|
||||
"evening_early_export_ban": (
|
||||
t in evening_early_export_penalty_ts if om == "AUTO" else None
|
||||
),
|
||||
}
|
||||
)
|
||||
tgt_s = st.safety_soc_target_wh if daytime_en else None
|
||||
@@ -3821,6 +3899,15 @@ def solve_dispatch(
|
||||
if slots[0].charge_acquisition_cutoff_at is not None
|
||||
else None
|
||||
),
|
||||
"evening_push_ts": [
|
||||
slots[i].interval_start.isoformat() for i in sorted(evening_push_ts)
|
||||
],
|
||||
"evening_push_peak_sell_czk_kwh": (
|
||||
_evening_push_peak_sell_czk(slots, evening_push_ts)
|
||||
if evening_push_ts
|
||||
else _evening_night_peak_sell_czk(slots)
|
||||
),
|
||||
"evening_push_hysteresis_retained": bool(evening_push_hysteresis_retained),
|
||||
},
|
||||
"masks": masks_snap,
|
||||
"soc_bounds": soc_bounds_snap,
|
||||
@@ -4070,6 +4157,9 @@ async def run_rolling_replan(
|
||||
slots_raw_pv.append(replace(s, pv_a_forecast_w=pva, pv_b_forecast_w=pvb))
|
||||
|
||||
commitment_prev = await _load_previous_plan_charge_commitment_prev_w(site_id, slots, db)
|
||||
evening_push_override = await _rolling_evening_push_override(
|
||||
site_id, slots, battery, soc_wh, db
|
||||
)
|
||||
|
||||
om = operating_mode or "AUTO"
|
||||
if om == "AUTO":
|
||||
@@ -4079,6 +4169,7 @@ async def run_rolling_replan(
|
||||
operating_mode=om,
|
||||
charge_commitment_prev_w=commitment_prev,
|
||||
planner_version=planner_version_resolved,
|
||||
evening_push_ts_override=evening_push_override,
|
||||
)
|
||||
else:
|
||||
results, duration_ms, solver_snapshot = solve_dispatch(
|
||||
@@ -4354,6 +4445,62 @@ async def _load_site_context(site_id: int, db):
|
||||
)
|
||||
|
||||
|
||||
async def _rolling_evening_push_override(
|
||||
site_id: int,
|
||||
slots: list[PlanningSlot],
|
||||
battery,
|
||||
current_soc_wh: float,
|
||||
db,
|
||||
) -> set[int] | None:
|
||||
"""Rolling: držet evening_push_ts z aktivního runu při malé změně peak sell / SoC."""
|
||||
if not slots:
|
||||
return None
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
select solver_params
|
||||
from ems.planning_run
|
||||
where site_id = $1::int
|
||||
and status = 'active'
|
||||
limit 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if row is None or row["solver_params"] is None:
|
||||
return None
|
||||
sp = row["solver_params"]
|
||||
if isinstance(sp, str):
|
||||
sp = json.loads(sp)
|
||||
if not isinstance(sp, dict):
|
||||
return None
|
||||
inputs = sp.get("inputs")
|
||||
if not isinstance(inputs, dict):
|
||||
return None
|
||||
prev_iso = inputs.get("evening_push_ts")
|
||||
if not isinstance(prev_iso, list) or not prev_iso:
|
||||
return None
|
||||
prev_push = _evening_push_ts_from_iso(slots, [str(x) for x in prev_iso])
|
||||
if not prev_push:
|
||||
return None
|
||||
prev_peak = inputs.get("evening_push_peak_sell_czk_kwh")
|
||||
prev_soc = inputs.get("current_soc_wh")
|
||||
new_peak = _evening_night_peak_sell_czk(slots)
|
||||
if not _evening_push_hysteresis_active(
|
||||
prev_peak_sell_czk=float(prev_peak) if prev_peak is not None else None,
|
||||
new_peak_sell_czk=new_peak,
|
||||
prev_soc_wh=float(prev_soc) if prev_soc is not None else None,
|
||||
current_soc_wh=float(current_soc_wh),
|
||||
usable_capacity_wh=float(battery.usable_capacity_wh),
|
||||
):
|
||||
return None
|
||||
logger.info(
|
||||
"[site=%s] evening_push hysteresis: retaining %d slot(s), peak_sell=%.3f",
|
||||
site_id,
|
||||
len(prev_push),
|
||||
new_peak,
|
||||
)
|
||||
return prev_push
|
||||
|
||||
|
||||
async def _load_previous_plan_charge_commitment_prev_w(
|
||||
site_id: int,
|
||||
slots: list[PlanningSlot],
|
||||
|
||||
Reference in New Issue
Block a user