Branch 1: failed run journal + bisect Infeasible + granulární relaxace (bez vypnutí evening push)
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-06-06 22:23:59 +02:00
parent 1429d402e5
commit 2a963c9793
7 changed files with 593 additions and 72 deletions

View File

@@ -71,7 +71,15 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0 PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0 PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
PLANNER_BUILD_TAG = "2026-06-01-kv1-fixed-night-self-consume-v62" PLANNER_BUILD_TAG = "2026-06-06-infeasible-journal-granular-prep-relax-v63"
SOLVER_RELAX_STEPS: tuple[str, ...] = (
"strict",
"relaxed_expensive_import",
"relaxed_neg_buy_charge",
"relaxed_neg_prep_hold_only",
"relaxed_neg_prep_window",
"neg_sell_phases_fallback",
)
# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak). # Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak).
DAWN_LOW_PV_NO_CURTAIL_W = 1500 DAWN_LOW_PV_NO_CURTAIL_W = 1500
# BA81/KV1: PV→bat jen v těsné blízkosti nejnižšího sell v horizontu (≈ poledne), ne při ~3 Kč ráno. # BA81/KV1: PV→bat jen v těsné blízkosti nejnižšího sell v horizontu (≈ poledne), ne při ~3 Kč ráno.
@@ -117,6 +125,42 @@ ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0..
_PRAGUE_TZ = ZoneInfo("Europe/Prague") _PRAGUE_TZ = ZoneInfo("Europe/Prague")
class PlannerSolverError(RuntimeError):
"""Solver selhal po vyčerpání retry řetězce (typicky Infeasible)."""
def __init__(
self,
solver_status: str,
*,
relax_chain: list[str] | None = None,
) -> None:
self.solver_status = solver_status
self.relax_chain = list(relax_chain or [])
super().__init__(f"Solver: {solver_status}")
def _solver_relax_chain(
*,
relaxed_expensive_import: bool = False,
relaxed_neg_buy_charge: bool = False,
relaxed_neg_prep_hold_only: bool = False,
relaxed_neg_prep_window: bool = False,
neg_sell_phases_fallback: bool = False,
) -> list[str]:
flags = {
"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,
}
chain = [SOLVER_RELAX_STEPS[0]]
for step in SOLVER_RELAX_STEPS[1:]:
if flags.get(step, False):
chain.append(step)
return chain
def _timestamptz_from_db(val: object) -> Optional[datetime]: def _timestamptz_from_db(val: object) -> Optional[datetime]:
if val is None: if val is None:
return None return None
@@ -2171,6 +2215,7 @@ def _solve_dispatch_relax_carryover(snap: dict[str, Any]) -> dict[str, Any]:
for key in ( for key in (
"relaxed_expensive_import", "relaxed_expensive_import",
"relaxed_neg_buy_charge", "relaxed_neg_buy_charge",
"relaxed_neg_prep_hold_only",
"relaxed_neg_prep_window", "relaxed_neg_prep_window",
"neg_sell_phases_fallback", "neg_sell_phases_fallback",
): ):
@@ -2273,6 +2318,7 @@ def _evening_push_override_for_solve(
*, *,
relaxed_expensive_import: bool, relaxed_expensive_import: bool,
relaxed_neg_buy_charge: bool, relaxed_neg_buy_charge: bool,
relaxed_neg_prep_hold_only: bool,
relaxed_neg_prep_window: bool, relaxed_neg_prep_window: bool,
neg_sell_phases_fallback: bool, neg_sell_phases_fallback: bool,
) -> Optional[set[int]]: ) -> Optional[set[int]]:
@@ -2329,6 +2375,7 @@ def solve_dispatch(
planner_version: str | None = None, planner_version: str | None = None,
relaxed_expensive_import: bool = False, relaxed_expensive_import: bool = False,
relaxed_neg_buy_charge: bool = False, relaxed_neg_buy_charge: bool = False,
relaxed_neg_prep_hold_only: bool = False,
relaxed_neg_prep_window: bool = False, relaxed_neg_prep_window: bool = False,
neg_sell_phases_fallback: bool = False, neg_sell_phases_fallback: bool = False,
evening_push_ts_override: Optional[set[int]] = None, evening_push_ts_override: Optional[set[int]] = None,
@@ -2338,7 +2385,8 @@ def solve_dispatch(
Vrátí (výsledky, solver_duration_ms, solver_debug_snapshot). Vrátí (výsledky, solver_duration_ms, solver_debug_snapshot).
relaxed_expensive_import: nouzový režim po Infeasible — síť smí krmit baseload v drahých slotech. relaxed_expensive_import: nouzový režim po Infeasible — síť smí krmit baseload v drahých slotech.
relaxed_neg_buy_charge: druhý nouzový retry bez neg_buy charge shortfall. relaxed_neg_buy_charge: druhý nouzový retry bez neg_buy charge shortfall.
relaxed_neg_prep_window: třetí retry — bez tvrdého večerního push/kotvy a prep hold binárek (sell<0 okno). 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 — navíc vypne neg-evening bundle a tvrdý evening push.
""" """
T = len(slots) T = len(slots)
if T < 1: if T < 1:
@@ -2349,6 +2397,7 @@ def solve_dispatch(
or relaxed_neg_prep_window or relaxed_neg_prep_window
or neg_sell_phases_fallback or neg_sell_phases_fallback
) )
prep_hold_relaxed = relaxed_neg_prep_hold_only or relaxed_neg_prep_window
EV = len(vehicles) # počet EV (typicky 2) EV = len(vehicles) # počet EV (typicky 2)
planner_version_resolved = _planner_engine_version(planner_version) planner_version_resolved = _planner_engine_version(planner_version)
planner_v2 = planner_version_resolved == "v2" planner_v2 = planner_version_resolved == "v2"
@@ -2779,6 +2828,7 @@ def solve_dispatch(
evening_push_ts_override, evening_push_ts_override,
relaxed_expensive_import=relaxed_expensive_import, relaxed_expensive_import=relaxed_expensive_import,
relaxed_neg_buy_charge=relaxed_neg_buy_charge, 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, relaxed_neg_prep_window=relaxed_neg_prep_window,
neg_sell_phases_fallback=neg_sell_phases_fallback, neg_sell_phases_fallback=neg_sell_phases_fallback,
) )
@@ -3118,7 +3168,7 @@ def solve_dispatch(
cap_w = float(min(pv_surplus_w, battery.max_charge_power_w)) cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
sf_pv = pulp.LpVariable(f"post_neg_pv_shortfall_{t}", 0, cap_w) sf_pv = pulp.LpVariable(f"post_neg_pv_shortfall_{t}", 0, cap_w)
pv_charge_shortfall.append((t, sf_pv, cap_w)) pv_charge_shortfall.append((t, sf_pv, cap_w))
if neg_sell_phases_en and not relaxed_neg_prep_window: if neg_sell_phases_en and not prep_hold_relaxed:
for t_ns in range(T): for t_ns in range(T):
phase_ns = neg_sell_phase_by_t[t_ns] phase_ns = neg_sell_phase_by_t[t_ns]
tgt_ns = neg_sell_soc_target_by_t[t_ns] tgt_ns = neg_sell_soc_target_by_t[t_ns]
@@ -3146,7 +3196,7 @@ def solve_dispatch(
neg_sell_soc_underfill.append( neg_sell_soc_underfill.append(
(t_tail_last, us_tail, float(battery.soc_max_wh)) (t_tail_last, us_tail, float(battery.soc_max_wh))
) )
if not relaxed_neg_prep_window: if not prep_hold_relaxed:
for t_ph in range(T): for t_ph in range(T):
if neg_sell_phase_by_t[t_ph] != "prep": if neg_sell_phase_by_t[t_ph] != "prep":
continue continue
@@ -4256,10 +4306,10 @@ def solve_dispatch(
relaxed_neg_buy_charge=True, relaxed_neg_buy_charge=True,
evening_push_ts_override=evening_push_ts_override, evening_push_ts_override=evening_push_ts_override,
) )
if not relaxed_neg_prep_window: if not relaxed_neg_prep_hold_only:
logger.warning( logger.warning(
"solve_dispatch still Infeasible, retry with relaxed_neg_prep_window " "solve_dispatch still Infeasible, retry with relaxed_neg_prep_hold_only "
"(skip evening push/anchors and prep hold hard constraints)" "(skip prep_soc_shortfall and prep hold binárek; evening push unchanged)"
) )
return solve_dispatch( return solve_dispatch(
slots, slots,
@@ -4276,6 +4326,30 @@ def solve_dispatch(
planner_version=planner_version, planner_version=planner_version,
relaxed_expensive_import=True, relaxed_expensive_import=True,
relaxed_neg_buy_charge=True, relaxed_neg_buy_charge=True,
relaxed_neg_prep_hold_only=True,
evening_push_ts_override=evening_push_ts_override,
)
if not relaxed_neg_prep_window:
logger.warning(
"solve_dispatch still Infeasible, retry with relaxed_neg_prep_window "
"(skip neg-evening bundle and tvrdý evening push)"
)
return solve_dispatch(
slots,
battery,
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, relaxed_neg_prep_window=True,
neg_sell_phases_fallback=neg_sell_phases_fallback, neg_sell_phases_fallback=neg_sell_phases_fallback,
evening_push_ts_override=evening_push_ts_override, evening_push_ts_override=evening_push_ts_override,
@@ -4306,11 +4380,21 @@ def solve_dispatch(
planner_version=planner_version, planner_version=planner_version,
relaxed_expensive_import=True, relaxed_expensive_import=True,
relaxed_neg_buy_charge=True, relaxed_neg_buy_charge=True,
relaxed_neg_prep_hold_only=True,
relaxed_neg_prep_window=True, relaxed_neg_prep_window=True,
neg_sell_phases_fallback=True, neg_sell_phases_fallback=True,
evening_push_ts_override=evening_push_ts_override, evening_push_ts_override=evening_push_ts_override,
) )
raise RuntimeError(f"Solver: {pulp.LpStatus[status]}") raise PlannerSolverError(
pulp.LpStatus[status],
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,
),
)
# --- Post-processing --- # --- Post-processing ---
results = [] results = []
@@ -4638,8 +4722,16 @@ def solve_dispatch(
"load_first_enabled": om == "AUTO", "load_first_enabled": om == "AUTO",
"relaxed_expensive_import": relaxed_expensive_import, "relaxed_expensive_import": relaxed_expensive_import,
"relaxed_neg_buy_charge": relaxed_neg_buy_charge, "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, "relaxed_neg_prep_window": relaxed_neg_prep_window,
"neg_sell_phases_fallback": neg_sell_phases_fallback, "neg_sell_phases_fallback": neg_sell_phases_fallback,
"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,
),
"charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh, "charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh,
"charge_acquisition_cutoff_at": ( "charge_acquisition_cutoff_at": (
slots[0].charge_acquisition_cutoff_at.isoformat() slots[0].charge_acquisition_cutoff_at.isoformat()
@@ -4740,20 +4832,36 @@ async def run_daily_plan(
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh) slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
om = operating_mode or "AUTO" om = operating_mode or "AUTO"
if om == "AUTO": try:
results, duration_ms, solver_snapshot = solve_dispatch_two_pass( if om == "AUTO":
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, results, duration_ms, solver_snapshot = solve_dispatch_two_pass(
tuv_delta_stats=tuv_stats, slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
operating_mode=om, tuv_delta_stats=tuv_stats,
planner_version=planner_version_resolved, operating_mode=om,
) planner_version=planner_version_resolved,
else: )
results, duration_ms, solver_snapshot = solve_dispatch( else:
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, results, duration_ms, solver_snapshot = solve_dispatch(
tuv_delta_stats=tuv_stats, slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
operating_mode=om, tuv_delta_stats=tuv_stats,
planner_version=planner_version_resolved, operating_mode=om,
planner_version=planner_version_resolved,
)
except PlannerSolverError as exc:
await _save_failed_planning_run(
site_id,
horizon_from,
horizon_to,
run_type="daily",
triggered_by=triggered_by,
replan_from=None,
soc_wh=soc_wh,
correction=1.0,
db=db,
error=exc,
slot_count=len(slots),
) )
raise
comparison_ctx = _maybe_add_planner_comparison( comparison_ctx = _maybe_add_planner_comparison(
slots=slots, slots=slots,
battery=battery, battery=battery,
@@ -4954,23 +5062,39 @@ async def run_rolling_replan(
) )
om = operating_mode or "AUTO" om = operating_mode or "AUTO"
if om == "AUTO": try:
results, duration_ms, solver_snapshot = solve_dispatch_two_pass( if om == "AUTO":
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, results, duration_ms, solver_snapshot = solve_dispatch_two_pass(
tuv_delta_stats=tuv_stats, slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
operating_mode=om, tuv_delta_stats=tuv_stats,
charge_commitment_prev_w=commitment_prev, operating_mode=om,
planner_version=planner_version_resolved, charge_commitment_prev_w=commitment_prev,
evening_push_ts_override=evening_push_override, planner_version=planner_version_resolved,
) evening_push_ts_override=evening_push_override,
else: )
results, duration_ms, solver_snapshot = solve_dispatch( else:
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, results, duration_ms, solver_snapshot = solve_dispatch(
tuv_delta_stats=tuv_stats, slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
operating_mode=om, tuv_delta_stats=tuv_stats,
charge_commitment_prev_w=commitment_prev, operating_mode=om,
planner_version=planner_version_resolved, charge_commitment_prev_w=commitment_prev,
planner_version=planner_version_resolved,
)
except PlannerSolverError as exc:
await _save_failed_planning_run(
site_id,
replan_from,
horizon_to,
run_type="rolling",
triggered_by=triggered_by,
replan_from=replan_from,
soc_wh=soc_wh,
correction=correction_factor,
db=db,
error=exc,
slot_count=len(slots),
) )
raise
comparison_ctx = _maybe_add_planner_comparison( comparison_ctx = _maybe_add_planner_comparison(
slots=slots, slots=slots,
battery=battery, battery=battery,
@@ -5523,3 +5647,57 @@ async def _save_planning_run(
activate_run, activate_run,
) )
) )
async def _save_failed_planning_run(
site_id: int,
horizon_from: datetime,
horizon_to: datetime,
*,
run_type: str,
triggered_by: str,
replan_from: datetime | None,
soc_wh: float,
correction: float,
db,
error: PlannerSolverError,
slot_count: int | None = None,
) -> int:
"""Uloží neúspěšný běh plánovače (status=failed); aktivní plán nemění."""
run_meta: dict[str, Any] = {
"run_type": run_type,
"triggered_by": triggered_by,
"replan_from": replan_from.isoformat() if replan_from else None,
"soc_at_replan_wh": soc_wh,
"solver_duration_ms": 0,
"forecast_correction_factor": correction,
"error_text": str(error),
"solver_params": {
"status": "failed",
"planner_build_tag": PLANNER_BUILD_TAG,
"solver_status": error.solver_status,
"relax_chain": error.relax_chain,
"slot_count": slot_count,
},
}
run_id = int(
await db.fetchval(
"""
select ems.fn_planning_run_fail(
$1::int, $2::timestamptz, $3::timestamptz, $4::jsonb
)
""",
site_id,
horizon_from,
horizon_to,
json.dumps(run_meta, default=str),
)
)
logger.error(
"[site=%s] Planning solver failed run_id=%s: %s relax_chain=%s",
site_id,
run_id,
error,
error.relax_chain,
)
return run_id

View File

@@ -21,6 +21,7 @@ from services.planning_engine import (
_evening_push_discharge_budget_wh, _evening_push_discharge_budget_wh,
_evening_push_override_for_solve, _evening_push_override_for_solve,
_filter_evening_push_override_indices, _filter_evening_push_override_indices,
_solver_relax_chain,
_primary_night_export_segment_indices, _primary_night_export_segment_indices,
_in_evening_push_hour_window, _in_evening_push_hour_window,
_in_night_battery_export_window, _in_night_battery_export_window,
@@ -3219,14 +3220,25 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
{2, 5}, {2, 5},
relaxed_expensive_import=False, relaxed_expensive_import=False,
relaxed_neg_buy_charge=False, relaxed_neg_buy_charge=False,
relaxed_neg_prep_hold_only=False,
relaxed_neg_prep_window=False, relaxed_neg_prep_window=False,
neg_sell_phases_fallback=False, neg_sell_phases_fallback=False,
) )
self.assertEqual(kept, {2, 5}) self.assertEqual(kept, {2, 5})
kept_prep_hold = _evening_push_override_for_solve(
{2, 5},
relaxed_expensive_import=False,
relaxed_neg_buy_charge=False,
relaxed_neg_prep_hold_only=True,
relaxed_neg_prep_window=False,
neg_sell_phases_fallback=False,
)
self.assertEqual(kept_prep_hold, {2, 5})
dropped = _evening_push_override_for_solve( dropped = _evening_push_override_for_solve(
{2, 5}, {2, 5},
relaxed_expensive_import=True, relaxed_expensive_import=True,
relaxed_neg_buy_charge=False, relaxed_neg_buy_charge=False,
relaxed_neg_prep_hold_only=False,
relaxed_neg_prep_window=False, relaxed_neg_prep_window=False,
neg_sell_phases_fallback=False, neg_sell_phases_fallback=False,
) )
@@ -3369,6 +3381,83 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
push_iso = snap["inputs"].get("evening_push_ts") or [] push_iso = snap["inputs"].get("evening_push_ts") or []
self.assertGreaterEqual(len(push_iso), 1) self.assertGreaterEqual(len(push_iso), 1)
def test_relaxed_neg_prep_hold_only_keeps_hard_push(self) -> None:
"""v63: prep_hold_only uvolní prep hold, ne tvrdý evening push."""
prague = ZoneInfo("Europe/Prague")
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 30, 18, 0, tzinfo=prague).astimezone(timezone.utc),
buy_price=3.0,
sell_price=4.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=datetime(2026, 5, 30, 22, 0, tzinfo=prague).astimezone(timezone.utc),
buy_price=3.0,
sell_price=6.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
),
]
battery = _battery(uc_wh=64_000.0)
battery.max_discharge_power_w = 18_000
battery.planner_neg_sell_prep_soc_percent = 80
battery.planner_neg_sell_full_soc_tail_slots = 4
grid = SimpleNamespace(
max_export_power_w=13_500,
max_import_power_w=17_000,
block_export_on_negative_sell=False,
purchase_pricing_mode="spot",
)
_results, _ms, snap = solve_dispatch(
slots,
battery,
SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0),
grid,
[None, None],
[
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),
],
current_soc_wh=16_000.0,
current_tuv_temp_c=55.0,
relaxed_expensive_import=True,
relaxed_neg_buy_charge=True,
relaxed_neg_prep_hold_only=True,
)
self.assertTrue(snap["inputs"].get("relaxed_neg_prep_hold_only"))
self.assertFalse(snap["inputs"].get("relaxed_neg_prep_window"))
self.assertFalse(snap["inputs"].get("evening_push_hard_suppressed"))
self.assertIn(
"relaxed_neg_prep_hold_only",
snap["inputs"].get("relax_chain") or [],
)
def test_solver_relax_chain_order(self) -> None:
chain = _solver_relax_chain(
relaxed_expensive_import=True,
relaxed_neg_buy_charge=True,
relaxed_neg_prep_hold_only=True,
)
self.assertEqual(
chain,
[
"strict",
"relaxed_expensive_import",
"relaxed_neg_buy_charge",
"relaxed_neg_prep_hold_only",
],
)
def test_kv1_evening_push_profitable_vs_morning_zone_peak(self) -> None: def test_kv1_evening_push_profitable_vs_morning_zone_peak(self) -> None:
"""v52: KV1 večer ≥ ranní max (511) degrad; pod prahem ne.""" """v52: KV1 večer ≥ ranní max (511) degrad; pod prahem ne."""
prague = ZoneInfo("Europe/Prague") prague = ZoneInfo("Europe/Prague")

