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

This commit is contained in:
Dusan Vojacek
2026-06-06 23:47:12 +02:00
parent 3ad5bec76b
commit b7903db714
3 changed files with 132 additions and 5 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_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-06-home01-late-replan-infeasible-v2" PLANNER_BUILD_TAG = "2026-06-06-home01-degraded-night-guard-v3"
SOLVER_RELAX_STEPS: tuple[str, ...] = ( SOLVER_RELAX_STEPS: tuple[str, ...] = (
"strict", "strict",
"relaxed_expensive_import", "relaxed_expensive_import",
@@ -1940,6 +1940,24 @@ def _evening_push_segment_candidates(
return out return out
def _degraded_relaxed_night_self_consume_indices(
slots: list[PlanningSlot],
) -> set[int]:
"""
relaxed_solver_masks: celé noční okno — dům z baterie (až min_soc), ne import za spot buy.
"""
out: set[int] = set()
for t, s in enumerate(slots):
if not _in_night_battery_export_window(s):
continue
if float(s.load_baseline_w) <= 0:
continue
if float(s.buy_price) < 0.0:
continue
out.add(t)
return out
def _post_evening_push_night_self_consume_indices( def _post_evening_push_night_self_consume_indices(
slots: list[PlanningSlot], slots: list[PlanningSlot],
evening_push_ts: set[int], evening_push_ts: set[int],
@@ -3027,6 +3045,7 @@ def solve_dispatch(
evening_early_export_penalty_ts: set[int] = set() evening_early_export_penalty_ts: set[int] = set()
night_self_consume_discourage_ts: set[int] = set() night_self_consume_discourage_ts: set[int] = set()
post_evening_push_night_ts: set[int] = set() post_evening_push_night_ts: set[int] = set()
degraded_relaxed_night_ts: set[int] = set()
evening_push_hysteresis_retained = False evening_push_hysteresis_retained = False
push_override_raw: Optional[set[int]] = None push_override_raw: Optional[set[int]] = None
push_override_eff: Optional[set[int]] = None push_override_eff: Optional[set[int]] = None
@@ -3195,6 +3214,9 @@ def solve_dispatch(
evening_early_export_penalty_ts = set() evening_early_export_penalty_ts = set()
battery_export_defer_pv_ts = set() battery_export_defer_pv_ts = set()
evening_push_hard_suppressed = True evening_push_hard_suppressed = True
degraded_relaxed_night_ts = _degraded_relaxed_night_self_consume_indices(slots)
night_self_consume_discourage_ts |= degraded_relaxed_night_ts
post_evening_push_night_ts |= degraded_relaxed_night_ts
pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh( pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh(
slots, slots,
first_neg_buy_idx=first_neg_buy_idx, first_neg_buy_idx=first_neg_buy_idx,
@@ -3307,6 +3329,8 @@ def solve_dispatch(
block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False)) block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False))
if om == "AUTO": if om == "AUTO":
for t in range(T): for t in range(T):
if relaxed_solver_masks and not purchase_fixed_pre:
continue
if t not in discharge_export_slots: if t not in discharge_export_slots:
continue continue
if t in evening_push_ts: if t in evening_push_ts:
@@ -3854,6 +3878,13 @@ def solve_dispatch(
continue continue
prob += ge_bat[t_pv] == 0 prob += ge_bat[t_pv] == 0
prob += z_export[t_pv] == 0 prob += z_export[t_pv] == 0
# Nouzový relax: spot v noci neexportovat baterii za ~2,5 Kč (žádný tvrdý evening dump).
if relaxed_solver_masks and not purchase_fixed_pre:
for t_blk in range(T):
if not _in_night_battery_export_window(slots[t_blk]):
continue
prob += ge_bat[t_blk] == 0
prob += z_export[t_blk] == 0
# Ostatní profitable sloty: měkká shortfall penalizace (ne večerní push). # Ostatní profitable sloty: měkká shortfall penalizace (ne večerní push).
if ( if (
last_pos_sell_pre_neg_buy is not None last_pos_sell_pre_neg_buy is not None
@@ -4195,6 +4226,7 @@ def solve_dispatch(
else: else:
export_soc_floor_t = float(arb_base_wh) export_soc_floor_t = float(arb_base_wh)
# Večerní exportní slot: podlaha jen min_soc (ne safety ramp), aby šlo vybít při z_export=1. # Večerní exportní slot: podlaha jen min_soc (ne safety ramp), aby šlo vybít při z_export=1.
# Nouzový relaxed_solver_masks: export nikdy pod reserve_soc (ekonomická podlaha).
if ( if (
om == "AUTO" om == "AUTO"
and t in discharge_export_slots and t in discharge_export_slots
@@ -4202,8 +4234,14 @@ def solve_dispatch(
t in evening_peak_export_ts t in evening_peak_export_ts
or t in neg_evening_push_ts or t in neg_evening_push_ts
) )
and not relaxed_solver_masks
): ):
export_soc_floor_t = float(min_soc_wh) export_soc_floor_t = float(min_soc_wh)
elif relaxed_solver_masks and om == "AUTO":
export_soc_floor_t = max(
export_soc_floor_t,
float(getattr(battery, "reserve_soc_wh", arb_base_wh)),
)
# Safety export floor: v běžných (ne high-sell) slotech nevybít exportem energii potřebnou pro # Safety export floor: v běžných (ne high-sell) slotech nevybít exportem energii potřebnou pro
# robustnost/noční baseload. Použije se pouze pokud je safety target v SQL vyplněný. # robustnost/noční baseload. Použije se pouze pokud je safety target v SQL vyplněný.
tgt_s = slots[t].safety_soc_target_wh if daytime_en else None tgt_s = slots[t].safety_soc_target_wh if daytime_en else None
@@ -5149,6 +5187,10 @@ def solve_dispatch(
slots[i].interval_start.isoformat() slots[i].interval_start.isoformat()
for i in sorted(night_self_consume_discourage_ts) for i in sorted(night_self_consume_discourage_ts)
], ],
"degraded_relaxed_night_ts": [
slots[i].interval_start.isoformat()
for i in sorted(degraded_relaxed_night_ts)
],
}, },
"masks": masks_snap, "masks": masks_snap,
"soc_bounds": soc_bounds_snap, "soc_bounds": soc_bounds_snap,

View File

@@ -3698,6 +3698,90 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
self.assertLess(results[0].grid_setpoint_w, -500) self.assertLess(results[0].grid_setpoint_w, -500)
self.assertLess(results[0].battery_soc_target, 70.0) self.assertLess(results[0].battery_soc_target, 70.0)
def test_degraded_relaxed_solver_no_night_dump_and_self_consume(self) -> None:
"""relaxed_solver_masks: žádný večerní dump za ~2,5 Kč; noc dům z baterie, ne import ~4 Kč."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 6, 6, 21, 30, tzinfo=prague).astimezone(timezone.utc)
rows: list[tuple[float, float, int]] = [
(4.66, 2.85, 780),
(4.48, 2.72, 780),
(4.76, 2.92, 450),
(4.35, 2.61, 450),
(4.06, 2.40, 440),
(3.80, 2.20, 440),
(3.76, 2.17, 440),
(3.48, 1.96, 440),
(3.76, 2.17, 460),
(3.48, 1.96, 460),
(3.34, 1.85, 460),
(3.03, 1.61, 460),
]
slots: list[PlanningSlot] = []
for i in range(48):
local = (base + timedelta(minutes=15 * i)).astimezone(prague)
if i < len(rows):
buy, sell, load = rows[i]
pv_a, pv_b = 0, 0
elif local.hour >= 5 and local.hour < 12:
buy, sell, load, pv_a, pv_b = 0.5, -0.3, 800, 2000, 2500
else:
buy, sell, load, pv_a, pv_b = 3.0, 2.0, 500, 0, 0
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=True,
allow_discharge_export=sell >= 0,
)
)
bat = _battery(uc_wh=64_000.0, arb_pct=20.0, terminal_soc_value_factor=0.9)
bat.planner_neg_sell_prep_soc_percent = 100.0
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=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),
]
soc = 0.71 * bat.soc_max_wh
results, _ms, snap = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
soc,
50.0,
operating_mode="AUTO",
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,
relaxed_solver_masks=True,
)
inp = snap.get("inputs") or {}
self.assertTrue(inp.get("relaxed_solver_masks"))
self.assertGreater(len(inp.get("degraded_relaxed_night_ts") or []), 0)
for i, r in enumerate(results[:12]):
self.assertGreaterEqual(r.battery_soc_target, 10.0)
if i < 4:
self.assertGreater(r.grid_setpoint_w, -500, msg=f"slot {i} evening dump")
if 8 <= i <= 11 and rows[i][0] > 3.0:
self.assertLessEqual(r.grid_setpoint_w, rows[i][2] + 50, msg=f"slot {i} grid import")
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

@@ -11,18 +11,19 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
**Příčina:** SQL maska `allow_charge=false` ve večerních slotech (drahý `buy`, `sell` < `buy`) + guard drahého importu vyžadoval baseload z baterie (`bd`), zatímco **v64 `future_neg_buy_discharge`** současně vynucoval večerní vývoz — LP bez rozšíření `charge_slots` neměl řešení. **Příčina:** SQL maska `allow_charge=false` ve večerních slotech (drahý `buy`, `sell` < `buy`) + guard drahého importu vyžadoval baseload z baterie (`bd`), zatímco **v64 `future_neg_buy_discharge`** současně vynucoval večerní vývoz — LP bez rozšíření `charge_slots` neměl řešení.
**Oprava (tag `2026-06-06-home01-late-replan-infeasible-v1`, doplněno **v2**):** **Oprava (tag `2026-06-06-home01-late-replan-infeasible-v1`, doplněno **v2**, guard **v3**):**
- Při **`future_neg_buy_discharge`**: rozšířit `charge_slots` o večerní / exportní sloty dne replanu (grid smí krmit load během vývozu). - Při **`future_neg_buy_discharge`**: rozšířit `charge_slots` o večerní / exportní sloty dne replanu (grid smí krmit load během vývozu).
- **`_unlock_late_replan_evening_slots`** po `fn_load_planning_slots_full` — večer D0 `allow_charge` + export z DB. - **`_unlock_late_replan_evening_slots`** po `fn_load_planning_slots_full` — večer D0 `allow_charge` + export z DB.
- Nový retry **`relaxed_pos_sell_ge_block`** (+ **`relaxed_solver_masks`** nouzový) v `SOLVER_RELAX_STEPS`. - Nový retry **`relaxed_pos_sell_ge_block`** (+ **`relaxed_solver_masks`** nouzový) v `SOLVER_RELAX_STEPS`.
- **v2:** two-pass pass2 dědí všechny relax flagy; při pass2 Infeasible fallback na pass1; override push zrušen už od `relaxed_neg_prep_hold_only`. - **v2:** two-pass pass2 dědí všechny relax flagy; při pass2 Infeasible fallback na pass1; override push zrušen už od `relaxed_neg_prep_hold_only`.
- **v3 (`degraded-night-guard-v3`):** v **`relaxed_solver_masks`** — spot **bez nočního/večerního `ge_bat` exportu**; **`_degraded_relaxed_night_self_consume_indices`** + tvrdý expensive-import guard (dům z baterie až **`min_soc`**, ne import za ~4 Kč); exportní podlaha SoC ≥ **`reserve_soc`**.
**Soubory:** `planning_engine.py`, `scripts/repro_home01_23840.py`, test `test_home01_late_replan_high_soc_realistic_masks`. **Soubory:** `planning_engine.py`, `scripts/repro_home01_23840.py`, testy `test_home01_late_replan_high_soc_realistic_masks`, `test_degraded_relaxed_solver_no_night_dump_and_self_consume`.
**Ověření:** **Ověření:**
- `PYTHONPATH=backend python3 scripts/repro_home01_23840.py``OK two_pass` - `PYTHONPATH=backend python3 scripts/repro_home01_23840.py``OK two_pass`
- `pytest backend/tests/test_planning_dispatch_milp.py -k home01_late_replan` - `pytest backend/tests/test_planning_dispatch_milp.py -k "home01_late_replan or degraded_relaxed"`
- Po deployi: ruční replan v AUTO → `planning_run.status=active`, `planner_build_tag` končí **`infeasible-v2`**, večerní sloty `grid_setpoint_w < 0`. - Po deployi: aktivní run `planner_build_tag` končí **`degraded-night-guard-v3`**; při `relax_chain` obsahujícím `relaxed_solver_masks` večer **bez** masivního exportu, noc **bez** importu pro baseload nad ~load setpoint.
--- ---