Branch 1: failed run journal + bisect Infeasible + granulární relaxace (bez vypnutí evening push)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 (5–11) − degrad; pod prahem ne."""
|
"""v52: KV1 večer ≥ ranní max (5–11) − degrad; pod prahem ne."""
|
||||||
prague = ZoneInfo("Europe/Prague")
|
prague = ZoneInfo("Europe/Prague")
|
||||||
|
|||||||
14
db/migration/V084__planning_run_failed_status.sql
Normal file
14
db/migration/V084__planning_run_failed_status.sql
Normal 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';
|
||||||
50
db/routines/R__091_fn_planning_run_fail.sql
Normal file
50
db/routines/R__091_fn_planning_run_fail.sql
Normal 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.';
|
||||||
@@ -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<0 okně neg dne grid nabíjení i bez `pv_surplus` (07:45+);
|
- **`neg_window_grid_charge`:** v sell<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<buy je záměr před neg dnem);
|
- večerní push zůstává **sell > acq+spread** (sell<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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user