zkraceni intervalu planneru na max 35h
This commit is contained in:
@@ -3,13 +3,14 @@
|
||||
# EMS Platform – plánovací engine
|
||||
# Obsahuje: hlavní denní plán + rolling 15min replan
|
||||
#
|
||||
# Spouštění (APScheduler v main.py):
|
||||
# Spouštění (APScheduler v lifespan.py):
|
||||
# scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0)
|
||||
# scheduler.add_job(run_rolling_replan, 'cron', minute='*/15')
|
||||
# Horizont: ems.fn_planning_horizon_end (OTE + strop/min v SQL).
|
||||
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from types import SimpleNamespace
|
||||
@@ -25,11 +26,11 @@ logger = logging.getLogger(__name__)
|
||||
# Konstanty
|
||||
# ============================================================
|
||||
|
||||
HORIZON_HOURS = 96 # horizont denního plánu (OTE ~36h + predikce)
|
||||
# Když DB vrátí NULL (skoro žádná OTE data), denní plán použije krátký fallback (soulad s min hodinami ve fn_planning_horizon_end).
|
||||
_DAILY_FALLBACK_HORIZON_HOURS = 1.0
|
||||
# Shadow cena zbytkové energie na konci horizontu: - (avg_buy * FACTOR / 1000) * soc[T-1] (Kč; soc v Wh).
|
||||
TERMINAL_SOC_VALUE_FACTOR = 0.9
|
||||
INTERVAL_H = 0.25 # 15 minut v hodinách
|
||||
SLOT_WEIGHT_FULL = 1.0 # 0–36h od začátku okna (přesné OTE ceny)
|
||||
SLOT_WEIGHT_MEDIUM = 0.7 # 36–72h
|
||||
SLOT_WEIGHT_LOW = 0.4 # 72–96h
|
||||
CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A
|
||||
SOLVER_TIME_LIMIT = 10 # sekund
|
||||
# MILP: jakýkoli významný výkon exportu ge (W) ⇒ koncové soc[t] ≥ arb_base_wh (rezerva z DB)
|
||||
@@ -46,14 +47,22 @@ ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0..
|
||||
_PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
|
||||
def slot_weight(slot_index: int, now_index: int = 0) -> float:
|
||||
"""Váha slotu v účelové funkci podle vzdálenosti od začátku optimalizačního okna."""
|
||||
hours_ahead = (slot_index - now_index) * INTERVAL_H
|
||||
if hours_ahead <= 36:
|
||||
return SLOT_WEIGHT_FULL
|
||||
if hours_ahead <= 72:
|
||||
return SLOT_WEIGHT_MEDIUM
|
||||
return SLOT_WEIGHT_LOW
|
||||
def _timestamptz_from_db(val: object) -> Optional[datetime]:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, datetime):
|
||||
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
|
||||
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
||||
|
||||
|
||||
async def _planning_horizon_end(site_id: int, horizon_from: datetime, db) -> Optional[datetime]:
|
||||
"""Konec horizontu z DB (`fn_planning_horizon_end`); NULL = rolling skip / daily fallback."""
|
||||
raw = await db.fetchval(
|
||||
"select ems.fn_planning_horizon_end($1::int, $2::timestamptz)",
|
||||
site_id,
|
||||
horizon_from,
|
||||
)
|
||||
return _timestamptz_from_db(raw)
|
||||
|
||||
|
||||
def _pv_scarcity_penalty_multiplier(slots: list["PlanningSlot"], battery) -> float:
|
||||
@@ -282,15 +291,15 @@ def solve_dispatch(
|
||||
current_tuv_temp_c: float,
|
||||
*,
|
||||
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
|
||||
now_slot_index: int = 0,
|
||||
operating_mode: str = "AUTO",
|
||||
price_failsafe_active: bool = False,
|
||||
) -> tuple[list[DispatchResult], int]:
|
||||
"""
|
||||
LP solver pro dispatch optimalizaci.
|
||||
Vrátí (výsledky, solver_duration_ms).
|
||||
"""
|
||||
T = len(slots)
|
||||
if T < 1:
|
||||
raise RuntimeError("solve_dispatch requires at least one slot")
|
||||
EV = len(vehicles) # počet EV (typicky 2)
|
||||
|
||||
EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency)
|
||||
@@ -339,13 +348,22 @@ def solve_dispatch(
|
||||
vehicles[e].max_charge_power_w)
|
||||
for t in range(T)] for e in range(EV)]
|
||||
|
||||
# --- Účelová funkce (váhy slotů podle nejistoty za horizontem OTE) ---
|
||||
prob += pulp.lpSum(
|
||||
slot_weight(t, now_slot_index) * (
|
||||
horizon_slots_h24 = min(T, int(24 / INTERVAL_H))
|
||||
avg_buy_terminal = (
|
||||
sum(float(slots[t].buy_price) for t in range(horizon_slots_h24)) / horizon_slots_h24
|
||||
if horizon_slots_h24 > 0
|
||||
else 4.0
|
||||
)
|
||||
# Kč/Wh: ocenění energie ponechané v baterii na konci horizontu (receding horizon kotva).
|
||||
terminal_soc_kcz_per_wh = (
|
||||
avg_buy_terminal * TERMINAL_SOC_VALUE_FACTOR / 1000.0
|
||||
)
|
||||
|
||||
# --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) ---
|
||||
prob += (
|
||||
pulp.lpSum(
|
||||
gi[t] * slots[t].buy_price * INTERVAL_H / 1000
|
||||
- ge[t] * slots[t].sell_price * INTERVAL_H / 1000
|
||||
# Degradační náklad rozložíme symetricky na charge/discharge (0.5 + 0.5),
|
||||
# aby nebyl roundtrip penalizovaný dvojnásobně.
|
||||
+ 0.5 * (bc[t] + bd[t]) * degradation_cost_effective * INTERVAL_H / 1000
|
||||
+ pulp.lpSum(
|
||||
ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000
|
||||
@@ -353,9 +371,11 @@ def solve_dispatch(
|
||||
for e in range(EV)
|
||||
)
|
||||
+ ca[t] * CURTAILMENT_PENALTY
|
||||
for t in range(T)
|
||||
)
|
||||
for t in range(T)
|
||||
) + soc_deficit_24h * soc_deficit_penalty_czk_kwh / 1000
|
||||
+ soc_deficit_24h * soc_deficit_penalty_czk_kwh / 1000
|
||||
- terminal_soc_kcz_per_wh * soc[T - 1]
|
||||
)
|
||||
|
||||
# --- Omezení ---
|
||||
for t in range(T):
|
||||
@@ -438,11 +458,6 @@ def solve_dispatch(
|
||||
prob += ge[t] == 0
|
||||
prob += bd[t] == 0
|
||||
|
||||
if price_failsafe_active:
|
||||
for t in range(T):
|
||||
if slots[t].is_predicted_price:
|
||||
prob += ge[t] == 0
|
||||
|
||||
# Slot pre-selection (z DB fn_load_planning_slots_full → allow_*)
|
||||
if om == "AUTO":
|
||||
charge_slots = {t for t, s in enumerate(slots) if s.allow_charge}
|
||||
@@ -559,11 +574,18 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
||||
"""
|
||||
Hlavní denní plánování. Spouštět v 15:00 po importu cen (14:00)
|
||||
a aktualizaci forecastu (14:30).
|
||||
Horizont: od začátku aktuálního 15min slotu do +HORIZON_HOURS (96h; OTE + predikce).
|
||||
Horizont: `ems.fn_planning_horizon_end` (OTE, strop a práh v SQL).
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
horizon_from = _current_slot_start(now)
|
||||
horizon_to = horizon_from + timedelta(hours=HORIZON_HOURS)
|
||||
horizon_to = await _planning_horizon_end(site_id, horizon_from, db)
|
||||
if horizon_to is None:
|
||||
horizon_to = horizon_from + timedelta(hours=_DAILY_FALLBACK_HORIZON_HOURS)
|
||||
logger.warning(
|
||||
"[site=%s] Daily plan: fn_planning_horizon_end NULL, fallback %.1fh",
|
||||
site_id,
|
||||
_DAILY_FALLBACK_HORIZON_HOURS,
|
||||
)
|
||||
|
||||
logger.info(f"[site={site_id}] Daily plan: {horizon_from} → {horizon_to}")
|
||||
|
||||
@@ -571,21 +593,11 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
||||
await _load_site_context(site_id, db)
|
||||
)
|
||||
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
|
||||
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
|
||||
if price_failsafe_active:
|
||||
logger.warning(
|
||||
"[site=%s] Price fail-safe active (daily): missing OTE slots in first 36h = %s",
|
||||
site_id,
|
||||
missing_ote_count,
|
||||
)
|
||||
|
||||
results, duration_ms = solve_dispatch(
|
||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
operating_mode=operating_mode or "AUTO",
|
||||
price_failsafe_active=price_failsafe_active,
|
||||
)
|
||||
|
||||
slot_inputs = _build_slot_inputs(slots, slots)
|
||||
@@ -641,11 +653,19 @@ async def run_rolling_replan(
|
||||
logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan")
|
||||
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
||||
|
||||
he = ar["horizon_end"]
|
||||
if isinstance(he, datetime):
|
||||
horizon_to = he if he.tzinfo else he.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
horizon_to = datetime.fromisoformat(str(he).replace("Z", "+00:00"))
|
||||
horizon_to = await _planning_horizon_end(site_id, replan_from, db)
|
||||
if horizon_to is None:
|
||||
if allow_skip:
|
||||
logger.info(
|
||||
"[site=%s] Rolling replan: fn_planning_horizon_end NULL (krátký OTE horizont), skipping",
|
||||
site_id,
|
||||
)
|
||||
return None, None
|
||||
logger.warning(
|
||||
"[site=%s] Rolling replan: fn_planning_horizon_end NULL, running daily plan",
|
||||
site_id,
|
||||
)
|
||||
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
||||
|
||||
if (horizon_to - replan_from).total_seconds() < 1800:
|
||||
if allow_skip:
|
||||
@@ -664,15 +684,6 @@ async def run_rolling_replan(
|
||||
|
||||
slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh)
|
||||
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
|
||||
if price_failsafe_active:
|
||||
logger.warning(
|
||||
"[site=%s] Price fail-safe active (rolling): missing OTE slots in first 36h = %s",
|
||||
site_id,
|
||||
missing_ote_count,
|
||||
)
|
||||
|
||||
slots = apply_forecast_correction(slots, now, correction_factor)
|
||||
|
||||
@@ -680,7 +691,6 @@ async def run_rolling_replan(
|
||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
operating_mode=operating_mode or "AUTO",
|
||||
price_failsafe_active=price_failsafe_active,
|
||||
)
|
||||
|
||||
slot_inputs = _build_slot_inputs(slots_before_pv_correction, slots)
|
||||
@@ -914,6 +924,11 @@ async def _load_slots(
|
||||
raise RuntimeError(
|
||||
"No planning slots available – check market prices and horizon settings"
|
||||
)
|
||||
if any(s.is_predicted_price for s in out):
|
||||
logger.warning(
|
||||
"[site=%s] Unexpected predicted-price slots in planning horizon",
|
||||
site_id,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user