fakt me to nebavi furt jsou tam chyby
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-06-06 23:58:01 +02:00
parent b7903db714
commit 50ac40868d
3 changed files with 127 additions and 32 deletions

View File

@@ -71,7 +71,7 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
PLANNER_BUILD_TAG = "2026-06-06-home01-degraded-night-guard-v3"
PLANNER_BUILD_TAG = "2026-06-06-home01-degraded-night-guard-v4"
SOLVER_RELAX_STEPS: tuple[str, ...] = (
"strict",
"relaxed_expensive_import",
@@ -1958,6 +1958,35 @@ def _degraded_relaxed_night_self_consume_indices(
return out
def _degraded_relaxed_evening_export_to_reserve_indices(
slots: list[PlanningSlot],
*,
observed_soc_wh: float,
reserve_soc_wh: float,
first_neg_buy_idx: int | None,
) -> set[int]:
"""
Nouzový solve: večer D0 smí vývoz bat k reserve_soc před dnem s buy<0 (headroom na zítra).
Jen kalendářní večer 1722h — po 22h už noc (dům z baterie, ne držet kvůli exportu).
"""
if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
return set()
if observed_soc_wh <= float(reserve_soc_wh) + 500.0:
return set()
replan_day = _prague_calendar_date(slots[0])
out: set[int] = set()
for t, s in enumerate(slots):
if _prague_calendar_date(s) != replan_day:
continue
h = _prague_hour(s)
if h < NIGHT_EXPORT_EVENING_START_HOUR or h > 22:
continue
if float(s.sell_price) < 0.0:
continue
out.add(t)
return out
def _post_evening_push_night_self_consume_indices(
slots: list[PlanningSlot],
evening_push_ts: set[int],
@@ -3046,6 +3075,7 @@ def solve_dispatch(
night_self_consume_discourage_ts: set[int] = set()
post_evening_push_night_ts: set[int] = set()
degraded_relaxed_night_ts: set[int] = set()
degraded_evening_export_ts: set[int] = set()
evening_push_hysteresis_retained = False
push_override_raw: Optional[set[int]] = None
push_override_eff: Optional[set[int]] = None
@@ -3215,6 +3245,15 @@ def solve_dispatch(
battery_export_defer_pv_ts = set()
evening_push_hard_suppressed = True
degraded_relaxed_night_ts = _degraded_relaxed_night_self_consume_indices(slots)
reserve_wh_degraded = float(
getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
)
degraded_evening_export_ts = _degraded_relaxed_evening_export_to_reserve_indices(
slots,
observed_soc_wh=observed_soc_wh,
reserve_soc_wh=reserve_wh_degraded,
first_neg_buy_idx=first_neg_buy_idx,
)
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(
@@ -3325,6 +3364,7 @@ def solve_dispatch(
neg_buy_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
pre_neg_batt_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
pre_neg_buy_empty_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
degraded_evening_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
fixed_tariff_like = fixed_tariff_like_pre
block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False))
if om == "AUTO":
@@ -3373,6 +3413,15 @@ def solve_dispatch(
continue
sf_e = pulp.LpVariable(f"pre_neg_buy_empty_sf_{t_empty}", 0, export_cap_w)
pre_neg_buy_empty_shortfall.append((t_empty, sf_e, export_cap_w))
if relaxed_solver_masks and degraded_evening_export_ts:
deg_cap = _battery_export_cap_w(battery, grid)
for t_deg in sorted(degraded_evening_export_ts):
sf_deg = pulp.LpVariable(
f"deg_eve_reserve_export_{t_deg}",
0,
deg_cap,
)
degraded_evening_export_shortfall.append((t_deg, sf_deg, deg_cap))
if not relaxed_neg_buy_charge:
neg_buy_slot_indices = [
t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
@@ -3681,6 +3730,10 @@ def solve_dispatch(
sf * PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in peak_export_shortfall
)
+ pulp.lpSum(
sf * NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in degraded_evening_export_shortfall
)
+ pulp.lpSum(
sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in pv_charge_shortfall
@@ -3817,6 +3870,8 @@ def solve_dispatch(
prob += sf >= cap_w - ge_pv[t_sf]
for t_sf, sf, cap_w in neg_evening_before_neg_shortfall:
prob += sf >= cap_w - ge_bat[t_sf]
for t_sf, sf, cap_w in degraded_evening_export_shortfall:
prob += sf >= cap_w - ge_bat[t_sf]
for t_sl, sl, reserve_tgt in neg_evening_reserve_soc_slack:
prob += soc[t_sl] <= float(reserve_tgt) + sl
preneg_export_min_soc_wh = float(min_soc_wh) + max(
@@ -3878,13 +3933,23 @@ def solve_dispatch(
continue
prob += ge_bat[t_pv] == 0
prob += z_export[t_pv] == 0
# Nouzový relax: spot v noci neexportovat baterii za ~2,5 Kč (žádný tvrdý evening dump).
# Nouzový relax: v noci jen vývoz k reserve večer D0; jinak ge_bat=0.
if relaxed_solver_masks and not purchase_fixed_pre:
reserve_wh_blk = float(
getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
)
for t_blk in range(T):
if t_blk in degraded_evening_export_ts:
continue
if not _in_night_battery_export_window(slots[t_blk]):
continue
prob += ge_bat[t_blk] == 0
prob += z_export[t_blk] == 0
for t_ev in sorted(degraded_evening_export_ts):
m_soc_deg = float(battery.usable_capacity_wh)
prob += soc[t_ev] >= float(reserve_wh_blk) - m_soc_deg * (
1 - z_export[t_ev]
)
# Ostatní profitable sloty: měkká shortfall penalizace (ne večerní push).
if (
last_pos_sell_pre_neg_buy is not None
@@ -4500,25 +4565,37 @@ def solve_dispatch(
expensive_import_slot = expensive_import_slot or (
buy_t > charge_acquisition_czk_kwh + min_spread
)
if expensive_import_slot and t not in charge_slots and buy_t >= 0.0:
# Strict: síť jen EV+TČ; baseload z baterie/FVE.
# Relaxed: síť smí baseload jen mimo night_self_consume (v46).
night_self_consume_slot = (
om == "AUTO"
and (
t in night_self_consume_discourage_ts
or t in post_evening_push_night_ts
)
if expensive_import_slot and buy_t >= 0.0:
force_night_self_consume = (
relaxed_solver_masks
and t in degraded_relaxed_night_ts
and t not in degraded_evening_export_ts
)
if relaxed_expensive_import and not night_self_consume_slot:
prob += gi[t] <= ev_cap_t + hp[t] + float(s.load_baseline_w)
else:
prob += gi[t] <= ev_cap_t + hp[t]
if (not relaxed_expensive_import or night_self_consume_slot) and om == "AUTO":
prob += (
bd[t] + pv_ld[t]
>= float(s.load_baseline_w) + hp[t]
if force_night_self_consume or (
expensive_import_slot and t not in charge_slots
):
# Strict: síť jen EV+TČ; baseload z baterie/FVE.
# Relaxed: síť smí baseload jen mimo night_self_consume (v46).
night_self_consume_slot = (
om == "AUTO"
and (
t in night_self_consume_discourage_ts
or t in post_evening_push_night_ts
or force_night_self_consume
)
)
if relaxed_expensive_import and not night_self_consume_slot:
prob += gi[t] <= ev_cap_t + hp[t] + float(s.load_baseline_w)
else:
prob += gi[t] <= ev_cap_t + hp[t]
if (
force_night_self_consume
or (not relaxed_expensive_import or night_self_consume_slot)
) and om == "AUTO":
prob += (
bd[t] + pv_ld[t]
>= float(s.load_baseline_w) + hp[t]
)
# Anti souběžný vývoz FVE + významný import (mikrocyklus).
if buy_t > sell_t + min_spread and pv_surplus_w > 0:
prob += ge_pv[t] <= pv_surplus_w
@@ -5191,6 +5268,10 @@ def solve_dispatch(
slots[i].interval_start.isoformat()
for i in sorted(degraded_relaxed_night_ts)
],
"degraded_evening_export_ts": [
slots[i].interval_start.isoformat()
for i in sorted(degraded_evening_export_ts)
],
},
"masks": masks_snap,
"soc_bounds": soc_bounds_snap,

View File

@@ -3698,8 +3698,8 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
self.assertLess(results[0].grid_setpoint_w, -500)
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č."""
def test_degraded_relaxed_solver_evening_to_reserve_and_night_self_consume(self) -> None:
"""relaxed_solver_masks: večer vývoz k ~20 %, 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]] = [
@@ -3723,7 +3723,10 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
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
if 8 <= local.hour < 11:
buy, sell, load, pv_a, pv_b = -0.4, -0.3, 800, 2000, 2500
else:
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(
@@ -3742,6 +3745,8 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
)
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
bat.max_charge_power_w = 18_000
bat.max_discharge_power_w = 18_000
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,
@@ -3753,7 +3758,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
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
soc = 0.56 * bat.soc_max_wh
results, _ms, snap = solve_dispatch(
slots,
bat,
@@ -3774,13 +3779,21 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
)
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")
self.assertGreater(len(inp.get("degraded_evening_export_ts") or []), 0)
evening_soc_end = min(r.battery_soc_target for r in results[:8])
self.assertLess(evening_soc_end, 55.0)
for i in range(8, 12):
if rows[i][0] > 3.0:
self.assertLessEqual(
results[i].grid_setpoint_w,
50,
msg=f"slot {i} should not import for baseload",
)
self.assertLess(
results[i].battery_setpoint_w,
-100,
msg=f"slot {i} should discharge for house",
)
def test_kv1_evening_push_profitable_vs_morning_zone_peak(self) -> None:
"""v52: KV1 večer ≥ ranní max (511) degrad; pod prahem ne."""

View File

@@ -17,13 +17,14 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
- 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`.
- **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`**.
- **v4 (`degraded-night-guard-v4`):** oprava v3 — `charge_slots=all` obcházela expensive-import guard → import za ~4 Kč. **Večer D0 (1722h):** vývoz k **`reserve_soc`** (`degraded_evening_export_ts` + shortfall). **Po 22h / půlnoc:** tvrdý **`bd ≥ load`** (`force_night_self_consume`) i když `t ∈ charge_slots`. Večerní export sloty **ne** sahají do 23h+ (jinak blokují noc).
**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`.
**Soubory:** `planning_engine.py`, `scripts/repro_home01_23840.py`, testy `test_home01_late_replan_high_soc_realistic_masks`, `test_degraded_relaxed_solver_evening_to_reserve_and_night_self_consume`.
**Ověření:**
- `PYTHONPATH=backend python3 scripts/repro_home01_23840.py``OK two_pass`
- `pytest backend/tests/test_planning_dispatch_milp.py -k "home01_late_replan or degraded_relaxed"`
- 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.
- Po deployi: aktivní run `planner_build_tag` končí **`degraded-night-guard-v4`**; při `relax_chain` obsahujícím `relaxed_solver_masks`: večer vývoz k ~**20 %**, noc **bez** importu pro baseload, ráno headroom na FVE + **`buy<0`**.
---