second version

This commit is contained in:
Dusan Vojacek
2026-04-03 14:23:16 +02:00
parent 897b95f728
commit 9f4126946d
105 changed files with 9738 additions and 1470 deletions

View File

@@ -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 # 036h od začátku okna (přesné OTE ceny)
SLOT_WEIGHT_MEDIUM = 0.7 # 3672h
SLOT_WEIGHT_LOW = 0.4 # 7296h
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
])