implementace co nejdrive dosazeni SOC na home-01 a umozneni plneho socu n slotu ped koncem sell < 0
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-26 08:07:00 +02:00
parent 8494ea26de
commit 91a9bef3d7
10 changed files with 566 additions and 25 deletions

View File

@@ -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[T1]` (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[T1]` (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 **6264** (č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`.

View File

@@ -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"]

View File

@@ -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()

View 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;

View File

@@ -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

View File

@@ -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)

View File

@@ -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í —

View File

@@ -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).

View File

@@ -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),
})
}
}

View File

@@ -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)}