View File

@@ -0,0 +1,14 @@
-- Journal neúspěšných běhů plánovače (Solver: Infeasible po celém retry řetězci).
alter table ems.planning_run
add column if not exists error_text text;
comment on column ems.planning_run.error_text is
'Chybová zpráva u status=failed (typicky Solver: Infeasible); aktivní plán se nemění.';
comment on column ems.planning_run.status is
'Stav plánu: draft, approved, active, superseded, comparison (shadow běh), failed (solver selhal).';
create index if not exists idx_planning_run_site_failed
on ems.planning_run (site_id, created_at desc)
where status = 'failed';

View File

@@ -0,0 +1,50 @@
-- neúspěšný běh plánovače bez aktivace a bez supersede aktivního plánu
create or replace function ems.fn_planning_run_fail(
p_site_id int,
p_horizon_start timestamptz,
p_horizon_end timestamptz,
p_run_meta jsonb
)
returns int
language plpgsql
as $fn$
declare
v_run_id int;
begin
insert into ems.planning_run (
site_id, horizon_start, horizon_end, status,
run_type, triggered_by, replan_from,
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor,
solver_params, error_text
) values (
p_site_id,
p_horizon_start,
p_horizon_end,
'failed',
nullif(trim(p_run_meta->>'run_type'), ''),
nullif(trim(p_run_meta->>'triggered_by'), ''),
case
when p_run_meta ? 'replan_from' and (p_run_meta->>'replan_from') is not null
and (p_run_meta->>'replan_from') <> 'null'
then (p_run_meta->>'replan_from')::timestamptz
else null::timestamptz
end,
(p_run_meta->>'soc_at_replan_wh')::numeric,
coalesce((p_run_meta->>'solver_duration_ms')::int, 0),
coalesce((p_run_meta->>'forecast_correction_factor')::numeric, 1.0),
case
when p_run_meta ? 'solver_params' and jsonb_typeof(p_run_meta->'solver_params') = 'object'
then p_run_meta->'solver_params'
else null::jsonb
end,
nullif(trim(p_run_meta->>'error_text'), '')
)
returning id into v_run_id;
return v_run_id;
end;
$fn$;
comment on function ems.fn_planning_run_fail is
'Uloží planning_run se statusem failed; neaktivuje plán a nesupersededuje active.';

