urpava KV1 vyliti v maxu v noci
Some checks failed
CI and deploy / migration-check (push) Failing after 19s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-30 23:47:44 +02:00
parent a03b45d4a9
commit 578cf315e2
4 changed files with 234 additions and 5 deletions

View File

@@ -30,6 +30,7 @@ from services.planning_engine import (
_pre_neg_pv_export_bundle,
_pre_neg_pv_export_forecast_cushion_ok_for_day,
_prague_calendar_date,
_prague_hour,
_pre_neg_buy_soc_ceiling_wh,
_pre_neg_peak_sell_idx,
_pre_neg_pv_export_forecast_cushion_ok,
@@ -2836,6 +2837,73 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
self.assertLessEqual(len(push), 4)
self.assertEqual(push, [0, 1, 2, 3][: len(push)])
def test_kv1_evening_push_profitable_vs_morning_zone_peak(self) -> None:
"""v52: KV1 večer ≥ ranní max (511) degrad; pod prahem ne."""
prague = ZoneInfo("Europe/Prague")
neg = datetime(2026, 5, 31, 8, 15, tzinfo=prague).astimezone(timezone.utc)
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 31, 6, 0, tzinfo=prague).astimezone(timezone.utc),
buy_price=6.35,
sell_price=2.55,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
),
PlanningSlot(
interval_start=neg,
buy_price=6.35,
sell_price=-0.3,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
),
]
eve = PlanningSlot(
interval_start=datetime(2026, 5, 30, 22, 0, tzinfo=prague).astimezone(timezone.utc),
buy_price=6.35,
sell_price=3.30,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
)
self.assertTrue(
_slot_evening_push_profitable(
eve,
charge_acquisition_czk_kwh=6.35,
min_spread=0.3,
slots=slots,
first_neg_sell_idx=1,
kv1_evening_push=True,
)
)
eve_low = PlanningSlot(
interval_start=eve.interval_start,
buy_price=6.35,
sell_price=2.20,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
)
self.assertFalse(
_slot_evening_push_profitable(
eve_low,
charge_acquisition_czk_kwh=6.35,
min_spread=0.3,
slots=slots,
first_neg_sell_idx=1,
kv1_evening_push=True,
)
)
def test_evening_push_ok_when_sell_below_buy_vs_acq(self) -> None:
"""v47: večer sell<buy ale >acq — push pro vyprázdnění před neg dnem."""
slot = PlanningSlot(
@@ -3817,6 +3885,106 @@ class PreNegativeSellExportTests(unittest.TestCase):
self.assertGreater(abs(res[1].grid_setpoint_w), 500)
self.assertLess(abs(res[1].battery_setpoint_w), 500)
def test_kv1_evening_push_when_sell_above_morning_peak_not_at_dawn(self) -> None:
"""v52: večer sell ≥ ranní max (511) před sell<0 — push, ne až úsvit za horší cenu."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 30, 23, 0, tzinfo=prague)
specs: list[tuple[int, float, float]] = []
for i in range(40):
local = base + timedelta(minutes=15 * i)
h = local.hour
if h >= 23 or h <= 2:
sell = 3.25 if i % 2 == 0 else 3.10
pv_a = 0
elif h == 4:
sell = 2.80
pv_a = 900
elif 5 <= h <= 7:
sell = 2.55
pv_a = 1200
elif h == 8 and local.minute == 15:
sell = -0.20
pv_a = 4000
elif h >= 8:
sell = -0.30
pv_a = 4500
else:
sell = 3.00
pv_a = 0
specs.append((pv_a, sell))
slots: list[PlanningSlot] = []
for i, (pv_a, sell) in enumerate(specs):
local = base + timedelta(minutes=15 * i)
slots.append(
PlanningSlot(
interval_start=local.astimezone(timezone.utc),
buy_price=6.3525,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=sell < 0,
allow_discharge_export=sell >= 0,
charge_acquisition_buy_czk_kwh=6.3525,
)
)
battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0)
battery.reserve_soc_percent = 30.0
battery.max_discharge_power_w = 6250
battery.discharge_slot_buffer = 1.5
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=17_000,
max_export_power_w=8000,
block_export_on_negative_sell=True,
purchase_pricing_mode="fixed",
)
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),
]
res, _, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
0.62 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertTrue(snap["inputs"].get("kv1_evening_push_morning_peak_rule"))
push_iso = set(snap["inputs"].get("evening_push_ts") or [])
self.assertTrue(
any(
slots[i].interval_start.isoformat() in push_iso
for i in range(4)
if _in_evening_push_hour_window(slots[i])
),
"večerní push musí zahrnout slot ≥17h",
)
eve_idx = next(
i
for i in range(len(slots))
if _in_evening_push_hour_window(slots[i])
and float(slots[i].sell_price) >= 3.0
)
self.assertLess(
res[eve_idx].grid_setpoint_w,
-2000,
"večer ~3,3 Kč: vývoz do sítě, ne jen úsvit",
)
dawn_idx = next(i for i, s in enumerate(slots) if _prague_hour(s) == 4)
if slots[dawn_idx].interval_start.isoformat() not in push_iso:
self.assertLess(
abs(res[dawn_idx].battery_setpoint_w),
4000,
"úsvit není jediný velký bat export pokud večer push proběhl",
)
class Home01PvStoreValueTests(unittest.TestCase):
"""FVE: spot sell<0 → nabít/vent B; sell>=0 → LP volí export vs bc (ne tvrdý curtail)."""