puldenni sltovoani , zruseni omemzeni na zakaz exportu pri zapornem sellu, hlubsi vybijeni ped zaporbnym nakupem
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
20
db/migration/V059__planner_soc_extremes.sql
Normal file
20
db/migration/V059__planner_soc_extremes.sql
Normal file
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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).';
|
||||
|
||||
Reference in New Issue
Block a user