diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 7c559af..94523bf 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -1258,7 +1258,8 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont pm: str | None = str(pm_raw).strip().upper() if pm_raw is not None else None sell_raw = pi.get("effective_sell_price") sell_f: float | None = float(sell_raw) if sell_raw is not None else None - export_ban = sell_f is not None and float(sell_f) < 0 + # Záporný výkup sám o sobě neblokuje export, pokud plán export explicitně žádá (soulad s LP). + 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 return ControlSetpoints( diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index f7ac139..2c13f74 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -178,6 +178,39 @@ class PlanningSlot: allow_discharge_export: bool = True +# Lookahead pro relax spodní meze SoC: až 36 h od indexu slotu (pevné OTE ceny v horizontu). +SOC_MIN_RELAX_LOOKAHEAD_SLOTS = 144 + + +def _soc_min_wh_series( + slots: list[PlanningSlot], + usable_wh: float, + base_min_wh: float, + buy_extreme_threshold: float, + planner_discharge_floor_pct: float | None, +) -> list[float]: + """ + Spodní mez SoC (Wh) pro každý slot: při extrémně záporném buy v lookahead povolit hlubší vybíjení + až na planner_discharge_floor_percent (jinak min_soc z DB). Absolutní minimum 5 % usable. + """ + t_len = len(slots) + abs_min_wh = max(usable_wh * 0.05, 1.0) + if planner_discharge_floor_pct is None: + relaxed_wh = base_min_wh + else: + relaxed_wh = max(abs_min_wh, float(planner_discharge_floor_pct) / 100.0 * usable_wh) + effective_relaxed = min(base_min_wh, relaxed_wh) + out: list[float] = [] + for t in range(t_len): + j_end = min(t_len, t + SOC_MIN_RELAX_LOOKAHEAD_SLOTS) + min_buy_fwd = min(float(slots[k].buy_price) for k in range(t, j_end)) + if min_buy_fwd <= buy_extreme_threshold: + out.append(float(effective_relaxed)) + else: + out.append(float(base_min_wh)) + return out + + @dataclass class DispatchResult: interval_start: datetime @@ -324,6 +357,18 @@ def solve_dispatch( IMPORT_OVER_BREAKER_PENALTY_CZK_KWH = 10.0 min_soc_wh = float(getattr(battery, "min_soc_wh", battery.reserve_soc_wh)) + buy_extreme_thr = float(getattr(battery, "planner_extreme_buy_threshold_czk_kwh", -5.0)) + floor_pct_raw = getattr(battery, "planner_discharge_floor_percent", None) + floor_pct = float(floor_pct_raw) if floor_pct_raw is not None else None + soc_min_series = _soc_min_wh_series( + slots, + float(battery.usable_capacity_wh), + min_soc_wh, + buy_extreme_thr, + floor_pct, + ) + current_soc_wh = float(current_soc_wh) + current_soc_wh = max(soc_min_series[0], min(current_soc_wh, float(battery.soc_max_wh))) arb_base_wh = max( float(getattr(battery, "arb_floor_wh", battery.reserve_soc_wh)), min_soc_wh, @@ -348,7 +393,9 @@ def solve_dispatch( ge = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w) for t in range(T)] bc = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_power_w) for t in range(T)] bd = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)] - soc = [pulp.LpVariable(f"soc_{t}", min_soc_wh, battery.soc_max_wh) for t in range(T)] + soc = [ + pulp.LpVariable(f"soc_{t}", soc_min_series[t], battery.soc_max_wh) for t in range(T) + ] w_arb = [pulp.LpVariable(f"w_arb_{t}", cat=pulp.LpBinary) for t in range(T)] z_export = [pulp.LpVariable(f"z_export_{t}", cat=pulp.LpBinary) for t in range(T)] ca = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)] @@ -433,11 +480,10 @@ def solve_dispatch( # ev_via_bat kryto z discharge prob += pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t] - # Záporná prodejní cena → zakázat export - if s.sell_price < 0: - prob += ge[t] == 0 - # GEN cut-off používáme jen jako nástroj pro BLOCK_EXPORT (sell < 0). - if z_gen_cutoff is not None and s.sell_price >= 0: + # Záporná prodejní cena: export nepovinně zakazovat — účelovka už obsahuje -ge*sell + # (záporné sell zvyšuje náklad exportu). GEN cut-off držíme vypnutý (jinak by z_gen_cutoff + # uměle nulovalo forecast pole B). + if z_gen_cutoff is not None: prob += z_gen_cutoff[t] == 0 # Záporná nákupní cena → cap import (baseline domu + akumulace + řízené zátěže) @@ -451,7 +497,8 @@ def solve_dispatch( soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1] arb_t = arb_floor_series[t] - prob += soc_prev_expr >= (arb_t - (arb_t - min_soc_wh) * (1 - w_arb[t])) + soc_low_t = soc_min_series[t] + prob += soc_prev_expr >= (arb_t - (arb_t - soc_low_t) * (1 - w_arb[t])) prob += bd[t] <= ( s.load_baseline_w + ev_total_t @@ -851,12 +898,15 @@ async def _load_site_context(site_id: int, db): b = ctx["battery"] ec_i = int(b["max_charge_power_w"]) ed_i = int(b["max_discharge_power_w"]) + planner_soc_max = float(b.get("planner_soc_max_wh", b["soc_max_wh"])) + floor_pct = b.get("planner_discharge_floor_percent") + buy_thr = b.get("planner_extreme_buy_threshold_czk_kwh") battery = SimpleNamespace( usable_capacity_wh=float(b["usable_capacity_wh"]), min_soc_wh=float(b["min_soc_wh"]), arb_floor_wh=float(b["arb_floor_wh"]), reserve_soc_wh=float(b["reserve_soc_wh"]), - soc_max_wh=float(b["soc_max_wh"]), + soc_max_wh=planner_soc_max, charge_efficiency=float(b["charge_efficiency"]), discharge_efficiency=float(b["discharge_efficiency"]), degradation_cost_czk_kwh=float(b["degradation_cost_czk_kwh"]), @@ -868,6 +918,8 @@ async def _load_site_context(site_id: int, db): discharge_slot_buffer=float(b["discharge_slot_buffer"]) if b.get("discharge_slot_buffer") is not None else 0, + planner_extreme_buy_threshold_czk_kwh=float(buy_thr) if buy_thr is not None else -5.0, + planner_discharge_floor_percent=float(floor_pct) if floor_pct is not None else None, ) hpj = ctx["heat_pump"] diff --git a/db/migration/V059__planner_soc_extremes.sql b/db/migration/V059__planner_soc_extremes.sql new file mode 100644 index 0000000..4c45a0c --- /dev/null +++ b/db/migration/V059__planner_soc_extremes.sql @@ -0,0 +1,20 @@ +-- Plánovač: vyšší strop SoC než provozní max, relaxované dno při extrémně záporném buy, práh z OTE horizontu. + +ALTER TABLE ems.asset_battery + ADD COLUMN IF NOT EXISTS planner_max_soc_percent NUMERIC(5, 2), + ADD COLUMN IF NOT EXISTS planner_discharge_floor_percent NUMERIC(5, 2), + ADD COLUMN IF NOT EXISTS planner_extreme_buy_threshold_czk_kwh NUMERIC(10, 4) DEFAULT -5.0; + +COMMENT ON COLUMN ems.asset_battery.planner_max_soc_percent IS + 'Horní mez SoC (%) pro LP; NULL = použij max_soc_percent. Typicky 100 pro plné využití kapacity při silně záporném nákupu.'; + +COMMENT ON COLUMN ems.asset_battery.planner_discharge_floor_percent IS + 'Dolní mez SoC (%) pro LP při aktivaci extrémně záporného nákupu v lookahead; NULL = použij min_soc_percent.'; + +COMMENT ON COLUMN ems.asset_battery.planner_extreme_buy_threshold_czk_kwh IS + 'Prah effective buy (Kč/kWh): pokud min buy v lookahead <= prah, LP smí snížit SoC k planner_discharge_floor_percent.'; + +-- home-01: plánovat až na 100 % (provozní max_soc může zůstat 95 %) +UPDATE ems.asset_battery +SET planner_max_soc_percent = 100 +WHERE site_id = 2 AND planner_max_soc_percent IS NULL; diff --git a/db/routines/R__039_fn_planning_site_context.sql b/db/routines/R__039_fn_planning_site_context.sql index 589a991..627f54c 100644 --- a/db/routines/R__039_fn_planning_site_context.sql +++ b/db/routines/R__039_fn_planning_site_context.sql @@ -36,6 +36,11 @@ begin 'arb_floor_wh', (ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric, 'reserve_soc_wh', (ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric, 'soc_max_wh', (ab.max_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric, + 'planner_soc_max_wh', ( + coalesce(ab.planner_max_soc_percent, ab.max_soc_percent) / 100.0 * ab.usable_capacity_wh + )::numeric, + 'planner_extreme_buy_threshold_czk_kwh', coalesce(ab.planner_extreme_buy_threshold_czk_kwh, -5.0), + 'planner_discharge_floor_percent', ab.planner_discharge_floor_percent, 'charge_efficiency', ab.charge_efficiency, 'discharge_efficiency', ab.discharge_efficiency, 'degradation_cost_czk_kwh', ab.degradation_cost_czk_kwh, diff --git a/db/routines/R__063_fn_load_planning_slots_full.sql b/db/routines/R__063_fn_load_planning_slots_full.sql index 7a8ae24..d719d90 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -41,6 +41,12 @@ declare v_discharge_target_wh numeric; v_cum numeric; r_slot record; + v_n_am int; + v_n_pm int; + v_chg_am_wh numeric; + v_chg_pm_wh numeric; + v_dis_am_wh numeric; + v_dis_pm_wh numeric; begin drop table if exists _ems_plan_slot_wk; create temp table _ems_plan_slot_wk on commit drop as @@ -252,7 +258,7 @@ begin coalesce(ab.discharge_slot_buffer, 0::numeric), ab.usable_capacity_wh::numeric, (ab.min_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric, - (ab.max_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric, + (coalesce(ab.planner_max_soc_percent, ab.max_soc_percent) / 100.0 * ab.usable_capacity_wh)::numeric, greatest(coalesce(ab.charge_efficiency, 1::numeric), 0.0001::numeric), least( coalesce(ai.max_battery_charge_w, ai.max_charge_power_w), @@ -302,6 +308,40 @@ begin v_grid_target_wh := v_energy_to_fill * v_charge_buf; v_discharge_target_wh := v_exportable * v_discharge_buf; + -- Rozpočet na půl dne (Europe/Prague): 00:00–12:00 vs 12:00–24:00; chybějící segment dostane celý budget. + select + coalesce( + count(*) filter ( + where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12 + ), + 0 + )::int, + coalesce( + count(*) filter ( + where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12 + ), + 0 + )::int + into v_n_am, v_n_pm + from _ems_plan_slot_wk wk; + + if v_n_am <= 0 then + v_chg_am_wh := 0; + v_chg_pm_wh := v_grid_target_wh; + v_dis_am_wh := 0; + v_dis_pm_wh := v_discharge_target_wh; + elsif v_n_pm <= 0 then + v_chg_am_wh := v_grid_target_wh; + v_chg_pm_wh := 0; + v_dis_am_wh := v_discharge_target_wh; + v_dis_pm_wh := 0; + else + v_chg_am_wh := v_grid_target_wh / 2.0; + v_chg_pm_wh := v_grid_target_wh - v_chg_am_wh; + v_dis_am_wh := v_discharge_target_wh / 2.0; + v_dis_pm_wh := v_discharge_target_wh - v_dis_am_wh; + end if; + -- charge mask (sloupce temp tabulky kvalifikujeme: RETURNS TABLE dělá PL proměnné stejných jmen) if v_charge_buf <= 0 then update _ems_plan_slot_wk wk set allow_charge = true; @@ -314,9 +354,23 @@ begin select wk.slot_ord from _ems_plan_slot_wk wk where wk.pv_surplus_w <= 0 + and extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12 order by wk.buy_price, wk.slot_ord loop - exit when v_cum >= v_grid_target_wh; + exit when v_cum >= v_chg_am_wh; + exit when v_per_slot_charge_wh <= 0; + update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord; + v_cum := v_cum + v_per_slot_charge_wh; + end loop; + v_cum := 0; + for r_slot in + select wk.slot_ord + from _ems_plan_slot_wk wk + where wk.pv_surplus_w <= 0 + and extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12 + order by wk.buy_price, wk.slot_ord + loop + exit when v_cum >= v_chg_pm_wh; exit when v_per_slot_charge_wh <= 0; update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord; v_cum := v_cum + v_per_slot_charge_wh; @@ -334,9 +388,22 @@ begin for r_slot in select wk.slot_ord from _ems_plan_slot_wk wk + where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12 order by wk.sell_price desc, wk.slot_ord desc loop - exit when v_cum >= v_discharge_target_wh; + exit when v_cum >= v_dis_am_wh; + exit when v_per_slot_discharge_wh <= 0; + update _ems_plan_slot_wk wk set allow_discharge_export = true where wk.slot_ord = r_slot.slot_ord; + v_cum := v_cum + v_per_slot_discharge_wh; + end loop; + v_cum := 0; + for r_slot in + select wk.slot_ord + from _ems_plan_slot_wk wk + where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12 + order by wk.sell_price desc, wk.slot_ord desc + loop + exit when v_cum >= v_dis_pm_wh; exit when v_per_slot_discharge_wh <= 0; update _ems_plan_slot_wk wk set allow_discharge_export = true where wk.slot_ord = r_slot.slot_ord; v_cum := v_cum + v_per_slot_discharge_wh; @@ -363,4 +430,6 @@ end; $fn$; comment on function ems.fn_load_planning_slots_full(int, timestamptz, timestamptz, numeric) is - '15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export).'; + '15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). ' + 'Masky charge/discharge-export se berou zvlášť pro 00–12 a 12–24 Europe/Prague (polovina budgetu na segment). ' + 'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent).';