dalsi
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-29 23:24:03 +02:00
parent b73c3323e1
commit 308c24f029
5 changed files with 288 additions and 30 deletions

View File

@@ -71,7 +71,9 @@ 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-05-29-evening-push-budget-rank-v42"
PLANNER_BUILD_TAG = "2026-05-29-night-selfconsume-evening-arb-v43"
# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu).
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0
# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat).
@@ -1565,6 +1567,11 @@ def _in_night_battery_export_window(slot: PlanningSlot) -> bool:
return h <= NIGHT_EXPORT_MORNING_END_HOUR
def _in_evening_push_hour_window(slot: PlanningSlot) -> bool:
"""Tvrdý večerní push jen ≥17h Prague — ne noční vývoz ve 0206h (sell < buy)."""
return _prague_hour(slot) >= NIGHT_EXPORT_EVENING_START_HOUR
def _night_export_window_segments(slots: list[PlanningSlot]) -> list[list[int]]:
"""Souvislé úseky nočního okna v horizontu (oddělené denní pauzou / východem FVE)."""
segments: list[list[int]] = []
@@ -1648,12 +1655,17 @@ def _evening_push_segment_candidates(
*,
charge_acquisition_czk_kwh: float,
min_spread: float,
discharge_export_ok: set[int] | None = None,
) -> list[int]:
"""Profitable sloty v nočním úseku — výběr pořadí a strop dělá rozpočet Wh (sell desc)."""
if not seg:
return []
out: list[int] = []
for t in seg:
if discharge_export_ok is not None and t not in discharge_export_ok:
continue
if not _in_evening_push_hour_window(slots[t]):
continue
if not _slot_evening_push_profitable(
slots[t],
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
@@ -1664,6 +1676,44 @@ def _evening_push_segment_candidates(
return out
def _evening_push_calendar_segments(
slots: list[PlanningSlot],
discharge_export_ok: set[int] | None = None,
) -> list[list[int]]:
"""Kalendářní večery (≥17h) v nočním okně — každý den vlastní push rozpočet."""
by_date: dict[object, list[int]] = {}
for t, s in enumerate(slots):
if not _in_evening_push_hour_window(s):
continue
if not _in_night_battery_export_window(s):
continue
if discharge_export_ok is not None and t not in discharge_export_ok:
continue
by_date.setdefault(_prague_calendar_date(s), []).append(t)
return [sorted(v) for v in by_date.values() if v]
def _night_self_consume_discourage_import_indices(
slots: list[PlanningSlot],
evening_early_export_penalty_ts: set[int],
*,
charge_acquisition_czk_kwh: float,
min_spread: float,
) -> set[int]:
"""
Sloty mimo evening_push, kde import pro dům nahrazuje levnou zásobu z baterie.
"""
out: set[int] = set()
for t in evening_early_export_penalty_ts:
buy_t = float(slots[t].buy_price)
if buy_t <= float(charge_acquisition_czk_kwh) + float(min_spread):
continue
if float(slots[t].load_baseline_w) <= 0:
continue
out.add(t)
return out
def _evening_battery_export_push_indices(
slots: list[PlanningSlot],
*,
@@ -1674,12 +1724,13 @@ def _evening_battery_export_push_indices(
soc_max_wh: float,
per_slot_discharge_wh: float,
discharge_slot_buffer: float,
discharge_export_ok: set[int] | None = None,
evening_start_hour: int = 17,
) -> list[int]:
"""
Noční push: plný ge_bat v tolika nejdražších slotách (sell desc v rámci úseku),
kolik unese Wh rozpočet — ne jen jeden slot s exact max sell (v41).
per_slot_discharge_wh: volající předá min(BMS, export cap) × účinnost × 0,25 h.
Večerní push (≥17h): plný ge_bat v nejdražších slotách (sell desc), rozpočet Wh
**per kalendářní večer** — druhý den v horizontu nedostane nulový push (v42 bug).
per_slot_discharge_wh: min(BMS, export cap) × účinnost × 0,25 h.
"""
_ = evening_start_hour # kompatibilita volání
if per_slot_discharge_wh <= 0.0:
@@ -1692,14 +1743,21 @@ def _evening_battery_export_push_indices(
)
if push_budget_wh < per_slot_discharge_wh * 0.5:
return []
evening_segments = _evening_push_calendar_segments(
slots,
discharge_export_ok=discharge_export_ok,
)
if not evening_segments:
return []
seg_budget_wh = push_budget_wh / float(len(evening_segments))
out: list[int] = []
remaining_wh = float(push_budget_wh)
for seg in _night_export_window_segments(slots):
for seg in evening_segments:
candidates = _evening_push_segment_candidates(
slots,
seg,
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=degrad_czk_kwh,
discharge_export_ok=discharge_export_ok,
)
if not candidates:
continue
@@ -1708,6 +1766,7 @@ def _evening_battery_export_push_indices(
key=lambda i: (float(slots[i].sell_price), -i),
reverse=True,
)
remaining_wh = float(seg_budget_wh)
for t in ranked:
if remaining_wh + 1e-6 < per_slot_discharge_wh:
break
@@ -2400,6 +2459,7 @@ def solve_dispatch(
profitable_export_ts_pre.add(_t)
evening_push_ts: set[int] = set()
evening_early_export_penalty_ts: set[int] = set()
night_self_consume_discourage_ts: set[int] = set()
evening_push_hysteresis_retained = False
if om == "AUTO":
per_slot_discharge_wh_pre = max(
@@ -2424,6 +2484,7 @@ def solve_dispatch(
soc_max_wh=float(battery.soc_max_wh),
per_slot_discharge_wh=per_slot_push_wh_pre,
discharge_slot_buffer=discharge_buf_pre,
discharge_export_ok=discharge_export_slots,
)
)
if evening_push_ts_override is not None:
@@ -2453,6 +2514,12 @@ def solve_dispatch(
evening_push_ts=evening_push_ts,
exempt_ts=evening_export_exempt_ts,
)
night_self_consume_discourage_ts = _night_self_consume_discourage_import_indices(
slots,
evening_early_export_penalty_ts,
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=float(degradation_cost_effective),
)
pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh(
slots,
first_neg_buy_idx=first_neg_buy_idx,
@@ -2842,6 +2909,20 @@ def solve_dispatch(
)
else 0
)
+ (
gi[t]
* max(
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH,
max(
0.0,
float(slots[t].buy_price) - charge_acquisition_czk_kwh,
),
)
* INTERVAL_H
/ 1000
if om == "AUTO" and t in night_self_consume_discourage_ts
else 0
)
+ pulp.lpSum(
ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000
+ ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000
@@ -3946,6 +4027,9 @@ def solve_dispatch(
"evening_early_export_ban": (
t in evening_early_export_penalty_ts if om == "AUTO" else None
),
"night_self_consume_discourage_import": (
t in night_self_consume_discourage_ts if om == "AUTO" else None
),
}
)
tgt_s = st.safety_soc_target_wh if daytime_en else None
@@ -4114,6 +4198,10 @@ def solve_dispatch(
else _evening_night_peak_sell_czk(slots)
),
"evening_push_hysteresis_retained": bool(evening_push_hysteresis_retained),
"night_self_consume_discourage_ts": [
slots[i].interval_start.isoformat()
for i in sorted(night_self_consume_discourage_ts)
],
},
"masks": masks_snap,
"soc_bounds": soc_bounds_snap,