View File

@@ -113,7 +113,7 @@ flowchart TD
5. **v45 — neg okno + noc z baterie:** 5. **v45 — neg okno + noc z baterie:**
- **`neg_window_grid_charge`:** v sell&lt;0 okně neg dne grid nabíjení i bez `pv_surplus` (07:45+); - **`neg_window_grid_charge`:** v sell&lt;0 okně neg dne grid nabíjení i bez `pv_surplus` (07:45+);
- **`night_self_consume_discourage`** na **celé** noční okno mimo push; - **`night_self_consume_discourage`** na **celé** noční okno mimo push;
- při `relaxed_neg_prep_window` bez prep shortfall penalizace. - při `relaxed_neg_prep_hold_only` nebo `relaxed_neg_prep_window` bez prep shortfall penalizace.
6. **v47 — po večerním pushu noc z baterie:** 6. **v47 — po večerním pushu noc z baterie:**
- večerní push zůstává **sell > acq+spread** (sell&lt;buy je záměr před neg dnem); - večerní push zůstává **sell > acq+spread** (sell&lt;buy je záměr před neg dnem);
@@ -140,6 +140,12 @@ flowchart TD
15. **v61 — spot: grid→bat jen při buy ≤ acq:** `sell < buy` ve slotu **není** kritérium (marže); zákaz nabíjení při **`buy > charge_acquisition + degrad`**. Zrušeno v60. Tag **`2026-06-01-spot-grid-charge-at-acq-buy-v61`**. 15. **v61 — spot: grid→bat jen při buy ≤ acq:** `sell < buy` ve slotu **není** kritérium (marže); zákaz nabíjení při **`buy > charge_acquisition + degrad`**. Zrušeno v60. Tag **`2026-06-01-spot-grid-charge-at-acq-buy-v61`**.
16. **v63 — Infeasible journal + granulární prep relax (Branch 1):**
- Retry řetězec: strict → `relaxed_expensive_import``relaxed_neg_buy_charge`**`relaxed_neg_prep_hold_only`** (jen prep hold / prep_soc shortfall) → **`relaxed_neg_prep_window`** (navíc vypne neg-evening bundle + tvrdý push) → `neg_sell_phases_fallback`.
- Snap: `relax_chain`, `relaxed_neg_prep_hold_only`; `evening_push_hard_suppressed` jen od `relaxed_neg_prep_window`.
- Selhání po celém řetězci → `planning_run.status = failed`, sloupec `error_text`, `ems.fn_planning_run_fail` (aktivní plán se nemění).
- Diagnostika: `scripts/diagnose_home01_infeasible.py --print-export-sql --run-id <id>`. Tag **`2026-06-06-infeasible-journal-granular-prep-relax-v63`**.
**Funkce:** … home-01 **v61**; BA81/KV1 fixed **v59** (+ `R__063`). **Funkce:** … home-01 **v61**; BA81/KV1 fixed **v59** (+ `R__063`).
### Rozpočet nabíjecích slotů (plánováno, 2026-06) ### Rozpočet nabíjecích slotů (plánováno, 2026-06)

