uprava aby rano prodaval do site pred sell < 0 oknem
This commit is contained in:
@@ -71,7 +71,12 @@ 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-sell-soc-phases-v32"
|
||||
PLANNER_BUILD_TAG = "2026-05-28-pre-neg-pv-export-forecast-v33"
|
||||
# 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
|
||||
PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 55.0
|
||||
PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH = 90.0
|
||||
POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.30
|
||||
PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH = 0.25
|
||||
PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
@@ -860,6 +865,90 @@ def _neg_sell_day_phases(
|
||||
return phases, soc_targets, shortfall_weights
|
||||
|
||||
|
||||
def _neg_sell_day_pv_usable_wh(
|
||||
slots: list[PlanningSlot],
|
||||
first_neg_sell_idx: int | None,
|
||||
*,
|
||||
max_charge_power_w: float,
|
||||
charge_efficiency: float,
|
||||
) -> float:
|
||||
"""
|
||||
Odhad Wh nabitelné z FVE v sell<0 slotech téhož pražského dne (forecast surplus × cap nabíjení).
|
||||
"""
|
||||
if first_neg_sell_idx is None:
|
||||
return 0.0
|
||||
neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
|
||||
total_wh = 0.0
|
||||
for s in slots:
|
||||
if _prague_calendar_date(s) != neg_day:
|
||||
continue
|
||||
if float(s.sell_price) >= 0.0:
|
||||
continue
|
||||
pv_surplus_w = max(
|
||||
0.0,
|
||||
float(s.pv_a_forecast_w)
|
||||
+ float(s.pv_b_forecast_w)
|
||||
- float(s.load_baseline_w),
|
||||
)
|
||||
if pv_surplus_w <= 500.0:
|
||||
continue
|
||||
cap_w = min(pv_surplus_w, float(max_charge_power_w))
|
||||
total_wh += cap_w * INTERVAL_H * float(charge_efficiency)
|
||||
return total_wh
|
||||
|
||||
|
||||
def _pre_neg_pv_export_forecast_cushion_ok(
|
||||
slots: list[PlanningSlot],
|
||||
battery: Any,
|
||||
current_soc_wh: float,
|
||||
first_neg_sell_idx: int | None,
|
||||
*,
|
||||
neg_sell_phases_en: bool,
|
||||
) -> bool:
|
||||
"""
|
||||
Export FVE před sell<0 jen pokud forecast v záporném okně pokryje dobítí na cíl (typ. 80 %).
|
||||
Jinak raději nabíjet teď — riziko deště / podhodnocené FVE v sell<0.
|
||||
"""
|
||||
if first_neg_sell_idx is None or first_neg_sell_idx <= 0:
|
||||
return False
|
||||
prep_pct = float(getattr(battery, "planner_neg_sell_prep_soc_percent", 100.0))
|
||||
if neg_sell_phases_en and prep_pct < 100.0 - 1e-6:
|
||||
target_wh = prep_pct / 100.0 * float(battery.soc_max_wh)
|
||||
else:
|
||||
target_wh = float(battery.soc_max_wh)
|
||||
needed_wh = max(0.0, target_wh - float(current_soc_wh))
|
||||
if needed_wh < PRE_NEG_PV_EXPORT_MIN_NEEDED_WH:
|
||||
return True
|
||||
usable_wh = _neg_sell_day_pv_usable_wh(
|
||||
slots,
|
||||
first_neg_sell_idx,
|
||||
max_charge_power_w=float(battery.max_charge_power_w),
|
||||
charge_efficiency=float(battery.charge_efficiency),
|
||||
)
|
||||
return usable_wh >= needed_wh * PRE_NEG_PV_EXPORT_FORECAST_MARGIN
|
||||
|
||||
|
||||
def _pre_neg_pv_export_slot_indices(
|
||||
slots: list[PlanningSlot],
|
||||
first_neg_sell_idx: int | None,
|
||||
pre_neg_export_last_t: int | None,
|
||||
first_neg_buy_idx: int | None,
|
||||
) -> set[int]:
|
||||
"""Sloty s kladným sell před prvním sell<0 (a před buy<0), PV přebytek)."""
|
||||
if first_neg_sell_idx is None or pre_neg_export_last_t is None:
|
||||
return set()
|
||||
out: set[int] = set()
|
||||
for t in range(pre_neg_export_last_t + 1):
|
||||
if float(slots[t].sell_price) < 0.0:
|
||||
continue
|
||||
if first_neg_buy_idx is not None and t >= first_neg_buy_idx:
|
||||
continue
|
||||
if _slot_pv_surplus_w(slots[t]) <= NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W:
|
||||
continue
|
||||
out.add(t)
|
||||
return out
|
||||
|
||||
|
||||
MORNING_PRENEG_START_HOUR = 5
|
||||
MORNING_PRENEG_END_HOUR = 11
|
||||
|
||||
@@ -1646,6 +1735,29 @@ def solve_dispatch(
|
||||
prep_hold_bcpv_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
prep_hold_curtail_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
prep_hold_met_binary: dict[int, pulp.LpVariable] = {}
|
||||
pre_neg_pv_export_forecast_ok = bool(
|
||||
om == "AUTO"
|
||||
and not purchase_fixed_pre
|
||||
and first_neg_sell_idx is not None
|
||||
and pre_neg_export_last_t is not None
|
||||
and _pre_neg_pv_export_forecast_cushion_ok(
|
||||
slots,
|
||||
battery,
|
||||
current_soc_wh,
|
||||
first_neg_sell_idx,
|
||||
neg_sell_phases_en=neg_sell_phases_en,
|
||||
)
|
||||
)
|
||||
pre_neg_pv_export_ts = (
|
||||
_pre_neg_pv_export_slot_indices(
|
||||
slots,
|
||||
first_neg_sell_idx,
|
||||
pre_neg_export_last_t,
|
||||
first_neg_buy_idx,
|
||||
)
|
||||
if pre_neg_pv_export_forecast_ok
|
||||
else set()
|
||||
)
|
||||
pre_neg_buy_discharge_ts: set[int] = set()
|
||||
if om == "AUTO" and first_neg_buy_idx is not None and first_neg_buy_idx > 0:
|
||||
pre_neg_buy_discharge_ts = _pre_neg_buy_discharge_indices(
|
||||
@@ -1815,6 +1927,7 @@ def solve_dispatch(
|
||||
peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
pre_neg_pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
pre_neg_pv_export_shortfall: 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]] = []
|
||||
@@ -1904,6 +2017,25 @@ def solve_dispatch(
|
||||
cap_ns = float(min(pv_surplus_ns, battery.max_charge_power_w))
|
||||
sf_ns = pulp.LpVariable(f"neg_phase_pv_charge_{t_ns}", 0, cap_ns)
|
||||
pv_charge_shortfall.append((t_ns, sf_ns, cap_ns))
|
||||
if pre_neg_pv_export_forecast_ok:
|
||||
for t_pe in sorted(pre_neg_pv_export_ts):
|
||||
s_pe = slots[t_pe]
|
||||
pv_surplus_pe = max(
|
||||
0.0,
|
||||
float(s_pe.pv_a_forecast_w)
|
||||
+ float(s_pe.pv_b_forecast_w)
|
||||
- float(s_pe.load_baseline_w),
|
||||
)
|
||||
cap_pe = float(
|
||||
min(
|
||||
pv_surplus_pe,
|
||||
float(grid.max_export_power_w),
|
||||
)
|
||||
)
|
||||
if cap_pe <= 500.0:
|
||||
continue
|
||||
sf_pe = pulp.LpVariable(f"pre_neg_pv_export_sf_{t_pe}", 0, cap_pe)
|
||||
pre_neg_pv_export_shortfall.append((t_pe, sf_pe, cap_pe))
|
||||
for t in range(T):
|
||||
if not post_neg_pv_topup[t]:
|
||||
continue
|
||||
@@ -2122,6 +2254,17 @@ def solve_dispatch(
|
||||
sf * NEG_SELL_CURTAIL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
|
||||
for _t, sf, _cap in prep_hold_curtail_shortfall
|
||||
)
|
||||
+ pulp.lpSum(
|
||||
sf * PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
|
||||
for _t, sf, _cap in pre_neg_pv_export_shortfall
|
||||
)
|
||||
+ pulp.lpSum(
|
||||
bc_pv[t]
|
||||
* PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH
|
||||
* INTERVAL_H
|
||||
/ 1000.0
|
||||
for t in pre_neg_pv_export_ts
|
||||
)
|
||||
+ pulp.lpSum(
|
||||
sf * NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
|
||||
for _t, sf, _cap in neg_sell_bat_dump_shortfall
|
||||
@@ -2211,6 +2354,8 @@ def solve_dispatch(
|
||||
prob += sf >= cap_w - ge_bat[t_sf]
|
||||
for t_sf, sf, cap_w in pre_neg_pv_charge_shortfall:
|
||||
prob += sf >= cap_w - bc_pv[t_sf]
|
||||
for t_sf, sf, cap_w in pre_neg_pv_export_shortfall:
|
||||
prob += sf >= cap_w - ge_pv[t_sf]
|
||||
preneg_export_min_soc_wh = float(min_soc_wh) + max(
|
||||
float(battery.max_discharge_power_w)
|
||||
* float(battery.discharge_efficiency)
|
||||
@@ -2662,6 +2807,9 @@ def solve_dispatch(
|
||||
):
|
||||
prob += ge_bat[t] == 0
|
||||
prob += z_export[t] == 0
|
||||
for t_pne in pre_neg_pv_export_ts:
|
||||
# v33: při dostatečné FVE v sell<0 okně neukládat ranní PV do baterie — export.
|
||||
prob += bc_pv[t_pne] == 0
|
||||
|
||||
# Ekonomické guardy: ceny v objective nestačí proti maskám / terminal SoC.
|
||||
# Referenční buy jen z ne-záporných slotů: jinak jeden buy<0 v horizontu označí
|
||||
@@ -2689,18 +2837,8 @@ def solve_dispatch(
|
||||
0.0,
|
||||
float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - load_t,
|
||||
)
|
||||
# FVE export: před prvním sell<0 smí jít přebytek do sítě (kladný sell), pak nabít
|
||||
# v záporném okně z PV. Jinak držet energii na future_sell peak.
|
||||
allow_pre_neg_pv_export = (
|
||||
first_neg_sell_idx is not None
|
||||
and pre_neg_export_last_t is not None
|
||||
and t <= pre_neg_export_last_t
|
||||
and sell_t >= 0
|
||||
and (
|
||||
first_neg_buy_idx is None
|
||||
or t < first_neg_buy_idx
|
||||
)
|
||||
)
|
||||
# FVE export před sell<0 jen pokud forecast v sell<0 okně pokryje dobítí (v33).
|
||||
allow_pre_neg_pv_export = t in pre_neg_pv_export_ts
|
||||
pv_store_val = _pv_store_value_czk_kwh(s, min_spread)
|
||||
skip_pv_store_block = (
|
||||
float(s.pv_b_forecast_w) > 0
|
||||
@@ -3124,6 +3262,20 @@ def solve_dispatch(
|
||||
),
|
||||
},
|
||||
"neg_sell_phases_enabled": bool(neg_sell_phases_en),
|
||||
"pre_neg_pv_export_forecast_ok": bool(pre_neg_pv_export_forecast_ok),
|
||||
"pre_neg_pv_export_slots": [
|
||||
slots[i].interval_start.isoformat() for i in sorted(pre_neg_pv_export_ts)
|
||||
],
|
||||
"neg_sell_day_pv_usable_wh": (
|
||||
_neg_sell_day_pv_usable_wh(
|
||||
slots,
|
||||
first_neg_sell_idx,
|
||||
max_charge_power_w=float(battery.max_charge_power_w),
|
||||
charge_efficiency=float(battery.charge_efficiency),
|
||||
)
|
||||
if first_neg_sell_idx is not None
|
||||
else None
|
||||
),
|
||||
"load_first_enabled": om == "AUTO",
|
||||
"relaxed_expensive_import": relaxed_expensive_import,
|
||||
"charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh,
|
||||
|
||||
Reference in New Issue
Block a user