cileni k vybiti pred ranem kdy nabiju z fve
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-26 14:25:12 +02:00
parent d1ba864fc0
commit 398e658d16
4 changed files with 132 additions and 8 deletions

View File

@@ -71,11 +71,13 @@ 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-28-neg-prep-window-v36"
PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36b"
# 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 směrem k soc_need na začátku zítřejšího sell<0 okna.
NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 70.0
# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat).
NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
# Kotva: SoC na konci večera D1 ≤ reserve_soc (+ slack) před ranním sell<0 dnem D.
NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH = 4.0
# Před prvním sell<0: export FVE jen pokud predikce v sell<0 okně pokryje dobítí na prep cíl.
PRE_NEG_PV_EXPORT_FORECAST_MARGIN = 1.15
PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0
@@ -1248,6 +1250,42 @@ def _evening_discharge_before_neg_day_ts(
return out
def _neg_evening_reserve_soc_anchors(
slots: list[PlanningSlot],
neg_sell_day_meta: dict[str, Any],
battery: Any,
) -> list[tuple[int, float]]:
"""
Poslední večerní slot kalendářního dne D1 před dnem D s sell<0: cíl SoC ≤ reserve_soc.
Headroom pro ranní nabíjení z FVE / levného spotu v neg okně (ne držet 60 %+ přes noc).
"""
from datetime import timedelta
reserve_wh = float(
getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
)
out: list[tuple[int, float]] = []
for day_info in neg_sell_day_meta.get("days") or []:
first_neg = int(day_info.get("first_neg_idx", -1))
if first_neg < 0 or first_neg >= len(slots):
continue
neg_date = _prague_calendar_date(slots[first_neg])
prev_date = neg_date - timedelta(days=1)
anchors = [
t
for t, st in enumerate(slots)
if _prague_calendar_date(st) == prev_date
and (
17 <= _prague_hour(st) <= 23
or _in_night_battery_export_window(st)
)
]
if not anchors:
continue
out.append((max(anchors), reserve_wh))
return out
MORNING_PRENEG_START_HOUR = 5
MORNING_PRENEG_END_HOUR = 11
@@ -2043,6 +2081,7 @@ def solve_dispatch(
pre_neg_cushion_by_day: dict[str, bool] = {}
pre_neg_pv_export_ts: set[int] = set()
neg_evening_before_neg_ts: set[int] = set()
neg_evening_reserve_anchors: list[tuple[int, float]] = []
if om == "AUTO" and not purchase_fixed_pre and neg_sell_phases_en:
pre_neg_pv_export_ts, pre_neg_cushion_by_day = _pre_neg_pv_export_bundle(
slots,
@@ -2056,6 +2095,11 @@ def solve_dispatch(
slots,
neg_sell_day_meta,
)
neg_evening_reserve_anchors = _neg_evening_reserve_soc_anchors(
slots,
neg_sell_day_meta,
battery,
)
elif om == "AUTO" and not purchase_fixed_pre:
legacy_ok = bool(
first_neg_sell_idx is not None
@@ -2247,6 +2291,7 @@ def solve_dispatch(
pre_neg_pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
pre_neg_pv_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
neg_evening_before_neg_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
neg_evening_reserve_soc_slack: list[tuple[int, pulp.LpVariable, float]] = []
neg_sell_bat_dump_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
neg_sell_soc_underfill: list[tuple[int, pulp.LpVariable, float]] = []
neg_buy_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
@@ -2371,6 +2416,17 @@ def solve_dispatch(
export_cap_evening,
)
neg_evening_before_neg_shortfall.append((t_ev, sf_ev, export_cap_evening))
for t_anchor, reserve_tgt in neg_evening_reserve_anchors:
slack_cap = max(
0.0,
float(battery.soc_max_wh) - float(reserve_tgt),
)
sl = pulp.LpVariable(
f"neg_eve_reserve_soc_slack_{t_anchor}",
0,
slack_cap,
)
neg_evening_reserve_soc_slack.append((t_anchor, sl, float(reserve_tgt)))
for t in range(T):
if not post_neg_pv_topup[t]:
continue
@@ -2599,6 +2655,10 @@ def solve_dispatch(
/ 1000.0
for _t, sf, _cap in neg_evening_before_neg_shortfall
)
+ pulp.lpSum(
sl * NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH
for _t, sl, _tgt in neg_evening_reserve_soc_slack
)
+ pulp.lpSum(
bc_pv[t]
* PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH
@@ -2701,6 +2761,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_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(
float(battery.max_discharge_power_w)
* float(battery.discharge_efficiency)
@@ -3543,6 +3605,11 @@ def solve_dispatch(
"neg_evening_before_neg": (
t in neg_evening_before_neg_ts if neg_sell_phases_en else None
),
"neg_evening_reserve_anchor": (
any(t == ta for ta, _ in neg_evening_reserve_anchors)
if neg_sell_phases_en
else None
),
}
)
tgt_s = st.safety_soc_target_wh if daytime_en else None
@@ -3663,6 +3730,13 @@ def solve_dispatch(
slots[i].interval_start.isoformat()
for i in sorted(neg_evening_before_neg_ts)
],
"neg_evening_reserve_soc_anchors": [
{
"slot": slots[t_a].interval_start.isoformat(),
"target_reserve_soc_wh": float(tgt_wh),
}
for t_a, tgt_wh in neg_evening_reserve_anchors
],
"neg_sell_prep_window_v36": bool(neg_sell_phases_en),
"neg_sell_day_pv_usable_wh": (
_neg_sell_day_pv_usable_wh(