sql first refactor
This commit is contained in:
@@ -3,51 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def fill_audit_for_completed_intervals(site_id: int, db) -> None:
|
||||
"""
|
||||
Naplní audit_interval pro všechny dokončené 15min intervaly
|
||||
za posledních 6 hodin které ještě nemají záznam.
|
||||
Volá PostgreSQL funkci ems.fn_fill_audit_interval().
|
||||
Naplní audit_interval pro dokončené 15min sloty přes ems.fn_fill_audit_for_site_window.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
last_complete = now.replace(
|
||||
minute=(now.minute // 15) * 15, second=0, microsecond=0
|
||||
)
|
||||
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT gs.slot
|
||||
FROM generate_series(
|
||||
$1::timestamptz - interval '6 hours',
|
||||
$1::timestamptz - interval '15 minutes',
|
||||
interval '15 minutes'
|
||||
) AS gs(slot)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ems.audit_interval ai
|
||||
WHERE ai.site_id = $2 AND ai.interval_start = gs.slot
|
||||
)
|
||||
""",
|
||||
last_complete,
|
||||
n = await db.fetchval(
|
||||
"select ems.fn_fill_audit_for_site_window($1::int, 6)",
|
||||
site_id,
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
slot = row["slot"]
|
||||
await db.execute(
|
||||
"SELECT ems.fn_fill_audit_interval($1, $2)",
|
||||
site_id,
|
||||
slot,
|
||||
)
|
||||
await db.execute(
|
||||
"SELECT ems.fn_fill_baseline_load_forecast_accuracy($1, $2)",
|
||||
site_id,
|
||||
slot,
|
||||
)
|
||||
|
||||
if rows:
|
||||
logger.info("[site=%s] Filled %s missing audit intervals", site_id, len(rows))
|
||||
if n:
|
||||
logger.info("[site=%s] Filled %s missing audit intervals", site_id, int(n))
|
||||
|
||||
3
backend/services/control/__init__.py
Normal file
3
backend/services/control/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Deye / Modbus control export (monolith v exporter_monolith.py – postupný split)."""
|
||||
|
||||
from .exporter_monolith import * # noqa: F401,F403
|
||||
1925
backend/services/control/exporter_monolith.py
Normal file
1925
backend/services/control/exporter_monolith.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
@@ -78,31 +79,26 @@ async def run_fn_set_mode_with_discord(
|
||||
notify_level: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Zavolá ems.fn_set_mode. Při skutečné změně režimu pošle Discord (pokud je webhook).
|
||||
Zavolá ems.fn_set_mode_with_context. Při skutečné změně režimu pošle Discord (pokud je webhook).
|
||||
Vrátí aktuální mode_code z DB po volání.
|
||||
"""
|
||||
prev = await conn.fetchval(
|
||||
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
|
||||
raw = await conn.fetchval(
|
||||
"""
|
||||
select ems.fn_set_mode_with_context($1::int, $2::text, $3::text, $4::timestamptz, $5::text)
|
||||
""",
|
||||
site_id,
|
||||
mode_code,
|
||||
activated_by,
|
||||
valid_until,
|
||||
notes,
|
||||
)
|
||||
new = await conn.fetchval(
|
||||
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
|
||||
site_id,
|
||||
)
|
||||
ctx = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
prev = ctx.get("previous_mode")
|
||||
new = ctx.get("new_mode")
|
||||
if new is None:
|
||||
new = mode_code
|
||||
site_code = ctx.get("site_code")
|
||||
if prev is not None and prev != new:
|
||||
site_code = await conn.fetchval(
|
||||
"SELECT code FROM ems.site WHERE id = $1", site_id
|
||||
)
|
||||
await notify_operating_mode_changed(
|
||||
site_code or str(site_id),
|
||||
str(prev),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
# scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0)
|
||||
# scheduler.add_job(run_rolling_replan, 'cron', minute='*/15')
|
||||
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from dataclasses import dataclass, replace
|
||||
@@ -149,107 +150,6 @@ def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]:
|
||||
return (loc.weekday() + 1) % 7, loc.hour
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Slot pre-selection (anti-micro-cycling)
|
||||
# ============================================================
|
||||
|
||||
def _select_charge_slots(
|
||||
slots: list["PlanningSlot"],
|
||||
battery,
|
||||
current_soc_wh: float,
|
||||
) -> set[int]:
|
||||
"""
|
||||
Pre-select which slots are eligible for battery charging (anti-micro-cycling).
|
||||
|
||||
Logika:
|
||||
1) Sloty s PV-surplus (pv_a + pv_b > load_baseline) jsou vždy zahrnuty –
|
||||
nabíjení z FVE je „zdarma“, solver ho musí mít povolené. Tyto sloty se
|
||||
NEzapočítávají do grid rozpočtu (v dlouhém horizontu by přetekly target).
|
||||
2) Nezávisle na bodu 1 se vybere top-N **grid** slotů seřazených podle
|
||||
`buy_price` ASC tak, aby pokryly `charge_buf × (soc_max − current_soc)`.
|
||||
Tím dostane solver k dispozici přístup k nejlevnějšímu nákupu ze sítě,
|
||||
i když PV v daném slotu spotřebu nepokrývá.
|
||||
3) Per-slot kapacita přírůstku SoC = max_charge_power × η × 15 min (plný
|
||||
výkon, ne limitovaný aktuálním PV-surplus výkonem).
|
||||
|
||||
Vrací množinu indexů povolených pro `bc[t] > 0` v MILP. Prázdná množina = žádné
|
||||
restrikce. `charge_slot_buffer <= 0` v DB ⇒ všechny sloty povoleny.
|
||||
"""
|
||||
charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0)
|
||||
if charge_buf <= 0:
|
||||
return set(range(len(slots)))
|
||||
|
||||
energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh)
|
||||
if energy_to_fill <= 0:
|
||||
return set()
|
||||
|
||||
eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0)
|
||||
max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0)
|
||||
per_slot_full_wh = max_p_w * eta * INTERVAL_H
|
||||
|
||||
selected: set[int] = set()
|
||||
for t, s in enumerate(slots):
|
||||
pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w)
|
||||
if pv_surplus_w > 0:
|
||||
selected.add(t)
|
||||
|
||||
grid_target_wh = energy_to_fill * charge_buf
|
||||
if grid_target_wh <= 0 or per_slot_full_wh <= 0:
|
||||
return selected
|
||||
|
||||
grid_candidates = [
|
||||
(t, float(s.buy_price)) for t, s in enumerate(slots) if t not in selected
|
||||
]
|
||||
grid_candidates.sort(key=lambda x: x[1])
|
||||
|
||||
cumulative = 0.0
|
||||
for t, _price in grid_candidates:
|
||||
if cumulative >= grid_target_wh:
|
||||
break
|
||||
selected.add(t)
|
||||
cumulative += per_slot_full_wh
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
def _select_discharge_export_slots(
|
||||
slots: list["PlanningSlot"],
|
||||
battery,
|
||||
) -> set[int]:
|
||||
"""
|
||||
Pre-select which slots may use battery energy for grid export.
|
||||
Only the Y most expensive sell-price slots are selected,
|
||||
enough to empty the exportable portion of the battery with a buffer.
|
||||
Returns set of slot indices. Empty set = no restriction.
|
||||
"""
|
||||
discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
|
||||
if discharge_buf <= 0:
|
||||
return set(range(len(slots)))
|
||||
|
||||
exportable = float(battery.soc_max_wh) - float(battery.min_soc_wh)
|
||||
if exportable <= 0:
|
||||
return set()
|
||||
|
||||
candidates = [(t, float(s.sell_price)) for t, s in enumerate(slots)]
|
||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
energy_per_slot = (
|
||||
float(battery.max_discharge_power_w)
|
||||
* float(battery.discharge_efficiency)
|
||||
* INTERVAL_H
|
||||
)
|
||||
target = exportable * discharge_buf
|
||||
selected: set[int] = set()
|
||||
cumulative = 0.0
|
||||
for t, _price in candidates:
|
||||
if cumulative >= target:
|
||||
break
|
||||
selected.add(t)
|
||||
cumulative += energy_per_slot
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Datové třídy (lze nahradit pydantic modely)
|
||||
# ============================================================
|
||||
@@ -265,6 +165,8 @@ class PlanningSlot:
|
||||
ev1_connected: bool
|
||||
ev2_connected: bool
|
||||
is_predicted_price: bool = False
|
||||
allow_charge: bool = True
|
||||
allow_discharge_export: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -303,49 +205,31 @@ async def compute_correction_factor(
|
||||
factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný.
|
||||
"""
|
||||
window_start = now - timedelta(hours=window_h)
|
||||
|
||||
# Skutečná výroba za okno (z telemetrie)
|
||||
actual = await db.fetchval("""
|
||||
SELECT COALESCE(SUM(pv_power_w) * 0.25 / 1000.0, 0) -- kWh
|
||||
FROM ems.telemetry_inverter
|
||||
WHERE site_id = $1
|
||||
AND measured_at >= $2 AND measured_at < $3
|
||||
""", site_id, window_start, now)
|
||||
|
||||
# Předpovídaná výroba za stejné okno (z nejnovějšího forecastu který platil tehdy)
|
||||
forecast = await db.fetchval("""
|
||||
SELECT COALESCE(SUM(fpi.power_w) * 0.25 / 1000.0, 0)
|
||||
FROM ems.forecast_pv_interval fpi
|
||||
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
||||
WHERE fpr.site_id = $1
|
||||
AND fpi.interval_start >= $2 AND fpi.interval_start < $3
|
||||
AND fpr.status = 'ok'
|
||||
AND fpr.created_at = (
|
||||
SELECT MAX(fpr2.created_at)
|
||||
FROM ems.forecast_pv_run fpr2
|
||||
WHERE fpr2.site_id = $1 AND fpr2.status = 'ok'
|
||||
AND fpr2.created_at <= $2
|
||||
)
|
||||
""", site_id, window_start, now)
|
||||
|
||||
raw = await db.fetchval(
|
||||
"""
|
||||
select ems.fn_pv_forecast_correction_factor(
|
||||
$1::int, $2::timestamptz, $3::timestamptz,
|
||||
$4::numeric, $5::numeric
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
window_start,
|
||||
now,
|
||||
CORRECTION_MIN_CLAMP,
|
||||
CORRECTION_MAX_CLAMP,
|
||||
)
|
||||
j = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
factor = float(j.get("correction_factor", 1.0))
|
||||
log_data = {
|
||||
"window_start": window_start,
|
||||
"window_end": now,
|
||||
"actual_pv_wh": actual * 1000,
|
||||
"forecast_pv_wh": forecast * 1000,
|
||||
"window_start": j.get("window_start", window_start),
|
||||
"window_end": j.get("window_end", now),
|
||||
"actual_pv_wh": j.get("actual_pv_wh"),
|
||||
"forecast_pv_wh": j.get("forecast_pv_wh"),
|
||||
"correction_factor": factor,
|
||||
"reason": j.get("reason", "ok"),
|
||||
}
|
||||
|
||||
# Pokud forecast nebo actual jsou příliš malé (noc, <0.1 kWh) → žádná korekce
|
||||
if forecast < 0.1 or actual < 0.05:
|
||||
log_data["correction_factor"] = 1.0
|
||||
log_data["reason"] = "insufficient_data"
|
||||
return 1.0, log_data
|
||||
|
||||
raw_factor = actual / forecast
|
||||
factor = max(CORRECTION_MIN_CLAMP, min(CORRECTION_MAX_CLAMP, raw_factor))
|
||||
|
||||
log_data["correction_factor"] = factor
|
||||
log_data["raw_factor"] = raw_factor
|
||||
if j.get("raw_factor") is not None:
|
||||
log_data["raw_factor"] = j["raw_factor"]
|
||||
return factor, log_data
|
||||
|
||||
|
||||
@@ -559,10 +443,10 @@ def solve_dispatch(
|
||||
if slots[t].is_predicted_price:
|
||||
prob += ge[t] == 0
|
||||
|
||||
# Slot pre-selection: omezení nabíjení a discharge-exportu na vybrané sloty
|
||||
# Slot pre-selection (z DB fn_load_planning_slots_full → allow_*)
|
||||
if om == "AUTO":
|
||||
charge_slots = _select_charge_slots(slots, battery, current_soc_wh)
|
||||
discharge_export_slots = _select_discharge_export_slots(slots, battery)
|
||||
charge_slots = {t for t, s in enumerate(slots) if s.allow_charge}
|
||||
discharge_export_slots = {t for t, s in enumerate(slots) if s.allow_discharge_export}
|
||||
for t in range(T):
|
||||
if t not in charge_slots:
|
||||
prob += bc[t] == 0
|
||||
@@ -683,7 +567,10 @@ 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)
|
||||
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
|
||||
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
|
||||
@@ -694,11 +581,6 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
||||
missing_ote_count,
|
||||
)
|
||||
|
||||
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,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
@@ -750,17 +632,20 @@ async def run_rolling_replan(
|
||||
now = datetime.now(timezone.utc)
|
||||
replan_from = _current_slot_start(now)
|
||||
|
||||
active_run = await db.fetchrow("""
|
||||
SELECT id, horizon_end FROM ems.planning_run
|
||||
WHERE site_id = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""", site_id)
|
||||
|
||||
if not active_run:
|
||||
ar_raw = await db.fetchval(
|
||||
"select ems.fn_planning_active_run($1::int)",
|
||||
site_id,
|
||||
)
|
||||
ar = ar_raw if isinstance(ar_raw, dict) else json.loads(ar_raw)
|
||||
if ar.get("error") == "no_active_plan":
|
||||
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)
|
||||
|
||||
horizon_to = active_run["horizon_end"]
|
||||
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"))
|
||||
|
||||
if (horizon_to - replan_from).total_seconds() < 1800:
|
||||
if allow_skip:
|
||||
@@ -771,13 +656,13 @@ 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, operating_mode = (
|
||||
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
|
||||
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)
|
||||
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)
|
||||
@@ -791,8 +676,6 @@ async def run_rolling_replan(
|
||||
|
||||
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,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
@@ -818,10 +701,10 @@ async def run_rolling_replan(
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.forecast_correction_log
|
||||
(site_id, window_start, window_end, actual_pv_wh, forecast_pv_wh,
|
||||
correction_factor, applied_to_run_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
select ems.fn_forecast_correction_log_insert(
|
||||
$1::int, $2::timestamptz, $3::timestamptz,
|
||||
$4::numeric, $5::numeric, $6::numeric, $7::int
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
correction_log["window_start"],
|
||||
@@ -870,184 +753,86 @@ def _current_slot_start(dt: datetime) -> datetime:
|
||||
return dt.replace(minute=minute, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _ev_session_ctx(row) -> Optional[SimpleNamespace]:
|
||||
"""Kontext deadline constraintu pro jedno EV (nebo None)."""
|
||||
if row is None or row["target_deadline"] is None:
|
||||
def _parse_json_dt(val: object) -> Optional[datetime]:
|
||||
if val is None:
|
||||
return None
|
||||
cap_kwh = row["veh_cap_kwh"]
|
||||
if cap_kwh is 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"))
|
||||
|
||||
|
||||
def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
|
||||
if obj is None or obj == []:
|
||||
return None
|
||||
cap_wh = float(cap_kwh) * 1000.0
|
||||
tgt = row["target_soc_pct"]
|
||||
if tgt is None:
|
||||
tgt = row["default_target_soc_pct"]
|
||||
if tgt is None:
|
||||
if isinstance(obj, str):
|
||||
obj = json.loads(obj)
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
tgt_f = float(tgt)
|
||||
soc0 = row["soc_at_connect_pct"]
|
||||
if soc0 is None:
|
||||
return None
|
||||
needed_wh = (tgt_f - float(soc0)) / 100.0 * cap_wh
|
||||
delivered = float(row["energy_delivered_wh"] or 0)
|
||||
remaining = max(0.0, needed_wh - delivered)
|
||||
if remaining <= 0:
|
||||
td = _parse_json_dt(obj.get("target_deadline"))
|
||||
if td is None:
|
||||
return None
|
||||
return SimpleNamespace(
|
||||
target_deadline=row["target_deadline"],
|
||||
energy_needed_wh=remaining,
|
||||
target_deadline=td,
|
||||
energy_needed_wh=float(obj["energy_needed_wh"]),
|
||||
)
|
||||
|
||||
|
||||
async def _load_site_context(site_id: int, db):
|
||||
"""
|
||||
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV a provozní režim pro solver.
|
||||
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV, režim a TUV statistiky (SQL).
|
||||
"""
|
||||
operating_mode = await db.fetchval(
|
||||
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
|
||||
raw = await db.fetchval(
|
||||
"select ems.fn_planning_site_context($1::int)",
|
||||
site_id,
|
||||
)
|
||||
ctx = raw if isinstance(raw, dict) else json.loads(raw)
|
||||
if ctx.get("error") == "unknown_site":
|
||||
raise RuntimeError(f"Site not found: {site_id}")
|
||||
|
||||
brow = await db.fetchrow(
|
||||
"""
|
||||
SELECT ab.usable_capacity_wh,
|
||||
ab.min_soc_percent,
|
||||
ab.reserve_soc_percent,
|
||||
ab.max_soc_percent,
|
||||
ab.charge_efficiency,
|
||||
ab.discharge_efficiency,
|
||||
ab.degradation_cost_czk_kwh,
|
||||
ab.charge_slot_buffer,
|
||||
ab.discharge_slot_buffer,
|
||||
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,
|
||||
)
|
||||
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"])
|
||||
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
|
||||
b = ctx["battery"]
|
||||
ec_i = int(b["max_charge_power_w"])
|
||||
ed_i = int(b["max_discharge_power_w"])
|
||||
battery = SimpleNamespace(
|
||||
usable_capacity_wh=uc,
|
||||
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"]),
|
||||
degradation_cost_czk_kwh=float(brow["degradation_cost_czk_kwh"]),
|
||||
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"]),
|
||||
charge_efficiency=float(b["charge_efficiency"]),
|
||||
discharge_efficiency=float(b["discharge_efficiency"]),
|
||||
degradation_cost_czk_kwh=float(b["degradation_cost_czk_kwh"]),
|
||||
max_charge_power_w=ec_i,
|
||||
max_discharge_power_w=ed_i,
|
||||
charge_slot_buffer=float(brow["charge_slot_buffer"]) if brow["charge_slot_buffer"] is not None else 0,
|
||||
discharge_slot_buffer=float(brow["discharge_slot_buffer"]) if brow["discharge_slot_buffer"] is not None else 0,
|
||||
charge_slot_buffer=float(b["charge_slot_buffer"])
|
||||
if b.get("charge_slot_buffer") is not None
|
||||
else 0,
|
||||
discharge_slot_buffer=float(b["discharge_slot_buffer"])
|
||||
if b.get("discharge_slot_buffer") is not None
|
||||
else 0,
|
||||
)
|
||||
|
||||
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_target_temp_c, 55) AS tuv_target_temp_c
|
||||
FROM ems.asset_heat_pump
|
||||
WHERE site_id = $1
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
hpj = ctx["heat_pump"]
|
||||
heat_pump = SimpleNamespace(
|
||||
rated_heating_power_w=int(hpj["rated_heating_power_w"]),
|
||||
tuv_min_temp_c=float(hpj["tuv_min_temp_c"]),
|
||||
tuv_target_temp_c=float(hpj["tuv_target_temp_c"]),
|
||||
)
|
||||
if hrow is None:
|
||||
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(
|
||||
"""
|
||||
SELECT max_import_power_w, max_export_power_w
|
||||
FROM ems.site_grid_connection
|
||||
WHERE site_id = $1
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if grow is None:
|
||||
raise RuntimeError(f"No site_grid_connection for site_id={site_id}")
|
||||
g = ctx["grid"]
|
||||
grid = SimpleNamespace(
|
||||
max_import_power_w=int(grow["max_import_power_w"]),
|
||||
max_export_power_w=int(grow["max_export_power_w"]),
|
||||
max_import_power_w=int(g["max_import_power_w"]),
|
||||
max_export_power_w=int(g["max_export_power_w"]),
|
||||
)
|
||||
|
||||
vrows = await db.fetch(
|
||||
"""
|
||||
SELECT v.battery_capacity_kwh,
|
||||
v.max_charge_power_w,
|
||||
v.default_target_soc_pct,
|
||||
ch.code AS charger_code
|
||||
FROM ems.asset_vehicle v
|
||||
JOIN ems.asset_ev_charger ch ON ch.id = v.default_charger_id
|
||||
WHERE v.site_id = $1
|
||||
AND ch.code IN ('ev-charger-1', 'ev-charger-2')
|
||||
ORDER BY ch.code
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
vehicles: list[SimpleNamespace] = [
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=int(r["max_charge_power_w"]),
|
||||
battery_capacity_kwh=float(r["battery_capacity_kwh"]),
|
||||
default_target_soc_pct=float(r["default_target_soc_pct"]),
|
||||
vehicles: list[SimpleNamespace] = []
|
||||
for v in ctx.get("vehicles") or []:
|
||||
vehicles.append(
|
||||
SimpleNamespace(
|
||||
max_charge_power_w=int(v["max_charge_power_w"]),
|
||||
battery_capacity_kwh=float(v["battery_capacity_kwh"]),
|
||||
default_target_soc_pct=float(v["default_target_soc_pct"]),
|
||||
)
|
||||
)
|
||||
for r in vrows
|
||||
]
|
||||
while len(vehicles) < 2:
|
||||
vehicles.append(
|
||||
SimpleNamespace(
|
||||
@@ -1057,56 +842,19 @@ async def _load_site_context(site_id: int, db):
|
||||
)
|
||||
)
|
||||
|
||||
srows = await db.fetch(
|
||||
"""
|
||||
SELECT es.target_deadline,
|
||||
es.target_soc_pct,
|
||||
es.soc_at_connect_pct,
|
||||
es.energy_delivered_wh,
|
||||
ch.code AS charger_code,
|
||||
v.battery_capacity_kwh AS veh_cap_kwh,
|
||||
v.default_target_soc_pct
|
||||
FROM ems.ev_session es
|
||||
JOIN ems.asset_ev_charger ch ON ch.id = es.charger_id
|
||||
LEFT JOIN ems.asset_vehicle v ON v.id = es.vehicle_id
|
||||
WHERE es.site_id = $1
|
||||
AND es.session_end IS NULL
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
by_charger = {r["charger_code"]: r for r in srows}
|
||||
ev_raw = ctx.get("ev_sessions") or []
|
||||
ev_sessions = [
|
||||
_ev_session_ctx(by_charger.get("ev-charger-1")),
|
||||
_ev_session_ctx(by_charger.get("ev-charger-2")),
|
||||
_ev_session_from_json(ev_raw[0]) if len(ev_raw) > 0 else None,
|
||||
_ev_session_from_json(ev_raw[1]) if len(ev_raw) > 1 else None,
|
||||
]
|
||||
|
||||
soc_pct = await db.fetchval(
|
||||
"""
|
||||
SELECT battery_soc_percent
|
||||
FROM ems.telemetry_inverter
|
||||
WHERE site_id = $1
|
||||
ORDER BY measured_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if soc_pct is None:
|
||||
soc_wh = uc * 0.5
|
||||
else:
|
||||
soc_wh = float(soc_pct) / 100.0 * uc
|
||||
soc_wh = max(min_soc_wh, min(soc_wh, soc_max_wh))
|
||||
soc_wh = float(ctx["soc_wh"])
|
||||
tuv_temp = float(ctx["tuv_temp"])
|
||||
operating_mode = ctx.get("operating_mode")
|
||||
|
||||
tuv = await db.fetchval(
|
||||
"""
|
||||
SELECT tuv_tank_temp_c
|
||||
FROM ems.telemetry_heat_pump
|
||||
WHERE site_id = $1
|
||||
ORDER BY measured_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
tuv_temp = float(tuv) if tuv is not None else 50.0
|
||||
tuv_stats: dict[tuple[int, int], float] = {}
|
||||
for row in ctx.get("tuv_delta_stats") or []:
|
||||
tuv_stats[(int(row["dow"]), int(row["hour"]))] = float(row["delta"])
|
||||
|
||||
return (
|
||||
battery,
|
||||
@@ -1117,120 +865,33 @@ async def _load_site_context(site_id: int, db):
|
||||
soc_wh,
|
||||
tuv_temp,
|
||||
operating_mode,
|
||||
tuv_stats,
|
||||
)
|
||||
|
||||
|
||||
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)."""
|
||||
async def _load_slots(
|
||||
site_id: int,
|
||||
from_dt: datetime,
|
||||
to_dt: datetime,
|
||||
db,
|
||||
*,
|
||||
soc_wh: float,
|
||||
) -> list[PlanningSlot]:
|
||||
"""15min sloty z ems.fn_load_planning_slots_full."""
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT day_of_week, hour_of_day, avg_temp_delta_c
|
||||
FROM ems.tuv_usage_stats
|
||||
WHERE site_id = $1
|
||||
select slot_ord, interval_start, buy_price, sell_price, is_predicted_price,
|
||||
pv_a_forecast_w, pv_b_forecast_w, load_baseline_w,
|
||||
ev1_connected, ev2_connected, allow_charge, allow_discharge_export
|
||||
from ems.fn_load_planning_slots_full(
|
||||
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
from_dt,
|
||||
to_dt,
|
||||
soc_wh,
|
||||
)
|
||||
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 (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
|
||||
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(
|
||||
(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 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 COALESCE(SUM(u.power_w), 0)::INT AS power_w
|
||||
FROM (
|
||||
SELECT DISTINCT ON (apa.id)
|
||||
fpi.power_w
|
||||
FROM ems.asset_pv_array apa
|
||||
JOIN ems.forecast_pv_run fpr
|
||||
ON fpr.pv_array_id = apa.id
|
||||
AND fpr.site_id = apa.site_id
|
||||
AND fpr.status = 'ok'
|
||||
JOIN ems.forecast_pv_interval fpi
|
||||
ON fpi.run_id = fpr.id
|
||||
AND fpi.pv_array_id = apa.id
|
||||
AND fpi.interval_start = s.interval_start
|
||||
WHERE apa.site_id = $1
|
||||
AND apa.controllable IS TRUE
|
||||
ORDER BY apa.id, fpr.created_at DESC
|
||||
) u
|
||||
) fpi_a ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(u.power_w), 0)::INT AS power_w
|
||||
FROM (
|
||||
SELECT DISTINCT ON (apa.id)
|
||||
fpi.power_w
|
||||
FROM ems.asset_pv_array apa
|
||||
JOIN ems.forecast_pv_run fpr
|
||||
ON fpr.pv_array_id = apa.id
|
||||
AND fpr.site_id = apa.site_id
|
||||
AND fpr.status = 'ok'
|
||||
JOIN ems.forecast_pv_interval fpi
|
||||
ON fpi.run_id = fpr.id
|
||||
AND fpi.pv_array_id = apa.id
|
||||
AND fpi.interval_start = s.interval_start
|
||||
WHERE apa.site_id = $1
|
||||
AND apa.controllable IS FALSE
|
||||
ORDER BY apa.id, fpr.created_at DESC
|
||||
) u
|
||||
) fpi_b ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT t.status
|
||||
FROM ems.telemetry_ev_charger t
|
||||
JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id
|
||||
WHERE t.site_id = $1 AND ch.code = 'ev-charger-1'
|
||||
ORDER BY t.measured_at DESC LIMIT 1
|
||||
) ev1 ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT t.status
|
||||
FROM ems.telemetry_ev_charger t
|
||||
JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id
|
||||
WHERE t.site_id = $1 AND ch.code = 'ev-charger-2'
|
||||
ORDER BY t.measured_at DESC LIMIT 1
|
||||
) ev2 ON true
|
||||
ORDER BY s.interval_start
|
||||
""", site_id, from_dt, to_dt)
|
||||
|
||||
out: list[PlanningSlot] = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
@@ -1245,6 +906,8 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
|
||||
ev1_connected=bool(d["ev1_connected"]),
|
||||
ev2_connected=bool(d["ev2_connected"]),
|
||||
is_predicted_price=bool(d.get("is_predicted_price")),
|
||||
allow_charge=bool(d.get("allow_charge", True)),
|
||||
allow_discharge_export=bool(d.get("allow_discharge_export", True)),
|
||||
)
|
||||
)
|
||||
if not out:
|
||||
@@ -1281,112 +944,59 @@ async def _save_planning_run(
|
||||
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í."""
|
||||
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
|
||||
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,
|
||||
run_type, triggered_by, replan_from,
|
||||
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor)
|
||||
VALUES ($1,$2,$3,'draft',$4,$5,$6,$7,$8,$9)
|
||||
RETURNING id
|
||||
""", site_id, horizon_from, horizon_to,
|
||||
run_type, triggered_by, replan_from,
|
||||
soc_wh, duration_ms, correction)
|
||||
run_meta = {
|
||||
"run_type": run_type,
|
||||
"triggered_by": triggered_by,
|
||||
"replan_from": replan_from.isoformat() if replan_from else None,
|
||||
"soc_at_replan_wh": soc_wh,
|
||||
"solver_duration_ms": duration_ms,
|
||||
"forecast_correction_factor": correction,
|
||||
}
|
||||
intervals: list[dict] = []
|
||||
for i, r in enumerate(results):
|
||||
row: dict = {
|
||||
"interval_start": r.interval_start.isoformat()
|
||||
if hasattr(r.interval_start, "isoformat")
|
||||
else r.interval_start,
|
||||
"battery_setpoint_w": r.battery_setpoint_w,
|
||||
"battery_soc_target_pct": r.battery_soc_target,
|
||||
"grid_setpoint_w": r.grid_setpoint_w,
|
||||
"ev1_setpoint_w": r.ev1_setpoint_w,
|
||||
"ev2_setpoint_w": r.ev2_setpoint_w,
|
||||
"ev1_via_bat_w": r.ev1_via_bat_w,
|
||||
"ev2_via_bat_w": r.ev2_via_bat_w,
|
||||
"heat_pump_enabled": r.heat_pump_enabled,
|
||||
"heat_pump_setpoint_w": r.heat_pump_setpoint_w,
|
||||
"pv_a_curtailed_w": r.pv_a_curtailed_w,
|
||||
"expected_cost_czk": float(r.expected_cost_czk),
|
||||
"effective_buy_price": float(r.effective_buy_price),
|
||||
"effective_sell_price": float(r.effective_sell_price),
|
||||
"is_predicted_price": r.is_predicted_price,
|
||||
}
|
||||
if slot_inputs is not None:
|
||||
si = slot_inputs[i]
|
||||
row["load_baseline_w"] = si[0]
|
||||
row["pv_a_forecast_raw_w"] = si[1]
|
||||
row["pv_b_forecast_raw_w"] = si[2]
|
||||
row["pv_a_forecast_solver_w"] = si[3]
|
||||
row["pv_b_forecast_solver_w"] = si[4]
|
||||
intervals.append(row)
|
||||
|
||||
# Bulk insert výsledků
|
||||
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],
|
||||
return int(
|
||||
await db.fetchval(
|
||||
"""
|
||||
select ems.fn_planning_run_commit(
|
||||
$1::int, $2::timestamptz, $3::timestamptz,
|
||||
$4::jsonb, $5::jsonb
|
||||
)
|
||||
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,
|
||||
site_id,
|
||||
horizon_from,
|
||||
horizon_to,
|
||||
json.dumps(run_meta, default=str),
|
||||
json.dumps(intervals, default=str),
|
||||
)
|
||||
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("""
|
||||
UPDATE ems.planning_run SET status = 'superseded'
|
||||
WHERE site_id = $1 AND status = 'active' AND id <> $2
|
||||
""", site_id, run_id)
|
||||
|
||||
await db.execute(
|
||||
"UPDATE ems.planning_run SET status = 'active' WHERE id = $1", run_id
|
||||
)
|
||||
|
||||
return run_id
|
||||
|
||||
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db_json import fetch_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -119,18 +120,14 @@ async def _apply_ote_json_to_db(conn, payload: dict) -> int:
|
||||
|
||||
async def count_ote_slots_prague_day(conn, target_day: date) -> int:
|
||||
"""Počet řádků OTE_CZ pro kalendářní den v Europe/Prague (plný den 92/96/100)."""
|
||||
return int(
|
||||
await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*)::int
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source = 'OTE_CZ'
|
||||
AND (interval_start AT TIME ZONE 'Europe/Prague')::date = $1::date
|
||||
""",
|
||||
target_day,
|
||||
)
|
||||
or 0
|
||||
stats = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ote_day_slot_stats_prague($1::date)",
|
||||
target_day,
|
||||
)
|
||||
if not isinstance(stats, dict):
|
||||
stats = json.loads(stats)
|
||||
return int(stats.get("count") or 0)
|
||||
|
||||
|
||||
async def import_ote_prices_for_day(
|
||||
@@ -147,18 +144,15 @@ async def import_ote_prices_for_day(
|
||||
return -1, day_str, 0.0, fetch_error or "fetch_failed"
|
||||
try:
|
||||
n = await _apply_ote_json_to_db(conn, payload)
|
||||
first_price = await conn.fetchval(
|
||||
"""
|
||||
SELECT buy_raw_price_czk_kwh
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source = 'OTE_CZ'
|
||||
AND (interval_start AT TIME ZONE 'Europe/Prague')::date = $1::date
|
||||
ORDER BY interval_start
|
||||
LIMIT 1
|
||||
""",
|
||||
stats_after = await fetch_json(
|
||||
conn,
|
||||
"select ems.fn_ote_day_slot_stats_prague($1::date)",
|
||||
target_day,
|
||||
)
|
||||
n_imported = await count_ote_slots_prague_day(conn, target_day)
|
||||
if not isinstance(stats_after, dict):
|
||||
stats_after = json.loads(stats_after)
|
||||
first_price = stats_after.get("first_price")
|
||||
n_imported = int(stats_after.get("count") or 0)
|
||||
if not ote_prague_day_slots_look_complete(n_imported):
|
||||
logger.warning(
|
||||
"OTE: %s slotů pro %s (plný den = jedna z %s; jinak neúplná data)",
|
||||
@@ -248,7 +242,7 @@ async def import_ote_prices(
|
||||
"""
|
||||
if site_id is not None:
|
||||
row = await db.fetchrow(
|
||||
"SELECT timezone FROM ems.site WHERE id = $1", site_id
|
||||
"select timezone from ems.vw_site_directory where id = $1", site_id
|
||||
)
|
||||
if row is None:
|
||||
logger.error("OTE import: site id=%s nenalezen", site_id)
|
||||
@@ -290,26 +284,15 @@ async def import_ote_prices(
|
||||
|
||||
try:
|
||||
n = await _apply_ote_json_to_db(db, payload)
|
||||
first_price = await db.fetchval(
|
||||
"""
|
||||
SELECT buy_raw_price_czk_kwh
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source = 'OTE_CZ'
|
||||
AND interval_start::date = $1::date
|
||||
ORDER BY interval_start
|
||||
LIMIT 1
|
||||
""",
|
||||
target_day,
|
||||
)
|
||||
n_imported = await db.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*)::int
|
||||
FROM ems.market_interval_price
|
||||
WHERE market_source = 'OTE_CZ'
|
||||
AND interval_start::date = $1::date
|
||||
""",
|
||||
stats_after = await fetch_json(
|
||||
db,
|
||||
"select ems.fn_ote_day_slot_stats_prague($1::date)",
|
||||
target_day,
|
||||
)
|
||||
if not isinstance(stats_after, dict):
|
||||
stats_after = json.loads(stats_after)
|
||||
first_price = stats_after.get("first_price")
|
||||
n_imported = int(stats_after.get("count") or 0)
|
||||
incomplete = not ote_prague_day_slots_look_complete(n_imported or 0)
|
||||
if incomplete:
|
||||
now_p = datetime.now(ZoneInfo("Europe/Prague"))
|
||||
|
||||
@@ -41,13 +41,9 @@ def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
|
||||
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT ai.id, ai.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_inverter ai
|
||||
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
|
||||
WHERE ai.site_id = $1
|
||||
AND ai.active = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
select inverter_id as id, code, host, port, unit_id
|
||||
from ems.vw_asset_inverter_modbus_poll
|
||||
where site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
@@ -67,7 +63,7 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY)
|
||||
batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY)
|
||||
grid_power = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER)
|
||||
load_power = await mb.read_register(DEYE_REG_LOAD_TOTAL_POWER)
|
||||
load_power = await mb.read_register_signed(DEYE_REG_LOAD_TOTAL_POWER)
|
||||
pv1_power = await mb.read_register_signed(DEYE_REG_PV1_POWER)
|
||||
pv2_power = await mb.read_register_signed(DEYE_REG_PV2_POWER)
|
||||
gen_port_power = await mb.read_register_signed(DEYE_REG_GEN_PORT_POWER)
|
||||
@@ -81,27 +77,7 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
logger.debug("inverter:%s Deye run_state raw=%s", code, run_state)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_inverter (
|
||||
site_id, inverter_id, measured_at,
|
||||
pv_power_w, pv1_power_w, pv2_power_w, gen_port_power_w,
|
||||
battery_soc_percent, battery_power_w,
|
||||
batt_charge_today_wh, batt_discharge_today_wh,
|
||||
grid_power_w, load_power_w,
|
||||
grid_import_total_wh, grid_export_total_wh,
|
||||
run_state
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
$4, $5, $6, $7,
|
||||
$8, $9,
|
||||
$10, $11,
|
||||
$12, $13,
|
||||
$14, $15,
|
||||
$16
|
||||
)
|
||||
ON CONFLICT (inverter_id, measured_at) DO NOTHING
|
||||
""",
|
||||
"select ems.fn_telemetry_inverter_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::int, $6::int, $7::int, $8::float8, $9::int, $10::int, $11::int, $12::int, $13::int, $14::bigint, $15::bigint, $16::int)",
|
||||
site_id,
|
||||
inv_id,
|
||||
measured_at,
|
||||
@@ -141,12 +117,9 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT ec.id, ec.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_ev_charger ec
|
||||
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
||||
WHERE ec.site_id = $1
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
select charger_id as id, code, host, port, unit_id
|
||||
from ems.vw_asset_ev_charger_modbus_poll
|
||||
where site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
@@ -156,117 +129,52 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
||||
code = row["code"]
|
||||
charger_id = row["id"]
|
||||
logger.info("TODO: EV charger Modbus registry pending | %s", code)
|
||||
# Placeholder až do mapování Modbus: status zůstává available (bez falešných přechodů).
|
||||
current_status = "available"
|
||||
|
||||
previous_status = await db.fetchval(
|
||||
"""
|
||||
SELECT status
|
||||
FROM ems.telemetry_ev_charger
|
||||
WHERE charger_id = $1 AND connector_id = $2
|
||||
ORDER BY measured_at DESC
|
||||
LIMIT 1
|
||||
select status
|
||||
from ems.telemetry_ev_charger
|
||||
where charger_id = $1 and connector_id = $2
|
||||
order by measured_at desc
|
||||
limit 1
|
||||
""",
|
||||
charger_id,
|
||||
connector_id,
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_ev_charger (
|
||||
site_id, charger_id, measured_at, connector_id,
|
||||
status, power_w, energy_kwh
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 0, 0)
|
||||
ON CONFLICT (charger_id, connector_id, measured_at) DO NOTHING
|
||||
""",
|
||||
"select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8)",
|
||||
site_id,
|
||||
charger_id,
|
||||
measured_at,
|
||||
connector_id,
|
||||
current_status,
|
||||
0,
|
||||
0.0,
|
||||
)
|
||||
|
||||
if previous_status is not None:
|
||||
await db.fetchval(
|
||||
"select ems.fn_ev_session_transition($1::int, $2::int, $3::text, $4::text, $5::timestamptz)",
|
||||
site_id,
|
||||
charger_id,
|
||||
str(previous_status),
|
||||
current_status,
|
||||
measured_at,
|
||||
)
|
||||
if previous_status == "available" and current_status != "available":
|
||||
vehicle_id = await db.fetchval(
|
||||
"""
|
||||
SELECT av.id
|
||||
FROM ems.asset_vehicle av
|
||||
WHERE av.site_id = $1
|
||||
AND av.default_charger_id = $2
|
||||
AND av.active = true
|
||||
ORDER BY av.id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
charger_id,
|
||||
)
|
||||
await db.execute(
|
||||
"SELECT ems.fn_update_ev_arrival_stats($1, $2, $3, $4)",
|
||||
site_id,
|
||||
charger_id,
|
||||
vehicle_id,
|
||||
measured_at,
|
||||
)
|
||||
logger.info("EV arrival detected on charger %s", code)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.ev_session (
|
||||
site_id, charger_id, vehicle_id, session_start,
|
||||
target_soc_pct, target_deadline
|
||||
)
|
||||
SELECT
|
||||
ac.site_id,
|
||||
ac.id,
|
||||
av.id,
|
||||
now(),
|
||||
av.default_target_soc_pct,
|
||||
CASE
|
||||
WHEN av.default_deadline_hour IS NOT NULL THEN
|
||||
(
|
||||
(timezone('Europe/Prague', now()))::date + interval '1 day'
|
||||
+ make_interval(hours => av.default_deadline_hour)
|
||||
)::timestamp AT TIME ZONE 'Europe/Prague'
|
||||
END
|
||||
FROM ems.asset_ev_charger ac
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT v.id, v.default_target_soc_pct, v.default_deadline_hour
|
||||
FROM ems.asset_vehicle v
|
||||
WHERE v.default_charger_id = ac.id
|
||||
AND v.site_id = ac.site_id
|
||||
AND v.active = true
|
||||
ORDER BY v.id
|
||||
LIMIT 1
|
||||
) av ON true
|
||||
WHERE ac.id = $1 AND ac.site_id = $2
|
||||
ON CONFLICT (charger_id) WHERE session_end IS NULL DO NOTHING
|
||||
""",
|
||||
charger_id,
|
||||
site_id,
|
||||
)
|
||||
|
||||
if previous_status != "available" and current_status == "available":
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.ev_session
|
||||
SET session_end = now()
|
||||
WHERE charger_id = $1 AND session_end IS NULL
|
||||
""",
|
||||
charger_id,
|
||||
)
|
||||
elif previous_status != "available" and current_status == "available":
|
||||
logger.info("EV departure detected on charger %s", code)
|
||||
|
||||
|
||||
async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT hp.id, hp.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_heat_pump hp
|
||||
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
|
||||
WHERE hp.site_id = $1
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
select heat_pump_id as id, code, host, port, unit_id
|
||||
from ems.vw_asset_heat_pump_modbus_poll
|
||||
where site_id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
@@ -275,18 +183,15 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
||||
code = row["code"]
|
||||
logger.info("TODO: heat pump Modbus registry pending (heat_pump=%s)", code)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_heat_pump (
|
||||
site_id, heat_pump_id, measured_at,
|
||||
power_w, outdoor_temp_c, water_outlet_temp_c, tuv_tank_temp_c,
|
||||
operating_mode
|
||||
)
|
||||
VALUES ($1, $2, $3, 0, 10.0, 45.0, 55.0, 'standby')
|
||||
ON CONFLICT (heat_pump_id, measured_at) DO NOTHING
|
||||
""",
|
||||
"select ems.fn_telemetry_heat_pump_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::float8, $6::float8, $7::float8, $8::text)",
|
||||
site_id,
|
||||
row["id"],
|
||||
measured_at,
|
||||
0,
|
||||
10.0,
|
||||
45.0,
|
||||
55.0,
|
||||
"standby",
|
||||
)
|
||||
|
||||
|
||||
@@ -297,7 +202,9 @@ async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
sites = await conn.fetch(
|
||||
"select id from ems.vw_site_directory where active = true"
|
||||
)
|
||||
for site in sites:
|
||||
sid = site["id"]
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user