View File

@@ -5,6 +5,25 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
--- ---
## 2026-06-06 — Infeasible journal + granulární prep relax (v63, Branch 1)
**Problém:** home-01 run 23784 prošel až **`relaxed_neg_prep_window`** (3. retry) → `evening_push_hard_suppressed`, prázdné `neg_evening_push_slots`, SoC ~80 % ve špičce + import @ ~5 Kč. Selhání **`Solver: Infeasible`** se neukládalo do DB (jen log backendu).
**Změna (v63):**
- Nový krok **`relaxed_neg_prep_hold_only`**: uvolní jen `prep_soc_shortfall` + prep hold binárky; **neg-evening bundle a tvrdý evening push zůstávají**.
- **`relaxed_neg_prep_window`** až jako 4. krok (full prep relax včetně neg-evening a `evening_push_hard_suppressed`).
- **`PlannerSolverError`** + `relax_chain` ve snap; po vyčerpání retry → **`fn_planning_run_fail`** (`status=failed`, `error_text`, migrace **V084**).
- **`scripts/diagnose_home01_infeasible.py`**: `--print-export-sql`, bisect všech relax kroků.
**Soubory:** `backend/services/planning_engine.py`, `db/migration/V084__planning_run_failed_status.sql`, `db/routines/R__091_fn_planning_run_fail.sql`, `scripts/diagnose_home01_infeasible.py`, `backend/tests/test_planning_dispatch_milp.py`.
**Ověření:**
- `pytest backend/tests/test_planning_dispatch_milp.py -k "prep_hold or relax_chain or evening_push_override"`
- MCP po neúspěšném API replanu: `select id, status, error_text, solver_params->'relax_chain' from ems.planning_run where site_id=2 and status='failed' order by created_at desc limit 3;`
- Úspěšný rolling: `relaxed_neg_prep_hold_only: true` bez `relaxed_neg_prep_window` a `evening_push_hard_suppressed: false`.
---
## Plánováno — rozpočet nabíjecích slotů (charge-slot-budget, neimplementováno) ## Plánováno — rozpočet nabíjecích slotů (charge-slot-budget, neimplementováno)
**Stav:** pouze dokumentace (2026-06); implementace později. **Stav:** pouze dokumentace (2026-06); implementace později.

