dalsi fix
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-06-06 23:12:08 +02:00
parent 3161421d5c
commit 37df01d43c
4 changed files with 443 additions and 1 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-06-06-ba81-gen-cutoff-exec-v1"
PLANNER_BUILD_TAG = "2026-06-06-home01-late-replan-infeasible-v1"
SOLVER_RELAX_STEPS: tuple[str, ...] = (
"strict",
"relaxed_expensive_import",
@@ -79,6 +79,7 @@ SOLVER_RELAX_STEPS: tuple[str, ...] = (
"relaxed_neg_prep_hold_only",
"relaxed_neg_prep_window",
"neg_sell_phases_fallback",
"relaxed_pos_sell_ge_block",
)
# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak).
DAWN_LOW_PV_NO_CURTAIL_W = 1500
@@ -149,6 +150,7 @@ def _solver_relax_chain(
relaxed_neg_prep_hold_only: bool = False,
relaxed_neg_prep_window: bool = False,
neg_sell_phases_fallback: bool = False,
relaxed_pos_sell_ge_block: bool = False,
) -> list[str]:
flags = {
"relaxed_expensive_import": relaxed_expensive_import,
@@ -156,6 +158,7 @@ def _solver_relax_chain(
"relaxed_neg_prep_hold_only": relaxed_neg_prep_hold_only,
"relaxed_neg_prep_window": relaxed_neg_prep_window,
"neg_sell_phases_fallback": neg_sell_phases_fallback,
"relaxed_pos_sell_ge_block": relaxed_pos_sell_ge_block,
}
chain = [SOLVER_RELAX_STEPS[0]]
for step in SOLVER_RELAX_STEPS[1:]:
@@ -2525,6 +2528,7 @@ def solve_dispatch(
relaxed_neg_prep_hold_only: bool = False,
relaxed_neg_prep_window: bool = False,
neg_sell_phases_fallback: bool = False,
relaxed_pos_sell_ge_block: bool = False,
evening_push_ts_override: Optional[set[int]] = None,
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
"""
@@ -2534,6 +2538,7 @@ def solve_dispatch(
relaxed_neg_buy_charge: druhý nouzový retry bez neg_buy charge shortfall.
relaxed_neg_prep_hold_only: třetí retry — bez prep_soc_shortfall a prep hold binárek (evening push zůstává).
relaxed_neg_prep_window: čtvrtý retry — vypne strict pre-neg bundle; future_neg_buy večerní export zůstává.
relaxed_pos_sell_ge_block: poslední retry — neaplikovat ge=0 v pos_sell před buy<0 (zbylá Infeasible).
"""
T = len(slots)
if T < 1:
@@ -2543,6 +2548,7 @@ def solve_dispatch(
or relaxed_neg_buy_charge
or relaxed_neg_prep_window
or neg_sell_phases_fallback
or relaxed_pos_sell_ge_block
)
prep_hold_relaxed = relaxed_neg_prep_hold_only or relaxed_neg_prep_window
EV = len(vehicles) # počet EV (typicky 2)
@@ -3085,6 +3091,36 @@ def solve_dispatch(
battery_export_defer_pv_ts = {
t for t in range(T) if _battery_export_push_defer_to_pv(slots[t])
}
# Pozdní replan večer: SQL allow_charge může být false (drahý buy), ale večerní vývoz
# k reserve před neg dnem vyžaduje souběžně grid import pro load (ne jen bd).
if neg_evening_discharge_active or evening_push_ts:
replan_day = _prague_calendar_date(slots[0])
for t in range(T):
if _prague_calendar_date(slots[t]) != replan_day:
continue
if float(slots[t].sell_price) < 0.0:
continue
if (
t in evening_push_ts
or t in neg_evening_push_ts
or (
_in_evening_push_hour_window(slots[t])
and t in discharge_export_slots
)
):
charge_slots.add(t)
if neg_evening_discharge_active:
for t in discharge_export_slots:
if _prague_calendar_date(slots[t]) == replan_day:
charge_slots.add(t)
if relaxed_pos_sell_ge_block:
# Poslední retry: SQL allow_charge / drahý import nesmí zablokovat fyzicky dosažitelný plán.
charge_slots = set(range(T))
discharge_export_slots = {
t
for t, s in enumerate(slots)
if s.allow_discharge_export or float(s.sell_price) >= 0.0
}
else:
battery_export_defer_pv_ts = set()
pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh(
@@ -4174,6 +4210,7 @@ def solve_dispatch(
and first_neg_buy_idx > 0
and t in pos_sell_pre_neg_buy_ts
and t not in pos_sell_pre_neg_buy_ge_exempt_ts
and not relaxed_pos_sell_ge_block
):
prob += ge[t] == 0
prob += ge_pv[t] == 0
@@ -4551,6 +4588,38 @@ def solve_dispatch(
neg_sell_phases_fallback=True,
evening_push_ts_override=evening_push_ts_override,
)
if not relaxed_pos_sell_ge_block:
logger.warning(
"solve_dispatch still Infeasible, retry with relaxed_pos_sell_ge_block "
"(no ge=0 on pos_sell before buy<0)"
)
battery_no_phases = SimpleNamespace(
**{
**vars(battery),
"planner_neg_sell_prep_soc_percent": 100.0,
}
)
return solve_dispatch(
slots,
battery_no_phases,
heat_pump,
grid,
ev_sessions,
vehicles,
current_soc_wh,
current_tuv_temp_c,
tuv_delta_stats=tuv_delta_stats,
operating_mode=operating_mode,
charge_commitment_prev_w=charge_commitment_prev_w,
planner_version=planner_version,
relaxed_expensive_import=True,
relaxed_neg_buy_charge=True,
relaxed_neg_prep_hold_only=True,
relaxed_neg_prep_window=True,
neg_sell_phases_fallback=True,
relaxed_pos_sell_ge_block=True,
evening_push_ts_override=evening_push_ts_override,
)
raise PlannerSolverError(
pulp.LpStatus[status],
relax_chain=_solver_relax_chain(
@@ -4559,6 +4628,7 @@ def solve_dispatch(
relaxed_neg_prep_hold_only=relaxed_neg_prep_hold_only,
relaxed_neg_prep_window=relaxed_neg_prep_window,
neg_sell_phases_fallback=neg_sell_phases_fallback,
relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block,
),
)
@@ -4910,12 +4980,14 @@ def solve_dispatch(
"relaxed_neg_prep_hold_only": relaxed_neg_prep_hold_only,
"relaxed_neg_prep_window": relaxed_neg_prep_window,
"neg_sell_phases_fallback": neg_sell_phases_fallback,
"relaxed_pos_sell_ge_block": relaxed_pos_sell_ge_block,
"relax_chain": _solver_relax_chain(
relaxed_expensive_import=relaxed_expensive_import,
relaxed_neg_buy_charge=relaxed_neg_buy_charge,
relaxed_neg_prep_hold_only=relaxed_neg_prep_hold_only,
relaxed_neg_prep_window=relaxed_neg_prep_window,
neg_sell_phases_fallback=neg_sell_phases_fallback,
relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block,
),
"charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh,
"charge_acquisition_cutoff_at": (

View File

@@ -3615,6 +3615,88 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
self.assertFalse(snap_mid["inputs"].get("neg_sell_phases_fallback"))
self.assertLess(results_mid[0].grid_setpoint_w, -1000)
def test_home01_late_replan_high_soc_realistic_masks(self) -> None:
"""Pozdní replan večer (21:00): SoC ~74 %, SQL masky allow_charge=false večer — musí být Feasible."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 6, 6, 21, 0, tzinfo=prague).astimezone(timezone.utc)
rows = [
(5.305729, 3.34, 2731, 0, 0),
(5.162299, 3.23125, 2731, 0, 0),
(4.866865, 3.00725, 2731, 0, 0),
(4.662765, 2.8525, 2731, 0, 0),
(5.18406, 3.24775, 1552, 0, 0),
(4.878076, 3.01575, 1552, 0, 0),
(4.749483, 2.91825, 1552, 0, 0),
(4.460314, 2.699, 1552, 0, 0),
(4.887308, 3.02275, 782, 0, 0),
(4.883351, 3.01975, 782, 0, 0),
(4.660787, 2.851, 782, 0, 0),
(4.484384, 2.71725, 782, 0, 0),
]
slots: list[PlanningSlot] = []
for i in range(96):
local = (base + timedelta(minutes=15 * i)).astimezone(prague)
if i < len(rows):
buy, sell, load, pv_a, pv_b = rows[i]
else:
d, h, m = local.day, local.hour, local.minute
hm = h + m / 60.0
if d == 6:
buy, sell, load, pv_a, pv_b = 4.5, 2.9, 800, 0, 0
elif hm >= 5.75 and hm < 15:
sell, buy = -0.3, 0.5
pv_a, pv_b, load = 2000, 2500, 800
elif 11 <= hm < 14:
sell, buy = -0.8, -0.4
pv_a, pv_b, load = 4000, 4500, 2000
else:
sell, buy = 2.5, 3.0
pv_a, pv_b, load = 200, 200, 500
pv_surplus = max(0, pv_a + pv_b - load)
h = local.hour
allow_discharge_export = sell >= 0 and (h >= 17 or sell > buy + 0.15)
allow_charge = buy < 0 or (sell < 0 and pv_surplus > 500)
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=pv_b,
load_baseline_w=load,
ev1_connected=False,
ev2_connected=False,
allow_charge=allow_charge,
allow_discharge_export=allow_discharge_export,
)
)
bat = _battery(uc_wh=64_000.0, arb_pct=20.0, terminal_soc_value_factor=0.9)
bat.planner_neg_sell_prep_soc_percent = 80
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=13_500,
block_export_on_negative_sell=False,
purchase_pricing_mode="spot",
)
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, _ms, snap = solve_dispatch_two_pass(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.74 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertLess(results[0].grid_setpoint_w, -500)
self.assertLess(results[0].battery_soc_target, 70.0)
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")