uprava PV omeznovani
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-25 11:08:01 +02:00
parent f1a4dbd7e7
commit e06f76b9ff
8 changed files with 439 additions and 91 deletions

View File

@@ -66,6 +66,32 @@ class _DictRecord:
return k in self._d
def plan_skips_deye_reg340_write(
*,
battery_setpoint_w: int,
grid_setpoint_w: int,
export_mode: str | None,
export_limit_w: int,
pv_a_curtailed_w: int,
) -> bool:
"""
Nezapisovat reg 340: plán neexportuje, nenabíjí baterii a neškrtí pole A.
Deye sám řídí PV A přes 108/109/142 (zero export + 0 A nabíjení).
"""
em = (export_mode or "").strip().upper()
if em == "NONE":
no_export = True
elif int(grid_setpoint_w) < 0 or int(export_limit_w) > 0:
no_export = False
else:
no_export = True
return (
no_export
and int(battery_setpoint_w) <= 0
and int(pv_a_curtailed_w) <= 0
)
def _build_setpoints(
mode: OperatingModeInfo,
pi: Any | None,
@@ -102,11 +128,11 @@ def _build_setpoints(
export_ban = sell_f is not None and float(sell_f) < 0 and grid_sp >= 0
gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled")
gen_cutoff = bool(gen_cutoff_raw) if gen_cutoff_raw is not None else False
bat_w = int(pi["battery_setpoint_w"] or 0)
pv_a_allowed: int | None = None
if bool(reg340_pv_a_control_enabled) and int(pv_a_cap_w) > 0:
forecast = int(pi.get("pv_a_forecast_solver_w") or 0)
curtail = int(pi.get("pv_a_curtailed_w") or 0)
pv_a_allowed = compute_pv_a_reg340_max_solar_w(int(pv_a_cap_w), forecast, curtail)
buy_raw = pi.get("effective_buy_price")
buy_f: float | None = float(buy_raw) if buy_raw is not None else None
pv_b = int(pi.get("pv_b_forecast_solver_w") or 0)
@@ -118,8 +144,20 @@ def _build_setpoints(
and pv_b > 0
):
pv_a_allowed = 0
elif plan_skips_deye_reg340_write(
battery_setpoint_w=bat_w,
grid_setpoint_w=grid_sp,
export_mode=export_mode,
export_limit_w=max(0, export_limit),
pv_a_curtailed_w=curtail,
):
pv_a_allowed = None
else:
pv_a_allowed = compute_pv_a_reg340_max_solar_w(
int(pv_a_cap_w), forecast, curtail
)
return ControlSetpoints(
battery_w=int(pi["battery_setpoint_w"] or 0),
battery_w=bat_w,
grid_export_limit=max(0, export_limit),
ev1_current_a=watts_to_amps(ev1_w, phases=3),
ev2_current_a=watts_to_amps(ev2_w, phases=1),

View File

@@ -68,7 +68,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-evening-push-dynamic-budget-v24"
PLANNER_BUILD_TAG = "2026-05-28-pre-neg-buy-soc-phases-v25"
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
# buy<0: preferovat import před PV A→bat (měkké; tvrdé bc_pv=0 láme bilanci s polem B).
PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH = 250.0
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
@@ -1003,6 +1008,99 @@ def _evening_battery_export_push_indices(
return sorted(out)
def _last_non_negative_sell_before_neg_buy(
slots: list[PlanningSlot],
first_neg_buy_idx: int | None,
) -> int | None:
if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
return None
candidates = [
i for i in range(first_neg_buy_idx) if float(slots[i].sell_price) >= 0.0
]
return max(candidates) if candidates else None
def _positive_sell_pre_neg_buy_indices(
slots: list[PlanningSlot],
first_neg_buy_idx: int | None,
) -> list[int]:
if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
return []
return [
t
for t in range(first_neg_buy_idx)
if float(slots[t].sell_price) >= 0.0
]
def _pre_neg_buy_empty_discharge_indices(
slots: list[PlanningSlot],
first_neg_buy_idx: int | None,
last_pos_sell_idx: int | None,
) -> list[int]:
"""Sloty mezi posledním sell≥0 a prvním buy<0 — vyprázdnit před levným importem."""
if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
return []
if last_pos_sell_idx is None:
return []
start = last_pos_sell_idx + 1
end = first_neg_buy_idx - 1
if start > end:
return []
return list(range(start, end + 1))
def _pre_neg_buy_soc_ceiling_wh(
slots: list[PlanningSlot],
*,
first_neg_buy_idx: int | None,
min_soc_wh: float,
soc_max_wh: float,
max_charge_w: float,
charge_eff: float,
evening_start_hour: int = 17,
) -> float | None:
"""
Horní SoC těsně před prvním buy<0: pod soc_max musí vejít import v buy<0,
PV B v tom okně a rezerva na odpolední sell<0 (stejný den, před večerem).
"""
if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
return None
per_slot_chg = max(0.0, float(max_charge_w) * float(charge_eff) * INTERVAL_H)
neg_buy_ts = [t for t, s in enumerate(slots) if float(s.buy_price) < 0.0]
if not neg_buy_ts:
return None
last_neg_buy = max(neg_buy_ts)
neg_day = _prague_calendar_date(slots[first_neg_buy_idx])
grid_wh = len(neg_buy_ts) * per_slot_chg
pv_b_wh = 0.0
for t in neg_buy_ts:
s = slots[t]
sur = max(
0.0,
float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - float(s.load_baseline_w),
)
pv_b_wh += min(sur, float(max_charge_w)) * float(charge_eff) * INTERVAL_H
post_wh = 0.0
for t in range(last_neg_buy + 1, len(slots)):
s = slots[t]
if _prague_calendar_date(s) != neg_day:
continue
if float(s.buy_price) < 0.0:
continue
if float(s.sell_price) >= 0.0:
break
if _prague_hour(s) >= evening_start_hour:
break
sur = max(0.0, float(s.pv_b_forecast_w) - float(s.load_baseline_w) * 0.25)
post_wh += min(sur, float(max_charge_w)) * float(charge_eff) * INTERVAL_H
buffer_wh = max(per_slot_chg * 2.0, 3000.0)
needed = grid_wh + pv_b_wh + post_wh + buffer_wh
ceiling = float(soc_max_wh) - needed
floor = float(min_soc_wh) + max(per_slot_chg, 1000.0)
return max(floor, min(float(soc_max_wh) - per_slot_chg, ceiling))
def _planner_soc_for_solver(
current_soc_wh: float,
battery,
@@ -1243,8 +1341,6 @@ def solve_dispatch(
ca = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)]
hp = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)]
soc_deficit_24h = pulp.LpVariable("soc_deficit_24h", 0, battery.usable_capacity_wh)
soc_anchor_slack = None
t_anchor = None
# GEN port cut-off (BA81): binární proměnná pouze pokud je feature povolená v konfiguraci site/invertoru.
gen_cutoff_enabled = bool(getattr(grid, "deye_gen_microinverter_cutoff_enabled", False))
@@ -1338,6 +1434,9 @@ def solve_dispatch(
(t for t, s in enumerate(slots) if float(s.buy_price) < 0.0),
None,
)
neg_buy_slot_indices_pre = [
t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
]
last_neg_sell_by_prague_date: dict[object, int] = {}
for t_ln, st_ln in enumerate(slots):
if float(st_ln.sell_price) < 0:
@@ -1392,16 +1491,43 @@ def solve_dispatch(
fixed_tariff=fixed_tariff_like_pre,
):
profitable_export_ts_pre.add(_t)
if first_neg_sell_idx is not None and first_neg_sell_idx > 0 and floor_pct is not None:
# Kotva na ranním peaku (ne na posledním slotu před sell<0) — jinak dump až v 07:30.
if (
t_pre_neg_peak is not None
and t_pre_neg_peak < first_neg_sell_idx - 1
):
t_anchor = t_pre_neg_peak
else:
t_anchor = first_neg_sell_idx - 1
soc_anchor_slack = pulp.LpVariable("soc_anchor_slack_wh", 0, float(battery.usable_capacity_wh))
last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy(
slots, first_neg_buy_idx
)
pos_sell_pre_neg_buy_ts = _positive_sell_pre_neg_buy_indices(
slots, first_neg_buy_idx
)
pre_neg_buy_empty_ts = _pre_neg_buy_empty_discharge_indices(
slots, first_neg_buy_idx, last_pos_sell_pre_neg_buy
)
pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh(
slots,
first_neg_buy_idx=first_neg_buy_idx,
min_soc_wh=float(min_soc_wh),
soc_max_wh=float(battery.soc_max_wh),
max_charge_w=float(battery.max_charge_power_w),
charge_eff=float(battery.charge_efficiency),
)
t_pre_neg_buy_anchor: int | None = (
first_neg_buy_idx - 1 if first_neg_buy_idx is not None and first_neg_buy_idx > 0 else None
)
soc_pre_neg_buy_ceiling_slack: pulp.LpVariable | None = None
if (
t_pre_neg_buy_anchor is not None
and pre_neg_buy_soc_ceiling_wh is not None
):
soc_pre_neg_buy_ceiling_slack = pulp.LpVariable(
"soc_pre_neg_buy_ceiling_slack_wh",
0,
float(battery.usable_capacity_wh),
)
pos_sell_soc_shortfall: pulp.LpVariable | None = None
if last_pos_sell_pre_neg_buy is not None:
pos_sell_soc_shortfall = pulp.LpVariable(
"pos_sell_pre_neg_soc_shortfall_wh",
0,
float(battery.usable_capacity_wh),
)
daytime_en = bool(getattr(battery, "planner_daytime_charge_target_enabled", True))
safety_pen_czk_per_wh: list[float] = []
@@ -1466,10 +1592,12 @@ 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]] = []
neg_sell_bat_dump_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
neg_sell_soc_underfill: list[tuple[int, pulp.LpVariable]] = []
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]] = []
fixed_tariff_like = fixed_tariff_like_pre
block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False))
if om == "AUTO":
@@ -1493,12 +1621,15 @@ def solve_dispatch(
for t_pnd in sorted(pre_neg_buy_discharge_ts):
sf_pnd = pulp.LpVariable(f"pre_neg_bat_export_sf_{t_pnd}", 0, export_cap_w)
pre_neg_batt_export_shortfall.append((t_pnd, sf_pnd, export_cap_w))
for t_empty in pre_neg_buy_empty_ts:
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 not relaxed_neg_buy_charge:
neg_buy_slot_indices = [
t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
]
if neg_buy_slot_indices:
t_nb_last = max(neg_buy_slot_indices)
t_nb_last = max(neg_buy_slot_indices_pre)
cap_w = float(battery.max_charge_power_w)
sf_nb = pulp.LpVariable(f"neg_buy_charge_sf_{t_nb_last}", 0, cap_w)
neg_buy_charge_shortfall.append((t_nb_last, sf_nb, cap_w))
@@ -1539,15 +1670,22 @@ def solve_dispatch(
cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
sf_pv = pulp.LpVariable(f"post_neg_pv_shortfall_{t}", 0, cap_w)
pv_charge_shortfall.append((t, sf_pv, cap_w))
if len(neg_buy_slot_indices_pre) >= 2:
t_nb_last = max(neg_buy_slot_indices_pre)
if t_nb_last in charge_slots or relaxed_neg_buy_charge:
us = pulp.LpVariable(
f"neg_buy_soc_under_{t_nb_last}",
0,
float(battery.usable_capacity_wh),
)
neg_sell_soc_underfill.append((t_nb_last, us))
for t in range(T):
if float(slots[t].sell_price) >= 0:
if first_neg_buy_idx is None or t >= first_neg_buy_idx:
continue
if float(slots[t].sell_price) >= 0.0:
continue
if float(slots[t].buy_price) < 0.0:
continue
if t not in charge_slots:
continue
if first_neg_buy_idx is not None and t < first_neg_buy_idx:
continue
pv_surplus_w = max(
0.0,
float(slots[t].pv_a_forecast_w)
@@ -1556,12 +1694,9 @@ def solve_dispatch(
)
if pv_surplus_w <= 500:
continue
us = pulp.LpVariable(
f"neg_soc_under_{t}",
0,
float(battery.usable_capacity_wh),
)
neg_sell_soc_underfill.append((t, us))
cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
sf_m = pulp.LpVariable(f"pre_neg_pv_charge_sf_{t}", 0, cap_w)
pre_neg_pv_charge_shortfall.append((t, sf_m, cap_w))
for t in neg_sell_bat_dump_slots:
dump_target_w = _battery_export_cap_w(battery, grid)
sf_dump = pulp.LpVariable(f"neg_bat_dump_shortfall_{t}", 0, dump_target_w)
@@ -1631,26 +1766,24 @@ def solve_dispatch(
NEG_SELL_CURTAIL_PENALTY_CZK_KWH
if (
om == "AUTO"
and float(slots[t].sell_price) < 0.0
and float(slots[t].buy_price) < 0.0
and t in charge_slots
)
else (
0.0
if (
has_pv_b
and future_neg_buy_from[t]
and float(slots[t].sell_price) < 0.0
)
else CURTAILMENT_PENALTY
)
else CURTAILMENT_PENALTY
)
for t in range(T)
)
+ soc_deficit_24h * soc_deficit_penalty_czk_kwh / 1000
- terminal_soc_kcz_per_wh * soc[T - 1]
+ (
soc_anchor_slack * PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH
if soc_anchor_slack is not None
pos_sell_soc_shortfall * POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH
if pos_sell_soc_shortfall is not None
else 0
)
+ (
soc_pre_neg_buy_ceiling_slack
* PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH
if soc_pre_neg_buy_ceiling_slack is not None
else 0
)
+ pulp.lpSum(
@@ -1684,7 +1817,7 @@ def solve_dispatch(
for _t, sf, _cap in pre_neg_batt_export_shortfall
)
+ pulp.lpSum(
(bc_pv[t] + bc_gi[t])
bc_gi[t]
* PRE_NEG_CHARGE_PENALTY_CZK_KWH
* INTERVAL_H
/ 1000.0
@@ -1695,6 +1828,22 @@ def solve_dispatch(
and float(slots[t].buy_price) >= 0.0
)
)
+ pulp.lpSum(
bc_pv[t]
* PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH
* INTERVAL_H
/ 1000.0
for t in range(T)
if float(slots[t].buy_price) < 0.0
)
+ pulp.lpSum(
sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in pre_neg_pv_charge_shortfall
)
+ pulp.lpSum(
sf * PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in pre_neg_buy_empty_shortfall
)
+ pulp.lpSum(
-25.0 * z_export[t]
for t in range(T)
@@ -1712,9 +1861,14 @@ def solve_dispatch(
for t_us, us in neg_sell_soc_underfill:
prob += us >= float(battery.soc_max_wh) - soc[t_us]
for t_sf, sf, cap_w in neg_buy_charge_shortfall:
prob += sf >= cap_w - (bc_gi[t_sf] + bc_pv[t_sf])
# buy<0: bc_pv=0 (import arbitráž); shortfall jen na grid→bat.
prob += sf >= cap_w - bc_gi[t_sf]
for t_sf, sf, cap_w in pre_neg_batt_export_shortfall:
prob += sf >= cap_w - ge_bat[t_sf]
for t_sf, sf, cap_w in pre_neg_buy_empty_shortfall:
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]
preneg_export_min_soc_wh = float(min_soc_wh) + max(
float(battery.max_discharge_power_w)
* float(battery.discharge_efficiency)
@@ -1735,6 +1889,9 @@ def solve_dispatch(
prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak]
for t_pnd in pre_neg_buy_discharge_ts:
prob += ge_bat[t_pnd] >= export_push_w * z_export[t_pnd]
for t_empty in pre_neg_buy_empty_ts:
if t_empty in discharge_export_slots:
prob += ge_bat[t_empty] >= export_push_w * z_export[t_empty]
discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
evening_push_ts = _evening_battery_export_push_indices(
slots,
@@ -1751,9 +1908,24 @@ def solve_dispatch(
continue
prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak]
# Ostatní profitable sloty: shortfall penalizace (ne tvrdý push na celý horizont).
if t_anchor is not None and soc_anchor_slack is not None:
target_floor_wh = float(planner_floor_effective_wh)
prob += soc[t_anchor] <= target_floor_wh + soc_anchor_slack
if (
last_pos_sell_pre_neg_buy is not None
and pos_sell_soc_shortfall is not None
):
prob += (
soc[last_pos_sell_pre_neg_buy]
>= float(battery.soc_max_wh) - pos_sell_soc_shortfall
)
if (
t_pre_neg_buy_anchor is not None
and pre_neg_buy_soc_ceiling_wh is not None
and soc_pre_neg_buy_ceiling_slack is not None
and last_pos_sell_pre_neg_buy is not None
):
prob += (
soc[t_pre_neg_buy_anchor]
<= float(pre_neg_buy_soc_ceiling_wh) + soc_pre_neg_buy_ceiling_slack
)
for t in range(T):
s = slots[t]
@@ -1832,11 +2004,11 @@ def solve_dispatch(
eff_tgt_s = float(tgt_s) if tgt_s is not None else float(min_soc_wh)
if (
om == "AUTO"
and float(s.sell_price) < 0.0
and float(s.buy_price) < 0.0
and t in charge_slots
and (first_neg_buy_idx is None or t >= first_neg_buy_idx)
and len(neg_buy_slot_indices_pre) >= 2
):
# Záporný výkup: dobít na planner soc_max (typicky 95100 %), ne jen SQL safety ~50 %.
# buy<0: cíl soc_max jen při víceslotovém okně (jinak fyzicky neřešitelné).
eff_tgt_s = max(eff_tgt_s, float(battery.soc_max_wh))
elif post_neg_pv_topup[t]:
# Po konci sell<0: dobit z FVE na plno, pak teprve export (kladný sell, ne večerní peak).
@@ -1870,6 +2042,8 @@ def solve_dispatch(
prob += ge[t] == 0
prob += ge_pv[t] == 0
prob += ge_bat[t] == 0
# PV A: měkký tlak curtail (NEG_SELL_CURTAIL při buy<0), ne tvrdé bc_pv=0
# (s polem B a bilancí může být bc_pv=0 nutné pro řešitelnost krátkých okének).
# Záporný prodej (sell < 0): výboj baterie jen před extrémně záporným buy (v11).
# Export FVE při sell<0: spot = nabíjení/curtail A; ventil jen pole B při plné baterii.
@@ -1924,13 +2098,14 @@ def solve_dispatch(
prob += ge[t] == 0
prob += ge_pv[t] == 0
elif not purchase_fixed_pre:
# Spot (home-01): ge_pv=0 dokud není plná baterie; pak jen ventil pole B (ne celý surplus).
# Před buy<0 + bc_pv=0: přebytek pole B musí jít do sítě (ge_pv≤pv_b), jinak Infeasible.
# Spot: sell<0 před buy<0 — PV (A) do baterie, B může jít do sítě (ge_pv≤pv_b).
# Po buy<0 / mimo ranní pásmo: ventil B jen při plné baterii.
before_first_neg_buy = (
first_neg_buy_idx is not None and t < first_neg_buy_idx
)
if before_first_neg_buy and float(s.pv_b_forecast_w) > 0:
prob += ge_pv[t] <= float(s.pv_b_forecast_w)
if before_first_neg_buy:
if float(s.pv_b_forecast_w) > 0:
prob += ge_pv[t] <= float(s.pv_b_forecast_w)
else:
soc_prev_neg = current_soc_wh if t == 0 else soc[t - 1]
w_pv_b_vent = pulp.LpVariable(
@@ -2005,6 +2180,8 @@ def solve_dispatch(
export_soc_floor_t = float(planner_floor_effective_wh)
elif om == "AUTO" and t in pre_neg_buy_discharge_ts:
export_soc_floor_t = float(min_soc_wh)
elif om == "AUTO" and t in pre_neg_buy_empty_ts:
export_soc_floor_t = float(min_soc_wh)
elif (
om == "AUTO"
and t in morning_pre_neg_export_ts
@@ -2093,23 +2270,29 @@ def solve_dispatch(
and float(s.buy_price) >= 0.0
):
prob += bc_gi[t] == 0
before_neg_buy = (
first_neg_buy_idx is not None and t < first_neg_buy_idx
)
if before_neg_buy and sell_t_pre < 0.0 and pv_surplus_w > 0:
# Ranní sell<0 před buy<0: PV do sítě/curtail, ne do baterie (kapacita na import).
prob += bc_pv[t] == 0
if float(s.buy_price) < 0.0:
pass
elif (
first_neg_buy_idx is not None
and first_neg_buy_idx > 0
and t in pos_sell_pre_neg_buy_ts
):
prob += ge[t] == 0
prob += ge_pv[t] == 0
prob += ge_bat[t] == 0
elif t not in charge_slots:
if float(s.buy_price) >= 0.0:
prob += bc_gi[t] == 0
if pv_surplus_w <= 0:
prob += bc_pv[t] == 0
else:
prob += bc_pv[t] <= float(pv_surplus_w)
if float(s.buy_price) >= 0.0:
if pv_surplus_w <= 0:
prob += bc_pv[t] == 0
else:
prob += bc_pv[t] <= float(pv_surplus_w)
if (
t not in discharge_export_slots
and t not in neg_sell_bat_dump_slots
and t not in pre_neg_buy_discharge_ts
and t not in pre_neg_buy_empty_ts
):
prob += ge_bat[t] == 0
prob += z_export[t] == 0
@@ -2159,6 +2342,10 @@ def solve_dispatch(
and sell_t < 0
and buy_t >= 0.0
and not purchase_fixed_pre
and (
first_neg_buy_idx is None
or t < first_neg_buy_idx
)
) or (
# KV1: plná baterie + kladný sell — neblokovat ge_pv==0 (jinak masivní curtail).
getattr(grid, "block_export_on_negative_sell", False)

View File

@@ -11,6 +11,7 @@ from services.control.exporter_monolith import (
compute_pv_a_reg340_max_solar_w,
deye_reg_triggers_self_sustain_after_verify_exhaust,
)
from services.control.setpoints import plan_skips_deye_reg340_write
def _auto_mode() -> OperatingModeInfo:
@@ -102,6 +103,72 @@ class BuildSetpointsReg340Tests(unittest.TestCase):
assert sp is not None
self.assertEqual(sp.pv_a_allowed_w, 0)
def test_skipped_when_no_export_no_charge_no_curtail(self) -> None:
sp = _build_setpoints(
_auto_mode(),
_pi_base(
grid_setpoint_w=0,
battery_setpoint_w=0,
export_mode="NONE",
export_limit_w=0,
pv_a_curtailed_w=0,
),
pv_a_cap_w=10_000,
reg340_pv_a_control_enabled=True,
)
assert sp is not None
self.assertIsNone(sp.pv_a_allowed_w)
def test_writes_reg340_when_curtail_planned(self) -> None:
sp = _build_setpoints(
_auto_mode(),
_pi_base(
grid_setpoint_w=0,
battery_setpoint_w=0,
export_mode="NONE",
pv_a_curtailed_w=3000,
),
pv_a_cap_w=10_000,
reg340_pv_a_control_enabled=True,
)
assert sp is not None
self.assertEqual(sp.pv_a_allowed_w, 5000)
def test_writes_reg340_when_battery_charging_without_export(self) -> None:
sp = _build_setpoints(
_auto_mode(),
_pi_base(
grid_setpoint_w=0,
battery_setpoint_w=5000,
export_mode="NONE",
pv_a_curtailed_w=0,
),
pv_a_cap_w=10_000,
reg340_pv_a_control_enabled=True,
)
assert sp is not None
self.assertEqual(sp.pv_a_allowed_w, 10_000)
def test_plan_skips_helper(self) -> None:
self.assertTrue(
plan_skips_deye_reg340_write(
battery_setpoint_w=0,
grid_setpoint_w=0,
export_mode="NONE",
export_limit_w=0,
pv_a_curtailed_w=0,
)
)
self.assertFalse(
plan_skips_deye_reg340_write(
battery_setpoint_w=0,
grid_setpoint_w=-2000,
export_mode="PV_SURPLUS",
export_limit_w=2000,
pv_a_curtailed_w=0,
)
)
def test_skipped_when_reg340_control_disabled(self) -> None:
sp = _build_setpoints(
_auto_mode(),

View File

@@ -13,6 +13,7 @@ from services.planning_engine import (
_dispatch_result_comparison,
_evening_battery_export_push_indices,
_evening_push_discharge_budget_wh,
_pre_neg_buy_soc_ceiling_wh,
_pre_neg_peak_sell_idx,
_prague_hour,
_prewindow_deferral_slots,
@@ -73,6 +74,43 @@ def _battery(
)
class PreNegBuySocPhaseTests(unittest.TestCase):
"""Dvoufázová SoC: plná při posledním sell≥0 před buy<0, strop před buy<0."""
def test_soc_ceiling_accounts_for_neg_buy_window(self) -> None:
base = datetime(2026, 5, 25, 8, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(16):
buy = -0.1 if 6 <= i < 10 else 1.0
sell = -0.3 if i < 6 else (2.5 if i < 10 else -0.2)
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=6000 if i >= 6 else 4000,
pv_b_forecast_w=3000 if i >= 6 else 2000,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
)
)
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
ceiling = _pre_neg_buy_soc_ceiling_wh(
slots,
first_neg_buy_idx=6,
min_soc_wh=bat.min_soc_wh,
soc_max_wh=bat.soc_max_wh,
max_charge_w=18_000,
charge_eff=0.95,
)
self.assertIsNotNone(ceiling)
assert ceiling is not None
self.assertLess(ceiling, bat.soc_max_wh * 0.85)
class EveningPushBudgetTests(unittest.TestCase):
"""Večerní tvrdý push: počet slotů z rozpočtu Wh (ne pevné top-3)."""
@@ -369,11 +407,8 @@ class PlanningDispatchMilpTests(unittest.TestCase):
def test_neg_sell_with_future_neg_buy_prefers_curtail_pv_a_over_export(self) -> None:
"""
Když:
- aktuální slot má sell < 0 (export je náklad),
- v horizontu existuje budoucí buy < 0,
- a zároveň existuje PV B (necurtailable) někde v horizontu,
solver preferuje curtail PV A (ca) místo placeného exportu ge.
v25: sell<0 před buy<0 — PV A smí do baterie (bc_pv), ne export za záporný sell.
Curtail PV A (ca) až v okně buy<0 (slot 1).
"""
slots = [
_slot(load=0, buy=3.0, sell=-0.1, pv_a=5000, pv_b=0),
@@ -412,9 +447,9 @@ class PlanningDispatchMilpTests(unittest.TestCase):
operating_mode="AUTO",
)
self.assertEqual(len(results), 2)
# Slot 0: záporný sell — žádný export FVE do sítě (LP guard sell < acquisition).
self.assertNotEqual(results[0].export_mode, "PV_SURPLUS")
self.assertNotEqual(results[0].export_mode, "PV_SURPLUS")
self.assertGreater(results[0].battery_setpoint_w, 500)
self.assertEqual(results[0].pv_a_curtailed_w, 0)
self.assertGreater(results[1].grid_setpoint_w, 1000)
def test_pv_surplus_export_uses_hard_export_cap(self) -> None:
slots = [
@@ -787,8 +822,8 @@ class PlanningDispatchMilpTests(unittest.TestCase):
def test_anchor_hits_floor_before_first_negative_sell(self) -> None:
"""
Pokud se v horizontu objeví první sell<0 a současně existuje planner floor (relaxace),
solver má skončit už v předchozím slotu u planner floor (cca 5 %), ne na ~15 %.
v25: před buy<0 — SoC u posledního sell0 blízko max, před prvním buy<0 pod stropem
(_pre_neg_buy_soc_ceiling_wh), ne kotva na planner floor před sell<0.
"""
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
# Slot 0-1: sell >= 0; slot 2: první sell < 0; slot 3: extrémně záporný buy (motivace k bufferu).
@@ -866,18 +901,22 @@ class PlanningDispatchMilpTests(unittest.TestCase):
tuv_delta_stats=None,
operating_mode="AUTO",
)
peak_t = _pre_neg_peak_sell_idx(slots, 2)
self.assertIsNotNone(peak_t)
self.assertLess(
results[peak_t].grid_setpoint_w,
-500,
msg="ranní peak: export baterie/FVE před sell<0",
last_pos = 1
pre_buy = 2
self.assertGreaterEqual(
results[last_pos].battery_soc_target or 0,
60.0,
msg="poslední sell≥0 před buy<0: směr k plné baterii (bez exportu)",
)
self.assertLessEqual(
results[pre_buy].battery_soc_target or 100.0,
75.0,
msg="slot před buy<0: rezerva pro import v buy<0 okně",
)
def test_anchor_uses_planner_floor_even_without_extreme_buy(self) -> None:
"""
Regrese: pokud v horizontu není buy <= threshold (soc_min_series by se nerelaxovala),
kotva před sell<0 má stejně mířit na planner floor (5 %), ne na base min SoC.
v25: bez buy<0 v horizontu — žádný strop před buy<0; poslední sell≥0 může držet vysoké SoC.
"""
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
slots = [
@@ -942,11 +981,8 @@ class PlanningDispatchMilpTests(unittest.TestCase):
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertLess(
results[0].grid_setpoint_w,
-1_000,
msg="morning peak slot should export before first negative sell",
)
self.assertEqual(results[0].grid_setpoint_w, 0)
self.assertEqual(results[0].battery_setpoint_w, 0)
def test_grid_import_soft_cap_penalizes_breaker_overdraw(self) -> None:
"""
@@ -1302,11 +1338,11 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-evening-push-dynamic-budget-v24")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-pre-neg-buy-soc-phases-v25")
self.assertGreater(
results[0].battery_setpoint_w,
5_500,
f"od ~51 % SoC má první neg slot nabíjet max, got {[r.battery_setpoint_w for r in results]}",
2_500,
f"první sell<0 slot nabíjet z PV, got {[r.battery_setpoint_w for r in results]}",
)
self.assertGreaterEqual(
max(r.battery_soc_target for r in results),
@@ -1452,7 +1488,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-evening-push-dynamic-budget-v24")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-pre-neg-buy-soc-phases-v25")
self.assertEqual(len(results), len(slots))
def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None:
@@ -1516,7 +1552,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
55.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-evening-push-dynamic-budget-v24")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-pre-neg-buy-soc-phases-v25")
self.assertEqual(len(results), len(slots))
def test_fixed_tariff_neg_sell_no_grid_export(self) -> None:
@@ -3065,13 +3101,12 @@ class SitePowerCapTests(unittest.TestCase):
13_500,
msg="export ze site ≤ max_export_power_w",
)
self.assertLess(r.grid_setpoint_w, -500, msg="očekáván významný export")
self.assertLess(r.battery_setpoint_w, -500, msg="očekáváno vybíjení baterie")
self.assertLessEqual(
r.export_limit_w,
13_500,
msg="export_limit_w odpovídá site limitu",
)
self.assertLessEqual(abs(r.battery_setpoint_w), 18_000)
class PlannerArbitrageImprovementsTests(unittest.TestCase):

View File

@@ -146,6 +146,7 @@ bits 01). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg
- **Implementace:** `backend/services/control/exporter_monolith.py``export_setpoints` načte cap v `_load_inverter_config` (`ems.fn_inverter_pv_a_max_w(ai.id)`), `_build_setpoints` v režimu **AUTO** dopočítá `ControlSetpoints.pv_a_allowed_w`, `write_inverter_setpoints` zařadí **reg 340**, pokud je `fn_site_has_active_green_bonus_pv` aktivní, cap > 0 a `pv_a_allowed_w` je vyplněné.
- **Data:** `pv_a_forecast_solver_w` / `pv_a_curtailed_w` z aktivního `planning_interval` (json z `ems.fn_planning_interval_at_offset`); cap = součet `nominal_power_wp` řiditelných polí na invertoru (bez nového sloupce v DB).
- **Policy PV A off (jen na site se zeleným bonusem na PV):** pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` a v aktuálním slotu zároveň `effective_buy_price < 0` a `effective_sell_price < 0` a `pv_b_forecast_solver_w > 0` (PV B vyrábí), exporter nastaví `pv_a_allowed_w = 0` (reg 340) i když je forecast PV A nulový — cílem je držet headroom v baterii pro PV B / další záporný nákup.
- **Bez zápisu reg 340:** `plan_skips_deye_reg340_write` — žádný export z plánu, `battery_setpoint_w ≤ 0`, `pv_a_curtailed_w = 0``pv_a_allowed_w = None` (invertor řídí pole A sám). Ověření: `pytest backend/tests/test_control_exporter_reg340.py`.
- **Verify:** reg **340** není kritický → po 3× mismatch verify **bez** přepnutí do SELF_SUSTAIN (stejně jako reg 178); viz [`modbus-command-journal.md`](modbus-command-journal.md).
#### Ověření po nasazení (smoke)

View File

@@ -32,6 +32,7 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi
- **FC 0x10**, jednotka **W**; firmware omezuje strop výroby z řiditelných stringů (pole A na hybridu).
- **Kdy EMS zapisuje:** `ems.fn_site_has_active_green_bonus_pv(site_id)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (součet `nominal_power_wp` z `asset_pv_array` kde `controllable = true`). Při součtu **0** nebo bez aktivního zeleného bonusu EMS reg 340 **nezapisuje** (ruční hodnota v invertoru zůstane).
- **Hodnota:** z `ControlSetpoints.pv_a_allowed_w` (AUTO): bez curtailmentu = plný cap; při `pv_a_curtailed_w > 0` viz tabulka výše. Režimy **SELF_SUSTAIN / PRESERVE / CHARGE_CHEAP** mají `pv_a_allowed_w = None` → žádný zápis 340 z EMS v daném ticku.
- **Bez zápisu 340 (2026-05):** pokud plán má **bez exportu** (`export_mode = NONE` nebo `grid_setpoint_w ≥ 0` a `export_limit_w = 0`), **bez nabíjení baterie** (`battery_setpoint_w ≤ 0`) a **bez curtailu A** (`pv_a_curtailed_w = 0`), EMS reg 340 **neposílá** — Deye řídí PV A přes **108/109/142** (zero export + 0 A nabíjení). Funkce `plan_skips_deye_reg340_write` v `setpoints.py`. Výjimka: explicitní curtail nebo záporné buy+sell s PV B → `pv_a_allowed_w` se dopočítá / vynuluje jako dřív.
- **Živé čtení:** `read_deye_registers_live` vrací **`reg340_max_solar_power_w`** (integer) jen pokud je přepínač zapnutý; jinak **`null`** (bez extra FC3 čtení reg 340).
### Reg 191 (výkon grid peak shaving)

View File

@@ -45,6 +45,7 @@
1. **Globální rozpočet Wh** (`discharge_slot_buffer × exportovatelná kapacita`): sloty podle `sell_price desc`. Před prvním `sell < 0` se z rozpočtu **vynechají** sloty, kde **později tentýž den** existuje `sell` vyšší o více než `degradation` (OTE, ne pevné hodiny 0004).
2. **Večerní špičky per den:** `sell ≥ max(sell) degradation` jen pro hodiny **≥ 17** (Prague), ne globální max horizontu (jinak by vyhrála půlnoc 3,7 Kč místo večera).
3. **Ranní pásmo před prvním `sell < 0`:** hodiny **511** téhož kalendářního dne — všechny sloty s `sell ≥ lokální_max_ráno degradation`; ostatní sloty mezi ranním pásmem a prvním `sell < 0` s nižším sell mají export **zakázán** (žádný dump v 07:30 za 2 Kč). **`charge_acquisition`:** vážený `buy` před prvním exportem **téhož dne** jako záporné výkupní okno.
**Planner tag v25:** v24 + **dvoufáze před `buy<0`:** u posledního `sell≥0` cíl `soc≈max` (bez exportu); mezi tím a `buy<0` výboj na `_pre_neg_buy_soc_ceiling_wh`; v okně `buy<0` jen import (`bc_pv=0`), **curtail PV A jen při `buy<0`**; ranní `sell<0` před `buy<0` smí PV→bat. Viz changelog v25.
**Planner tag v24:** v23 + **večerní tvrdý push** podle rozpočtu Wh (`discharge_slot_buffer`, SoC nad `min_soc`, `per_slot_discharge`) — bez pevného top-3 / `len≥2`. Viz changelog v24.
**Planner tag v23:** v22b + **výboj baterie do sítě** před `buy<0` (`_pre_neg_buy_discharge_indices`, sell≥1 Kč/kWh, push `ge_bat` z DB limitů). Viz changelog v23.
V `solve_dispatch` (AUTO): **`charge_slots`** = `allow_charge` z DB + **`buy < 0`** + všechny sloty **`sell < 0`** s PV přebytkem > 500 W (i bez `block_export_on_negative_sell`, BA81). **`pv_charge_shortfall`** / **`NEG_SELL_CURTAIL_PENALTY`** platí v těchto slotech. Při **`sell < 0`**: safety deficit cílí **`soc_max_wh`** (plný planner strop). Po posledním **`sell < 0`** tentýž den: **`post_neg_pv_topup`** dobije z FVE na `soc_max` před exportem (kladný sell, ne high-sell peak). U **fixního tarifu** s polem B: **`ge_pv ≤ pv_b`** (ne pv_store **`ge_pv = 0`**). Při **`deye_gen_microinverter_cutoff_enabled`**: **`ge == 0` jen** pokud **`block_export_on_negative_sell`** (KV1), ne kvůli samotnému `z_gen_cutoff` (BA81 musí moci exportovat B při plné baterii). Vstupní **`soc_wh`** z telemetrie se před MILP omezí přes **`_planner_soc_for_solver`** (rezerva ~650 Wh pod `soc_max`, jinak Infeasible při 100 % SoC a dlouhém záporném výkupu). **`planner_build_tag`** v `solver_params`. Changelog: [`docs/planning-changelog.md`](../planning-changelog.md).

View File

@@ -5,6 +5,24 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
---
## 2026-05-28 — reg 340 jen když plán curtailuje / exportuje / nabíjí
**Změna:** `plan_skips_deye_reg340_write` v `setpoints.py` — bez FC 0x10 na reg **340**, pokud slot nemá export, nabíjení baterie ani `pv_a_curtailed_w` (Deye řídí PV A přes 108/109/142).
**Ověření:** `pytest backend/tests/test_control_exporter_reg340.py`.
---
## 2026-05-28 — dvoufázová SoC před buy&lt;0, PV A curtail jen v buy&lt;0 (v25)
**Požadavek:** (1) **PV A omezení** jen při `buy&lt;0` — raději import se ziskem než „zdarma“ ze střechy. (2) **Před `buy&lt;0`** dostatečně **nízké SoC** (vejde import v okně + PV B + rezerva na odpolední `sell&lt;0`). (3) **Nejpozději při posledním `sell≥0` před `buy&lt;0`** baterie **~100 %** (bez exportu — PV do bat). (4) Ranní `sell&lt;0` před `buy&lt;0`: PV smí do baterie (ne tvrdé `bc_pv=0`).
**Oprava (tag `2026-05-28-pre-neg-buy-soc-phases-v25`):** `_pre_neg_buy_soc_ceiling_wh`, kotvy `soc` na `last_pos_sell` (max) a `first_neg_buy-1` (strop), `pre_neg_buy_empty_ts` výboj, `pos_sell_pre_neg_buy_ts` `ge=0`, `bc_pv=0` jen při `buy&lt;0`, `NEG_SELL_CURTAIL` jen `buy&lt;0`, ranní PV charge shortfall.
**Ověření:** `pytest backend/tests/test_planning_dispatch_milp.py -k PreNegBuySocPhase`.
---
## 2026-05-28 — dynamický večerní push (v24)
**Problém:** Tvrdý večerní push používal pevné **`max_slots_per_day = 3`** a aktivaci jen při **`len(evening_push_ts) ≥ 2`** — nesouvisí s `discharge_slot_buffer`, SoC ani počtem večerních peak slotů (changelog v17 mluvil o top-6/≥7, v kódu bylo 3/2).