oprava battery hold
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-30 22:11:03 +02:00
parent 5208e035a4
commit 96d0d52b07
4 changed files with 146 additions and 19 deletions

View File

@@ -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-29-neg-window-charge-night-v45"
PLANNER_BUILD_TAG = "2026-05-30-evening-spot-sell-ge-buy-v46"
# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu).
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
@@ -1660,9 +1660,20 @@ def _slot_evening_push_profitable(
*,
charge_acquisition_czk_kwh: float,
min_spread: float,
spot_push_sell_ge_buy: bool = False,
) -> bool:
"""Push večerní špičky: spot marže (acq+spread), ne fixní buy z konstantního horizontu."""
return float(slot.sell_price) > float(charge_acquisition_czk_kwh) + float(min_spread)
"""
Push večerní špičky: acq+spread (historická zásoba).
Spot (home-01): navíc sell >= buyspread — jinak vývoz za 3 Kč a noc import za 5 Kč.
Fixní tarif (KV1): jen acq+spread (sell často < konstantní buy).
"""
sell_t = float(slot.sell_price)
buy_t = float(slot.buy_price)
spread = float(min_spread)
if spot_push_sell_ge_buy and buy_t >= 0.0 and sell_t >= 0.0:
if sell_t < buy_t - spread:
return False
return sell_t > float(charge_acquisition_czk_kwh) + spread
def _evening_push_segment_candidates(
@@ -1671,6 +1682,7 @@ def _evening_push_segment_candidates(
*,
charge_acquisition_czk_kwh: float,
min_spread: float,
spot_push_sell_ge_buy: bool = False,
discharge_export_ok: set[int] | None = None,
) -> list[int]:
"""Profitable sloty v nočním úseku — výběr pořadí a strop dělá rozpočet Wh (sell desc)."""
@@ -1686,6 +1698,7 @@ def _evening_push_segment_candidates(
slots[t],
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=min_spread,
spot_push_sell_ge_buy=spot_push_sell_ge_buy,
):
continue
out.append(t)
@@ -1745,6 +1758,7 @@ def _evening_battery_export_push_indices(
soc_max_wh: float,
per_slot_discharge_wh: float,
discharge_slot_buffer: float,
spot_push_sell_ge_buy: bool = False,
discharge_export_ok: set[int] | None = None,
evening_start_hour: int = 17,
) -> list[int]:
@@ -1778,6 +1792,7 @@ def _evening_battery_export_push_indices(
seg,
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=degrad_czk_kwh,
spot_push_sell_ge_buy=spot_push_sell_ge_buy,
discharge_export_ok=discharge_export_ok,
)
if not candidates:
@@ -2505,6 +2520,7 @@ def solve_dispatch(
soc_max_wh=float(battery.soc_max_wh),
per_slot_discharge_wh=per_slot_push_wh_pre,
discharge_slot_buffer=discharge_buf_pre,
spot_push_sell_ge_buy=not purchase_fixed_pre,
discharge_export_ok=discharge_export_slots,
)
)
@@ -3720,11 +3736,16 @@ def solve_dispatch(
buy_t > charge_acquisition_czk_kwh + min_spread
)
if expensive_import_slot and t not in charge_slots and buy_t >= 0.0:
# Strict: síť jen EV+TČ; baseload z baterie/FVE. Relaxed: síť smí krmit baseload (nouzový režim).
prob += gi[t] <= ev_cap_t + hp[t] + (
float(s.load_baseline_w) if relaxed_expensive_import else 0.0
# Strict: síť jen EV+TČ; baseload z baterie/FVE.
# Relaxed: síť smí baseload jen mimo night_self_consume (v46).
night_self_consume_slot = (
om == "AUTO" and t in night_self_consume_discourage_ts
)
if not relaxed_expensive_import and om == "AUTO":
if relaxed_expensive_import and not night_self_consume_slot:
prob += gi[t] <= ev_cap_t + hp[t] + float(s.load_baseline_w)
else:
prob += gi[t] <= ev_cap_t + hp[t]
if (not relaxed_expensive_import or night_self_consume_slot) and om == "AUTO":
prob += (
bd[t] + pv_ld[t]
>= float(s.load_baseline_w) + hp[t]

View File

@@ -15,6 +15,7 @@ from services.planning_engine import (
_dispatch_result_comparison,
_evening_battery_export_push_indices,
_evening_peak_export_indices,
_slot_evening_push_profitable,
_evening_push_calendar_segments,
_evening_push_discharge_budget_wh,
_in_evening_push_hour_window,
@@ -2547,7 +2548,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
slots = [
PlanningSlot(
interval_start=base,
buy_price=7.3,
buy_price=3.0,
sell_price=4.4,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
@@ -2587,7 +2588,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
def test_evening_battery_export_when_sell_above_acquisition(self) -> None:
base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
cheap = (0.75, 0.25)
peak = (7.0, 4.8)
peak = (3.5, 4.8)
slots: list[PlanningSlot] = []
for i in range(6):
buy, sell = cheap if i < 2 else peak
@@ -2701,14 +2702,14 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
self.assertEqual(r.export_mode, "BATTERY_SELL")
def test_evening_no_spread_export_below_segment_peak_home01(self) -> None:
"""home-01 večer: plný export v top push slotech dle rozpočtu Wh, ne v levnějších mimo push."""
"""Spot večer sell≥buy: push jen top sell sloty; levnější mimo push bez exportu."""
prague = ZoneInfo("Europe/Prague")
sells = [3.834, 3.518, 3.204, 3.204, 3.136, 3.020]
base = datetime(2026, 5, 29, 20, 15, tzinfo=prague)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=5.5,
buy_price=3.0,
sell_price=sells[i],
pv_a_forecast_w=0,
pv_b_forecast_w=0,
@@ -2795,16 +2796,105 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
self.assertLessEqual(len(push), 4)
self.assertEqual(push, [0, 1, 2, 3][: len(push)])
def test_night_self_consume_prefers_battery_over_grid(self) -> None:
"""v43: mezi push sloty baterie krmí dům místo importu za ~5 Kč."""
def test_home01_evening_no_push_when_sell_below_buy(self) -> None:
"""v46: OTE večer sell<buy — žádný push (ne vývoz za 3 Kč při buy 5 Kč)."""
prague = ZoneInfo("Europe/Prague")
sells = [3.9, 3.8, 3.1, 3.0]
base = datetime(2026, 5, 29, 20, 0, tzinfo=prague)
base = datetime(2026, 5, 30, 20, 0, tzinfo=prague)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=5.0,
sell_price=sells[i],
buy_price=5.5,
sell_price=3.3 - 0.05 * i,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1400,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.61,
)
for i in range(4)
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
per_slot = min(18_000, 13_500) * 0.95 * 0.25
push = _evening_battery_export_push_indices(
slots,
charge_acquisition_czk_kwh=0.61,
degrad_czk_kwh=0.15,
current_soc_wh=0.55 * battery.soc_max_wh,
min_soc_wh=battery.min_soc_wh,
soc_max_wh=battery.soc_max_wh,
per_slot_discharge_wh=per_slot,
discharge_slot_buffer=1.5,
spot_push_sell_ge_buy=True,
)
self.assertEqual(push, [])
def test_spot_evening_push_requires_sell_ge_buy(self) -> None:
"""v46: spot nepush když sell < buy (3 Kč vývoz / 5 Kč nákup)."""
base = datetime(2026, 5, 30, 20, 0, tzinfo=ZoneInfo("Europe/Prague"))
bad = PlanningSlot(
interval_start=base.astimezone(timezone.utc),
buy_price=5.5,
sell_price=3.3,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1400,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
)
ok = PlanningSlot(
interval_start=base.astimezone(timezone.utc),
buy_price=2.0,
sell_price=4.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1400,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
)
self.assertFalse(
_slot_evening_push_profitable(
bad,
charge_acquisition_czk_kwh=0.61,
min_spread=0.15,
spot_push_sell_ge_buy=True,
)
)
self.assertTrue(
_slot_evening_push_profitable(
ok,
charge_acquisition_czk_kwh=0.61,
min_spread=0.15,
spot_push_sell_ge_buy=True,
)
)
self.assertTrue(
_slot_evening_push_profitable(
bad,
charge_acquisition_czk_kwh=0.61,
min_spread=0.15,
spot_push_sell_ge_buy=False,
)
)
def test_night_self_consume_prefers_battery_over_grid(self) -> None:
"""v43/v46: mimo push baterie krmí dům, ne import za ~5 Kč."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 29, 20, 0, tzinfo=prague)
slot_specs = [
(3.0, 3.9),
(3.0, 3.8),
(5.0, 3.1),
(5.0, 3.0),
]
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=2000,
@@ -2813,7 +2903,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.7,
)
for i in range(4)
for i, (buy, sell) in enumerate(slot_specs)
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
battery.max_discharge_power_w = 18_000