sql first refactor
Some checks failed
CI and deploy / migration-check (push) Successful in 5s
CI and deploy / deploy (push) Failing after 20s

This commit is contained in:
Dusan Vojacek
2026-04-19 20:02:20 +02:00
parent a02e11ee13
commit 93f883f5e0
74 changed files with 6022 additions and 4014 deletions

View File

@@ -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))

View File

@@ -0,0 +1,3 @@
"""Deye / Modbus control export (monolith v exporter_monolith.py postupný split)."""
from .exporter_monolith import * # noqa: F401,F403

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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),

View File

@@ -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

View File

@@ -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"))

View File

@@ -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: