puldenni sltovoani , zruseni omemzeni na zakaz exportu pri zapornem sellu, hlubsi vybijeni ped zaporbnym nakupem
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-26 00:27:36 +02:00
parent f6e239aa8d
commit 5d7d7e2823
5 changed files with 160 additions and 13 deletions

View File

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

View File

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