uprava PV omeznovani
This commit is contained in:
@@ -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 sell≥0 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 má 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):
|
||||
|
||||
Reference in New Issue
Block a user