third version before modbus cleanup
This commit is contained in:
@@ -36,6 +36,9 @@ CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
|
||||
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
|
||||
# Útlum korekce: čím dál od aktuálního času, tím méně korigujeme forecast
|
||||
CORRECTION_DECAY_SLOTS = 16 # po 16 slotech (4h) klesne korekce na 0
|
||||
# Dynamická ekonomická podlaha (MILP w_arb): lookahead FVE energie v dalších slotech
|
||||
ARB_LOOKAHEAD_SLOTS = 32 # 8 h při INTERVAL_H=0.25
|
||||
ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0..1)
|
||||
|
||||
_PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
@@ -83,6 +86,34 @@ def _pv_coverage_ratio(slots: list["PlanningSlot"], battery, hours: int = 24) ->
|
||||
return max(0.0, min(1.0, pv_kwh / batt_kwh))
|
||||
|
||||
|
||||
def _dynamic_arb_floor_wh_series(
|
||||
slots: list["PlanningSlot"],
|
||||
min_soc_wh: float,
|
||||
arb_base_wh: float,
|
||||
usable_wh: float,
|
||||
) -> list[float]:
|
||||
"""
|
||||
Časově proměnná ekonomická podlaha Wh pro MILP (nad min_soc_wh).
|
||||
Hodně očekávané FVE energie v dalších ARB_LOOKAHEAD_SLOTS → podlaha klesá k min_soc_wh;
|
||||
málo slunce → zůstává u arb_base_wh (typicky reserve z DB).
|
||||
"""
|
||||
T = len(slots)
|
||||
if T == 0:
|
||||
return []
|
||||
e_ref = max(1.0, ARB_FLOOR_E_REF_FRAC * float(usable_wh))
|
||||
spread = max(0.0, float(arb_base_wh) - float(min_soc_wh))
|
||||
out: list[float] = []
|
||||
for t in range(T):
|
||||
e_pv_wh = 0.0
|
||||
for k in range(t, min(T, t + ARB_LOOKAHEAD_SLOTS)):
|
||||
s = slots[k]
|
||||
e_pv_wh += max(0, s.pv_a_forecast_w + s.pv_b_forecast_w) * INTERVAL_H
|
||||
f = min(1.0, e_pv_wh / e_ref) if e_ref > 1e-9 else 1.0
|
||||
arb_t = float(min_soc_wh) + (1.0 - f) * spread
|
||||
out.append(arb_t)
|
||||
return out
|
||||
|
||||
|
||||
def _soc_security_profile(slots: list["PlanningSlot"], battery) -> tuple[float, float]:
|
||||
"""
|
||||
Při nízkém očekávaném slunci drží solver vyšší SoC buffer:
|
||||
@@ -282,12 +313,25 @@ def solve_dispatch(
|
||||
|
||||
prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize)
|
||||
|
||||
min_soc_wh = float(getattr(battery, "min_soc_wh", battery.reserve_soc_wh))
|
||||
arb_base_wh = max(
|
||||
float(getattr(battery, "arb_floor_wh", battery.reserve_soc_wh)),
|
||||
min_soc_wh,
|
||||
)
|
||||
if getattr(battery, "disable_dynamic_arb_floor", False):
|
||||
arb_floor_series = [arb_base_wh] * T
|
||||
else:
|
||||
arb_floor_series = _dynamic_arb_floor_wh_series(
|
||||
slots, min_soc_wh, arb_base_wh, float(battery.usable_capacity_wh)
|
||||
)
|
||||
|
||||
# --- Proměnné ---
|
||||
gi = [pulp.LpVariable(f"gi_{t}", 0, grid.max_import_power_w) for t in range(T)]
|
||||
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}", battery.reserve_soc_wh, battery.soc_max_wh) for t in range(T)]
|
||||
soc = [pulp.LpVariable(f"soc_{t}", min_soc_wh, 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)]
|
||||
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)
|
||||
@@ -346,14 +390,26 @@ def solve_dispatch(
|
||||
if s.sell_price < 0:
|
||||
prob += ge[t] == 0
|
||||
|
||||
# Záporná nákupní cena → cap import na reálnou spotřebu
|
||||
# Záporná nákupní cena → cap import (baseline domu + akumulace + řízené zátěže)
|
||||
if s.buy_price < 0:
|
||||
prob += gi[t] <= (
|
||||
battery.max_charge_power_w
|
||||
s.load_baseline_w
|
||||
+ battery.max_charge_power_w
|
||||
+ sum(v.max_charge_power_w for v in vehicles)
|
||||
+ heat_pump.rated_heating_power_w
|
||||
)
|
||||
|
||||
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]))
|
||||
prob += bd[t] <= (
|
||||
s.load_baseline_w
|
||||
+ ev_total_t
|
||||
+ hp[t]
|
||||
+ bc[t]
|
||||
+ battery.max_discharge_power_w * w_arb[t]
|
||||
)
|
||||
|
||||
# EV – limity a připojení
|
||||
for e in range(EV):
|
||||
connected = (
|
||||
@@ -519,6 +575,7 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
||||
price_failsafe_active=price_failsafe_active,
|
||||
)
|
||||
|
||||
slot_inputs = _build_slot_inputs(slots, slots)
|
||||
run_id = await _save_planning_run(
|
||||
site_id,
|
||||
results,
|
||||
@@ -531,6 +588,7 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
||||
duration_ms=duration_ms,
|
||||
correction=1.0,
|
||||
db=db,
|
||||
slot_inputs=slot_inputs,
|
||||
)
|
||||
logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms")
|
||||
return run_id, duration_ms
|
||||
@@ -589,6 +647,7 @@ async def run_rolling_replan(
|
||||
correction_factor, correction_log = await compute_correction_factor(site_id, now, db)
|
||||
|
||||
slots = await _load_slots(site_id, replan_from, horizon_to, db)
|
||||
slots_before_pv_correction = list(slots)
|
||||
critical_slots = int(36 / INTERVAL_H)
|
||||
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price)
|
||||
price_failsafe_active = missing_ote_count > 0
|
||||
@@ -610,6 +669,7 @@ async def run_rolling_replan(
|
||||
price_failsafe_active=price_failsafe_active,
|
||||
)
|
||||
|
||||
slot_inputs = _build_slot_inputs(slots_before_pv_correction, slots)
|
||||
run_id = await _save_planning_run(
|
||||
site_id,
|
||||
results,
|
||||
@@ -622,6 +682,7 @@ async def run_rolling_replan(
|
||||
duration_ms=duration_ms,
|
||||
correction=correction_factor,
|
||||
db=db,
|
||||
slot_inputs=slot_inputs,
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
@@ -718,6 +779,7 @@ async def _load_site_context(site_id: int, db):
|
||||
brow = await db.fetchrow(
|
||||
"""
|
||||
SELECT ab.usable_capacity_wh,
|
||||
ab.min_soc_percent,
|
||||
ab.reserve_soc_percent,
|
||||
ab.max_soc_percent,
|
||||
ab.charge_efficiency,
|
||||
@@ -770,11 +832,14 @@ async def _load_site_context(site_id: int, db):
|
||||
)
|
||||
|
||||
uc = float(brow["usable_capacity_wh"])
|
||||
reserve_wh = float(brow["reserve_soc_percent"]) / 100.0 * uc
|
||||
min_soc_wh = float(brow["min_soc_percent"]) / 100.0 * uc
|
||||
arb_floor_wh = float(brow["reserve_soc_percent"]) / 100.0 * uc
|
||||
soc_max_wh = float(brow["max_soc_percent"]) / 100.0 * uc
|
||||
battery = SimpleNamespace(
|
||||
usable_capacity_wh=uc,
|
||||
reserve_soc_wh=reserve_wh,
|
||||
min_soc_wh=min_soc_wh,
|
||||
arb_floor_wh=arb_floor_wh,
|
||||
reserve_soc_wh=arb_floor_wh,
|
||||
soc_max_wh=soc_max_wh,
|
||||
charge_efficiency=float(brow["charge_efficiency"]),
|
||||
discharge_efficiency=float(brow["discharge_efficiency"]),
|
||||
@@ -894,7 +959,7 @@ async def _load_site_context(site_id: int, db):
|
||||
soc_wh = uc * 0.5
|
||||
else:
|
||||
soc_wh = float(soc_pct) / 100.0 * uc
|
||||
soc_wh = max(reserve_wh, min(soc_wh, soc_max_wh))
|
||||
soc_wh = max(min_soc_wh, min(soc_wh, soc_max_wh))
|
||||
|
||||
tuv = await db.fetchval(
|
||||
"""
|
||||
@@ -1032,12 +1097,36 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
|
||||
return out
|
||||
|
||||
|
||||
def _build_slot_inputs(
|
||||
slots_raw_pv: list[PlanningSlot],
|
||||
slots_solver: list[PlanningSlot],
|
||||
) -> list[tuple[int, int, int, int, int]]:
|
||||
"""(load_baseline_w, pv_a_raw, pv_b_raw, pv_a_solver, pv_b_solver) pro každý slot."""
|
||||
if len(slots_raw_pv) != len(slots_solver):
|
||||
raise ValueError("slots_raw_pv and slots_solver length mismatch")
|
||||
out: list[tuple[int, int, int, int, int]] = []
|
||||
for raw, sol in zip(slots_raw_pv, slots_solver):
|
||||
out.append(
|
||||
(
|
||||
int(raw.load_baseline_w),
|
||||
int(raw.pv_a_forecast_w),
|
||||
int(raw.pv_b_forecast_w),
|
||||
int(sol.pv_a_forecast_w),
|
||||
int(sol.pv_b_forecast_w),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def _save_planning_run(
|
||||
site_id, results, horizon_from, horizon_to,
|
||||
run_type, triggered_by, replan_from,
|
||||
soc_wh, duration_ms, correction, db
|
||||
soc_wh, duration_ms, correction, db,
|
||||
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
|
||||
) -> int:
|
||||
"""Uloží výsledky solveru jako nový planning_run, deaktivuje předchozí."""
|
||||
if slot_inputs is not None and len(slot_inputs) != len(results):
|
||||
raise ValueError("slot_inputs and results length mismatch")
|
||||
run_id = await db.fetchval("""
|
||||
INSERT INTO ems.planning_run
|
||||
(site_id, horizon_start, horizon_end, status,
|
||||
@@ -1050,28 +1139,88 @@ async def _save_planning_run(
|
||||
soc_wh, duration_ms, correction)
|
||||
|
||||
# Bulk insert výsledků
|
||||
await db.executemany("""
|
||||
INSERT INTO ems.planning_interval
|
||||
(run_id, interval_start,
|
||||
battery_setpoint_w, battery_soc_target_pct,
|
||||
grid_setpoint_w,
|
||||
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
|
||||
heat_pump_enabled, heat_pump_setpoint_w,
|
||||
pv_a_curtailed_w, expected_cost_czk,
|
||||
effective_buy_price, effective_sell_price,
|
||||
is_predicted_price)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
""", [
|
||||
(run_id, r.interval_start,
|
||||
r.battery_setpoint_w, r.battery_soc_target,
|
||||
r.grid_setpoint_w,
|
||||
r.ev1_setpoint_w, r.ev2_setpoint_w, r.ev1_via_bat_w, r.ev2_via_bat_w,
|
||||
r.heat_pump_enabled, r.heat_pump_setpoint_w,
|
||||
r.pv_a_curtailed_w, r.expected_cost_czk,
|
||||
r.effective_buy_price, r.effective_sell_price,
|
||||
r.is_predicted_price)
|
||||
for r in results
|
||||
])
|
||||
if slot_inputs is not None:
|
||||
rows_pi = [
|
||||
(
|
||||
run_id,
|
||||
r.interval_start,
|
||||
r.battery_setpoint_w,
|
||||
r.battery_soc_target,
|
||||
r.grid_setpoint_w,
|
||||
r.ev1_setpoint_w,
|
||||
r.ev2_setpoint_w,
|
||||
r.ev1_via_bat_w,
|
||||
r.ev2_via_bat_w,
|
||||
r.heat_pump_enabled,
|
||||
r.heat_pump_setpoint_w,
|
||||
r.pv_a_curtailed_w,
|
||||
r.expected_cost_czk,
|
||||
r.effective_buy_price,
|
||||
r.effective_sell_price,
|
||||
r.is_predicted_price,
|
||||
si[0],
|
||||
si[1],
|
||||
si[2],
|
||||
si[3],
|
||||
si[4],
|
||||
)
|
||||
for r, si in zip(results, slot_inputs)
|
||||
]
|
||||
await db.executemany(
|
||||
"""
|
||||
INSERT INTO ems.planning_interval
|
||||
(run_id, interval_start,
|
||||
battery_setpoint_w, battery_soc_target_pct,
|
||||
grid_setpoint_w,
|
||||
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
|
||||
heat_pump_enabled, heat_pump_setpoint_w,
|
||||
pv_a_curtailed_w, expected_cost_czk,
|
||||
effective_buy_price, effective_sell_price,
|
||||
is_predicted_price,
|
||||
load_baseline_w,
|
||||
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
|
||||
pv_a_forecast_solver_w, pv_b_forecast_solver_w)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
|
||||
$17,$18,$19,$20,$21)
|
||||
""",
|
||||
rows_pi,
|
||||
)
|
||||
else:
|
||||
await db.executemany(
|
||||
"""
|
||||
INSERT INTO ems.planning_interval
|
||||
(run_id, interval_start,
|
||||
battery_setpoint_w, battery_soc_target_pct,
|
||||
grid_setpoint_w,
|
||||
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
|
||||
heat_pump_enabled, heat_pump_setpoint_w,
|
||||
pv_a_curtailed_w, expected_cost_czk,
|
||||
effective_buy_price, effective_sell_price,
|
||||
is_predicted_price)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
""",
|
||||
[
|
||||
(
|
||||
run_id,
|
||||
r.interval_start,
|
||||
r.battery_setpoint_w,
|
||||
r.battery_soc_target,
|
||||
r.grid_setpoint_w,
|
||||
r.ev1_setpoint_w,
|
||||
r.ev2_setpoint_w,
|
||||
r.ev1_via_bat_w,
|
||||
r.ev2_via_bat_w,
|
||||
r.heat_pump_enabled,
|
||||
r.heat_pump_setpoint_w,
|
||||
r.pv_a_curtailed_w,
|
||||
r.expected_cost_czk,
|
||||
r.effective_buy_price,
|
||||
r.effective_sell_price,
|
||||
r.is_predicted_price,
|
||||
)
|
||||
for r in results
|
||||
],
|
||||
)
|
||||
|
||||
# Aktivovat nový plán, supersede předchozí
|
||||
await db.execute("""
|
||||
|
||||
Reference in New Issue
Block a user