second version
This commit is contained in:
@@ -13,9 +13,9 @@ from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pulp
|
||||
from pulp import HiGHS_CMD
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,8 +24,11 @@ logger = logging.getLogger(__name__)
|
||||
# Konstanty
|
||||
# ============================================================
|
||||
|
||||
HORIZON_HOURS = 36 # horizont denního plánu
|
||||
HORIZON_HOURS = 96 # horizont denního plánu (OTE ~36h + predikce)
|
||||
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
|
||||
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
|
||||
@@ -34,6 +37,84 @@ 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
|
||||
|
||||
_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 _pv_scarcity_penalty_multiplier(slots: list["PlanningSlot"], battery) -> float:
|
||||
"""
|
||||
Měkká úprava ekonomiky cyklu podle očekávaného slunečního zisku.
|
||||
- málo očekávané FVE energie -> nižší penalizace cyklu (podpora precharge ze sítě),
|
||||
- hodně očekávané FVE energie -> standardní penalizace.
|
||||
"""
|
||||
horizon_slots = min(len(slots), int(24 / INTERVAL_H)) # konzervativní 1 den dopředu
|
||||
if horizon_slots <= 0:
|
||||
return 1.0
|
||||
|
||||
pv_kwh = 0.0
|
||||
for s in slots[:horizon_slots]:
|
||||
pv_kwh += max(0.0, float(s.pv_a_forecast_w + s.pv_b_forecast_w)) * INTERVAL_H / 1000.0
|
||||
|
||||
batt_kwh = max(1.0, float(getattr(battery, "usable_capacity_wh", 0.0)) / 1000.0)
|
||||
# coverage = kolikanásobek baterie očekáváme ze slunce v horizontu.
|
||||
coverage = pv_kwh / batt_kwh
|
||||
coverage_clamped = max(0.0, min(1.0, coverage))
|
||||
# 0.65 při nízkém slunci, 1.0 při vysokém slunci.
|
||||
return 0.65 + 0.35 * coverage_clamped
|
||||
|
||||
|
||||
def _pv_coverage_ratio(slots: list["PlanningSlot"], battery, hours: int = 24) -> float:
|
||||
horizon_slots = min(len(slots), int(hours / INTERVAL_H))
|
||||
if horizon_slots <= 0:
|
||||
return 1.0
|
||||
pv_kwh = 0.0
|
||||
for s in slots[:horizon_slots]:
|
||||
pv_kwh += max(0.0, float(s.pv_a_forecast_w + s.pv_b_forecast_w)) * INTERVAL_H / 1000.0
|
||||
batt_kwh = max(1.0, float(getattr(battery, "usable_capacity_wh", 0.0)) / 1000.0)
|
||||
return max(0.0, min(1.0, pv_kwh / batt_kwh))
|
||||
|
||||
|
||||
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:
|
||||
- cílový buffer: reserve + až 20 % usable capacity,
|
||||
- ekonomická penalizace deficitu vůči bufferu z průměrné ceny.
|
||||
"""
|
||||
coverage = _pv_coverage_ratio(slots, battery, hours=24)
|
||||
scarcity = 1.0 - coverage
|
||||
usable_wh = float(getattr(battery, "usable_capacity_wh", 0.0))
|
||||
reserve_wh = float(getattr(battery, "reserve_soc_wh", 0.0))
|
||||
soc_max_wh = float(getattr(battery, "soc_max_wh", usable_wh))
|
||||
extra_buffer_wh = 0.35 * usable_wh * scarcity
|
||||
target_wh = min(soc_max_wh, reserve_wh + extra_buffer_wh)
|
||||
|
||||
h24 = min(len(slots), int(24 / INTERVAL_H))
|
||||
avg_buy = (
|
||||
sum(float(s.buy_price) for s in slots[:h24]) / h24
|
||||
if h24 > 0
|
||||
else 4.0
|
||||
)
|
||||
penalty_czk_kwh = max(0.1, avg_buy * 1.00 * scarcity)
|
||||
return target_wh, penalty_czk_kwh
|
||||
|
||||
|
||||
def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]:
|
||||
"""DOW v konvenci PostgreSQL EXTRACT(DOW, Europe/Prague): 0=Ne … 6=So."""
|
||||
dt = interval_start
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
loc = dt.astimezone(_PRAGUE_TZ)
|
||||
return (loc.weekday() + 1) % 7, loc.hour
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Datové třídy (lze nahradit pydantic modely)
|
||||
@@ -49,6 +130,7 @@ class PlanningSlot:
|
||||
load_baseline_w: int # W – predikce bazální spotřeby
|
||||
ev1_connected: bool
|
||||
ev2_connected: bool
|
||||
is_predicted_price: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -67,6 +149,7 @@ class DispatchResult:
|
||||
expected_cost_czk: float
|
||||
effective_buy_price: float
|
||||
effective_sell_price: float
|
||||
is_predicted_price: bool # shodné s PlanningSlot (chybí OTE v efektivní ceně → fn_get_predicted_price)
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -179,6 +262,11 @@ def solve_dispatch(
|
||||
vehicles: list, # [vehicle1, vehicle2]
|
||||
current_soc_wh: float,
|
||||
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.
|
||||
@@ -188,6 +276,9 @@ def solve_dispatch(
|
||||
EV = len(vehicles) # počet EV (typicky 2)
|
||||
|
||||
EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency)
|
||||
cycle_penalty_mult = _pv_scarcity_penalty_multiplier(slots, battery)
|
||||
degradation_cost_effective = battery.degradation_cost_czk_kwh * cycle_penalty_mult
|
||||
soc_buffer_target_wh, soc_deficit_penalty_czk_kwh = _soc_security_profile(slots, battery)
|
||||
|
||||
prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize)
|
||||
|
||||
@@ -199,6 +290,7 @@ def solve_dispatch(
|
||||
soc = [pulp.LpVariable(f"soc_{t}", battery.reserve_soc_wh, battery.soc_max_wh) 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)
|
||||
|
||||
# EV proměnné per vozidlo
|
||||
ev_direct = [[pulp.LpVariable(f"evd_{e}_{t}", 0,
|
||||
@@ -208,19 +300,23 @@ def solve_dispatch(
|
||||
vehicles[e].max_charge_power_w)
|
||||
for t in range(T)] for e in range(EV)]
|
||||
|
||||
# --- Účelová funkce ---
|
||||
# --- Účelová funkce (váhy slotů podle nejistoty za horizontem OTE) ---
|
||||
prob += pulp.lpSum(
|
||||
gi[t] * slots[t].buy_price * INTERVAL_H / 1000
|
||||
- ge[t] * slots[t].sell_price * INTERVAL_H / 1000
|
||||
+ (bc[t] + bd[t]) * battery.degradation_cost_czk_kwh * INTERVAL_H / 1000
|
||||
+ pulp.lpSum(
|
||||
ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000
|
||||
+ ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000
|
||||
for e in range(EV)
|
||||
slot_weight(t, now_slot_index) * (
|
||||
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
|
||||
+ ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000
|
||||
for e in range(EV)
|
||||
)
|
||||
+ ca[t] * CURTAILMENT_PENALTY
|
||||
)
|
||||
+ ca[t] * CURTAILMENT_PENALTY
|
||||
for t in range(T)
|
||||
)
|
||||
) + soc_deficit_24h * soc_deficit_penalty_czk_kwh / 1000
|
||||
|
||||
# --- Omezení ---
|
||||
for t in range(T):
|
||||
@@ -270,6 +366,27 @@ def solve_dispatch(
|
||||
else:
|
||||
prob += ev_direct[e][t] + ev_via_bat[e][t] <= vehicles[e].max_charge_power_w
|
||||
|
||||
om = (operating_mode or "AUTO").strip().upper()
|
||||
if om == "SELF_SUSTAIN":
|
||||
for t in range(T):
|
||||
prob += ge[t] == 0
|
||||
prob += gi[t] <= slots[t].load_baseline_w
|
||||
elif om == "PRESERVE":
|
||||
for t in range(T):
|
||||
prob += bc[t] == 0
|
||||
prob += bd[t] == 0
|
||||
elif om == "CHARGE_CHEAP":
|
||||
for t in range(T):
|
||||
prob += ge[t] == 0
|
||||
prob += bd[t] == 0
|
||||
|
||||
if price_failsafe_active:
|
||||
for t in range(T):
|
||||
# Fail-safe aplikujeme po slotech: v predikovaných cenách zakážeme pouze export.
|
||||
# Baterie se má dál normálně používat pro interní spotřebu (nabíjení/vybíjení do domu).
|
||||
if slots[t].is_predicted_price:
|
||||
prob += ge[t] == 0
|
||||
|
||||
# Deadline constraints pro EV
|
||||
for e, session in enumerate(ev_sessions):
|
||||
if session and session.target_deadline and session.energy_needed_wh > 0:
|
||||
@@ -283,14 +400,44 @@ def solve_dispatch(
|
||||
if (e == 0 and slots[t].ev1_connected) or (e == 1 and slots[t].ev2_connected)
|
||||
) >= session.energy_needed_wh
|
||||
|
||||
# TUV look-ahead podle tuv_usage_stats (DOW+hodina, konvence jako v DB)
|
||||
if (
|
||||
tuv_delta_stats
|
||||
and heat_pump.rated_heating_power_w > 0
|
||||
and getattr(heat_pump, "tuv_min_temp_c", 0) is not None
|
||||
):
|
||||
tuv_pred = float(current_tuv_temp_c)
|
||||
tgt = float(getattr(heat_pump, "tuv_target_temp_c", 55.0) or 55.0)
|
||||
thr = float(heat_pump.tuv_min_temp_c) + 5.0
|
||||
for t in range(T):
|
||||
dow, hour = _prague_dow_hour(slots[t].interval_start)
|
||||
delta = tuv_delta_stats.get((dow, hour), -0.1)
|
||||
tuv_pred += float(delta) * INTERVAL_H
|
||||
if tuv_pred < thr:
|
||||
prob += (
|
||||
pulp.lpSum(hp[s] for s in range(max(0, t - 8), t + 1))
|
||||
>= heat_pump.rated_heating_power_w * 0.5
|
||||
)
|
||||
tuv_pred = tgt
|
||||
|
||||
# Nouzový ohřev TUV
|
||||
if current_tuv_temp_c < heat_pump.tuv_min_temp_c:
|
||||
prob += hp[0] >= heat_pump.rated_heating_power_w * 0.8
|
||||
|
||||
# --- Řešení ---
|
||||
# SoC bezpečnostní buffer vyhodnocený až na konci 24h horizontu
|
||||
eod_idx = min(T - 1, int(24 / INTERVAL_H) - 1)
|
||||
prob += soc_deficit_24h >= soc_buffer_target_wh - soc[eod_idx]
|
||||
|
||||
# --- Řešení (HiGHS přes highspy / PuLP API; bez externí binárky HiGHS_CMD) ---
|
||||
t_start = time.monotonic()
|
||||
solver = HiGHS_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
|
||||
status = prob.solve(solver)
|
||||
try:
|
||||
solver = pulp.getSolver(
|
||||
"HiGHS", msg=False, timeLimit=SOLVER_TIME_LIMIT
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("HiGHS nedostupný, používám CBC fallback")
|
||||
solver = pulp.PULP_CBC_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
|
||||
status = prob.solve(solver)
|
||||
duration_ms = int((time.monotonic() - t_start) * 1000)
|
||||
|
||||
if pulp.LpStatus[status] != 'Optimal':
|
||||
@@ -327,6 +474,7 @@ def solve_dispatch(
|
||||
expected_cost_czk = round(cost, 4),
|
||||
effective_buy_price = slots[t].buy_price,
|
||||
effective_sell_price = slots[t].sell_price,
|
||||
is_predicted_price = bool(slots[t].is_predicted_price),
|
||||
))
|
||||
|
||||
return results, duration_ms
|
||||
@@ -340,7 +488,7 @@ 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 +36h.
|
||||
Horizont: od začátku aktuálního 15min slotu do +HORIZON_HOURS (96h; OTE + predikce).
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
horizon_from = _current_slot_start(now)
|
||||
@@ -349,13 +497,26 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
||||
logger.info(f"[site={site_id}] Daily plan: {horizon_from} → {horizon_to}")
|
||||
|
||||
slots = await _load_slots(site_id, horizon_from, horizon_to, db)
|
||||
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,
|
||||
)
|
||||
|
||||
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp = await _load_site_context(
|
||||
site_id, db
|
||||
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode = (
|
||||
await _load_site_context(site_id, db)
|
||||
)
|
||||
tuv_stats = await _load_tuv_usage_stats(site_id, db)
|
||||
|
||||
results, duration_ms = solve_dispatch(
|
||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp
|
||||
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,
|
||||
)
|
||||
|
||||
run_id = await _save_planning_run(
|
||||
@@ -421,18 +582,32 @@ async def run_rolling_replan(
|
||||
|
||||
logger.info(f"[site={site_id}] Rolling replan from {replan_from} → {horizon_to}")
|
||||
|
||||
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp = await _load_site_context(
|
||||
site_id, db
|
||||
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode = (
|
||||
await _load_site_context(site_id, db)
|
||||
)
|
||||
|
||||
correction_factor, correction_log = await compute_correction_factor(site_id, now, db)
|
||||
|
||||
slots = await _load_slots(site_id, replan_from, horizon_to, db)
|
||||
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)
|
||||
|
||||
tuv_stats = await _load_tuv_usage_stats(site_id, db)
|
||||
|
||||
results, duration_ms = solve_dispatch(
|
||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp
|
||||
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,
|
||||
)
|
||||
|
||||
run_id = await _save_planning_run(
|
||||
@@ -533,22 +708,45 @@ def _ev_session_ctx(row) -> Optional[SimpleNamespace]:
|
||||
|
||||
async def _load_site_context(site_id: int, db):
|
||||
"""
|
||||
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC a TUV pro solver.
|
||||
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV a provozní režim pro solver.
|
||||
"""
|
||||
operating_mode = await db.fetchval(
|
||||
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
|
||||
brow = await db.fetchrow(
|
||||
"""
|
||||
SELECT bat.usable_capacity_wh,
|
||||
bat.reserve_soc_percent,
|
||||
bat.max_soc_percent,
|
||||
bat.charge_efficiency,
|
||||
bat.discharge_efficiency,
|
||||
bat.degradation_cost_czk_kwh,
|
||||
inv.max_charge_power_w,
|
||||
inv.max_discharge_power_w
|
||||
FROM ems.asset_battery bat
|
||||
JOIN ems.asset_inverter inv ON inv.id = bat.inverter_id AND inv.site_id = bat.site_id
|
||||
WHERE bat.site_id = $1
|
||||
ORDER BY bat.id
|
||||
SELECT ab.usable_capacity_wh,
|
||||
ab.reserve_soc_percent,
|
||||
ab.max_soc_percent,
|
||||
ab.charge_efficiency,
|
||||
ab.discharge_efficiency,
|
||||
ab.degradation_cost_czk_kwh,
|
||||
LEAST(
|
||||
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w),
|
||||
COALESCE(
|
||||
ab.bms_max_charge_w,
|
||||
CASE WHEN ab.max_charge_c_rate IS NOT NULL
|
||||
THEN (ab.max_charge_c_rate * ab.usable_capacity_wh)::bigint
|
||||
END,
|
||||
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w)
|
||||
)
|
||||
) AS effective_charge_w,
|
||||
LEAST(
|
||||
COALESCE(ai.max_battery_discharge_w, ai.max_discharge_power_w),
|
||||
COALESCE(
|
||||
ab.bms_max_discharge_w,
|
||||
CASE WHEN ab.max_discharge_c_rate IS NOT NULL
|
||||
THEN (ab.max_discharge_c_rate * ab.usable_capacity_wh)::bigint
|
||||
END,
|
||||
COALESCE(ai.max_battery_discharge_w, ai.max_discharge_power_w)
|
||||
)
|
||||
) AS effective_discharge_w
|
||||
FROM ems.asset_battery ab
|
||||
JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id AND ai.site_id = ab.site_id
|
||||
WHERE ab.site_id = $1
|
||||
ORDER BY ab.id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
@@ -556,6 +754,21 @@ async def _load_site_context(site_id: int, db):
|
||||
if brow is None:
|
||||
raise RuntimeError(f"No asset_battery for site_id={site_id}")
|
||||
|
||||
ec_w = brow["effective_charge_w"]
|
||||
ed_w = brow["effective_discharge_w"]
|
||||
if ec_w is None or ed_w is None:
|
||||
raise RuntimeError(
|
||||
f"Battery effective power limits missing for site_id={site_id} "
|
||||
"(need max_battery_charge_w/max_discharge or legacy max_charge_power_w / max_discharge_power_w)"
|
||||
)
|
||||
ec_i = int(ec_w)
|
||||
ed_i = int(ed_w)
|
||||
if ec_i <= 0 or ed_i <= 0:
|
||||
raise RuntimeError(
|
||||
f"Invalid battery effective limits for site_id={site_id}: "
|
||||
f"charge={ec_i}W discharge={ed_i}W"
|
||||
)
|
||||
|
||||
uc = float(brow["usable_capacity_wh"])
|
||||
reserve_wh = float(brow["reserve_soc_percent"]) / 100.0 * uc
|
||||
soc_max_wh = float(brow["max_soc_percent"]) / 100.0 * uc
|
||||
@@ -566,14 +779,15 @@ async def _load_site_context(site_id: int, db):
|
||||
charge_efficiency=float(brow["charge_efficiency"]),
|
||||
discharge_efficiency=float(brow["discharge_efficiency"]),
|
||||
degradation_cost_czk_kwh=float(brow["degradation_cost_czk_kwh"]),
|
||||
max_charge_power_w=int(brow["max_charge_power_w"]),
|
||||
max_discharge_power_w=int(brow["max_discharge_power_w"]),
|
||||
max_charge_power_w=ec_i,
|
||||
max_discharge_power_w=ed_i,
|
||||
)
|
||||
|
||||
hrow = await db.fetchrow(
|
||||
"""
|
||||
SELECT COALESCE(rated_heating_power_w, 8000) AS rated_heating_power_w,
|
||||
COALESCE(tuv_min_temp_c, 45) AS tuv_min_temp_c
|
||||
COALESCE(tuv_min_temp_c, 45) AS tuv_min_temp_c,
|
||||
COALESCE(tuv_target_temp_c, 55) AS tuv_target_temp_c
|
||||
FROM ems.asset_heat_pump
|
||||
WHERE site_id = $1
|
||||
ORDER BY id
|
||||
@@ -582,12 +796,17 @@ async def _load_site_context(site_id: int, db):
|
||||
site_id,
|
||||
)
|
||||
if hrow is None:
|
||||
heat_pump = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=0.0)
|
||||
heat_pump = SimpleNamespace(
|
||||
rated_heating_power_w=0,
|
||||
tuv_min_temp_c=0.0,
|
||||
tuv_target_temp_c=55.0,
|
||||
)
|
||||
else:
|
||||
hp_w = int(hrow["rated_heating_power_w"])
|
||||
heat_pump = SimpleNamespace(
|
||||
rated_heating_power_w=max(hp_w, 0),
|
||||
tuv_min_temp_c=float(hrow["tuv_min_temp_c"]),
|
||||
tuv_target_temp_c=float(hrow["tuv_target_temp_c"]),
|
||||
)
|
||||
|
||||
grow = await db.fetchrow(
|
||||
@@ -689,46 +908,90 @@ async def _load_site_context(site_id: int, db):
|
||||
)
|
||||
tuv_temp = float(tuv) if tuv is not None else 50.0
|
||||
|
||||
return battery, heat_pump, grid, vehicles, ev_sessions, soc_wh, tuv_temp
|
||||
return (
|
||||
battery,
|
||||
heat_pump,
|
||||
grid,
|
||||
vehicles,
|
||||
ev_sessions,
|
||||
soc_wh,
|
||||
tuv_temp,
|
||||
operating_mode,
|
||||
)
|
||||
|
||||
|
||||
async def _load_tuv_usage_stats(site_id: int, db) -> dict[tuple[int, int], float]:
|
||||
"""Průměrná změna teploty TUV zásobníku per (DOW, hodina) v konvenci DB EXTRACT(DOW)."""
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT day_of_week, hour_of_day, avg_temp_delta_c
|
||||
FROM ems.tuv_usage_stats
|
||||
WHERE site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
return {
|
||||
(int(r["day_of_week"]), int(r["hour_of_day"])): float(r["avg_temp_delta_c"])
|
||||
for r in rows
|
||||
}
|
||||
|
||||
|
||||
async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
|
||||
"""Načte 15min sloty s cenami, forecasty a stavem EV z DB."""
|
||||
"""Načte 15min sloty s cenami (OTE + predikce za horizont), forecasty a stavem EV z DB."""
|
||||
rows = await db.fetch("""
|
||||
WITH slot_spine AS (
|
||||
SELECT gs AS interval_start
|
||||
FROM generate_series(
|
||||
$2::timestamptz,
|
||||
($3::timestamptz - interval '15 minutes')::timestamptz,
|
||||
interval '15 minutes'
|
||||
) AS gs
|
||||
)
|
||||
SELECT
|
||||
ep.interval_start,
|
||||
ep.effective_buy_price_czk_kwh AS buy_price,
|
||||
ep.effective_sell_price_czk_kwh AS sell_price,
|
||||
s.interval_start,
|
||||
COALESCE(
|
||||
ep.effective_buy_price_czk_kwh,
|
||||
ems.fn_get_predicted_price($1, s.interval_start)
|
||||
) AS buy_price,
|
||||
COALESCE(
|
||||
ep.effective_sell_price_czk_kwh,
|
||||
ems.fn_get_predicted_price($1, s.interval_start) * 0.85
|
||||
) AS sell_price,
|
||||
(ep.effective_buy_price_czk_kwh IS NULL) AS is_predicted_price,
|
||||
COALESCE(fpi_a.power_w, 0) AS pv_a_forecast_w,
|
||||
COALESCE(fpi_b.power_w, 0) AS pv_b_forecast_w,
|
||||
COALESCE(cbi.power_w, 500) AS load_baseline_w,
|
||||
-- EV připojení z poslední telemetrie nabíječek (bez řádku = nepřipojeno)
|
||||
COALESCE(
|
||||
(SELECT bs.avg_power_w
|
||||
FROM ems.consumption_baseline_stats bs
|
||||
WHERE bs.site_id = $1
|
||||
AND bs.day_of_week = EXTRACT(DOW FROM s.interval_start
|
||||
AT TIME ZONE 'Europe/Prague')::INT
|
||||
AND bs.hour_of_day = EXTRACT(HOUR FROM s.interval_start
|
||||
AT TIME ZONE 'Europe/Prague')::INT
|
||||
LIMIT 1),
|
||||
500
|
||||
) AS load_baseline_w,
|
||||
(COALESCE(ev1.status, 'available') NOT IN ('available', 'unavailable')) AS ev1_connected,
|
||||
(COALESCE(ev2.status, 'available') NOT IN ('available', 'unavailable')) AS ev2_connected
|
||||
FROM ems.vw_site_effective_price ep
|
||||
-- FVE pole A forecast
|
||||
FROM slot_spine s
|
||||
LEFT JOIN ems.vw_site_effective_price ep
|
||||
ON ep.site_id = $1 AND ep.interval_start = s.interval_start
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT fpi.power_w FROM ems.forecast_pv_interval fpi
|
||||
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
||||
JOIN ems.asset_pv_array apa ON apa.id = fpi.pv_array_id AND apa.site_id = fpr.site_id
|
||||
WHERE fpr.site_id = $1 AND apa.code = 'pv-a'
|
||||
AND fpi.interval_start = ep.interval_start AND fpr.status = 'ok'
|
||||
AND fpi.interval_start = s.interval_start AND fpr.status = 'ok'
|
||||
ORDER BY fpr.created_at DESC LIMIT 1
|
||||
) fpi_a ON true
|
||||
-- FVE pole B forecast
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT fpi.power_w FROM ems.forecast_pv_interval fpi
|
||||
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
||||
JOIN ems.asset_pv_array apa ON apa.id = fpi.pv_array_id AND apa.site_id = fpr.site_id
|
||||
WHERE fpr.site_id = $1 AND apa.code = 'pv-b'
|
||||
AND fpi.interval_start = ep.interval_start AND fpr.status = 'ok'
|
||||
AND fpi.interval_start = s.interval_start AND fpr.status = 'ok'
|
||||
ORDER BY fpr.created_at DESC LIMIT 1
|
||||
) fpi_b ON true
|
||||
-- Bazální spotřeba
|
||||
LEFT JOIN ems.consumption_baseline_interval cbi
|
||||
ON cbi.site_id = $1 AND cbi.interval_start = ep.interval_start
|
||||
AND cbi.data_type = 'forecast'
|
||||
-- Stav EV nabíječek (aktuální, pro celý horizont stejný)
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT t.status
|
||||
FROM ems.telemetry_ev_charger t
|
||||
@@ -743,9 +1006,7 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
|
||||
WHERE t.site_id = $1 AND ch.code = 'ev-charger-2'
|
||||
ORDER BY t.measured_at DESC LIMIT 1
|
||||
) ev2 ON true
|
||||
WHERE ep.site_id = $1
|
||||
AND ep.interval_start >= $2 AND ep.interval_start < $3
|
||||
ORDER BY ep.interval_start
|
||||
ORDER BY s.interval_start
|
||||
""", site_id, from_dt, to_dt)
|
||||
|
||||
out: list[PlanningSlot] = []
|
||||
@@ -761,6 +1022,7 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
|
||||
load_baseline_w=int(d["load_baseline_w"] or 0),
|
||||
ev1_connected=bool(d["ev1_connected"]),
|
||||
ev2_connected=bool(d["ev2_connected"]),
|
||||
is_predicted_price=bool(d.get("is_predicted_price")),
|
||||
)
|
||||
)
|
||||
if not out:
|
||||
@@ -796,8 +1058,9 @@ async def _save_planning_run(
|
||||
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)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
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,
|
||||
@@ -805,7 +1068,8 @@ async def _save_planning_run(
|
||||
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.effective_buy_price, r.effective_sell_price,
|
||||
r.is_predicted_price)
|
||||
for r in results
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user