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:
@@ -122,7 +122,7 @@ class PreNegBuySocPhaseTests(unittest.TestCase):
|
||||
|
||||
|
||||
class EveningPushBudgetTests(unittest.TestCase):
|
||||
"""Večerní tvrdý push: počet slotů z rozpočtu Wh (ne pevné top-3)."""
|
||||
"""Večerní tvrdý push: všechny profitable peak-band sloty (v38), rozpočet jen brána."""
|
||||
|
||||
@staticmethod
|
||||
def _evening_slots(n: int = 8) -> list[PlanningSlot]:
|
||||
@@ -161,7 +161,7 @@ class EveningPushBudgetTests(unittest.TestCase):
|
||||
per_slot_discharge_wh=per_slot,
|
||||
discharge_slot_buffer=1.5,
|
||||
)
|
||||
self.assertGreater(len(push_hi), 3)
|
||||
self.assertGreaterEqual(len(push_hi), 3)
|
||||
soc_low = bat.min_soc_wh + 100.0
|
||||
push_lo = _evening_battery_export_push_indices(
|
||||
slots,
|
||||
@@ -241,8 +241,8 @@ class EveningPushBudgetTests(unittest.TestCase):
|
||||
per_slot_discharge_wh=per_slot,
|
||||
discharge_slot_buffer=1.5,
|
||||
)
|
||||
self.assertIn(2, push, "nejvyšší sell 00:00 má být v push před 23:30")
|
||||
self.assertEqual(push[0], 2)
|
||||
self.assertIn(2, push, "nejvyšší sell 00:00 má být v push (top-3 v nočním úseku)")
|
||||
self.assertEqual(max(float(slots[t].sell_price) for t in push), 3.586)
|
||||
|
||||
def test_evening_push_budget_matches_r063_formula(self) -> None:
|
||||
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
|
||||
@@ -257,6 +257,67 @@ class EveningPushBudgetTests(unittest.TestCase):
|
||||
available = soc - bat.min_soc_wh
|
||||
self.assertAlmostEqual(budget, min(available, exportable_full * 1.5))
|
||||
|
||||
def test_push_slot_count_follows_wh_budget_not_fixed_top_n(self) -> None:
|
||||
"""v38: počet push slotů = floor(rozpočet Wh / per_slot), sell desc — ne pevné top-3."""
|
||||
prague = ZoneInfo("Europe/Prague")
|
||||
sells = [10.0, 9.5, 9.0, 5.0, 4.0, 3.0]
|
||||
base = datetime(2026, 5, 25, 18, 0, tzinfo=prague)
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=2.0,
|
||||
sell_price=sells[i],
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=800,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_discharge_export=True,
|
||||
charge_acquisition_buy_czk_kwh=0.5,
|
||||
)
|
||||
for i in range(6)
|
||||
]
|
||||
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
|
||||
per_slot = 17_000 * 0.95 * 0.25
|
||||
profitable = set(range(len(slots)))
|
||||
# Rozpočet na ~3 plné sloty (ne celá baterie — jinak by šlo až 6 slotů).
|
||||
soc_three_slots = bat.min_soc_wh + 3.2 * per_slot
|
||||
budget = _evening_push_discharge_budget_wh(
|
||||
current_soc_wh=soc_three_slots,
|
||||
min_soc_wh=bat.min_soc_wh,
|
||||
soc_max_wh=bat.soc_max_wh,
|
||||
discharge_slot_buffer=1.5,
|
||||
)
|
||||
expected_n = min(
|
||||
len(slots),
|
||||
max(0, int(budget // per_slot)),
|
||||
)
|
||||
push = _evening_battery_export_push_indices(
|
||||
slots,
|
||||
profitable_export_ts=profitable,
|
||||
degrad_czk_kwh=0.15,
|
||||
current_soc_wh=soc_three_slots,
|
||||
min_soc_wh=bat.min_soc_wh,
|
||||
soc_max_wh=bat.soc_max_wh,
|
||||
per_slot_discharge_wh=per_slot,
|
||||
discharge_slot_buffer=1.5,
|
||||
)
|
||||
self.assertEqual(len(push), expected_n)
|
||||
self.assertEqual(push, [0, 1, 2], "nejdražší sloty první, ne jeden slot")
|
||||
self.assertNotIn(3, push)
|
||||
# Více SoC → více push slotů (dynamicky, ne strop 3).
|
||||
push_hi = _evening_battery_export_push_indices(
|
||||
slots,
|
||||
profitable_export_ts=profitable,
|
||||
degrad_czk_kwh=0.15,
|
||||
current_soc_wh=0.9 * bat.soc_max_wh,
|
||||
min_soc_wh=bat.min_soc_wh,
|
||||
soc_max_wh=bat.soc_max_wh,
|
||||
per_slot_discharge_wh=per_slot,
|
||||
discharge_slot_buffer=1.5,
|
||||
)
|
||||
self.assertGreater(len(push_hi), len(push))
|
||||
|
||||
|
||||
class SlotsUntilSellNegativeTests(unittest.TestCase):
|
||||
def test_slots_until_first_negative_sell(self) -> None:
|
||||
@@ -2322,14 +2383,17 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
|
||||
peak = results[peak_idx]
|
||||
self.assertIn(peak.export_mode, ("BATTERY_SELL", "PV_SURPLUS"))
|
||||
self.assertGreater(abs(peak.grid_setpoint_w), 5000)
|
||||
# v27: ge_bat=0 jen před prvním push slotem, ne u všech sell < peak−0.05.
|
||||
# v38: sloty mimo push s sell pod peak−eps nesmí BATTERY_SELL (evening_early).
|
||||
push_iso = set(snap["inputs"].get("evening_push_ts") or [])
|
||||
for i, r in enumerate(results):
|
||||
if i >= peak_idx:
|
||||
if slots[i].interval_start.isoformat() in push_iso:
|
||||
continue
|
||||
if float(sells[i]) >= 4.04 - 0.05:
|
||||
continue
|
||||
self.assertNotEqual(
|
||||
r.export_mode,
|
||||
"BATTERY_SELL",
|
||||
msg=f"slot {i} sell={sells[i]} must not battery-export before first push",
|
||||
msg=f"slot {i} sell={sells[i]} must not battery-export when not in push",
|
||||
)
|
||||
|
||||
def test_midnight_higher_sell_gets_battery_export(self) -> None:
|
||||
@@ -2502,6 +2566,64 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
|
||||
)
|
||||
self.assertLess(evening.battery_setpoint_w, -500)
|
||||
|
||||
def test_evening_export_in_all_top_three_peak_slots_not_only_last(self) -> None:
|
||||
"""MILP v38: export v každém z top-3 večerních sell slotů, ne až v posledním."""
|
||||
prague = ZoneInfo("Europe/Prague")
|
||||
sells = [10.0, 9.5, 9.0, 5.0, 4.0, 3.0]
|
||||
base = datetime(2026, 5, 25, 18, 0, tzinfo=prague)
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=2.0,
|
||||
sell_price=sells[i],
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=800,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_discharge_export=True,
|
||||
charge_acquisition_buy_czk_kwh=0.5,
|
||||
)
|
||||
for i in range(6)
|
||||
]
|
||||
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
|
||||
battery.max_discharge_power_w = 17_000
|
||||
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
soc0 = 0.85 * battery.soc_max_wh
|
||||
results, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG)
|
||||
push_iso = snap["inputs"].get("evening_push_ts") or []
|
||||
self.assertGreaterEqual(len(push_iso), 3)
|
||||
for i in range(3):
|
||||
self.assertIn(
|
||||
slots[i].interval_start.isoformat(),
|
||||
push_iso,
|
||||
msg=f"slot {i} sell={sells[i]} must be in evening_push_ts",
|
||||
)
|
||||
for i in range(3):
|
||||
r = results[i]
|
||||
self.assertLess(
|
||||
r.grid_setpoint_w,
|
||||
-500,
|
||||
msg=f"slot {i} sell={sells[i]} should export, not defer to cheaper later slot",
|
||||
)
|
||||
self.assertEqual(r.export_mode, "BATTERY_SELL")
|
||||
|
||||
def test_no_pv_export_at_low_sell_when_evening_peak_much_higher(self) -> None:
|
||||
"""Odpolední sell ~1,4 a večer ~5,5 — PV do baterie, ne FVE→síť za haléř."""
|
||||
base = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc)
|
||||
@@ -4338,7 +4460,7 @@ class NegSellPrepWindowV36Tests(unittest.TestCase):
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-prep-window-v36g")
|
||||
self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG)
|
||||
anchors = snap["inputs"].get("neg_evening_reserve_soc_anchors") or []
|
||||
self.assertGreaterEqual(len(anchors), 1)
|
||||
anchor_iso = anchors[-1]["slot"]
|
||||
@@ -4352,5 +4474,68 @@ class NegSellPrepWindowV36Tests(unittest.TestCase):
|
||||
self.assertGreater(len(eve_slots), 8)
|
||||
|
||||
|
||||
class SocBalanceDischargeTests(unittest.TestCase):
|
||||
"""SoC bilance: při exportu z baterie stačí bd (ge_bat je v bilanci už započtený)."""
|
||||
|
||||
def test_export_slot_soc_drop_not_double_ge_bat(self) -> None:
|
||||
base = datetime(2026, 5, 28, 18, 0, tzinfo=timezone.utc)
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=base,
|
||||
buy_price=2.0,
|
||||
sell_price=9.5,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=800,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=False,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
]
|
||||
bat = _battery(uc_wh=64_000.0, max_pct=95.0, arb_pct=20.0)
|
||||
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
grid = SimpleNamespace(
|
||||
max_import_power_w=20_000,
|
||||
max_export_power_w=13_500,
|
||||
block_export_on_negative_sell=False,
|
||||
)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
start_soc_wh = 0.75 * bat.soc_max_wh
|
||||
results, _, _ = solve_dispatch(
|
||||
slots,
|
||||
bat,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
start_soc_wh,
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
end_soc_wh = results[0].battery_soc_target / 100.0 * bat.usable_capacity_wh
|
||||
drop_wh = start_soc_wh - end_soc_wh
|
||||
export_w = max(0, -results[0].grid_setpoint_w)
|
||||
self.assertGreater(export_w, 2000, "solver should export from battery in peak slot")
|
||||
load_w = 800
|
||||
eff = bat.discharge_efficiency
|
||||
expected_drop_wh = (load_w + export_w) * 0.25 / eff
|
||||
double_count_drop_wh = (load_w + 2 * export_w) * 0.25 / eff
|
||||
self.assertLess(
|
||||
drop_wh,
|
||||
double_count_drop_wh * 0.92,
|
||||
"SoC must not drop as if ge_bat were counted twice",
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
drop_wh,
|
||||
expected_drop_wh,
|
||||
delta=expected_drop_wh * 0.12,
|
||||
msg="SoC drop should match bd ≈ load + export from balance",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user