diff --git a/CLAUDE.md b/CLAUDE.md index b3e483d..4c8feac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 5b27442..df4fd4f 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -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"] diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 83f8503..512d784 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -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() diff --git a/db/migration/V083__planner_neg_sell_phases.sql b/db/migration/V083__planner_neg_sell_phases.sql new file mode 100644 index 0000000..221e5e1 --- /dev/null +++ b/db/migration/V083__planner_neg_sell_phases.sql @@ -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; diff --git a/db/routines/R__039_fn_planning_site_context.sql b/db/routines/R__039_fn_planning_site_context.sql index 2f7e3f4..d19387d 100644 --- a/db/routines/R__039_fn_planning_site_context.sql +++ b/db/routines/R__039_fn_planning_site_context.sql @@ -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 diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index f935860..227684b 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -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) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index eb70e2a..4e24808 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -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í — diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 665d246..53977b7 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -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). diff --git a/frontend/src/lib/planSolverSnapshot.ts b/frontend/src/lib/planSolverSnapshot.ts index df5b2f2..892a713 100644 --- a/frontend/src/lib/planSolverSnapshot.ts +++ b/frontend/src/lib/planSolverSnapshot.ts @@ -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 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), }) } } diff --git a/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx index a6a6f8e..761be00 100644 --- a/frontend/src/pages/Planning.tsx +++ b/frontend/src/pages/Planning.tsx @@ -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 ? ( ↓ export bat. OK ) : null} + {(() => { + const pb = negSellPhaseBadge(mask) + return pb ? ( + + {pb.label} + + ) : null + })()}
@@ -572,8 +610,21 @@ function PlanSlotDetail({
-
Škrcení A
-
{(i.pv_a_curtailed_w ?? 0) > 0 ? `${i.pv_a_curtailed_w} W` : '—'}
+
Škrcení A / ≈ reg 340
+
+ {(i.pv_a_curtailed_w ?? 0) > 0 ? ( + + CURTAIL {(i.pv_a_curtailed_w ?? 0).toLocaleString('cs-CZ')} W + + ) : ( + '—' + )} + {pvAAllowedW(i) != null ? ( + + · povoleno {pvAAllowedW(i)!.toLocaleString('cs-CZ')} W + + ) : null} +
Výnos slotu
@@ -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() { ) : null} SoC % + + PV A + FVE W delta · audit @@ -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 ( + +
+ {pvAllowed != null ? pvAllowed.toLocaleString('cs-CZ') : '—'} + {(i.pv_a_curtailed_w ?? 0) > 0 ? ( + + CURTAIL + + ) : null} + {phaseBadge ? ( + + {phaseBadge.label} + + ) : null} +
+ {formatPlanPowerW(i.load_baseline_w)}