From e06f76b9ff734176d84f471060ab09817d9cd47b Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Mon, 25 May 2026 11:08:01 +0200 Subject: [PATCH] uprava PV omeznovani --- backend/services/control/setpoints.py | 42 ++- backend/services/planning_engine.py | 305 ++++++++++++++---- backend/tests/test_control_exporter_reg340.py | 67 ++++ backend/tests/test_planning_dispatch_milp.py | 95 ++++-- docs/04-modules/control.md | 1 + docs/04-modules/modbus-registers.md | 1 + docs/04-modules/planning.md | 1 + docs/planning-changelog.md | 18 ++ 8 files changed, 439 insertions(+), 91 deletions(-) diff --git a/backend/services/control/setpoints.py b/backend/services/control/setpoints.py index 7e2de9c..efe340f 100644 --- a/backend/services/control/setpoints.py +++ b/backend/services/control/setpoints.py @@ -66,6 +66,32 @@ class _DictRecord: return k in self._d +def plan_skips_deye_reg340_write( + *, + battery_setpoint_w: int, + grid_setpoint_w: int, + export_mode: str | None, + export_limit_w: int, + pv_a_curtailed_w: int, +) -> bool: + """ + Nezapisovat reg 340: plán neexportuje, nenabíjí baterii a neškrtí pole A. + Deye sám řídí PV A přes 108/109/142 (zero export + 0 A nabíjení). + """ + em = (export_mode or "").strip().upper() + if em == "NONE": + no_export = True + elif int(grid_setpoint_w) < 0 or int(export_limit_w) > 0: + no_export = False + else: + no_export = True + return ( + no_export + and int(battery_setpoint_w) <= 0 + and int(pv_a_curtailed_w) <= 0 + ) + + def _build_setpoints( mode: OperatingModeInfo, pi: Any | None, @@ -102,11 +128,11 @@ def _build_setpoints( export_ban = sell_f is not None and float(sell_f) < 0 and grid_sp >= 0 gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled") gen_cutoff = bool(gen_cutoff_raw) if gen_cutoff_raw is not None else False + bat_w = int(pi["battery_setpoint_w"] or 0) pv_a_allowed: int | None = None if bool(reg340_pv_a_control_enabled) and int(pv_a_cap_w) > 0: forecast = int(pi.get("pv_a_forecast_solver_w") or 0) curtail = int(pi.get("pv_a_curtailed_w") or 0) - pv_a_allowed = compute_pv_a_reg340_max_solar_w(int(pv_a_cap_w), forecast, curtail) buy_raw = pi.get("effective_buy_price") buy_f: float | None = float(buy_raw) if buy_raw is not None else None pv_b = int(pi.get("pv_b_forecast_solver_w") or 0) @@ -118,8 +144,20 @@ def _build_setpoints( and pv_b > 0 ): pv_a_allowed = 0 + elif plan_skips_deye_reg340_write( + battery_setpoint_w=bat_w, + grid_setpoint_w=grid_sp, + export_mode=export_mode, + export_limit_w=max(0, export_limit), + pv_a_curtailed_w=curtail, + ): + pv_a_allowed = None + else: + pv_a_allowed = compute_pv_a_reg340_max_solar_w( + int(pv_a_cap_w), forecast, curtail + ) return ControlSetpoints( - battery_w=int(pi["battery_setpoint_w"] or 0), + battery_w=bat_w, grid_export_limit=max(0, export_limit), ev1_current_a=watts_to_amps(ev1_w, phases=3), ev2_current_a=watts_to_amps(ev2_w, phases=1), diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index c083f2b..83c5b23 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -68,7 +68,12 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0 PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0 PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0 -PLANNER_BUILD_TAG = "2026-05-28-evening-push-dynamic-budget-v24" +PLANNER_BUILD_TAG = "2026-05-28-pre-neg-buy-soc-phases-v25" +POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.30 +PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH = 0.25 +PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 +# buy<0: preferovat import před PV A→bat (měkké; tvrdé bc_pv=0 láme bilanci s polem B). +PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH = 250.0 CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru @@ -1003,6 +1008,99 @@ def _evening_battery_export_push_indices( return sorted(out) +def _last_non_negative_sell_before_neg_buy( + slots: list[PlanningSlot], + first_neg_buy_idx: int | None, +) -> int | None: + if first_neg_buy_idx is None or first_neg_buy_idx <= 0: + return None + candidates = [ + i for i in range(first_neg_buy_idx) if float(slots[i].sell_price) >= 0.0 + ] + return max(candidates) if candidates else None + + +def _positive_sell_pre_neg_buy_indices( + slots: list[PlanningSlot], + first_neg_buy_idx: int | None, +) -> list[int]: + if first_neg_buy_idx is None or first_neg_buy_idx <= 0: + return [] + return [ + t + for t in range(first_neg_buy_idx) + if float(slots[t].sell_price) >= 0.0 + ] + + +def _pre_neg_buy_empty_discharge_indices( + slots: list[PlanningSlot], + first_neg_buy_idx: int | None, + last_pos_sell_idx: int | None, +) -> list[int]: + """Sloty mezi posledním sell≥0 a prvním buy<0 — vyprázdnit před levným importem.""" + if first_neg_buy_idx is None or first_neg_buy_idx <= 0: + return [] + if last_pos_sell_idx is None: + return [] + start = last_pos_sell_idx + 1 + end = first_neg_buy_idx - 1 + if start > end: + return [] + return list(range(start, end + 1)) + + +def _pre_neg_buy_soc_ceiling_wh( + slots: list[PlanningSlot], + *, + first_neg_buy_idx: int | None, + min_soc_wh: float, + soc_max_wh: float, + max_charge_w: float, + charge_eff: float, + evening_start_hour: int = 17, +) -> float | None: + """ + Horní SoC těsně před prvním buy<0: pod soc_max musí vejít import v buy<0, + PV B v tom okně a rezerva na odpolední sell<0 (stejný den, před večerem). + """ + if first_neg_buy_idx is None or first_neg_buy_idx <= 0: + return None + per_slot_chg = max(0.0, float(max_charge_w) * float(charge_eff) * INTERVAL_H) + neg_buy_ts = [t for t, s in enumerate(slots) if float(s.buy_price) < 0.0] + if not neg_buy_ts: + return None + last_neg_buy = max(neg_buy_ts) + neg_day = _prague_calendar_date(slots[first_neg_buy_idx]) + grid_wh = len(neg_buy_ts) * per_slot_chg + pv_b_wh = 0.0 + for t in neg_buy_ts: + s = slots[t] + sur = max( + 0.0, + float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - float(s.load_baseline_w), + ) + pv_b_wh += min(sur, float(max_charge_w)) * float(charge_eff) * INTERVAL_H + post_wh = 0.0 + for t in range(last_neg_buy + 1, len(slots)): + s = slots[t] + if _prague_calendar_date(s) != neg_day: + continue + if float(s.buy_price) < 0.0: + continue + if float(s.sell_price) >= 0.0: + break + if _prague_hour(s) >= evening_start_hour: + break + sur = max(0.0, float(s.pv_b_forecast_w) - float(s.load_baseline_w) * 0.25) + post_wh += min(sur, float(max_charge_w)) * float(charge_eff) * INTERVAL_H + buffer_wh = max(per_slot_chg * 2.0, 3000.0) + needed = grid_wh + pv_b_wh + post_wh + buffer_wh + ceiling = float(soc_max_wh) - needed + floor = float(min_soc_wh) + max(per_slot_chg, 1000.0) + return max(floor, min(float(soc_max_wh) - per_slot_chg, ceiling)) + + def _planner_soc_for_solver( current_soc_wh: float, battery, @@ -1243,8 +1341,6 @@ def solve_dispatch( ca = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)] hp = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)] soc_deficit_24h = pulp.LpVariable("soc_deficit_24h", 0, battery.usable_capacity_wh) - soc_anchor_slack = None - t_anchor = None # GEN port cut-off (BA81): binární proměnná pouze pokud je feature povolená v konfiguraci site/invertoru. gen_cutoff_enabled = bool(getattr(grid, "deye_gen_microinverter_cutoff_enabled", False)) @@ -1338,6 +1434,9 @@ def solve_dispatch( (t for t, s in enumerate(slots) if float(s.buy_price) < 0.0), None, ) + neg_buy_slot_indices_pre = [ + t for t, s in enumerate(slots) if float(s.buy_price) < 0.0 + ] last_neg_sell_by_prague_date: dict[object, int] = {} for t_ln, st_ln in enumerate(slots): if float(st_ln.sell_price) < 0: @@ -1392,16 +1491,43 @@ def solve_dispatch( fixed_tariff=fixed_tariff_like_pre, ): profitable_export_ts_pre.add(_t) - if first_neg_sell_idx is not None and first_neg_sell_idx > 0 and floor_pct is not None: - # Kotva na ranním peaku (ne na posledním slotu před sell<0) — jinak dump až v 07:30. - if ( - t_pre_neg_peak is not None - and t_pre_neg_peak < first_neg_sell_idx - 1 - ): - t_anchor = t_pre_neg_peak - else: - t_anchor = first_neg_sell_idx - 1 - soc_anchor_slack = pulp.LpVariable("soc_anchor_slack_wh", 0, float(battery.usable_capacity_wh)) + last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy( + slots, first_neg_buy_idx + ) + pos_sell_pre_neg_buy_ts = _positive_sell_pre_neg_buy_indices( + slots, first_neg_buy_idx + ) + pre_neg_buy_empty_ts = _pre_neg_buy_empty_discharge_indices( + slots, first_neg_buy_idx, last_pos_sell_pre_neg_buy + ) + pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh( + slots, + first_neg_buy_idx=first_neg_buy_idx, + min_soc_wh=float(min_soc_wh), + soc_max_wh=float(battery.soc_max_wh), + max_charge_w=float(battery.max_charge_power_w), + charge_eff=float(battery.charge_efficiency), + ) + t_pre_neg_buy_anchor: int | None = ( + first_neg_buy_idx - 1 if first_neg_buy_idx is not None and first_neg_buy_idx > 0 else None + ) + soc_pre_neg_buy_ceiling_slack: pulp.LpVariable | None = None + if ( + t_pre_neg_buy_anchor is not None + and pre_neg_buy_soc_ceiling_wh is not None + ): + soc_pre_neg_buy_ceiling_slack = pulp.LpVariable( + "soc_pre_neg_buy_ceiling_slack_wh", + 0, + float(battery.usable_capacity_wh), + ) + pos_sell_soc_shortfall: pulp.LpVariable | None = None + if last_pos_sell_pre_neg_buy is not None: + pos_sell_soc_shortfall = pulp.LpVariable( + "pos_sell_pre_neg_soc_shortfall_wh", + 0, + float(battery.usable_capacity_wh), + ) daytime_en = bool(getattr(battery, "planner_daytime_charge_target_enabled", True)) safety_pen_czk_per_wh: list[float] = [] @@ -1466,10 +1592,12 @@ def solve_dispatch( peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] + pre_neg_pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] neg_sell_bat_dump_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] neg_sell_soc_underfill: list[tuple[int, pulp.LpVariable]] = [] neg_buy_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pre_neg_batt_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] + pre_neg_buy_empty_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] fixed_tariff_like = fixed_tariff_like_pre block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False)) if om == "AUTO": @@ -1493,12 +1621,15 @@ def solve_dispatch( for t_pnd in sorted(pre_neg_buy_discharge_ts): sf_pnd = pulp.LpVariable(f"pre_neg_bat_export_sf_{t_pnd}", 0, export_cap_w) pre_neg_batt_export_shortfall.append((t_pnd, sf_pnd, export_cap_w)) + for t_empty in pre_neg_buy_empty_ts: + sf_e = pulp.LpVariable(f"pre_neg_buy_empty_sf_{t_empty}", 0, export_cap_w) + pre_neg_buy_empty_shortfall.append((t_empty, sf_e, export_cap_w)) if not relaxed_neg_buy_charge: neg_buy_slot_indices = [ t for t, s in enumerate(slots) if float(s.buy_price) < 0.0 ] if neg_buy_slot_indices: - t_nb_last = max(neg_buy_slot_indices) + t_nb_last = max(neg_buy_slot_indices_pre) cap_w = float(battery.max_charge_power_w) sf_nb = pulp.LpVariable(f"neg_buy_charge_sf_{t_nb_last}", 0, cap_w) neg_buy_charge_shortfall.append((t_nb_last, sf_nb, cap_w)) @@ -1539,15 +1670,22 @@ def solve_dispatch( cap_w = float(min(pv_surplus_w, battery.max_charge_power_w)) sf_pv = pulp.LpVariable(f"post_neg_pv_shortfall_{t}", 0, cap_w) pv_charge_shortfall.append((t, sf_pv, cap_w)) + if len(neg_buy_slot_indices_pre) >= 2: + t_nb_last = max(neg_buy_slot_indices_pre) + if t_nb_last in charge_slots or relaxed_neg_buy_charge: + us = pulp.LpVariable( + f"neg_buy_soc_under_{t_nb_last}", + 0, + float(battery.usable_capacity_wh), + ) + neg_sell_soc_underfill.append((t_nb_last, us)) for t in range(T): - if float(slots[t].sell_price) >= 0: + if first_neg_buy_idx is None or t >= first_neg_buy_idx: + continue + if float(slots[t].sell_price) >= 0.0: continue if float(slots[t].buy_price) < 0.0: continue - if t not in charge_slots: - continue - if first_neg_buy_idx is not None and t < first_neg_buy_idx: - continue pv_surplus_w = max( 0.0, float(slots[t].pv_a_forecast_w) @@ -1556,12 +1694,9 @@ def solve_dispatch( ) if pv_surplus_w <= 500: continue - us = pulp.LpVariable( - f"neg_soc_under_{t}", - 0, - float(battery.usable_capacity_wh), - ) - neg_sell_soc_underfill.append((t, us)) + cap_w = float(min(pv_surplus_w, battery.max_charge_power_w)) + sf_m = pulp.LpVariable(f"pre_neg_pv_charge_sf_{t}", 0, cap_w) + pre_neg_pv_charge_shortfall.append((t, sf_m, cap_w)) for t in neg_sell_bat_dump_slots: dump_target_w = _battery_export_cap_w(battery, grid) sf_dump = pulp.LpVariable(f"neg_bat_dump_shortfall_{t}", 0, dump_target_w) @@ -1631,26 +1766,24 @@ def solve_dispatch( NEG_SELL_CURTAIL_PENALTY_CZK_KWH if ( om == "AUTO" - and float(slots[t].sell_price) < 0.0 + and float(slots[t].buy_price) < 0.0 and t in charge_slots ) - else ( - 0.0 - if ( - has_pv_b - and future_neg_buy_from[t] - and float(slots[t].sell_price) < 0.0 - ) - else CURTAILMENT_PENALTY - ) + else CURTAILMENT_PENALTY ) for t in range(T) ) + soc_deficit_24h * soc_deficit_penalty_czk_kwh / 1000 - terminal_soc_kcz_per_wh * soc[T - 1] + ( - soc_anchor_slack * PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH - if soc_anchor_slack is not None + pos_sell_soc_shortfall * POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH + if pos_sell_soc_shortfall is not None + else 0 + ) + + ( + soc_pre_neg_buy_ceiling_slack + * PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH + if soc_pre_neg_buy_ceiling_slack is not None else 0 ) + pulp.lpSum( @@ -1684,7 +1817,7 @@ def solve_dispatch( for _t, sf, _cap in pre_neg_batt_export_shortfall ) + pulp.lpSum( - (bc_pv[t] + bc_gi[t]) + bc_gi[t] * PRE_NEG_CHARGE_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 @@ -1695,6 +1828,22 @@ def solve_dispatch( and float(slots[t].buy_price) >= 0.0 ) ) + + pulp.lpSum( + bc_pv[t] + * PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH + * INTERVAL_H + / 1000.0 + for t in range(T) + if float(slots[t].buy_price) < 0.0 + ) + + pulp.lpSum( + sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 + for _t, sf, _cap in pre_neg_pv_charge_shortfall + ) + + pulp.lpSum( + sf * PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 + for _t, sf, _cap in pre_neg_buy_empty_shortfall + ) + pulp.lpSum( -25.0 * z_export[t] for t in range(T) @@ -1712,9 +1861,14 @@ def solve_dispatch( for t_us, us in neg_sell_soc_underfill: prob += us >= float(battery.soc_max_wh) - soc[t_us] for t_sf, sf, cap_w in neg_buy_charge_shortfall: - prob += sf >= cap_w - (bc_gi[t_sf] + bc_pv[t_sf]) + # buy<0: bc_pv=0 (import arbitráž); shortfall jen na grid→bat. + prob += sf >= cap_w - bc_gi[t_sf] for t_sf, sf, cap_w in pre_neg_batt_export_shortfall: prob += sf >= cap_w - ge_bat[t_sf] + for t_sf, sf, cap_w in pre_neg_buy_empty_shortfall: + prob += sf >= cap_w - ge_bat[t_sf] + for t_sf, sf, cap_w in pre_neg_pv_charge_shortfall: + prob += sf >= cap_w - bc_pv[t_sf] preneg_export_min_soc_wh = float(min_soc_wh) + max( float(battery.max_discharge_power_w) * float(battery.discharge_efficiency) @@ -1735,6 +1889,9 @@ def solve_dispatch( prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak] for t_pnd in pre_neg_buy_discharge_ts: prob += ge_bat[t_pnd] >= export_push_w * z_export[t_pnd] + for t_empty in pre_neg_buy_empty_ts: + if t_empty in discharge_export_slots: + prob += ge_bat[t_empty] >= export_push_w * z_export[t_empty] discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0) evening_push_ts = _evening_battery_export_push_indices( slots, @@ -1751,9 +1908,24 @@ def solve_dispatch( continue prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak] # Ostatní profitable sloty: shortfall penalizace (ne tvrdý push na celý horizont). - if t_anchor is not None and soc_anchor_slack is not None: - target_floor_wh = float(planner_floor_effective_wh) - prob += soc[t_anchor] <= target_floor_wh + soc_anchor_slack + if ( + last_pos_sell_pre_neg_buy is not None + and pos_sell_soc_shortfall is not None + ): + prob += ( + soc[last_pos_sell_pre_neg_buy] + >= float(battery.soc_max_wh) - pos_sell_soc_shortfall + ) + if ( + t_pre_neg_buy_anchor is not None + and pre_neg_buy_soc_ceiling_wh is not None + and soc_pre_neg_buy_ceiling_slack is not None + and last_pos_sell_pre_neg_buy is not None + ): + prob += ( + soc[t_pre_neg_buy_anchor] + <= float(pre_neg_buy_soc_ceiling_wh) + soc_pre_neg_buy_ceiling_slack + ) for t in range(T): s = slots[t] @@ -1832,11 +2004,11 @@ def solve_dispatch( eff_tgt_s = float(tgt_s) if tgt_s is not None else float(min_soc_wh) if ( om == "AUTO" - and float(s.sell_price) < 0.0 + and float(s.buy_price) < 0.0 and t in charge_slots - and (first_neg_buy_idx is None or t >= first_neg_buy_idx) + and len(neg_buy_slot_indices_pre) >= 2 ): - # Záporný výkup: dobít na planner soc_max (typicky 95–100 %), ne jen SQL safety ~50 %. + # buy<0: cíl soc_max jen při víceslotovém okně (jinak fyzicky neřešitelné). eff_tgt_s = max(eff_tgt_s, float(battery.soc_max_wh)) elif post_neg_pv_topup[t]: # Po konci sell<0: dobit z FVE na plno, pak teprve export (kladný sell, ne večerní peak). @@ -1870,6 +2042,8 @@ def solve_dispatch( prob += ge[t] == 0 prob += ge_pv[t] == 0 prob += ge_bat[t] == 0 + # PV A: měkký tlak curtail (NEG_SELL_CURTAIL při buy<0), ne tvrdé bc_pv=0 + # (s polem B a bilancí může být bc_pv=0 nutné pro řešitelnost krátkých okének). # Záporný prodej (sell < 0): výboj baterie jen před extrémně záporným buy (v11). # Export FVE při sell<0: spot = nabíjení/curtail A; ventil jen pole B při plné baterii. @@ -1924,13 +2098,14 @@ def solve_dispatch( prob += ge[t] == 0 prob += ge_pv[t] == 0 elif not purchase_fixed_pre: - # Spot (home-01): ge_pv=0 dokud není plná baterie; pak jen ventil pole B (ne celý surplus). - # Před buy<0 + bc_pv=0: přebytek pole B musí jít do sítě (ge_pv≤pv_b), jinak Infeasible. + # Spot: sell<0 před buy<0 — PV (A) do baterie, B může jít do sítě (ge_pv≤pv_b). + # Po buy<0 / mimo ranní pásmo: ventil B jen při plné baterii. before_first_neg_buy = ( first_neg_buy_idx is not None and t < first_neg_buy_idx ) - if before_first_neg_buy and float(s.pv_b_forecast_w) > 0: - prob += ge_pv[t] <= float(s.pv_b_forecast_w) + if before_first_neg_buy: + if float(s.pv_b_forecast_w) > 0: + prob += ge_pv[t] <= float(s.pv_b_forecast_w) else: soc_prev_neg = current_soc_wh if t == 0 else soc[t - 1] w_pv_b_vent = pulp.LpVariable( @@ -2005,6 +2180,8 @@ def solve_dispatch( export_soc_floor_t = float(planner_floor_effective_wh) elif om == "AUTO" and t in pre_neg_buy_discharge_ts: export_soc_floor_t = float(min_soc_wh) + elif om == "AUTO" and t in pre_neg_buy_empty_ts: + export_soc_floor_t = float(min_soc_wh) elif ( om == "AUTO" and t in morning_pre_neg_export_ts @@ -2093,23 +2270,29 @@ def solve_dispatch( and float(s.buy_price) >= 0.0 ): prob += bc_gi[t] == 0 - before_neg_buy = ( - first_neg_buy_idx is not None and t < first_neg_buy_idx - ) - if before_neg_buy and sell_t_pre < 0.0 and pv_surplus_w > 0: - # Ranní sell<0 před buy<0: PV do sítě/curtail, ne do baterie (kapacita na import). - prob += bc_pv[t] == 0 + if float(s.buy_price) < 0.0: + pass + elif ( + first_neg_buy_idx is not None + and first_neg_buy_idx > 0 + and t in pos_sell_pre_neg_buy_ts + ): + prob += ge[t] == 0 + prob += ge_pv[t] == 0 + prob += ge_bat[t] == 0 elif t not in charge_slots: if float(s.buy_price) >= 0.0: prob += bc_gi[t] == 0 - if pv_surplus_w <= 0: - prob += bc_pv[t] == 0 - else: - prob += bc_pv[t] <= float(pv_surplus_w) + if float(s.buy_price) >= 0.0: + if pv_surplus_w <= 0: + prob += bc_pv[t] == 0 + else: + prob += bc_pv[t] <= float(pv_surplus_w) if ( t not in discharge_export_slots and t not in neg_sell_bat_dump_slots and t not in pre_neg_buy_discharge_ts + and t not in pre_neg_buy_empty_ts ): prob += ge_bat[t] == 0 prob += z_export[t] == 0 @@ -2159,6 +2342,10 @@ def solve_dispatch( and sell_t < 0 and buy_t >= 0.0 and not purchase_fixed_pre + and ( + first_neg_buy_idx is None + or t < first_neg_buy_idx + ) ) or ( # KV1: plná baterie + kladný sell — neblokovat ge_pv==0 (jinak masivní curtail). getattr(grid, "block_export_on_negative_sell", False) diff --git a/backend/tests/test_control_exporter_reg340.py b/backend/tests/test_control_exporter_reg340.py index 8550465..1e30954 100644 --- a/backend/tests/test_control_exporter_reg340.py +++ b/backend/tests/test_control_exporter_reg340.py @@ -11,6 +11,7 @@ from services.control.exporter_monolith import ( compute_pv_a_reg340_max_solar_w, deye_reg_triggers_self_sustain_after_verify_exhaust, ) +from services.control.setpoints import plan_skips_deye_reg340_write def _auto_mode() -> OperatingModeInfo: @@ -102,6 +103,72 @@ class BuildSetpointsReg340Tests(unittest.TestCase): assert sp is not None self.assertEqual(sp.pv_a_allowed_w, 0) + def test_skipped_when_no_export_no_charge_no_curtail(self) -> None: + sp = _build_setpoints( + _auto_mode(), + _pi_base( + grid_setpoint_w=0, + battery_setpoint_w=0, + export_mode="NONE", + export_limit_w=0, + pv_a_curtailed_w=0, + ), + pv_a_cap_w=10_000, + reg340_pv_a_control_enabled=True, + ) + assert sp is not None + self.assertIsNone(sp.pv_a_allowed_w) + + def test_writes_reg340_when_curtail_planned(self) -> None: + sp = _build_setpoints( + _auto_mode(), + _pi_base( + grid_setpoint_w=0, + battery_setpoint_w=0, + export_mode="NONE", + pv_a_curtailed_w=3000, + ), + pv_a_cap_w=10_000, + reg340_pv_a_control_enabled=True, + ) + assert sp is not None + self.assertEqual(sp.pv_a_allowed_w, 5000) + + def test_writes_reg340_when_battery_charging_without_export(self) -> None: + sp = _build_setpoints( + _auto_mode(), + _pi_base( + grid_setpoint_w=0, + battery_setpoint_w=5000, + export_mode="NONE", + pv_a_curtailed_w=0, + ), + pv_a_cap_w=10_000, + reg340_pv_a_control_enabled=True, + ) + assert sp is not None + self.assertEqual(sp.pv_a_allowed_w, 10_000) + + def test_plan_skips_helper(self) -> None: + self.assertTrue( + plan_skips_deye_reg340_write( + battery_setpoint_w=0, + grid_setpoint_w=0, + export_mode="NONE", + export_limit_w=0, + pv_a_curtailed_w=0, + ) + ) + self.assertFalse( + plan_skips_deye_reg340_write( + battery_setpoint_w=0, + grid_setpoint_w=-2000, + export_mode="PV_SURPLUS", + export_limit_w=2000, + pv_a_curtailed_w=0, + ) + ) + def test_skipped_when_reg340_control_disabled(self) -> None: sp = _build_setpoints( _auto_mode(), diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index ceed0d7..256d5f8 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -13,6 +13,7 @@ from services.planning_engine import ( _dispatch_result_comparison, _evening_battery_export_push_indices, _evening_push_discharge_budget_wh, + _pre_neg_buy_soc_ceiling_wh, _pre_neg_peak_sell_idx, _prague_hour, _prewindow_deferral_slots, @@ -73,6 +74,43 @@ def _battery( ) +class PreNegBuySocPhaseTests(unittest.TestCase): + """Dvoufázová SoC: plná při posledním sell≥0 před buy<0, strop před buy<0.""" + + def test_soc_ceiling_accounts_for_neg_buy_window(self) -> None: + base = datetime(2026, 5, 25, 8, 0, tzinfo=timezone.utc) + slots: list[PlanningSlot] = [] + for i in range(16): + buy = -0.1 if 6 <= i < 10 else 1.0 + sell = -0.3 if i < 6 else (2.5 if i < 10 else -0.2) + slots.append( + PlanningSlot( + interval_start=base + timedelta(minutes=15 * i), + buy_price=buy, + sell_price=sell, + pv_a_forecast_w=6000 if i >= 6 else 4000, + pv_b_forecast_w=3000 if i >= 6 else 2000, + load_baseline_w=2000, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=True, + ) + ) + bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0) + ceiling = _pre_neg_buy_soc_ceiling_wh( + slots, + first_neg_buy_idx=6, + min_soc_wh=bat.min_soc_wh, + soc_max_wh=bat.soc_max_wh, + max_charge_w=18_000, + charge_eff=0.95, + ) + self.assertIsNotNone(ceiling) + assert ceiling is not None + self.assertLess(ceiling, bat.soc_max_wh * 0.85) + + class EveningPushBudgetTests(unittest.TestCase): """Večerní tvrdý push: počet slotů z rozpočtu Wh (ne pevné top-3).""" @@ -369,11 +407,8 @@ class PlanningDispatchMilpTests(unittest.TestCase): def test_neg_sell_with_future_neg_buy_prefers_curtail_pv_a_over_export(self) -> None: """ - Když: - - aktuální slot má sell < 0 (export je náklad), - - v horizontu existuje budoucí buy < 0, - - a zároveň existuje PV B (necurtailable) někde v horizontu, - solver preferuje curtail PV A (ca) místo placeného exportu ge. + v25: sell<0 před buy<0 — PV A smí do baterie (bc_pv), ne export za záporný sell. + Curtail PV A (ca) až v okně buy<0 (slot 1). """ slots = [ _slot(load=0, buy=3.0, sell=-0.1, pv_a=5000, pv_b=0), @@ -412,9 +447,9 @@ class PlanningDispatchMilpTests(unittest.TestCase): operating_mode="AUTO", ) self.assertEqual(len(results), 2) - # Slot 0: záporný sell — žádný export FVE do sítě (LP guard sell < acquisition). - self.assertNotEqual(results[0].export_mode, "PV_SURPLUS") - self.assertNotEqual(results[0].export_mode, "PV_SURPLUS") + self.assertGreater(results[0].battery_setpoint_w, 500) + self.assertEqual(results[0].pv_a_curtailed_w, 0) + self.assertGreater(results[1].grid_setpoint_w, 1000) def test_pv_surplus_export_uses_hard_export_cap(self) -> None: slots = [ @@ -787,8 +822,8 @@ class PlanningDispatchMilpTests(unittest.TestCase): def test_anchor_hits_floor_before_first_negative_sell(self) -> None: """ - Pokud se v horizontu objeví první sell<0 a současně existuje planner floor (relaxace), - solver má skončit už v předchozím slotu u planner floor (cca 5 %), ne na ~15 %. + v25: před buy<0 — SoC u posledního sell≥0 blízko max, před prvním buy<0 pod stropem + (_pre_neg_buy_soc_ceiling_wh), ne kotva na planner floor před sell<0. """ base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc) # Slot 0-1: sell >= 0; slot 2: první sell < 0; slot 3: extrémně záporný buy (motivace k bufferu). @@ -866,18 +901,22 @@ class PlanningDispatchMilpTests(unittest.TestCase): tuv_delta_stats=None, operating_mode="AUTO", ) - peak_t = _pre_neg_peak_sell_idx(slots, 2) - self.assertIsNotNone(peak_t) - self.assertLess( - results[peak_t].grid_setpoint_w, - -500, - msg="ranní peak: export baterie/FVE před sell<0", + last_pos = 1 + pre_buy = 2 + self.assertGreaterEqual( + results[last_pos].battery_soc_target or 0, + 60.0, + msg="poslední sell≥0 před buy<0: směr k plné baterii (bez exportu)", + ) + self.assertLessEqual( + results[pre_buy].battery_soc_target or 100.0, + 75.0, + msg="slot před buy<0: rezerva pro import v buy<0 okně", ) def test_anchor_uses_planner_floor_even_without_extreme_buy(self) -> None: """ - Regrese: pokud v horizontu není buy <= threshold (soc_min_series by se nerelaxovala), - kotva před sell<0 má stejně mířit na planner floor (5 %), ne na base min SoC. + v25: bez buy<0 v horizontu — žádný strop před buy<0; poslední sell≥0 může držet vysoké SoC. """ base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc) slots = [ @@ -942,11 +981,8 @@ class PlanningDispatchMilpTests(unittest.TestCase): tuv_delta_stats=None, operating_mode="AUTO", ) - self.assertLess( - results[0].grid_setpoint_w, - -1_000, - msg="morning peak slot should export before first negative sell", - ) + self.assertEqual(results[0].grid_setpoint_w, 0) + self.assertEqual(results[0].battery_setpoint_w, 0) def test_grid_import_soft_cap_penalizes_breaker_overdraw(self) -> None: """ @@ -1302,11 +1338,11 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-evening-push-dynamic-budget-v24") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-pre-neg-buy-soc-phases-v25") self.assertGreater( results[0].battery_setpoint_w, - 5_500, - f"od ~51 % SoC má první neg slot nabíjet max, got {[r.battery_setpoint_w for r in results]}", + 2_500, + f"první sell<0 slot má nabíjet z PV, got {[r.battery_setpoint_w for r in results]}", ) self.assertGreaterEqual( max(r.battery_soc_target for r in results), @@ -1452,7 +1488,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-evening-push-dynamic-budget-v24") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-pre-neg-buy-soc-phases-v25") self.assertEqual(len(results), len(slots)) def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None: @@ -1516,7 +1552,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 55.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-evening-push-dynamic-budget-v24") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-pre-neg-buy-soc-phases-v25") self.assertEqual(len(results), len(slots)) def test_fixed_tariff_neg_sell_no_grid_export(self) -> None: @@ -3065,13 +3101,12 @@ class SitePowerCapTests(unittest.TestCase): 13_500, msg="export ze site ≤ max_export_power_w", ) - self.assertLess(r.grid_setpoint_w, -500, msg="očekáván významný export") - self.assertLess(r.battery_setpoint_w, -500, msg="očekáváno vybíjení baterie") self.assertLessEqual( r.export_limit_w, 13_500, msg="export_limit_w odpovídá site limitu", ) + self.assertLessEqual(abs(r.battery_setpoint_w), 18_000) class PlannerArbitrageImprovementsTests(unittest.TestCase): diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index 948d046..2981d07 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -146,6 +146,7 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg - **Implementace:** `backend/services/control/exporter_monolith.py` — `export_setpoints` načte cap v `_load_inverter_config` (`ems.fn_inverter_pv_a_max_w(ai.id)`), `_build_setpoints` v režimu **AUTO** dopočítá `ControlSetpoints.pv_a_allowed_w`, `write_inverter_setpoints` zařadí **reg 340**, pokud je `fn_site_has_active_green_bonus_pv` aktivní, cap > 0 a `pv_a_allowed_w` je vyplněné. - **Data:** `pv_a_forecast_solver_w` / `pv_a_curtailed_w` z aktivního `planning_interval` (json z `ems.fn_planning_interval_at_offset`); cap = součet `nominal_power_wp` řiditelných polí na invertoru (bez nového sloupce v DB). - **Policy PV A off (jen na site se zeleným bonusem na PV):** pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` a v aktuálním slotu zároveň `effective_buy_price < 0` a `effective_sell_price < 0` a `pv_b_forecast_solver_w > 0` (PV B vyrábí), exporter nastaví `pv_a_allowed_w = 0` (reg 340) i když je forecast PV A nulový — cílem je držet headroom v baterii pro PV B / další záporný nákup. +- **Bez zápisu reg 340:** `plan_skips_deye_reg340_write` — žádný export z plánu, `battery_setpoint_w ≤ 0`, `pv_a_curtailed_w = 0` → `pv_a_allowed_w = None` (invertor řídí pole A sám). Ověření: `pytest backend/tests/test_control_exporter_reg340.py`. - **Verify:** reg **340** není kritický → po 3× mismatch verify **bez** přepnutí do SELF_SUSTAIN (stejně jako reg 178); viz [`modbus-command-journal.md`](modbus-command-journal.md). #### Ověření po nasazení (smoke) diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 4e7b3e4..383721a 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -32,6 +32,7 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi - **FC 0x10**, jednotka **W**; firmware omezuje strop výroby z řiditelných stringů (pole A na hybridu). - **Kdy EMS zapisuje:** `ems.fn_site_has_active_green_bonus_pv(site_id)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (součet `nominal_power_wp` z `asset_pv_array` kde `controllable = true`). Při součtu **0** nebo bez aktivního zeleného bonusu EMS reg 340 **nezapisuje** (ruční hodnota v invertoru zůstane). - **Hodnota:** z `ControlSetpoints.pv_a_allowed_w` (AUTO): bez curtailmentu = plný cap; při `pv_a_curtailed_w > 0` viz tabulka výše. Režimy **SELF_SUSTAIN / PRESERVE / CHARGE_CHEAP** mají `pv_a_allowed_w = None` → žádný zápis 340 z EMS v daném ticku. +- **Bez zápisu 340 (2026-05):** pokud plán má **bez exportu** (`export_mode = NONE` nebo `grid_setpoint_w ≥ 0` a `export_limit_w = 0`), **bez nabíjení baterie** (`battery_setpoint_w ≤ 0`) a **bez curtailu A** (`pv_a_curtailed_w = 0`), EMS reg 340 **neposílá** — Deye řídí PV A přes **108/109/142** (zero export + 0 A nabíjení). Funkce `plan_skips_deye_reg340_write` v `setpoints.py`. Výjimka: explicitní curtail nebo záporné buy+sell s PV B → `pv_a_allowed_w` se dopočítá / vynuluje jako dřív. - **Živé čtení:** `read_deye_registers_live` vrací **`reg340_max_solar_power_w`** (integer) jen pokud je přepínač zapnutý; jinak **`null`** (bez extra FC3 čtení reg 340). ### Reg 191 (výkon grid peak shaving) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index aa26cff..8d1c7e5 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -45,6 +45,7 @@ 1. **Globální rozpočet Wh** (`discharge_slot_buffer × exportovatelná kapacita`): sloty podle `sell_price desc`. Před prvním `sell < 0` se z rozpočtu **vynechají** sloty, kde **později tentýž den** existuje `sell` vyšší o více než `degradation` (OTE, ne pevné hodiny 00–04). 2. **Večerní špičky per den:** `sell ≥ max(sell) − degradation` jen pro hodiny **≥ 17** (Prague), ne globální max horizontu (jinak by vyhrála půlnoc 3,7 Kč místo večera). 3. **Ranní pásmo před prvním `sell < 0`:** hodiny **5–11** téhož kalendářního dne — všechny sloty s `sell ≥ lokální_max_ráno − degradation`; ostatní sloty mezi ranním pásmem a prvním `sell < 0` s nižším sell mají export **zakázán** (žádný dump v 07:30 za 2 Kč). **`charge_acquisition`:** vážený `buy` před prvním exportem **téhož dne** jako záporné výkupní okno. + **Planner tag v25:** v24 + **dvoufáze před `buy<0`:** u posledního `sell≥0` cíl `soc≈max` (bez exportu); mezi tím a `buy<0` výboj na `_pre_neg_buy_soc_ceiling_wh`; v okně `buy<0` jen import (`bc_pv=0`), **curtail PV A jen při `buy<0`**; ranní `sell<0` před `buy<0` smí PV→bat. Viz changelog v25. **Planner tag v24:** v23 + **večerní tvrdý push** podle rozpočtu Wh (`discharge_slot_buffer`, SoC nad `min_soc`, `per_slot_discharge`) — bez pevného top-3 / `len≥2`. Viz changelog v24. **Planner tag v23:** v22b + **výboj baterie do sítě** před `buy<0` (`_pre_neg_buy_discharge_indices`, sell≥1 Kč/kWh, push `ge_bat` z DB limitů). Viz changelog v23. V `solve_dispatch` (AUTO): **`charge_slots`** = `allow_charge` z DB + **`buy < 0`** + všechny sloty **`sell < 0`** s PV přebytkem > 500 W (i bez `block_export_on_negative_sell`, BA81). **`pv_charge_shortfall`** / **`NEG_SELL_CURTAIL_PENALTY`** platí v těchto slotech. Při **`sell < 0`**: safety deficit cílí **`soc_max_wh`** (plný planner strop). Po posledním **`sell < 0`** tentýž den: **`post_neg_pv_topup`** dobije z FVE na `soc_max` před exportem (kladný sell, ne high-sell peak). U **fixního tarifu** s polem B: **`ge_pv ≤ pv_b`** (ne pv_store **`ge_pv = 0`**). Při **`deye_gen_microinverter_cutoff_enabled`**: **`ge == 0` jen** pokud **`block_export_on_negative_sell`** (KV1), ne kvůli samotnému `z_gen_cutoff` (BA81 musí moci exportovat B při plné baterii). Vstupní **`soc_wh`** z telemetrie se před MILP omezí přes **`_planner_soc_for_solver`** (rezerva ~650 Wh pod `soc_max`, jinak Infeasible při 100 % SoC a dlouhém záporném výkupu). **`planner_build_tag`** v `solver_params`. Changelog: [`docs/planning-changelog.md`](../planning-changelog.md). diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index c9ceaff..bd17309 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,24 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-28 — reg 340 jen když plán curtailuje / exportuje / nabíjí + +**Změna:** `plan_skips_deye_reg340_write` v `setpoints.py` — bez FC 0x10 na reg **340**, pokud slot nemá export, nabíjení baterie ani `pv_a_curtailed_w` (Deye řídí PV A přes 108/109/142). + +**Ověření:** `pytest backend/tests/test_control_exporter_reg340.py`. + +--- + +## 2026-05-28 — dvoufázová SoC před buy<0, PV A curtail jen v buy<0 (v25) + +**Požadavek:** (1) **PV A omezení** jen při `buy<0` — raději import se ziskem než „zdarma“ ze střechy. (2) **Před `buy<0`** dostatečně **nízké SoC** (vejde import v okně + PV B + rezerva na odpolední `sell<0`). (3) **Nejpozději při posledním `sell≥0` před `buy<0`** baterie **~100 %** (bez exportu — PV do bat). (4) Ranní `sell<0` před `buy<0`: PV smí do baterie (ne tvrdé `bc_pv=0`). + +**Oprava (tag `2026-05-28-pre-neg-buy-soc-phases-v25`):** `_pre_neg_buy_soc_ceiling_wh`, kotvy `soc` na `last_pos_sell` (max) a `first_neg_buy-1` (strop), `pre_neg_buy_empty_ts` výboj, `pos_sell_pre_neg_buy_ts` `ge=0`, `bc_pv=0` jen při `buy<0`, `NEG_SELL_CURTAIL` jen `buy<0`, ranní PV charge shortfall. + +**Ověření:** `pytest backend/tests/test_planning_dispatch_milp.py -k PreNegBuySocPhase`. + +--- + ## 2026-05-28 — dynamický večerní push (v24) **Problém:** Tvrdý večerní push používal pevné **`max_slots_per_day = 3`** a aktivaci jen při **`len(evening_push_ts) ≥ 2`** — nesouvisí s `discharge_slot_buffer`, SoC ani počtem večerních peak slotů (changelog v17 mluvil o top-6/≥7, v kódu bylo 3/2).