uprava PV omeznovani
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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 95–100 %), 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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 sell≥0 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 má 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):
|
||||
|
||||
@@ -146,6 +146,7 @@ bits 0–1). 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 00–04).
|
||||
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 **5–11** 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).
|
||||
|
||||
@@ -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<0, PV A curtail jen v buy<0 (v25)
|
||||
|
||||
**Požadavek:** (1) **PV A omezení** jen při `buy<0` — raději import se ziskem než „zdarma“ ze střechy. (2) **Před `buy<0`** dostatečně **nízké SoC** (vejde import v okně + PV B + rezerva na odpolední `sell<0`). (3) **Nejpozději při posledním `sell≥0` před `buy<0`** baterie **~100 %** (bez exportu — PV do bat). (4) Ranní `sell<0` před `buy<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<0`, `NEG_SELL_CURTAIL` jen `buy<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).
|
||||
|
||||
Reference in New Issue
Block a user