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

@@ -1,5 +1,9 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates gcc g++ libgomp1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1

View File

@@ -24,17 +24,22 @@ class Settings(BaseSettings):
postgrest_anon_role: str = Field(default="ems_anon")
ote_api_url: str = Field(
default="https://www.ote-cr.cz/pubapi/v1/market-data/dam",
default=(
"https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data"
),
)
eur_czk_rate: float = Field(default=25.0)
open_meteo_api_url: str = Field(
default="https://api.open-meteo.com/v1/forecast",
)
open_meteo_forecast_days: int = Field(default=7)
loxone_user: str = Field(default="")
loxone_password: str = Field(default="")
discord_webhook_url: str = Field(default="")
telemetry_poll_interval_sec: int = Field(default=60)
planning_horizon_hours: int = Field(default=36)
planning_hp_max_cost_czk_kwh: float = Field(default=3.0)

View File

@@ -8,6 +8,7 @@ import os
from contextlib import asynccontextmanager
from datetime import date, datetime, timedelta, timezone
from typing import Annotated, Any, Literal
from zoneinfo import ZoneInfo
import asyncpg
import httpx
@@ -17,13 +18,29 @@ from app.deps import set_pg_pool
from app.routers.ev import router as ev_router
from app.routers.full_status import router as full_status_router
from app.routers.plan import router as plan_router
from app.ws_log_handler import WSLogHandler
from app.ws_manager import manager
from fastapi import (
APIRouter,
Depends,
FastAPI,
HTTPException,
Query,
Request,
WebSocket,
WebSocketDisconnect,
)
from fastapi.middleware.cors import CORSMiddleware
from services.audit_filler import fill_audit_for_completed_intervals
from services.control_exporter import (
export_setpoints,
read_deye_registers_live,
verify_modbus_commands,
)
from services.heartbeat_service import send_heartbeat
from services.forecast_service import fetch_pv_forecast
from services.price_importer import import_ote_prices
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
from services.audit_filler import fill_audit_for_completed_intervals
from services.heartbeat_service import send_heartbeat
from services.telemetry_collector import run_telemetry_loop_wrapper
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
@@ -47,7 +64,8 @@ async def get_pool() -> asyncpg.Pool:
return pool
scheduler = AsyncIOScheduler()
# Cron hodiny/minuty = Europe/Prague (import OTE 13:30 / 14:00, denní plán 15:00, …)
scheduler = AsyncIOScheduler(timezone=ZoneInfo("Europe/Prague"))
@asynccontextmanager
@@ -57,7 +75,10 @@ async def lifespan(app: FastAPI):
set_pg_pool(pool)
app.state.pg_pool = pool
from services.control_exporter import export_setpoints
app.state.ws_log_handler = WSLogHandler()
app.state.ws_log_handler.setLevel(logging.INFO)
logging.getLogger().addHandler(app.state.ws_log_handler)
from services.planning_engine import run_daily_plan, run_rolling_replan
async def scheduled_heartbeat() -> None:
@@ -78,6 +99,26 @@ async def lifespan(app: FastAPI):
except Exception:
logger.exception("scheduled_audit_filler site=%s failed", site["id"])
async def scheduled_forecast_accuracy() -> None:
async with app.state.pg_pool.acquire() as conn:
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
for site in sites:
try:
n = await conn.fetchval(
"SELECT ems.fn_fill_forecast_accuracy($1, 48)",
site["id"],
)
if n:
logger.info(
"forecast_accuracy filled %s slots for site %s",
n,
site["id"],
)
except Exception:
logger.exception(
"scheduled_forecast_accuracy site=%s failed", site["id"]
)
async def scheduled_expire_modes() -> None:
async with app.state.pg_pool.acquire() as conn:
try:
@@ -94,23 +135,200 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.exception("scheduled_control_export site=%s: %s", site["id"], e)
async def scheduled_daily_plan() -> None:
async def scheduled_verify_modbus() -> None:
"""
Ověří příkazy ve stavu written z posledních 20 minut.
Běží každé 2 minuty, nezávisle na control_exporter (delší okno kvůli zpoždění jobu).
"""
async with app.state.pg_pool.acquire() as conn:
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
for site in sites:
try:
await run_daily_plan(site["id"], conn)
cmd_rows = await conn.fetch(
"""
SELECT id FROM ems.modbus_command
WHERE site_id = $1
AND status = 'written'
AND written_at >= now() - INTERVAL '20 minutes'
ORDER BY written_at
""",
site["id"],
)
if cmd_rows:
await verify_modbus_commands(
[int(r["id"]) for r in cmd_rows],
conn,
int(site["id"]),
)
except Exception:
logger.exception("scheduled_daily_plan site=%s failed", site["id"])
logger.exception(
"scheduled_verify_modbus site=%s failed", site["id"]
)
async def scheduled_daily_plan() -> None:
async with app.state.pg_pool.acquire() as conn:
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
for site in sites:
site_id = int(site["id"])
try:
await run_daily_plan(site_id, conn)
# Aplikuj nový active run okamžitě, nečekej na další 15min tick exportu.
await export_setpoints(site_id, conn)
except Exception:
logger.exception("scheduled_daily_plan site=%s failed", site_id)
async def scheduled_rolling_replan() -> None:
async with app.state.pg_pool.acquire() as conn:
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
for site in sites:
site_id = int(site["id"])
try:
await run_rolling_replan(site["id"], conn)
await run_rolling_replan(site_id, conn)
# Aplikuj nový active run okamžitě, nečekej na další 15min tick exportu.
await export_setpoints(site_id, conn)
except Exception:
logger.exception("scheduled_rolling_replan site=%s failed", site["id"])
logger.exception("scheduled_rolling_replan site=%s failed", site_id)
async def scheduled_baseline_update() -> None:
async with app.state.pg_pool.acquire() as conn:
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
for site in sites:
try:
n = await conn.fetchval(
"SELECT ems.fn_update_baseline_stats($1, 30)",
site["id"],
)
logger.info(
"baseline_stats updated %s rows for site %s",
n,
site["id"],
)
except Exception:
logger.exception(
"scheduled_baseline_update site=%s failed", site["id"]
)
async def scheduled_market_price_stats() -> None:
async with app.state.pg_pool.acquire() as conn:
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
for site in sites:
try:
n = await conn.fetchval(
"SELECT ems.fn_update_market_price_stats($1, 90)",
site["id"],
)
logger.info(
"market_price_stats updated %s rows site=%s",
n,
site["id"],
)
except Exception:
logger.exception(
"scheduled_market_price_stats site=%s failed", site["id"]
)
async def scheduled_tuv_usage_stats() -> None:
async with app.state.pg_pool.acquire() as conn:
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
for site in sites:
try:
n = await conn.fetchval(
"SELECT ems.fn_update_tuv_usage_stats($1, 30)",
site["id"],
)
logger.info(
"tuv_usage_stats updated %s rows site=%s",
n,
site["id"],
)
except Exception:
logger.exception(
"scheduled_tuv_usage_stats site=%s failed", site["id"]
)
async def scheduled_forecast_refresh() -> None:
async with app.state.pg_pool.acquire() as conn:
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
for site in sites:
site_id = int(site["id"])
try:
intervals, pv_arrays = await fetch_pv_forecast(site_id, conn)
if intervals >= 0:
logger.info(
"scheduled_forecast_refresh site=%s intervals=%s arrays=%s",
site_id,
intervals,
pv_arrays,
)
await _refresh_negative_price_predictions(conn, site_id)
else:
logger.warning(
"scheduled_forecast_refresh site=%s failed",
site_id,
)
except Exception:
logger.exception("scheduled_forecast_refresh site=%s failed", site_id)
async def _count_ote_slots_for_day(
conn: asyncpg.Connection, site_id: int, target_day: date
) -> int:
return int(
await conn.fetchval(
"""
SELECT COUNT(*)::int
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND interval_start::date = $1::date
""",
target_day,
)
or 0
)
async def _scheduled_ote_import_for_site(
conn: asyncpg.Connection, site_id: int
) -> None:
tz_name = await conn.fetchval(
"SELECT timezone FROM ems.site WHERE id = $1",
site_id,
)
tz = ZoneInfo(tz_name or "Europe/Prague")
now_loc = datetime.now(tz)
today = now_loc.date()
tomorrow = today + timedelta(days=1)
# Zajistit data pro dnešek i zítřek; import jen pokud není kompletních 96 slotů.
for day in (today, tomorrow):
slots = await _count_ote_slots_for_day(conn, site_id, day)
if slots >= 96:
continue
n, imported_day, _, err = await import_ote_prices(
site_id, conn, target_date=day
)
if n < 0:
logger.warning(
"scheduled_ote_import site=%s day=%s failed (%s)",
site_id,
day.isoformat(),
err,
)
continue
logger.info(
"scheduled_ote_import site=%s day=%s imported=%s",
site_id,
imported_day,
n,
)
await _refresh_negative_price_predictions(conn, site_id)
async def scheduled_ote_import() -> None:
async with app.state.pg_pool.acquire() as conn:
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
for site in sites:
try:
await _scheduled_ote_import_for_site(conn, int(site["id"]))
except Exception:
logger.exception("scheduled_ote_import site=%s failed", site["id"])
scheduler.add_job(scheduled_heartbeat, "interval", seconds=60, id="heartbeat")
scheduler.add_job(
@@ -120,6 +338,13 @@ async def lifespan(app: FastAPI):
second=0,
id="audit_filler",
)
scheduler.add_job(
scheduled_forecast_accuracy,
"cron",
minute="2,17,32,47",
id="forecast_accuracy",
replace_existing=True,
)
scheduler.add_job(scheduled_expire_modes, "interval", minutes=1, id="expire_modes")
scheduler.add_job(
scheduled_control_export,
@@ -128,6 +353,13 @@ async def lifespan(app: FastAPI):
second=0,
id="control_export",
)
scheduler.add_job(
scheduled_verify_modbus,
"interval",
minutes=2,
id="verify_modbus",
replace_existing=True,
)
scheduler.add_job(scheduled_daily_plan, "cron", hour=15, minute=0, id="daily_plan")
scheduler.add_job(
scheduled_rolling_replan,
@@ -135,6 +367,62 @@ async def lifespan(app: FastAPI):
minute="*/15",
id="rolling_replan",
)
scheduler.add_job(
scheduled_baseline_update,
"cron",
hour=0,
minute=30,
id="baseline_update",
replace_existing=True,
)
scheduler.add_job(
scheduled_market_price_stats,
"cron",
hour=14,
minute=45,
id="market_price_stats",
replace_existing=True,
)
scheduler.add_job(
scheduled_tuv_usage_stats,
"cron",
hour=0,
minute=45,
id="tuv_usage_stats",
replace_existing=True,
)
scheduler.add_job(
scheduled_ote_import,
"cron",
hour=13,
minute=30,
id="ote_import_preopen",
replace_existing=True,
)
scheduler.add_job(
scheduled_ote_import,
"cron",
hour=14,
minute=0,
id="ote_import_main",
replace_existing=True,
)
scheduler.add_job(
scheduled_ote_import,
"cron",
hour=0,
minute=5,
id="ote_import_backfill",
replace_existing=True,
)
scheduler.add_job(
scheduled_forecast_refresh,
"cron",
hour="*/2",
minute=5,
id="forecast_refresh_2h",
replace_existing=True,
)
scheduler.start()
telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool))
@@ -142,6 +430,11 @@ async def lifespan(app: FastAPI):
yield
ws_h = getattr(app.state, "ws_log_handler", None)
if ws_h is not None:
logging.getLogger().removeHandler(ws_h)
app.state.ws_log_handler = None
telemetry_task.cancel()
try:
await telemetry_task
@@ -230,6 +523,45 @@ class ForecastRunResponse(BaseModel):
pv_arrays: int
class ModbusCommandVerifyItem(BaseModel):
id: int
asset_code: str
register_name: str | None
value_to_write: int
value_verified: int | None
status: str
class ModbusVerifyResponse(BaseModel):
checked: int
verified: int
mismatch: int
commands: list[ModbusCommandVerifyItem]
class NegativePricePredictionItem(BaseModel):
id: int
predicted_at: datetime
predicted_date: date
window_start_hour: int
window_end_hour: int
probability_pct: int
expected_min_price: float | None
reason: str | None
async def _refresh_negative_price_predictions(conn: asyncpg.Connection, site_id: int) -> None:
"""Po importu cen / forecastu obnoví cache predikce záporných cen."""
try:
await conn.fetch("SELECT * FROM ems.fn_predict_negative_price_windows($1, 7)", site_id)
except Exception:
logger.warning(
"fn_predict_negative_price_windows failed for site %s",
site_id,
exc_info=True,
)
@sites_router.post("/{site_id}/prices/import", response_model=PricesImportResponse)
async def post_import_site_prices(
site_id: int,
@@ -241,15 +573,18 @@ async def post_import_site_prices(
),
) -> PricesImportResponse:
target: date | None = _parse_ymd(date_str) if date_str is not None else None
import_error: str | None = None
async with db.acquire() as conn:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
n, day, first_price = await import_ote_prices(site_id, conn, target_date=target)
n, day, first_price, import_error = await import_ote_prices(site_id, conn, target_date=target)
if n >= 0:
await _refresh_negative_price_predictions(conn, site_id)
if n < 0:
raise HTTPException(
status_code=422,
detail="OTE API nedostupné nebo nevrátilo data",
detail=f"OTE import selhal ({import_error or 'unknown'})",
)
return PricesImportResponse(
slots_imported=n,
@@ -258,6 +593,66 @@ async def post_import_site_prices(
)
@sites_router.get(
"/{site_id}/prices/negative-predictions",
response_model=list[NegativePricePredictionItem],
)
async def get_site_negative_price_predictions(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pool)],
) -> list[NegativePricePredictionItem]:
"""Záznamy z cache predikce záporných cen na příštích 7 kalendářních dní (v časové zóně lokality)."""
async with db.acquire() as conn:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
rows = await conn.fetch(
"""
SELECT
p.id,
p.predicted_at,
p.predicted_date,
p.window_start_hour,
p.window_end_hour,
p.probability_pct,
p.expected_min_price,
p.reason
FROM ems.predicted_negative_price_window p
WHERE p.site_id = $1
AND p.predicted_date > (
CURRENT_TIMESTAMP AT TIME ZONE COALESCE(
NULLIF((SELECT timezone FROM ems.site WHERE id = $1), ''),
'Europe/Prague'
)
)::date
AND p.predicted_date <= (
CURRENT_TIMESTAMP AT TIME ZONE COALESCE(
NULLIF((SELECT timezone FROM ems.site WHERE id = $1), ''),
'Europe/Prague'
)
)::date + 7
ORDER BY p.predicted_date, p.window_start_hour
""",
site_id,
)
out: list[NegativePricePredictionItem] = []
for r in rows:
em = r["expected_min_price"]
out.append(
NegativePricePredictionItem(
id=int(r["id"]),
predicted_at=r["predicted_at"],
predicted_date=r["predicted_date"],
window_start_hour=int(r["window_start_hour"]),
window_end_hour=int(r["window_end_hour"]),
probability_pct=int(r["probability_pct"]),
expected_min_price=float(em) if em is not None else None,
reason=r["reason"],
)
)
return out
@sites_router.get("/{site_id}/prices/latest", response_model=PricesLatestResponse)
async def get_site_prices_latest(
site_id: int,
@@ -293,6 +688,186 @@ async def get_site_prices_latest(
)
@sites_router.get("/{site_id}/control/verify", response_model=ModbusVerifyResponse)
async def get_verify_modbus_commands(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pool)],
minutes: int = Query(10, ge=1, le=1440, description="Jak daleko zpět hledat written příkazy"),
) -> ModbusVerifyResponse:
"""
Ruční ověření Modbus zápisů (written) z posledních N minut.
Vhodné hned po manuálním exportu setpointů.
"""
async with db.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
lookback = timedelta(minutes=minutes)
rows = await conn.fetch(
"""
SELECT id FROM ems.modbus_command
WHERE site_id = $1
AND status = 'written'
AND written_at >= now() - $2::interval
ORDER BY written_at
""",
site_id,
lookback,
)
ids = [int(r["id"]) for r in rows]
checked = len(ids)
if ids:
await verify_modbus_commands(ids, conn, site_id)
detail_rows = (
await conn.fetch(
"""
SELECT id, asset_code, register_name, value_to_write, value_verified, status
FROM ems.modbus_command
WHERE id = ANY($1::int[])
ORDER BY id
""",
ids,
)
if ids
else []
)
commands = [
ModbusCommandVerifyItem(
id=int(r["id"]),
asset_code=r["asset_code"],
register_name=r["register_name"],
value_to_write=int(r["value_to_write"]),
value_verified=int(r["value_verified"])
if r["value_verified"] is not None
else None,
status=r["status"],
)
for r in detail_rows
]
verified = sum(1 for c in commands if c.status == "verified")
mismatch = sum(1 for c in commands if c.status == "mismatch")
return ModbusVerifyResponse(
checked=checked,
verified=verified,
mismatch=mismatch,
commands=commands,
)
class DeyeRegistersLiveResponse(BaseModel):
reg108_charge_a: int
reg109_discharge_a: int
reg141_energy_mode: int
reg142_limit_control: int
reg143_export_limit_w: int
reg178_peak_shaving_switch: int
reg191_peak_shaving_w: int
read_at: str
@sites_router.get(
"/{site_id}/control/registers",
response_model=DeyeRegistersLiveResponse,
)
async def get_control_registers_live(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pool)],
) -> DeyeRegistersLiveResponse:
"""Živé hodnoty registrů Deye 108/109/141/142/143/178/191 přes sdílený Modbus klient."""
async with db.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
try:
payload = await read_deye_registers_live(site_id, conn)
except ValueError:
raise HTTPException(
status_code=404,
detail="No controllable Modbus inverter for this site",
) from None
except Exception as e:
logger.warning("get_control_registers_live site=%s: %s", site_id, e)
raise HTTPException(
status_code=503,
detail=f"Modbus read failed: {e}",
) from e
return DeyeRegistersLiveResponse(**payload)
class ModbusJournalCommandRow(BaseModel):
id: int
register: int
register_name: str | None
value_to_write: int
value_written: int | None
value_verified: int | None
status: str
attempt_count: int
created_at: str
class ModbusJournalListResponse(BaseModel):
commands: list[ModbusJournalCommandRow]
@sites_router.get(
"/{site_id}/control/journal",
response_model=ModbusJournalListResponse,
)
async def get_control_command_journal(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pool)],
limit: int = Query(50, ge=1, le=100),
) -> ModbusJournalListResponse:
async with db.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
rows = await conn.fetch(
"""
SELECT id, register, register_name, value_to_write, value_written,
value_verified, status, attempt_count, created_at
FROM ems.modbus_command
WHERE site_id = $1
ORDER BY created_at DESC
LIMIT $2
""",
site_id,
limit,
)
cmds: list[ModbusJournalCommandRow] = []
for r in rows:
d = record_to_dict(r)
ca = d["created_at"]
cmds.append(
ModbusJournalCommandRow(
id=int(d["id"]),
register=int(d["register"]),
register_name=d.get("register_name"),
value_to_write=int(d["value_to_write"]),
value_written=int(d["value_written"])
if d.get("value_written") is not None
else None,
value_verified=int(d["value_verified"])
if d.get("value_verified") is not None
else None,
status=str(d["status"]),
attempt_count=int(d["attempt_count"]),
created_at=ca if isinstance(ca, str) else str(ca),
)
)
return ModbusJournalListResponse(commands=cmds)
@sites_router.post("/{site_id}/forecast/run", response_model=ForecastRunResponse)
async def post_run_site_forecast(
site_id: int,
@@ -302,7 +877,13 @@ async def post_run_site_forecast(
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
intervals, pv_arrays = await fetch_pv_forecast(site_id, conn)
try:
intervals, pv_arrays = await fetch_pv_forecast(site_id, conn)
except Exception as e:
logger.error("Forecast failed: %s", e, exc_info=True)
raise HTTPException(status_code=422, detail=str(e)) from e
if intervals >= 0:
await _refresh_negative_price_predictions(conn, site_id)
if intervals < 0:
raise HTTPException(
status_code=422,
@@ -326,14 +907,27 @@ async def get_site_forecast_pv(
raise HTTPException(status_code=404, detail="Site not found")
rows = await conn.fetch(
"""
SELECT fpi.*, apa.code AS pv_array_code
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 fpi.interval_start::date = $2::date
AND fpr.status = 'ok'
ORDER BY apa.code, fpi.interval_start
SELECT run_id, pv_array_id, interval_start, power_w,
irradiance_wm2, temp_c, pv_array_code
FROM (
SELECT DISTINCT ON (fpi.interval_start, fpr.pv_array_id)
fpi.run_id,
fpi.pv_array_id,
fpi.interval_start,
fpi.power_w,
fpi.irradiance_wm2,
fpi.temp_c,
apa.code AS pv_array_code
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 = fpr.pv_array_id AND apa.site_id = fpr.site_id
WHERE fpr.site_id = $1
AND fpi.interval_start::date = $2::date
AND fpr.status = 'ok'
ORDER BY fpi.interval_start, fpr.pv_array_id, fpr.created_at DESC
) latest
ORDER BY pv_array_code, interval_start
""",
site_id,
d,
@@ -351,6 +945,45 @@ async def get_site_forecast_pv(
return {"pv_a": pv_a, "pv_b": pv_b}
class NegPricePredictionItem(BaseModel):
predicted_date: str
window_start_hour: int
window_end_hour: int
probability_pct: float
expected_min_price: float | None
reason: str
class NegativePredictionsResponse(BaseModel):
predictions: list[NegPricePredictionItem]
insufficient_history: bool
@sites_router.get(
"/{site_id}/prices/negative-predictions",
response_model=NegativePredictionsResponse,
)
async def get_site_negative_price_predictions(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pool)],
) -> NegativePredictionsResponse:
"""Zástupný endpoint predikce modelu doplnit později; historii počítáme z OTE dat."""
async with db.acquire() as conn:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
ndays = await conn.fetchval(
"""
SELECT COUNT(DISTINCT (interval_start AT TIME ZONE 'Europe/Prague')::date)::int
FROM ems.market_interval_price
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
AND interval_start >= now() - INTERVAL '400 days'
"""
)
n = int(ndays or 0)
return NegativePredictionsResponse(predictions=[], insufficient_history=n < 28)
app.include_router(sites_router)
app.add_middleware(
@@ -362,6 +995,26 @@ app.add_middleware(
)
@app.websocket("/ws/telemetry")
async def ws_telemetry(websocket: WebSocket) -> None:
await manager.connect_telemetry(websocket)
try:
while True:
await websocket.receive_text() # keepalive
except WebSocketDisconnect:
manager.disconnect(websocket)
@app.websocket("/ws/logs")
async def ws_logs(websocket: WebSocket) -> None:
await manager.connect_logs(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def _health_payload(db: asyncpg.Pool) -> dict[str, Any]:
db_status = "error"
active_plan_slots = 0

View File

@@ -0,0 +1,249 @@
"""Pravidla pro GET /sites/{id}/notifications (ceny, EV, predikce záporných cen)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal
from typing import Any, Literal
from zoneinfo import ZoneInfo
PRAGUE = ZoneInfo("Europe/Prague")
NotificationLevel = Literal["success", "info", "warning", "error"]
NotificationAction = Literal["connect_ev", "replan", "import_prices", "switch_auto"]
@dataclass(frozen=True)
class PriceSlot:
interval_start: datetime
buy: float
sell: float
@dataclass(frozen=True)
class EvSessionRow:
id: int
charger_id: int
energy_delivered_wh: float
target_soc_pct: float | None
session_start: datetime
battery_capacity_kwh: float | None
make: str | None
model: str | None
default_target_soc_pct: float | None
charger_code: str
soc_at_connect_pct: float | None
@dataclass(frozen=True)
class NegWindowRow:
predicted_date: date
window_start_hour: int
window_end_hour: int
probability_pct: int
def _num(v: Any) -> float | None:
if v is None:
return None
if isinstance(v, Decimal):
return float(v)
return float(v)
def _ev_connect_hint(ev_sessions: list[EvSessionRow], current_price: float, avg_buy: float | None) -> str:
if ev_sessions:
return ""
kwh = 30.0
if current_price < 0:
return f"Připojíš-li Teslu, dostaneš zaplaceno za ~{kwh * abs(current_price):.0f} Kč za 30 kWh."
if avg_buy is None:
return ""
savings = avg_buy - current_price
return f"Připojíš-li Teslu, ušetříš ~{kwh * max(0, savings):.0f} Kč."
def _estimate_ev_soc(s: EvSessionRow) -> float:
cap = s.battery_capacity_kwh
delivered_kwh = (s.energy_delivered_wh or 0) / 1000.0
if s.soc_at_connect_pct is not None and cap and cap > 0:
return min(95.0, float(s.soc_at_connect_pct) + (delivered_kwh / cap) * 100.0)
if cap and cap > 0 and s.energy_delivered_wh:
return min(95.0, (delivered_kwh / cap) * 100.0 + 20.0)
return 50.0
def _estimate_needed_kwh(soc_pct: float, battery_kwh: float, ev_sessions: list[EvSessionRow]) -> float:
bat_needed = max(0.0, (80.0 - soc_pct) / 100.0 * battery_kwh)
ev_needed = 0.0
for s in ev_sessions:
cap = s.battery_capacity_kwh or 60.0
tgt = (s.target_soc_pct if s.target_soc_pct is not None else None) or (
s.default_target_soc_pct if s.default_target_soc_pct is not None else 80.0
)
delivered = (s.energy_delivered_wh or 0) / 1000.0
want = max(0.0, (tgt / 100.0) * cap - delivered)
ev_needed += want
return bat_needed + ev_needed
def _estimate_ev_free_kwh(s: EvSessionRow) -> float:
cap = s.battery_capacity_kwh or 60.0
delivered = (s.energy_delivered_wh or 0) / 1000.0
return max(0.0, cap * 0.95 - delivered)
def _tesla_potential_kwh(ev_sessions: list[EvSessionRow]) -> float:
"""Odhad „kolik lze ještě dobít“ pro typickou Teslu, pokud není připojena."""
if ev_sessions:
return 0.0
return 55.0
def _date_label(d: date) -> str:
today = datetime.now(PRAGUE).date()
if d == today:
return "dnes"
if d == today + timedelta(days=1):
return "zítra"
return f"{d.day}. {d.month}."
def _hours_until(predicted_date: date, start_hour: int) -> float:
start_local = datetime.combine(predicted_date, time(hour=start_hour, minute=0), tzinfo=PRAGUE)
now = datetime.now(PRAGUE)
delta = start_local - now
return max(0.0, delta.total_seconds() / 3600.0)
def build_smart_notifications(
*,
prices: list[PriceSlot],
avg_buy: float | None,
soc_pct: float | None,
battery_kwh: float | None,
ev_sessions: list[EvSessionRow],
neg_windows: list[NegWindowRow],
mode: str,
sell_price_now: float | None,
) -> list[dict[str, Any]]:
notifications: list[dict[str, Any]] = []
soc = float(soc_pct) if soc_pct is not None else 50.0
bat_kwh = float(battery_kwh) if battery_kwh is not None and battery_kwh > 0 else 10.0
current_buy = prices[0].buy if prices else None
current_sell = prices[0].sell if prices else None
# 1. Záporná cena právě teď
if current_buy is not None and current_buy < 0:
bat_free_kwh = (100.0 - soc) / 100.0 * bat_kwh
ev_hint = _ev_connect_hint(ev_sessions, current_buy, avg_buy)
notifications.append(
{
"id": "neg_price_now",
"level": "success",
"title": f"Záporné ceny právě teď ({current_buy:.3f} Kč/kWh)",
"body": (
f"Dostaneš zaplaceno za každý odebraný kWh. "
f"Baterie může pojmout ještě {bat_free_kwh:.1f} kWh. "
+ ev_hint
),
"eta_minutes": 0,
"action": "connect_ev" if not ev_sessions else None,
}
)
# 2. Levná cena v příštích 6 h
avg_ok = avg_buy is not None and avg_buy > 0
if prices and avg_ok and not (current_buy is not None and current_buy < 0):
horizon = prices[:24]
cheap_slots = [p for p in horizon if p.buy < avg_buy * 0.60]
if cheap_slots:
cheapest = min(cheap_slots, key=lambda p: p.buy)
now_utc = datetime.now(timezone.utc)
istart = cheapest.interval_start
if istart.tzinfo is None:
istart = istart.replace(tzinfo=timezone.utc)
eta_min = int((istart - now_utc).total_seconds() / 60)
eta_min = max(0, eta_min)
savings_per_kwh = avg_buy - cheapest.buy
ev_plug_useful = (not ev_sessions) or any(_estimate_ev_soc(s) < 60.0 for s in ev_sessions)
bat_low = soc < 70.0
if ev_plug_useful or bat_low:
needed_kwh = _estimate_needed_kwh(soc, bat_kwh, ev_sessions)
savings_czk = needed_kwh * max(0.0, savings_per_kwh)
extra_body = ""
if ev_plug_useful and not ev_sessions:
extra_body = " Připoj auto před tímto oknem."
notifications.append(
{
"id": "cheap_price_soon",
"level": "info",
"title": f"Levná elektřina za {eta_min} min ({cheapest.buy:.3f} Kč/kWh)",
"body": (
f"Cena bude o {savings_per_kwh:.2f} Kč/kWh nižší než průměr. "
f"Potenciální úspora: ~{savings_czk:.0f} Kč."
+ extra_body
),
"eta_minutes": eta_min,
"action": "connect_ev" if ev_plug_useful and not ev_sessions else None,
}
)
# 3. Predikované záporné ceny
for window in neg_windows[:2]:
if any(n["id"] == "neg_price_now" for n in notifications):
continue
date_label = _date_label(window.predicted_date)
window_str = f"{window.window_start_hour:02d}:00{window.window_end_hour:02d}:00"
hours_until = _hours_until(window.predicted_date, window.window_start_hour)
bat_free = (100.0 - soc) / 100.0 * bat_kwh
ev_free = sum(_estimate_ev_free_kwh(s) for s in ev_sessions)
total_free = bat_free + ev_free
tesla_kwh = _tesla_potential_kwh(ev_sessions)
ev_hint = (
f" Připojíš-li Teslu, lze dobít až {tesla_kwh:.0f} kWh navíc." if not ev_sessions else ""
)
lvl: NotificationLevel = "success" if window.probability_pct >= 70 else "info"
notifications.append(
{
"id": f"neg_pred_{window.predicted_date}_{window.window_start_hour}",
"level": lvl,
"title": (
f"Záporné ceny {date_label} {window_str} ({window.probability_pct}% jistota)"
),
"body": (
f"Solver naplánuje max. odběr ze sítě. Lze dobít ~{total_free:.0f} kWh zdarma."
+ ev_hint
),
"eta_minutes": int(hours_until * 60),
"action": "connect_ev" if not ev_sessions else None,
}
)
# 4. Manuální režim + drahá cena + plná baterie
mode_u = (mode or "").strip().upper()
sell = sell_price_now if sell_price_now is not None else (current_sell if current_sell is not None else None)
if mode_u != "AUTO" and avg_ok and sell is not None and sell > avg_buy * 1.30 and soc > 70.0:
pct_above = ((sell / avg_buy) - 1.0) * 100.0 if avg_buy else 0.0
notifications.append(
{
"id": "manual_expensive",
"level": "warning",
"title": "Drahá elektřina systém není v AUTO",
"body": (
f"Cena prodeje {sell:.3f} Kč/kWh (+{pct_above:.0f}% nad průměr). "
f"Baterie {soc:.0f}%. Přepnutím na AUTO solver využije cenové okno."
),
"eta_minutes": None,
"action": "switch_auto",
}
)
priority = {"error": 0, "success": 1, "warning": 2, "info": 3}
notifications.sort(key=lambda n: priority.get(n["level"], 9))
return notifications

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime
from typing import Annotated, Any
import asyncpg
@@ -91,3 +91,88 @@ async def patch_ev_session(
if row is None:
raise HTTPException(status_code=404, detail="Session not found")
return EvSessionPatchResponse(success=True, session_id=int(row["id"]))
class ArrivalHourItem(BaseModel):
hour: int
confidence_pct: int
samples: int
class ChargerTomorrowArrival(BaseModel):
tomorrow: list[ArrivalHourItem]
class EvArrivalPredictionResponse(BaseModel):
insufficient_data: bool
tomorrow_date: str
chargers: dict[str, ChargerTomorrowArrival]
@router.get("/arrival-prediction", response_model=EvArrivalPredictionResponse)
async def get_ev_arrival_prediction(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> EvArrivalPredictionResponse:
"""Top hodiny příjezdu z ems.fn_ev_expected_arrival; při <5 session celkem insufficient_data."""
async with pool.acquire() as conn:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
n_sessions = int(
await conn.fetchval(
"SELECT COUNT(*)::int FROM ems.ev_session WHERE site_id = $1",
site_id,
)
or 0
)
insufficient = n_sessions < 5
tomorrow = await conn.fetchval(
"""
SELECT (
CURRENT_TIMESTAMP AT TIME ZONE COALESCE(
NULLIF(TRIM(timezone), ''),
'Europe/Prague'
)
)::date + 1
FROM ems.site
WHERE id = $1
""",
site_id,
)
if tomorrow is None:
raise HTTPException(status_code=500, detail="Site date resolution failed")
tomorrow_d: date = tomorrow
chargers_rows = await conn.fetch(
"SELECT id, code FROM ems.asset_ev_charger WHERE site_id = $1 ORDER BY id",
site_id,
)
chargers: dict[str, ChargerTomorrowArrival] = {}
for ch in chargers_rows:
code = str(ch["code"])
preds = await conn.fetch(
"SELECT * FROM ems.fn_ev_expected_arrival($1, $2, $3::date)",
site_id,
ch["id"],
tomorrow_d,
)
chargers[code] = ChargerTomorrowArrival(
tomorrow=[
ArrivalHourItem(
hour=int(r["expected_hour"]),
confidence_pct=int(r["confidence_pct"]),
samples=int(r["sample_count"]),
)
for r in preds
]
)
return EvArrivalPredictionResponse(
insufficient_data=insufficient,
tomorrow_date=tomorrow_d.isoformat(),
chargers=chargers,
)

View File

@@ -2,17 +2,38 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from typing import Annotated, Any, Literal
from zoneinfo import ZoneInfo
import asyncpg
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.db_json import record_to_dict
from app.deps import get_pg_pool
from app.notifications_logic import (
EvSessionRow,
NegWindowRow,
PriceSlot,
build_smart_notifications,
)
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"])
class SiteNotificationItem(BaseModel):
id: str
level: Literal["success", "info", "warning", "error"]
title: str
body: str
eta_minutes: int | None = None
action: Literal["connect_ev", "replan", "import_prices", "switch_auto"] | None = None
class SiteNotificationsResponse(BaseModel):
notifications: list[SiteNotificationItem] = Field(default_factory=list)
INV_STALE_SEC = 300
HEARTBEAT_STALE_SEC = 300
EXPECTED_TOMORROW_PRICE_SLOTS = 90
@@ -235,7 +256,10 @@ async def get_site_status_full(
if not has_plan:
add_alert("warn", "Není aktivní plán EMS neoptimalizuje")
if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS:
# OTE D+1 typicky až po ~14:30 Europe/Prague před tím nevarovat
now_prague = datetime.now(ZoneInfo("Europe/Prague"))
prices_expected = (now_prague.hour, now_prague.minute) >= (14, 30)
if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS and prices_expected:
add_alert("warn", "Chybí spotové ceny pro zítřek")
if mode_code.upper() == "MANUAL":
@@ -266,3 +290,326 @@ async def get_site_status_full(
"planning": planning,
"alerts": alerts,
}
_NOTIF_LEVEL_PRIORITY = {"error": 0, "success": 1, "warning": 2, "info": 3}
def _infrastructure_notification_items(
*,
has_plan: bool,
tomorrow_slots: int,
mode_code: str,
reserve_soc: float | None,
soc: float | None,
inv_age: int | None,
hb_age: int | None,
) -> list[SiteNotificationItem]:
"""Kritické / provozní notifikace (telemetrie, plán, ceny, režim, heartbeat)."""
items: list[SiteNotificationItem] = []
def push(
nid: str,
level: Literal["success", "info", "warning", "error"],
title: str,
body: str,
*,
eta_minutes: int | None = None,
action: Literal["connect_ev", "replan", "import_prices", "switch_auto"] | None = None,
) -> None:
items.append(
SiteNotificationItem(
id=nid,
level=level,
title=title,
body=body,
eta_minutes=eta_minutes,
action=action,
)
)
if inv_age is None or inv_age > INV_STALE_SEC:
push("telemetry_inverter", "error", "Telemetrie střídače", "Data ze střídače nejsou aktuální.")
if not has_plan:
push(
"no_active_plan",
"warning",
"Chybí aktivní plán",
"EMS zatím neoptimalizuje provoz spusťte plánování.",
action="replan",
)
now_prague = datetime.now(ZoneInfo("Europe/Prague"))
prices_expected = (now_prague.hour, now_prague.minute) >= (14, 30)
if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS and prices_expected:
push(
"prices_tomorrow",
"warning",
"Ceny na zítřek",
"Nejsou kompletní spotové ceny OTE pro následující den.",
action="import_prices",
)
if mode_code.upper() == "MANUAL":
push("mode_manual", "info", "Manuální režim", "Automatická optimalizace je vypnutá.")
if reserve_soc is not None and soc is not None and soc < reserve_soc:
push("soc_reserve", "error", "SoC pod rezervou", "Nabití baterie je pod nastavenou bezpečnostní rezervou.")
if hb_age is None or hb_age > HEARTBEAT_STALE_SEC:
push("heartbeat", "error", "EMS heartbeat", "Služba EMS nehlásí pravidelný heartbeat.")
return items
def _float_or_none(v: Any) -> float | None:
if v is None:
return None
return float(v)
@router.get("/notifications", response_model=SiteNotificationsResponse)
async def get_site_notifications(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> SiteNotificationsResponse:
async with pool.acquire() as conn:
site = await conn.fetchrow(
"SELECT id, timezone FROM ems.site WHERE id = $1",
site_id,
)
if site is None:
raise HTTPException(status_code=404, detail="Site not found")
tz = site["timezone"] or "Europe/Prague"
mode_row = await conn.fetchrow(
"""
SELECT m.mode_code
FROM ems.site_operating_mode m
WHERE m.site_id = $1
""",
site_id,
)
run_row = await conn.fetchrow(
"""
SELECT id FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 1
""",
site_id,
)
reserve_row = await conn.fetchrow(
"""
SELECT MIN(reserve_soc_percent)::float AS reserve_soc
FROM ems.asset_battery
WHERE site_id = $1
""",
site_id,
)
inv_row = await conn.fetchrow(
"""
SELECT battery_soc_percent, measured_at
FROM ems.vw_latest_inverter
WHERE site_id = $1
ORDER BY measured_at DESC NULLS LAST
LIMIT 1
""",
site_id,
)
hb_row = await conn.fetchrow(
"SELECT last_seen FROM ems.site_heartbeat WHERE site_id = $1",
site_id,
)
tomorrow_slots = await conn.fetchval(
"""
SELECT COUNT(*)::int
FROM ems.vw_site_effective_price v
WHERE v.site_id = $1
AND (v.interval_start AT TIME ZONE $2)::date =
((CURRENT_TIMESTAMP AT TIME ZONE $2)::date + INTERVAL '1 day')::date
""",
site_id,
tz,
)
price_rows = await conn.fetch(
"""
SELECT interval_start,
effective_buy_price_czk_kwh,
effective_sell_price_czk_kwh
FROM ems.vw_site_effective_price
WHERE site_id = $1
AND interval_start >= now()
AND interval_start < now() + INTERVAL '48 hours'
ORDER BY interval_start
""",
site_id,
)
avg_row = await conn.fetchrow(
"""
SELECT AVG(effective_buy_price_czk_kwh)::float AS avg_buy
FROM ems.vw_site_effective_price
WHERE site_id = $1
AND interval_start::date IN (CURRENT_DATE, CURRENT_DATE + INTERVAL '1 day')
""",
site_id,
)
bat_row = await conn.fetchrow(
"""
SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float AS usable_wh
FROM ems.asset_battery ab
JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id
WHERE ai.site_id = $1
""",
site_id,
)
ev_rows = await conn.fetch(
"""
SELECT DISTINCT ON (es.id)
es.id,
es.charger_id,
es.energy_delivered_wh,
es.target_soc_pct,
es.session_start,
es.soc_at_connect_pct,
COALESCE(av_id.battery_capacity_kwh, av_def.battery_capacity_kwh) AS battery_capacity_kwh,
COALESCE(av_id.make, av_def.make) AS make,
COALESCE(av_id.model, av_def.model) AS model,
COALESCE(av_id.default_target_soc_pct, av_def.default_target_soc_pct) AS default_target_soc_pct,
ac.code AS charger_code
FROM ems.ev_session es
JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id
LEFT JOIN ems.asset_vehicle av_id ON av_id.id = es.vehicle_id
LEFT JOIN ems.asset_vehicle av_def
ON av_def.default_charger_id = ac.id AND es.vehicle_id IS NULL
WHERE es.site_id = $1 AND es.session_end IS NULL
ORDER BY es.id, av_def.id NULLS LAST
""",
site_id,
)
neg_rows = await conn.fetch(
"""
SELECT predicted_date, window_start_hour, window_end_hour, probability_pct
FROM ems.predicted_negative_price_window
WHERE site_id = $1
AND predicted_date BETWEEN CURRENT_DATE AND CURRENT_DATE + 2
AND probability_pct >= 50
ORDER BY predicted_date, window_start_hour
""",
site_id,
)
has_plan = run_row is not None
mode_code = (mode_row["mode_code"] if mode_row else None) or ""
reserve_soc = (
float(reserve_row["reserve_soc"])
if reserve_row and reserve_row["reserve_soc"] is not None
else None
)
soc = (
float(inv_row["battery_soc_percent"])
if inv_row and inv_row["battery_soc_percent"] is not None
else None
)
inv_age = _age_seconds(inv_row["measured_at"] if inv_row else None)
hb_age = _age_seconds(hb_row["last_seen"] if hb_row else None)
infra = _infrastructure_notification_items(
has_plan=has_plan,
tomorrow_slots=int(tomorrow_slots or 0),
mode_code=mode_code,
reserve_soc=reserve_soc,
soc=soc,
inv_age=inv_age,
hb_age=hb_age,
)
prices: list[PriceSlot] = []
for r in price_rows:
buy = _float_or_none(r["effective_buy_price_czk_kwh"])
if buy is None:
continue
sell_v = _float_or_none(r["effective_sell_price_czk_kwh"])
istart = r["interval_start"]
prices.append(
PriceSlot(
interval_start=istart,
buy=buy,
sell=sell_v if sell_v is not None else buy,
)
)
avg_buy = _float_or_none(avg_row["avg_buy"]) if avg_row else None
usable_wh = _float_or_none(bat_row["usable_wh"]) if bat_row else None
battery_kwh = (usable_wh / 1000.0) if usable_wh is not None else None
ev_sessions: list[EvSessionRow] = []
for er in ev_rows:
ev_sessions.append(
EvSessionRow(
id=int(er["id"]),
charger_id=int(er["charger_id"]),
energy_delivered_wh=float(er["energy_delivered_wh"] or 0),
target_soc_pct=_float_or_none(er["target_soc_pct"]),
session_start=er["session_start"],
battery_capacity_kwh=_float_or_none(er["battery_capacity_kwh"]),
make=er["make"],
model=er["model"],
default_target_soc_pct=_float_or_none(er["default_target_soc_pct"]),
charger_code=str(er["charger_code"] or ""),
soc_at_connect_pct=_float_or_none(er["soc_at_connect_pct"]),
)
)
neg_windows: list[NegWindowRow] = []
for nr in neg_rows:
dr = nr["predicted_date"]
if isinstance(dr, datetime):
d_conv = dr.date()
elif isinstance(dr, date):
d_conv = dr
else:
d_conv = date.today()
neg_windows.append(
NegWindowRow(
predicted_date=d_conv,
window_start_hour=int(nr["window_start_hour"]),
window_end_hour=int(nr["window_end_hour"]),
probability_pct=int(nr["probability_pct"]),
)
)
sell_now = prices[0].sell if prices else None
smart_raw = build_smart_notifications(
prices=prices,
avg_buy=avg_buy,
soc_pct=soc,
battery_kwh=battery_kwh,
ev_sessions=ev_sessions,
neg_windows=neg_windows,
mode=mode_code,
sell_price_now=sell_now,
)
smart_items = [
SiteNotificationItem(
id=d["id"],
level=d["level"],
title=d["title"],
body=d["body"],
eta_minutes=d.get("eta_minutes"),
action=d.get("action"),
)
for d in smart_raw
]
merged = infra + smart_items
merged.sort(key=lambda x: _NOTIF_LEVEL_PRIORITY.get(x.level, 9))
return SiteNotificationsResponse(notifications=merged[:5])

View File

@@ -1,21 +1,21 @@
"""REST API aktivní plán a ruční přepočet."""
from datetime import datetime, timedelta, timezone
import logging
from datetime import datetime, timezone
from typing import Annotated, Any, Literal
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field
from app.db_json import record_to_dict
from app.deps import get_pg_pool
from services.planning_engine import _current_slot_start, run_plan_api
from services.control_exporter import export_setpoints
from services.planning_engine import run_plan_api
router = APIRouter(prefix="/sites/{site_id}/plan", tags=["plan"])
PRICE_CHECK_HOURS = 24
_SLOTS_PER_HOUR = 4
_EXPECTED_PRICE_SLOTS = PRICE_CHECK_HOURS * _SLOTS_PER_HOUR
logger = logging.getLogger(__name__)
class RunPlanResponse(BaseModel):
@@ -25,6 +25,27 @@ class RunPlanResponse(BaseModel):
horizon_end: datetime
class PlanningIntervalDto(BaseModel):
"""Řádek `ems.planning_interval` v odpovědi aktivního plánu."""
model_config = ConfigDict(extra="allow")
interval_start: str
is_predicted_price: bool = Field(
default=False,
description=(
"True pokud solver pro slot použil predikovanou cenu (market_price_stats), "
"nikoli přesný řádek z vw_site_effective_price / OTE."
),
)
class CurrentPlanResponseModel(BaseModel):
run: dict[str, Any]
intervals: list[PlanningIntervalDto]
summary: dict[str, Any]
def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]:
total_cost = 0.0
total_curtailed_kwh = 0.0
@@ -55,11 +76,29 @@ def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]:
}
@router.get("/current")
def _pv_scarcity_factor_from_intervals(
intervals: list[dict[str, Any]], battery_usable_wh: float | None
) -> float:
"""Stejná logika jako v solveru: 0.65..1.0 podle očekávané FVE energie na ~24h."""
if not intervals:
return 1.0
batt_kwh = max(1.0, float(battery_usable_wh or 0.0) / 1000.0)
horizon_slots = min(len(intervals), int(24 / 0.25))
pv_kwh = 0.0
for row in intervals[:horizon_slots]:
pv = row.get("pv_forecast_total_w")
if pv is not None:
pv_kwh += max(0.0, float(pv)) * 0.25 / 1000.0
coverage = pv_kwh / batt_kwh
coverage_clamped = max(0.0, min(1.0, coverage))
return round(0.65 + 0.35 * coverage_clamped, 4)
@router.get("/current", response_model=CurrentPlanResponseModel)
async def get_current_plan(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> dict[str, Any]:
) -> CurrentPlanResponseModel:
async with pool.acquire() as conn:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok:
@@ -81,17 +120,53 @@ async def get_current_plan(
run_id = run_row["id"]
int_rows = await conn.fetch(
"""
SELECT *
FROM ems.planning_interval
WHERE run_id = $1
ORDER BY interval_start
WITH latest_fc AS (
SELECT id
FROM ems.forecast_pv_run
WHERE site_id = $2 AND status = 'ok'
ORDER BY created_at DESC
LIMIT 1
),
fc_slot AS (
SELECT fpi.interval_start, COALESCE(SUM(fpi.power_w), 0)::BIGINT AS pv_forecast_total_w
FROM ems.forecast_pv_interval fpi
WHERE fpi.run_id = (SELECT id FROM latest_fc)
GROUP BY fpi.interval_start
)
SELECT
pi.*,
ai.actual_pv_power_w AS pv_power_w,
fs.pv_forecast_total_w AS pv_forecast_total_w
FROM ems.planning_interval pi
LEFT JOIN ems.audit_interval ai
ON ai.site_id = $2 AND ai.interval_start = pi.interval_start
LEFT JOIN fc_slot fs ON fs.interval_start = pi.interval_start
WHERE pi.run_id = $1
ORDER BY pi.interval_start
""",
run_id,
site_id,
)
battery_usable_wh = await conn.fetchval(
"""
SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float
FROM ems.asset_battery ab
WHERE ab.site_id = $1
""",
site_id,
)
intervals = [record_to_dict(r) for r in int_rows]
summary = _build_summary(intervals)
return {"run": record_to_dict(run_row), "intervals": intervals, "summary": summary}
intervals_raw = [record_to_dict(r) for r in int_rows]
summary = _build_summary(intervals_raw)
summary["pv_scarcity_factor"] = _pv_scarcity_factor_from_intervals(
intervals_raw, float(battery_usable_wh or 0.0)
)
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw]
return CurrentPlanResponseModel(
run=record_to_dict(run_row),
intervals=intervals,
summary=summary,
)
@router.post("/run", response_model=RunPlanResponse)
@@ -100,52 +175,52 @@ async def post_run_plan(
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
plan_type: Literal["daily", "rolling"] = Query("rolling", alias="type"),
) -> RunPlanResponse:
window_start = _current_slot_start(datetime.now(timezone.utc))
window_end = window_start + timedelta(hours=PRICE_CHECK_HOURS)
async with pool.acquire() as conn:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
price_slots = await conn.fetchval(
days_with_prices = await conn.fetchval(
"""
SELECT COUNT(DISTINCT interval_start::date)::int AS days_with_prices
FROM ems.market_interval_price
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
AND interval_start >= now()
AND interval_start < now() + INTERVAL '48 hours'
"""
SELECT COUNT(DISTINCT interval_start)::int
FROM ems.vw_site_effective_price
WHERE site_id = $1
AND interval_start >= $2
AND interval_start < $3
""",
site_id,
window_start,
window_end,
)
if (price_slots or 0) < _EXPECTED_PRICE_SLOTS:
if (days_with_prices or 0) < 1:
raise HTTPException(
status_code=422,
detail="Nejsou dostupné tržní ceny. Spusťte nejdřív import cen.",
detail="Nejsou dostupné tržní ceny",
)
try:
run_id, solver_duration_ms = await run_plan_api(
site_id, plan_type, conn, triggered_by="api"
)
# Nový active run aplikuj hned; nečekej na periodický control_export job.
await export_setpoints(site_id, conn)
row = await conn.fetchrow(
"""
SELECT horizon_start, horizon_end
FROM ems.planning_run
WHERE id = $1
""",
run_id,
)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except RuntimeError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
except Exception as e:
logger.error("Plan run failed: %s", e, exc_info=True)
raise HTTPException(status_code=422, detail=str(e)) from e
row = await conn.fetchrow(
"""
SELECT horizon_start, horizon_end
FROM ems.planning_run
WHERE id = $1
""",
run_id,
)
if row is None:
raise HTTPException(status_code=500, detail="Planning run row missing after insert")
if row is None:
raise HTTPException(status_code=500, detail="Planning run row missing after insert")
return RunPlanResponse(
run_id=run_id,

View File

@@ -0,0 +1,25 @@
import asyncio
import logging
from datetime import datetime, timezone
from .ws_manager import manager
class WSLogHandler(logging.Handler):
"""Posílá log záznamy přes WebSocket všem připojeným klientům /ws/logs."""
def emit(self, record: logging.LogRecord) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
ts = datetime.fromtimestamp(record.created, tz=timezone.utc).strftime("%H:%M:%S")
msg = {
"ts": ts,
"level": record.levelname,
"logger": record.name.split(".")[-1],
"msg": record.getMessage(),
}
loop.call_soon_threadsafe(
lambda: asyncio.ensure_future(manager.broadcast_log(msg))
)

38
backend/app/ws_manager.py Normal file
View File

@@ -0,0 +1,38 @@
from fastapi import WebSocket
class ConnectionManager:
def __init__(self):
self._telemetry: list[WebSocket] = []
self._logs: list[WebSocket] = []
async def connect_telemetry(self, ws: WebSocket):
await ws.accept()
self._telemetry.append(ws)
async def connect_logs(self, ws: WebSocket):
await ws.accept()
self._logs.append(ws)
def disconnect(self, ws: WebSocket):
self._telemetry = [w for w in self._telemetry if w != ws]
self._logs = [w for w in self._logs if w != ws]
async def broadcast_telemetry(self, data: dict):
await self._broadcast(self._telemetry, data)
async def broadcast_log(self, record: dict):
await self._broadcast(self._logs, record)
async def _broadcast(self, clients: list, data: dict):
dead = []
for ws in clients:
try:
await ws.send_json(data)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(ws)
manager = ConnectionManager()

View File

@@ -6,15 +6,51 @@ import asyncio
import logging
import os
from dataclasses import dataclass
from typing import Any
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import asyncpg
import httpx
from app.config import get_settings
from services.telemetry_collector import ModbusDevice
from services.modbus_client import get_modbus_client
logger = logging.getLogger(__name__)
# Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md)
BATT_VOLTAGE_V = 51.2
# Reg 178 pevné hodnoty (bit45); bez read-modify-write (kolize s Loxone / transaction ID)
REG178_SELL = 0b00100000 # 32, grid peak shaving disable
REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
DEYE_REGISTER_NAMES: dict[int, str] = {
108: "max_charge_a (max nabíjecí proud baterie)",
109: "max_discharge_a (max vybíjecí proud baterie)",
141: "energy_mode (0, EMS nemění)",
142: "limit_control (0=selling first, 1=zero export built-in CT)",
143: "export_limit_w (max export do sítě)",
178: "grid_peak_shaving_switch (SELL=32 bit4-5=10, PASSIVE/CHARGE=48 bit4-5=11)",
148: "time_point_1_time",
149: "time_point_2_time",
154: "time_point_1_power_w",
155: "time_point_2_power_w",
166: "time_point_1_soc_min_pct",
167: "time_point_2_soc_min_pct",
172: "time_point_1_grid_charge",
173: "time_point_2_grid_charge",
62: "system_time_year_month",
63: "system_time_day_hour",
64: "system_time_min_sec",
}
for _tp_i in range(6):
_n = _tp_i + 1
DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time")
DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w")
DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct")
DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge")
def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int:
if not power_w or power_w <= 0:
@@ -22,6 +58,50 @@ def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> i
return min(32, max(0, int(power_w / (phases * voltage))))
def battery_watts_to_amps(power_w: int, max_amps: int) -> int:
"""Proud z |výkonu| baterie; max_amps výhradně z DB (_load_inverter_config)."""
return min(max(0, max_amps), max(0, round(abs(power_w) / BATT_VOLTAGE_V)))
def current_slot_hhmm() -> int:
"""Začátek probíhajícího 15min slotu v Europe/Prague, formát HHMM (např. 1415)."""
now = datetime.now(ZoneInfo("Europe/Prague"))
slot_min = (now.minute // 15) * 15
return now.hour * 100 + slot_min
def next_slot_hhmm() -> int:
"""Začátek příštího 15min slotu v Europe/Prague, formát HHMM (např. 1430)."""
now = datetime.now(ZoneInfo("Europe/Prague"))
minutes = now.minute
slot_minutes = ((minutes // 15) + 1) * 15
if slot_minutes >= 60:
next_hour = (now.hour + 1) % 24
next_min = 0
else:
next_hour = now.hour
next_min = slot_minutes
return next_hour * 100 + next_min
@dataclass
class InverterConfig:
id: int
code: str
host: str
port: int
unit_id: int
max_export_power_w: int | None
max_import_power_w: int | None
no_export: bool
max_battery_charge_w: int | None
max_battery_discharge_w: int | None
reserve_soc_percent: int | None
usable_capacity_wh: int | None
max_charge_a: int
max_discharge_a: int
@dataclass
class ControlSetpoints:
battery_w: int | None
@@ -32,6 +112,9 @@ class ControlSetpoints:
grid_setpoint_w: int
ev1_power_w: int
ev2_power_w: int
target_soc_pct: int | None = None
#: True = reg 108/109 na 0 (PRESERVE Deye baterii nepoužívá)
lock_battery: bool = False
@dataclass
@@ -44,8 +127,253 @@ class OperatingModeInfo:
loxone_mode_value: int
def _clamp_u16(value: int) -> int:
return max(0, min(65535, int(value)))
async def create_modbus_commands(
site_id: int,
planning_run_id: int | None,
asset_type: str,
asset_id: int,
asset_code: str,
host: str,
port: int,
unit_id: int,
registers: list[tuple[int, str, int]],
db: asyncpg.Connection,
deye_physical_mode: str | None = None,
) -> list[int]:
"""
Vytvoří záznamy v modbus_command pro sadu zápisů.
Vrátí list command IDs.
Pro Deye se jméno registru bere z DEYE_REGISTER_NAMES (prostřední položka tuplu se ignoruje).
"""
ids: list[int] = []
for reg, _ignored_name, val in registers:
register_name = DEYE_REGISTER_NAMES.get(reg, f"reg_{reg}")
cmd_id = await db.fetchval(
"""
INSERT INTO ems.modbus_command
(site_id, asset_type, asset_id, asset_code,
device_host, device_port, device_unit_id,
register, register_name, value_to_write,
planning_run_id, status, deye_physical_mode)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending',$12)
RETURNING id
""",
site_id,
asset_type,
asset_id,
asset_code,
host,
port,
unit_id,
reg,
register_name,
val,
planning_run_id,
deye_physical_mode,
)
if cmd_id is not None:
ids.append(int(cmd_id))
return ids
async def execute_modbus_commands(
command_ids: list[int],
db: asyncpg.Connection,
) -> bool:
"""
Zapíše příkazy z modbus_command do zařízení.
Aktualizuje status na 'written' nebo 'failed'.
Vrátí True pokud všechny příkazy uspěly.
"""
MAX_RETRIES = 3
RETRY_DELAY = 0.5
all_ok = True
for cmd_id in command_ids:
cmd = await db.fetchrow(
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
)
if cmd is None:
continue
client = await get_modbus_client(
cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])
)
for attempt in range(MAX_RETRIES):
try:
await client.write_registers(
int(cmd["register"]), [int(cmd["value_to_write"])]
)
await db.execute(
"""
UPDATE ems.modbus_command
SET status='written', value_written=$1, written_at=now(),
attempt_count=attempt_count+1, error_msg=NULL
WHERE id=$2
""",
int(cmd["value_to_write"]),
cmd_id,
)
logger.info(
"[cmd %s] %s 0x%04X=%s OK (attempt %s)",
cmd_id,
cmd["asset_code"],
int(cmd["register"]),
int(cmd["value_to_write"]),
attempt + 1,
)
break
except Exception as e:
if attempt < MAX_RETRIES - 1:
logger.warning(
"[cmd %s] attempt %s failed: %s, retrying...",
cmd_id,
attempt + 1,
e,
)
await asyncio.sleep(RETRY_DELAY)
client._client = None # force reconnect
else:
await db.execute(
"""
UPDATE ems.modbus_command
SET status='failed', error_msg=$1,
attempt_count=attempt_count+1
WHERE id=$2
""",
str(e),
cmd_id,
)
logger.error(
"[cmd %s] all %s attempts failed: %s",
cmd_id,
MAX_RETRIES,
e,
)
all_ok = False
return all_ok
async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason: str) -> None:
"""Přepne lokalitu na SELF_SUSTAIN a zaloguje důvod."""
await db.execute(
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
site_id,
"SELF_SUSTAIN",
"system:mismatch",
None,
reason,
)
logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason)
async def verify_modbus_commands(
command_ids: list[int],
db: asyncpg.Connection,
site_id: int,
) -> bool:
"""
Přečte registry zpět a porovná s value_to_write.
Při mismatch: retry → SELF_SUSTAIN + Discord.
"""
from services.notification_service import (
notify_modbus_mismatch,
notify_self_sustain_activated,
)
all_ok = True
for cmd_id in command_ids:
cmd = await db.fetchrow(
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
)
if cmd is None or cmd["status"] != "written":
continue
try:
client = await get_modbus_client(
cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])
)
actual = await client.read_register(int(cmd["register"]))
await db.execute(
"""
UPDATE ems.modbus_command
SET value_verified=$1, verified_at=now(),
status=CASE WHEN $1=$2 THEN 'verified' ELSE 'mismatch' END
WHERE id=$3
""",
actual,
int(cmd["value_to_write"]),
cmd_id,
)
if actual != int(cmd["value_to_write"]):
logger.error(
"[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s",
cmd_id,
cmd["asset_code"],
int(cmd["register"]),
cmd["value_to_write"],
actual,
)
row_ac = await db.fetchrow(
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
)
attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0
await notify_modbus_mismatch(
cmd["asset_code"],
int(cmd["register"]),
cmd["register_name"] or "",
int(cmd["value_to_write"]),
actual,
attempts,
)
if attempts < 3:
await db.execute(
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
cmd_id,
)
await execute_modbus_commands([cmd_id], db)
await verify_modbus_commands([cmd_id], db, site_id)
else:
logger.critical(
"[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN",
cmd_id,
)
site = await db.fetchrow(
"SELECT code FROM ems.site WHERE id=$1", site_id
)
await _switch_to_self_sustain(
site_id,
db,
reason=(
f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} "
f"reg 0x{cmd['register']:04X}"
),
)
if site:
await notify_self_sustain_activated(
site["code"],
(
f"Modbus mismatch: {cmd['asset_code']} "
f"0x{cmd['register']:04X} expected={cmd['value_to_write']} "
f"actual={actual}"
),
)
all_ok = False
else:
logger.info(
"[cmd %s] verified OK: %s 0x%04X=%s",
cmd_id,
cmd["asset_code"],
int(cmd["register"]),
actual,
)
except Exception as e:
logger.error("[cmd %s] verify read failed: %s", cmd_id, e)
all_ok = False
return all_ok
async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> OperatingModeInfo | None:
@@ -80,21 +408,155 @@ async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> Operati
)
async def _fetch_current_slot_plan_row(site_id: int, db: asyncpg.Connection) -> asyncpg.Record | None:
"""Řádek plánu pro následující 15min slot (export ~1 min před hranicí, např. 14:29 → 14:3014:45)."""
return await db.fetchrow(
async def _get_current_soc(site_id: int, db: asyncpg.Connection) -> int:
soc = await db.fetchval(
"""
SELECT battery_soc_percent
FROM ems.telemetry_inverter
WHERE site_id = $1 AND battery_soc_percent IS NOT NULL
ORDER BY measured_at DESC
LIMIT 1
""",
site_id,
)
return int(soc) if soc is not None else 50
async def _load_inverter_config(
site_id: int, db: asyncpg.Connection
) -> InverterConfig | None:
row = await db.fetchrow(
"""
SELECT
ai.id, ai.code,
se.host, se.port, se.unit_id,
sgc.max_export_power_w,
sgc.max_import_power_w,
sgc.no_export,
ai.max_battery_charge_w,
ai.max_battery_discharge_w,
ab.reserve_soc_percent,
ab.usable_capacity_wh,
LEAST(
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w),
ai.max_battery_charge_w
) / 51.2 AS max_charge_a,
LEAST(
COALESCE(ab.bms_max_discharge_w, ai.max_battery_discharge_w),
ai.max_battery_discharge_w
) / 51.2 AS max_discharge_a
FROM ems.asset_inverter ai
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
JOIN ems.asset_battery ab ON ab.inverter_id = ai.id
LEFT JOIN ems.site_grid_connection sgc ON sgc.site_id = ai.site_id
WHERE ai.site_id = $1
AND ai.active = true
AND ai.controllable = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
ORDER BY ai.id
LIMIT 1
""",
site_id,
)
if row is None:
return None
mc = row["max_charge_a"]
md = row["max_discharge_a"]
max_charge_a = int(mc) if mc is not None else 0
max_discharge_a = int(md) if md is not None else 0
port = int(row["port"] or 502)
uid = int(row["unit_id"] if row["unit_id"] is not None else 1)
return InverterConfig(
id=int(row["id"]),
code=row["code"],
host=row["host"],
port=port,
unit_id=uid,
max_export_power_w=int(row["max_export_power_w"])
if row["max_export_power_w"] is not None
else None,
max_import_power_w=int(row["max_import_power_w"])
if row["max_import_power_w"] is not None
else None,
no_export=bool(row["no_export"] or False),
max_battery_charge_w=int(row["max_battery_charge_w"])
if row["max_battery_charge_w"] is not None
else None,
max_battery_discharge_w=int(row["max_battery_discharge_w"])
if row["max_battery_discharge_w"] is not None
else None,
reserve_soc_percent=int(row["reserve_soc_percent"])
if row["reserve_soc_percent"] is not None
else None,
usable_capacity_wh=int(row["usable_capacity_wh"])
if row["usable_capacity_wh"] is not None
else None,
max_charge_a=max_charge_a,
max_discharge_a=max_discharge_a,
)
def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]:
"""Hodnoty pro reg 6264 (Europe/Prague)."""
now = datetime.now(ZoneInfo("Europe/Prague"))
reg62 = ((now.year - 2000) << 8) | now.month
reg63 = (now.day << 8) | now.hour
reg64 = (now.minute << 8) | now.second
rows = [
(62, "", reg62),
(63, "", reg63),
(64, "", reg64),
]
return now, rows
def _deye_time_point_rows(
slot_index: int,
time_hhmm: int,
power_w: int,
soc_pct: int,
grid_charge: bool,
) -> list[tuple[int, str, int]]:
g = 1 if grid_charge else 0
return [
(148 + slot_index, "", time_hhmm),
(154 + slot_index, "", power_w),
(166 + slot_index, "", soc_pct),
(172 + slot_index, "", g),
]
def _slot_start_prague_sql(slot_offset: int) -> str:
"""Výraz TIMESTAMPTZ = začátek aktuálního (+offset) 15min slotu v Europe/Prague."""
off = int(slot_offset)
return f"""
(
WITH loc AS (SELECT now() AT TIME ZONE 'Europe/Prague' AS ts)
SELECT (
(date_trunc('day', ts)
+ make_interval(
hours => EXTRACT(HOUR FROM ts)::int,
mins => (FLOOR(EXTRACT(MINUTE FROM ts) / 15) * 15)::int
)
)::timestamp AT TIME ZONE 'Europe/Prague'
) + INTERVAL '{off * 15} minutes'
FROM loc
)
"""
async def _fetch_plan_row_for_slot_offset(
site_id: int, db: asyncpg.Connection, slot_offset: int
) -> asyncpg.Record | None:
"""Řádek plánu pro slot: 0 = probíhající 15min, 1 = následující (hranice v Europe/Prague)."""
t = _slot_start_prague_sql(slot_offset)
return await db.fetchrow(
f"""
SELECT pi.* FROM ems.planning_interval pi
JOIN ems.planning_run pr ON pr.id = pi.run_id
WHERE pr.site_id = $1 AND pr.status = 'active'
AND pi.interval_start = (
SELECT MIN(pi2.interval_start) FROM ems.planning_interval pi2
JOIN ems.planning_run pr2 ON pr2.id = pi2.run_id
WHERE pr2.site_id = $1 AND pr2.status = 'active'
AND pi2.interval_start >= date_trunc('hour', now())
+ INTERVAL '15 min' * FLOOR(EXTRACT(MINUTE FROM now()) / 15)
+ INTERVAL '15 minutes'
)
AND pi.interval_start = {t}
LIMIT 1
""",
site_id,
@@ -104,10 +566,20 @@ async def _fetch_current_slot_plan_row(site_id: int, db: asyncpg.Connection) ->
async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int:
v = await db.fetchval(
"""
SELECT ai.max_charge_power_w
FROM ems.asset_inverter ai
WHERE ai.site_id = $1 AND ai.controllable = true AND ai.active = true
ORDER BY ai.id
SELECT 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
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 AND ai.controllable = true AND ai.active = true
ORDER BY ab.id
LIMIT 1
""",
site_id,
@@ -129,6 +601,8 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0
ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0
hp_en = bool(pi["heat_pump_enabled"])
tgt = pi["battery_soc_target_pct"]
target_soc = int(round(float(tgt))) if tgt is not None else None
return ControlSetpoints(
battery_w=int(pi["battery_setpoint_w"] or 0),
grid_export_limit=abs(min(grid_sp, 0)),
@@ -138,6 +612,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
grid_setpoint_w=grid_sp,
ev1_power_w=ev1_w,
ev2_power_w=ev2_w,
target_soc_pct=target_soc,
)
if code == "SELF_SUSTAIN":
@@ -150,6 +625,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
)
if code == "CHARGE_CHEAP":
@@ -163,6 +639,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
)
if code == "PRESERVE":
@@ -175,62 +652,240 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
lock_battery=True,
)
logger.warning("Unknown mode_code %s for site export, skipping", code)
return None
async def write_inverter_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str:
if setpoints.battery_w is None:
return "OK inverter: skipped (battery_w=None, Deye unchanged)"
rows = await db.fetch(
"""
SELECT 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.controllable = true
AND ai.active = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
""",
def _apply_price_failsafe_guard(
site_id: int,
mode: OperatingModeInfo,
pi: asyncpg.Record | None,
sp: ControlSetpoints,
) -> ControlSetpoints:
if mode.mode_code != "AUTO" or pi is None:
return sp
if "is_predicted_price" not in pi or not bool(pi["is_predicted_price"]):
return sp
logger.warning(
"control export site=%s: AUTO slot uses predicted price -> forcing PASSIVE no-export guard",
site_id,
)
if not rows:
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=sp.ev1_current_a,
ev2_current_a=sp.ev2_current_a,
heat_pump_enable=sp.heat_pump_enable,
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
ev1_power_w=sp.ev1_power_w,
ev2_power_w=sp.ev2_power_w,
target_soc_pct=sp.target_soc_pct,
)
def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> int:
"""Reg 143 max export W z DB (např. SUN-20K / home-01 = 13 500 W)."""
if no_export:
return 0
return max(0, int(max_export_power_w or 0))
def get_deye_mode(setpoints: ControlSetpoints) -> str:
"""
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
Solver: záporný grid_setpoint_w = export; kladný výrazný + nabíjení = CHARGE ze sítě.
battery_w=None (SELF_SUSTAIN) → bat_w považuj za 0 → typicky PASSIVE při grid_setpoint_w=0.
"""
grid_w = int(setpoints.grid_setpoint_w or 0)
if setpoints.battery_w is None:
bat_w = 0
else:
bat_w = int(setpoints.battery_w)
if grid_w < -200:
return "SELL"
if bat_w > 500 and grid_w > 200:
return "CHARGE"
return "PASSIVE"
def _deye_tou_params(
setpoints: ControlSetpoints,
inv: InverterConfig,
) -> tuple[int, int, bool]:
"""
Parametry jednoho Deye time pointu: výkon W, SOC min %, grid_charge.
Musí odpovídat logice get_deye_mode / lock_battery v write_inverter_setpoints.
"""
reserve_soc = inv.reserve_soc_percent or 20
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge
if setpoints.lock_battery:
return tp_discharge_w, reserve_soc, False
deye_mode = get_deye_mode(setpoints)
if deye_mode == "CHARGE":
raw_bat = setpoints.battery_w
battery_w = int(raw_bat) if raw_bat is not None else 0
target_soc = min(95, setpoints.target_soc_pct or 80)
tp_charge_w = battery_watts_to_amps(battery_w, inv.max_charge_a) * int(BATT_VOLTAGE_V)
return tp_charge_w, target_soc, True
return tp_discharge_w, reserve_soc, False
async def write_inverter_setpoints(
site_id: int,
setpoints_now: ControlSetpoints,
setpoints_next: ControlSetpoints | None,
db: asyncpg.Connection,
planning_run_id: int | None = None,
) -> str:
inv = await _load_inverter_config(site_id, db)
if inv is None:
return "FAIL inverter: no controllable Modbus endpoint"
bw = setpoints.battery_w
gex = _clamp_u16(setpoints.grid_export_limit)
chg = _clamp_u16(bw) if bw >= 0 else 0
dis = _clamp_u16(abs(bw)) if bw < 0 else 0
raw_bat = setpoints_now.battery_w
grid_w = int(setpoints_now.grid_setpoint_w or 0)
no_export = inv.no_export
export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w)
reserve_soc = inv.reserve_soc_percent or 20
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge
errors: list[str] = []
for row in rows:
code = row["code"]
host = row["host"]
port = int(row["port"] or 502)
unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
dev = ModbusDevice(host, port, unit_id, f"inverter-write:{code}")
try:
if bw >= 0:
ok1 = await dev.write_register(0x00F3, chg)
ok2 = await dev.write_register(0x00F4, 0)
else:
ok1 = await dev.write_register(0x00F3, 0)
ok2 = await dev.write_register(0x00F4, dis)
ok3 = await dev.write_register(0x00F6, gex)
if not (ok1 and ok2 and ok3):
errors.append(f"{code}: Modbus write failed")
except Exception as e:
errors.append(f"{code}: {e}")
finally:
await dev.close()
try:
soc_telemetry = await _get_current_soc(site_id, db)
if errors:
return "FAIL inverter: " + "; ".join(errors)
return f"OK inverter: batt_w={bw} export_limit_w={gex}"
deye_mode = get_deye_mode(setpoints_now)
if setpoints_now.lock_battery:
charge_a = 0
discharge_a = 0
elif deye_mode == "CHARGE":
battery_w = int(raw_bat) if raw_bat is not None else 0
charge_a = battery_watts_to_amps(battery_w, inv.max_charge_a)
discharge_a = 0
else:
charge_a = int(inv.max_charge_a)
discharge_a = int(inv.max_discharge_a)
selling_mode = 0 if deye_mode == "SELL" else 1
export_limit = export_lim
reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE
logger.info(
f"[control] site={site_id} fyzický režim Deye: {deye_mode} | "
f"battery_w={raw_bat!r} grid_w={grid_w} | "
f"charge_a={charge_a} discharge_a={discharge_a} | "
f"reg142={'0=SELL' if deye_mode == 'SELL' else '1=ZERO_EXP'} "
f"reg178={reg178_val}"
)
now_cet, time_rows = _deye_system_time_register_rows()
logger.info("Deye time synced: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S"))
registers: list[tuple[int, str, int]] = list(time_rows)
sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now
hh_cur = current_slot_hhmm()
hh_nxt = next_slot_hhmm()
p1, s1, g1 = _deye_tou_params(setpoints_now, inv)
p2, s2, g2 = _deye_tou_params(sp_tp2, inv)
registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1))
registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2))
for idx in range(2, 6):
registers.extend(
_deye_time_point_rows(
idx, 2359, tp_discharge_w, reserve_soc, False
)
)
registers.extend(
[
(108, "", charge_a),
(109, "", discharge_a),
(141, "energy_mode (0)", 0),
(142, "limit_control (0=selling, 1=zero_export)", selling_mode),
(178, "grid_peak_shaving_switch", reg178_val),
(143, "", export_limit),
]
)
logger.info(
"[control] %s: deye_mode=%s charge=%sA discharge=%sA limit_control=%s export=%sW "
"time_point1=%s time_point2=%s soc_telemetry=%s%% (batt=%r grid=%sW)",
inv.code,
deye_mode,
charge_a,
discharge_a,
selling_mode,
export_limit,
hh_cur,
hh_nxt,
soc_telemetry,
raw_bat,
grid_w,
)
cmd_ids = await create_modbus_commands(
site_id,
planning_run_id,
"inverter",
inv.id,
inv.code,
inv.host,
inv.port,
inv.unit_id,
registers,
db,
deye_physical_mode=deye_mode,
)
if not await execute_modbus_commands(cmd_ids, db):
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
logger.info("[control] Inverter %s journal write OK", inv.code)
except Exception as e:
return f"FAIL inverter: {inv.code}: {e}"
return (
f"OK inverter: batt_w={raw_bat!r} "
f"(time points + FC 0x10: 108/109/141/142/178/143)"
)
async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]:
"""
Živé čtení holding registrů Deye 108, 109, 141, 142, 143, 178, 191 (stejné TCP spojení jako telemetrie/export).
"""
inv = await _load_inverter_config(site_id, db)
if inv is None:
raise ValueError("no controllable Modbus inverter for site")
client = await get_modbus_client(inv.host, inv.port, inv.unit_id)
read_at = datetime.now(timezone.utc)
try:
r108 = await client.read_register(108)
r109 = await client.read_register(109)
r141 = await client.read_register(141)
r142 = await client.read_register(142)
r143 = await client.read_register(143)
r178 = await client.read_register(178)
r191 = await client.read_register(191)
except Exception:
logger.exception("read_deye_registers_live site=%s failed", site_id)
raise
return {
"reg108_charge_a": int(r108),
"reg109_discharge_a": int(r109),
"reg141_energy_mode": int(r141),
"reg142_limit_control": int(r142),
"reg143_export_limit_w": int(r143),
"reg178_peak_shaving_switch": int(r178),
"reg191_peak_shaving_w": int(r191),
"read_at": read_at.isoformat(),
}
def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int:
@@ -371,18 +1026,20 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
logger.info("control export site=%s: MANUAL, skip writes", site_id)
return
pi = await _fetch_current_slot_plan_row(site_id, db)
sp = _build_setpoints(mode, pi)
pi_now = await _fetch_plan_row_for_slot_offset(site_id, db, 0)
pi_next = await _fetch_plan_row_for_slot_offset(site_id, db, 1)
sp_now = _build_setpoints(mode, pi_now)
sp_next = _build_setpoints(mode, pi_next)
if mode.mode_code == "AUTO" and sp is None:
if pi is None:
if mode.mode_code == "AUTO" and sp_now is None:
if pi_now is None:
logger.warning(
"control export site=%s: AUTO but no planning_interval for current slot, skip",
site_id,
)
return
if sp is None:
if sp_now is None:
logger.warning(
"control export site=%s: no setpoints for mode %s, skip",
site_id,
@@ -392,27 +1049,67 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
if mode.mode_code == "CHARGE_CHEAP":
max_ch = await _fetch_max_charge_power_w(site_id, db)
sp = ControlSetpoints(
# Kladný grid_setpoint_w > 200 → fyzický CHARGE (nabíjení ze sítě), viz get_deye_mode
grid_for_charge = max(300, max_ch)
sp_now = ControlSetpoints(
battery_w=max_ch,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
grid_setpoint_w=grid_for_charge,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
)
sp_next = sp_now
else:
sp_now = _apply_price_failsafe_guard(site_id, mode, pi_now, sp_now)
if sp_next is not None:
sp_next = _apply_price_failsafe_guard(site_id, mode, pi_next, sp_next)
planning_run_id = await db.fetchval(
"""
SELECT id FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 1
""",
site_id,
)
if planning_run_id is not None:
planning_run_id = int(planning_run_id)
try:
inv_res = await write_inverter_setpoints(
site_id, sp_now, sp_next, db, planning_run_id=planning_run_id
)
except Exception as e:
logger.error("inverter write failed: %s", e)
inv_res = f"FAIL inverter: {e}"
try:
ev_res = await write_ev_setpoints(site_id, sp_now, db)
except Exception as e:
logger.error("ev write failed: %s", e)
ev_res = f"FAIL ev: {e}"
try:
hp_res = await write_heat_pump_setpoint(site_id, sp_now, db)
except Exception as e:
logger.error("hp write failed: %s", e)
hp_res = f"FAIL heat pump: {e}"
try:
lox_res = await send_loxone_setpoints(site_id, sp_now, mode, db)
except Exception as e:
logger.error("loxone write failed: %s", e)
lox_res = f"FAIL Loxone: {e}"
results = list(
zip(
("inverter", "ev", "heat_pump", "loxone"),
await asyncio.gather(
write_inverter_setpoints(site_id, sp, db),
write_ev_setpoints(site_id, sp, db),
write_heat_pump_setpoint(site_id, sp, db),
send_loxone_setpoints(site_id, sp, mode, db),
return_exceptions=True,
),
(inv_res, ev_res, hp_res, lox_res),
)
)

View File

@@ -12,7 +12,6 @@ import httpx
import pandas as pd
import pvlib
from pvlib import irradiance
from pvlib.pvsystem import pvwatts_dc
from app.config import get_settings
@@ -64,9 +63,12 @@ async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]:
arrays = await db.fetch(
"""
SELECT *
SELECT id, code, nominal_power_wp, azimuth_deg, tilt_deg,
shading_factor, controllable
FROM ems.asset_pv_array
WHERE site_id = $1
AND azimuth_deg IS NOT NULL
AND tilt_deg IS NOT NULL
ORDER BY id
""",
site_id,
@@ -91,7 +93,7 @@ async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]:
"temperature_2m",
]
),
"forecast_days": 2,
"forecast_days": max(2, min(int(settings.open_meteo_forecast_days), 16)),
"timezone": "auto",
}
@@ -148,6 +150,7 @@ async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]:
loc = pvlib.location.Location(lat, lon, tz=api_tz)
solar_pos = loc.get_solarposition(times)
dni_extra = irradiance.get_extra_radiation(times)
total_rows = 0
horizon_start = times[0].tz_convert(timezone.utc).to_pydatetime()
@@ -156,13 +159,13 @@ async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]:
)
for arr in arrays:
tilt = float(arr["tilt_deg"] or 0.0)
az_db = float(arr["azimuth_deg"] or 0.0)
tilt = float(arr["tilt_deg"])
az_db = float(arr["azimuth_deg"])
az_pvlib = _db_azimuth_to_pvlib(az_db)
pdc0 = float(arr["nominal_power_wp"])
nominal_power_wp = float(arr["nominal_power_wp"])
shading = float(arr["shading_factor"] or 1.0)
poa = irradiance.get_total_irradiance(
poa_global = irradiance.get_total_irradiance(
surface_tilt=tilt,
surface_azimuth=az_pvlib,
solar_zenith=solar_pos["apparent_zenith"],
@@ -170,20 +173,23 @@ async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]:
dni=dni,
ghi=ghi,
dhi=dhi,
dni_extra=dni_extra,
model="haydavies",
)["poa_global"].fillna(0).clip(lower=0)
temp_cell = temp_air + 0.04 * poa
p_dc = pvwatts_dc(poa, temp_cell, pdc0, -0.004)
p_dc = p_dc.fillna(0).clip(lower=0) * shading
power_w = p_dc.round().astype(int)
area_m2 = nominal_power_wp / (1000.0 * 0.20)
power_w = poa_global * area_m2 * 0.20 * shading
cap_w = nominal_power_wp * 1.1
power_w = power_w.clip(lower=0, upper=cap_w).round().astype(int)
model_params: dict[str, Any] = {
"source": "open_meteo",
"endpoint": base,
"params": params,
"pvlib_model": "haydavies",
"pvwatts_gamma_pdc": -0.004,
"nominal_power_wp": nominal_power_wp,
"shading_factor": shading,
"area_m2_ref_20pct": area_m2,
}
run_id = await db.fetchval(

View File

@@ -0,0 +1,166 @@
"""Persistentní Modbus TCP klient na sdílené Waveshare / RS485 bráně (jedno spojení + lock)."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pymodbus.client import AsyncModbusTcpClient
logger = logging.getLogger(__name__)
class ModbusBatch:
"""Více read/write pod jedním držením locku (žádný jiný task na stejném klientovi mezi nimi)."""
def __init__(self, owner: PersistentModbusClient) -> None:
self._o = owner
async def read_register(self, address: int) -> int:
return await self._o._read_register_locked(address)
async def read_register_signed(self, address: int) -> int:
raw = await self.read_register(address)
return raw - 65536 if raw > 32767 else raw
async def write_register(self, address: int, value: int) -> bool:
return await self._o._write_register_locked(address, value)
async def write_registers(self, address: int, values: list[int]) -> bool:
return await self._o._write_registers_locked(address, values)
class PersistentModbusClient:
"""
Jedno persistentní TCP spojení na převodník.
Serializuje všechny požadavky přes asyncio.Lock().
Automaticky reconnectuje při výpadku.
"""
def __init__(self, host: str, port: int, device_id: int = 1) -> None:
self.host = host
self.port = port
self.device_id = device_id
self._client: AsyncModbusTcpClient | None = None
self._lock = asyncio.Lock()
async def _ensure_connected(self) -> None:
if self._client is not None and self._client.connected:
return
if self._client is not None:
self._client.close()
self._client = None
logger.info("Modbus connecting %s:%s dev=%s", self.host, self.port, self.device_id)
self._client = AsyncModbusTcpClient(
self.host,
port=self.port,
timeout=5,
retries=2,
)
await self._client.connect()
if not self._client.connected:
self._client.close()
self._client = None
raise ConnectionError(f"Cannot connect Modbus {self.host}:{self.port}")
logger.info("Modbus connected %s:%s", self.host, self.port)
async def _read_register_locked(self, address: int) -> int:
if self._client is None or not self._client.connected:
await self._ensure_connected()
assert self._client is not None
try:
r = await self._client.read_holding_registers(
address, count=1, device_id=self.device_id
)
if r.isError() or not getattr(r, "registers", None):
raise OSError(f"Read error 0x{address:04X}: {r!r}")
return int(r.registers[0])
except Exception as e:
logger.warning("Modbus read 0x%04X failed: %s", address, e)
self._client.close()
self._client = None
raise
async def _write_registers_locked(self, address: int, values: list[int]) -> bool:
if self._client is None or not self._client.connected:
await self._ensure_connected()
assert self._client is not None
try:
clamped = [max(0, min(65535, int(v))) for v in values]
r = await self._client.write_registers(
address, clamped, device_id=self.device_id
)
if r.isError():
raise OSError(f"Write error 0x{address:04X}={clamped}: {r!r}")
return True
except Exception as e:
logger.warning(
"Modbus write_registers 0x%04X failed: %s", address, e
)
self._client.close()
self._client = None
raise
async def _write_register_locked(self, address: int, value: int) -> bool:
if self._client is None or not self._client.connected:
await self._ensure_connected()
assert self._client is not None
try:
v = max(0, min(65535, int(value)))
r = await self._client.write_register(address, v, device_id=self.device_id)
if r.isError():
raise OSError(f"Write error 0x{address:04X}={v}: {r!r}")
return True
except Exception as e:
logger.warning("Modbus write 0x%04X=%s failed: %s", address, value, e)
self._client.close()
self._client = None
raise
async def read_register(self, address: int) -> int:
async with self._lock:
await self._ensure_connected()
return await self._read_register_locked(address)
async def read_register_signed(self, address: int) -> int:
raw = await self.read_register(address)
return raw - 65536 if raw > 32767 else raw
async def write_register(self, address: int, value: int) -> bool:
async with self._lock:
await self._ensure_connected()
return await self._write_register_locked(address, value)
async def write_registers(self, address: int, values: list[int]) -> bool:
"""FC 0x10 povinné pro Deye registry 60499 (jeden i více registrů)."""
async with self._lock:
await self._ensure_connected()
return await self._write_registers_locked(address, values)
@asynccontextmanager
async def batch(self) -> AsyncIterator[ModbusBatch]:
"""Drží lock pro více po sobě jdoucích operací (telemetrie vs. control na stejné bráně)."""
async with self._lock:
await self._ensure_connected()
yield ModbusBatch(self)
def close(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
_clients: dict[str, PersistentModbusClient] = {}
_registry_lock = asyncio.Lock()
async def get_modbus_client(
host: str, port: int, device_id: int = 1
) -> PersistentModbusClient:
key = f"{host}:{port}:{device_id}"
async with _registry_lock:
if key not in _clients:
_clients[key] = PersistentModbusClient(host, port, device_id)
return _clients[key]

View File

@@ -0,0 +1,65 @@
"""Discord a další notifikace pro provoz EMS."""
from __future__ import annotations
import logging
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
async def send_discord(message: str, level: str = "info") -> bool:
"""
Pošle notifikaci na Discord webhook.
level: 'info', 'warning', 'error', 'critical'
Vrátí True při úspěchu.
"""
settings = get_settings()
webhook_url = settings.discord_webhook_url
if not webhook_url:
logger.debug("Discord webhook not configured, skipping notification")
return False
emoji = {"info": "", "warning": "⚠️", "error": "", "critical": "🚨"}.get(level, "")
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
webhook_url,
json={
"content": f"{emoji} **EMS Alert** [{level.upper()}]\n{message}",
},
)
resp.raise_for_status()
return True
except Exception as e:
logger.warning("Discord notification failed: %s", e)
return False
async def notify_modbus_mismatch(
asset_code: str,
register: int,
register_name: str,
value_written: int,
value_verified: int,
attempt: int,
) -> None:
msg = (
f"Modbus mismatch na **{asset_code}**\n"
f"Registr: `0x{register:04X}` ({register_name})\n"
f"Zapsáno: `{value_written}` | Přečteno: `{value_verified}`\n"
f"Pokus č. {attempt}"
)
await send_discord(msg, level="error")
async def notify_self_sustain_activated(site_code: str, reason: str) -> None:
msg = (
f"Přepnutí na **SELF_SUSTAIN** lokalita `{site_code}`\n"
f"Důvod: {reason}"
)
await send_discord(msg, level="critical")

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

View File

@@ -1,11 +1,10 @@
"""OTE CZ DAM spot price import (15min slots, shared market table)."""
"""OTE CZ price import Python dělá pouze HTTP fetch, logika je v PostgreSQL."""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import date, datetime, timedelta, timezone
from typing import Any
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
import httpx
@@ -14,167 +13,178 @@ from app.config import get_settings
logger = logging.getLogger(__name__)
MARKET_SOURCE = "OTE_CZ"
OTE_URL = (
"https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/"
"@@chart-data?report_date={date}&time_resolution=PT15M"
)
def _is_retryable_status(status_code: int) -> bool:
return status_code in {408, 425, 429, 500, 502, 503, 504}
async def _fetch_ote_json(date_str: str) -> tuple[dict | None, str | None]:
url = OTE_URL.format(date=date_str)
timeout = httpx.Timeout(connect=10.0, read=45.0, write=10.0, pool=10.0)
headers = {
"User-Agent": "Mozilla/5.0 (compatible; EMS/1.0; +https://www.ote-cr.cz)",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "cs-CZ,cs;q=0.9,en;q=0.8",
"Connection": "keep-alive",
}
max_attempts = 4
backoff_s = 1.0
last_err: str | None = None
async with httpx.AsyncClient(
timeout=timeout,
headers=headers,
follow_redirects=True,
) as client:
for attempt in range(1, max_attempts + 1):
try:
logger.info("OTE fetch %s attempt %s/%s", date_str, attempt, max_attempts)
resp = await client.get(url)
if _is_retryable_status(resp.status_code) and attempt < max_attempts:
last_err = f"http_status:{resp.status_code}"
logger.warning(
"OTE temporary HTTP %s for %s (attempt %s/%s), retrying",
resp.status_code,
date_str,
attempt,
max_attempts,
)
await asyncio.sleep(backoff_s)
backoff_s *= 2.0
continue
resp.raise_for_status()
return resp.json(), None
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
last_err = f"timeout_or_connect:{e.__class__.__name__}"
if attempt < max_attempts:
logger.warning(
"OTE request failed for %s (%s), retrying %s/%s",
date_str,
e.__class__.__name__,
attempt,
max_attempts,
)
await asyncio.sleep(backoff_s)
backoff_s *= 2.0
continue
logger.error("OTE fetch failed for %s after retries: %s", date_str, e)
except httpx.HTTPStatusError as e:
code = e.response.status_code if e.response is not None else "unknown"
last_err = f"http_status:{code}"
logger.error("OTE HTTP error for %s: %s", date_str, code)
break
except json.JSONDecodeError as e:
last_err = f"invalid_json:{e.__class__.__name__}"
logger.error("OTE invalid JSON for %s: %s", date_str, e)
break
except Exception as e:
last_err = f"unexpected:{e.__class__.__name__}"
logger.error("OTE fetch unexpected error for %s: %s", date_str, e)
break
return None, last_err
async def import_ote_prices(
site_id: int,
db,
target_date: date | None = None,
) -> tuple[int, str, float]:
) -> tuple[int, str, float, str | None]:
"""
Stáhne DAM ceny OTE pro zvolený den (nebo „zítřek“ v TZ lokality), uloží 96 slotů (15 min).
Schéma DB: ``ems.market_interval_price`` má PK ``(market_source, interval_start)``;
ceny v ``buy_raw_price_czk_kwh`` / ``sell_raw_price_czk_kwh`` (pro OTE stejné).
Returns:
``(počet_slotů, datum_YMD, první_cena_kč_kwh)``. Počet 96 při úspěchu, -1 při chybě.
První cena je cena prvního 15min slotu dne; při chybě 0.0.
Datum je prázdný řetězec jen pokud site neexistuje nebo je neplatná timezone.
Stáhne OTE JSON a předá ho PostgreSQL funkci ems.fn_ote_import_from_json.
Python nedělá žádné parsování ani přepočty vše je v DB funkcích.
Returns: (počet_slotů, datum_str, první_cena_kč_kwh, error_code)
(-1, datum_str, 0.0, error_code) při chybě
"""
settings = get_settings()
row = await db.fetchrow(
"SELECT timezone FROM ems.site WHERE id = $1",
site_id,
"SELECT timezone FROM ems.site WHERE id = $1", site_id
)
if row is None:
logger.error("import_ote_prices: site id=%s nenalezen", site_id)
return -1, "", 0.0
logger.error("OTE import: site id=%s nenalezen", site_id)
return -1, "", 0.0, "site_not_found"
tz_name: str = row["timezone"] or "Europe/Prague"
try:
site_tz = ZoneInfo(tz_name)
except Exception as e:
logger.error("import_ote_prices: neplatná timezone %r: %s", tz_name, e)
return -1, "", 0.0
site_tz = ZoneInfo(row["timezone"] or "Europe/Prague")
now_site = datetime.now(site_tz)
today_site = now_site.date()
tomorrow_site = today_site + timedelta(days=1)
candidate_days = [target_date] if target_date is not None else [tomorrow_site, today_site]
payload: dict | None = None
fetch_error: str | None = None
target_day = candidate_days[0]
# Varování před 13:30 CET při implicitním (zítra) importu.
if target_date is None:
now_cet = datetime.now(ZoneInfo("Europe/Prague"))
if now_cet.hour < 13 or (now_cet.hour == 13 and now_cet.minute < 30):
logger.warning(
"OTE: ceny pro %s nemusí být dostupné (před 13:30 CET), použiji fallback na dnešek",
tomorrow_site.isoformat(),
)
for day in candidate_days:
day_str = day.isoformat()
payload, fetch_error = await _fetch_ote_json(day_str)
if payload is not None:
target_day = day
break
logger.warning("OTE fetch selhal pro %s (err=%s)", day_str, fetch_error)
if payload is None:
return -1, candidate_days[0].isoformat(), 0.0, fetch_error or "fetch_failed"
if target_date is not None:
target_day = target_date
else:
now_local = datetime.now(site_tz)
target_day = (now_local + timedelta(days=1)).date()
date_str = target_day.isoformat()
cet = ZoneInfo("Europe/Prague")
now_cet = datetime.now(cet)
tomorrow_cet = (now_cet + timedelta(days=1)).date()
if target_day == tomorrow_cet:
cutoff = now_cet.replace(hour=13, minute=30, second=0, microsecond=0)
if now_cet < cutoff:
logger.warning(
"OTE prices for tomorrow may not be available yet (before 13:30 CET)"
)
settings = get_settings()
base_url = settings.ote_api_url.rstrip("/")
url = f"{base_url}?date={date_str}"
# Vše ostatní řeší PostgreSQL funkce
eur_czk = float(settings.eur_czk_rate)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(url)
resp.raise_for_status()
body = resp.json()
except httpx.TimeoutException:
logger.warning("import_ote_prices: timeout při GET %s", url)
return -1, date_str, 0.0
except httpx.HTTPStatusError as e:
logger.warning(
"import_ote_prices: HTTP %s při GET %s: %s",
e.response.status_code,
url,
e.response.text[:500],
n = await db.fetchval(
"SELECT ems.fn_ote_import_from_json($1::jsonb, $2)",
json.dumps(payload),
eur_czk,
)
return -1, date_str, 0.0
except httpx.HTTPError as e:
logger.warning("import_ote_prices: HTTP chyba při GET %s: %s", url, e)
return -1, date_str, 0.0
except Exception as e:
logger.warning("import_ote_prices: neočekávaná chyba při stahování: %s", e)
return -1, date_str, 0.0
hourly_eur_mwh: dict[int, float] | None = None
try:
points: list[dict[str, Any]] = body["data"]["dataLine"][0]["point"]
hourly_eur_mwh = {}
for p in points:
x = int(p["x"])
y = float(p["y"])
hourly_eur_mwh[x] = y
except (KeyError, TypeError, ValueError, IndexError):
snippet = json.dumps(body, ensure_ascii=False)[:500]
logger.error("import_ote_prices: neočekádaná struktura OTE, začátek: %s", snippet)
return -1, date_str, 0.0
if len(hourly_eur_mwh) != 24 or set(hourly_eur_mwh.keys()) != set(range(1, 25)):
logger.error(
"import_ote_prices: očekáváno 24 bodů x=1..24, dostáno klíče %s",
sorted(hourly_eur_mwh.keys()),
)
return -1, date_str, 0.0
slots: list[tuple[datetime, datetime, float]] = []
for h in range(24):
x = h + 1
eur_mwh = hourly_eur_mwh[x]
price_czk_kwh = eur_mwh * eur_czk / 1000.0
for minute in (0, 15, 30, 45):
interval_start_local = datetime(
target_day.year,
target_day.month,
target_day.day,
h,
minute,
tzinfo=site_tz,
)
interval_start_utc = interval_start_local.astimezone(timezone.utc)
interval_end_utc = interval_start_utc + timedelta(minutes=15)
slots.append((interval_start_utc, interval_end_utc, price_czk_kwh))
for interval_start_utc, interval_end_utc, price in slots:
await db.execute(
first_price = await db.fetchval(
"""
INSERT INTO ems.market_interval_price (
market_source,
interval_start,
interval_end,
buy_raw_price_czk_kwh,
sell_raw_price_czk_kwh,
currency,
imported_at
)
VALUES ($1, $2, $3, $4, $5, 'CZK', now())
ON CONFLICT (market_source, interval_start)
DO UPDATE SET
interval_end = EXCLUDED.interval_end,
buy_raw_price_czk_kwh = EXCLUDED.buy_raw_price_czk_kwh,
sell_raw_price_czk_kwh = EXCLUDED.sell_raw_price_czk_kwh,
imported_at = now()
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
""",
MARKET_SOURCE,
interval_start_utc,
interval_end_utc,
price,
price,
target_day,
)
first_price = float(slots[0][2]) if slots else 0.0
return len(slots), date_str, first_price
if __name__ == "__main__":
import asyncio
import os
import asyncpg
from dotenv import load_dotenv
load_dotenv()
async def test():
conn = await asyncpg.connect(os.getenv("DATABASE_URL"))
n, d, fp = await import_ote_prices(1, conn)
print(f"Uloženo {n} slotů pro {d}, první cena {fp}")
await conn.close()
asyncio.run(test())
n_imported = await db.fetchval(
"""
SELECT COUNT(*)::int
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND interval_start::date = $1::date
""",
target_day,
)
incomplete = (n_imported or 0) < 96
if incomplete:
now_p = datetime.now(ZoneInfo("Europe/Prague"))
tomorrow_p = (now_p + timedelta(days=1)).date()
# Stejná logika jako dashboard: neúplný D+1 před 14:30 je očekávaný
if not (
target_day == tomorrow_p
and (now_p.hour, now_p.minute) < (14, 30)
):
logger.warning("OTE: jen %s/96 slotů pro %s", n_imported, date_str)
logger.info(
"OTE import OK: %s slotů pro %s, první cena %.4f Kč/kWh",
n, date_str, float(first_price or 0),
)
return int(n), date_str, float(first_price or 0.0), None
except Exception as e:
logger.error("OTE import DB error: %s", e)
return -1, date_str, 0.0, f"db_import:{e.__class__.__name__}"

View File

@@ -7,148 +7,23 @@ import logging
from datetime import datetime, timezone
import asyncpg
from pymodbus.client import AsyncModbusTcpClient
from pymodbus.exceptions import ConnectionException, ModbusIOException
from app.ws_manager import manager
from services.modbus_client import get_modbus_client
logger = logging.getLogger(__name__)
def _to_signed_i16(value: int) -> int:
v = value & 0xFFFF
if v >= 0x8000:
return v - 0x10000
return v
class ModbusDevice:
def __init__(self, host: str, port: int, unit_id: int, device_name: str) -> None:
self._host = host
self._port = int(port) if port else 502
self._unit_id = int(unit_id) if unit_id is not None else 1
self._device_name = device_name
self._client: AsyncModbusTcpClient | None = None
self._error_count = 0
def _log_prefix(self) -> str:
return f"[{self._device_name}]"
def _note_communication_failure(self, exc: BaseException | None) -> None:
self._error_count += 1
if isinstance(exc, ConnectionError):
logger.warning("%s ConnectionError: %s", self._log_prefix(), exc)
else:
logger.warning(
"%s komunikace selhala: %s",
self._log_prefix(),
exc if exc is not None else "neznámá chyba",
)
if self._error_count >= 3:
logger.error("%s Opakované chyby komunikace", self._log_prefix())
if self._error_count >= 10 and self._error_count % 10 == 0:
logger.critical(
"%s Opakované chyby komunikace, pokus o reconnect",
self._log_prefix(),
)
def _reset_error_count(self) -> None:
self._error_count = 0
async def _ensure_connected(self) -> bool:
if self._client is None:
self._client = AsyncModbusTcpClient(self._host, port=self._port)
if not self._client.connected:
try:
ok = await self._client.connect()
except ConnectionError as e:
self._note_communication_failure(e)
return False
except OSError as e:
self._note_communication_failure(e)
return False
if not ok:
self._note_communication_failure(ConnectionError("Modbus connect() returned False"))
return False
return True
async def _reconnect(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
self._client = AsyncModbusTcpClient(self._host, port=self._port)
try:
await self._client.connect()
except (ConnectionError, OSError) as e:
logger.warning("%s reconnect selhal: %s", self._log_prefix(), e)
async def read_register(self, address: int) -> int:
"""Čte jeden holding register. Vrátí 0 při chybě."""
try:
if not await self._ensure_connected():
if self._error_count >= 10 and self._error_count % 10 == 0:
await self._reconnect()
return 0
assert self._client is not None
resp = await self._client.read_holding_registers(
address, count=1, device_id=self._unit_id
)
if resp.isError() or not getattr(resp, "registers", None):
self._note_communication_failure(
ConnectionException(f"read_holding_registers@{address:#x}: {resp!r}")
)
if self._error_count >= 10 and self._error_count % 10 == 0:
await self._reconnect()
return 0
self._reset_error_count()
return int(resp.registers[0])
except ConnectionError as e:
self._note_communication_failure(e)
if self._error_count >= 10 and self._error_count % 10 == 0:
await self._reconnect()
return 0
except (OSError, ModbusIOException, ConnectionException) as e:
self._note_communication_failure(e)
if self._error_count >= 10 and self._error_count % 10 == 0:
await self._reconnect()
return 0
async def read_register_signed(self, address: int) -> int:
"""Čte signed int16 (pro výkony které mohou být záporné)."""
u = await self.read_register(address)
return _to_signed_i16(u)
async def write_register(self, address: int, value: int) -> bool:
"""Zapíše jeden holding register. Vrátí False při chybě."""
try:
if not await self._ensure_connected():
if self._error_count >= 10 and self._error_count % 10 == 0:
await self._reconnect()
return False
assert self._client is not None
resp = await self._client.write_register(address, value, device_id=self._unit_id)
if resp.isError():
self._note_communication_failure(
ConnectionException(f"write_register@{address:#x}: {resp!r}")
)
if self._error_count >= 10 and self._error_count % 10 == 0:
await self._reconnect()
return False
self._reset_error_count()
return True
except ConnectionError as e:
self._note_communication_failure(e)
if self._error_count >= 10 and self._error_count % 10 == 0:
await self._reconnect()
return False
except (OSError, ModbusIOException, ConnectionException) as e:
self._note_communication_failure(e)
if self._error_count >= 10 and self._error_count % 10 == 0:
await self._reconnect()
return False
async def close(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
# Deye SUN holding registry (decimal adresa = přímo pro read_holding_registers)
DEYE_REG_RUN_STATE = 500
DEYE_REG_BATT_CHARGE_TODAY = 514
DEYE_REG_BATT_DISCHARGE_TODAY = 515
DEYE_REG_BATTERY_SOC = 588
DEYE_REG_BATTERY_POWER_FLOW = 590
DEYE_REG_GRID_TOTAL_POWER = 625
DEYE_REG_GEN_PORT_POWER = 667
DEYE_REG_LOAD_TOTAL_POWER = 653
DEYE_REG_PV1_POWER = 672
DEYE_REG_PV2_POWER = 673
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
@@ -169,34 +44,43 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
inv_id = row["id"]
code = row["code"]
host = row["host"]
port = row["port"] or 502
unit_id = row["unit_id"] if row["unit_id"] is not None else 1
dev = ModbusDevice(host, port, unit_id, f"inverter:{code}")
port = int(row["port"] or 502)
unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
try:
pv_power_w = await dev.read_register(0x0215)
battery_soc = await dev.read_register(0x0103)
battery_power = await dev.read_register_signed(0x0105)
battery_voltage = (await dev.read_register(0x0101)) / 10.0
grid_power = await dev.read_register_signed(0x0169)
grid_voltage = (await dev.read_register(0x016F)) / 10.0
load_power = await dev.read_register(0x0213)
inv_temp = (await dev.read_register(0x0220)) / 10.0
op_mode = await dev.read_register(0x0168)
fault_code = await dev.read_register(0x0180)
client = await get_modbus_client(host, port, unit_id)
async with client.batch() as mb:
run_state = await mb.read_register(DEYE_REG_RUN_STATE)
battery_soc = await mb.read_register(DEYE_REG_BATTERY_SOC)
battery_power = await mb.read_register_signed(DEYE_REG_BATTERY_POWER_FLOW)
batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY)
batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY)
gen_port_power = await mb.read_register(DEYE_REG_GEN_PORT_POWER)
grid_power = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER)
load_power = await mb.read_register(DEYE_REG_LOAD_TOTAL_POWER)
pv1_power = await mb.read_register(DEYE_REG_PV1_POWER)
pv2_power = await mb.read_register(DEYE_REG_PV2_POWER)
# Celková výroba FVE na této instalaci = stringy PV + výkon přes GEN port.
pv_power_w = int(pv1_power) + int(pv2_power) + int(gen_port_power)
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, battery_soc_percent, battery_power_w, battery_voltage_v,
grid_power_w, grid_voltage_v, load_power_w,
inverter_temp_c, operating_mode, fault_code
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,
run_state
)
VALUES (
$1, $2, $3,
$4, $5, $6, $7,
$8, $9, $10,
$11, $12, $13
$8, $9,
$10, $11,
$12, $13,
$14
)
ON CONFLICT (inverter_id, measured_at) DO NOTHING
""",
@@ -204,20 +88,34 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
inv_id,
measured_at,
pv_power_w,
battery_soc,
pv1_power,
pv2_power,
gen_port_power,
float(battery_soc),
battery_power,
battery_voltage,
batt_charge_today,
batt_discharge_today,
grid_power,
grid_voltage,
load_power,
inv_temp,
str(op_mode),
fault_code,
run_state,
)
inv_temp: float | None = None
await manager.broadcast_telemetry(
{
"type": "telemetry",
"site_id": site_id,
"ts": datetime.now(timezone.utc).isoformat(),
"pv_power_w": pv_power_w,
"battery_soc_pct": float(battery_soc),
"battery_power_w": battery_power,
"grid_power_w": grid_power,
"load_power_w": load_power,
"gen_port_power_w": gen_port_power,
"inverter_temp_c": inv_temp,
}
)
except Exception as e:
logger.error("poll_inverter site=%s inverter=%s: %s", site_id, code, e)
finally:
await dev.close()
async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
@@ -233,23 +131,112 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
site_id,
)
measured_at = datetime.now(timezone.utc)
connector_id = 1
for row in rows:
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
""",
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, 1, 'available', 0, 0)
VALUES ($1, $2, $3, $4, $5, 0, 0)
ON CONFLICT (charger_id, connector_id, measured_at) DO NOTHING
""",
site_id,
row["id"],
charger_id,
measured_at,
connector_id,
current_status,
)
if previous_status is not None:
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,
)
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(