uprava PV omeznovani
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-25 11:08:01 +02:00
parent f1a4dbd7e7
commit e06f76b9ff
8 changed files with 439 additions and 91 deletions

View File

@@ -13,6 +13,7 @@ from services.planning_engine import (
_dispatch_result_comparison,
_evening_battery_export_push_indices,
_evening_push_discharge_budget_wh,
_pre_neg_buy_soc_ceiling_wh,
_pre_neg_peak_sell_idx,
_prague_hour,
_prewindow_deferral_slots,
@@ -73,6 +74,43 @@ def _battery(
)
class PreNegBuySocPhaseTests(unittest.TestCase):
"""Dvoufázová SoC: plná při posledním sell≥0 před buy<0, strop před buy<0."""
def test_soc_ceiling_accounts_for_neg_buy_window(self) -> None:
base = datetime(2026, 5, 25, 8, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(16):
buy = -0.1 if 6 <= i < 10 else 1.0
sell = -0.3 if i < 6 else (2.5 if i < 10 else -0.2)
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=6000 if i >= 6 else 4000,
pv_b_forecast_w=3000 if i >= 6 else 2000,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
)
)
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
ceiling = _pre_neg_buy_soc_ceiling_wh(
slots,
first_neg_buy_idx=6,
min_soc_wh=bat.min_soc_wh,
soc_max_wh=bat.soc_max_wh,
max_charge_w=18_000,
charge_eff=0.95,
)
self.assertIsNotNone(ceiling)
assert ceiling is not None
self.assertLess(ceiling, bat.soc_max_wh * 0.85)
class EveningPushBudgetTests(unittest.TestCase):
"""Večerní tvrdý push: počet slotů z rozpočtu Wh (ne pevné top-3)."""
@@ -369,11 +407,8 @@ class PlanningDispatchMilpTests(unittest.TestCase):
def test_neg_sell_with_future_neg_buy_prefers_curtail_pv_a_over_export(self) -> None:
"""
Když:
- aktuální slot má sell < 0 (export je náklad),
- v horizontu existuje budoucí buy < 0,
- a zároveň existuje PV B (necurtailable) někde v horizontu,
solver preferuje curtail PV A (ca) místo placeného exportu ge.
v25: sell<0 před buy<0 — PV A smí do baterie (bc_pv), ne export za záporný sell.
Curtail PV A (ca) až v okně buy<0 (slot 1).
"""
slots = [
_slot(load=0, buy=3.0, sell=-0.1, pv_a=5000, pv_b=0),
@@ -412,9 +447,9 @@ class PlanningDispatchMilpTests(unittest.TestCase):
operating_mode="AUTO",
)
self.assertEqual(len(results), 2)
# Slot 0: záporný sell — žádný export FVE do sítě (LP guard sell < acquisition).
self.assertNotEqual(results[0].export_mode, "PV_SURPLUS")
self.assertNotEqual(results[0].export_mode, "PV_SURPLUS")
self.assertGreater(results[0].battery_setpoint_w, 500)
self.assertEqual(results[0].pv_a_curtailed_w, 0)
self.assertGreater(results[1].grid_setpoint_w, 1000)
def test_pv_surplus_export_uses_hard_export_cap(self) -> None:
slots = [
@@ -787,8 +822,8 @@ class PlanningDispatchMilpTests(unittest.TestCase):
def test_anchor_hits_floor_before_first_negative_sell(self) -> None:
"""
Pokud se v horizontu objeví první sell<0 a současně existuje planner floor (relaxace),
solver má skončit už v předchozím slotu u planner floor (cca 5 %), ne na ~15 %.
v25: před buy<0 — SoC u posledního sell0 blízko max, před prvním buy<0 pod stropem
(_pre_neg_buy_soc_ceiling_wh), ne kotva na planner floor před sell<0.
"""
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
# Slot 0-1: sell >= 0; slot 2: první sell < 0; slot 3: extrémně záporný buy (motivace k bufferu).
@@ -866,18 +901,22 @@ class PlanningDispatchMilpTests(unittest.TestCase):
tuv_delta_stats=None,
operating_mode="AUTO",
)
peak_t = _pre_neg_peak_sell_idx(slots, 2)
self.assertIsNotNone(peak_t)
self.assertLess(
results[peak_t].grid_setpoint_w,
-500,
msg="ranní peak: export baterie/FVE před sell<0",
last_pos = 1
pre_buy = 2
self.assertGreaterEqual(
results[last_pos].battery_soc_target or 0,
60.0,
msg="poslední sell≥0 před buy<0: směr k plné baterii (bez exportu)",
)
self.assertLessEqual(
results[pre_buy].battery_soc_target or 100.0,
75.0,
msg="slot před buy<0: rezerva pro import v buy<0 okně",
)
def test_anchor_uses_planner_floor_even_without_extreme_buy(self) -> None:
"""
Regrese: pokud v horizontu není buy <= threshold (soc_min_series by se nerelaxovala),
kotva před sell<0 má stejně mířit na planner floor (5 %), ne na base min SoC.
v25: bez buy<0 v horizontu — žádný strop před buy<0; poslední sell≥0 může držet vysoké SoC.
"""
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
slots = [
@@ -942,11 +981,8 @@ class PlanningDispatchMilpTests(unittest.TestCase):
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertLess(
results[0].grid_setpoint_w,
-1_000,
msg="morning peak slot should export before first negative sell",
)
self.assertEqual(results[0].grid_setpoint_w, 0)
self.assertEqual(results[0].battery_setpoint_w, 0)
def test_grid_import_soft_cap_penalizes_breaker_overdraw(self) -> None:
"""
@@ -1302,11 +1338,11 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-evening-push-dynamic-budget-v24")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-pre-neg-buy-soc-phases-v25")
self.assertGreater(
results[0].battery_setpoint_w,
5_500,
f"od ~51 % SoC má první neg slot nabíjet max, got {[r.battery_setpoint_w for r in results]}",
2_500,
f"první sell<0 slot nabíjet z PV, got {[r.battery_setpoint_w for r in results]}",
)
self.assertGreaterEqual(
max(r.battery_soc_target for r in results),
@@ -1452,7 +1488,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-evening-push-dynamic-budget-v24")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-pre-neg-buy-soc-phases-v25")
self.assertEqual(len(results), len(slots))
def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None:
@@ -1516,7 +1552,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
55.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-evening-push-dynamic-budget-v24")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-pre-neg-buy-soc-phases-v25")
self.assertEqual(len(results), len(slots))
def test_fixed_tariff_neg_sell_no_grid_export(self) -> None:
@@ -3065,13 +3101,12 @@ class SitePowerCapTests(unittest.TestCase):
13_500,
msg="export ze site ≤ max_export_power_w",
)
self.assertLess(r.grid_setpoint_w, -500, msg="očekáván významný export")
self.assertLess(r.battery_setpoint_w, -500, msg="očekáváno vybíjení baterie")
self.assertLessEqual(
r.export_limit_w,
13_500,
msg="export_limit_w odpovídá site limitu",
)
self.assertLessEqual(abs(r.battery_setpoint_w), 18_000)
class PlannerArbitrageImprovementsTests(unittest.TestCase):