implementace co nejdrive dosazeni SOC na home-01 a umozneni plneho socu n slotu ped koncem sell < 0
This commit is contained in:
@@ -104,7 +104,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st
|
||||
|
||||
15. **Bazální spotřeba** = `load_power_w` minus řízené zátěže (součet EV z `telemetry_ev_charger`, TČ z `telemetry_heat_pump`). Tabulka `consumption_baseline_stats` se plní denně (APScheduler 00:30) přes `fn_update_baseline_stats`; **bez EMA „ocasu“** přepočítáš smaž+hromadný update přes **`ems.fn_rebuild_consumption_baseline_stats(site_id, lookback)`** (`site_id NULL` → všechny lokality). **Solver** načítá průměr bazálu z `consumption_baseline_stats` (DOW + hodina v Europe/Prague), ne z `consumption_baseline_interval`.
|
||||
|
||||
16. **Dynamický horizont plánování (jen OTE):** konec okna z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (`min` posledního OTE konce a `start + 36 h`, NULL pokud je známé OTE kratší než **1 h** od startu – rolling se přeskočí; denní plán při NULL použije **1 h** fallback v Pythonu). Strop a práh měnit v SQL (defaultní argumenty funkce / repeatable migrace), ne přes env. Solver používá **výhradně** sloty s efektivní cenou z `vw_site_effective_price` (žádná predikce v LP). Účelová funkce má **terminal SoC shadow price**: `−(průměr buy v prvních 24 h slotů × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč; SoC v Wh), kde **`planner_terminal_soc_value_factor`** je **`ems.asset_battery.planner_terminal_soc_value_factor`** načtené přes **`ems.fn_planning_site_context`** (žádný skrytý faktor v Pythonu). `market_price_stats` / `fn_get_predicted_price` zůstávají pro statistiky a budoucí rozšíření; detail historie: `docs/04-modules/planning-extended-horizon.md`.
|
||||
16. **Dynamický horizont plánování (jen OTE):** konec okna z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (`min` posledního OTE konce a `start + 36 h`, NULL pokud je známé OTE kratší než **1 h** od startu – rolling se přeskočí; denní plán při NULL použije **1 h** fallback v Pythonu). Strop a práh měnit v SQL (defaultní argumenty funkce / repeatable migrace), ne přes env. Solver používá **výhradně** sloty s efektivní cenou z `vw_site_effective_price` (žádná predikce v LP). Účelová funkce má **terminal SoC shadow price**: `−(průměr buy v prvních 24 h slotů × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč; SoC v Wh), kde **`planner_terminal_soc_value_factor`** je **`ems.asset_battery.planner_terminal_soc_value_factor`** načtené přes **`ems.fn_planning_site_context`** (žádný skrytý faktor v Pythonu). **Fázované SoC v okně `sell < 0` (v32):** `planner_neg_sell_prep_soc_percent`, `planner_neg_sell_full_soc_tail_slots`, `planner_neg_sell_vent_min_sell_czk_kwh` na **`asset_battery`**; curtail A → reg 340, plná baterie = solar sell off bez zápisu 340. `market_price_stats` / `fn_get_predicted_price` zůstávají pro statistiky; detail: `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`.
|
||||
|
||||
17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord` → `fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **62–64** (čas) → po 3 neúspěšných ověřeních **bez** změny režimu, kritický **Discord** (`notify_modbus_clock_verify_exhausted`). **Obecně:** při jakékoli změně `mode_code` z Pythonu (`POST /api/v1/sites/{id}/mode`, mismatch → SELF_SUSTAIN, `fn_expire_modes`) lze Discord zapnout přes `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`.
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ NEG_SELL_PV_CHARGE_REWARD_CZK_KWH = 0.8
|
||||
NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH = 0.35
|
||||
# Jen ventil nekontrolovatelného pole B při plné baterii a sell<0 (spot); ne celý PV přebytek.
|
||||
NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0
|
||||
# Fáze sell<0 (v32): ASAP na prep_soc %, tail rampa na soc_max.
|
||||
NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.85
|
||||
NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH = 60.0
|
||||
# Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž).
|
||||
EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12
|
||||
NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
@@ -68,7 +71,7 @@ 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-morning-pv-export-priority-v31"
|
||||
PLANNER_BUILD_TAG = "2026-05-28-neg-sell-soc-phases-v32"
|
||||
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
|
||||
@@ -806,6 +809,57 @@ def _prague_calendar_date(slot: PlanningSlot):
|
||||
return dt.astimezone(ZoneInfo("Europe/Prague")).date()
|
||||
|
||||
|
||||
def _neg_sell_phases_enabled(battery: Any) -> bool:
|
||||
# Bez atributů z DB (unit testy) = legacy; z DB default 80 % / 4 sloty (V083).
|
||||
prep_pct = float(getattr(battery, "planner_neg_sell_prep_soc_percent", 100.0))
|
||||
tail_slots = int(getattr(battery, "planner_neg_sell_full_soc_tail_slots", 0))
|
||||
return prep_pct < 100.0 - 1e-6 and tail_slots > 0
|
||||
|
||||
|
||||
def _neg_sell_day_phases(
|
||||
slots: list[PlanningSlot],
|
||||
battery: Any,
|
||||
) -> tuple[list[str], list[Optional[float]], list[float]]:
|
||||
"""
|
||||
Per slot: phase (none|prep|tail), soc_target_wh (None mimo sell<0 fáze), prep shortfall váha.
|
||||
Fáze po kalendářním dni v Europe/Prague.
|
||||
"""
|
||||
t_len = len(slots)
|
||||
phases: list[str] = ["none"] * t_len
|
||||
soc_targets: list[Optional[float]] = [None] * t_len
|
||||
shortfall_weights: list[float] = [0.0] * t_len
|
||||
prep_pct = float(getattr(battery, "planner_neg_sell_prep_soc_percent", 100.0))
|
||||
tail_n = int(getattr(battery, "planner_neg_sell_full_soc_tail_slots", 0))
|
||||
prep_wh = prep_pct / 100.0 * float(battery.soc_max_wh)
|
||||
soc_max = float(battery.soc_max_wh)
|
||||
|
||||
by_day: dict[object, list[int]] = {}
|
||||
for t, st in enumerate(slots):
|
||||
if float(st.sell_price) < 0.0:
|
||||
by_day.setdefault(_prague_calendar_date(st), []).append(t)
|
||||
|
||||
for _day, indices in by_day.items():
|
||||
if not indices:
|
||||
continue
|
||||
indices.sort()
|
||||
last_t = indices[-1]
|
||||
tail_start = max(indices[0], last_t - tail_n + 1)
|
||||
for t in indices:
|
||||
if t >= tail_start:
|
||||
phases[t] = "tail"
|
||||
if tail_n <= 1:
|
||||
soc_targets[t] = soc_max
|
||||
else:
|
||||
pos = t - tail_start
|
||||
frac = pos / float(max(1, tail_n - 1))
|
||||
soc_targets[t] = prep_wh + frac * (soc_max - prep_wh)
|
||||
else:
|
||||
phases[t] = "prep"
|
||||
soc_targets[t] = prep_wh
|
||||
shortfall_weights[t] = float(last_t - t + 1) / float(len(indices))
|
||||
return phases, soc_targets, shortfall_weights
|
||||
|
||||
|
||||
MORNING_PRENEG_START_HOUR = 5
|
||||
MORNING_PRENEG_END_HOUR = 11
|
||||
|
||||
@@ -1574,6 +1628,24 @@ def solve_dispatch(
|
||||
min_spread_pre = float(degradation_cost_effective)
|
||||
purchase_fixed_pre = _purchase_pricing_fixed(grid)
|
||||
fixed_tariff_like_pre = purchase_fixed_pre or _horizon_fixed_tariff_like(slots)
|
||||
neg_sell_phases_en = (
|
||||
om == "AUTO"
|
||||
and not purchase_fixed_pre
|
||||
and _neg_sell_phases_enabled(battery)
|
||||
)
|
||||
neg_sell_phase_by_t: list[str] = ["none"] * T
|
||||
neg_sell_soc_target_by_t: list[Optional[float]] = [None] * T
|
||||
neg_sell_shortfall_weight_by_t: list[float] = [0.0] * T
|
||||
if neg_sell_phases_en:
|
||||
(
|
||||
neg_sell_phase_by_t,
|
||||
neg_sell_soc_target_by_t,
|
||||
neg_sell_shortfall_weight_by_t,
|
||||
) = _neg_sell_day_phases(slots, battery)
|
||||
prep_soc_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
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_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(
|
||||
@@ -1744,7 +1816,7 @@ def solve_dispatch(
|
||||
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_sell_soc_underfill: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
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]] = []
|
||||
@@ -1812,6 +1884,26 @@ def solve_dispatch(
|
||||
cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
|
||||
sf_pv = pulp.LpVariable(f"pv_charge_shortfall_{t}", 0, cap_w)
|
||||
pv_charge_shortfall.append((t, sf_pv, cap_w))
|
||||
if neg_sell_phases_en:
|
||||
pv_charge_taken = {t_sf for t_sf, _sf, _c in pv_charge_shortfall}
|
||||
for t_ns in range(T):
|
||||
if neg_sell_phase_by_t[t_ns] not in ("prep", "tail"):
|
||||
continue
|
||||
if t_ns in pv_charge_taken:
|
||||
continue
|
||||
if float(slots[t_ns].sell_price) >= 0.0:
|
||||
continue
|
||||
pv_surplus_ns = max(
|
||||
0.0,
|
||||
float(slots[t_ns].pv_a_forecast_w)
|
||||
+ float(slots[t_ns].pv_b_forecast_w)
|
||||
- float(slots[t_ns].load_baseline_w),
|
||||
)
|
||||
if pv_surplus_ns <= 500:
|
||||
continue
|
||||
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))
|
||||
for t in range(T):
|
||||
if not post_neg_pv_topup[t]:
|
||||
continue
|
||||
@@ -1828,7 +1920,48 @@ 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:
|
||||
if neg_sell_phases_en:
|
||||
for t_ns in range(T):
|
||||
phase_ns = neg_sell_phase_by_t[t_ns]
|
||||
tgt_ns = neg_sell_soc_target_by_t[t_ns]
|
||||
if phase_ns == "none" or tgt_ns is None:
|
||||
continue
|
||||
us_prep = pulp.LpVariable(
|
||||
f"neg_sell_prep_soc_{t_ns}",
|
||||
0,
|
||||
float(battery.usable_capacity_wh),
|
||||
)
|
||||
w_sf = float(neg_sell_shortfall_weight_by_t[t_ns])
|
||||
prep_soc_shortfall.append((t_ns, us_prep, w_sf))
|
||||
tail_last_by_day: dict[object, int] = {}
|
||||
for t_ln, st_ln in enumerate(slots):
|
||||
if neg_sell_phase_by_t[t_ln] != "tail":
|
||||
continue
|
||||
tail_last_by_day[_prague_calendar_date(st_ln)] = t_ln
|
||||
for t_tail_last in tail_last_by_day.values():
|
||||
if t_tail_last in charge_slots or relaxed_neg_buy_charge:
|
||||
us_tail = pulp.LpVariable(
|
||||
f"neg_sell_tail_soc_{t_tail_last}",
|
||||
0,
|
||||
float(battery.usable_capacity_wh),
|
||||
)
|
||||
neg_sell_soc_underfill.append(
|
||||
(t_tail_last, us_tail, float(battery.soc_max_wh))
|
||||
)
|
||||
for t_ph in range(T):
|
||||
if neg_sell_phase_by_t[t_ph] != "prep":
|
||||
continue
|
||||
cap_bc = float(battery.max_charge_power_w)
|
||||
prep_hold_met_binary[t_ph] = pulp.LpVariable(
|
||||
f"prep_hold_met_{t_ph}",
|
||||
cat=pulp.LpBinary,
|
||||
)
|
||||
sf_hold = pulp.LpVariable(f"prep_hold_bcpv_{t_ph}", 0, cap_bc)
|
||||
prep_hold_bcpv_shortfall.append((t_ph, sf_hold, cap_bc))
|
||||
cap_ca = float(max(0, slots[t_ph].pv_a_forecast_w))
|
||||
sf_ca = pulp.LpVariable(f"prep_hold_curtail_{t_ph}", 0, cap_ca)
|
||||
prep_hold_curtail_shortfall.append((t_ph, sf_ca, cap_ca))
|
||||
elif 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(
|
||||
@@ -1836,7 +1969,9 @@ def solve_dispatch(
|
||||
0,
|
||||
float(battery.usable_capacity_wh),
|
||||
)
|
||||
neg_sell_soc_underfill.append((t_nb_last, us))
|
||||
neg_sell_soc_underfill.append(
|
||||
(t_nb_last, us, float(battery.soc_max_wh))
|
||||
)
|
||||
for t in range(T):
|
||||
if first_neg_buy_idx is None or t >= first_neg_buy_idx:
|
||||
continue
|
||||
@@ -1904,7 +2039,17 @@ def solve_dispatch(
|
||||
)
|
||||
+ (
|
||||
ge_pv[t]
|
||||
* NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH
|
||||
* (
|
||||
max(
|
||||
0.05,
|
||||
-float(slots[t].sell_price),
|
||||
)
|
||||
if (
|
||||
neg_sell_phases_en
|
||||
and neg_sell_phase_by_t[t] == "tail"
|
||||
)
|
||||
else NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH
|
||||
)
|
||||
* INTERVAL_H
|
||||
/ 1000
|
||||
if (
|
||||
@@ -1926,6 +2071,9 @@ def solve_dispatch(
|
||||
om == "AUTO"
|
||||
and float(slots[t].buy_price) < 0.0
|
||||
and t in charge_slots
|
||||
and not (
|
||||
neg_sell_phases_en and neg_sell_phase_by_t[t] == "prep"
|
||||
)
|
||||
)
|
||||
else CURTAILMENT_PENALTY
|
||||
)
|
||||
@@ -1960,7 +2108,19 @@ def solve_dispatch(
|
||||
)
|
||||
+ pulp.lpSum(
|
||||
us * NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH
|
||||
for _t, us in neg_sell_soc_underfill
|
||||
for _t, us, _tgt in neg_sell_soc_underfill
|
||||
)
|
||||
+ pulp.lpSum(
|
||||
us * w_sf * NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH
|
||||
for _t, us, w_sf in prep_soc_shortfall
|
||||
)
|
||||
+ pulp.lpSum(
|
||||
sf * NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
|
||||
for _t, sf, _cap in prep_hold_bcpv_shortfall
|
||||
)
|
||||
+ pulp.lpSum(
|
||||
sf * NEG_SELL_CURTAIL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
|
||||
for _t, sf, _cap in prep_hold_curtail_shortfall
|
||||
)
|
||||
+ pulp.lpSum(
|
||||
sf * NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
|
||||
@@ -2020,8 +2180,28 @@ def solve_dispatch(
|
||||
prob += sf >= cap_w - bc_pv[t_sf]
|
||||
for t_sf, sf, cap_w in neg_sell_bat_dump_shortfall:
|
||||
prob += sf >= cap_w - ge_bat[t_sf]
|
||||
for t_us, us in neg_sell_soc_underfill:
|
||||
prob += us >= float(battery.soc_max_wh) - soc[t_us]
|
||||
for t_us, us, _w_sf in prep_soc_shortfall:
|
||||
tgt_prep = neg_sell_soc_target_by_t[t_us]
|
||||
if tgt_prep is not None:
|
||||
prob += us >= float(tgt_prep) - soc[t_us]
|
||||
for t_us, us, tgt_wh in neg_sell_soc_underfill:
|
||||
prob += us >= float(tgt_wh) - soc[t_us]
|
||||
prep_wh_phases = (
|
||||
float(getattr(battery, "planner_neg_sell_prep_soc_percent", 80.0))
|
||||
/ 100.0
|
||||
* float(battery.soc_max_wh)
|
||||
if neg_sell_phases_en
|
||||
else 0.0
|
||||
)
|
||||
m_hold_soc = float(battery.soc_max_wh)
|
||||
for t_h, sf_h, cap_h in prep_hold_bcpv_shortfall:
|
||||
w_h = prep_hold_met_binary[t_h]
|
||||
soc_prev_h = current_soc_wh if t_h == 0 else soc[t_h - 1]
|
||||
prob += soc_prev_h >= prep_wh_phases - m_hold_soc * (1 - w_h)
|
||||
prob += sf_h >= bc_pv[t_h] - cap_h * w_h
|
||||
for t_c, sf_c, cap_c in prep_hold_curtail_shortfall:
|
||||
w_c = prep_hold_met_binary[t_c]
|
||||
prob += sf_c >= ca[t_c] - cap_c * (1 - w_c)
|
||||
for t_sf, sf, cap_w in neg_buy_charge_shortfall:
|
||||
# buy<0: bc_pv=0 (import arbitráž); shortfall jen na grid→bat.
|
||||
prob += sf >= cap_w - bc_gi[t_sf]
|
||||
@@ -2171,10 +2351,17 @@ def solve_dispatch(
|
||||
if sv is not None:
|
||||
eff_tgt_s = float(tgt_s) if tgt_s is not None else float(min_soc_wh)
|
||||
if (
|
||||
neg_sell_phases_en
|
||||
and float(s.sell_price) < 0.0
|
||||
and neg_sell_soc_target_by_t[t] is not None
|
||||
):
|
||||
eff_tgt_s = max(eff_tgt_s, float(neg_sell_soc_target_by_t[t]))
|
||||
elif (
|
||||
om == "AUTO"
|
||||
and float(s.buy_price) < 0.0
|
||||
and t in charge_slots
|
||||
and len(neg_buy_slot_indices_pre) >= 2
|
||||
and not neg_sell_phases_en
|
||||
):
|
||||
# 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))
|
||||
@@ -2267,11 +2454,22 @@ def solve_dispatch(
|
||||
prob += ge_pv[t] == 0
|
||||
elif not purchase_fixed_pre:
|
||||
# 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.
|
||||
# Po buy<0 / mimo ranní pásmo: ventil B jen při plné baterii (nebo tail + sell práh).
|
||||
before_first_neg_buy = (
|
||||
first_neg_buy_idx is not None and t < first_neg_buy_idx
|
||||
)
|
||||
if before_first_neg_buy:
|
||||
vent_min_sell = getattr(
|
||||
battery, "planner_neg_sell_vent_min_sell_czk_kwh", None
|
||||
)
|
||||
tail_free_vent = bool(
|
||||
neg_sell_phases_en
|
||||
and neg_sell_phase_by_t[t] == "tail"
|
||||
and vent_min_sell is not None
|
||||
and float(s.sell_price) >= float(vent_min_sell)
|
||||
)
|
||||
if tail_free_vent and float(s.pv_b_forecast_w) > 0:
|
||||
prob += ge_pv[t] <= float(s.pv_b_forecast_w)
|
||||
elif before_first_neg_buy:
|
||||
if float(s.pv_b_forecast_w) > 0:
|
||||
prob += ge_pv[t] <= float(s.pv_b_forecast_w)
|
||||
else:
|
||||
@@ -2752,12 +2950,19 @@ def solve_dispatch(
|
||||
* INTERVAL_H
|
||||
/ 1000.0
|
||||
)
|
||||
for _tt, _us in neg_sell_soc_underfill:
|
||||
for _tt, _us, _tgt in neg_sell_soc_underfill:
|
||||
if _tt == t:
|
||||
penalty_terms_t += (
|
||||
float(pulp.value(_us) or 0.0)
|
||||
* NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH
|
||||
)
|
||||
for _tt, _us, _w in prep_soc_shortfall:
|
||||
if _tt == t:
|
||||
penalty_terms_t += (
|
||||
float(pulp.value(_us) or 0.0)
|
||||
* float(_w)
|
||||
* NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH
|
||||
)
|
||||
sv_t = safety_vars[t]
|
||||
if sv_t is not None:
|
||||
penalty_terms_t += float(pulp.value(sv_t) or 0.0) * safety_pen_czk_per_wh[t]
|
||||
@@ -2817,6 +3022,12 @@ def solve_dispatch(
|
||||
"slot": st.interval_start.isoformat(),
|
||||
"allow_charge": bool(st.allow_charge),
|
||||
"allow_discharge_export": bool(st.allow_discharge_export),
|
||||
"neg_sell_phase": neg_sell_phase_by_t[t] if neg_sell_phases_en else None,
|
||||
"neg_sell_soc_target_wh": (
|
||||
float(neg_sell_soc_target_by_t[t])
|
||||
if neg_sell_soc_target_by_t[t] is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
tgt_s = st.safety_soc_target_wh if daytime_en else None
|
||||
@@ -2902,7 +3113,17 @@ def solve_dispatch(
|
||||
"planner_terminal_soc_value_factor": float(battery.planner_terminal_soc_value_factor),
|
||||
"planner_daytime_charge_target_enabled": daytime_en,
|
||||
"planner_charge_commitment_penalty_czk_kwh": float(commit_pen),
|
||||
"planner_neg_sell_prep_soc_percent": float(
|
||||
getattr(battery, "planner_neg_sell_prep_soc_percent", 80.0)
|
||||
),
|
||||
"planner_neg_sell_full_soc_tail_slots": int(
|
||||
getattr(battery, "planner_neg_sell_full_soc_tail_slots", 4)
|
||||
),
|
||||
"planner_neg_sell_vent_min_sell_czk_kwh": getattr(
|
||||
battery, "planner_neg_sell_vent_min_sell_czk_kwh", None
|
||||
),
|
||||
},
|
||||
"neg_sell_phases_enabled": bool(neg_sell_phases_en),
|
||||
"load_first_enabled": om == "AUTO",
|
||||
"relaxed_expensive_import": relaxed_expensive_import,
|
||||
"charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh,
|
||||
@@ -3368,6 +3589,17 @@ async def _load_site_context(site_id: int, db):
|
||||
planner_charge_commitment_penalty_czk_kwh=float(
|
||||
b.get("planner_charge_commitment_penalty_czk_kwh") or 0.20
|
||||
),
|
||||
planner_neg_sell_prep_soc_percent=float(
|
||||
b.get("planner_neg_sell_prep_soc_percent") or 80.0
|
||||
),
|
||||
planner_neg_sell_full_soc_tail_slots=int(
|
||||
b.get("planner_neg_sell_full_soc_tail_slots") or 4
|
||||
),
|
||||
planner_neg_sell_vent_min_sell_czk_kwh=(
|
||||
float(b["planner_neg_sell_vent_min_sell_czk_kwh"])
|
||||
if b.get("planner_neg_sell_vent_min_sell_czk_kwh") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
hpj = ctx["heat_pump"]
|
||||
|
||||
@@ -8,6 +8,7 @@ from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from services.planning_engine import (
|
||||
PLANNER_BUILD_TAG,
|
||||
DispatchResult,
|
||||
PlanningSlot,
|
||||
_dynamic_arb_floor_wh_series,
|
||||
@@ -16,6 +17,8 @@ from services.planning_engine import (
|
||||
_evening_peak_export_indices,
|
||||
_evening_push_discharge_budget_wh,
|
||||
_in_night_battery_export_window,
|
||||
_neg_sell_day_phases,
|
||||
_neg_sell_phases_enabled,
|
||||
_pre_neg_buy_soc_ceiling_wh,
|
||||
_pre_neg_peak_sell_idx,
|
||||
_prague_hour,
|
||||
@@ -1410,7 +1413,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-morning-pv-export-priority-v31")
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-sell-soc-phases-v32")
|
||||
self.assertGreater(
|
||||
results[0].battery_setpoint_w,
|
||||
2_500,
|
||||
@@ -1560,7 +1563,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-morning-pv-export-priority-v31")
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-sell-soc-phases-v32")
|
||||
self.assertEqual(len(results), len(slots))
|
||||
|
||||
def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None:
|
||||
@@ -1624,7 +1627,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
|
||||
55.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-morning-pv-export-priority-v31")
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-sell-soc-phases-v32")
|
||||
self.assertEqual(len(results), len(slots))
|
||||
|
||||
def test_fixed_tariff_neg_sell_no_grid_export(self) -> None:
|
||||
@@ -2310,7 +2313,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap["planner_build_tag"], "2026-05-28-morning-pv-export-priority-v31")
|
||||
self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32")
|
||||
peak_idx = sells.index(4.04)
|
||||
peak = results[peak_idx]
|
||||
self.assertIn(peak.export_mode, ("BATTERY_SELL", "PV_SURPLUS"))
|
||||
@@ -2388,7 +2391,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap["planner_build_tag"], "2026-05-28-morning-pv-export-priority-v31")
|
||||
self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32")
|
||||
r_midnight = results[2]
|
||||
self.assertEqual(r_midnight.export_mode, "BATTERY_SELL")
|
||||
self.assertGreaterEqual(abs(r_midnight.grid_setpoint_w), 12_500)
|
||||
@@ -2431,7 +2434,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap["planner_build_tag"], "2026-05-28-morning-pv-export-priority-v31")
|
||||
self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32")
|
||||
r = results[0]
|
||||
self.assertEqual(r.export_mode, "BATTERY_SELL")
|
||||
self.assertGreaterEqual(abs(r.grid_setpoint_w), 12_500)
|
||||
@@ -3207,7 +3210,7 @@ class Home01PvStoreValueTests(unittest.TestCase):
|
||||
results, _, snap = solve_dispatch(
|
||||
slots, battery, hp, grid, [None, None], vehicles, 0.55 * battery.soc_max_wh, 50.0, operating_mode="AUTO"
|
||||
)
|
||||
self.assertEqual(snap["planner_build_tag"], "2026-05-28-morning-pv-export-priority-v31")
|
||||
self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32")
|
||||
r0 = results[0]
|
||||
self.assertLess(
|
||||
r0.pv_a_curtailed_w,
|
||||
@@ -3670,5 +3673,177 @@ class PlannerArbitrageImprovementsTests(unittest.TestCase):
|
||||
self.assertEqual(r.export_mode, "BATTERY_SELL")
|
||||
|
||||
|
||||
class NegSellSocPhaseTests(unittest.TestCase):
|
||||
"""Fázované SoC v okně sell<0 (v32): prep 80 %, tail rampa, vent B s prahem."""
|
||||
|
||||
@staticmethod
|
||||
def _phase_battery(**kw: float) -> SimpleNamespace:
|
||||
bat = _battery(uc_wh=64_000.0, max_pct=95.0)
|
||||
bat.planner_neg_sell_prep_soc_percent = kw.get("prep_pct", 80.0)
|
||||
bat.planner_neg_sell_full_soc_tail_slots = int(kw.get("tail_slots", 4))
|
||||
vent = kw.get("vent_min", -1.0)
|
||||
bat.planner_neg_sell_vent_min_sell_czk_kwh = None if vent is None else float(vent)
|
||||
return bat
|
||||
|
||||
@staticmethod
|
||||
def _neg_sell_slots(
|
||||
n: int,
|
||||
*,
|
||||
sell: float = -0.2,
|
||||
pv_a: int = 8000,
|
||||
pv_b: int = 4000,
|
||||
) -> list[PlanningSlot]:
|
||||
base = datetime(2026, 6, 10, 8, 0, tzinfo=ZoneInfo("Europe/Prague")).astimezone(timezone.utc)
|
||||
out: list[PlanningSlot] = []
|
||||
for i in range(n):
|
||||
out.append(
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=2.0,
|
||||
sell_price=sell,
|
||||
pv_a_forecast_w=pv_a,
|
||||
pv_b_forecast_w=pv_b,
|
||||
load_baseline_w=2000,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=False,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
def test_phases_enabled_helper(self) -> None:
|
||||
bat = self._phase_battery()
|
||||
self.assertTrue(_neg_sell_phases_enabled(bat))
|
||||
bat_legacy = self._phase_battery(prep_pct=100.0)
|
||||
self.assertFalse(_neg_sell_phases_enabled(bat_legacy))
|
||||
|
||||
def test_day_phases_tail_last_four(self) -> None:
|
||||
slots = self._neg_sell_slots(10)
|
||||
bat = self._phase_battery(tail_slots=4)
|
||||
phases, targets, _w = _neg_sell_day_phases(slots, bat)
|
||||
self.assertEqual(phases[5], "prep")
|
||||
self.assertEqual(phases[9], "tail")
|
||||
self.assertEqual(phases.count("tail"), 4)
|
||||
self.assertAlmostEqual(float(targets[9] or 0), bat.soc_max_wh, delta=50.0)
|
||||
|
||||
def test_prep_reaches_soc_by_mid_window(self) -> None:
|
||||
slots = self._neg_sell_slots(12)
|
||||
bat = self._phase_battery()
|
||||
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
grid = SimpleNamespace(
|
||||
max_import_power_w=20_000,
|
||||
max_export_power_w=13_500,
|
||||
block_export_on_negative_sell=False,
|
||||
)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
results, _, snap = solve_dispatch(
|
||||
slots,
|
||||
bat,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
0.35 * bat.soc_max_wh,
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG)
|
||||
self.assertTrue(snap.get("inputs", {}).get("neg_sell_phases_enabled"))
|
||||
# Nabíjení z FVE v sell<0: SoC roste, tail má vyšší cíl než začátek okna.
|
||||
self.assertGreater(results[-1].battery_soc_target, results[0].battery_soc_target)
|
||||
self.assertGreaterEqual(results[-1].battery_soc_target, 75.0)
|
||||
masks = snap.get("masks") or []
|
||||
phases = {m.get("neg_sell_phase") for m in masks if isinstance(m, dict)}
|
||||
self.assertIn("prep", phases)
|
||||
self.assertIn("tail", phases)
|
||||
|
||||
def test_hold_curtails_pv_a_when_soc_high(self) -> None:
|
||||
slots = self._neg_sell_slots(8)
|
||||
bat = self._phase_battery()
|
||||
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
grid = SimpleNamespace(
|
||||
max_import_power_w=20_000,
|
||||
max_export_power_w=13_500,
|
||||
block_export_on_negative_sell=False,
|
||||
)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
results, _, _ = solve_dispatch(
|
||||
slots,
|
||||
bat,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
0.85 * bat.soc_max_wh,
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
curtailed_any = any(r.pv_a_curtailed_w > 500 for r in results)
|
||||
self.assertTrue(
|
||||
curtailed_any,
|
||||
"při vysokém SoC v prep fázi očekáván curtail A (pv_a_curtailed_w)",
|
||||
)
|
||||
|
||||
def test_tail_allows_b_vent_when_sell_above_threshold(self) -> None:
|
||||
slots = self._neg_sell_slots(8, sell=-0.5)
|
||||
bat = self._phase_battery(vent_min=-1.0)
|
||||
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
grid = SimpleNamespace(
|
||||
max_import_power_w=20_000,
|
||||
max_export_power_w=13_500,
|
||||
block_export_on_negative_sell=False,
|
||||
)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
results, _, _ = solve_dispatch(
|
||||
slots,
|
||||
bat,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
0.82 * bat.soc_max_wh,
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
tail_export = max(0, -results[-1].grid_setpoint_w)
|
||||
self.assertGreater(tail_export, 200)
|
||||
|
||||
def test_tail_blocks_voluntary_vent_when_sell_too_negative(self) -> None:
|
||||
slots = self._neg_sell_slots(8, sell=-12.0, pv_b=6000)
|
||||
bat = self._phase_battery(vent_min=-1.0)
|
||||
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
grid = SimpleNamespace(
|
||||
max_import_power_w=20_000,
|
||||
max_export_power_w=13_500,
|
||||
block_export_on_negative_sell=False,
|
||||
)
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||
]
|
||||
results, _, _ = solve_dispatch(
|
||||
slots,
|
||||
bat,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
0.82 * bat.soc_max_wh,
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertLessEqual(max(0, -results[-1].grid_setpoint_w), 500)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
35
db/migration/V083__planner_neg_sell_phases.sql
Normal file
35
db/migration/V083__planner_neg_sell_phases.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Fázované SoC a curtail v okně sell < 0 (plánovač v32).
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_neg_sell_prep_soc_percent numeric(5, 2) not null default 80;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_neg_sell_full_soc_tail_slots int not null default 4;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_neg_sell_vent_min_sell_czk_kwh numeric;
|
||||
|
||||
comment on column ems.asset_battery.planner_neg_sell_prep_soc_percent is
|
||||
'Cíl SoC (%) v hlavní části denního okna sell<0 (ASAP nabít z FVE). 100 = legacy (tlak na soc_max až na konci). Realizace škrcení A přes plánovaný pv_a_curtailed_w → Deye reg 340.';
|
||||
|
||||
comment on column ems.asset_battery.planner_neg_sell_full_soc_tail_slots is
|
||||
'Počet 15min slotů před koncem denního úseku sell<0 (Europe/Prague), kdy LP rampuje cíl SoC na soc_max. 0 = bez tail fáze (legacy).';
|
||||
|
||||
comment on column ems.asset_battery.planner_neg_sell_vent_min_sell_czk_kwh is
|
||||
'V tail fázi: dobrovolný ventil pole B (ge_pv) jen pokud effective sell >= tato hodnota (Kč/kWh). NULL = vent jen při plné baterii (stávající w_pv_b_vent).';
|
||||
|
||||
update ems.asset_battery ab
|
||||
set
|
||||
planner_neg_sell_prep_soc_percent = 80,
|
||||
planner_neg_sell_full_soc_tail_slots = 4,
|
||||
planner_neg_sell_vent_min_sell_czk_kwh = -1.0
|
||||
from ems.site s
|
||||
where ab.site_id = s.id
|
||||
and s.code = 'home-01';
|
||||
|
||||
update ems.asset_battery ab
|
||||
set planner_neg_sell_prep_soc_percent = 100
|
||||
from ems.site s
|
||||
join ems.site_grid_connection sgc on sgc.site_id = s.id
|
||||
where ab.site_id = s.id
|
||||
and coalesce(sgc.block_export_on_negative_sell, false) = true;
|
||||
@@ -72,7 +72,10 @@ begin
|
||||
'planner_daytime_charge_target_enabled', coalesce(ab.planner_daytime_charge_target_enabled, true),
|
||||
'planner_night_baseload_buffer_percent', coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric),
|
||||
'planner_daytime_charge_price_quantile', coalesce(ab.planner_daytime_charge_price_quantile, 0.70::numeric),
|
||||
'planner_charge_commitment_penalty_czk_kwh', coalesce(ab.planner_charge_commitment_penalty_czk_kwh, 0.20::numeric)
|
||||
'planner_charge_commitment_penalty_czk_kwh', coalesce(ab.planner_charge_commitment_penalty_czk_kwh, 0.20::numeric),
|
||||
'planner_neg_sell_prep_soc_percent', coalesce(ab.planner_neg_sell_prep_soc_percent, 80::numeric),
|
||||
'planner_neg_sell_full_soc_tail_slots', coalesce(ab.planner_neg_sell_full_soc_tail_slots, 4),
|
||||
'planner_neg_sell_vent_min_sell_czk_kwh', ab.planner_neg_sell_vent_min_sell_czk_kwh
|
||||
)
|
||||
into v_b
|
||||
from ems.asset_battery ab
|
||||
|
||||
@@ -32,7 +32,8 @@ 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` (řiditelné pole A + nenulový strop střídače z `deye_reg340_max_solar_w` / `max_dc_input_w`). Bez bonusu nebo cap **0** EMS reg 340 **nezapisuje**.
|
||||
- **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.
|
||||
- **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** a při **plné baterii** typicky **solar sell off** (hardware). Funkce `plan_skips_deye_reg340_write` v `setpoints.py`. **Plánovač v32:** škrcení A v okně `sell < 0` jde přes `pv_a_curtailed_w` → reg 340; registry 108/109 se kvůli fázím nemění.
|
||||
- **Výjimka:** explicitní curtail v plánu 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)
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
**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 v26:** v25 + upřesnění večerního exportu — viz sekce **Večerní export z baterie** níže a changelog v26.
|
||||
**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).
|
||||
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`** (legacy): safety deficit cílí **`soc_max_wh`**; po posledním **`sell < 0`**: **`post_neg_pv_topup`**. **Planner tag v32:** fázované SoC — viz níže.
|
||||
- **Záporný výkup — fázované SoC (v32, `2026-05-28-neg-sell-soc-phases-v32`):** Parametry na **`ems.asset_battery`**: `planner_neg_sell_prep_soc_percent` (default **80**), `planner_neg_sell_full_soc_tail_slots` (default **4** = 1 h), `planner_neg_sell_vent_min_sell_czk_kwh` (default **−1** u home-01; **NULL** = ventil B jen při plné baterii). **`_neg_sell_day_phases`** (kalendářní den Prague): **prep** = ASAP nabít na prep %; **tail** = poslední N slotů rampa na `soc_max` + volitelný `ge_pv ≤ pv_b` pokud `sell ≥` práh; měkký curtail A (`pv_a_curtailed_w`) při SoC ≥ prep. Realizace na Deye: **reg 340** = forecast − curtail; při plné baterii bez curtailu v plánu EMS 340 **nezapisuje** (solar sell off). Legacy: `prep_soc_percent ≥ 100` nebo `tail_slots = 0`. KV1: seed `prep=100`. Ověření: `NegSellSocPhaseTests`, `solver_params.masks[].neg_sell_phase`. 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).
|
||||
- **Záporná nákupní cena:**
|
||||
- horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu).
|
||||
- **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí —
|
||||
|
||||
@@ -5,6 +5,14 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-28 — Záporný výkup: fázované SoC a curtail A (v32)
|
||||
|
||||
**Problém:** V okně `sell < 0` LP tlačil `soc_max` až na konci; nepraktické pro EV/TČ/oblačnost; curtail A na FE málo viditelný.
|
||||
|
||||
**Změna (tag `2026-05-28-neg-sell-soc-phases-v32`):** Sloupce na `ems.asset_battery`: `planner_neg_sell_prep_soc_percent` (default 80), `planner_neg_sell_full_soc_tail_slots` (default 4), `planner_neg_sell_vent_min_sell_czk_kwh` (default −1 u home-01). `_neg_sell_day_phases` v `solve_dispatch`: **prep** (ASAP na prep %), **tail** (rampa na `soc_max`, ventil B pokud `sell ≥` práh), měkké curtail A přes `pv_a_curtailed_w` → reg 340. Legacy: `prep_soc_percent ≥ 100` nebo `tail_slots = 0`. KV1 s `block_export_on_negative_sell`: seed `prep=100`.
|
||||
|
||||
**Ověření:** `pytest … -k NegSellSocPhaseTests` · `planner_build_tag` **v32** · FE sloupec PV A + badge sell− prep/tail.
|
||||
|
||||
## 2026-05-28 — Ráno: FVE do sítě místo plného ge_bat push (v31)
|
||||
|
||||
**Problém (run 17622, 07:00):** Při `sell ≥ 0` a PV přebytku `pre_neg_buy_discharge` vynutilo `ge_bat ≈ 13,5 kW` → exportní cap obsadila baterie → **celý curtail PV A** (v29 `ge_pv` sice povoleno, ale bez kapacity).
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
export type PlanMaskSlot = {
|
||||
allow_charge: boolean
|
||||
allow_discharge_export: boolean
|
||||
neg_sell_phase?: 'none' | 'prep' | 'tail' | null
|
||||
neg_sell_soc_target_wh?: number | null
|
||||
}
|
||||
|
||||
export type PlanSolverSnapshot = {
|
||||
@@ -47,9 +49,16 @@ export function parsePlanSolverSnapshot(
|
||||
const row = m as Record<string, unknown>
|
||||
const slot = recordString(row.slot)
|
||||
if (slot == null) continue
|
||||
const phaseRaw = row.neg_sell_phase
|
||||
const phase =
|
||||
phaseRaw === 'prep' || phaseRaw === 'tail' || phaseRaw === 'none'
|
||||
? phaseRaw
|
||||
: null
|
||||
masksByIso.set(slot, {
|
||||
allow_charge: recordBool(row.allow_charge),
|
||||
allow_discharge_export: recordBool(row.allow_discharge_export),
|
||||
neg_sell_phase: phase,
|
||||
neg_sell_soc_target_wh: recordNumber(row.neg_sell_soc_target_wh),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,11 +395,41 @@ function tableRowClass(
|
||||
if (buy != null && buy < 0) parts.push('bg-green-950/80')
|
||||
else if (sell != null && sell < 0) parts.push('bg-red-950/80')
|
||||
if ((i.pv_a_curtailed_w ?? 0) > 0) parts.push('border-l-4 border-l-yellow-500')
|
||||
else if (mask?.neg_sell_phase === 'prep' || mask?.neg_sell_phase === 'tail') {
|
||||
parts.push('border-l-4 border-l-violet-600/70')
|
||||
}
|
||||
else if (mask?.allow_charge) parts.push('border-l-4 border-l-emerald-600/70')
|
||||
else if (mask?.allow_discharge_export) parts.push('border-l-4 border-l-orange-500/70')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function pvAAllowedW(i: PlanningIntervalDto): number | null {
|
||||
const fc = i.pv_a_forecast_solver_w ?? i.pv_a_forecast_w
|
||||
if (fc == null) return null
|
||||
return Math.max(0, fc - (i.pv_a_curtailed_w ?? 0))
|
||||
}
|
||||
|
||||
function negSellPhaseBadge(mask: PlanMaskSlot | null): {
|
||||
label: string
|
||||
klass: string
|
||||
title: string
|
||||
} | null {
|
||||
const p = mask?.neg_sell_phase
|
||||
if (p == null || p === 'none') return null
|
||||
if (p === 'prep') {
|
||||
return {
|
||||
label: 'sell− prep',
|
||||
klass: 'bg-violet-500/20 text-violet-100 ring-1 ring-violet-500/40',
|
||||
title: 'Fáze přípravy SoC v okně záporného výkupu (cíl z DB, typ. 80 %)',
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: 'sell− tail',
|
||||
klass: 'bg-fuchsia-500/20 text-fuchsia-100 ring-1 ring-fuchsia-500/40',
|
||||
title: 'Závěr okna sell<0: rampa na plné SoC, volitelný ventil pole B',
|
||||
}
|
||||
}
|
||||
|
||||
function exportModeBadge(i: PlanningIntervalDto): {
|
||||
label: string
|
||||
klass: string
|
||||
@@ -543,6 +573,14 @@ function PlanSlotDetail({
|
||||
{mask?.allow_discharge_export ? (
|
||||
<span className="rounded bg-orange-950/60 px-2 py-0.5 text-[10px] text-orange-200">↓ export bat. OK</span>
|
||||
) : null}
|
||||
{(() => {
|
||||
const pb = negSellPhaseBadge(mask)
|
||||
return pb ? (
|
||||
<span className={`inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold ${pb.klass}`} title={pb.title}>
|
||||
{pb.label}
|
||||
</span>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
<dl className="mt-3 grid gap-2 font-mono text-xs sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
@@ -572,8 +610,21 @@ function PlanSlotDetail({
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-slate-500">Škrcení A</dt>
|
||||
<dd>{(i.pv_a_curtailed_w ?? 0) > 0 ? `${i.pv_a_curtailed_w} W` : '—'}</dd>
|
||||
<dt className="text-slate-500">Škrcení A / ≈ reg 340</dt>
|
||||
<dd>
|
||||
{(i.pv_a_curtailed_w ?? 0) > 0 ? (
|
||||
<span className="text-yellow-200">
|
||||
CURTAIL {(i.pv_a_curtailed_w ?? 0).toLocaleString('cs-CZ')} W
|
||||
</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
{pvAAllowedW(i) != null ? (
|
||||
<span className="ml-2 text-slate-400">
|
||||
· povoleno {pvAAllowedW(i)!.toLocaleString('cs-CZ')} W
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-slate-500">Výnos slotu</dt>
|
||||
@@ -1008,7 +1059,7 @@ export default function Planning() {
|
||||
[visibleSlots, selectedStart],
|
||||
)
|
||||
|
||||
const tableColCount = 13 + (solverSnap != null ? 1 : 0) + (showGenCut ? 1 : 0)
|
||||
const tableColCount = 14 + (solverSnap != null ? 1 : 0) + (showGenCut ? 1 : 0)
|
||||
|
||||
async function onReplan() {
|
||||
if (siteId == null) return
|
||||
@@ -1603,6 +1654,12 @@ export default function Planning() {
|
||||
</th>
|
||||
) : null}
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th>
|
||||
<th
|
||||
className="whitespace-nowrap py-2 pr-2 font-medium"
|
||||
title="Povolený výkon pole A (forecast − curtail) ≈ Deye reg 340 max solar"
|
||||
>
|
||||
PV A
|
||||
</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium" title="Budoucí sloty: korigovaná předpověď z delta profilu; proběhlé sloty z auditu.">
|
||||
<span className="block">FVE W</span>
|
||||
<span className="block text-[10px] font-normal normal-case text-slate-600">delta · audit</span>
|
||||
@@ -1656,6 +1713,8 @@ export default function Planning() {
|
||||
const sel = selectedStart === i.interval_start
|
||||
const slotMask = maskForInterval(solverSnap, i.interval_start)
|
||||
const exBadge = exportModeBadge(i)
|
||||
const phaseBadge = negSellPhaseBadge(slotMask)
|
||||
const pvAllowed = pvAAllowedW(i)
|
||||
return (
|
||||
<tr
|
||||
key={i.interval_start}
|
||||
@@ -1729,6 +1788,24 @@ export default function Planning() {
|
||||
? `${i.battery_soc_target_pct.toFixed(1)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{pvAllowed != null ? pvAllowed.toLocaleString('cs-CZ') : '—'}
|
||||
{(i.pv_a_curtailed_w ?? 0) > 0 ? (
|
||||
<span className="rounded bg-yellow-500/20 px-1 py-0.5 text-[9px] font-semibold text-yellow-200">
|
||||
CURTAIL
|
||||
</span>
|
||||
) : null}
|
||||
{phaseBadge ? (
|
||||
<span
|
||||
className={`rounded px-1 py-0.5 text-[9px] font-semibold ${phaseBadge.klass}`}
|
||||
title={phaseBadge.title}
|
||||
>
|
||||
{phaseBadge.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<FveWCell i={i} nowMs={nowMs} />
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">
|
||||
{formatPlanPowerW(i.load_baseline_w)}
|
||||
|
||||
Reference in New Issue
Block a user