View File

@@ -1,7 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Bisect Infeasible na reálných slotech home-01 (MCP run 16674). PYTHONPATH=backend.""" """Bisect Infeasible na reálných slotech home-01 (fixture z MCP).
Export fixture z MCP (server user-postgres-ems, nástroj query):
python scripts/diagnose_home01_infeasible.py --print-export-sql --run-id 23784
# výstup SQL vlož do MCP query; JSON ulož např.:
# scripts/home01_run23784_slots.json
Spuštění bisectu:
PYTHONPATH=backend python scripts/diagnose_home01_infeasible.py \\
--fixture scripts/home01_run23784_slots.json --soc-wh 51840
"""
from __future__ import annotations from __future__ import annotations
import argparse
import json import json
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -10,13 +24,42 @@ from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backend")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backend"))
from services.planning_engine import PlanningSlot, solve_dispatch, solve_dispatch_two_pass, PLANNER_BUILD_TAG from services.planning_engine import ( # noqa: E402
PLANNER_BUILD_TAG,
PlanningSlot,
SOLVER_RELAX_STEPS,
solve_dispatch,
solve_dispatch_two_pass,
)
# Export z MCP: planning_interval run_id=16674 + fn_planning_site_context(2) DEFAULT_FIXTURE = Path(__file__).with_name("home01_run16674_slots.json")
SLOTS_JSON = Path(__file__).with_name("home01_run16706_slots.json") DEFAULT_SOC_WH = 37120.0
if not SLOTS_JSON.exists():
SLOTS_JSON = Path(__file__).with_name("home01_run16674_slots.json")
SOC_WH = 37120.0 def export_slots_sql(run_id: int) -> str:
"""SQL pro MCP export slotů z fn_load_planning_slots_full pro daný run."""
return f"""
select json_agg(row order by row.interval_start)
from (
select
s.interval_start,
s.buy_price::float8 as buy,
s.sell_price::float8 as sell,
s.load_baseline_w as load,
s.pv_a_forecast_w as pv_a,
s.pv_b_forecast_w as pv_b,
s.allow_charge,
s.allow_discharge_export
from ems.planning_run pr
cross join lateral ems.fn_load_planning_slots_full(
pr.site_id,
coalesce(pr.replan_from, pr.horizon_start),
pr.horizon_end,
coalesce(pr.soc_at_replan_wh, 0)
) s
where pr.id = {run_id}
) row;
""".strip()
def _ctx() -> tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, list]: def _ctx() -> tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, list]:
@@ -39,12 +82,16 @@ def _ctx() -> tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, list]:
planner_daytime_charge_target_enabled=True, planner_daytime_charge_target_enabled=True,
planner_charge_commitment_penalty_czk_kwh=0.2, planner_charge_commitment_penalty_czk_kwh=0.2,
planner_night_baseload_buffer_percent=20, planner_night_baseload_buffer_percent=20,
planner_neg_sell_prep_soc_percent=10.0,
planner_neg_sell_full_soc_tail_slots=4,
planner_neg_sell_vent_min_sell_czk_kwh=-0.5,
) )
grid = SimpleNamespace( grid = SimpleNamespace(
max_import_power_w=17000, max_import_power_w=17000,
max_export_power_w=13500, max_export_power_w=13500,
block_export_on_negative_sell=False, block_export_on_negative_sell=False,
deye_gen_microinverter_cutoff_enabled=False, deye_gen_microinverter_cutoff_enabled=False,
purchase_pricing_mode="spot",
) )
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0) hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
vehicles = [ vehicles = [
@@ -54,12 +101,27 @@ def _ctx() -> tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, list]:
return battery, hp, grid, vehicles return battery, hp, grid, vehicles
def load_slots(*, permissive_masks: bool) -> list[PlanningSlot]: def load_slots(
rows = json.loads(SLOTS_JSON.read_text()) rows: list[dict],
*,
permissive_masks: bool,
use_row_masks: bool,
) -> list[PlanningSlot]:
out: list[PlanningSlot] = [] out: list[PlanningSlot] = []
for r in rows: for r in rows:
ts = datetime.fromisoformat(r["interval_start"].replace("Z", "+00:00")) ts = datetime.fromisoformat(r["interval_start"].replace("Z", "+00:00"))
pv_surplus = max(0, int(r["pv_a"]) + int(r["pv_b"]) - int(r["load"])) pv_surplus = max(0, int(r["pv_a"]) + int(r["pv_b"]) - int(r["load"]))
if permissive_masks:
allow_charge = True
allow_discharge_export = True
elif use_row_masks and "allow_charge" in r:
allow_charge = bool(r.get("allow_charge"))
allow_discharge_export = bool(r.get("allow_discharge_export", True))
else:
allow_charge = float(r["buy"]) < 0 or (
float(r["sell"]) < 0 and pv_surplus > 500
)
allow_discharge_export = float(r["sell"]) >= 0
out.append( out.append(
PlanningSlot( PlanningSlot(
interval_start=ts, interval_start=ts,
@@ -70,53 +132,156 @@ def load_slots(*, permissive_masks: bool) -> list[PlanningSlot]:
load_baseline_w=int(r["load"]), load_baseline_w=int(r["load"]),
ev1_connected=False, ev1_connected=False,
ev2_connected=False, ev2_connected=False,
allow_charge=True if permissive_masks else (float(r["buy"]) < 0 or (float(r["sell"]) < 0 and pv_surplus > 500)), allow_charge=allow_charge,
allow_discharge_export=permissive_masks, allow_discharge_export=allow_discharge_export,
) )
) )
return out return out
def try_solve(label: str, slots: list[PlanningSlot], **kwargs) -> str: def try_solve(
label: str,
slots: list[PlanningSlot],
soc_wh: float,
**kwargs,
) -> tuple[str, dict | None]:
battery, hp, grid, vehicles = _ctx() battery, hp, grid, vehicles = _ctx()
try: try:
if kwargs.pop("two_pass", False): if kwargs.pop("two_pass", False):
solve_dispatch_two_pass( _results, _ms, snap = solve_dispatch_two_pass(
slots, battery, hp, grid, [None, None], vehicles, SOC_WH, 55.0, slots,
operating_mode="AUTO", **kwargs, battery,
hp,
grid,
[None, None],
vehicles,
soc_wh,
55.0,
operating_mode="AUTO",
**kwargs,
) )
else: else:
solve_dispatch( _results, _ms, snap = solve_dispatch(
slots, battery, hp, grid, [None, None], vehicles, SOC_WH, 55.0, slots,
operating_mode="AUTO", **kwargs, battery,
hp,
grid,
[None, None],
vehicles,
soc_wh,
55.0,
operating_mode="AUTO",
**kwargs,
) )
return f"OK {label}" inp = snap.get("inputs") or {}
relax = inp.get("relax_chain") or []
push = len(inp.get("evening_push_ts") or [])
suppressed = inp.get("evening_push_hard_suppressed")
return (
f"OK {label} relax={relax[-1] if relax else 'strict'} "
f"push_slots={push} hard_suppressed={suppressed}",
inp,
)
except Exception as e: except Exception as e:
return f"FAIL {label}: {e}" return f"FAIL {label}: {e}", None
def main() -> None: def main() -> None:
if not SLOTS_JSON.exists(): parser = argparse.ArgumentParser(description=__doc__)
print(f"Chybí {SLOTS_JSON} — spusť export z MCP (run 16674).", file=sys.stderr) parser.add_argument("--fixture", type=Path, default=DEFAULT_FIXTURE)
parser.add_argument("--soc-wh", type=float, default=DEFAULT_SOC_WH)
parser.add_argument("--run-id", type=int, help="MCP run id pro --print-export-sql")
parser.add_argument(
"--print-export-sql",
action="store_true",
help="Vytiskne SQL pro export slotů z MCP",
)
args = parser.parse_args()
if args.print_export_sql:
run_id = args.run_id or 23784
print(export_slots_sql(run_id))
print(
f"\n-- Ulož json_agg výsledek do {args.fixture.name} "
f"(nebo jiné cesty přes --fixture).",
file=sys.stderr,
)
return
fixture = args.fixture
if not fixture.exists():
print(
f"Chybí {fixture}. Spusť:\n"
f" python {Path(__file__).name} --print-export-sql --run-id 23784",
file=sys.stderr,
)
sys.exit(1) sys.exit(1)
print("tag", PLANNER_BUILD_TAG) rows = json.loads(fixture.read_text())
print("slots", len(json.loads(SLOTS_JSON.read_text()))) neg_buy = [r for r in rows if r["buy"] < 0]
neg_buy = [r for r in json.loads(SLOTS_JSON.read_text()) if r["buy"] < 0] neg_sell = [r for r in rows if r["sell"] < 0]
print("neg_buy slots", len(neg_buy), "first", neg_buy[0]["interval_start"] if neg_buy else None)
cases = [ print("tag", PLANNER_BUILD_TAG)
print("fixture", fixture)
print("slots", len(rows))
print("soc_wh", args.soc_wh)
print("neg_buy slots", len(neg_buy), "first", neg_buy[0]["interval_start"] if neg_buy else None)
print("neg_sell slots", len(neg_sell), "first", neg_sell[0]["interval_start"] if neg_sell else None)
print("relax steps", list(SOLVER_RELAX_STEPS))
print()
cases: list[tuple[str, dict]] = [
("permissive masks, 1-pass", dict(permissive_masks=True, two_pass=False)), ("permissive masks, 1-pass", dict(permissive_masks=True, two_pass=False)),
("permissive masks, 2-pass", dict(permissive_masks=True, two_pass=True)), ("permissive masks, 2-pass", dict(permissive_masks=True, two_pass=True)),
("realistic masks, 1-pass", dict(permissive_masks=False, two_pass=False)), ("realistic masks, 1-pass auto-retry", dict(permissive_masks=False, two_pass=False)),
("realistic masks, 2-pass", dict(permissive_masks=False, two_pass=True)), ("realistic masks, 2-pass auto-retry", dict(permissive_masks=False, two_pass=True)),
("realistic + relaxed_expensive", dict(permissive_masks=False, two_pass=False, relaxed_expensive_import=True)), ("realistic + row masks from fixture", dict(use_row_masks=True, two_pass=False)),
("realistic + both relaxed", dict(permissive_masks=False, two_pass=False, relaxed_expensive_import=True, relaxed_neg_buy_pressure=True)), ("strict only (no auto retry)", dict(
permissive_masks=False,
relaxed_expensive_import=False,
relaxed_neg_buy_charge=False,
relaxed_neg_prep_hold_only=False,
relaxed_neg_prep_window=False,
neg_sell_phases_fallback=False,
)),
("+ relaxed_expensive_import", dict(
permissive_masks=False,
relaxed_expensive_import=True,
)),
("+ relaxed_neg_buy_charge", dict(
permissive_masks=False,
relaxed_expensive_import=True,
relaxed_neg_buy_charge=True,
)),
("+ relaxed_neg_prep_hold_only (evening push kept)", dict(
permissive_masks=False,
relaxed_expensive_import=True,
relaxed_neg_buy_charge=True,
relaxed_neg_prep_hold_only=True,
)),
("+ relaxed_neg_prep_window (full prep relax)", dict(
permissive_masks=False,
relaxed_expensive_import=True,
relaxed_neg_buy_charge=True,
relaxed_neg_prep_hold_only=True,
relaxed_neg_prep_window=True,
)),
("+ neg_sell_phases_fallback", dict(
permissive_masks=False,
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,
)),
] ]
for label, kw in cases: for label, kw in cases:
masks = kw.pop("permissive_masks") permissive = kw.pop("permissive_masks", False)
slots = load_slots(permissive_masks=masks) use_row_masks = kw.pop("use_row_masks", False)
print(try_solve(label, slots, **kw)) slots = load_slots(rows, permissive_masks=permissive, use_row_masks=use_row_masks)
msg, _inp = try_solve(label, slots, args.soc_wh, **kw)
print(msg)
if __name__ == "__main__": if __name__ == "__main__":