implementace co nejdrive dosazeni SOC na home-01 a umozneni plneho socu n slotu ped koncem sell < 0
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-26 08:07:00 +02:00
parent 8494ea26de
commit 91a9bef3d7
10 changed files with 566 additions and 25 deletions

View File

@@ -8,6 +8,7 @@ from types import SimpleNamespace
from zoneinfo import ZoneInfo
from services.planning_engine import (
PLANNER_BUILD_TAG,
DispatchResult,
PlanningSlot,
_dynamic_arb_floor_wh_series,
@@ -16,6 +17,8 @@ from services.planning_engine import (
_evening_peak_export_indices,
_evening_push_discharge_budget_wh,
_in_night_battery_export_window,
_neg_sell_day_phases,
_neg_sell_phases_enabled,
_pre_neg_buy_soc_ceiling_wh,
_pre_neg_peak_sell_idx,
_prague_hour,
@@ -1410,7 +1413,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-morning-pv-export-priority-v31")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-sell-soc-phases-v32")
self.assertGreater(
results[0].battery_setpoint_w,
2_500,
@@ -1560,7 +1563,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-morning-pv-export-priority-v31")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-sell-soc-phases-v32")
self.assertEqual(len(results), len(slots))
def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None:
@@ -1624,7 +1627,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
55.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-morning-pv-export-priority-v31")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-sell-soc-phases-v32")
self.assertEqual(len(results), len(slots))
def test_fixed_tariff_neg_sell_no_grid_export(self) -> None:
@@ -2310,7 +2313,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap["planner_build_tag"], "2026-05-28-morning-pv-export-priority-v31")
self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32")
peak_idx = sells.index(4.04)
peak = results[peak_idx]
self.assertIn(peak.export_mode, ("BATTERY_SELL", "PV_SURPLUS"))
@@ -2388,7 +2391,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap["planner_build_tag"], "2026-05-28-morning-pv-export-priority-v31")
self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32")
r_midnight = results[2]
self.assertEqual(r_midnight.export_mode, "BATTERY_SELL")
self.assertGreaterEqual(abs(r_midnight.grid_setpoint_w), 12_500)
@@ -2431,7 +2434,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap["planner_build_tag"], "2026-05-28-morning-pv-export-priority-v31")
self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32")
r = results[0]
self.assertEqual(r.export_mode, "BATTERY_SELL")
self.assertGreaterEqual(abs(r.grid_setpoint_w), 12_500)
@@ -3207,7 +3210,7 @@ class Home01PvStoreValueTests(unittest.TestCase):
results, _, snap = solve_dispatch(
slots, battery, hp, grid, [None, None], vehicles, 0.55 * battery.soc_max_wh, 50.0, operating_mode="AUTO"
)
self.assertEqual(snap["planner_build_tag"], "2026-05-28-morning-pv-export-priority-v31")
self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32")
r0 = results[0]
self.assertLess(
r0.pv_a_curtailed_w,
@@ -3670,5 +3673,177 @@ class PlannerArbitrageImprovementsTests(unittest.TestCase):
self.assertEqual(r.export_mode, "BATTERY_SELL")
class NegSellSocPhaseTests(unittest.TestCase):
"""Fázované SoC v okně sell<0 (v32): prep 80 %, tail rampa, vent B s prahem."""
@staticmethod
def _phase_battery(**kw: float) -> SimpleNamespace:
bat = _battery(uc_wh=64_000.0, max_pct=95.0)
bat.planner_neg_sell_prep_soc_percent = kw.get("prep_pct", 80.0)
bat.planner_neg_sell_full_soc_tail_slots = int(kw.get("tail_slots", 4))
vent = kw.get("vent_min", -1.0)
bat.planner_neg_sell_vent_min_sell_czk_kwh = None if vent is None else float(vent)
return bat
@staticmethod
def _neg_sell_slots(
n: int,
*,
sell: float = -0.2,
pv_a: int = 8000,
pv_b: int = 4000,
) -> list[PlanningSlot]:
base = datetime(2026, 6, 10, 8, 0, tzinfo=ZoneInfo("Europe/Prague")).astimezone(timezone.utc)
out: list[PlanningSlot] = []
for i in range(n):
out.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=pv_b,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
)
)
return out
def test_phases_enabled_helper(self) -> None:
bat = self._phase_battery()
self.assertTrue(_neg_sell_phases_enabled(bat))
bat_legacy = self._phase_battery(prep_pct=100.0)
self.assertFalse(_neg_sell_phases_enabled(bat_legacy))
def test_day_phases_tail_last_four(self) -> None:
slots = self._neg_sell_slots(10)
bat = self._phase_battery(tail_slots=4)
phases, targets, _w = _neg_sell_day_phases(slots, bat)
self.assertEqual(phases[5], "prep")
self.assertEqual(phases[9], "tail")
self.assertEqual(phases.count("tail"), 4)
self.assertAlmostEqual(float(targets[9] or 0), bat.soc_max_wh, delta=50.0)
def test_prep_reaches_soc_by_mid_window(self) -> None:
slots = self._neg_sell_slots(12)
bat = self._phase_battery()
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),
]
results, _, snap = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.35 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG)
self.assertTrue(snap.get("inputs", {}).get("neg_sell_phases_enabled"))
# Nabíjení z FVE v sell<0: SoC roste, tail má vyšší cíl než začátek okna.
self.assertGreater(results[-1].battery_soc_target, results[0].battery_soc_target)
self.assertGreaterEqual(results[-1].battery_soc_target, 75.0)
masks = snap.get("masks") or []
phases = {m.get("neg_sell_phase") for m in masks if isinstance(m, dict)}
self.assertIn("prep", phases)
self.assertIn("tail", phases)
def test_hold_curtails_pv_a_when_soc_high(self) -> None:
slots = self._neg_sell_slots(8)
bat = self._phase_battery()
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),
]
results, _, _ = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.85 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
curtailed_any = any(r.pv_a_curtailed_w > 500 for r in results)
self.assertTrue(
curtailed_any,
"při vysokém SoC v prep fázi očekáván curtail A (pv_a_curtailed_w)",
)
def test_tail_allows_b_vent_when_sell_above_threshold(self) -> None:
slots = self._neg_sell_slots(8, sell=-0.5)
bat = self._phase_battery(vent_min=-1.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),
]
results, _, _ = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.82 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
tail_export = max(0, -results[-1].grid_setpoint_w)
self.assertGreater(tail_export, 200)
def test_tail_blocks_voluntary_vent_when_sell_too_negative(self) -> None:
slots = self._neg_sell_slots(8, sell=-12.0, pv_b=6000)
bat = self._phase_battery(vent_min=-1.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),
]
results, _, _ = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.82 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertLessEqual(max(0, -results[-1].grid_setpoint_w), 500)
if __name__ == "__main__":
unittest.main()