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