dasli fix
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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 (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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user