sql first refactor
This commit is contained in:
@@ -64,6 +64,10 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
|||||||
|
|
||||||
11. **Přepínání provozního režimu** přes DB API / `ems.fn_set_mode` – držet konzistenci s `operating_mode_def` a Loxone `loxone_mode_value`.
|
11. **Přepínání provozního režimu** přes DB API / `ems.fn_set_mode` – držet konzistenci s `operating_mode_def` a Loxone `loxone_mode_value`.
|
||||||
|
|
||||||
|
### SQL vs Python (read-model)
|
||||||
|
|
||||||
|
- **Žádné ad-hoc `SELECT`/`INSERT`/`UPDATE` v `backend/services/*.py` a `backend/app/routers/*.py`** kromě: existence `SELECT 1` / `EXISTS`, volání `select ems.fn_*(…)`, a čtení z **`ems.vw_*`**. IO (Modbus, HTTP), PuLP solver a orchestrace zůstávají v Pythonu.
|
||||||
|
|
||||||
### Provozní režimy (operating_mode)
|
### Provozní režimy (operating_mode)
|
||||||
|
|
||||||
- Pět hodnot `mode_code` v `ems.site_operating_mode`: **AUTO**, **SELF_SUSTAIN**, **CHARGE_CHEAP**, **PRESERVE**, **MANUAL**.
|
- Pět hodnot `mode_code` v `ems.site_operating_mode`: **AUTO**, **SELF_SUSTAIN**, **CHARGE_CHEAP**, **PRESERVE**, **MANUAL**.
|
||||||
@@ -129,7 +133,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
|||||||
| `modbus_command` | Journal Modbus zápisů (pending → written → verified / mismatch / failed); retry a vazba na `planning_run`; u Deye exportu `deye_physical_mode` (PASSIVE/SELL/CHARGE). |
|
| `modbus_command` | Journal Modbus zápisů (pending → written → verified / mismatch / failed); retry a vazba na `planning_run`; u Deye exportu `deye_physical_mode` (PASSIVE/SELL/CHARGE). |
|
||||||
| `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. |
|
| `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. |
|
||||||
|
|
||||||
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`.
|
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -177,6 +181,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
|||||||
| Rolling plán, forecast log | `db/migration/V007__rolling_replanning.sql` |
|
| Rolling plán, forecast log | `db/migration/V007__rolling_replanning.sql` |
|
||||||
| Audit 15min | `db/routines/R__fn_fill_audit_interval.sql`, `docs/04-modules/telemetry.md` |
|
| Audit 15min | `db/routines/R__fn_fill_audit_interval.sql`, `docs/04-modules/telemetry.md` |
|
||||||
| Nové sloupce / tabulky | nový `db/migration/V00x__*.sql` + případně `db/routines` / `db/views` |
|
| Nové sloupce / tabulky | nový `db/migration/V00x__*.sql` + případně `db/routines` / `db/views` |
|
||||||
|
| JSONB read-model (`fn_*`, `fetch_json`) | `docs/02-architecture.md` sekce Read-model JSONB, `app/db_json.py` |
|
||||||
| Self-hosted deploy (Gitea, Caddy, `/opt/ems-deploy`) | `docs/deployment-self-hosted.md`, `deploy/deploy.sh` |
|
| Self-hosted deploy (Gitea, Caddy, `/opt/ems-deploy`) | `docs/deployment-self-hosted.md`, `deploy/deploy.sh` |
|
||||||
| Reset DB / restore z dumpu (Docker volume, Timescale) | `docs/database-reset-and-restore.md`, `scripts/import_ems_db.sh` |
|
| Reset DB / restore z dumpu (Docker volume, Timescale) | `docs/database-reset-and-restore.md`, `scripts/import_ems_db.sh` |
|
||||||
| Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) |
|
| Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) |
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""asyncpg Record → JSON-serializovatelný dict."""
|
"""asyncpg Record → JSON-serializovatelný dict + helper pro jsonb z fn_*."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -33,3 +34,17 @@ def record_to_dict(r: asyncpg.Record) -> dict[str, Any]:
|
|||||||
else:
|
else:
|
||||||
out[k] = str(v)
|
out[k] = str(v)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_json(conn: asyncpg.Connection, query: str, *args: Any) -> Any:
|
||||||
|
"""fetchval pro dotazy vracející jsonb (např. select ems.fn_*(...))."""
|
||||||
|
v = await conn.fetchval(query, *args)
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, (dict, list)):
|
||||||
|
return v
|
||||||
|
if isinstance(v, (bytes, memoryview)):
|
||||||
|
return json.loads(bytes(v).decode("utf-8"))
|
||||||
|
if isinstance(v, str):
|
||||||
|
return json.loads(v)
|
||||||
|
return v
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -13,7 +14,7 @@ from zoneinfo import ZoneInfo
|
|||||||
import asyncpg
|
import asyncpg
|
||||||
import httpx
|
import httpx
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from app.db_json import record_to_dict
|
from app.db_json import fetch_json, record_to_dict
|
||||||
from app.deps import set_pg_pool
|
from app.deps import set_pg_pool
|
||||||
from app.routers.economics import router as economics_router
|
from app.routers.economics import router as economics_router
|
||||||
from app.routers.energy_flows import router as energy_flows_router
|
from app.routers.energy_flows import router as energy_flows_router
|
||||||
@@ -90,7 +91,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
async def scheduled_heartbeat() -> None:
|
async def scheduled_heartbeat() -> None:
|
||||||
async with app.state.pg_pool.acquire() as conn:
|
async with app.state.pg_pool.acquire() as conn:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
try:
|
try:
|
||||||
await send_heartbeat(site["id"], conn)
|
await send_heartbeat(site["id"], conn)
|
||||||
@@ -99,7 +100,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
async def scheduled_audit_filler() -> None:
|
async def scheduled_audit_filler() -> None:
|
||||||
async with app.state.pg_pool.acquire() as conn:
|
async with app.state.pg_pool.acquire() as conn:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
try:
|
try:
|
||||||
await fill_audit_for_completed_intervals(site["id"], conn)
|
await fill_audit_for_completed_intervals(site["id"], conn)
|
||||||
@@ -108,7 +109,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
async def scheduled_forecast_accuracy() -> None:
|
async def scheduled_forecast_accuracy() -> None:
|
||||||
async with app.state.pg_pool.acquire() as conn:
|
async with app.state.pg_pool.acquire() as conn:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
try:
|
try:
|
||||||
n = await conn.fetchval(
|
n = await conn.fetchval(
|
||||||
@@ -143,7 +144,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
async def scheduled_control_export() -> None:
|
async def scheduled_control_export() -> None:
|
||||||
async with app.state.pg_pool.acquire() as conn:
|
async with app.state.pg_pool.acquire() as conn:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
try:
|
try:
|
||||||
await export_setpoints(site["id"], conn)
|
await export_setpoints(site["id"], conn)
|
||||||
@@ -156,7 +157,7 @@ async def lifespan(app: FastAPI):
|
|||||||
Běží každé 2 minuty, nezávisle na control_exporter (delší okno kvůli zpoždění jobu).
|
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:
|
async with app.state.pg_pool.acquire() as conn:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
try:
|
try:
|
||||||
cmd_rows = await conn.fetch(
|
cmd_rows = await conn.fetch(
|
||||||
@@ -182,7 +183,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
async def scheduled_daily_plan() -> None:
|
async def scheduled_daily_plan() -> None:
|
||||||
async with app.state.pg_pool.acquire() as conn:
|
async with app.state.pg_pool.acquire() as conn:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
site_id = int(site["id"])
|
site_id = int(site["id"])
|
||||||
try:
|
try:
|
||||||
@@ -194,7 +195,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
async def scheduled_rolling_replan() -> None:
|
async def scheduled_rolling_replan() -> None:
|
||||||
async with app.state.pg_pool.acquire() as conn:
|
async with app.state.pg_pool.acquire() as conn:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
site_id = int(site["id"])
|
site_id = int(site["id"])
|
||||||
try:
|
try:
|
||||||
@@ -206,7 +207,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
async def scheduled_baseline_update() -> None:
|
async def scheduled_baseline_update() -> None:
|
||||||
async with app.state.pg_pool.acquire() as conn:
|
async with app.state.pg_pool.acquire() as conn:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
try:
|
try:
|
||||||
n = await conn.fetchval(
|
n = await conn.fetchval(
|
||||||
@@ -225,7 +226,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
async def scheduled_market_price_stats() -> None:
|
async def scheduled_market_price_stats() -> None:
|
||||||
async with app.state.pg_pool.acquire() as conn:
|
async with app.state.pg_pool.acquire() as conn:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
try:
|
try:
|
||||||
n = await conn.fetchval(
|
n = await conn.fetchval(
|
||||||
@@ -244,7 +245,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
async def scheduled_tuv_usage_stats() -> None:
|
async def scheduled_tuv_usage_stats() -> None:
|
||||||
async with app.state.pg_pool.acquire() as conn:
|
async with app.state.pg_pool.acquire() as conn:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
try:
|
try:
|
||||||
n = await conn.fetchval(
|
n = await conn.fetchval(
|
||||||
@@ -263,7 +264,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
async def scheduled_forecast_refresh() -> None:
|
async def scheduled_forecast_refresh() -> None:
|
||||||
async with app.state.pg_pool.acquire() as conn:
|
async with app.state.pg_pool.acquire() as conn:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
site_id = int(site["id"])
|
site_id = int(site["id"])
|
||||||
try:
|
try:
|
||||||
@@ -303,7 +304,7 @@ async def lifespan(app: FastAPI):
|
|||||||
async def _refresh_negative_price_predictions_all_active(
|
async def _refresh_negative_price_predictions_all_active(
|
||||||
conn: asyncpg.Connection,
|
conn: asyncpg.Connection,
|
||||||
) -> None:
|
) -> None:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
await _refresh_negative_price_predictions(conn, int(site["id"]))
|
await _refresh_negative_price_predictions(conn, int(site["id"]))
|
||||||
|
|
||||||
@@ -444,7 +445,7 @@ async def lifespan(app: FastAPI):
|
|||||||
from services.notification_service import notify_daily_economics
|
from services.notification_service import notify_daily_economics
|
||||||
|
|
||||||
async with app.state.pg_pool.acquire() as conn:
|
async with app.state.pg_pool.acquire() as conn:
|
||||||
sites = await conn.fetch("SELECT id, code FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id, code from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
site_id = int(site["id"])
|
site_id = int(site["id"])
|
||||||
site_code = site["code"]
|
site_code = site["code"]
|
||||||
@@ -546,9 +547,9 @@ async def list_sites(db: Annotated[asyncpg.Pool, Depends(get_pool)]) -> list[dic
|
|||||||
async with db.acquire() as conn:
|
async with db.acquire() as conn:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at
|
select id, code, name, timezone, latitude, longitude, active, notes, created_at
|
||||||
FROM ems.site
|
from ems.vw_site_directory
|
||||||
ORDER BY id
|
order by id
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
return [record_to_dict(r) for r in rows]
|
return [record_to_dict(r) for r in rows]
|
||||||
@@ -567,17 +568,15 @@ async def get_site_prices(
|
|||||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||||
if not site_ok:
|
if not site_ok:
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
rows = await conn.fetch(
|
rows = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT *
|
"select ems.fn_site_effective_prices_day_prague($1::int, $2::date)",
|
||||||
FROM ems.vw_site_effective_price
|
|
||||||
WHERE site_id = $1 AND interval_start::date = $2::date
|
|
||||||
ORDER BY interval_start
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
d,
|
d,
|
||||||
)
|
)
|
||||||
return [record_to_dict(r) for r in rows]
|
if not isinstance(rows, list):
|
||||||
|
rows = json.loads(rows) if isinstance(rows, str) else []
|
||||||
|
return [r for r in rows if isinstance(r, dict)]
|
||||||
|
|
||||||
|
|
||||||
class PricesImportResponse(BaseModel):
|
class PricesImportResponse(BaseModel):
|
||||||
@@ -656,7 +655,7 @@ async def post_import_site_prices(
|
|||||||
conn, site_id=None, target_date=target
|
conn, site_id=None, target_date=target
|
||||||
)
|
)
|
||||||
if n >= 0:
|
if n >= 0:
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch("select id from ems.vw_site_directory where active = true")
|
||||||
for site in sites:
|
for site in sites:
|
||||||
await _refresh_negative_price_predictions(conn, int(site["id"]))
|
await _refresh_negative_price_predictions(conn, int(site["id"]))
|
||||||
if n < 0:
|
if n < 0:
|
||||||
@@ -698,59 +697,35 @@ async def get_site_negative_price_predictions(
|
|||||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||||
if not site_ok:
|
if not site_ok:
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
ndays = await conn.fetchval(
|
bundle = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT COUNT(DISTINCT (interval_start AT TIME ZONE 'Europe/Prague')::date)::int
|
"select ems.fn_negative_price_predictions($1::int)",
|
||||||
FROM ems.market_interval_price
|
|
||||||
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
|
|
||||||
AND interval_start >= now() - INTERVAL '400 days'
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
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,
|
site_id,
|
||||||
)
|
)
|
||||||
n_hist = int(ndays or 0)
|
if not isinstance(bundle, dict):
|
||||||
|
bundle = json.loads(bundle)
|
||||||
|
rows = bundle.get("predictions") or []
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
rows = []
|
||||||
predictions: list[NegPricePredictionItem] = []
|
predictions: list[NegPricePredictionItem] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
em = r["expected_min_price"]
|
if not isinstance(r, dict):
|
||||||
pd = r["predicted_date"]
|
continue
|
||||||
|
em = r.get("expected_min_price")
|
||||||
|
pd = r.get("predicted_date")
|
||||||
predictions.append(
|
predictions.append(
|
||||||
NegPricePredictionItem(
|
NegPricePredictionItem(
|
||||||
predicted_date=pd.isoformat() if hasattr(pd, "isoformat") else str(pd),
|
predicted_date=pd.isoformat() if hasattr(pd, "isoformat") else str(pd),
|
||||||
window_start_hour=int(r["window_start_hour"]),
|
window_start_hour=int(r.get("window_start_hour") or 0),
|
||||||
window_end_hour=int(r["window_end_hour"]),
|
window_end_hour=int(r.get("window_end_hour") or 0),
|
||||||
probability_pct=float(r["probability_pct"]),
|
probability_pct=float(r.get("probability_pct") or 0),
|
||||||
expected_min_price=float(em) if em is not None else None,
|
expected_min_price=float(em) if em is not None else None,
|
||||||
reason=r["reason"] if r["reason"] is not None else "",
|
reason=str(r.get("reason") or ""),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return NegativePredictionsResponse(
|
return NegativePredictionsResponse(
|
||||||
predictions=predictions,
|
predictions=predictions,
|
||||||
insufficient_history=n_hist < 28,
|
insufficient_history=bool(bundle.get("insufficient_history")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -763,29 +738,19 @@ async def get_site_prices_latest(
|
|||||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||||
if not site_ok:
|
if not site_ok:
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
row = await conn.fetchrow(
|
row = await fetch_json(conn, "select ems.fn_latest_ote_day_stats()")
|
||||||
"""
|
if not isinstance(row, dict):
|
||||||
SELECT
|
row = json.loads(row)
|
||||||
(interval_start AT TIME ZONE 'Europe/Prague')::date AS day,
|
day = row.get("latest_date")
|
||||||
COUNT(*)::int AS slots,
|
if day is None:
|
||||||
MIN(buy_raw_price_czk_kwh)::float AS min_price,
|
|
||||||
MAX(buy_raw_price_czk_kwh)::float AS max_price,
|
|
||||||
AVG(buy_raw_price_czk_kwh)::float AS avg_price
|
|
||||||
FROM ems.market_interval_price
|
|
||||||
WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM')
|
|
||||||
GROUP BY day
|
|
||||||
ORDER BY day DESC
|
|
||||||
LIMIT 1
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
if row is None or row["day"] is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Žádná tržní data v databázi")
|
raise HTTPException(status_code=404, detail="Žádná tržní data v databázi")
|
||||||
|
latest_date = day.isoformat() if hasattr(day, "isoformat") else str(day)[:10]
|
||||||
return PricesLatestResponse(
|
return PricesLatestResponse(
|
||||||
latest_date=row["day"].isoformat(),
|
latest_date=latest_date,
|
||||||
slots=int(row["slots"] or 0),
|
slots=int(row.get("slots") or 0),
|
||||||
min_price=float(row["min_price"] or 0.0),
|
min_price=float(row.get("min_price") or 0.0),
|
||||||
max_price=float(row["max_price"] or 0.0),
|
max_price=float(row.get("max_price") or 0.0),
|
||||||
avg_price=float(row["avg_price"] or 0.0),
|
avg_price=float(row.get("avg_price") or 0.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -807,48 +772,45 @@ async def get_verify_modbus_commands(
|
|||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
lookback = timedelta(minutes=minutes)
|
lookback = timedelta(minutes=minutes)
|
||||||
rows = await conn.fetch(
|
id_json = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT id FROM ems.modbus_command
|
"select ems.fn_modbus_written_command_ids($1::int, $2::interval)",
|
||||||
WHERE site_id = $1
|
|
||||||
AND status = 'written'
|
|
||||||
AND written_at >= now() - $2::interval
|
|
||||||
ORDER BY written_at
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
lookback,
|
lookback,
|
||||||
)
|
)
|
||||||
ids = [int(r["id"]) for r in rows]
|
if not isinstance(id_json, list):
|
||||||
|
id_json = json.loads(id_json) if isinstance(id_json, str) else []
|
||||||
|
ids = [int(x) for x in id_json]
|
||||||
checked = len(ids)
|
checked = len(ids)
|
||||||
if ids:
|
if ids:
|
||||||
await verify_modbus_commands(ids, conn, site_id)
|
await verify_modbus_commands(ids, conn, site_id)
|
||||||
|
|
||||||
detail_rows = (
|
detail_json = (
|
||||||
await conn.fetch(
|
await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT id, asset_code, register_name, value_to_write, value_verified, status
|
"select ems.fn_modbus_commands_by_ids($1::int[])",
|
||||||
FROM ems.modbus_command
|
|
||||||
WHERE id = ANY($1::int[])
|
|
||||||
ORDER BY id
|
|
||||||
""",
|
|
||||||
ids,
|
ids,
|
||||||
)
|
)
|
||||||
if ids
|
if ids
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
|
if ids and not isinstance(detail_json, list):
|
||||||
|
detail_json = json.loads(detail_json) if isinstance(detail_json, str) else []
|
||||||
|
detail_rows = detail_json if ids else []
|
||||||
|
|
||||||
commands = [
|
commands = [
|
||||||
ModbusCommandVerifyItem(
|
ModbusCommandVerifyItem(
|
||||||
id=int(r["id"]),
|
id=int(r["id"]),
|
||||||
asset_code=r["asset_code"],
|
asset_code=str(r.get("asset_code") or ""),
|
||||||
register_name=r["register_name"],
|
register_name=r.get("register_name"),
|
||||||
value_to_write=int(r["value_to_write"]),
|
value_to_write=int(r["value_to_write"]),
|
||||||
value_verified=int(r["value_verified"])
|
value_verified=int(r["value_verified"])
|
||||||
if r["value_verified"] is not None
|
if r.get("value_verified") is not None
|
||||||
else None,
|
else None,
|
||||||
status=r["status"],
|
status=str(r.get("status") or ""),
|
||||||
)
|
)
|
||||||
for r in detail_rows
|
for r in detail_rows
|
||||||
|
if isinstance(r, dict)
|
||||||
]
|
]
|
||||||
verified = sum(1 for c in commands if c.status == "verified")
|
verified = sum(1 for c in commands if c.status == "verified")
|
||||||
mismatch = sum(1 for c in commands if c.status == "mismatch")
|
mismatch = sum(1 for c in commands if c.status == "mismatch")
|
||||||
@@ -933,21 +895,17 @@ async def get_control_command_journal(
|
|||||||
)
|
)
|
||||||
if not site_ok:
|
if not site_ok:
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
rows = await conn.fetch(
|
rows = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT id, register, register_name, value_to_write, value_written,
|
"select ems.fn_modbus_journal_list($1::int, $2::int)",
|
||||||
value_verified, status, attempt_count, created_at
|
|
||||||
FROM ems.modbus_command
|
|
||||||
WHERE site_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT $2
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
limit,
|
limit,
|
||||||
)
|
)
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
rows = json.loads(rows) if isinstance(rows, str) else []
|
||||||
cmds: list[ModbusJournalCommandRow] = []
|
cmds: list[ModbusJournalCommandRow] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = record_to_dict(r)
|
d = r if isinstance(r, dict) else {}
|
||||||
ca = d["created_at"]
|
ca = d["created_at"]
|
||||||
cmds.append(
|
cmds.append(
|
||||||
ModbusJournalCommandRow(
|
ModbusJournalCommandRow(
|
||||||
@@ -1006,51 +964,20 @@ async def get_site_forecast_pv(
|
|||||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
||||||
if not site_ok:
|
if not site_ok:
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
rows = await conn.fetch(
|
split = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT run_id, pv_array_id, interval_start, power_w,
|
"select ems.fn_forecast_pv_split($1::int, $2::date)",
|
||||||
irradiance_wm2, temp_c, pv_array_code, controllable
|
|
||||||
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,
|
|
||||||
apa.controllable
|
|
||||||
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
|
|
||||||
AT TIME ZONE COALESCE(
|
|
||||||
(SELECT timezone FROM ems.site WHERE id = $1),
|
|
||||||
'Europe/Prague'
|
|
||||||
)
|
|
||||||
)::date = $2::date
|
|
||||||
AND fpr.status = 'ok'
|
|
||||||
ORDER BY fpi.interval_start, fpr.pv_array_id, fpr.created_at DESC
|
|
||||||
) latest
|
|
||||||
ORDER BY controllable DESC, pv_array_code, interval_start
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
d,
|
d,
|
||||||
)
|
)
|
||||||
|
if not isinstance(split, dict):
|
||||||
# pv_a = řiditelná pole (curtailment / Deye), pv_b = neřízená (GEN, …) — sloučí více orientací
|
split = json.loads(split) if isinstance(split, str) else {}
|
||||||
pv_a: list[dict[str, Any]] = []
|
pv_a = split.get("pv_a") or []
|
||||||
pv_b: list[dict[str, Any]] = []
|
pv_b = split.get("pv_b") or []
|
||||||
for r in rows:
|
if not isinstance(pv_a, list):
|
||||||
item = record_to_dict(r)
|
pv_a = []
|
||||||
item.pop("controllable", None)
|
if not isinstance(pv_b, list):
|
||||||
if r["controllable"]:
|
pv_b = []
|
||||||
pv_a.append(item)
|
|
||||||
else:
|
|
||||||
pv_b.append(item)
|
|
||||||
return {"pv_a": pv_a, "pv_b": pv_b}
|
return {"pv_a": pv_a, "pv_b": pv_b}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
@@ -10,6 +11,7 @@ import asyncpg
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.db_json import fetch_json
|
||||||
from app.deps import get_pg_pool
|
from app.deps import get_pg_pool
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -105,56 +107,14 @@ async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
|
|||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
|
|
||||||
async def _has_green_bonus(conn: asyncpg.Connection, site_id: int) -> bool:
|
def _parse_day(val: Any) -> date:
|
||||||
return bool(
|
if isinstance(val, datetime):
|
||||||
await conn.fetchval(
|
return val.date()
|
||||||
"""
|
if isinstance(val, date):
|
||||||
SELECT EXISTS(
|
return val
|
||||||
SELECT 1 FROM ems.asset_pv_array
|
if isinstance(val, str):
|
||||||
WHERE site_id = $1
|
return date.fromisoformat(val[:10])
|
||||||
AND green_bonus_czk_kwh IS NOT NULL
|
raise ValueError(val)
|
||||||
)
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_get(record: Any, key: str, fallback: Any = None) -> Any:
|
|
||||||
"""Safely get a key from asyncpg Record (which supports [] but not .get())."""
|
|
||||||
try:
|
|
||||||
return record[key]
|
|
||||||
except (KeyError, TypeError):
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
def _daily_from_row(r: Any, lock: Any | None, is_locked: bool) -> DailyEconomics:
|
|
||||||
src = lock if (lock and is_locked) else r
|
|
||||||
return DailyEconomics(
|
|
||||||
day=r["day_local"],
|
|
||||||
interval_count=r["interval_count"],
|
|
||||||
import_kwh=_num(r["import_kwh"]),
|
|
||||||
export_kwh=_num(r["export_kwh"]),
|
|
||||||
pv_kwh=_num(r["pv_kwh"]),
|
|
||||||
load_kwh=_num(r["load_kwh"]),
|
|
||||||
pv_self_consumption_kwh=_num(r["pv_self_consumption_kwh"]),
|
|
||||||
ev_kwh=_num(r["ev_kwh"]),
|
|
||||||
hp_kwh=_num(r["hp_kwh"]),
|
|
||||||
import_cost_czk=_num(src["import_cost_czk"]),
|
|
||||||
export_revenue_czk=_num(src["export_revenue_czk"]),
|
|
||||||
grid_import_cashflow_czk=_num(
|
|
||||||
_safe_get(src, "grid_import_cashflow_czk", r["grid_import_cashflow_czk"])
|
|
||||||
),
|
|
||||||
grid_export_revenue_czk=_num(
|
|
||||||
_safe_get(src, "grid_export_revenue_czk", r["grid_export_revenue_czk"])
|
|
||||||
),
|
|
||||||
net_cost_czk=_num(src["net_cost_czk"]),
|
|
||||||
green_bonus_czk=_num(src["green_bonus_czk"]),
|
|
||||||
total_balance_czk=_num(src["total_balance_czk"]),
|
|
||||||
planned_balance_czk=_opt(r["planned_balance_czk"]),
|
|
||||||
deviation_cost_czk=_opt(r["deviation_cost_czk"]),
|
|
||||||
is_locked=is_locked,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/daily", response_model=DailyEconomicsResponse)
|
@router.get("/daily", response_model=DailyEconomicsResponse)
|
||||||
@@ -179,41 +139,47 @@ async def get_economics_daily(
|
|||||||
|
|
||||||
async with db.acquire() as conn:
|
async with db.acquire() as conn:
|
||||||
await _check_site(conn, site_id)
|
await _check_site(conn, site_id)
|
||||||
has_bonus = await _has_green_bonus(conn, site_id)
|
raw = await fetch_json(
|
||||||
|
conn,
|
||||||
dyn_rows = await conn.fetch(
|
"select ems.fn_economics_daily_month($1::int, $2::date, $3::date)",
|
||||||
"""
|
|
||||||
SELECT * FROM ems.vw_economics_daily
|
|
||||||
WHERE site_id = $1
|
|
||||||
AND day_local >= $2
|
|
||||||
AND day_local < $3
|
|
||||||
ORDER BY day_local
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
month_start,
|
month_start,
|
||||||
month_end,
|
month_end,
|
||||||
)
|
)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
lock_rows = await conn.fetch(
|
raw = json.loads(raw)
|
||||||
"""
|
days_in: list[Any] = list(raw.get("days") or [])
|
||||||
SELECT * FROM ems.audit_day_lock
|
|
||||||
WHERE site_id = $1
|
|
||||||
AND day_local >= $2
|
|
||||||
AND day_local < $3
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
month_start,
|
|
||||||
month_end,
|
|
||||||
)
|
|
||||||
locks = {r["day_local"]: r for r in lock_rows}
|
|
||||||
|
|
||||||
days: list[DailyEconomics] = []
|
days: list[DailyEconomics] = []
|
||||||
for r in dyn_rows:
|
for d in days_in:
|
||||||
d = r["day_local"]
|
if not isinstance(d, dict):
|
||||||
lock = locks.get(d)
|
continue
|
||||||
days.append(_daily_from_row(r, lock, is_locked=lock is not None))
|
days.append(
|
||||||
|
DailyEconomics(
|
||||||
return DailyEconomicsResponse(days=days, has_green_bonus=has_bonus)
|
day=_parse_day(d.get("day")),
|
||||||
|
interval_count=int(d.get("interval_count") or 0),
|
||||||
|
import_kwh=_num(d.get("import_kwh")),
|
||||||
|
export_kwh=_num(d.get("export_kwh")),
|
||||||
|
pv_kwh=_num(d.get("pv_kwh")),
|
||||||
|
load_kwh=_num(d.get("load_kwh")),
|
||||||
|
pv_self_consumption_kwh=_num(d.get("pv_self_consumption_kwh")),
|
||||||
|
ev_kwh=_num(d.get("ev_kwh")),
|
||||||
|
hp_kwh=_num(d.get("hp_kwh")),
|
||||||
|
import_cost_czk=_num(d.get("import_cost_czk")),
|
||||||
|
export_revenue_czk=_num(d.get("export_revenue_czk")),
|
||||||
|
grid_import_cashflow_czk=_num(d.get("grid_import_cashflow_czk")),
|
||||||
|
grid_export_revenue_czk=_num(d.get("grid_export_revenue_czk")),
|
||||||
|
net_cost_czk=_num(d.get("net_cost_czk")),
|
||||||
|
green_bonus_czk=_num(d.get("green_bonus_czk")),
|
||||||
|
total_balance_czk=_num(d.get("total_balance_czk")),
|
||||||
|
planned_balance_czk=_opt(d.get("planned_balance_czk")),
|
||||||
|
deviation_cost_czk=_opt(d.get("deviation_cost_czk")),
|
||||||
|
is_locked=bool(d.get("is_locked")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return DailyEconomicsResponse(
|
||||||
|
days=days,
|
||||||
|
has_green_bonus=bool(raw.get("has_green_bonus")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/daily/{day}/intervals", response_model=list[IntervalEconomics])
|
@router.get("/daily/{day}/intervals", response_model=list[IntervalEconomics])
|
||||||
@@ -270,50 +236,18 @@ async def lock_day(
|
|||||||
) -> LockResponse:
|
) -> LockResponse:
|
||||||
async with db.acquire() as conn:
|
async with db.acquire() as conn:
|
||||||
await _check_site(conn, site_id)
|
await _check_site(conn, site_id)
|
||||||
|
raw = await fetch_json(
|
||||||
row = await conn.fetchrow(
|
conn,
|
||||||
"""
|
"select ems.fn_economics_lock_day($1::int, $2::date)",
|
||||||
SELECT import_cost_czk, export_revenue_czk, net_cost_czk,
|
|
||||||
green_bonus_czk, total_balance_czk,
|
|
||||||
grid_import_cashflow_czk, grid_export_revenue_czk
|
|
||||||
FROM ems.vw_economics_daily
|
|
||||||
WHERE site_id = $1 AND day_local = $2
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
day,
|
day,
|
||||||
)
|
)
|
||||||
if row is None:
|
if not isinstance(raw, dict):
|
||||||
raise HTTPException(
|
raw = json.loads(raw)
|
||||||
status_code=404,
|
if raw.get("locked") is not True:
|
||||||
detail=f"No economics data for {day.isoformat()}",
|
raise HTTPException(
|
||||||
)
|
status_code=404,
|
||||||
|
detail=f"No economics data for {day.isoformat()}",
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO ems.audit_day_lock
|
|
||||||
(site_id, day_local, import_cost_czk, export_revenue_czk,
|
|
||||||
net_cost_czk, green_bonus_czk, total_balance_czk,
|
|
||||||
grid_import_cashflow_czk, grid_export_revenue_czk)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
ON CONFLICT (site_id, day_local) DO UPDATE SET
|
|
||||||
import_cost_czk = EXCLUDED.import_cost_czk,
|
|
||||||
export_revenue_czk = EXCLUDED.export_revenue_czk,
|
|
||||||
net_cost_czk = EXCLUDED.net_cost_czk,
|
|
||||||
green_bonus_czk = EXCLUDED.green_bonus_czk,
|
|
||||||
total_balance_czk = EXCLUDED.total_balance_czk,
|
|
||||||
grid_import_cashflow_czk = EXCLUDED.grid_import_cashflow_czk,
|
|
||||||
grid_export_revenue_czk = EXCLUDED.grid_export_revenue_czk,
|
|
||||||
locked_at = now()
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
day,
|
|
||||||
row["import_cost_czk"],
|
|
||||||
row["export_revenue_czk"],
|
|
||||||
row["net_cost_czk"],
|
|
||||||
row["green_bonus_czk"],
|
|
||||||
row["total_balance_czk"],
|
|
||||||
row["grid_import_cashflow_czk"],
|
|
||||||
row["grid_export_revenue_czk"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return LockResponse(locked=True, day=day)
|
return LockResponse(locked=True, day=day)
|
||||||
@@ -327,8 +261,9 @@ async def unlock_day(
|
|||||||
) -> LockResponse:
|
) -> LockResponse:
|
||||||
async with db.acquire() as conn:
|
async with db.acquire() as conn:
|
||||||
await _check_site(conn, site_id)
|
await _check_site(conn, site_id)
|
||||||
await conn.execute(
|
await fetch_json(
|
||||||
"DELETE FROM ems.audit_day_lock WHERE site_id = $1 AND day_local = $2",
|
conn,
|
||||||
|
"select ems.fn_economics_unlock_day($1::int, $2::date)",
|
||||||
site_id,
|
site_id,
|
||||||
day,
|
day,
|
||||||
)
|
)
|
||||||
@@ -357,61 +292,29 @@ async def get_monthly_chart(
|
|||||||
|
|
||||||
async with db.acquire() as conn:
|
async with db.acquire() as conn:
|
||||||
await _check_site(conn, site_id)
|
await _check_site(conn, site_id)
|
||||||
|
arr = await fetch_json(
|
||||||
rows = await conn.fetch(
|
conn,
|
||||||
"""
|
"select ems.fn_economics_monthly_chart($1::int, $2::date, $3::date)",
|
||||||
SELECT day_local, total_balance_czk, net_cost_czk,
|
|
||||||
green_bonus_czk, grid_import_cashflow_czk, grid_export_revenue_czk
|
|
||||||
FROM ems.vw_economics_daily
|
|
||||||
WHERE site_id = $1
|
|
||||||
AND day_local >= $2
|
|
||||||
AND day_local < $3
|
|
||||||
ORDER BY day_local
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
month_start,
|
month_start,
|
||||||
month_end,
|
month_end,
|
||||||
)
|
)
|
||||||
|
if not isinstance(arr, list):
|
||||||
lock_rows = await conn.fetch(
|
arr = json.loads(arr) if isinstance(arr, str) else []
|
||||||
"""
|
|
||||||
SELECT day_local, total_balance_czk, net_cost_czk,
|
|
||||||
green_bonus_czk, grid_import_cashflow_czk, grid_export_revenue_czk
|
|
||||||
FROM ems.audit_day_lock
|
|
||||||
WHERE site_id = $1
|
|
||||||
AND day_local >= $2
|
|
||||||
AND day_local < $3
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
month_start,
|
|
||||||
month_end,
|
|
||||||
)
|
|
||||||
locks = {r["day_local"]: r for r in lock_rows}
|
|
||||||
|
|
||||||
points: list[ChartDayPoint] = []
|
points: list[ChartDayPoint] = []
|
||||||
cum_balance = 0.0
|
for r in arr:
|
||||||
cum_grid = 0.0
|
if not isinstance(r, dict):
|
||||||
for r in rows:
|
continue
|
||||||
d = r["day_local"]
|
|
||||||
src = locks.get(d, r)
|
|
||||||
balance = _num(src["total_balance_czk"])
|
|
||||||
grid_balance = -_num(src["net_cost_czk"])
|
|
||||||
green_bonus = _num(src["green_bonus_czk"])
|
|
||||||
import_cost = _num(_safe_get(src, "grid_import_cashflow_czk", r["grid_import_cashflow_czk"]))
|
|
||||||
export_revenue = _num(_safe_get(src, "grid_export_revenue_czk", r["grid_export_revenue_czk"]))
|
|
||||||
cum_balance += balance
|
|
||||||
cum_grid += grid_balance
|
|
||||||
points.append(
|
points.append(
|
||||||
ChartDayPoint(
|
ChartDayPoint(
|
||||||
day=d,
|
day=_parse_day(r.get("day")),
|
||||||
daily_balance_czk=round(balance, 2),
|
daily_balance_czk=float(r.get("daily_balance_czk") or 0),
|
||||||
daily_grid_balance_czk=round(grid_balance, 2),
|
daily_grid_balance_czk=float(r.get("daily_grid_balance_czk") or 0),
|
||||||
daily_green_bonus_czk=round(green_bonus, 2),
|
daily_green_bonus_czk=float(r.get("daily_green_bonus_czk") or 0),
|
||||||
daily_import_cost_czk=round(import_cost, 2),
|
daily_import_cost_czk=float(r.get("daily_import_cost_czk") or 0),
|
||||||
daily_export_revenue_czk=round(export_revenue, 2),
|
daily_export_revenue_czk=float(r.get("daily_export_revenue_czk") or 0),
|
||||||
cumulative_balance_czk=round(cum_balance, 2),
|
cumulative_balance_czk=float(r.get("cumulative_balance_czk") or 0),
|
||||||
cumulative_grid_balance_czk=round(cum_grid, 2),
|
cumulative_grid_balance_czk=float(r.get("cumulative_grid_balance_czk") or 0),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return points
|
return points
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import asyncpg
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.db_json import fetch_json
|
||||||
from app.deps import get_pg_pool
|
from app.deps import get_pg_pool
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -16,6 +18,7 @@ router = APIRouter(
|
|||||||
tags=["energy-flows"],
|
tags=["energy-flows"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DailyEnergyFlows(BaseModel):
|
class DailyEnergyFlows(BaseModel):
|
||||||
day: date
|
day: date
|
||||||
interval_count: int
|
interval_count: int
|
||||||
@@ -65,12 +68,6 @@ def _num(val: Any) -> float:
|
|||||||
return float(val)
|
return float(val)
|
||||||
|
|
||||||
|
|
||||||
def _wh_to_kwh(val: Any) -> float | None:
|
|
||||||
if val is None:
|
|
||||||
return None
|
|
||||||
return round(float(val) / 1000.0, 4)
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
|
async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
|
||||||
ok = await conn.fetchval(
|
ok = await conn.fetchval(
|
||||||
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||||
@@ -79,28 +76,16 @@ async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
|
|||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
|
|
||||||
def _row_to_daily(r: Any) -> DailyEnergyFlows:
|
def _parse_day(val: Any) -> date:
|
||||||
return DailyEnergyFlows(
|
from datetime import datetime as _dt
|
||||||
day=r["day_local"],
|
|
||||||
interval_count=int(r["interval_count"] or 0),
|
if isinstance(val, _dt):
|
||||||
pv_production_kwh=_num(r["pv_production_kwh"]),
|
return val.date()
|
||||||
grid_import_kwh=_num(r["grid_import_kwh"]),
|
if isinstance(val, date):
|
||||||
grid_export_kwh=_num(r["grid_export_kwh"]),
|
return val
|
||||||
batt_charge_kwh=_num(r["batt_charge_kwh"]),
|
if isinstance(val, str):
|
||||||
batt_discharge_kwh=_num(r["batt_discharge_kwh"]),
|
return date.fromisoformat(val[:10])
|
||||||
load_kwh=_num(r["load_kwh"]),
|
raise ValueError(val)
|
||||||
pv_to_load_kwh=_num(r["pv_to_load_kwh"]),
|
|
||||||
pv_to_batt_kwh=_num(r["pv_to_batt_kwh"]),
|
|
||||||
pv_to_grid_kwh=_num(r["pv_to_grid_kwh"]),
|
|
||||||
batt_to_load_kwh=_num(r["batt_to_load_kwh"]),
|
|
||||||
batt_to_grid_kwh=_num(r["batt_to_grid_kwh"]),
|
|
||||||
grid_to_load_kwh=_num(r["grid_to_load_kwh"]),
|
|
||||||
grid_to_batt_kwh=_num(r["grid_to_batt_kwh"]),
|
|
||||||
grid_import_cashflow_czk=_num(r["grid_import_cashflow_czk"]),
|
|
||||||
grid_export_revenue_czk=_num(r["grid_export_revenue_czk"]),
|
|
||||||
grid_to_load_cost_czk=_num(r["grid_to_load_cost_czk"]),
|
|
||||||
grid_to_batt_cost_czk=_num(r["grid_to_batt_cost_czk"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/daily", response_model=DailyEnergyFlowsResponse)
|
@router.get("/daily", response_model=DailyEnergyFlowsResponse)
|
||||||
@@ -125,84 +110,44 @@ async def get_energy_flows_daily(
|
|||||||
|
|
||||||
async with db.acquire() as conn:
|
async with db.acquire() as conn:
|
||||||
await _check_site(conn, site_id)
|
await _check_site(conn, site_id)
|
||||||
rows = await conn.fetch(
|
raw = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT
|
"select ems.fn_energy_flows_daily_month($1::int, $2::date, $3::date)",
|
||||||
(date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date
|
|
||||||
AS day_local,
|
|
||||||
COUNT(*)::int AS interval_count,
|
|
||||||
ROUND(SUM(COALESCE(ai.actual_pv_production_wh, 0)) / 1000, 3)
|
|
||||||
AS pv_production_kwh,
|
|
||||||
ROUND(SUM(COALESCE(ai.actual_grid_import_wh, 0)) / 1000, 3)
|
|
||||||
AS grid_import_kwh,
|
|
||||||
ROUND(SUM(COALESCE(ai.actual_grid_export_wh, 0)) / 1000, 3)
|
|
||||||
AS grid_export_kwh,
|
|
||||||
ROUND(SUM(COALESCE(ai.actual_batt_charge_wh, 0)) / 1000, 3)
|
|
||||||
AS batt_charge_kwh,
|
|
||||||
ROUND(SUM(COALESCE(ai.actual_batt_discharge_wh, 0)) / 1000, 3)
|
|
||||||
AS batt_discharge_kwh,
|
|
||||||
ROUND(SUM(COALESCE(ai.actual_load_consumption_wh, 0)) / 1000, 3)
|
|
||||||
AS load_kwh,
|
|
||||||
ROUND(SUM(COALESCE(ai.flow_pv_to_load_wh, 0)) / 1000, 3)
|
|
||||||
AS pv_to_load_kwh,
|
|
||||||
ROUND(SUM(COALESCE(ai.flow_pv_to_batt_wh, 0)) / 1000, 3)
|
|
||||||
AS pv_to_batt_kwh,
|
|
||||||
ROUND(SUM(COALESCE(ai.flow_pv_to_grid_wh, 0)) / 1000, 3)
|
|
||||||
AS pv_to_grid_kwh,
|
|
||||||
ROUND(SUM(COALESCE(ai.flow_batt_to_load_wh, 0)) / 1000, 3)
|
|
||||||
AS batt_to_load_kwh,
|
|
||||||
ROUND(SUM(COALESCE(ai.flow_batt_to_grid_wh, 0)) / 1000, 3)
|
|
||||||
AS batt_to_grid_kwh,
|
|
||||||
ROUND(SUM(COALESCE(ai.flow_grid_to_load_wh, 0)) / 1000, 3)
|
|
||||||
AS grid_to_load_kwh,
|
|
||||||
ROUND(SUM(COALESCE(ai.flow_grid_to_batt_wh, 0)) / 1000, 3)
|
|
||||||
AS grid_to_batt_kwh,
|
|
||||||
ROUND(
|
|
||||||
SUM(
|
|
||||||
COALESCE(ai.actual_grid_import_wh, 0) / 1000.0
|
|
||||||
* COALESCE(ep.effective_buy_price_czk_kwh, 0)
|
|
||||||
),
|
|
||||||
2
|
|
||||||
) AS grid_import_cashflow_czk,
|
|
||||||
ROUND(
|
|
||||||
SUM(
|
|
||||||
COALESCE(ai.actual_grid_export_wh, 0) / 1000.0
|
|
||||||
* COALESCE(ep.effective_sell_price_czk_kwh, 0)
|
|
||||||
),
|
|
||||||
2
|
|
||||||
) AS grid_export_revenue_czk,
|
|
||||||
ROUND(
|
|
||||||
SUM(
|
|
||||||
COALESCE(ai.flow_grid_to_load_wh, 0) / 1000.0
|
|
||||||
* COALESCE(ep.effective_buy_price_czk_kwh, 0)
|
|
||||||
),
|
|
||||||
2
|
|
||||||
) AS grid_to_load_cost_czk,
|
|
||||||
ROUND(
|
|
||||||
SUM(
|
|
||||||
COALESCE(ai.flow_grid_to_batt_wh, 0) / 1000.0
|
|
||||||
* COALESCE(ep.effective_buy_price_czk_kwh, 0)
|
|
||||||
),
|
|
||||||
2
|
|
||||||
) AS grid_to_batt_cost_czk
|
|
||||||
FROM ems.audit_interval ai
|
|
||||||
LEFT JOIN ems.vw_site_effective_price ep
|
|
||||||
ON ep.site_id = ai.site_id
|
|
||||||
AND ep.interval_start = ai.interval_start
|
|
||||||
WHERE ai.site_id = $1
|
|
||||||
AND (date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date
|
|
||||||
>= $2
|
|
||||||
AND (date_trunc('day', ai.interval_start AT TIME ZONE 'Europe/Prague'))::date
|
|
||||||
< $3
|
|
||||||
GROUP BY 1
|
|
||||||
ORDER BY 1
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
month_start,
|
month_start,
|
||||||
month_end,
|
month_end,
|
||||||
)
|
)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
return DailyEnergyFlowsResponse(days=[_row_to_daily(r) for r in rows])
|
raw = json.loads(raw)
|
||||||
|
rows = raw.get("days") or []
|
||||||
|
days: list[DailyEnergyFlows] = []
|
||||||
|
for r in rows:
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
days.append(
|
||||||
|
DailyEnergyFlows(
|
||||||
|
day=_parse_day(r.get("day")),
|
||||||
|
interval_count=int(r.get("interval_count") or 0),
|
||||||
|
pv_production_kwh=_num(r.get("pv_production_kwh")),
|
||||||
|
grid_import_kwh=_num(r.get("grid_import_kwh")),
|
||||||
|
grid_export_kwh=_num(r.get("grid_export_kwh")),
|
||||||
|
batt_charge_kwh=_num(r.get("batt_charge_kwh")),
|
||||||
|
batt_discharge_kwh=_num(r.get("batt_discharge_kwh")),
|
||||||
|
load_kwh=_num(r.get("load_kwh")),
|
||||||
|
pv_to_load_kwh=_num(r.get("pv_to_load_kwh")),
|
||||||
|
pv_to_batt_kwh=_num(r.get("pv_to_batt_kwh")),
|
||||||
|
pv_to_grid_kwh=_num(r.get("pv_to_grid_kwh")),
|
||||||
|
batt_to_load_kwh=_num(r.get("batt_to_load_kwh")),
|
||||||
|
batt_to_grid_kwh=_num(r.get("batt_to_grid_kwh")),
|
||||||
|
grid_to_load_kwh=_num(r.get("grid_to_load_kwh")),
|
||||||
|
grid_to_batt_kwh=_num(r.get("grid_to_batt_kwh")),
|
||||||
|
grid_import_cashflow_czk=_num(r.get("grid_import_cashflow_czk")),
|
||||||
|
grid_export_revenue_czk=_num(r.get("grid_export_revenue_czk")),
|
||||||
|
grid_to_load_cost_czk=_num(r.get("grid_to_load_cost_czk")),
|
||||||
|
grid_to_batt_cost_czk=_num(r.get("grid_to_batt_cost_czk")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return DailyEnergyFlowsResponse(days=days)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/daily/{day}/intervals", response_model=list[IntervalEnergyFlows])
|
@router.get("/daily/{day}/intervals", response_model=list[IntervalEnergyFlows])
|
||||||
@@ -213,48 +158,35 @@ async def get_energy_flows_intervals(
|
|||||||
) -> list[IntervalEnergyFlows]:
|
) -> list[IntervalEnergyFlows]:
|
||||||
async with db.acquire() as conn:
|
async with db.acquire() as conn:
|
||||||
await _check_site(conn, site_id)
|
await _check_site(conn, site_id)
|
||||||
rows = await conn.fetch(
|
rows = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT
|
"select ems.fn_energy_flows_intervals_day($1::int, $2::date)",
|
||||||
interval_start,
|
|
||||||
actual_pv_production_wh,
|
|
||||||
actual_grid_import_wh,
|
|
||||||
actual_grid_export_wh,
|
|
||||||
actual_batt_charge_wh,
|
|
||||||
actual_batt_discharge_wh,
|
|
||||||
actual_load_consumption_wh,
|
|
||||||
flow_pv_to_load_wh,
|
|
||||||
flow_pv_to_batt_wh,
|
|
||||||
flow_pv_to_grid_wh,
|
|
||||||
flow_batt_to_load_wh,
|
|
||||||
flow_batt_to_grid_wh,
|
|
||||||
flow_grid_to_load_wh,
|
|
||||||
flow_grid_to_batt_wh
|
|
||||||
FROM ems.audit_interval
|
|
||||||
WHERE site_id = $1
|
|
||||||
AND (date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague'))::date = $2
|
|
||||||
ORDER BY interval_start
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
day,
|
day,
|
||||||
)
|
)
|
||||||
|
if not isinstance(rows, list):
|
||||||
return [
|
rows = json.loads(rows) if isinstance(rows, str) else []
|
||||||
IntervalEnergyFlows(
|
out: list[IntervalEnergyFlows] = []
|
||||||
interval_start=r["interval_start"].isoformat(),
|
for r in rows:
|
||||||
pv_production_kwh=_wh_to_kwh(r["actual_pv_production_wh"]),
|
if not isinstance(r, dict):
|
||||||
grid_import_kwh=_wh_to_kwh(r["actual_grid_import_wh"]),
|
continue
|
||||||
grid_export_kwh=_wh_to_kwh(r["actual_grid_export_wh"]),
|
ist = r.get("interval_start")
|
||||||
batt_charge_kwh=_wh_to_kwh(r["actual_batt_charge_wh"]),
|
out.append(
|
||||||
batt_discharge_kwh=_wh_to_kwh(r["actual_batt_discharge_wh"]),
|
IntervalEnergyFlows(
|
||||||
load_kwh=_wh_to_kwh(r["actual_load_consumption_wh"]),
|
interval_start=ist if isinstance(ist, str) else str(ist),
|
||||||
pv_to_load_kwh=_wh_to_kwh(r["flow_pv_to_load_wh"]),
|
pv_production_kwh=r.get("pv_production_kwh"),
|
||||||
pv_to_batt_kwh=_wh_to_kwh(r["flow_pv_to_batt_wh"]),
|
grid_import_kwh=r.get("grid_import_kwh"),
|
||||||
pv_to_grid_kwh=_wh_to_kwh(r["flow_pv_to_grid_wh"]),
|
grid_export_kwh=r.get("grid_export_kwh"),
|
||||||
batt_to_load_kwh=_wh_to_kwh(r["flow_batt_to_load_wh"]),
|
batt_charge_kwh=r.get("batt_charge_kwh"),
|
||||||
batt_to_grid_kwh=_wh_to_kwh(r["flow_batt_to_grid_wh"]),
|
batt_discharge_kwh=r.get("batt_discharge_kwh"),
|
||||||
grid_to_load_kwh=_wh_to_kwh(r["flow_grid_to_load_wh"]),
|
load_kwh=r.get("load_kwh"),
|
||||||
grid_to_batt_kwh=_wh_to_kwh(r["flow_grid_to_batt_wh"]),
|
pv_to_load_kwh=r.get("pv_to_load_kwh"),
|
||||||
|
pv_to_batt_kwh=r.get("pv_to_batt_kwh"),
|
||||||
|
pv_to_grid_kwh=r.get("pv_to_grid_kwh"),
|
||||||
|
batt_to_load_kwh=r.get("batt_to_load_kwh"),
|
||||||
|
batt_to_grid_kwh=r.get("batt_to_grid_kwh"),
|
||||||
|
grid_to_load_kwh=r.get("grid_to_load_kwh"),
|
||||||
|
grid_to_batt_kwh=r.get("grid_to_batt_kwh"),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for r in rows
|
return out
|
||||||
]
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ import asyncpg
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
from app.db_json import record_to_dict
|
from app.db_json import fetch_json
|
||||||
from app.deps import get_pg_pool
|
from app.deps import get_pg_pool
|
||||||
|
|
||||||
router = APIRouter(prefix="/sites/{site_id}/ev", tags=["ev"])
|
router = APIRouter(prefix="/sites/{site_id}/ev", tags=["ev"])
|
||||||
@@ -38,30 +39,19 @@ async def get_active_ev_sessions(
|
|||||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
site_ok = await conn.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||||
|
)
|
||||||
if not site_ok:
|
if not site_ok:
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
rows = await conn.fetch(
|
rows = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT es.id, es.charger_id, es.vehicle_id,
|
"select ems.fn_ev_sessions_active($1::int)",
|
||||||
es.session_start, es.energy_delivered_wh,
|
|
||||||
es.target_soc_pct, es.target_deadline,
|
|
||||||
av.make, av.model, av.battery_capacity_kwh,
|
|
||||||
av.default_target_soc_pct, av.default_deadline_hour,
|
|
||||||
ac.code AS charger_code,
|
|
||||||
COALESCE(
|
|
||||||
NULLIF(TRIM(CONCAT_WS(' ', ac.manufacturer, ac.model)), ''),
|
|
||||||
ac.code
|
|
||||||
) AS charger_name
|
|
||||||
FROM ems.ev_session es
|
|
||||||
LEFT JOIN ems.asset_vehicle av ON av.id = es.vehicle_id
|
|
||||||
JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id
|
|
||||||
WHERE es.site_id = $1 AND es.session_end IS NULL
|
|
||||||
ORDER BY es.session_start DESC
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
return [record_to_dict(r) for r in rows]
|
if not isinstance(rows, list):
|
||||||
|
rows = json.loads(rows) if isinstance(rows, str) else []
|
||||||
|
return [r for r in rows if isinstance(r, dict)]
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/sessions/{session_id}", response_model=EvSessionPatchResponse)
|
@router.patch("/sessions/{session_id}", response_model=EvSessionPatchResponse)
|
||||||
@@ -72,25 +62,25 @@ async def patch_ev_session(
|
|||||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
) -> EvSessionPatchResponse:
|
) -> EvSessionPatchResponse:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
site_ok = await conn.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||||
|
)
|
||||||
if not site_ok:
|
if not site_ok:
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
row = await conn.fetchrow(
|
patch = body.model_dump(exclude_unset=True)
|
||||||
"""
|
raw = await fetch_json(
|
||||||
UPDATE ems.ev_session
|
conn,
|
||||||
SET target_soc_pct = $1, target_deadline = $2
|
"select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)",
|
||||||
WHERE id = $3 AND site_id = $4
|
|
||||||
RETURNING id
|
|
||||||
""",
|
|
||||||
body.target_soc_pct,
|
|
||||||
body.target_deadline,
|
|
||||||
session_id,
|
|
||||||
site_id,
|
site_id,
|
||||||
|
session_id,
|
||||||
|
json.dumps(patch),
|
||||||
)
|
)
|
||||||
if row is None:
|
if not isinstance(raw, dict):
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raw = json.loads(raw)
|
||||||
return EvSessionPatchResponse(success=True, session_id=int(row["id"]))
|
if not raw.get("success"):
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
return EvSessionPatchResponse(success=True, session_id=int(raw["session_id"]))
|
||||||
|
|
||||||
|
|
||||||
class ArrivalHourItem(BaseModel):
|
class ArrivalHourItem(BaseModel):
|
||||||
@@ -114,65 +104,48 @@ async def get_ev_arrival_prediction(
|
|||||||
site_id: int,
|
site_id: int,
|
||||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
) -> EvArrivalPredictionResponse:
|
) -> EvArrivalPredictionResponse:
|
||||||
"""Top hodiny příjezdu z ems.fn_ev_expected_arrival; při <5 session celkem insufficient_data."""
|
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
raw = await fetch_json(
|
||||||
if not site_ok:
|
conn,
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
"select ems.fn_ev_arrival_prediction_bundle($1::int)",
|
||||||
|
|
||||||
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,
|
site_id,
|
||||||
)
|
)
|
||||||
if tomorrow is None:
|
if not isinstance(raw, dict):
|
||||||
raise HTTPException(status_code=500, detail="Site date resolution failed")
|
raw = json.loads(raw)
|
||||||
tomorrow_d: date = tomorrow
|
if raw.get("error") == "site_not_found":
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
chargers_rows = await conn.fetch(
|
chargers: dict[str, ChargerTomorrowArrival] = {}
|
||||||
"SELECT id, code FROM ems.asset_ev_charger WHERE site_id = $1 ORDER BY id",
|
ch_raw = raw.get("chargers") or {}
|
||||||
site_id,
|
if isinstance(ch_raw, dict):
|
||||||
)
|
for code, v in ch_raw.items():
|
||||||
|
if not isinstance(v, dict):
|
||||||
chargers: dict[str, ChargerTomorrowArrival] = {}
|
continue
|
||||||
for ch in chargers_rows:
|
tlist = v.get("tomorrow") or []
|
||||||
code = str(ch["code"])
|
items: list[ArrivalHourItem] = []
|
||||||
preds = await conn.fetch(
|
if isinstance(tlist, list):
|
||||||
"SELECT * FROM ems.fn_ev_expected_arrival($1, $2, $3::date)",
|
for it in tlist:
|
||||||
site_id,
|
if not isinstance(it, dict):
|
||||||
ch["id"],
|
continue
|
||||||
tomorrow_d,
|
items.append(
|
||||||
)
|
ArrivalHourItem(
|
||||||
chargers[code] = ChargerTomorrowArrival(
|
hour=int(it.get("hour") or 0),
|
||||||
tomorrow=[
|
confidence_pct=int(it.get("confidence_pct") or 0),
|
||||||
ArrivalHourItem(
|
samples=int(it.get("samples") or 0),
|
||||||
hour=int(r["expected_hour"]),
|
)
|
||||||
confidence_pct=int(r["confidence_pct"]),
|
|
||||||
samples=int(r["sample_count"]),
|
|
||||||
)
|
)
|
||||||
for r in preds
|
chargers[str(code)] = ChargerTomorrowArrival(tomorrow=items)
|
||||||
]
|
|
||||||
)
|
td = raw.get("tomorrow_date")
|
||||||
|
if isinstance(td, date):
|
||||||
|
td_s = td.isoformat()
|
||||||
|
elif isinstance(td, datetime):
|
||||||
|
td_s = td.date().isoformat()
|
||||||
|
else:
|
||||||
|
td_s = str(td or "")
|
||||||
|
|
||||||
return EvArrivalPredictionResponse(
|
return EvArrivalPredictionResponse(
|
||||||
insufficient_data=insufficient,
|
insufficient_data=bool(raw.get("insufficient_data")),
|
||||||
tomorrow_date=tomorrow_d.isoformat(),
|
tomorrow_date=td_s,
|
||||||
chargers=chargers,
|
chargers=chargers,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
@@ -10,7 +11,7 @@ import asyncpg
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.db_json import record_to_dict
|
from app.db_json import fetch_json
|
||||||
from app.deps import get_pg_pool
|
from app.deps import get_pg_pool
|
||||||
from app.notifications_logic import (
|
from app.notifications_logic import (
|
||||||
EvSessionRow,
|
EvSessionRow,
|
||||||
@@ -47,6 +48,16 @@ def _iso_utc(dt: datetime | None) -> str | None:
|
|||||||
return dt.astimezone(timezone.utc).isoformat()
|
return dt.astimezone(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(val: Any) -> datetime | None:
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, datetime):
|
||||||
|
return val
|
||||||
|
if isinstance(val, str):
|
||||||
|
return datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _age_seconds(at: datetime | None) -> int | None:
|
def _age_seconds(at: datetime | None) -> int | None:
|
||||||
if at is None:
|
if at is None:
|
||||||
return None
|
return None
|
||||||
@@ -81,174 +92,105 @@ async def get_site_status_full(
|
|||||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
site = await conn.fetchrow(
|
bundle = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT id, code, name, timezone
|
"select ems.fn_site_full_status($1::int)",
|
||||||
FROM ems.site
|
|
||||||
WHERE id = $1
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
if site is None:
|
if not isinstance(bundle, dict):
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
bundle = json.loads(bundle)
|
||||||
|
if bundle.get("error") == "not_found":
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
tz = site["timezone"] or "Europe/Prague"
|
site = bundle.get("site") or {}
|
||||||
|
mode_row = bundle.get("operating_mode") or {}
|
||||||
|
hb_row = bundle.get("heartbeat") or {}
|
||||||
|
inv_row = bundle.get("inverter_latest")
|
||||||
|
if not isinstance(inv_row, dict):
|
||||||
|
inv_row = None
|
||||||
|
ev_rows = bundle.get("ev_chargers") or []
|
||||||
|
if not isinstance(ev_rows, list):
|
||||||
|
ev_rows = []
|
||||||
|
hp_row = bundle.get("heat_pump_latest")
|
||||||
|
if not isinstance(hp_row, dict):
|
||||||
|
hp_row = None
|
||||||
|
reserve_row = bundle.get("battery_limits") or {}
|
||||||
|
run_row = bundle.get("active_plan")
|
||||||
|
if not isinstance(run_row, dict):
|
||||||
|
run_row = None
|
||||||
|
intervals: list[dict[str, Any]] = []
|
||||||
|
raw_iv = bundle.get("planning_intervals") or []
|
||||||
|
if isinstance(raw_iv, list):
|
||||||
|
intervals = [x for x in raw_iv if isinstance(x, dict)]
|
||||||
|
|
||||||
mode_row = await conn.fetchrow(
|
tomorrow_slots = int(bundle.get("tomorrow_price_slot_count") or 0)
|
||||||
"""
|
|
||||||
SELECT m.mode_code, d.name AS mode_name, m.activated_at, m.activated_by
|
|
||||||
FROM ems.site_operating_mode m
|
|
||||||
JOIN ems.operating_mode_def d ON d.code = m.mode_code
|
|
||||||
WHERE m.site_id = $1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
hb_row = await conn.fetchrow(
|
|
||||||
"""
|
|
||||||
SELECT last_seen, status
|
|
||||||
FROM ems.site_heartbeat
|
|
||||||
WHERE site_id = $1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
inv_row = await conn.fetchrow(
|
|
||||||
"""
|
|
||||||
SELECT pv_power_w, battery_soc_percent, grid_power_w, measured_at
|
|
||||||
FROM ems.vw_latest_inverter
|
|
||||||
WHERE site_id = $1
|
|
||||||
ORDER BY measured_at DESC NULLS LAST
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
ev_rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
SELECT DISTINCT ON (charger_id)
|
|
||||||
charger_code AS code,
|
|
||||||
status,
|
|
||||||
power_w,
|
|
||||||
measured_at
|
|
||||||
FROM ems.vw_latest_ev_charger
|
|
||||||
WHERE site_id = $1
|
|
||||||
ORDER BY charger_id, measured_at DESC NULLS LAST
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
hp_row = await conn.fetchrow(
|
|
||||||
"""
|
|
||||||
SELECT power_w, tuv_tank_temp_c, measured_at
|
|
||||||
FROM ems.vw_latest_heat_pump
|
|
||||||
WHERE site_id = $1
|
|
||||||
ORDER BY measured_at DESC NULLS LAST
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
reserve_row = await conn.fetchrow(
|
|
||||||
"""
|
|
||||||
SELECT MIN(reserve_soc_percent)::float AS reserve_soc,
|
|
||||||
MIN(min_soc_percent)::float AS min_soc
|
|
||||||
FROM ems.asset_battery
|
|
||||||
WHERE site_id = $1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
run_row = await conn.fetchrow(
|
|
||||||
"""
|
|
||||||
SELECT id, created_at
|
|
||||||
FROM ems.planning_run
|
|
||||||
WHERE site_id = $1 AND status = 'active'
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
intervals: list[dict[str, Any]] = []
|
|
||||||
if run_row:
|
|
||||||
int_rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
SELECT interval_start, battery_setpoint_w,
|
|
||||||
load_baseline_w,
|
|
||||||
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
|
|
||||||
pv_a_forecast_solver_w, pv_b_forecast_solver_w
|
|
||||||
FROM ems.planning_interval
|
|
||||||
WHERE run_id = $1
|
|
||||||
ORDER BY interval_start
|
|
||||||
""",
|
|
||||||
run_row["id"],
|
|
||||||
)
|
|
||||||
intervals = [record_to_dict(r) for r in int_rows]
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
tomorrow_slots = int(tomorrow_slots or 0)
|
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
now_utc = datetime.now(timezone.utc)
|
||||||
hb_last = hb_row["last_seen"] if hb_row else None
|
hb_last = hb_row.get("last_seen") if hb_row else None
|
||||||
hb_age = _age_seconds(hb_last)
|
hb_age = _age_seconds(hb_last)
|
||||||
inv_measured = inv_row["measured_at"] if inv_row else None
|
inv_measured = inv_row.get("measured_at") if inv_row else None
|
||||||
inv_age = _age_seconds(inv_measured)
|
inv_age = _age_seconds(inv_measured)
|
||||||
|
|
||||||
next_start, next_bat = _next_plan_interval(intervals, now_utc)
|
next_start, next_bat = _next_plan_interval(intervals, now_utc)
|
||||||
|
|
||||||
ev_list: list[dict[str, Any]] = []
|
ev_list: list[dict[str, Any]] = []
|
||||||
for r in ev_rows:
|
for r in ev_rows:
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
ev_list.append(
|
ev_list.append(
|
||||||
{
|
{
|
||||||
"code": r["code"],
|
"code": r.get("code"),
|
||||||
"status": r["status"],
|
"status": r.get("status"),
|
||||||
"power_w": int(r["power_w"]) if r["power_w"] is not None else None,
|
"power_w": int(r["power_w"]) if r.get("power_w") is not None else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
telemetry: dict[str, Any] = {
|
telemetry: dict[str, Any] = {
|
||||||
"inverter": {
|
"inverter": {
|
||||||
"pv_power_w": int(inv_row["pv_power_w"]) if inv_row and inv_row["pv_power_w"] is not None else None,
|
"pv_power_w": int(inv_row["pv_power_w"])
|
||||||
"battery_soc_pct": float(inv_row["battery_soc_percent"])
|
if inv_row and inv_row.get("pv_power_w") is not None
|
||||||
if inv_row and inv_row["battery_soc_percent"] is not None
|
else None,
|
||||||
|
"battery_soc_pct": float(inv_row["battery_soc_percent"])
|
||||||
|
if inv_row and inv_row.get("battery_soc_percent") is not None
|
||||||
|
else None,
|
||||||
|
"grid_power_w": int(inv_row["grid_power_w"])
|
||||||
|
if inv_row and inv_row.get("grid_power_w") is not None
|
||||||
else None,
|
else None,
|
||||||
"grid_power_w": int(inv_row["grid_power_w"]) if inv_row and inv_row["grid_power_w"] is not None else None,
|
|
||||||
"measured_at": _iso_utc(inv_measured),
|
"measured_at": _iso_utc(inv_measured),
|
||||||
"age_seconds": inv_age,
|
"age_seconds": inv_age,
|
||||||
},
|
},
|
||||||
"ev_chargers": ev_list,
|
"ev_chargers": ev_list,
|
||||||
"heat_pump": {
|
"heat_pump": {
|
||||||
"power_w": int(hp_row["power_w"]) if hp_row and hp_row["power_w"] is not None else None,
|
"power_w": int(hp_row["power_w"]) if hp_row and hp_row.get("power_w") is not None else None,
|
||||||
"tank_temp_c": float(hp_row["tuv_tank_temp_c"])
|
"tank_temp_c": float(hp_row["tuv_tank_temp_c"])
|
||||||
if hp_row and hp_row["tuv_tank_temp_c"] is not None
|
if hp_row and hp_row.get("tuv_tank_temp_c") is not None
|
||||||
else None,
|
else None,
|
||||||
"measured_at": _iso_utc(hp_row["measured_at"]) if hp_row else None,
|
"measured_at": _iso_utc(hp_row.get("measured_at")) if hp_row else None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
has_plan = run_row is not None
|
has_plan = run_row is not None
|
||||||
planning = {
|
planning = {
|
||||||
"has_active_plan": has_plan,
|
"has_active_plan": has_plan,
|
||||||
"plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None,
|
"plan_created_at": _iso_utc(run_row.get("created_at")) if run_row else None,
|
||||||
"next_interval_start": next_start,
|
"next_interval_start": next_start,
|
||||||
"next_battery_setpoint_w": next_bat,
|
"next_battery_setpoint_w": next_bat,
|
||||||
}
|
}
|
||||||
|
|
||||||
mode_code = (mode_row["mode_code"] if mode_row else None) or ""
|
mode_code = (mode_row.get("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
|
reserve_soc = (
|
||||||
min_soc = float(reserve_row["min_soc"]) if reserve_row and reserve_row["min_soc"] is not None else None
|
float(reserve_row["reserve_soc"])
|
||||||
soc = float(inv_row["battery_soc_percent"]) if inv_row and inv_row["battery_soc_percent"] is not None else None
|
if reserve_row and reserve_row.get("reserve_soc") is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
min_soc = (
|
||||||
|
float(reserve_row["min_soc"]) if reserve_row and reserve_row.get("min_soc") is not None else None
|
||||||
|
)
|
||||||
|
soc = (
|
||||||
|
float(inv_row["battery_soc_percent"])
|
||||||
|
if inv_row and inv_row.get("battery_soc_percent") is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
alerts: list[dict[str, str]] = []
|
alerts: list[dict[str, str]] = []
|
||||||
|
|
||||||
@@ -281,17 +223,17 @@ async def get_site_status_full(
|
|||||||
alerts.sort(key=lambda a: (0 if a["level"] == "error" else 1, a["message"]))
|
alerts.sort(key=lambda a: (0 if a["level"] == "error" else 1, a["message"]))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"site": {"id": site["id"], "code": site["code"], "name": site["name"]},
|
"site": {"id": site.get("id"), "code": site.get("code"), "name": site.get("name")},
|
||||||
"operating_mode": {
|
"operating_mode": {
|
||||||
"mode_code": mode_row["mode_code"] if mode_row else None,
|
"mode_code": mode_row.get("mode_code") if mode_row else None,
|
||||||
"mode_name": mode_row["mode_name"] if mode_row else None,
|
"mode_name": mode_row.get("mode_name") if mode_row else None,
|
||||||
"activated_at": _iso_utc(mode_row["activated_at"]) if mode_row else None,
|
"activated_at": _iso_utc(mode_row.get("activated_at")) if mode_row else None,
|
||||||
"activated_by": mode_row["activated_by"] if mode_row else None,
|
"activated_by": mode_row.get("activated_by") if mode_row else None,
|
||||||
},
|
},
|
||||||
"heartbeat": {
|
"heartbeat": {
|
||||||
"last_seen": _iso_utc(hb_last),
|
"last_seen": _iso_utc(hb_last),
|
||||||
"age_seconds": hb_age,
|
"age_seconds": hb_age,
|
||||||
"status": hb_row["status"] if hb_row else None,
|
"status": hb_row.get("status") if hb_row else None,
|
||||||
},
|
},
|
||||||
"telemetry": telemetry,
|
"telemetry": telemetry,
|
||||||
"planning": planning,
|
"planning": planning,
|
||||||
@@ -395,156 +337,39 @@ async def get_site_notifications(
|
|||||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
) -> SiteNotificationsResponse:
|
) -> SiteNotificationsResponse:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
site = await conn.fetchrow(
|
ctx = await fetch_json(
|
||||||
"SELECT id, timezone FROM ems.site WHERE id = $1",
|
conn,
|
||||||
|
"select ems.fn_site_notifications_context($1::int)",
|
||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
if site is None:
|
if not isinstance(ctx, dict):
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
ctx = json.loads(ctx)
|
||||||
tz = site["timezone"] or "Europe/Prague"
|
if ctx.get("error") == "not_found":
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
mode_row = await conn.fetchrow(
|
has_plan = bool(ctx.get("has_plan"))
|
||||||
"""
|
mode_code = (ctx.get("mode_code") or "") or ""
|
||||||
SELECT m.mode_code
|
reserve_soc = _float_or_none(ctx.get("reserve_soc"))
|
||||||
FROM ems.site_operating_mode m
|
min_soc = _float_or_none(ctx.get("min_soc"))
|
||||||
WHERE m.site_id = $1
|
soc = _float_or_none(ctx.get("soc_pct"))
|
||||||
""",
|
inv_age = _age_seconds(_parse_ts(ctx.get("inv_measured_at")))
|
||||||
site_id,
|
hb_age = _age_seconds(_parse_ts(ctx.get("hb_last_seen")))
|
||||||
)
|
tomorrow_slots = int(ctx.get("tomorrow_slots") or 0)
|
||||||
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,
|
|
||||||
MIN(min_soc_percent)::float AS min_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(
|
price_rows = ctx.get("price_slots") or []
|
||||||
"""
|
if not isinstance(price_rows, list):
|
||||||
SELECT interval_start,
|
price_rows = []
|
||||||
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(
|
avg_buy = _float_or_none(ctx.get("avg_buy"))
|
||||||
"""
|
usable_wh = _float_or_none(ctx.get("usable_wh"))
|
||||||
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(
|
ev_rows = ctx.get("ev_sessions") or []
|
||||||
"""
|
if not isinstance(ev_rows, list):
|
||||||
SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float AS usable_wh
|
ev_rows = []
|
||||||
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(
|
neg_rows = ctx.get("neg_windows") or []
|
||||||
"""
|
if not isinstance(neg_rows, list):
|
||||||
SELECT DISTINCT ON (es.id)
|
neg_rows = []
|
||||||
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
|
|
||||||
)
|
|
||||||
min_soc = (
|
|
||||||
float(reserve_row["min_soc"])
|
|
||||||
if reserve_row and reserve_row["min_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(
|
infra = _infrastructure_notification_items(
|
||||||
has_plan=has_plan,
|
has_plan=has_plan,
|
||||||
@@ -559,11 +384,15 @@ async def get_site_notifications(
|
|||||||
|
|
||||||
prices: list[PriceSlot] = []
|
prices: list[PriceSlot] = []
|
||||||
for r in price_rows:
|
for r in price_rows:
|
||||||
buy = _float_or_none(r["effective_buy_price_czk_kwh"])
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
buy = _float_or_none(r.get("effective_buy_price_czk_kwh"))
|
||||||
if buy is None:
|
if buy is None:
|
||||||
continue
|
continue
|
||||||
sell_v = _float_or_none(r["effective_sell_price_czk_kwh"])
|
sell_v = _float_or_none(r.get("effective_sell_price_czk_kwh"))
|
||||||
istart = r["interval_start"]
|
istart = r.get("interval_start")
|
||||||
|
if isinstance(istart, str):
|
||||||
|
istart = datetime.fromisoformat(istart.replace("Z", "+00:00"))
|
||||||
prices.append(
|
prices.append(
|
||||||
PriceSlot(
|
PriceSlot(
|
||||||
interval_start=istart,
|
interval_start=istart,
|
||||||
@@ -572,43 +401,50 @@ async def get_site_notifications(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
battery_kwh = (usable_wh / 1000.0) if usable_wh is not None else None
|
||||||
|
|
||||||
ev_sessions: list[EvSessionRow] = []
|
ev_sessions: list[EvSessionRow] = []
|
||||||
for er in ev_rows:
|
for er in ev_rows:
|
||||||
|
if not isinstance(er, dict):
|
||||||
|
continue
|
||||||
|
ss = er.get("session_start")
|
||||||
|
if isinstance(ss, str):
|
||||||
|
ss = datetime.fromisoformat(ss.replace("Z", "+00:00"))
|
||||||
ev_sessions.append(
|
ev_sessions.append(
|
||||||
EvSessionRow(
|
EvSessionRow(
|
||||||
id=int(er["id"]),
|
id=int(er["id"]),
|
||||||
charger_id=int(er["charger_id"]),
|
charger_id=int(er["charger_id"]),
|
||||||
energy_delivered_wh=float(er["energy_delivered_wh"] or 0),
|
energy_delivered_wh=float(er.get("energy_delivered_wh") or 0),
|
||||||
target_soc_pct=_float_or_none(er["target_soc_pct"]),
|
target_soc_pct=_float_or_none(er.get("target_soc_pct")),
|
||||||
session_start=er["session_start"],
|
session_start=ss,
|
||||||
battery_capacity_kwh=_float_or_none(er["battery_capacity_kwh"]),
|
battery_capacity_kwh=_float_or_none(er.get("battery_capacity_kwh")),
|
||||||
make=er["make"],
|
make=er.get("make"),
|
||||||
model=er["model"],
|
model=er.get("model"),
|
||||||
default_target_soc_pct=_float_or_none(er["default_target_soc_pct"]),
|
default_target_soc_pct=_float_or_none(er.get("default_target_soc_pct")),
|
||||||
charger_code=str(er["charger_code"] or ""),
|
charger_code=str(er.get("charger_code") or ""),
|
||||||
soc_at_connect_pct=_float_or_none(er["soc_at_connect_pct"]),
|
soc_at_connect_pct=_float_or_none(er.get("soc_at_connect_pct")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
neg_windows: list[NegWindowRow] = []
|
neg_windows: list[NegWindowRow] = []
|
||||||
for nr in neg_rows:
|
for nr in neg_rows:
|
||||||
dr = nr["predicted_date"]
|
if not isinstance(nr, dict):
|
||||||
|
continue
|
||||||
|
dr = nr.get("predicted_date")
|
||||||
if isinstance(dr, datetime):
|
if isinstance(dr, datetime):
|
||||||
d_conv = dr.date()
|
d_conv = dr.date()
|
||||||
elif isinstance(dr, date):
|
elif isinstance(dr, date):
|
||||||
d_conv = dr
|
d_conv = dr
|
||||||
|
elif isinstance(dr, str):
|
||||||
|
d_conv = date.fromisoformat(dr[:10])
|
||||||
else:
|
else:
|
||||||
d_conv = date.today()
|
d_conv = date.today()
|
||||||
neg_windows.append(
|
neg_windows.append(
|
||||||
NegWindowRow(
|
NegWindowRow(
|
||||||
predicted_date=d_conv,
|
predicted_date=d_conv,
|
||||||
window_start_hour=int(nr["window_start_hour"]),
|
window_start_hour=int(nr.get("window_start_hour") or 0),
|
||||||
window_end_hour=int(nr["window_end_hour"]),
|
window_end_hour=int(nr.get("window_end_hour") or 0),
|
||||||
probability_pct=int(nr["probability_pct"]),
|
probability_pct=int(nr.get("probability_pct") or 0),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""REST API – aktivní plán a ruční přepočet."""
|
"""REST API – aktivní plán a ruční přepočet."""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
@@ -8,7 +9,7 @@ import asyncpg
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from app.db_json import record_to_dict
|
from app.db_json import fetch_json
|
||||||
from app.deps import get_pg_pool
|
from app.deps import get_pg_pool
|
||||||
from services.control_exporter import export_setpoints
|
from services.control_exporter import export_setpoints
|
||||||
from services.planning_engine import run_plan_api
|
from services.planning_engine import run_plan_api
|
||||||
@@ -46,131 +47,36 @@ class CurrentPlanResponseModel(BaseModel):
|
|||||||
summary: dict[str, Any]
|
summary: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]:
|
|
||||||
total_cost = 0.0
|
|
||||||
total_curtailed_kwh = 0.0
|
|
||||||
charge_slots = 0
|
|
||||||
discharge_slots = 0
|
|
||||||
export_slots = 0
|
|
||||||
for row in intervals:
|
|
||||||
ec = row.get("expected_cost_czk")
|
|
||||||
if ec is not None:
|
|
||||||
total_cost += float(ec)
|
|
||||||
c = row.get("pv_a_curtailed_w") or 0
|
|
||||||
total_curtailed_kwh += int(c) * 0.25 / 1000.0
|
|
||||||
b = row.get("battery_setpoint_w")
|
|
||||||
if b is not None:
|
|
||||||
if int(b) > 0:
|
|
||||||
charge_slots += 1
|
|
||||||
elif int(b) < 0:
|
|
||||||
discharge_slots += 1
|
|
||||||
g = row.get("grid_setpoint_w")
|
|
||||||
if g is not None and int(g) < 0:
|
|
||||||
export_slots += 1
|
|
||||||
return {
|
|
||||||
"total_expected_cost_czk": round(total_cost, 4),
|
|
||||||
"total_pv_curtailed_kwh": round(total_curtailed_kwh, 6),
|
|
||||||
"charge_slots": charge_slots,
|
|
||||||
"discharge_slots": discharge_slots,
|
|
||||||
"export_slots": export_slots,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
@router.get("/current", response_model=CurrentPlanResponseModel)
|
||||||
async def get_current_plan(
|
async def get_current_plan(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
) -> CurrentPlanResponseModel:
|
) -> CurrentPlanResponseModel:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
site_ok = await conn.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||||
|
)
|
||||||
if not site_ok:
|
if not site_ok:
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
run_row = await conn.fetchrow(
|
bundle = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT pr.*
|
"select ems.fn_plan_current_bundle($1::int)",
|
||||||
FROM ems.planning_run pr
|
|
||||||
WHERE pr.site_id = $1 AND pr.status = 'active'
|
|
||||||
ORDER BY pr.created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
if not run_row:
|
if not isinstance(bundle, dict):
|
||||||
raise HTTPException(status_code=404, detail="No active plan")
|
bundle = json.loads(bundle)
|
||||||
|
if bundle.get("error") == "no_active_plan":
|
||||||
|
raise HTTPException(status_code=404, detail="No active plan")
|
||||||
|
|
||||||
run_id = run_row["id"]
|
intervals_raw = bundle.get("intervals") or []
|
||||||
int_rows = await conn.fetch(
|
if not isinstance(intervals_raw, list):
|
||||||
"""
|
intervals_raw = []
|
||||||
WITH fc_slot AS (
|
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
|
||||||
SELECT
|
|
||||||
interval_start,
|
|
||||||
COALESCE(SUM(power_w), 0)::BIGINT AS pv_forecast_total_w
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT ON (fpi.interval_start, fpr.pv_array_id)
|
|
||||||
fpi.interval_start,
|
|
||||||
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 = fpr.pv_array_id AND apa.site_id = fpr.site_id
|
|
||||||
WHERE fpr.site_id = $2
|
|
||||||
AND fpr.status = 'ok'
|
|
||||||
ORDER BY fpi.interval_start, fpr.pv_array_id, fpr.created_at DESC
|
|
||||||
) latest_per_array
|
|
||||||
GROUP BY 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_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(
|
return CurrentPlanResponseModel(
|
||||||
run=record_to_dict(run_row),
|
run=bundle.get("run") or {},
|
||||||
intervals=intervals,
|
intervals=intervals,
|
||||||
summary=summary,
|
summary=bundle.get("summary") or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -181,18 +87,14 @@ async def post_run_plan(
|
|||||||
plan_type: Literal["daily", "rolling"] = Query("rolling", alias="type"),
|
plan_type: Literal["daily", "rolling"] = Query("rolling", alias="type"),
|
||||||
) -> RunPlanResponse:
|
) -> RunPlanResponse:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
|
site_ok = await conn.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||||
|
)
|
||||||
if not site_ok:
|
if not site_ok:
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
days_with_prices = await conn.fetchval(
|
days_with_prices = await conn.fetchval(
|
||||||
"""
|
"select ems.fn_planning_future_price_days()",
|
||||||
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'
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
if (days_with_prices or 0) < 1:
|
if (days_with_prices or 0) < 1:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -204,14 +106,10 @@ async def post_run_plan(
|
|||||||
run_id, solver_duration_ms = await run_plan_api(
|
run_id, solver_duration_ms = await run_plan_api(
|
||||||
site_id, plan_type, conn, triggered_by="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)
|
await export_setpoints(site_id, conn)
|
||||||
row = await conn.fetchrow(
|
row = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT horizon_start, horizon_end
|
"select ems.fn_planning_run_horizon($1::int)",
|
||||||
FROM ems.planning_run
|
|
||||||
WHERE id = $1
|
|
||||||
""",
|
|
||||||
run_id,
|
run_id,
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -224,7 +122,7 @@ async def post_run_plan(
|
|||||||
logger.error("Plan run failed: %s", e, exc_info=True)
|
logger.error("Plan run failed: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||||
|
|
||||||
if row is None:
|
if not isinstance(row, dict) or row.get("horizon_start") is None:
|
||||||
raise HTTPException(status_code=500, detail="Planning run row missing after insert")
|
raise HTTPException(status_code=500, detail="Planning run row missing after insert")
|
||||||
|
|
||||||
return RunPlanResponse(
|
return RunPlanResponse(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ import asyncpg
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.db_json import record_to_dict
|
from app.db_json import fetch_json
|
||||||
from app.deps import get_pg_pool
|
from app.deps import get_pg_pool
|
||||||
|
|
||||||
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"])
|
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"])
|
||||||
@@ -19,39 +20,30 @@ class InverterModbusCurrentCapsBody(BaseModel):
|
|||||||
"""Tvrdý strop proudu pro zápis Deye reg 108/109 (A); NULL ve JSONu = smaž strop v DB."""
|
"""Tvrdý strop proudu pro zápis Deye reg 108/109 (A); NULL ve JSONu = smaž strop v DB."""
|
||||||
|
|
||||||
deye_register_max_charge_a: int | None = Field(
|
deye_register_max_charge_a: int | None = Field(
|
||||||
default=None, ge=0, le=640, description="None při vynechání klíče = nezměnit; explicitní null = smazat strop"
|
default=None,
|
||||||
|
ge=0,
|
||||||
|
le=640,
|
||||||
|
description="None při vynechání klíče = nezměnit; explicitní null = smazat strop",
|
||||||
)
|
)
|
||||||
deye_register_max_discharge_a: int | None = Field(
|
deye_register_max_discharge_a: int | None = Field(
|
||||||
default=None, ge=0, le=640, description="Jako u nabíjení"
|
default=None,
|
||||||
|
ge=0,
|
||||||
|
le=640,
|
||||||
|
description="Jako u nabíjení",
|
||||||
)
|
)
|
||||||
|
|
||||||
_DEYE_KEYS = frozenset(
|
|
||||||
{
|
|
||||||
"deye_last_system_time_sync_at",
|
|
||||||
"deye_last_system_time_sync_minute",
|
|
||||||
"deye_last_tou_inactive_write_prague_date",
|
|
||||||
"deye_tou_inactive_signature",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
def _iso_utc_from_cfg(val: Any) -> str | None:
|
||||||
def _mask_secret_reference(raw: str | None) -> str | None:
|
if val is None:
|
||||||
if raw is None:
|
|
||||||
return None
|
return None
|
||||||
s = str(raw).strip()
|
if isinstance(val, str):
|
||||||
if not s:
|
return val
|
||||||
return None
|
if isinstance(val, datetime):
|
||||||
if len(s) <= 4:
|
dt = val
|
||||||
return "nastaveno"
|
if dt.tzinfo is None:
|
||||||
return f"…{s[-2:]}"
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc).isoformat()
|
||||||
|
return str(val)
|
||||||
def _iso_utc(dt: datetime | None) -> str | None:
|
|
||||||
if dt is None:
|
|
||||||
return None
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
|
||||||
return dt.astimezone(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/configuration")
|
@router.get("/configuration")
|
||||||
@@ -60,204 +52,29 @@ async def get_site_configuration(
|
|||||||
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
site_row = await conn.fetchrow(
|
raw = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at
|
"select ems.fn_site_configuration($1::int)",
|
||||||
FROM ems.site
|
|
||||||
WHERE id = $1
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
if site_row is None:
|
if raw is None:
|
||||||
raise HTTPException(status_code=404, detail="Site not found")
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
grid_row = await conn.fetchrow(
|
raw = json.loads(raw)
|
||||||
"SELECT * FROM ems.site_grid_connection WHERE site_id = $1",
|
op = raw.get("operational")
|
||||||
site_id,
|
if isinstance(op, dict):
|
||||||
)
|
op = dict(op)
|
||||||
market_row = await conn.fetchrow(
|
op["heartbeat_last_seen"] = _iso_utc_from_cfg(op.get("heartbeat_last_seen"))
|
||||||
"""
|
op["active_plan_created_at"] = _iso_utc_from_cfg(op.get("active_plan_created_at"))
|
||||||
SELECT *
|
raw["operational"] = op
|
||||||
FROM ems.site_market_config
|
lat = raw.get("site", {}).get("latitude") if isinstance(raw.get("site"), dict) else None
|
||||||
WHERE site_id = $1
|
lon = raw.get("site", {}).get("longitude") if isinstance(raw.get("site"), dict) else None
|
||||||
AND valid_from <= now()
|
if isinstance(raw.get("site"), dict):
|
||||||
AND (valid_to IS NULL OR valid_to > now())
|
site = dict(raw["site"])
|
||||||
ORDER BY valid_from DESC
|
site["latitude"] = float(lat) if lat is not None else None
|
||||||
LIMIT 1
|
site["longitude"] = float(lon) if lon is not None else None
|
||||||
""",
|
raw["site"] = site
|
||||||
site_id,
|
return raw
|
||||||
)
|
|
||||||
endpoint_rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
SELECT id, site_id, endpoint_type, host, port, protocol, unit_id,
|
|
||||||
auth_reference, enabled, notes
|
|
||||||
FROM ems.site_endpoint
|
|
||||||
WHERE site_id = $1
|
|
||||||
ORDER BY id
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
endpoints: list[dict[str, Any]] = []
|
|
||||||
for er in endpoint_rows:
|
|
||||||
d = record_to_dict(er)
|
|
||||||
d["auth_reference"] = _mask_secret_reference(er["auth_reference"])
|
|
||||||
endpoints.append(d)
|
|
||||||
|
|
||||||
inv_rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
SELECT ai.*,
|
|
||||||
(SELECT ep.host || CASE
|
|
||||||
WHEN ep.port IS NOT NULL THEN ':' || ep.port::text
|
|
||||||
ELSE ''
|
|
||||||
END
|
|
||||||
FROM ems.site_endpoint ep
|
|
||||||
WHERE ep.id = ai.endpoint_id) AS endpoint_connection
|
|
||||||
FROM ems.asset_inverter ai
|
|
||||||
WHERE ai.site_id = $1
|
|
||||||
ORDER BY ai.id
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
inverters: list[dict[str, Any]] = []
|
|
||||||
for ir in inv_rows:
|
|
||||||
full = record_to_dict(ir)
|
|
||||||
ep_label = full.pop("endpoint_connection", None)
|
|
||||||
core = {k: v for k, v in full.items() if k not in _DEYE_KEYS}
|
|
||||||
deye_meta = {k: full[k] for k in _DEYE_KEYS if full.get(k) is not None}
|
|
||||||
core["endpoint_connection"] = ep_label
|
|
||||||
core["deye_meta"] = deye_meta if deye_meta else None
|
|
||||||
inverters.append(core)
|
|
||||||
|
|
||||||
bat_rows = await conn.fetch(
|
|
||||||
"SELECT * FROM ems.asset_battery WHERE site_id = $1 ORDER BY id",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
pv_rows = await conn.fetch(
|
|
||||||
"SELECT * FROM ems.asset_pv_array WHERE site_id = $1 ORDER BY id",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
ev_rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
SELECT ec.*,
|
|
||||||
se.host || CASE
|
|
||||||
WHEN se.port IS NOT NULL THEN ':' || se.port::text
|
|
||||||
ELSE ''
|
|
||||||
END AS endpoint_connection
|
|
||||||
FROM ems.asset_ev_charger ec
|
|
||||||
LEFT JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
|
||||||
WHERE ec.site_id = $1
|
|
||||||
ORDER BY ec.id
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
ev_chargers = [record_to_dict(r) for r in ev_rows]
|
|
||||||
|
|
||||||
veh_rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
SELECT id, site_id, code, name, make, model, battery_capacity_kwh,
|
|
||||||
max_charge_power_w, default_charger_id, api_type, api_reference,
|
|
||||||
default_target_soc_pct, default_deadline_hour, active
|
|
||||||
FROM ems.asset_vehicle
|
|
||||||
WHERE site_id = $1
|
|
||||||
ORDER BY code
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
vehicles: list[dict[str, Any]] = []
|
|
||||||
for vr in veh_rows:
|
|
||||||
d = record_to_dict(vr)
|
|
||||||
d["api_reference"] = _mask_secret_reference(vr["api_reference"])
|
|
||||||
vehicles.append(d)
|
|
||||||
|
|
||||||
hp_rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
SELECT hp.*,
|
|
||||||
se.host || CASE
|
|
||||||
WHEN se.port IS NOT NULL THEN ':' || se.port::text
|
|
||||||
ELSE ''
|
|
||||||
END AS endpoint_connection
|
|
||||||
FROM ems.asset_heat_pump hp
|
|
||||||
LEFT JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
|
|
||||||
WHERE hp.site_id = $1
|
|
||||||
ORDER BY hp.id
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
heat_pumps = [record_to_dict(r) for r in hp_rows]
|
|
||||||
|
|
||||||
mode_row = await conn.fetchrow(
|
|
||||||
"""
|
|
||||||
SELECT m.mode_code, m.activated_at, m.activated_by, m.valid_until,
|
|
||||||
m.previous_mode, m.notes,
|
|
||||||
d.name AS mode_name, d.description AS mode_description,
|
|
||||||
d.loxone_mode_value, d.ev_enabled, d.heat_pump_enabled,
|
|
||||||
d.battery_mode, d.grid_mode, d.is_autonomous
|
|
||||||
FROM ems.site_operating_mode m
|
|
||||||
JOIN ems.operating_mode_def d ON d.code = m.mode_code
|
|
||||||
WHERE m.site_id = $1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
override_rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
SELECT id, override_type, value_json, valid_from, valid_to, reason, created_by, created_at
|
|
||||||
FROM ems.site_override
|
|
||||||
WHERE site_id = $1
|
|
||||||
AND valid_from <= now()
|
|
||||||
AND (valid_to IS NULL OR valid_to > now())
|
|
||||||
ORDER BY valid_from DESC
|
|
||||||
LIMIT 50
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
hb_row = await conn.fetchrow(
|
|
||||||
"SELECT last_seen, status FROM ems.site_heartbeat WHERE site_id = $1",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
run_row = await conn.fetchrow(
|
|
||||||
"""
|
|
||||||
SELECT id, created_at
|
|
||||||
FROM ems.planning_run
|
|
||||||
WHERE site_id = $1 AND status = 'active'
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
site = record_to_dict(site_row)
|
|
||||||
lat = site_row["latitude"]
|
|
||||||
lon = site_row["longitude"]
|
|
||||||
site["latitude"] = float(lat) if lat is not None else None
|
|
||||||
site["longitude"] = float(lon) if lon is not None else None
|
|
||||||
|
|
||||||
operating_mode = record_to_dict(mode_row) if mode_row else None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"site": site,
|
|
||||||
"grid_connection": record_to_dict(grid_row) if grid_row else None,
|
|
||||||
"market_config": record_to_dict(market_row) if market_row else None,
|
|
||||||
"market_config_note": (
|
|
||||||
"Zelený bonus za výrobu je u FVE polí (asset_pv_array), ne v obchodní konfiguraci."
|
|
||||||
),
|
|
||||||
"endpoints": endpoints,
|
|
||||||
"inverters": inverters,
|
|
||||||
"batteries": [record_to_dict(r) for r in bat_rows],
|
|
||||||
"pv_arrays": [record_to_dict(r) for r in pv_rows],
|
|
||||||
"ev_chargers": ev_chargers,
|
|
||||||
"vehicles": vehicles,
|
|
||||||
"heat_pumps": heat_pumps,
|
|
||||||
"operating_mode": operating_mode,
|
|
||||||
"active_overrides": [record_to_dict(r) for r in override_rows],
|
|
||||||
"operational": {
|
|
||||||
"heartbeat_last_seen": _iso_utc(hb_row["last_seen"]) if hb_row else None,
|
|
||||||
"heartbeat_status": hb_row["status"] if hb_row else None,
|
|
||||||
"has_active_plan": run_row is not None,
|
|
||||||
"active_plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/inverters/{inverter_id}/modbus-current-caps")
|
@router.patch("/inverters/{inverter_id}/modbus-current-caps")
|
||||||
@@ -269,7 +86,6 @@ async def patch_inverter_modbus_current_caps(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Nastavení `deye_register_max_charge_a` / `deye_register_max_discharge_a` na `ems.asset_inverter`.
|
Nastavení `deye_register_max_charge_a` / `deye_register_max_discharge_a` na `ems.asset_inverter`.
|
||||||
Hodnoty se uplatní v dotazu `_load_inverter_config` jako `COALESCE(strop_A, FLOOR(…z_kW))` pro reg 108/109.
|
|
||||||
"""
|
"""
|
||||||
updates = body.model_dump(exclude_unset=True)
|
updates = body.model_dump(exclude_unset=True)
|
||||||
if not updates:
|
if not updates:
|
||||||
@@ -277,52 +93,29 @@ async def patch_inverter_modbus_current_caps(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Send at least one of: deye_register_max_charge_a, deye_register_max_discharge_a",
|
detail="Send at least one of: deye_register_max_charge_a, deye_register_max_discharge_a",
|
||||||
)
|
)
|
||||||
|
patch: dict[str, Any] = {}
|
||||||
|
if "deye_register_max_charge_a" in updates:
|
||||||
|
patch["deye_register_max_charge_a"] = updates["deye_register_max_charge_a"]
|
||||||
|
if "deye_register_max_discharge_a" in updates:
|
||||||
|
patch["deye_register_max_discharge_a"] = updates["deye_register_max_discharge_a"]
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
owner = await conn.fetchval(
|
raw = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT id FROM ems.asset_inverter
|
"select ems.fn_inverter_modbus_caps_patch($1::int, $2::int, $3::jsonb)",
|
||||||
WHERE id = $1 AND site_id = $2
|
|
||||||
""",
|
|
||||||
inverter_id,
|
|
||||||
site_id,
|
site_id,
|
||||||
|
inverter_id,
|
||||||
|
json.dumps(patch),
|
||||||
)
|
)
|
||||||
if owner is None:
|
if not isinstance(raw, dict):
|
||||||
|
raw = json.loads(raw)
|
||||||
|
if not raw.get("ok"):
|
||||||
|
if raw.get("error") == "not_found":
|
||||||
raise HTTPException(status_code=404, detail="Inverter not found for this site")
|
raise HTTPException(status_code=404, detail="Inverter not found for this site")
|
||||||
|
raise HTTPException(status_code=400, detail=raw.get("error", "patch_failed"))
|
||||||
sets: list[str] = []
|
|
||||||
args: list[Any] = []
|
|
||||||
n = 1
|
|
||||||
if "deye_register_max_charge_a" in updates:
|
|
||||||
sets.append(f"deye_register_max_charge_a = ${n}")
|
|
||||||
args.append(updates["deye_register_max_charge_a"])
|
|
||||||
n += 1
|
|
||||||
if "deye_register_max_discharge_a" in updates:
|
|
||||||
sets.append(f"deye_register_max_discharge_a = ${n}")
|
|
||||||
args.append(updates["deye_register_max_discharge_a"])
|
|
||||||
n += 1
|
|
||||||
|
|
||||||
args.extend([inverter_id, site_id])
|
|
||||||
await conn.execute(
|
|
||||||
f"""
|
|
||||||
UPDATE ems.asset_inverter
|
|
||||||
SET {", ".join(sets)}
|
|
||||||
WHERE id = ${n} AND site_id = ${n + 1}
|
|
||||||
""",
|
|
||||||
*args,
|
|
||||||
)
|
|
||||||
row = await conn.fetchrow(
|
|
||||||
"""
|
|
||||||
SELECT id, code, deye_register_max_charge_a, deye_register_max_discharge_a
|
|
||||||
FROM ems.asset_inverter
|
|
||||||
WHERE id = $1 AND site_id = $2
|
|
||||||
""",
|
|
||||||
inverter_id,
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
assert row is not None
|
|
||||||
return {
|
return {
|
||||||
"inverter_id": int(row["id"]),
|
"inverter_id": int(raw["inverter_id"]),
|
||||||
"code": row["code"],
|
"code": raw["code"],
|
||||||
"deye_register_max_charge_a": row["deye_register_max_charge_a"],
|
"deye_register_max_charge_a": raw.get("deye_register_max_charge_a"),
|
||||||
"deye_register_max_discharge_a": row["deye_register_max_discharge_a"],
|
"deye_register_max_discharge_a": raw.get("deye_register_max_discharge_a"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,51 +3,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def fill_audit_for_completed_intervals(site_id: int, db) -> None:
|
async def fill_audit_for_completed_intervals(site_id: int, db) -> None:
|
||||||
"""
|
"""
|
||||||
Naplní audit_interval pro všechny dokončené 15min intervaly
|
Naplní audit_interval pro dokončené 15min sloty přes ems.fn_fill_audit_for_site_window.
|
||||||
za posledních 6 hodin které ještě nemají záznam.
|
|
||||||
Volá PostgreSQL funkci ems.fn_fill_audit_interval().
|
|
||||||
"""
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
n = await db.fetchval(
|
||||||
last_complete = now.replace(
|
"select ems.fn_fill_audit_for_site_window($1::int, 6)",
|
||||||
minute=(now.minute // 15) * 15, second=0, microsecond=0
|
|
||||||
)
|
|
||||||
|
|
||||||
rows = await db.fetch(
|
|
||||||
"""
|
|
||||||
SELECT gs.slot
|
|
||||||
FROM generate_series(
|
|
||||||
$1::timestamptz - interval '6 hours',
|
|
||||||
$1::timestamptz - interval '15 minutes',
|
|
||||||
interval '15 minutes'
|
|
||||||
) AS gs(slot)
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM ems.audit_interval ai
|
|
||||||
WHERE ai.site_id = $2 AND ai.interval_start = gs.slot
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
last_complete,
|
|
||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
|
if n:
|
||||||
for row in rows:
|
logger.info("[site=%s] Filled %s missing audit intervals", site_id, int(n))
|
||||||
slot = row["slot"]
|
|
||||||
await db.execute(
|
|
||||||
"SELECT ems.fn_fill_audit_interval($1, $2)",
|
|
||||||
site_id,
|
|
||||||
slot,
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
"SELECT ems.fn_fill_baseline_load_forecast_accuracy($1, $2)",
|
|
||||||
site_id,
|
|
||||||
slot,
|
|
||||||
)
|
|
||||||
|
|
||||||
if rows:
|
|
||||||
logger.info("[site=%s] Filled %s missing audit intervals", site_id, len(rows))
|
|
||||||
|
|||||||
3
backend/services/control/__init__.py
Normal file
3
backend/services/control/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Deye / Modbus control export (monolith v exporter_monolith.py – postupný split)."""
|
||||||
|
|
||||||
|
from .exporter_monolith import * # noqa: F401,F403
|
||||||
1925
backend/services/control/exporter_monolith.py
Normal file
1925
backend/services/control/exporter_monolith.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -78,31 +79,26 @@ async def run_fn_set_mode_with_discord(
|
|||||||
notify_level: str | None = None,
|
notify_level: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Zavolá ems.fn_set_mode. Při skutečné změně režimu pošle Discord (pokud je webhook).
|
Zavolá ems.fn_set_mode_with_context. Při skutečné změně režimu pošle Discord (pokud je webhook).
|
||||||
Vrátí aktuální mode_code z DB po volání.
|
Vrátí aktuální mode_code z DB po volání.
|
||||||
"""
|
"""
|
||||||
prev = await conn.fetchval(
|
raw = await conn.fetchval(
|
||||||
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
|
"""
|
||||||
site_id,
|
select ems.fn_set_mode_with_context($1::int, $2::text, $3::text, $4::timestamptz, $5::text)
|
||||||
)
|
""",
|
||||||
await conn.execute(
|
|
||||||
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
|
|
||||||
site_id,
|
site_id,
|
||||||
mode_code,
|
mode_code,
|
||||||
activated_by,
|
activated_by,
|
||||||
valid_until,
|
valid_until,
|
||||||
notes,
|
notes,
|
||||||
)
|
)
|
||||||
new = await conn.fetchval(
|
ctx = raw if isinstance(raw, dict) else json.loads(raw)
|
||||||
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
|
prev = ctx.get("previous_mode")
|
||||||
site_id,
|
new = ctx.get("new_mode")
|
||||||
)
|
|
||||||
if new is None:
|
if new is None:
|
||||||
new = mode_code
|
new = mode_code
|
||||||
|
site_code = ctx.get("site_code")
|
||||||
if prev is not None and prev != new:
|
if prev is not None and prev != new:
|
||||||
site_code = await conn.fetchval(
|
|
||||||
"SELECT code FROM ems.site WHERE id = $1", site_id
|
|
||||||
)
|
|
||||||
await notify_operating_mode_changed(
|
await notify_operating_mode_changed(
|
||||||
site_code or str(site_id),
|
site_code or str(site_id),
|
||||||
str(prev),
|
str(prev),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
# scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0)
|
# scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0)
|
||||||
# scheduler.add_job(run_rolling_replan, 'cron', minute='*/15')
|
# scheduler.add_job(run_rolling_replan, 'cron', minute='*/15')
|
||||||
|
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
@@ -149,107 +150,6 @@ def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]:
|
|||||||
return (loc.weekday() + 1) % 7, loc.hour
|
return (loc.weekday() + 1) % 7, loc.hour
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Slot pre-selection (anti-micro-cycling)
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
def _select_charge_slots(
|
|
||||||
slots: list["PlanningSlot"],
|
|
||||||
battery,
|
|
||||||
current_soc_wh: float,
|
|
||||||
) -> set[int]:
|
|
||||||
"""
|
|
||||||
Pre-select which slots are eligible for battery charging (anti-micro-cycling).
|
|
||||||
|
|
||||||
Logika:
|
|
||||||
1) Sloty s PV-surplus (pv_a + pv_b > load_baseline) jsou vždy zahrnuty –
|
|
||||||
nabíjení z FVE je „zdarma“, solver ho musí mít povolené. Tyto sloty se
|
|
||||||
NEzapočítávají do grid rozpočtu (v dlouhém horizontu by přetekly target).
|
|
||||||
2) Nezávisle na bodu 1 se vybere top-N **grid** slotů seřazených podle
|
|
||||||
`buy_price` ASC tak, aby pokryly `charge_buf × (soc_max − current_soc)`.
|
|
||||||
Tím dostane solver k dispozici přístup k nejlevnějšímu nákupu ze sítě,
|
|
||||||
i když PV v daném slotu spotřebu nepokrývá.
|
|
||||||
3) Per-slot kapacita přírůstku SoC = max_charge_power × η × 15 min (plný
|
|
||||||
výkon, ne limitovaný aktuálním PV-surplus výkonem).
|
|
||||||
|
|
||||||
Vrací množinu indexů povolených pro `bc[t] > 0` v MILP. Prázdná množina = žádné
|
|
||||||
restrikce. `charge_slot_buffer <= 0` v DB ⇒ všechny sloty povoleny.
|
|
||||||
"""
|
|
||||||
charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0)
|
|
||||||
if charge_buf <= 0:
|
|
||||||
return set(range(len(slots)))
|
|
||||||
|
|
||||||
energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh)
|
|
||||||
if energy_to_fill <= 0:
|
|
||||||
return set()
|
|
||||||
|
|
||||||
eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0)
|
|
||||||
max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0)
|
|
||||||
per_slot_full_wh = max_p_w * eta * INTERVAL_H
|
|
||||||
|
|
||||||
selected: set[int] = set()
|
|
||||||
for t, s in enumerate(slots):
|
|
||||||
pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w)
|
|
||||||
if pv_surplus_w > 0:
|
|
||||||
selected.add(t)
|
|
||||||
|
|
||||||
grid_target_wh = energy_to_fill * charge_buf
|
|
||||||
if grid_target_wh <= 0 or per_slot_full_wh <= 0:
|
|
||||||
return selected
|
|
||||||
|
|
||||||
grid_candidates = [
|
|
||||||
(t, float(s.buy_price)) for t, s in enumerate(slots) if t not in selected
|
|
||||||
]
|
|
||||||
grid_candidates.sort(key=lambda x: x[1])
|
|
||||||
|
|
||||||
cumulative = 0.0
|
|
||||||
for t, _price in grid_candidates:
|
|
||||||
if cumulative >= grid_target_wh:
|
|
||||||
break
|
|
||||||
selected.add(t)
|
|
||||||
cumulative += per_slot_full_wh
|
|
||||||
|
|
||||||
return selected
|
|
||||||
|
|
||||||
|
|
||||||
def _select_discharge_export_slots(
|
|
||||||
slots: list["PlanningSlot"],
|
|
||||||
battery,
|
|
||||||
) -> set[int]:
|
|
||||||
"""
|
|
||||||
Pre-select which slots may use battery energy for grid export.
|
|
||||||
Only the Y most expensive sell-price slots are selected,
|
|
||||||
enough to empty the exportable portion of the battery with a buffer.
|
|
||||||
Returns set of slot indices. Empty set = no restriction.
|
|
||||||
"""
|
|
||||||
discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
|
|
||||||
if discharge_buf <= 0:
|
|
||||||
return set(range(len(slots)))
|
|
||||||
|
|
||||||
exportable = float(battery.soc_max_wh) - float(battery.min_soc_wh)
|
|
||||||
if exportable <= 0:
|
|
||||||
return set()
|
|
||||||
|
|
||||||
candidates = [(t, float(s.sell_price)) for t, s in enumerate(slots)]
|
|
||||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
|
||||||
|
|
||||||
energy_per_slot = (
|
|
||||||
float(battery.max_discharge_power_w)
|
|
||||||
* float(battery.discharge_efficiency)
|
|
||||||
* INTERVAL_H
|
|
||||||
)
|
|
||||||
target = exportable * discharge_buf
|
|
||||||
selected: set[int] = set()
|
|
||||||
cumulative = 0.0
|
|
||||||
for t, _price in candidates:
|
|
||||||
if cumulative >= target:
|
|
||||||
break
|
|
||||||
selected.add(t)
|
|
||||||
cumulative += energy_per_slot
|
|
||||||
|
|
||||||
return selected
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Datové třídy (lze nahradit pydantic modely)
|
# Datové třídy (lze nahradit pydantic modely)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -265,6 +165,8 @@ class PlanningSlot:
|
|||||||
ev1_connected: bool
|
ev1_connected: bool
|
||||||
ev2_connected: bool
|
ev2_connected: bool
|
||||||
is_predicted_price: bool = False
|
is_predicted_price: bool = False
|
||||||
|
allow_charge: bool = True
|
||||||
|
allow_discharge_export: bool = True
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -303,49 +205,31 @@ async def compute_correction_factor(
|
|||||||
factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný.
|
factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný.
|
||||||
"""
|
"""
|
||||||
window_start = now - timedelta(hours=window_h)
|
window_start = now - timedelta(hours=window_h)
|
||||||
|
raw = await db.fetchval(
|
||||||
# Skutečná výroba za okno (z telemetrie)
|
"""
|
||||||
actual = await db.fetchval("""
|
select ems.fn_pv_forecast_correction_factor(
|
||||||
SELECT COALESCE(SUM(pv_power_w) * 0.25 / 1000.0, 0) -- kWh
|
$1::int, $2::timestamptz, $3::timestamptz,
|
||||||
FROM ems.telemetry_inverter
|
$4::numeric, $5::numeric
|
||||||
WHERE site_id = $1
|
)
|
||||||
AND measured_at >= $2 AND measured_at < $3
|
""",
|
||||||
""", site_id, window_start, now)
|
site_id,
|
||||||
|
window_start,
|
||||||
# Předpovídaná výroba za stejné okno (z nejnovějšího forecastu který platil tehdy)
|
now,
|
||||||
forecast = await db.fetchval("""
|
CORRECTION_MIN_CLAMP,
|
||||||
SELECT COALESCE(SUM(fpi.power_w) * 0.25 / 1000.0, 0)
|
CORRECTION_MAX_CLAMP,
|
||||||
FROM ems.forecast_pv_interval fpi
|
)
|
||||||
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
|
j = raw if isinstance(raw, dict) else json.loads(raw)
|
||||||
WHERE fpr.site_id = $1
|
factor = float(j.get("correction_factor", 1.0))
|
||||||
AND fpi.interval_start >= $2 AND fpi.interval_start < $3
|
|
||||||
AND fpr.status = 'ok'
|
|
||||||
AND fpr.created_at = (
|
|
||||||
SELECT MAX(fpr2.created_at)
|
|
||||||
FROM ems.forecast_pv_run fpr2
|
|
||||||
WHERE fpr2.site_id = $1 AND fpr2.status = 'ok'
|
|
||||||
AND fpr2.created_at <= $2
|
|
||||||
)
|
|
||||||
""", site_id, window_start, now)
|
|
||||||
|
|
||||||
log_data = {
|
log_data = {
|
||||||
"window_start": window_start,
|
"window_start": j.get("window_start", window_start),
|
||||||
"window_end": now,
|
"window_end": j.get("window_end", now),
|
||||||
"actual_pv_wh": actual * 1000,
|
"actual_pv_wh": j.get("actual_pv_wh"),
|
||||||
"forecast_pv_wh": forecast * 1000,
|
"forecast_pv_wh": j.get("forecast_pv_wh"),
|
||||||
|
"correction_factor": factor,
|
||||||
|
"reason": j.get("reason", "ok"),
|
||||||
}
|
}
|
||||||
|
if j.get("raw_factor") is not None:
|
||||||
# Pokud forecast nebo actual jsou příliš malé (noc, <0.1 kWh) → žádná korekce
|
log_data["raw_factor"] = j["raw_factor"]
|
||||||
if forecast < 0.1 or actual < 0.05:
|
|
||||||
log_data["correction_factor"] = 1.0
|
|
||||||
log_data["reason"] = "insufficient_data"
|
|
||||||
return 1.0, log_data
|
|
||||||
|
|
||||||
raw_factor = actual / forecast
|
|
||||||
factor = max(CORRECTION_MIN_CLAMP, min(CORRECTION_MAX_CLAMP, raw_factor))
|
|
||||||
|
|
||||||
log_data["correction_factor"] = factor
|
|
||||||
log_data["raw_factor"] = raw_factor
|
|
||||||
return factor, log_data
|
return factor, log_data
|
||||||
|
|
||||||
|
|
||||||
@@ -559,10 +443,10 @@ def solve_dispatch(
|
|||||||
if slots[t].is_predicted_price:
|
if slots[t].is_predicted_price:
|
||||||
prob += ge[t] == 0
|
prob += ge[t] == 0
|
||||||
|
|
||||||
# Slot pre-selection: omezení nabíjení a discharge-exportu na vybrané sloty
|
# Slot pre-selection (z DB fn_load_planning_slots_full → allow_*)
|
||||||
if om == "AUTO":
|
if om == "AUTO":
|
||||||
charge_slots = _select_charge_slots(slots, battery, current_soc_wh)
|
charge_slots = {t for t, s in enumerate(slots) if s.allow_charge}
|
||||||
discharge_export_slots = _select_discharge_export_slots(slots, battery)
|
discharge_export_slots = {t for t, s in enumerate(slots) if s.allow_discharge_export}
|
||||||
for t in range(T):
|
for t in range(T):
|
||||||
if t not in charge_slots:
|
if t not in charge_slots:
|
||||||
prob += bc[t] == 0
|
prob += bc[t] == 0
|
||||||
@@ -683,7 +567,10 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
|||||||
|
|
||||||
logger.info(f"[site={site_id}] Daily plan: {horizon_from} → {horizon_to}")
|
logger.info(f"[site={site_id}] Daily plan: {horizon_from} → {horizon_to}")
|
||||||
|
|
||||||
slots = await _load_slots(site_id, horizon_from, horizon_to, db)
|
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
|
||||||
|
await _load_site_context(site_id, db)
|
||||||
|
)
|
||||||
|
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
|
||||||
critical_slots = int(36 / INTERVAL_H)
|
critical_slots = int(36 / INTERVAL_H)
|
||||||
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price)
|
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price)
|
||||||
price_failsafe_active = missing_ote_count > 0
|
price_failsafe_active = missing_ote_count > 0
|
||||||
@@ -694,11 +581,6 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
|||||||
missing_ote_count,
|
missing_ote_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode = (
|
|
||||||
await _load_site_context(site_id, db)
|
|
||||||
)
|
|
||||||
tuv_stats = await _load_tuv_usage_stats(site_id, db)
|
|
||||||
|
|
||||||
results, duration_ms = solve_dispatch(
|
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,
|
tuv_delta_stats=tuv_stats,
|
||||||
@@ -750,17 +632,20 @@ async def run_rolling_replan(
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
replan_from = _current_slot_start(now)
|
replan_from = _current_slot_start(now)
|
||||||
|
|
||||||
active_run = await db.fetchrow("""
|
ar_raw = await db.fetchval(
|
||||||
SELECT id, horizon_end FROM ems.planning_run
|
"select ems.fn_planning_active_run($1::int)",
|
||||||
WHERE site_id = $1 AND status = 'active'
|
site_id,
|
||||||
ORDER BY created_at DESC LIMIT 1
|
)
|
||||||
""", site_id)
|
ar = ar_raw if isinstance(ar_raw, dict) else json.loads(ar_raw)
|
||||||
|
if ar.get("error") == "no_active_plan":
|
||||||
if not active_run:
|
|
||||||
logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan")
|
logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan")
|
||||||
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
||||||
|
|
||||||
horizon_to = active_run["horizon_end"]
|
he = ar["horizon_end"]
|
||||||
|
if isinstance(he, datetime):
|
||||||
|
horizon_to = he if he.tzinfo else he.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
horizon_to = datetime.fromisoformat(str(he).replace("Z", "+00:00"))
|
||||||
|
|
||||||
if (horizon_to - replan_from).total_seconds() < 1800:
|
if (horizon_to - replan_from).total_seconds() < 1800:
|
||||||
if allow_skip:
|
if allow_skip:
|
||||||
@@ -771,13 +656,13 @@ async def run_rolling_replan(
|
|||||||
|
|
||||||
logger.info(f"[site={site_id}] Rolling replan from {replan_from} → {horizon_to}")
|
logger.info(f"[site={site_id}] Rolling replan from {replan_from} → {horizon_to}")
|
||||||
|
|
||||||
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode = (
|
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
|
||||||
await _load_site_context(site_id, db)
|
await _load_site_context(site_id, db)
|
||||||
)
|
)
|
||||||
|
|
||||||
correction_factor, correction_log = await compute_correction_factor(site_id, now, db)
|
correction_factor, correction_log = await compute_correction_factor(site_id, now, db)
|
||||||
|
|
||||||
slots = await _load_slots(site_id, replan_from, horizon_to, db)
|
slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh)
|
||||||
slots_before_pv_correction = list(slots)
|
slots_before_pv_correction = list(slots)
|
||||||
critical_slots = int(36 / INTERVAL_H)
|
critical_slots = int(36 / INTERVAL_H)
|
||||||
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price)
|
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price)
|
||||||
@@ -791,8 +676,6 @@ async def run_rolling_replan(
|
|||||||
|
|
||||||
slots = apply_forecast_correction(slots, now, correction_factor)
|
slots = apply_forecast_correction(slots, now, correction_factor)
|
||||||
|
|
||||||
tuv_stats = await _load_tuv_usage_stats(site_id, db)
|
|
||||||
|
|
||||||
results, duration_ms = solve_dispatch(
|
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,
|
tuv_delta_stats=tuv_stats,
|
||||||
@@ -818,10 +701,10 @@ async def run_rolling_replan(
|
|||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO ems.forecast_correction_log
|
select ems.fn_forecast_correction_log_insert(
|
||||||
(site_id, window_start, window_end, actual_pv_wh, forecast_pv_wh,
|
$1::int, $2::timestamptz, $3::timestamptz,
|
||||||
correction_factor, applied_to_run_id)
|
$4::numeric, $5::numeric, $6::numeric, $7::int
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
)
|
||||||
""",
|
""",
|
||||||
site_id,
|
site_id,
|
||||||
correction_log["window_start"],
|
correction_log["window_start"],
|
||||||
@@ -870,184 +753,86 @@ def _current_slot_start(dt: datetime) -> datetime:
|
|||||||
return dt.replace(minute=minute, second=0, microsecond=0)
|
return dt.replace(minute=minute, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
def _ev_session_ctx(row) -> Optional[SimpleNamespace]:
|
def _parse_json_dt(val: object) -> Optional[datetime]:
|
||||||
"""Kontext deadline constraintu pro jedno EV (nebo None)."""
|
if val is None:
|
||||||
if row is None or row["target_deadline"] is None:
|
|
||||||
return None
|
return None
|
||||||
cap_kwh = row["veh_cap_kwh"]
|
if isinstance(val, datetime):
|
||||||
if cap_kwh is None:
|
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
|
||||||
|
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
|
||||||
|
if obj is None or obj == []:
|
||||||
return None
|
return None
|
||||||
cap_wh = float(cap_kwh) * 1000.0
|
if isinstance(obj, str):
|
||||||
tgt = row["target_soc_pct"]
|
obj = json.loads(obj)
|
||||||
if tgt is None:
|
if not isinstance(obj, dict):
|
||||||
tgt = row["default_target_soc_pct"]
|
|
||||||
if tgt is None:
|
|
||||||
return None
|
return None
|
||||||
tgt_f = float(tgt)
|
td = _parse_json_dt(obj.get("target_deadline"))
|
||||||
soc0 = row["soc_at_connect_pct"]
|
if td is None:
|
||||||
if soc0 is None:
|
|
||||||
return None
|
|
||||||
needed_wh = (tgt_f - float(soc0)) / 100.0 * cap_wh
|
|
||||||
delivered = float(row["energy_delivered_wh"] or 0)
|
|
||||||
remaining = max(0.0, needed_wh - delivered)
|
|
||||||
if remaining <= 0:
|
|
||||||
return None
|
return None
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
target_deadline=row["target_deadline"],
|
target_deadline=td,
|
||||||
energy_needed_wh=remaining,
|
energy_needed_wh=float(obj["energy_needed_wh"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _load_site_context(site_id: int, db):
|
async def _load_site_context(site_id: int, db):
|
||||||
"""
|
"""
|
||||||
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV a provozní režim pro solver.
|
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV, režim a TUV statistiky (SQL).
|
||||||
"""
|
"""
|
||||||
operating_mode = await db.fetchval(
|
raw = await db.fetchval(
|
||||||
"SELECT mode_code FROM ems.site_operating_mode WHERE site_id = $1",
|
"select ems.fn_planning_site_context($1::int)",
|
||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
|
ctx = raw if isinstance(raw, dict) else json.loads(raw)
|
||||||
|
if ctx.get("error") == "unknown_site":
|
||||||
|
raise RuntimeError(f"Site not found: {site_id}")
|
||||||
|
|
||||||
brow = await db.fetchrow(
|
b = ctx["battery"]
|
||||||
"""
|
ec_i = int(b["max_charge_power_w"])
|
||||||
SELECT ab.usable_capacity_wh,
|
ed_i = int(b["max_discharge_power_w"])
|
||||||
ab.min_soc_percent,
|
|
||||||
ab.reserve_soc_percent,
|
|
||||||
ab.max_soc_percent,
|
|
||||||
ab.charge_efficiency,
|
|
||||||
ab.discharge_efficiency,
|
|
||||||
ab.degradation_cost_czk_kwh,
|
|
||||||
ab.charge_slot_buffer,
|
|
||||||
ab.discharge_slot_buffer,
|
|
||||||
LEAST(
|
|
||||||
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w),
|
|
||||||
COALESCE(
|
|
||||||
ab.bms_max_charge_w,
|
|
||||||
CASE WHEN ab.max_charge_c_rate IS NOT NULL
|
|
||||||
THEN (ab.max_charge_c_rate * ab.usable_capacity_wh)::bigint
|
|
||||||
END,
|
|
||||||
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w)
|
|
||||||
)
|
|
||||||
) AS effective_charge_w,
|
|
||||||
LEAST(
|
|
||||||
COALESCE(ai.max_battery_discharge_w, ai.max_discharge_power_w),
|
|
||||||
COALESCE(
|
|
||||||
ab.bms_max_discharge_w,
|
|
||||||
CASE WHEN ab.max_discharge_c_rate IS NOT NULL
|
|
||||||
THEN (ab.max_discharge_c_rate * ab.usable_capacity_wh)::bigint
|
|
||||||
END,
|
|
||||||
COALESCE(ai.max_battery_discharge_w, ai.max_discharge_power_w)
|
|
||||||
)
|
|
||||||
) AS effective_discharge_w
|
|
||||||
FROM ems.asset_battery ab
|
|
||||||
JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id AND ai.site_id = ab.site_id
|
|
||||||
WHERE ab.site_id = $1
|
|
||||||
ORDER BY ab.id
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
if brow is None:
|
|
||||||
raise RuntimeError(f"No asset_battery for site_id={site_id}")
|
|
||||||
|
|
||||||
ec_w = brow["effective_charge_w"]
|
|
||||||
ed_w = brow["effective_discharge_w"]
|
|
||||||
if ec_w is None or ed_w is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Battery effective power limits missing for site_id={site_id} "
|
|
||||||
"(need max_battery_charge_w/max_discharge or legacy max_charge_power_w / max_discharge_power_w)"
|
|
||||||
)
|
|
||||||
ec_i = int(ec_w)
|
|
||||||
ed_i = int(ed_w)
|
|
||||||
if ec_i <= 0 or ed_i <= 0:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Invalid battery effective limits for site_id={site_id}: "
|
|
||||||
f"charge={ec_i}W discharge={ed_i}W"
|
|
||||||
)
|
|
||||||
|
|
||||||
uc = float(brow["usable_capacity_wh"])
|
|
||||||
min_soc_wh = float(brow["min_soc_percent"]) / 100.0 * uc
|
|
||||||
arb_floor_wh = float(brow["reserve_soc_percent"]) / 100.0 * uc
|
|
||||||
soc_max_wh = float(brow["max_soc_percent"]) / 100.0 * uc
|
|
||||||
battery = SimpleNamespace(
|
battery = SimpleNamespace(
|
||||||
usable_capacity_wh=uc,
|
usable_capacity_wh=float(b["usable_capacity_wh"]),
|
||||||
min_soc_wh=min_soc_wh,
|
min_soc_wh=float(b["min_soc_wh"]),
|
||||||
arb_floor_wh=arb_floor_wh,
|
arb_floor_wh=float(b["arb_floor_wh"]),
|
||||||
reserve_soc_wh=arb_floor_wh,
|
reserve_soc_wh=float(b["reserve_soc_wh"]),
|
||||||
soc_max_wh=soc_max_wh,
|
soc_max_wh=float(b["soc_max_wh"]),
|
||||||
charge_efficiency=float(brow["charge_efficiency"]),
|
charge_efficiency=float(b["charge_efficiency"]),
|
||||||
discharge_efficiency=float(brow["discharge_efficiency"]),
|
discharge_efficiency=float(b["discharge_efficiency"]),
|
||||||
degradation_cost_czk_kwh=float(brow["degradation_cost_czk_kwh"]),
|
degradation_cost_czk_kwh=float(b["degradation_cost_czk_kwh"]),
|
||||||
max_charge_power_w=ec_i,
|
max_charge_power_w=ec_i,
|
||||||
max_discharge_power_w=ed_i,
|
max_discharge_power_w=ed_i,
|
||||||
charge_slot_buffer=float(brow["charge_slot_buffer"]) if brow["charge_slot_buffer"] is not None else 0,
|
charge_slot_buffer=float(b["charge_slot_buffer"])
|
||||||
discharge_slot_buffer=float(brow["discharge_slot_buffer"]) if brow["discharge_slot_buffer"] is not None else 0,
|
if b.get("charge_slot_buffer") is not None
|
||||||
|
else 0,
|
||||||
|
discharge_slot_buffer=float(b["discharge_slot_buffer"])
|
||||||
|
if b.get("discharge_slot_buffer") is not None
|
||||||
|
else 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
hrow = await db.fetchrow(
|
hpj = ctx["heat_pump"]
|
||||||
"""
|
heat_pump = SimpleNamespace(
|
||||||
SELECT COALESCE(rated_heating_power_w, 8000) AS rated_heating_power_w,
|
rated_heating_power_w=int(hpj["rated_heating_power_w"]),
|
||||||
COALESCE(tuv_min_temp_c, 45) AS tuv_min_temp_c,
|
tuv_min_temp_c=float(hpj["tuv_min_temp_c"]),
|
||||||
COALESCE(tuv_target_temp_c, 55) AS tuv_target_temp_c
|
tuv_target_temp_c=float(hpj["tuv_target_temp_c"]),
|
||||||
FROM ems.asset_heat_pump
|
|
||||||
WHERE site_id = $1
|
|
||||||
ORDER BY id
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
)
|
||||||
if hrow is None:
|
|
||||||
heat_pump = SimpleNamespace(
|
|
||||||
rated_heating_power_w=0,
|
|
||||||
tuv_min_temp_c=0.0,
|
|
||||||
tuv_target_temp_c=55.0,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
hp_w = int(hrow["rated_heating_power_w"])
|
|
||||||
heat_pump = SimpleNamespace(
|
|
||||||
rated_heating_power_w=max(hp_w, 0),
|
|
||||||
tuv_min_temp_c=float(hrow["tuv_min_temp_c"]),
|
|
||||||
tuv_target_temp_c=float(hrow["tuv_target_temp_c"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
grow = await db.fetchrow(
|
g = ctx["grid"]
|
||||||
"""
|
|
||||||
SELECT max_import_power_w, max_export_power_w
|
|
||||||
FROM ems.site_grid_connection
|
|
||||||
WHERE site_id = $1
|
|
||||||
ORDER BY id
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
if grow is None:
|
|
||||||
raise RuntimeError(f"No site_grid_connection for site_id={site_id}")
|
|
||||||
grid = SimpleNamespace(
|
grid = SimpleNamespace(
|
||||||
max_import_power_w=int(grow["max_import_power_w"]),
|
max_import_power_w=int(g["max_import_power_w"]),
|
||||||
max_export_power_w=int(grow["max_export_power_w"]),
|
max_export_power_w=int(g["max_export_power_w"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
vrows = await db.fetch(
|
vehicles: list[SimpleNamespace] = []
|
||||||
"""
|
for v in ctx.get("vehicles") or []:
|
||||||
SELECT v.battery_capacity_kwh,
|
vehicles.append(
|
||||||
v.max_charge_power_w,
|
SimpleNamespace(
|
||||||
v.default_target_soc_pct,
|
max_charge_power_w=int(v["max_charge_power_w"]),
|
||||||
ch.code AS charger_code
|
battery_capacity_kwh=float(v["battery_capacity_kwh"]),
|
||||||
FROM ems.asset_vehicle v
|
default_target_soc_pct=float(v["default_target_soc_pct"]),
|
||||||
JOIN ems.asset_ev_charger ch ON ch.id = v.default_charger_id
|
)
|
||||||
WHERE v.site_id = $1
|
|
||||||
AND ch.code IN ('ev-charger-1', 'ev-charger-2')
|
|
||||||
ORDER BY ch.code
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
vehicles: list[SimpleNamespace] = [
|
|
||||||
SimpleNamespace(
|
|
||||||
max_charge_power_w=int(r["max_charge_power_w"]),
|
|
||||||
battery_capacity_kwh=float(r["battery_capacity_kwh"]),
|
|
||||||
default_target_soc_pct=float(r["default_target_soc_pct"]),
|
|
||||||
)
|
)
|
||||||
for r in vrows
|
|
||||||
]
|
|
||||||
while len(vehicles) < 2:
|
while len(vehicles) < 2:
|
||||||
vehicles.append(
|
vehicles.append(
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
@@ -1057,56 +842,19 @@ async def _load_site_context(site_id: int, db):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
srows = await db.fetch(
|
ev_raw = ctx.get("ev_sessions") or []
|
||||||
"""
|
|
||||||
SELECT es.target_deadline,
|
|
||||||
es.target_soc_pct,
|
|
||||||
es.soc_at_connect_pct,
|
|
||||||
es.energy_delivered_wh,
|
|
||||||
ch.code AS charger_code,
|
|
||||||
v.battery_capacity_kwh AS veh_cap_kwh,
|
|
||||||
v.default_target_soc_pct
|
|
||||||
FROM ems.ev_session es
|
|
||||||
JOIN ems.asset_ev_charger ch ON ch.id = es.charger_id
|
|
||||||
LEFT JOIN ems.asset_vehicle v ON v.id = es.vehicle_id
|
|
||||||
WHERE es.site_id = $1
|
|
||||||
AND es.session_end IS NULL
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
by_charger = {r["charger_code"]: r for r in srows}
|
|
||||||
ev_sessions = [
|
ev_sessions = [
|
||||||
_ev_session_ctx(by_charger.get("ev-charger-1")),
|
_ev_session_from_json(ev_raw[0]) if len(ev_raw) > 0 else None,
|
||||||
_ev_session_ctx(by_charger.get("ev-charger-2")),
|
_ev_session_from_json(ev_raw[1]) if len(ev_raw) > 1 else None,
|
||||||
]
|
]
|
||||||
|
|
||||||
soc_pct = await db.fetchval(
|
soc_wh = float(ctx["soc_wh"])
|
||||||
"""
|
tuv_temp = float(ctx["tuv_temp"])
|
||||||
SELECT battery_soc_percent
|
operating_mode = ctx.get("operating_mode")
|
||||||
FROM ems.telemetry_inverter
|
|
||||||
WHERE site_id = $1
|
|
||||||
ORDER BY measured_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
if soc_pct is None:
|
|
||||||
soc_wh = uc * 0.5
|
|
||||||
else:
|
|
||||||
soc_wh = float(soc_pct) / 100.0 * uc
|
|
||||||
soc_wh = max(min_soc_wh, min(soc_wh, soc_max_wh))
|
|
||||||
|
|
||||||
tuv = await db.fetchval(
|
tuv_stats: dict[tuple[int, int], float] = {}
|
||||||
"""
|
for row in ctx.get("tuv_delta_stats") or []:
|
||||||
SELECT tuv_tank_temp_c
|
tuv_stats[(int(row["dow"]), int(row["hour"]))] = float(row["delta"])
|
||||||
FROM ems.telemetry_heat_pump
|
|
||||||
WHERE site_id = $1
|
|
||||||
ORDER BY measured_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
site_id,
|
|
||||||
)
|
|
||||||
tuv_temp = float(tuv) if tuv is not None else 50.0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
battery,
|
battery,
|
||||||
@@ -1117,120 +865,33 @@ async def _load_site_context(site_id: int, db):
|
|||||||
soc_wh,
|
soc_wh,
|
||||||
tuv_temp,
|
tuv_temp,
|
||||||
operating_mode,
|
operating_mode,
|
||||||
|
tuv_stats,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _load_tuv_usage_stats(site_id: int, db) -> dict[tuple[int, int], float]:
|
async def _load_slots(
|
||||||
"""Průměrná změna teploty TUV zásobníku per (DOW, hodina) v konvenci DB EXTRACT(DOW)."""
|
site_id: int,
|
||||||
|
from_dt: datetime,
|
||||||
|
to_dt: datetime,
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
soc_wh: float,
|
||||||
|
) -> list[PlanningSlot]:
|
||||||
|
"""15min sloty z ems.fn_load_planning_slots_full."""
|
||||||
rows = await db.fetch(
|
rows = await db.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT day_of_week, hour_of_day, avg_temp_delta_c
|
select slot_ord, interval_start, buy_price, sell_price, is_predicted_price,
|
||||||
FROM ems.tuv_usage_stats
|
pv_a_forecast_w, pv_b_forecast_w, load_baseline_w,
|
||||||
WHERE site_id = $1
|
ev1_connected, ev2_connected, allow_charge, allow_discharge_export
|
||||||
|
from ems.fn_load_planning_slots_full(
|
||||||
|
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric
|
||||||
|
)
|
||||||
""",
|
""",
|
||||||
site_id,
|
site_id,
|
||||||
|
from_dt,
|
||||||
|
to_dt,
|
||||||
|
soc_wh,
|
||||||
)
|
)
|
||||||
return {
|
|
||||||
(int(r["day_of_week"]), int(r["hour_of_day"])): float(r["avg_temp_delta_c"])
|
|
||||||
for r in rows
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
|
|
||||||
"""Načte 15min sloty s cenami (OTE + predikce za horizont), forecasty a stavem EV z DB."""
|
|
||||||
rows = await db.fetch("""
|
|
||||||
WITH slot_spine AS (
|
|
||||||
SELECT gs AS interval_start
|
|
||||||
FROM generate_series(
|
|
||||||
$2::timestamptz,
|
|
||||||
($3::timestamptz - interval '15 minutes')::timestamptz,
|
|
||||||
interval '15 minutes'
|
|
||||||
) AS gs
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
s.interval_start,
|
|
||||||
COALESCE(
|
|
||||||
ep.effective_buy_price_czk_kwh,
|
|
||||||
ems.fn_get_predicted_price($1, s.interval_start)
|
|
||||||
) AS buy_price,
|
|
||||||
COALESCE(
|
|
||||||
ep.effective_sell_price_czk_kwh,
|
|
||||||
ems.fn_get_predicted_price($1, s.interval_start) * 0.85
|
|
||||||
) AS sell_price,
|
|
||||||
(ep.effective_buy_price_czk_kwh IS NULL) AS is_predicted_price,
|
|
||||||
COALESCE(fpi_a.power_w, 0) AS pv_a_forecast_w,
|
|
||||||
COALESCE(fpi_b.power_w, 0) AS pv_b_forecast_w,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT bs.avg_power_w
|
|
||||||
FROM ems.consumption_baseline_stats bs
|
|
||||||
WHERE bs.site_id = $1
|
|
||||||
AND bs.day_of_week = EXTRACT(DOW FROM s.interval_start
|
|
||||||
AT TIME ZONE 'Europe/Prague')::INT
|
|
||||||
AND bs.hour_of_day = EXTRACT(HOUR FROM s.interval_start
|
|
||||||
AT TIME ZONE 'Europe/Prague')::INT
|
|
||||||
LIMIT 1),
|
|
||||||
500
|
|
||||||
) AS load_baseline_w,
|
|
||||||
(COALESCE(ev1.status, 'available') NOT IN ('available', 'unavailable')) AS ev1_connected,
|
|
||||||
(COALESCE(ev2.status, 'available') NOT IN ('available', 'unavailable')) AS ev2_connected
|
|
||||||
FROM slot_spine s
|
|
||||||
LEFT JOIN ems.vw_site_effective_price ep
|
|
||||||
ON ep.site_id = $1 AND ep.interval_start = s.interval_start
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT COALESCE(SUM(u.power_w), 0)::INT AS power_w
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT ON (apa.id)
|
|
||||||
fpi.power_w
|
|
||||||
FROM ems.asset_pv_array apa
|
|
||||||
JOIN ems.forecast_pv_run fpr
|
|
||||||
ON fpr.pv_array_id = apa.id
|
|
||||||
AND fpr.site_id = apa.site_id
|
|
||||||
AND fpr.status = 'ok'
|
|
||||||
JOIN ems.forecast_pv_interval fpi
|
|
||||||
ON fpi.run_id = fpr.id
|
|
||||||
AND fpi.pv_array_id = apa.id
|
|
||||||
AND fpi.interval_start = s.interval_start
|
|
||||||
WHERE apa.site_id = $1
|
|
||||||
AND apa.controllable IS TRUE
|
|
||||||
ORDER BY apa.id, fpr.created_at DESC
|
|
||||||
) u
|
|
||||||
) fpi_a ON true
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT COALESCE(SUM(u.power_w), 0)::INT AS power_w
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT ON (apa.id)
|
|
||||||
fpi.power_w
|
|
||||||
FROM ems.asset_pv_array apa
|
|
||||||
JOIN ems.forecast_pv_run fpr
|
|
||||||
ON fpr.pv_array_id = apa.id
|
|
||||||
AND fpr.site_id = apa.site_id
|
|
||||||
AND fpr.status = 'ok'
|
|
||||||
JOIN ems.forecast_pv_interval fpi
|
|
||||||
ON fpi.run_id = fpr.id
|
|
||||||
AND fpi.pv_array_id = apa.id
|
|
||||||
AND fpi.interval_start = s.interval_start
|
|
||||||
WHERE apa.site_id = $1
|
|
||||||
AND apa.controllable IS FALSE
|
|
||||||
ORDER BY apa.id, fpr.created_at DESC
|
|
||||||
) u
|
|
||||||
) fpi_b ON true
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT t.status
|
|
||||||
FROM ems.telemetry_ev_charger t
|
|
||||||
JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id
|
|
||||||
WHERE t.site_id = $1 AND ch.code = 'ev-charger-1'
|
|
||||||
ORDER BY t.measured_at DESC LIMIT 1
|
|
||||||
) ev1 ON true
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT t.status
|
|
||||||
FROM ems.telemetry_ev_charger t
|
|
||||||
JOIN ems.asset_ev_charger ch ON ch.id = t.charger_id
|
|
||||||
WHERE t.site_id = $1 AND ch.code = 'ev-charger-2'
|
|
||||||
ORDER BY t.measured_at DESC LIMIT 1
|
|
||||||
) ev2 ON true
|
|
||||||
ORDER BY s.interval_start
|
|
||||||
""", site_id, from_dt, to_dt)
|
|
||||||
|
|
||||||
out: list[PlanningSlot] = []
|
out: list[PlanningSlot] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = dict(r)
|
d = dict(r)
|
||||||
@@ -1245,6 +906,8 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
|
|||||||
ev1_connected=bool(d["ev1_connected"]),
|
ev1_connected=bool(d["ev1_connected"]),
|
||||||
ev2_connected=bool(d["ev2_connected"]),
|
ev2_connected=bool(d["ev2_connected"]),
|
||||||
is_predicted_price=bool(d.get("is_predicted_price")),
|
is_predicted_price=bool(d.get("is_predicted_price")),
|
||||||
|
allow_charge=bool(d.get("allow_charge", True)),
|
||||||
|
allow_discharge_export=bool(d.get("allow_discharge_export", True)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not out:
|
if not out:
|
||||||
@@ -1281,112 +944,59 @@ async def _save_planning_run(
|
|||||||
soc_wh, duration_ms, correction, db,
|
soc_wh, duration_ms, correction, db,
|
||||||
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
|
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Uloží výsledky solveru jako nový planning_run, deaktivuje předchozí."""
|
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
|
||||||
if slot_inputs is not None and len(slot_inputs) != len(results):
|
if slot_inputs is not None and len(slot_inputs) != len(results):
|
||||||
raise ValueError("slot_inputs and results length mismatch")
|
raise ValueError("slot_inputs and results length mismatch")
|
||||||
run_id = await db.fetchval("""
|
run_meta = {
|
||||||
INSERT INTO ems.planning_run
|
"run_type": run_type,
|
||||||
(site_id, horizon_start, horizon_end, status,
|
"triggered_by": triggered_by,
|
||||||
run_type, triggered_by, replan_from,
|
"replan_from": replan_from.isoformat() if replan_from else None,
|
||||||
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor)
|
"soc_at_replan_wh": soc_wh,
|
||||||
VALUES ($1,$2,$3,'draft',$4,$5,$6,$7,$8,$9)
|
"solver_duration_ms": duration_ms,
|
||||||
RETURNING id
|
"forecast_correction_factor": correction,
|
||||||
""", site_id, horizon_from, horizon_to,
|
}
|
||||||
run_type, triggered_by, replan_from,
|
intervals: list[dict] = []
|
||||||
soc_wh, duration_ms, correction)
|
for i, r in enumerate(results):
|
||||||
|
row: dict = {
|
||||||
|
"interval_start": r.interval_start.isoformat()
|
||||||
|
if hasattr(r.interval_start, "isoformat")
|
||||||
|
else r.interval_start,
|
||||||
|
"battery_setpoint_w": r.battery_setpoint_w,
|
||||||
|
"battery_soc_target_pct": r.battery_soc_target,
|
||||||
|
"grid_setpoint_w": r.grid_setpoint_w,
|
||||||
|
"ev1_setpoint_w": r.ev1_setpoint_w,
|
||||||
|
"ev2_setpoint_w": r.ev2_setpoint_w,
|
||||||
|
"ev1_via_bat_w": r.ev1_via_bat_w,
|
||||||
|
"ev2_via_bat_w": r.ev2_via_bat_w,
|
||||||
|
"heat_pump_enabled": r.heat_pump_enabled,
|
||||||
|
"heat_pump_setpoint_w": r.heat_pump_setpoint_w,
|
||||||
|
"pv_a_curtailed_w": r.pv_a_curtailed_w,
|
||||||
|
"expected_cost_czk": float(r.expected_cost_czk),
|
||||||
|
"effective_buy_price": float(r.effective_buy_price),
|
||||||
|
"effective_sell_price": float(r.effective_sell_price),
|
||||||
|
"is_predicted_price": r.is_predicted_price,
|
||||||
|
}
|
||||||
|
if slot_inputs is not None:
|
||||||
|
si = slot_inputs[i]
|
||||||
|
row["load_baseline_w"] = si[0]
|
||||||
|
row["pv_a_forecast_raw_w"] = si[1]
|
||||||
|
row["pv_b_forecast_raw_w"] = si[2]
|
||||||
|
row["pv_a_forecast_solver_w"] = si[3]
|
||||||
|
row["pv_b_forecast_solver_w"] = si[4]
|
||||||
|
intervals.append(row)
|
||||||
|
|
||||||
# Bulk insert výsledků
|
return int(
|
||||||
if slot_inputs is not None:
|
await db.fetchval(
|
||||||
rows_pi = [
|
"""
|
||||||
(
|
select ems.fn_planning_run_commit(
|
||||||
run_id,
|
$1::int, $2::timestamptz, $3::timestamptz,
|
||||||
r.interval_start,
|
$4::jsonb, $5::jsonb
|
||||||
r.battery_setpoint_w,
|
|
||||||
r.battery_soc_target,
|
|
||||||
r.grid_setpoint_w,
|
|
||||||
r.ev1_setpoint_w,
|
|
||||||
r.ev2_setpoint_w,
|
|
||||||
r.ev1_via_bat_w,
|
|
||||||
r.ev2_via_bat_w,
|
|
||||||
r.heat_pump_enabled,
|
|
||||||
r.heat_pump_setpoint_w,
|
|
||||||
r.pv_a_curtailed_w,
|
|
||||||
r.expected_cost_czk,
|
|
||||||
r.effective_buy_price,
|
|
||||||
r.effective_sell_price,
|
|
||||||
r.is_predicted_price,
|
|
||||||
si[0],
|
|
||||||
si[1],
|
|
||||||
si[2],
|
|
||||||
si[3],
|
|
||||||
si[4],
|
|
||||||
)
|
)
|
||||||
for r, si in zip(results, slot_inputs)
|
|
||||||
]
|
|
||||||
await db.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO ems.planning_interval
|
|
||||||
(run_id, interval_start,
|
|
||||||
battery_setpoint_w, battery_soc_target_pct,
|
|
||||||
grid_setpoint_w,
|
|
||||||
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
|
|
||||||
heat_pump_enabled, heat_pump_setpoint_w,
|
|
||||||
pv_a_curtailed_w, expected_cost_czk,
|
|
||||||
effective_buy_price, effective_sell_price,
|
|
||||||
is_predicted_price,
|
|
||||||
load_baseline_w,
|
|
||||||
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
|
|
||||||
pv_a_forecast_solver_w, pv_b_forecast_solver_w)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
|
|
||||||
$17,$18,$19,$20,$21)
|
|
||||||
""",
|
""",
|
||||||
rows_pi,
|
site_id,
|
||||||
|
horizon_from,
|
||||||
|
horizon_to,
|
||||||
|
json.dumps(run_meta, default=str),
|
||||||
|
json.dumps(intervals, default=str),
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
await db.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO ems.planning_interval
|
|
||||||
(run_id, interval_start,
|
|
||||||
battery_setpoint_w, battery_soc_target_pct,
|
|
||||||
grid_setpoint_w,
|
|
||||||
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
|
|
||||||
heat_pump_enabled, heat_pump_setpoint_w,
|
|
||||||
pv_a_curtailed_w, expected_cost_czk,
|
|
||||||
effective_buy_price, effective_sell_price,
|
|
||||||
is_predicted_price)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
|
||||||
""",
|
|
||||||
[
|
|
||||||
(
|
|
||||||
run_id,
|
|
||||||
r.interval_start,
|
|
||||||
r.battery_setpoint_w,
|
|
||||||
r.battery_soc_target,
|
|
||||||
r.grid_setpoint_w,
|
|
||||||
r.ev1_setpoint_w,
|
|
||||||
r.ev2_setpoint_w,
|
|
||||||
r.ev1_via_bat_w,
|
|
||||||
r.ev2_via_bat_w,
|
|
||||||
r.heat_pump_enabled,
|
|
||||||
r.heat_pump_setpoint_w,
|
|
||||||
r.pv_a_curtailed_w,
|
|
||||||
r.expected_cost_czk,
|
|
||||||
r.effective_buy_price,
|
|
||||||
r.effective_sell_price,
|
|
||||||
r.is_predicted_price,
|
|
||||||
)
|
|
||||||
for r in results
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Aktivovat nový plán, supersede předchozí
|
|
||||||
await db.execute("""
|
|
||||||
UPDATE ems.planning_run SET status = 'superseded'
|
|
||||||
WHERE site_id = $1 AND status = 'active' AND id <> $2
|
|
||||||
""", site_id, run_id)
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE ems.planning_run SET status = 'active' WHERE id = $1", run_id
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return run_id
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.db_json import fetch_json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -119,18 +120,14 @@ async def _apply_ote_json_to_db(conn, payload: dict) -> int:
|
|||||||
|
|
||||||
async def count_ote_slots_prague_day(conn, target_day: date) -> int:
|
async def count_ote_slots_prague_day(conn, target_day: date) -> int:
|
||||||
"""Počet řádků OTE_CZ pro kalendářní den v Europe/Prague (plný den 92/96/100)."""
|
"""Počet řádků OTE_CZ pro kalendářní den v Europe/Prague (plný den 92/96/100)."""
|
||||||
return int(
|
stats = await fetch_json(
|
||||||
await conn.fetchval(
|
conn,
|
||||||
"""
|
"select ems.fn_ote_day_slot_stats_prague($1::date)",
|
||||||
SELECT COUNT(*)::int
|
target_day,
|
||||||
FROM ems.market_interval_price
|
|
||||||
WHERE market_source = 'OTE_CZ'
|
|
||||||
AND (interval_start AT TIME ZONE 'Europe/Prague')::date = $1::date
|
|
||||||
""",
|
|
||||||
target_day,
|
|
||||||
)
|
|
||||||
or 0
|
|
||||||
)
|
)
|
||||||
|
if not isinstance(stats, dict):
|
||||||
|
stats = json.loads(stats)
|
||||||
|
return int(stats.get("count") or 0)
|
||||||
|
|
||||||
|
|
||||||
async def import_ote_prices_for_day(
|
async def import_ote_prices_for_day(
|
||||||
@@ -147,18 +144,15 @@ async def import_ote_prices_for_day(
|
|||||||
return -1, day_str, 0.0, fetch_error or "fetch_failed"
|
return -1, day_str, 0.0, fetch_error or "fetch_failed"
|
||||||
try:
|
try:
|
||||||
n = await _apply_ote_json_to_db(conn, payload)
|
n = await _apply_ote_json_to_db(conn, payload)
|
||||||
first_price = await conn.fetchval(
|
stats_after = await fetch_json(
|
||||||
"""
|
conn,
|
||||||
SELECT buy_raw_price_czk_kwh
|
"select ems.fn_ote_day_slot_stats_prague($1::date)",
|
||||||
FROM ems.market_interval_price
|
|
||||||
WHERE market_source = 'OTE_CZ'
|
|
||||||
AND (interval_start AT TIME ZONE 'Europe/Prague')::date = $1::date
|
|
||||||
ORDER BY interval_start
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
target_day,
|
target_day,
|
||||||
)
|
)
|
||||||
n_imported = await count_ote_slots_prague_day(conn, target_day)
|
if not isinstance(stats_after, dict):
|
||||||
|
stats_after = json.loads(stats_after)
|
||||||
|
first_price = stats_after.get("first_price")
|
||||||
|
n_imported = int(stats_after.get("count") or 0)
|
||||||
if not ote_prague_day_slots_look_complete(n_imported):
|
if not ote_prague_day_slots_look_complete(n_imported):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"OTE: %s slotů pro %s (plný den = jedna z %s; jinak neúplná data)",
|
"OTE: %s slotů pro %s (plný den = jedna z %s; jinak neúplná data)",
|
||||||
@@ -248,7 +242,7 @@ async def import_ote_prices(
|
|||||||
"""
|
"""
|
||||||
if site_id is not None:
|
if site_id is not None:
|
||||||
row = await db.fetchrow(
|
row = await db.fetchrow(
|
||||||
"SELECT timezone FROM ems.site WHERE id = $1", site_id
|
"select timezone from ems.vw_site_directory where id = $1", site_id
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
logger.error("OTE import: site id=%s nenalezen", site_id)
|
logger.error("OTE import: site id=%s nenalezen", site_id)
|
||||||
@@ -290,26 +284,15 @@ async def import_ote_prices(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
n = await _apply_ote_json_to_db(db, payload)
|
n = await _apply_ote_json_to_db(db, payload)
|
||||||
first_price = await db.fetchval(
|
stats_after = await fetch_json(
|
||||||
"""
|
db,
|
||||||
SELECT buy_raw_price_czk_kwh
|
"select ems.fn_ote_day_slot_stats_prague($1::date)",
|
||||||
FROM ems.market_interval_price
|
|
||||||
WHERE market_source = 'OTE_CZ'
|
|
||||||
AND interval_start::date = $1::date
|
|
||||||
ORDER BY interval_start
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
target_day,
|
|
||||||
)
|
|
||||||
n_imported = await db.fetchval(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*)::int
|
|
||||||
FROM ems.market_interval_price
|
|
||||||
WHERE market_source = 'OTE_CZ'
|
|
||||||
AND interval_start::date = $1::date
|
|
||||||
""",
|
|
||||||
target_day,
|
target_day,
|
||||||
)
|
)
|
||||||
|
if not isinstance(stats_after, dict):
|
||||||
|
stats_after = json.loads(stats_after)
|
||||||
|
first_price = stats_after.get("first_price")
|
||||||
|
n_imported = int(stats_after.get("count") or 0)
|
||||||
incomplete = not ote_prague_day_slots_look_complete(n_imported or 0)
|
incomplete = not ote_prague_day_slots_look_complete(n_imported or 0)
|
||||||
if incomplete:
|
if incomplete:
|
||||||
now_p = datetime.now(ZoneInfo("Europe/Prague"))
|
now_p = datetime.now(ZoneInfo("Europe/Prague"))
|
||||||
|
|||||||
@@ -41,13 +41,9 @@ def aggregate_pv_production_w(pv1_w: int, pv2_w: int, gen_port_w: int) -> int:
|
|||||||
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||||
rows = await db.fetch(
|
rows = await db.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT ai.id, ai.code, se.host, se.port, se.unit_id
|
select inverter_id as id, code, host, port, unit_id
|
||||||
FROM ems.asset_inverter ai
|
from ems.vw_asset_inverter_modbus_poll
|
||||||
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
|
where site_id = $1
|
||||||
WHERE ai.site_id = $1
|
|
||||||
AND ai.active = true
|
|
||||||
AND se.enabled = true
|
|
||||||
AND se.endpoint_type = 'modbus_tcp'
|
|
||||||
""",
|
""",
|
||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
@@ -67,7 +63,7 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
|||||||
batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY)
|
batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY)
|
||||||
batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY)
|
batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY)
|
||||||
grid_power = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER)
|
grid_power = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER)
|
||||||
load_power = await mb.read_register(DEYE_REG_LOAD_TOTAL_POWER)
|
load_power = await mb.read_register_signed(DEYE_REG_LOAD_TOTAL_POWER)
|
||||||
pv1_power = await mb.read_register_signed(DEYE_REG_PV1_POWER)
|
pv1_power = await mb.read_register_signed(DEYE_REG_PV1_POWER)
|
||||||
pv2_power = await mb.read_register_signed(DEYE_REG_PV2_POWER)
|
pv2_power = await mb.read_register_signed(DEYE_REG_PV2_POWER)
|
||||||
gen_port_power = await mb.read_register_signed(DEYE_REG_GEN_PORT_POWER)
|
gen_port_power = await mb.read_register_signed(DEYE_REG_GEN_PORT_POWER)
|
||||||
@@ -81,27 +77,7 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
|||||||
logger.debug("inverter:%s Deye run_state raw=%s", code, run_state)
|
logger.debug("inverter:%s Deye run_state raw=%s", code, run_state)
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"select ems.fn_telemetry_inverter_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::int, $6::int, $7::int, $8::float8, $9::int, $10::int, $11::int, $12::int, $13::int, $14::bigint, $15::bigint, $16::int)",
|
||||||
INSERT INTO ems.telemetry_inverter (
|
|
||||||
site_id, inverter_id, measured_at,
|
|
||||||
pv_power_w, pv1_power_w, pv2_power_w, gen_port_power_w,
|
|
||||||
battery_soc_percent, battery_power_w,
|
|
||||||
batt_charge_today_wh, batt_discharge_today_wh,
|
|
||||||
grid_power_w, load_power_w,
|
|
||||||
grid_import_total_wh, grid_export_total_wh,
|
|
||||||
run_state
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1, $2, $3,
|
|
||||||
$4, $5, $6, $7,
|
|
||||||
$8, $9,
|
|
||||||
$10, $11,
|
|
||||||
$12, $13,
|
|
||||||
$14, $15,
|
|
||||||
$16
|
|
||||||
)
|
|
||||||
ON CONFLICT (inverter_id, measured_at) DO NOTHING
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
inv_id,
|
inv_id,
|
||||||
measured_at,
|
measured_at,
|
||||||
@@ -141,12 +117,9 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
|||||||
async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
||||||
rows = await db.fetch(
|
rows = await db.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT ec.id, ec.code, se.host, se.port, se.unit_id
|
select charger_id as id, code, host, port, unit_id
|
||||||
FROM ems.asset_ev_charger ec
|
from ems.vw_asset_ev_charger_modbus_poll
|
||||||
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
|
where site_id = $1
|
||||||
WHERE ec.site_id = $1
|
|
||||||
AND se.enabled = true
|
|
||||||
AND se.endpoint_type = 'modbus_tcp'
|
|
||||||
""",
|
""",
|
||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
@@ -156,117 +129,52 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
|||||||
code = row["code"]
|
code = row["code"]
|
||||||
charger_id = row["id"]
|
charger_id = row["id"]
|
||||||
logger.info("TODO: EV charger Modbus registry pending | %s", code)
|
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"
|
current_status = "available"
|
||||||
|
|
||||||
previous_status = await db.fetchval(
|
previous_status = await db.fetchval(
|
||||||
"""
|
"""
|
||||||
SELECT status
|
select status
|
||||||
FROM ems.telemetry_ev_charger
|
from ems.telemetry_ev_charger
|
||||||
WHERE charger_id = $1 AND connector_id = $2
|
where charger_id = $1 and connector_id = $2
|
||||||
ORDER BY measured_at DESC
|
order by measured_at desc
|
||||||
LIMIT 1
|
limit 1
|
||||||
""",
|
""",
|
||||||
charger_id,
|
charger_id,
|
||||||
connector_id,
|
connector_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8)",
|
||||||
INSERT INTO ems.telemetry_ev_charger (
|
|
||||||
site_id, charger_id, measured_at, connector_id,
|
|
||||||
status, power_w, energy_kwh
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, 0, 0)
|
|
||||||
ON CONFLICT (charger_id, connector_id, measured_at) DO NOTHING
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
charger_id,
|
charger_id,
|
||||||
measured_at,
|
measured_at,
|
||||||
connector_id,
|
connector_id,
|
||||||
current_status,
|
current_status,
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
if previous_status is not None:
|
if previous_status is not None:
|
||||||
|
await db.fetchval(
|
||||||
|
"select ems.fn_ev_session_transition($1::int, $2::int, $3::text, $4::text, $5::timestamptz)",
|
||||||
|
site_id,
|
||||||
|
charger_id,
|
||||||
|
str(previous_status),
|
||||||
|
current_status,
|
||||||
|
measured_at,
|
||||||
|
)
|
||||||
if previous_status == "available" and current_status != "available":
|
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)
|
logger.info("EV arrival detected on charger %s", code)
|
||||||
await db.execute(
|
elif previous_status != "available" and current_status == "available":
|
||||||
"""
|
|
||||||
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)
|
logger.info("EV departure detected on charger %s", code)
|
||||||
|
|
||||||
|
|
||||||
async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
||||||
rows = await db.fetch(
|
rows = await db.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT hp.id, hp.code, se.host, se.port, se.unit_id
|
select heat_pump_id as id, code, host, port, unit_id
|
||||||
FROM ems.asset_heat_pump hp
|
from ems.vw_asset_heat_pump_modbus_poll
|
||||||
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
|
where site_id = $1
|
||||||
WHERE hp.site_id = $1
|
|
||||||
AND se.enabled = true
|
|
||||||
AND se.endpoint_type = 'modbus_tcp'
|
|
||||||
""",
|
""",
|
||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
@@ -275,18 +183,15 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
|||||||
code = row["code"]
|
code = row["code"]
|
||||||
logger.info("TODO: heat pump Modbus registry pending (heat_pump=%s)", code)
|
logger.info("TODO: heat pump Modbus registry pending (heat_pump=%s)", code)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"select ems.fn_telemetry_heat_pump_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::float8, $6::float8, $7::float8, $8::text)",
|
||||||
INSERT INTO ems.telemetry_heat_pump (
|
|
||||||
site_id, heat_pump_id, measured_at,
|
|
||||||
power_w, outdoor_temp_c, water_outlet_temp_c, tuv_tank_temp_c,
|
|
||||||
operating_mode
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, 0, 10.0, 45.0, 55.0, 'standby')
|
|
||||||
ON CONFLICT (heat_pump_id, measured_at) DO NOTHING
|
|
||||||
""",
|
|
||||||
site_id,
|
site_id,
|
||||||
row["id"],
|
row["id"],
|
||||||
measured_at,
|
measured_at,
|
||||||
|
0,
|
||||||
|
10.0,
|
||||||
|
45.0,
|
||||||
|
55.0,
|
||||||
|
"standby",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -297,7 +202,9 @@ async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
|
|||||||
"""
|
"""
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
start = loop.time()
|
start = loop.time()
|
||||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
sites = await conn.fetch(
|
||||||
|
"select id from ems.vw_site_directory where active = true"
|
||||||
|
)
|
||||||
for site in sites:
|
for site in sites:
|
||||||
sid = site["id"]
|
sid = site["id"]
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
|
||||||
from services.control_exporter import (
|
from services.control.exporter_monolith import (
|
||||||
ControlSetpoints,
|
ControlSetpoints,
|
||||||
InverterConfig,
|
InverterConfig,
|
||||||
_deye_reg178_verify_with_double_read,
|
_deye_reg178_verify_with_double_read,
|
||||||
|
|||||||
28
backend/tests/test_db_json_fetch_json.py
Normal file
28
backend/tests/test_db_json_fetch_json.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Smoke: fetch_json toleruje dict z asyncpg (bez reálné DB)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from app.db_json import fetch_json
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_json_returns_dict() -> None:
|
||||||
|
async def _run() -> None:
|
||||||
|
conn = AsyncMock()
|
||||||
|
conn.fetchval = AsyncMock(return_value={"a": 1})
|
||||||
|
out = await fetch_json(conn, "select ems.fn_x()", 1)
|
||||||
|
assert out == {"a": 1}
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_json_parses_str() -> None:
|
||||||
|
async def _run() -> None:
|
||||||
|
conn = AsyncMock()
|
||||||
|
conn.fetchval = AsyncMock(return_value='{"b": 2}')
|
||||||
|
out = await fetch_json(conn, "select 1")
|
||||||
|
assert out == {"b": 2}
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
@@ -6,7 +6,7 @@ import unittest
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from services.control_exporter import (
|
from services.control.exporter_monolith import (
|
||||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||||
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC,
|
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC,
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"""`_select_charge_slots`: pre-selection nabíjecích slotů (anti-micro-cycling).
|
"""Pre-selection nabíjecích slotů (anti-micro-cycling) – referenční Python.
|
||||||
|
|
||||||
Ověřuje novou logiku podle varianty B:
|
Logika je v DB: ems.fn_load_planning_slots_full. Tento soubor drží kopii algoritmu
|
||||||
- PV-surplus sloty jsou vždy zahrnuty.
|
pro rychlé unit testy bez PostgreSQL.
|
||||||
- Zbytek rozpočtu doplnit nejlevnějšími sloty podle `buy_price` (ne `sell_price`).
|
|
||||||
- Žádné sloty nesmí být vyloučeny kvůli tomu, že nemají PV-surplus, když
|
|
||||||
`charge_slot_buffer` > 0 a ještě chybí energie do `soc_max`.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,7 +10,50 @@ import unittest
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from services.planning_engine import INTERVAL_H, PlanningSlot, _select_charge_slots
|
from services.planning_engine import INTERVAL_H, PlanningSlot
|
||||||
|
|
||||||
|
|
||||||
|
def _select_charge_slots(
|
||||||
|
slots: list[PlanningSlot],
|
||||||
|
battery: SimpleNamespace,
|
||||||
|
current_soc_wh: float,
|
||||||
|
) -> set[int]:
|
||||||
|
"""Kopie logiky z ems.fn_load_planning_slots_full (charge mask)."""
|
||||||
|
charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0)
|
||||||
|
if charge_buf <= 0:
|
||||||
|
return set(range(len(slots)))
|
||||||
|
|
||||||
|
energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh)
|
||||||
|
if energy_to_fill <= 0:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0)
|
||||||
|
max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0)
|
||||||
|
per_slot_full_wh = max_p_w * eta * INTERVAL_H
|
||||||
|
|
||||||
|
selected: set[int] = set()
|
||||||
|
for t, s in enumerate(slots):
|
||||||
|
pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w)
|
||||||
|
if pv_surplus_w > 0:
|
||||||
|
selected.add(t)
|
||||||
|
|
||||||
|
grid_target_wh = energy_to_fill * charge_buf
|
||||||
|
if grid_target_wh <= 0 or per_slot_full_wh <= 0:
|
||||||
|
return selected
|
||||||
|
|
||||||
|
grid_candidates = [
|
||||||
|
(t, float(s.buy_price)) for t, s in enumerate(slots) if t not in selected
|
||||||
|
]
|
||||||
|
grid_candidates.sort(key=lambda x: x[1])
|
||||||
|
|
||||||
|
cumulative = 0.0
|
||||||
|
for t, _price in grid_candidates:
|
||||||
|
if cumulative >= grid_target_wh:
|
||||||
|
break
|
||||||
|
selected.add(t)
|
||||||
|
cumulative += per_slot_full_wh
|
||||||
|
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
def _slot(*, buy: float, sell: float = 1.0, pv: int = 0, load: int = 2_000) -> PlanningSlot:
|
def _slot(*, buy: float, sell: float = 1.0, pv: int = 0, load: int = 2_000) -> PlanningSlot:
|
||||||
|
|||||||
11
db/migration/V049__planning_config.sql
Normal file
11
db/migration/V049__planning_config.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- volitelné plánovací konstanty per site (horizont, decay, …) – čte fn_planning_site_context
|
||||||
|
|
||||||
|
create table if not exists ems.planning_config (
|
||||||
|
site_id int not null references ems.site (id) on delete cascade,
|
||||||
|
config jsonb not null default '{}'::jsonb,
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
primary key (site_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table ems.planning_config is
|
||||||
|
'JSON konfigurace pro budoucí přesun konstant z planning_engine.py (slot weights, correction decay, …).';
|
||||||
51
db/routines/R__fn_battery_cycle_audit.sql
Normal file
51
db/routines/R__fn_battery_cycle_audit.sql
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
-- audit „ekvivalent plných cyklů“ z 1min telemetrie battery_power_w (bez LP constraintu)
|
||||||
|
|
||||||
|
create or replace function ems.fn_battery_cycle_audit(
|
||||||
|
p_site_id int,
|
||||||
|
p_from timestamptz,
|
||||||
|
p_to timestamptz
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_usable numeric;
|
||||||
|
v_throughput_wh numeric;
|
||||||
|
v_full_cycles numeric;
|
||||||
|
begin
|
||||||
|
select coalesce(sum(ab.usable_capacity_wh), 0)::numeric
|
||||||
|
into v_usable
|
||||||
|
from ems.asset_battery ab
|
||||||
|
where ab.site_id = p_site_id;
|
||||||
|
|
||||||
|
if v_usable is null or v_usable <= 0 then
|
||||||
|
return jsonb_build_object('error', 'no_battery', 'full_cycles', 0);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select coalesce(
|
||||||
|
sum(abs(ti.battery_power_w::numeric) / 60.0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
into v_throughput_wh
|
||||||
|
from ems.telemetry_inverter ti
|
||||||
|
where ti.site_id = p_site_id
|
||||||
|
and ti.measured_at >= p_from
|
||||||
|
and ti.measured_at < p_to
|
||||||
|
and ti.battery_power_w is not null;
|
||||||
|
|
||||||
|
v_full_cycles := case
|
||||||
|
when v_usable * 2 > 0 then v_throughput_wh / (v_usable * 2)
|
||||||
|
else 0
|
||||||
|
end;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'full_cycles', round(v_full_cycles::numeric, 4),
|
||||||
|
'throughput_wh', round(v_throughput_wh, 2),
|
||||||
|
'throughput_vs_usable_ratio', round((v_throughput_wh / nullif(v_usable, 0))::numeric, 4),
|
||||||
|
'usable_capacity_wh', v_usable,
|
||||||
|
'window_start', p_from,
|
||||||
|
'window_end', p_to
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
16
db/routines/R__fn_deye_clock_drift_sec.sql
Normal file
16
db/routines/R__fn_deye_clock_drift_sec.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
create or replace function ems.fn_deye_clock_drift_sec(
|
||||||
|
p_device_ts timestamptz,
|
||||||
|
p_reference_ts timestamptz
|
||||||
|
)
|
||||||
|
returns int
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
as $fn$
|
||||||
|
select case
|
||||||
|
when p_device_ts is null or p_reference_ts is null then null::int
|
||||||
|
else abs(extract(epoch from (p_device_ts - p_reference_ts)))::int
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_deye_clock_drift_sec(timestamptz, timestamptz) is
|
||||||
|
'Absolutní odchylka hodin Deye vs referenční UTC (sekundy).';
|
||||||
17
db/routines/R__fn_deye_pack_system_time.sql
Normal file
17
db/routines/R__fn_deye_pack_system_time.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- pack reg 62–64 (Europe/Prague wall time, seconds = 0) stejně jako _deye_system_time_register_rows
|
||||||
|
|
||||||
|
create or replace function ems.fn_deye_pack_system_time(p_ts timestamptz)
|
||||||
|
returns int[]
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
with loc as (
|
||||||
|
select (p_ts at time zone 'Europe/Prague') as t
|
||||||
|
)
|
||||||
|
select array[
|
||||||
|
((extract(year from t)::int - 2000) << 8) | extract(month from t)::int,
|
||||||
|
(extract(day from t)::int << 8) | extract(hour from t)::int,
|
||||||
|
(extract(minute from t)::int << 8) | 0
|
||||||
|
]
|
||||||
|
from loc;
|
||||||
|
$fn$;
|
||||||
27
db/routines/R__fn_deye_time_point_regs.sql
Normal file
27
db/routines/R__fn_deye_time_point_regs.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- pole registrů pro jeden TOU time point (čistá logika čísel; zápis řeší control exporter)
|
||||||
|
|
||||||
|
create or replace function ems.fn_deye_time_point_regs(
|
||||||
|
p_slot_index int,
|
||||||
|
p_hhmm int,
|
||||||
|
p_power_w int,
|
||||||
|
p_soc_pct int,
|
||||||
|
p_grid_charge_bit int
|
||||||
|
)
|
||||||
|
returns int[]
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
as $fn$
|
||||||
|
select array[
|
||||||
|
148 + p_slot_index * 6,
|
||||||
|
p_hhmm,
|
||||||
|
154 + p_slot_index * 6,
|
||||||
|
p_power_w,
|
||||||
|
166 + p_slot_index * 6,
|
||||||
|
p_soc_pct,
|
||||||
|
172 + p_slot_index * 6,
|
||||||
|
p_grid_charge_bit
|
||||||
|
];
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_deye_time_point_regs(int, int, int, int, int) is
|
||||||
|
'Adresy a hodnoty pro jeden Deye TOU blok (reg páry 148/154/166/172 + offset slotu).';
|
||||||
21
db/routines/R__fn_deye_tou_inactive_signature.sql
Normal file
21
db/routines/R__fn_deye_tou_inactive_signature.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
create or replace function ems.fn_deye_tou_inactive_signature(
|
||||||
|
p_hhmm_inactive int,
|
||||||
|
p_min_soc_pct numeric,
|
||||||
|
p_reserve_soc_pct numeric,
|
||||||
|
p_tp_discharge_w int
|
||||||
|
)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
as $fn$
|
||||||
|
select concat_ws(
|
||||||
|
'|',
|
||||||
|
p_hhmm_inactive::text,
|
||||||
|
round(p_min_soc_pct, 2)::text,
|
||||||
|
round(p_reserve_soc_pct, 2)::text,
|
||||||
|
p_tp_discharge_w::text
|
||||||
|
);
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_deye_tou_inactive_signature(int, numeric, numeric, int) is
|
||||||
|
'Podpis neaktivních TOU slotů (shoda s asset_inverter.deye_tou_inactive_signature).';
|
||||||
69
db/routines/R__fn_economics_daily_month.sql
Normal file
69
db/routines/R__fn_economics_daily_month.sql
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
create or replace function ems.fn_economics_daily_month(
|
||||||
|
p_site_id int,
|
||||||
|
p_month_start date,
|
||||||
|
p_month_end date
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select jsonb_build_object(
|
||||||
|
'has_green_bonus',
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from ems.asset_pv_array apv
|
||||||
|
where apv.site_id = p_site_id
|
||||||
|
and apv.green_bonus_czk_kwh is not null
|
||||||
|
),
|
||||||
|
'days',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(sub.day_row order by sub.day_local)
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
r.day_local,
|
||||||
|
jsonb_build_object(
|
||||||
|
'day', r.day_local,
|
||||||
|
'interval_count', r.interval_count,
|
||||||
|
'import_kwh', r.import_kwh,
|
||||||
|
'export_kwh', r.export_kwh,
|
||||||
|
'pv_kwh', r.pv_kwh,
|
||||||
|
'load_kwh', r.load_kwh,
|
||||||
|
'pv_self_consumption_kwh', r.pv_self_consumption_kwh,
|
||||||
|
'ev_kwh', r.ev_kwh,
|
||||||
|
'hp_kwh', r.hp_kwh,
|
||||||
|
'import_cost_czk',
|
||||||
|
case when l.site_id is not null then l.import_cost_czk else r.import_cost_czk end,
|
||||||
|
'export_revenue_czk',
|
||||||
|
case when l.site_id is not null then l.export_revenue_czk else r.export_revenue_czk end,
|
||||||
|
'grid_import_cashflow_czk',
|
||||||
|
coalesce(l.grid_import_cashflow_czk, r.grid_import_cashflow_czk),
|
||||||
|
'grid_export_revenue_czk',
|
||||||
|
coalesce(l.grid_export_revenue_czk, r.grid_export_revenue_czk),
|
||||||
|
'net_cost_czk',
|
||||||
|
case when l.site_id is not null then l.net_cost_czk else r.net_cost_czk end,
|
||||||
|
'green_bonus_czk',
|
||||||
|
case when l.site_id is not null then l.green_bonus_czk else r.green_bonus_czk end,
|
||||||
|
'total_balance_czk',
|
||||||
|
case when l.site_id is not null then l.total_balance_czk else r.total_balance_czk end,
|
||||||
|
'planned_balance_czk', r.planned_balance_czk,
|
||||||
|
'deviation_cost_czk', r.deviation_cost_czk,
|
||||||
|
'is_locked', (l.site_id is not null)
|
||||||
|
) as day_row
|
||||||
|
from ems.vw_economics_daily r
|
||||||
|
left join ems.audit_day_lock l
|
||||||
|
on l.site_id = r.site_id
|
||||||
|
and l.day_local = r.day_local
|
||||||
|
where r.site_id = p_site_id
|
||||||
|
and r.day_local >= p_month_start
|
||||||
|
and r.day_local < p_month_end
|
||||||
|
order by r.day_local
|
||||||
|
) sub
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_economics_daily_month(int, date, date) is
|
||||||
|
'Měsíční denní ekonomika + lock merge jako JSON (GET /economics/daily).';
|
||||||
74
db/routines/R__fn_economics_lock_day.sql
Normal file
74
db/routines/R__fn_economics_lock_day.sql
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
create or replace function ems.fn_economics_lock_day(p_site_id int, p_day date)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_import_cost numeric;
|
||||||
|
v_export_rev numeric;
|
||||||
|
v_net numeric;
|
||||||
|
v_green numeric;
|
||||||
|
v_total numeric;
|
||||||
|
v_gic numeric;
|
||||||
|
v_ger numeric;
|
||||||
|
begin
|
||||||
|
select
|
||||||
|
r.import_cost_czk,
|
||||||
|
r.export_revenue_czk,
|
||||||
|
r.net_cost_czk,
|
||||||
|
r.green_bonus_czk,
|
||||||
|
r.total_balance_czk,
|
||||||
|
r.grid_import_cashflow_czk,
|
||||||
|
r.grid_export_revenue_czk
|
||||||
|
into strict
|
||||||
|
v_import_cost,
|
||||||
|
v_export_rev,
|
||||||
|
v_net,
|
||||||
|
v_green,
|
||||||
|
v_total,
|
||||||
|
v_gic,
|
||||||
|
v_ger
|
||||||
|
from ems.vw_economics_daily r
|
||||||
|
where r.site_id = p_site_id
|
||||||
|
and r.day_local = p_day;
|
||||||
|
|
||||||
|
insert into ems.audit_day_lock (
|
||||||
|
site_id,
|
||||||
|
day_local,
|
||||||
|
import_cost_czk,
|
||||||
|
export_revenue_czk,
|
||||||
|
net_cost_czk,
|
||||||
|
green_bonus_czk,
|
||||||
|
total_balance_czk,
|
||||||
|
grid_import_cashflow_czk,
|
||||||
|
grid_export_revenue_czk
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
p_site_id,
|
||||||
|
p_day,
|
||||||
|
v_import_cost,
|
||||||
|
v_export_rev,
|
||||||
|
v_net,
|
||||||
|
v_green,
|
||||||
|
v_total,
|
||||||
|
v_gic,
|
||||||
|
v_ger
|
||||||
|
)
|
||||||
|
on conflict (site_id, day_local) do update set
|
||||||
|
import_cost_czk = excluded.import_cost_czk,
|
||||||
|
export_revenue_czk = excluded.export_revenue_czk,
|
||||||
|
net_cost_czk = excluded.net_cost_czk,
|
||||||
|
green_bonus_czk = excluded.green_bonus_czk,
|
||||||
|
total_balance_czk = excluded.total_balance_czk,
|
||||||
|
grid_import_cashflow_czk = excluded.grid_import_cashflow_czk,
|
||||||
|
grid_export_revenue_czk = excluded.grid_export_revenue_czk,
|
||||||
|
locked_at = now();
|
||||||
|
|
||||||
|
return jsonb_build_object('locked', true, 'day', p_day);
|
||||||
|
exception
|
||||||
|
when no_data_found then
|
||||||
|
return jsonb_build_object('locked', false, 'error', 'no_economics_data');
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_economics_lock_day(int, date) is
|
||||||
|
'Zamkne den ekonomiky podle aktuálního vw_economics_daily (POST lock).';
|
||||||
62
db/routines/R__fn_economics_monthly_chart.sql
Normal file
62
db/routines/R__fn_economics_monthly_chart.sql
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
create or replace function ems.fn_economics_monthly_chart(
|
||||||
|
p_site_id int,
|
||||||
|
p_month_start date,
|
||||||
|
p_month_end date
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
with base as (
|
||||||
|
select
|
||||||
|
r.day_local,
|
||||||
|
case when l.site_id is not null then l.total_balance_czk else r.total_balance_czk end as tb,
|
||||||
|
case when l.site_id is not null then l.net_cost_czk else r.net_cost_czk end as nc,
|
||||||
|
case when l.site_id is not null then l.green_bonus_czk else r.green_bonus_czk end as gb,
|
||||||
|
coalesce(l.grid_import_cashflow_czk, r.grid_import_cashflow_czk) as gic,
|
||||||
|
coalesce(l.grid_export_revenue_czk, r.grid_export_revenue_czk) as ger
|
||||||
|
from ems.vw_economics_daily r
|
||||||
|
left join ems.audit_day_lock l
|
||||||
|
on l.site_id = r.site_id
|
||||||
|
and l.day_local = r.day_local
|
||||||
|
where r.site_id = p_site_id
|
||||||
|
and r.day_local >= p_month_start
|
||||||
|
and r.day_local < p_month_end
|
||||||
|
),
|
||||||
|
w as (
|
||||||
|
select
|
||||||
|
day_local,
|
||||||
|
round(tb::numeric, 2) as daily_balance_czk,
|
||||||
|
round((-nc)::numeric, 2) as daily_grid_balance_czk,
|
||||||
|
round(gb::numeric, 2) as daily_green_bonus_czk,
|
||||||
|
round(gic::numeric, 2) as daily_import_cost_czk,
|
||||||
|
round(ger::numeric, 2) as daily_export_revenue_czk,
|
||||||
|
sum(round(tb::numeric, 2)) over (
|
||||||
|
order by day_local rows between unbounded preceding and current row
|
||||||
|
) as cumb,
|
||||||
|
sum(round((-nc)::numeric, 2)) over (
|
||||||
|
order by day_local rows between unbounded preceding and current row
|
||||||
|
) as cumg
|
||||||
|
from base
|
||||||
|
)
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'day', day_local,
|
||||||
|
'daily_balance_czk', daily_balance_czk,
|
||||||
|
'daily_grid_balance_czk', daily_grid_balance_czk,
|
||||||
|
'daily_green_bonus_czk', daily_green_bonus_czk,
|
||||||
|
'daily_import_cost_czk', daily_import_cost_czk,
|
||||||
|
'daily_export_revenue_czk', daily_export_revenue_czk,
|
||||||
|
'cumulative_balance_czk', round(cumb::numeric, 2),
|
||||||
|
'cumulative_grid_balance_czk', round(cumg::numeric, 2)
|
||||||
|
)
|
||||||
|
order by day_local
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
from w;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_economics_monthly_chart(int, date, date) is
|
||||||
|
'Křivka měsíční bilance s běžícími součty (GET /economics/monthly-chart).';
|
||||||
15
db/routines/R__fn_economics_unlock_day.sql
Normal file
15
db/routines/R__fn_economics_unlock_day.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
create or replace function ems.fn_economics_unlock_day(p_site_id int, p_day date)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
as $fn$
|
||||||
|
begin
|
||||||
|
delete from ems.audit_day_lock
|
||||||
|
where site_id = p_site_id
|
||||||
|
and day_local = p_day;
|
||||||
|
|
||||||
|
return jsonb_build_object('locked', false, 'day', p_day);
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_economics_unlock_day(int, date) is
|
||||||
|
'Odebere zámek dne ekonomiky (DELETE lock).';
|
||||||
82
db/routines/R__fn_energy_flows_daily_month.sql
Normal file
82
db/routines/R__fn_energy_flows_daily_month.sql
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
create or replace function ems.fn_energy_flows_daily_month(
|
||||||
|
p_site_id int,
|
||||||
|
p_month_start date,
|
||||||
|
p_month_end date
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select jsonb_build_object(
|
||||||
|
'days',
|
||||||
|
coalesce(
|
||||||
|
jsonb_agg(t.row_json order by t.day_local),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
)
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
(date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date as day_local,
|
||||||
|
jsonb_build_object(
|
||||||
|
'day', (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date,
|
||||||
|
'interval_count', count(*)::int,
|
||||||
|
'pv_production_kwh', round(sum(coalesce(ai.actual_pv_production_wh, 0)) / 1000, 3),
|
||||||
|
'grid_import_kwh', round(sum(coalesce(ai.actual_grid_import_wh, 0)) / 1000, 3),
|
||||||
|
'grid_export_kwh', round(sum(coalesce(ai.actual_grid_export_wh, 0)) / 1000, 3),
|
||||||
|
'batt_charge_kwh', round(sum(coalesce(ai.actual_batt_charge_wh, 0)) / 1000, 3),
|
||||||
|
'batt_discharge_kwh', round(sum(coalesce(ai.actual_batt_discharge_wh, 0)) / 1000, 3),
|
||||||
|
'load_kwh', round(sum(coalesce(ai.actual_load_consumption_wh, 0)) / 1000, 3),
|
||||||
|
'pv_to_load_kwh', round(sum(coalesce(ai.flow_pv_to_load_wh, 0)) / 1000, 3),
|
||||||
|
'pv_to_batt_kwh', round(sum(coalesce(ai.flow_pv_to_batt_wh, 0)) / 1000, 3),
|
||||||
|
'pv_to_grid_kwh', round(sum(coalesce(ai.flow_pv_to_grid_wh, 0)) / 1000, 3),
|
||||||
|
'batt_to_load_kwh', round(sum(coalesce(ai.flow_batt_to_load_wh, 0)) / 1000, 3),
|
||||||
|
'batt_to_grid_kwh', round(sum(coalesce(ai.flow_batt_to_grid_wh, 0)) / 1000, 3),
|
||||||
|
'grid_to_load_kwh', round(sum(coalesce(ai.flow_grid_to_load_wh, 0)) / 1000, 3),
|
||||||
|
'grid_to_batt_kwh', round(sum(coalesce(ai.flow_grid_to_batt_wh, 0)) / 1000, 3),
|
||||||
|
'grid_import_cashflow_czk',
|
||||||
|
round(
|
||||||
|
sum(
|
||||||
|
coalesce(ai.actual_grid_import_wh, 0) / 1000.0
|
||||||
|
* coalesce(ep.effective_buy_price_czk_kwh, 0)
|
||||||
|
),
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'grid_export_revenue_czk',
|
||||||
|
round(
|
||||||
|
sum(
|
||||||
|
coalesce(ai.actual_grid_export_wh, 0) / 1000.0
|
||||||
|
* coalesce(ep.effective_sell_price_czk_kwh, 0)
|
||||||
|
),
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'grid_to_load_cost_czk',
|
||||||
|
round(
|
||||||
|
sum(
|
||||||
|
coalesce(ai.flow_grid_to_load_wh, 0) / 1000.0
|
||||||
|
* coalesce(ep.effective_buy_price_czk_kwh, 0)
|
||||||
|
),
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'grid_to_batt_cost_czk',
|
||||||
|
round(
|
||||||
|
sum(
|
||||||
|
coalesce(ai.flow_grid_to_batt_wh, 0) / 1000.0
|
||||||
|
* coalesce(ep.effective_buy_price_czk_kwh, 0)
|
||||||
|
),
|
||||||
|
2
|
||||||
|
)
|
||||||
|
) as row_json
|
||||||
|
from ems.audit_interval ai
|
||||||
|
left join ems.vw_site_effective_price ep
|
||||||
|
on ep.site_id = ai.site_id
|
||||||
|
and ep.interval_start = ai.interval_start
|
||||||
|
where ai.site_id = p_site_id
|
||||||
|
and (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date >= p_month_start
|
||||||
|
and (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date < p_month_end
|
||||||
|
group by 1
|
||||||
|
order by 1
|
||||||
|
) t;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_energy_flows_daily_month(int, date, date) is
|
||||||
|
'Denní agregace energy flows za měsíc jako JSON pole řádků.';
|
||||||
86
db/routines/R__fn_energy_flows_intervals_day.sql
Normal file
86
db/routines/R__fn_energy_flows_intervals_day.sql
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
create or replace function ems.fn_energy_flows_intervals_day(p_site_id int, p_day date)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'interval_start', ai.interval_start,
|
||||||
|
'pv_production_kwh',
|
||||||
|
case
|
||||||
|
when ai.actual_pv_production_wh is null then null
|
||||||
|
else round(ai.actual_pv_production_wh::numeric / 1000, 4)
|
||||||
|
end,
|
||||||
|
'grid_import_kwh',
|
||||||
|
case
|
||||||
|
when ai.actual_grid_import_wh is null then null
|
||||||
|
else round(ai.actual_grid_import_wh::numeric / 1000, 4)
|
||||||
|
end,
|
||||||
|
'grid_export_kwh',
|
||||||
|
case
|
||||||
|
when ai.actual_grid_export_wh is null then null
|
||||||
|
else round(ai.actual_grid_export_wh::numeric / 1000, 4)
|
||||||
|
end,
|
||||||
|
'batt_charge_kwh',
|
||||||
|
case
|
||||||
|
when ai.actual_batt_charge_wh is null then null
|
||||||
|
else round(ai.actual_batt_charge_wh::numeric / 1000, 4)
|
||||||
|
end,
|
||||||
|
'batt_discharge_kwh',
|
||||||
|
case
|
||||||
|
when ai.actual_batt_discharge_wh is null then null
|
||||||
|
else round(ai.actual_batt_discharge_wh::numeric / 1000, 4)
|
||||||
|
end,
|
||||||
|
'load_kwh',
|
||||||
|
case
|
||||||
|
when ai.actual_load_consumption_wh is null then null
|
||||||
|
else round(ai.actual_load_consumption_wh::numeric / 1000, 4)
|
||||||
|
end,
|
||||||
|
'pv_to_load_kwh',
|
||||||
|
case
|
||||||
|
when ai.flow_pv_to_load_wh is null then null
|
||||||
|
else round(ai.flow_pv_to_load_wh::numeric / 1000, 4)
|
||||||
|
end,
|
||||||
|
'pv_to_batt_kwh',
|
||||||
|
case
|
||||||
|
when ai.flow_pv_to_batt_wh is null then null
|
||||||
|
else round(ai.flow_pv_to_batt_wh::numeric / 1000, 4)
|
||||||
|
end,
|
||||||
|
'pv_to_grid_kwh',
|
||||||
|
case
|
||||||
|
when ai.flow_pv_to_grid_wh is null then null
|
||||||
|
else round(ai.flow_pv_to_grid_wh::numeric / 1000, 4)
|
||||||
|
end,
|
||||||
|
'batt_to_load_kwh',
|
||||||
|
case
|
||||||
|
when ai.flow_batt_to_load_wh is null then null
|
||||||
|
else round(ai.flow_batt_to_load_wh::numeric / 1000, 4)
|
||||||
|
end,
|
||||||
|
'batt_to_grid_kwh',
|
||||||
|
case
|
||||||
|
when ai.flow_batt_to_grid_wh is null then null
|
||||||
|
else round(ai.flow_batt_to_grid_wh::numeric / 1000, 4)
|
||||||
|
end,
|
||||||
|
'grid_to_load_kwh',
|
||||||
|
case
|
||||||
|
when ai.flow_grid_to_load_wh is null then null
|
||||||
|
else round(ai.flow_grid_to_load_wh::numeric / 1000, 4)
|
||||||
|
end,
|
||||||
|
'grid_to_batt_kwh',
|
||||||
|
case
|
||||||
|
when ai.flow_grid_to_batt_wh is null then null
|
||||||
|
else round(ai.flow_grid_to_batt_wh::numeric / 1000, 4)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
order by ai.interval_start
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
from ems.audit_interval ai
|
||||||
|
where ai.site_id = p_site_id
|
||||||
|
and (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date = p_day;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_energy_flows_intervals_day(int, date) is
|
||||||
|
'15min energy flows pro jeden kalendářní den (Prague) jako JSON pole.';
|
||||||
70
db/routines/R__fn_ev_arrival_prediction_bundle.sql
Normal file
70
db/routines/R__fn_ev_arrival_prediction_bundle.sql
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
create or replace function ems.fn_ev_arrival_prediction_bundle(p_site_id int)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_tz text;
|
||||||
|
v_tomorrow date;
|
||||||
|
v_n_sessions int;
|
||||||
|
v_insufficient boolean;
|
||||||
|
v_chargers jsonb := '{}'::jsonb;
|
||||||
|
r record;
|
||||||
|
v_rows jsonb;
|
||||||
|
begin
|
||||||
|
select coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague')
|
||||||
|
into v_tz
|
||||||
|
from ems.site s
|
||||||
|
where s.id = p_site_id;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
return jsonb_build_object('error', 'site_not_found');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_tomorrow := (
|
||||||
|
(current_timestamp at time zone v_tz)::date + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
select count(*)::int
|
||||||
|
into v_n_sessions
|
||||||
|
from ems.ev_session
|
||||||
|
where site_id = p_site_id;
|
||||||
|
|
||||||
|
v_insufficient := coalesce(v_n_sessions, 0) < 5;
|
||||||
|
|
||||||
|
for r in
|
||||||
|
select id, code
|
||||||
|
from ems.asset_ev_charger
|
||||||
|
where site_id = p_site_id
|
||||||
|
order by id
|
||||||
|
loop
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'hour', x.expected_hour,
|
||||||
|
'confidence_pct', x.confidence_pct,
|
||||||
|
'samples', x.sample_count
|
||||||
|
)
|
||||||
|
order by x.expected_hour
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
into v_rows
|
||||||
|
from ems.fn_ev_expected_arrival(p_site_id, r.id, v_tomorrow) x;
|
||||||
|
|
||||||
|
v_chargers := v_chargers || jsonb_build_object(
|
||||||
|
r.code::text,
|
||||||
|
jsonb_build_object('tomorrow', coalesce(v_rows, '[]'::jsonb))
|
||||||
|
);
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'insufficient_data', v_insufficient,
|
||||||
|
'tomorrow_date', v_tomorrow,
|
||||||
|
'chargers', v_chargers
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_ev_arrival_prediction_bundle(int) is
|
||||||
|
'Predikce příjezdů pro všechny nabíječky (nahrazuje N+1 volání fn_ev_expected_arrival).';
|
||||||
49
db/routines/R__fn_ev_session_patch.sql
Normal file
49
db/routines/R__fn_ev_session_patch.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
create or replace function ems.fn_ev_session_apply_patch(
|
||||||
|
p_site_id int,
|
||||||
|
p_session_id int,
|
||||||
|
p_patch jsonb
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_id int;
|
||||||
|
begin
|
||||||
|
if not (p_patch ? 'target_soc_pct') and not (p_patch ? 'target_deadline') then
|
||||||
|
return jsonb_build_object('success', false, 'error', 'no_fields');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update ems.ev_session es
|
||||||
|
set
|
||||||
|
target_soc_pct = case
|
||||||
|
when p_patch ? 'target_soc_pct' then
|
||||||
|
case
|
||||||
|
when p_patch->'target_soc_pct' is null
|
||||||
|
or jsonb_typeof(p_patch->'target_soc_pct') = 'null' then null
|
||||||
|
else (p_patch->>'target_soc_pct')::double precision
|
||||||
|
end
|
||||||
|
else es.target_soc_pct
|
||||||
|
end,
|
||||||
|
target_deadline = case
|
||||||
|
when p_patch ? 'target_deadline' then
|
||||||
|
case
|
||||||
|
when p_patch->'target_deadline' is null
|
||||||
|
or jsonb_typeof(p_patch->'target_deadline') = 'null' then null
|
||||||
|
else (p_patch->>'target_deadline')::timestamptz
|
||||||
|
end
|
||||||
|
else es.target_deadline
|
||||||
|
end
|
||||||
|
where es.id = p_session_id
|
||||||
|
and es.site_id = p_site_id
|
||||||
|
returning es.id into v_id;
|
||||||
|
|
||||||
|
if v_id is null then
|
||||||
|
return jsonb_build_object('success', false, 'session_id', null);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return jsonb_build_object('success', true, 'session_id', v_id);
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_ev_session_apply_patch(int, int, jsonb) is
|
||||||
|
'PATCH EV session – jen klíče přítomné v JSON (ISO string pro deadline).';
|
||||||
87
db/routines/R__fn_ev_session_transition.sql
Normal file
87
db/routines/R__fn_ev_session_transition.sql
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
create or replace function ems.fn_ev_session_transition(
|
||||||
|
p_site_id int,
|
||||||
|
p_charger_id int,
|
||||||
|
p_prev_status text,
|
||||||
|
p_new_status text,
|
||||||
|
p_measured_at timestamptz
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_vehicle_id int;
|
||||||
|
begin
|
||||||
|
if p_prev_status is not distinct from p_new_status then
|
||||||
|
return jsonb_build_object('action', 'none');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_prev_status = 'available' and p_new_status is distinct from 'available' then
|
||||||
|
select av.id
|
||||||
|
into v_vehicle_id
|
||||||
|
from ems.asset_vehicle av
|
||||||
|
where av.site_id = p_site_id
|
||||||
|
and av.default_charger_id = p_charger_id
|
||||||
|
and av.active = true
|
||||||
|
order by av.id
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
perform ems.fn_update_ev_arrival_stats(
|
||||||
|
p_site_id,
|
||||||
|
p_charger_id,
|
||||||
|
v_vehicle_id,
|
||||||
|
p_measured_at
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = p_charger_id
|
||||||
|
and ac.site_id = p_site_id
|
||||||
|
on conflict (charger_id) where session_end is null do nothing;
|
||||||
|
|
||||||
|
return jsonb_build_object('action', 'arrival');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_prev_status is distinct from 'available' and p_new_status = 'available' then
|
||||||
|
update ems.ev_session es
|
||||||
|
set session_end = now()
|
||||||
|
where es.charger_id = p_charger_id
|
||||||
|
and es.session_end is null;
|
||||||
|
|
||||||
|
return jsonb_build_object('action', 'departure');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return jsonb_build_object('action', 'none');
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_ev_session_transition(int, int, text, text, timestamptz) is
|
||||||
|
'Detekce příjezdu/odjezdu EV po změně statusu nabíječky (telemetry_collector).';
|
||||||
49
db/routines/R__fn_ev_sessions_active.sql
Normal file
49
db/routines/R__fn_ev_sessions_active.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
create or replace function ems.fn_ev_sessions_active(p_site_id int)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', es.id,
|
||||||
|
'charger_id', es.charger_id,
|
||||||
|
'vehicle_id', es.vehicle_id,
|
||||||
|
'session_start', es.session_start,
|
||||||
|
'energy_delivered_wh', es.energy_delivered_wh,
|
||||||
|
'target_soc_pct', es.target_soc_pct,
|
||||||
|
'target_deadline', es.target_deadline,
|
||||||
|
'make', av.make,
|
||||||
|
'model', av.model,
|
||||||
|
'battery_capacity_kwh', av.battery_capacity_kwh,
|
||||||
|
'default_target_soc_pct', av.default_target_soc_pct,
|
||||||
|
'default_deadline_hour', av.default_deadline_hour,
|
||||||
|
'charger_code', ac.code,
|
||||||
|
'charger_name',
|
||||||
|
coalesce(
|
||||||
|
nullif(
|
||||||
|
trim(
|
||||||
|
concat_ws(
|
||||||
|
' ',
|
||||||
|
nullif(trim(ac.manufacturer), ''),
|
||||||
|
nullif(trim(ac.model), '')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
''
|
||||||
|
),
|
||||||
|
ac.code
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order by es.session_start desc
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
from ems.ev_session es
|
||||||
|
left join ems.asset_vehicle av on av.id = es.vehicle_id
|
||||||
|
join ems.asset_ev_charger ac on ac.id = es.charger_id
|
||||||
|
where es.site_id = p_site_id
|
||||||
|
and es.session_end is null;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_ev_sessions_active(int) is
|
||||||
|
'Aktivní EV session pro site (GET /ev/sessions/active).';
|
||||||
39
db/routines/R__fn_fill_audit_for_site_window.sql
Normal file
39
db/routines/R__fn_fill_audit_for_site_window.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
-- doplní chybějící audit_interval za posledních p_hours hodin (15min sloty)
|
||||||
|
|
||||||
|
create or replace function ems.fn_fill_audit_for_site_window(
|
||||||
|
p_site_id int,
|
||||||
|
p_hours int default 6
|
||||||
|
)
|
||||||
|
returns int
|
||||||
|
language plpgsql
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_last timestamptz;
|
||||||
|
v_slot timestamptz;
|
||||||
|
v_cnt int := 0;
|
||||||
|
begin
|
||||||
|
v_last := date_trunc('minute', now())
|
||||||
|
- (extract(minute from now())::int % 15) * interval '1 minute';
|
||||||
|
v_last := v_last - interval '15 minutes';
|
||||||
|
|
||||||
|
for v_slot in
|
||||||
|
select gs.slot
|
||||||
|
from generate_series(
|
||||||
|
v_last - make_interval(hours => p_hours),
|
||||||
|
v_last,
|
||||||
|
interval '15 minutes'
|
||||||
|
) as gs(slot)
|
||||||
|
where not exists (
|
||||||
|
select 1 from ems.audit_interval ai
|
||||||
|
where ai.site_id = p_site_id
|
||||||
|
and ai.interval_start = gs.slot
|
||||||
|
)
|
||||||
|
loop
|
||||||
|
perform ems.fn_fill_audit_interval(p_site_id, v_slot);
|
||||||
|
perform ems.fn_fill_baseline_load_forecast_accuracy(p_site_id, v_slot);
|
||||||
|
v_cnt := v_cnt + 1;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
return v_cnt;
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
74
db/routines/R__fn_forecast_pv_split.sql
Normal file
74
db/routines/R__fn_forecast_pv_split.sql
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
create or replace function ems.fn_forecast_pv_split(p_site_id int, p_day date)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
with latest as (
|
||||||
|
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,
|
||||||
|
apa.controllable
|
||||||
|
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 = p_site_id
|
||||||
|
and (
|
||||||
|
fpi.interval_start at time zone coalesce(
|
||||||
|
nullif(trim((select timezone from ems.site s where s.id = p_site_id)), ''),
|
||||||
|
'Europe/Prague'
|
||||||
|
)
|
||||||
|
)::date = p_day
|
||||||
|
and fpr.status = 'ok'
|
||||||
|
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
|
||||||
|
),
|
||||||
|
rows as (
|
||||||
|
select
|
||||||
|
case
|
||||||
|
when controllable then 'a'
|
||||||
|
else 'b'
|
||||||
|
end as pole,
|
||||||
|
jsonb_build_object(
|
||||||
|
'run_id', run_id,
|
||||||
|
'pv_array_id', pv_array_id,
|
||||||
|
'interval_start', interval_start,
|
||||||
|
'power_w', power_w,
|
||||||
|
'irradiance_wm2', irradiance_wm2,
|
||||||
|
'temp_c', temp_c,
|
||||||
|
'pv_array_code', pv_array_code
|
||||||
|
) as j,
|
||||||
|
controllable,
|
||||||
|
pv_array_code,
|
||||||
|
interval_start
|
||||||
|
from latest
|
||||||
|
)
|
||||||
|
select jsonb_build_object(
|
||||||
|
'pv_a',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(j order by pv_array_code, interval_start)
|
||||||
|
from rows
|
||||||
|
where controllable
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'pv_b',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(j order by pv_array_code, interval_start)
|
||||||
|
from rows
|
||||||
|
where not controllable
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_forecast_pv_split(int, date) is
|
||||||
|
'Predikce FVE rozsplitěná na pole A (controllable) a B pro UI.';
|
||||||
69
db/routines/R__fn_inverter_modbus_caps_patch.sql
Normal file
69
db/routines/R__fn_inverter_modbus_caps_patch.sql
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
create or replace function ems.fn_inverter_modbus_caps_patch(
|
||||||
|
p_site_id int,
|
||||||
|
p_inverter_id int,
|
||||||
|
p_patch jsonb
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_charge int;
|
||||||
|
v_discharge int;
|
||||||
|
r record;
|
||||||
|
begin
|
||||||
|
if not (p_patch ? 'deye_register_max_charge_a')
|
||||||
|
and not (p_patch ? 'deye_register_max_discharge_a') then
|
||||||
|
return jsonb_build_object('ok', false, 'error', 'no_fields');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_charge := case
|
||||||
|
when p_patch ? 'deye_register_max_charge_a' then
|
||||||
|
case
|
||||||
|
when p_patch->'deye_register_max_charge_a' is null
|
||||||
|
or jsonb_typeof(p_patch->'deye_register_max_charge_a') = 'null' then null
|
||||||
|
else (p_patch->>'deye_register_max_charge_a')::int
|
||||||
|
end
|
||||||
|
else null
|
||||||
|
end;
|
||||||
|
|
||||||
|
v_discharge := case
|
||||||
|
when p_patch ? 'deye_register_max_discharge_a' then
|
||||||
|
case
|
||||||
|
when p_patch->'deye_register_max_discharge_a' is null
|
||||||
|
or jsonb_typeof(p_patch->'deye_register_max_discharge_a') = 'null' then null
|
||||||
|
else (p_patch->>'deye_register_max_discharge_a')::int
|
||||||
|
end
|
||||||
|
else null
|
||||||
|
end;
|
||||||
|
|
||||||
|
update ems.asset_inverter ai
|
||||||
|
set
|
||||||
|
deye_register_max_charge_a = case
|
||||||
|
when p_patch ? 'deye_register_max_charge_a' then v_charge
|
||||||
|
else ai.deye_register_max_charge_a
|
||||||
|
end,
|
||||||
|
deye_register_max_discharge_a = case
|
||||||
|
when p_patch ? 'deye_register_max_discharge_a' then v_discharge
|
||||||
|
else ai.deye_register_max_discharge_a
|
||||||
|
end
|
||||||
|
where ai.id = p_inverter_id
|
||||||
|
and ai.site_id = p_site_id
|
||||||
|
returning ai.id, ai.code, ai.deye_register_max_charge_a, ai.deye_register_max_discharge_a
|
||||||
|
into r;
|
||||||
|
|
||||||
|
if r.id is null then
|
||||||
|
return jsonb_build_object('ok', false, 'error', 'not_found');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'inverter_id', r.id,
|
||||||
|
'code', r.code,
|
||||||
|
'deye_register_max_charge_a', r.deye_register_max_charge_a,
|
||||||
|
'deye_register_max_discharge_a', r.deye_register_max_discharge_a
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_inverter_modbus_caps_patch(int, int, jsonb) is
|
||||||
|
'PATCH stropů proudu reg 108/109 – explicitní JSON null maže strop.';
|
||||||
23
db/routines/R__fn_latest_ote_day_stats.sql
Normal file
23
db/routines/R__fn_latest_ote_day_stats.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
create or replace function ems.fn_latest_ote_day_stats()
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select to_jsonb(sub)
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
(mip.interval_start at time zone 'Europe/Prague')::date as latest_date,
|
||||||
|
count(*)::int as slots,
|
||||||
|
min(mip.buy_raw_price_czk_kwh)::float as min_price,
|
||||||
|
max(mip.buy_raw_price_czk_kwh)::float as max_price,
|
||||||
|
avg(mip.buy_raw_price_czk_kwh)::float as avg_price
|
||||||
|
from ems.market_interval_price mip
|
||||||
|
where mip.market_source in ('OTE_CZ', 'OTE_CZ_DAM')
|
||||||
|
group by (mip.interval_start at time zone 'Europe/Prague')::date
|
||||||
|
order by latest_date desc
|
||||||
|
limit 1
|
||||||
|
) sub;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_latest_ote_day_stats() is
|
||||||
|
'Agregace posledního kalendářního dne s OTE daty (globální, bez site_id).';
|
||||||
285
db/routines/R__fn_load_planning_slots_full.sql
Normal file
285
db/routines/R__fn_load_planning_slots_full.sql
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
-- sloty pro LP: ceny, forecast, baseline, EV připojení + masky allow_charge / allow_discharge_export
|
||||||
|
|
||||||
|
create or replace function ems.fn_load_planning_slots_full(
|
||||||
|
p_site_id int,
|
||||||
|
p_from timestamptz,
|
||||||
|
p_to timestamptz,
|
||||||
|
p_current_soc_wh numeric
|
||||||
|
)
|
||||||
|
returns table (
|
||||||
|
slot_ord int,
|
||||||
|
interval_start timestamptz,
|
||||||
|
buy_price numeric,
|
||||||
|
sell_price numeric,
|
||||||
|
is_predicted_price boolean,
|
||||||
|
pv_a_forecast_w int,
|
||||||
|
pv_b_forecast_w int,
|
||||||
|
load_baseline_w int,
|
||||||
|
ev1_connected boolean,
|
||||||
|
ev2_connected boolean,
|
||||||
|
allow_charge boolean,
|
||||||
|
allow_discharge_export boolean
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_charge_buf numeric;
|
||||||
|
v_discharge_buf numeric;
|
||||||
|
v_usable numeric;
|
||||||
|
v_min_soc_wh numeric;
|
||||||
|
v_soc_max_wh numeric;
|
||||||
|
v_energy_to_fill numeric;
|
||||||
|
v_exportable numeric;
|
||||||
|
v_charge_eff numeric;
|
||||||
|
v_discharge_eff numeric;
|
||||||
|
v_max_charge_w numeric;
|
||||||
|
v_max_discharge_w numeric;
|
||||||
|
v_per_slot_charge_wh numeric;
|
||||||
|
v_per_slot_discharge_wh numeric;
|
||||||
|
v_grid_target_wh numeric;
|
||||||
|
v_discharge_target_wh numeric;
|
||||||
|
v_cum numeric;
|
||||||
|
r_slot record;
|
||||||
|
begin
|
||||||
|
drop table if exists _ems_plan_slot_wk;
|
||||||
|
create temp table _ems_plan_slot_wk on commit drop as
|
||||||
|
with slot_spine as (
|
||||||
|
select gs as interval_start
|
||||||
|
from generate_series(
|
||||||
|
p_from,
|
||||||
|
(p_to - interval '15 minutes')::timestamptz,
|
||||||
|
interval '15 minutes'
|
||||||
|
) as gs
|
||||||
|
)
|
||||||
|
select
|
||||||
|
row_number() over (order by s.interval_start) - 1 as slot_ord,
|
||||||
|
s.interval_start,
|
||||||
|
coalesce(
|
||||||
|
ep.effective_buy_price_czk_kwh,
|
||||||
|
ems.fn_get_predicted_price(p_site_id, s.interval_start)
|
||||||
|
) as buy_price,
|
||||||
|
coalesce(
|
||||||
|
ep.effective_sell_price_czk_kwh,
|
||||||
|
ems.fn_get_predicted_price(p_site_id, 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)::int as pv_a_forecast_w,
|
||||||
|
coalesce(fpi_b.power_w, 0)::int as pv_b_forecast_w,
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select bs.avg_power_w
|
||||||
|
from ems.consumption_baseline_stats bs
|
||||||
|
where bs.site_id = p_site_id
|
||||||
|
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
|
||||||
|
)::int 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,
|
||||||
|
greatest(
|
||||||
|
0,
|
||||||
|
coalesce(fpi_a.power_w, 0) + coalesce(fpi_b.power_w, 0)
|
||||||
|
- coalesce(
|
||||||
|
(
|
||||||
|
select bs.avg_power_w
|
||||||
|
from ems.consumption_baseline_stats bs
|
||||||
|
where bs.site_id = p_site_id
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)::int as pv_surplus_w,
|
||||||
|
false::boolean as allow_charge,
|
||||||
|
false::boolean as allow_discharge_export
|
||||||
|
from slot_spine s
|
||||||
|
left join ems.vw_site_effective_price ep
|
||||||
|
on ep.site_id = p_site_id and ep.interval_start = s.interval_start
|
||||||
|
left join lateral (
|
||||||
|
select coalesce(sum(u.power_w), 0)::int as power_w
|
||||||
|
from (
|
||||||
|
select distinct on (apa.id)
|
||||||
|
fpi.power_w
|
||||||
|
from ems.asset_pv_array apa
|
||||||
|
join ems.forecast_pv_run fpr
|
||||||
|
on fpr.pv_array_id = apa.id
|
||||||
|
and fpr.site_id = apa.site_id
|
||||||
|
and fpr.status = 'ok'
|
||||||
|
join ems.forecast_pv_interval fpi
|
||||||
|
on fpi.run_id = fpr.id
|
||||||
|
and fpi.pv_array_id = apa.id
|
||||||
|
and fpi.interval_start = s.interval_start
|
||||||
|
where apa.site_id = p_site_id
|
||||||
|
and apa.controllable is true
|
||||||
|
order by apa.id, fpr.created_at desc
|
||||||
|
) u
|
||||||
|
) fpi_a on true
|
||||||
|
left join lateral (
|
||||||
|
select coalesce(sum(u.power_w), 0)::int as power_w
|
||||||
|
from (
|
||||||
|
select distinct on (apa.id)
|
||||||
|
fpi.power_w
|
||||||
|
from ems.asset_pv_array apa
|
||||||
|
join ems.forecast_pv_run fpr
|
||||||
|
on fpr.pv_array_id = apa.id
|
||||||
|
and fpr.site_id = apa.site_id
|
||||||
|
and fpr.status = 'ok'
|
||||||
|
join ems.forecast_pv_interval fpi
|
||||||
|
on fpi.run_id = fpr.id
|
||||||
|
and fpi.pv_array_id = apa.id
|
||||||
|
and fpi.interval_start = s.interval_start
|
||||||
|
where apa.site_id = p_site_id
|
||||||
|
and apa.controllable is false
|
||||||
|
order by apa.id, fpr.created_at desc
|
||||||
|
) u
|
||||||
|
) fpi_b on true
|
||||||
|
left join lateral (
|
||||||
|
select t.status
|
||||||
|
from ems.telemetry_ev_charger t
|
||||||
|
join ems.asset_ev_charger ch on ch.id = t.charger_id
|
||||||
|
where t.site_id = p_site_id and ch.code = 'ev-charger-1'
|
||||||
|
order by t.measured_at desc
|
||||||
|
limit 1
|
||||||
|
) ev1 on true
|
||||||
|
left join lateral (
|
||||||
|
select t.status
|
||||||
|
from ems.telemetry_ev_charger t
|
||||||
|
join ems.asset_ev_charger ch on ch.id = t.charger_id
|
||||||
|
where t.site_id = p_site_id and ch.code = 'ev-charger-2'
|
||||||
|
order by t.measured_at desc
|
||||||
|
limit 1
|
||||||
|
) ev2 on true;
|
||||||
|
|
||||||
|
if not exists (select 1 from _ems_plan_slot_wk) then
|
||||||
|
raise exception 'No planning slots available – check market prices and horizon settings';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select
|
||||||
|
coalesce(ab.charge_slot_buffer, 0::numeric),
|
||||||
|
coalesce(ab.discharge_slot_buffer, 0::numeric),
|
||||||
|
ab.usable_capacity_wh::numeric,
|
||||||
|
(ab.min_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
|
||||||
|
(ab.max_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
|
||||||
|
greatest(coalesce(ab.charge_efficiency, 1::numeric), 0.0001::numeric),
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
)::numeric,
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
)::numeric,
|
||||||
|
greatest(coalesce(ab.discharge_efficiency, 1::numeric), 0.0001::numeric)
|
||||||
|
into
|
||||||
|
v_charge_buf,
|
||||||
|
v_discharge_buf,
|
||||||
|
v_usable,
|
||||||
|
v_min_soc_wh,
|
||||||
|
v_soc_max_wh,
|
||||||
|
v_charge_eff,
|
||||||
|
v_max_charge_w,
|
||||||
|
v_max_discharge_w,
|
||||||
|
v_discharge_eff
|
||||||
|
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 = p_site_id
|
||||||
|
order by ab.id
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_usable is null then
|
||||||
|
raise exception 'No asset_battery for site_id=%', p_site_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_per_slot_charge_wh := v_max_charge_w * v_charge_eff * 0.25;
|
||||||
|
v_per_slot_discharge_wh := v_max_discharge_w * v_discharge_eff * 0.25;
|
||||||
|
v_energy_to_fill := v_soc_max_wh - p_current_soc_wh;
|
||||||
|
v_exportable := v_soc_max_wh - v_min_soc_wh;
|
||||||
|
v_grid_target_wh := v_energy_to_fill * v_charge_buf;
|
||||||
|
v_discharge_target_wh := v_exportable * v_discharge_buf;
|
||||||
|
|
||||||
|
-- charge mask
|
||||||
|
if v_charge_buf <= 0 then
|
||||||
|
update _ems_plan_slot_wk set allow_charge = true;
|
||||||
|
elsif v_energy_to_fill <= 0 then
|
||||||
|
update _ems_plan_slot_wk set allow_charge = false;
|
||||||
|
else
|
||||||
|
update _ems_plan_slot_wk set allow_charge = (pv_surplus_w > 0);
|
||||||
|
v_cum := 0;
|
||||||
|
for r_slot in
|
||||||
|
select slot_ord
|
||||||
|
from _ems_plan_slot_wk
|
||||||
|
where pv_surplus_w <= 0
|
||||||
|
order by buy_price, slot_ord
|
||||||
|
loop
|
||||||
|
exit when v_cum >= v_grid_target_wh;
|
||||||
|
exit when v_per_slot_charge_wh <= 0;
|
||||||
|
update _ems_plan_slot_wk set allow_charge = true where slot_ord = r_slot.slot_ord;
|
||||||
|
v_cum := v_cum + v_per_slot_charge_wh;
|
||||||
|
end loop;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- discharge-export mask
|
||||||
|
if v_discharge_buf <= 0 then
|
||||||
|
update _ems_plan_slot_wk set allow_discharge_export = true;
|
||||||
|
elsif v_exportable <= 0 then
|
||||||
|
update _ems_plan_slot_wk set allow_discharge_export = false;
|
||||||
|
else
|
||||||
|
update _ems_plan_slot_wk set allow_discharge_export = false;
|
||||||
|
v_cum := 0;
|
||||||
|
for r_slot in
|
||||||
|
select slot_ord
|
||||||
|
from _ems_plan_slot_wk
|
||||||
|
order by sell_price desc, slot_ord desc
|
||||||
|
loop
|
||||||
|
exit when v_cum >= v_discharge_target_wh;
|
||||||
|
exit when v_per_slot_discharge_wh <= 0;
|
||||||
|
update _ems_plan_slot_wk set allow_discharge_export = true where slot_ord = r_slot.slot_ord;
|
||||||
|
v_cum := v_cum + v_per_slot_discharge_wh;
|
||||||
|
end loop;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return query
|
||||||
|
select
|
||||||
|
w.slot_ord,
|
||||||
|
w.interval_start,
|
||||||
|
w.buy_price,
|
||||||
|
w.sell_price,
|
||||||
|
w.is_predicted_price,
|
||||||
|
w.pv_a_forecast_w,
|
||||||
|
w.pv_b_forecast_w,
|
||||||
|
w.load_baseline_w,
|
||||||
|
w.ev1_connected,
|
||||||
|
w.ev2_connected,
|
||||||
|
w.allow_charge,
|
||||||
|
w.allow_discharge_export
|
||||||
|
from _ems_plan_slot_wk w
|
||||||
|
order by w.slot_ord;
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_load_planning_slots_full(int, timestamptz, timestamptz, numeric) is
|
||||||
|
'15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export).';
|
||||||
15
db/routines/R__fn_modbus_commands_by_ids.sql
Normal file
15
db/routines/R__fn_modbus_commands_by_ids.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
create or replace function ems.fn_modbus_commands_by_ids(p_ids int[])
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(to_jsonb(m.*) order by m.id),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
from ems.modbus_command m
|
||||||
|
where m.id = any(p_ids);
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_modbus_commands_by_ids(int[]) is
|
||||||
|
'Řádky modbus_command pro seznam ID (ověření / journal detail).';
|
||||||
42
db/routines/R__fn_modbus_journal_list.sql
Normal file
42
db/routines/R__fn_modbus_journal_list.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
create or replace function ems.fn_modbus_journal_list(p_site_id int, p_limit int)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', q.id,
|
||||||
|
'register', q.register,
|
||||||
|
'register_name', q.register_name,
|
||||||
|
'value_to_write', q.value_to_write,
|
||||||
|
'value_written', q.value_written,
|
||||||
|
'value_verified', q.value_verified,
|
||||||
|
'status', q.status,
|
||||||
|
'attempt_count', q.attempt_count,
|
||||||
|
'created_at', q.created_at
|
||||||
|
)
|
||||||
|
order by q.created_at desc
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
mc.id,
|
||||||
|
mc.register,
|
||||||
|
mc.register_name,
|
||||||
|
mc.value_to_write,
|
||||||
|
mc.value_written,
|
||||||
|
mc.value_verified,
|
||||||
|
mc.status,
|
||||||
|
mc.attempt_count,
|
||||||
|
mc.created_at
|
||||||
|
from ems.modbus_command mc
|
||||||
|
where mc.site_id = p_site_id
|
||||||
|
order by mc.created_at desc
|
||||||
|
limit p_limit
|
||||||
|
) q;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_modbus_journal_list(int, int) is
|
||||||
|
'Poslední Modbus příkazy pro site (GET control/journal).';
|
||||||
24
db/routines/R__fn_modbus_last_verified_map.sql
Normal file
24
db/routines/R__fn_modbus_last_verified_map.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- map register -> value_verified z modbus_command (poslední verified řádek per register)
|
||||||
|
|
||||||
|
create or replace function ems.fn_modbus_last_verified_map(
|
||||||
|
p_site_id int,
|
||||||
|
p_asset_id int
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select coalesce(
|
||||||
|
jsonb_object_agg(register::text, to_jsonb(value_verified)),
|
||||||
|
'{}'::jsonb
|
||||||
|
)
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
v.register,
|
||||||
|
v.value_verified
|
||||||
|
from ems.vw_modbus_last_verified v
|
||||||
|
where v.site_id = p_site_id
|
||||||
|
and v.asset_type = 'inverter'
|
||||||
|
and v.asset_id = p_asset_id
|
||||||
|
) t;
|
||||||
|
$fn$;
|
||||||
20
db/routines/R__fn_modbus_written_command_ids.sql
Normal file
20
db/routines/R__fn_modbus_written_command_ids.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
create or replace function ems.fn_modbus_written_command_ids(
|
||||||
|
p_site_id int,
|
||||||
|
p_lookback interval
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(mc.id order by mc.written_at),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
from ems.modbus_command mc
|
||||||
|
where mc.site_id = p_site_id
|
||||||
|
and mc.status = 'written'
|
||||||
|
and mc.written_at >= now() - p_lookback;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_modbus_written_command_ids(int, interval) is
|
||||||
|
'ID written příkazů k ruční verifikaci (GET control/verify).';
|
||||||
59
db/routines/R__fn_negative_price_predictions.sql
Normal file
59
db/routines/R__fn_negative_price_predictions.sql
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
create or replace function ems.fn_negative_price_predictions(p_site_id int)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
with hist as (
|
||||||
|
select count(distinct (mip.interval_start at time zone 'Europe/Prague')::date)::int as ndays
|
||||||
|
from ems.market_interval_price mip
|
||||||
|
where mip.market_source in ('OTE_CZ', 'OTE_CZ_DAM')
|
||||||
|
and mip.interval_start >= now() - interval '400 days'
|
||||||
|
),
|
||||||
|
rows as (
|
||||||
|
select
|
||||||
|
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 = p_site_id
|
||||||
|
and p.predicted_date > (
|
||||||
|
current_timestamp at time zone coalesce(
|
||||||
|
nullif(trim((select s.timezone from ems.site s where s.id = p_site_id)), ''),
|
||||||
|
'Europe/Prague'
|
||||||
|
)
|
||||||
|
)::date
|
||||||
|
and p.predicted_date <= (
|
||||||
|
current_timestamp at time zone coalesce(
|
||||||
|
nullif(trim((select s.timezone from ems.site s where s.id = p_site_id)), ''),
|
||||||
|
'Europe/Prague'
|
||||||
|
)
|
||||||
|
)::date + 7
|
||||||
|
order by p.predicted_date, p.window_start_hour
|
||||||
|
)
|
||||||
|
select jsonb_build_object(
|
||||||
|
'predictions',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'predicted_date', r.predicted_date,
|
||||||
|
'window_start_hour', r.window_start_hour,
|
||||||
|
'window_end_hour', r.window_end_hour,
|
||||||
|
'probability_pct', r.probability_pct,
|
||||||
|
'expected_min_price', r.expected_min_price,
|
||||||
|
'reason', coalesce(r.reason, '')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
from rows r
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'insufficient_history', (select ndays < 28 from hist)
|
||||||
|
);
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_negative_price_predictions(int) is
|
||||||
|
'Predikovaná okna záporných cen + flag nedostatečné historie OTE.';
|
||||||
42
db/routines/R__fn_ote_day_slot_stats_prague.sql
Normal file
42
db/routines/R__fn_ote_day_slot_stats_prague.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- statistiky importovaných OTE slotů pro kalendářní den v Europe/Prague
|
||||||
|
|
||||||
|
create or replace function ems.fn_ote_day_slot_stats_prague(p_day date)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select jsonb_build_object(
|
||||||
|
'count',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select count(*)::int
|
||||||
|
from ems.market_interval_price mip
|
||||||
|
where mip.market_source = 'OTE_CZ'
|
||||||
|
and (mip.interval_start at time zone 'Europe/Prague')::date = p_day
|
||||||
|
),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
'first_price',
|
||||||
|
(
|
||||||
|
select mip.buy_raw_price_czk_kwh
|
||||||
|
from ems.market_interval_price mip
|
||||||
|
where mip.market_source = 'OTE_CZ'
|
||||||
|
and (mip.interval_start at time zone 'Europe/Prague')::date = p_day
|
||||||
|
order by mip.interval_start
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
'is_complete',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select count(*)::int
|
||||||
|
from ems.market_interval_price mip
|
||||||
|
where mip.market_source = 'OTE_CZ'
|
||||||
|
and (mip.interval_start at time zone 'Europe/Prague')::date = p_day
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) in (92, 96, 100)
|
||||||
|
);
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_ote_day_slot_stats_prague(date) is
|
||||||
|
'Počet slotů OTE_CZ, první cena a zda den vypadá kompletně (92/96/100) v TZ Praha.';
|
||||||
26
db/routines/R__fn_ote_list_missing_days.sql
Normal file
26
db/routines/R__fn_ote_list_missing_days.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
create or replace function ems.fn_ote_list_missing_days(p_from date, p_to date)
|
||||||
|
returns table(day_local date)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
with days as (
|
||||||
|
select gs::date as d
|
||||||
|
from generate_series(p_from::timestamp, p_to::timestamp, interval '1 day') gs
|
||||||
|
),
|
||||||
|
counts as (
|
||||||
|
select
|
||||||
|
(mip.interval_start at time zone 'Europe/Prague')::date as d,
|
||||||
|
count(*)::int as n
|
||||||
|
from ems.market_interval_price mip
|
||||||
|
where mip.market_source = 'OTE_CZ'
|
||||||
|
and (mip.interval_start at time zone 'Europe/Prague')::date between p_from and p_to
|
||||||
|
group by 1
|
||||||
|
)
|
||||||
|
select days.d as day_local
|
||||||
|
from days
|
||||||
|
left join counts c on c.d = days.d
|
||||||
|
where coalesce(c.n, 0) not in (92, 96, 100);
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_ote_list_missing_days(date, date) is
|
||||||
|
'Kalendářní dny v rozsahu bez „plného“ počtu OTE slotů (backfill).';
|
||||||
177
db/routines/R__fn_plan_current_bundle.sql
Normal file
177
db/routines/R__fn_plan_current_bundle.sql
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
create or replace function ems.fn_plan_current_bundle(p_site_id int)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_run jsonb;
|
||||||
|
v_run_id int;
|
||||||
|
v_batt_wh float;
|
||||||
|
v_intervals jsonb;
|
||||||
|
v_total_cost numeric;
|
||||||
|
v_curtailed numeric;
|
||||||
|
v_charge bigint;
|
||||||
|
v_discharge bigint;
|
||||||
|
v_export bigint;
|
||||||
|
v_pv_kwh numeric;
|
||||||
|
v_cap numeric;
|
||||||
|
v_cov numeric;
|
||||||
|
v_scarcity numeric;
|
||||||
|
begin
|
||||||
|
select to_jsonb(pr)
|
||||||
|
into v_run
|
||||||
|
from ems.planning_run pr
|
||||||
|
where pr.site_id = p_site_id
|
||||||
|
and pr.status = 'active'
|
||||||
|
order by pr.created_at desc
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_run is null then
|
||||||
|
return jsonb_build_object('error', 'no_active_plan');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_run_id := (v_run->>'id')::int;
|
||||||
|
|
||||||
|
select coalesce(sum(ab.usable_capacity_wh), 0)::float
|
||||||
|
into v_batt_wh
|
||||||
|
from ems.asset_battery ab
|
||||||
|
where ab.site_id = p_site_id;
|
||||||
|
|
||||||
|
with fc_slot as (
|
||||||
|
select
|
||||||
|
u.interval_start,
|
||||||
|
coalesce(sum(u.power_w), 0)::bigint as pv_forecast_total_w
|
||||||
|
from (
|
||||||
|
select distinct on (fpi.interval_start, fpr.pv_array_id)
|
||||||
|
fpi.interval_start,
|
||||||
|
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 = fpr.pv_array_id
|
||||||
|
and apa.site_id = fpr.site_id
|
||||||
|
where fpr.site_id = p_site_id
|
||||||
|
and fpr.status = 'ok'
|
||||||
|
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
|
||||||
|
) u
|
||||||
|
group by u.interval_start
|
||||||
|
),
|
||||||
|
joined as (
|
||||||
|
select
|
||||||
|
to_jsonb(pi.*)
|
||||||
|
|| jsonb_build_object(
|
||||||
|
'pv_power_w', ai.actual_pv_power_w,
|
||||||
|
'pv_forecast_total_w', fs.pv_forecast_total_w
|
||||||
|
) as j,
|
||||||
|
pi.interval_start,
|
||||||
|
pi.expected_cost_czk,
|
||||||
|
pi.pv_a_curtailed_w,
|
||||||
|
pi.battery_setpoint_w,
|
||||||
|
pi.grid_setpoint_w,
|
||||||
|
fs.pv_forecast_total_w
|
||||||
|
from ems.planning_interval pi
|
||||||
|
left join ems.audit_interval ai
|
||||||
|
on ai.site_id = p_site_id
|
||||||
|
and ai.interval_start = pi.interval_start
|
||||||
|
left join fc_slot fs on fs.interval_start = pi.interval_start
|
||||||
|
where pi.run_id = v_run_id
|
||||||
|
),
|
||||||
|
agg as (
|
||||||
|
select
|
||||||
|
coalesce(jsonb_agg(j order by interval_start), '[]'::jsonb) as intervals,
|
||||||
|
coalesce(
|
||||||
|
sum(
|
||||||
|
case
|
||||||
|
when expected_cost_czk is not null then expected_cost_czk::numeric
|
||||||
|
else 0::numeric
|
||||||
|
end
|
||||||
|
),
|
||||||
|
0::numeric
|
||||||
|
) as total_cost,
|
||||||
|
coalesce(
|
||||||
|
sum(coalesce(pv_a_curtailed_w, 0)::numeric * 0.25 / 1000.0),
|
||||||
|
0::numeric
|
||||||
|
) as curtailed_kwh,
|
||||||
|
coalesce(
|
||||||
|
sum(
|
||||||
|
case
|
||||||
|
when battery_setpoint_w is not null and battery_setpoint_w > 0 then 1
|
||||||
|
else 0
|
||||||
|
end
|
||||||
|
),
|
||||||
|
0::bigint
|
||||||
|
) as charge_slots,
|
||||||
|
coalesce(
|
||||||
|
sum(
|
||||||
|
case
|
||||||
|
when battery_setpoint_w is not null and battery_setpoint_w < 0 then 1
|
||||||
|
else 0
|
||||||
|
end
|
||||||
|
),
|
||||||
|
0::bigint
|
||||||
|
) as discharge_slots,
|
||||||
|
coalesce(
|
||||||
|
sum(
|
||||||
|
case
|
||||||
|
when grid_setpoint_w is not null and grid_setpoint_w < 0 then 1
|
||||||
|
else 0
|
||||||
|
end
|
||||||
|
),
|
||||||
|
0::bigint
|
||||||
|
) as export_slots
|
||||||
|
from joined
|
||||||
|
),
|
||||||
|
pv96 as (
|
||||||
|
select coalesce(
|
||||||
|
sum(
|
||||||
|
greatest(0::numeric, coalesce(pv_forecast_total_w, 0)::numeric) * 0.25 / 1000.0
|
||||||
|
),
|
||||||
|
0::numeric
|
||||||
|
) as pv_kwh
|
||||||
|
from (
|
||||||
|
select pv_forecast_total_w
|
||||||
|
from joined
|
||||||
|
order by interval_start
|
||||||
|
limit 96
|
||||||
|
) z
|
||||||
|
)
|
||||||
|
select
|
||||||
|
a.intervals,
|
||||||
|
a.total_cost,
|
||||||
|
a.curtailed_kwh,
|
||||||
|
a.charge_slots,
|
||||||
|
a.discharge_slots,
|
||||||
|
a.export_slots,
|
||||||
|
p.pv_kwh
|
||||||
|
into strict
|
||||||
|
v_intervals,
|
||||||
|
v_total_cost,
|
||||||
|
v_curtailed,
|
||||||
|
v_charge,
|
||||||
|
v_discharge,
|
||||||
|
v_export,
|
||||||
|
v_pv_kwh
|
||||||
|
from agg a
|
||||||
|
cross join pv96 p;
|
||||||
|
|
||||||
|
v_cap := greatest(1::numeric, coalesce(v_batt_wh, 0::float)::numeric / 1000.0);
|
||||||
|
v_cov := least(1::numeric, greatest(0::numeric, coalesce(v_pv_kwh, 0) / v_cap));
|
||||||
|
v_scarcity := round(0.65::numeric + 0.35 * v_cov, 4);
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'run', v_run,
|
||||||
|
'intervals', v_intervals,
|
||||||
|
'summary', jsonb_build_object(
|
||||||
|
'total_expected_cost_czk', round(v_total_cost, 4),
|
||||||
|
'total_pv_curtailed_kwh', round(v_curtailed, 6),
|
||||||
|
'charge_slots', v_charge,
|
||||||
|
'discharge_slots', v_discharge,
|
||||||
|
'export_slots', v_export,
|
||||||
|
'pv_scarcity_factor', v_scarcity
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_plan_current_bundle(int) is
|
||||||
|
'Aktivní planning_run + intervaly + souhrn (GET /plan/current).';
|
||||||
29
db/routines/R__fn_planning_active_run.sql
Normal file
29
db/routines/R__fn_planning_active_run.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-- aktivní planning_run pro site (rolling horizon)
|
||||||
|
|
||||||
|
create or replace function ems.fn_planning_active_run(p_site_id int)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select case
|
||||||
|
when not exists (select 1 from ems.site s where s.id = p_site_id) then
|
||||||
|
jsonb_build_object('error', 'unknown_site')
|
||||||
|
when not exists (
|
||||||
|
select 1 from ems.planning_run pr
|
||||||
|
where pr.site_id = p_site_id and pr.status = 'active'
|
||||||
|
) then
|
||||||
|
jsonb_build_object('error', 'no_active_plan')
|
||||||
|
else (
|
||||||
|
select jsonb_build_object(
|
||||||
|
'id', pr.id,
|
||||||
|
'horizon_end', pr.horizon_end,
|
||||||
|
'horizon_start', pr.horizon_start,
|
||||||
|
'created_at', pr.created_at
|
||||||
|
)
|
||||||
|
from ems.planning_run pr
|
||||||
|
where pr.site_id = p_site_id and pr.status = 'active'
|
||||||
|
order by pr.created_at desc
|
||||||
|
limit 1
|
||||||
|
)
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
14
db/routines/R__fn_planning_future_price_days.sql
Normal file
14
db/routines/R__fn_planning_future_price_days.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
create or replace function ems.fn_planning_future_price_days()
|
||||||
|
returns int
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select count(distinct (mip.interval_start at time zone 'Europe/Prague')::date)::int
|
||||||
|
from ems.market_interval_price mip
|
||||||
|
where mip.market_source in ('OTE_CZ', 'OTE_CZ_DAM')
|
||||||
|
and mip.interval_start >= now()
|
||||||
|
and mip.interval_start < now() + interval '48 hours';
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_planning_future_price_days() is
|
||||||
|
'Počet kalendářních dní s OTE daty v okně now..now+48h (před spuštěním plánu).';
|
||||||
45
db/routines/R__fn_planning_interval_at_offset.sql
Normal file
45
db/routines/R__fn_planning_interval_at_offset.sql
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
-- jeden řádek planning_interval jako jsonb pro aktivní plán a slot offset (Prague 15min)
|
||||||
|
|
||||||
|
create or replace function ems.fn_planning_interval_at_offset(
|
||||||
|
p_site_id int,
|
||||||
|
p_offset_slots int default 0
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select to_jsonb(pi)
|
||||||
|
from ems.planning_interval pi
|
||||||
|
join ems.planning_run pr on pr.id = pi.run_id
|
||||||
|
where pr.site_id = p_site_id
|
||||||
|
and pr.status = 'active'
|
||||||
|
and pi.interval_start = ems.fn_planning_slot_boundary_prague(p_offset_slots)
|
||||||
|
limit 1;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
create or replace function ems.fn_planning_max_effective_charge_w(p_site_id int)
|
||||||
|
returns int
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select coalesce(
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
)::int,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
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 = p_site_id
|
||||||
|
and ai.controllable is true
|
||||||
|
and ai.active is true
|
||||||
|
order by ab.id
|
||||||
|
limit 1;
|
||||||
|
$fn$;
|
||||||
148
db/routines/R__fn_planning_run_commit.sql
Normal file
148
db/routines/R__fn_planning_run_commit.sql
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
-- uložení planning_run + planning_interval v jedné transakci
|
||||||
|
|
||||||
|
create or replace function ems.fn_planning_run_commit(
|
||||||
|
p_site_id int,
|
||||||
|
p_horizon_start timestamptz,
|
||||||
|
p_horizon_end timestamptz,
|
||||||
|
p_run_meta jsonb,
|
||||||
|
p_intervals jsonb
|
||||||
|
)
|
||||||
|
returns int
|
||||||
|
language plpgsql
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_run_id int;
|
||||||
|
r record;
|
||||||
|
v_has_slot_inputs boolean;
|
||||||
|
begin
|
||||||
|
v_has_slot_inputs := coalesce(
|
||||||
|
(jsonb_typeof(p_intervals) = 'array' and jsonb_array_length(p_intervals) > 0
|
||||||
|
and (p_intervals->0) ? 'load_baseline_w'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
insert into ems.planning_run (
|
||||||
|
site_id, horizon_start, horizon_end, status,
|
||||||
|
run_type, triggered_by, replan_from,
|
||||||
|
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor
|
||||||
|
) values (
|
||||||
|
p_site_id,
|
||||||
|
p_horizon_start,
|
||||||
|
p_horizon_end,
|
||||||
|
'draft',
|
||||||
|
nullif(trim(p_run_meta->>'run_type'), ''),
|
||||||
|
nullif(trim(p_run_meta->>'triggered_by'), ''),
|
||||||
|
case
|
||||||
|
when p_run_meta ? 'replan_from' and (p_run_meta->>'replan_from') is not null
|
||||||
|
and (p_run_meta->>'replan_from') <> 'null'
|
||||||
|
then (p_run_meta->>'replan_from')::timestamptz
|
||||||
|
else null::timestamptz
|
||||||
|
end,
|
||||||
|
(p_run_meta->>'soc_at_replan_wh')::numeric,
|
||||||
|
(p_run_meta->>'solver_duration_ms')::int,
|
||||||
|
(p_run_meta->>'forecast_correction_factor')::numeric
|
||||||
|
)
|
||||||
|
returning id into v_run_id;
|
||||||
|
|
||||||
|
for r in select * from jsonb_array_elements(p_intervals) as elem(value)
|
||||||
|
loop
|
||||||
|
if v_has_slot_inputs then
|
||||||
|
insert into ems.planning_interval (
|
||||||
|
run_id, interval_start,
|
||||||
|
battery_setpoint_w, battery_soc_target_pct,
|
||||||
|
grid_setpoint_w,
|
||||||
|
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
|
||||||
|
heat_pump_enabled, heat_pump_setpoint_w,
|
||||||
|
pv_a_curtailed_w, expected_cost_czk,
|
||||||
|
effective_buy_price, effective_sell_price,
|
||||||
|
is_predicted_price,
|
||||||
|
load_baseline_w,
|
||||||
|
pv_a_forecast_raw_w, pv_b_forecast_raw_w,
|
||||||
|
pv_a_forecast_solver_w, pv_b_forecast_solver_w
|
||||||
|
) values (
|
||||||
|
v_run_id,
|
||||||
|
(r.value->>'interval_start')::timestamptz,
|
||||||
|
(r.value->>'battery_setpoint_w')::int,
|
||||||
|
(r.value->>'battery_soc_target_pct')::numeric,
|
||||||
|
(r.value->>'grid_setpoint_w')::int,
|
||||||
|
nullif(r.value->>'ev1_setpoint_w', '')::int,
|
||||||
|
nullif(r.value->>'ev2_setpoint_w', '')::int,
|
||||||
|
coalesce((r.value->>'ev1_via_bat_w')::int, 0),
|
||||||
|
coalesce((r.value->>'ev2_via_bat_w')::int, 0),
|
||||||
|
coalesce((r.value->>'heat_pump_enabled')::boolean, false),
|
||||||
|
(r.value->>'heat_pump_setpoint_w')::int,
|
||||||
|
(r.value->>'pv_a_curtailed_w')::int,
|
||||||
|
(r.value->>'expected_cost_czk')::numeric,
|
||||||
|
(r.value->>'effective_buy_price')::numeric,
|
||||||
|
(r.value->>'effective_sell_price')::numeric,
|
||||||
|
coalesce((r.value->>'is_predicted_price')::boolean, false),
|
||||||
|
(r.value->>'load_baseline_w')::int,
|
||||||
|
(r.value->>'pv_a_forecast_raw_w')::int,
|
||||||
|
(r.value->>'pv_b_forecast_raw_w')::int,
|
||||||
|
(r.value->>'pv_a_forecast_solver_w')::int,
|
||||||
|
(r.value->>'pv_b_forecast_solver_w')::int
|
||||||
|
);
|
||||||
|
else
|
||||||
|
insert into ems.planning_interval (
|
||||||
|
run_id, interval_start,
|
||||||
|
battery_setpoint_w, battery_soc_target_pct,
|
||||||
|
grid_setpoint_w,
|
||||||
|
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
|
||||||
|
heat_pump_enabled, heat_pump_setpoint_w,
|
||||||
|
pv_a_curtailed_w, expected_cost_czk,
|
||||||
|
effective_buy_price, effective_sell_price,
|
||||||
|
is_predicted_price
|
||||||
|
) values (
|
||||||
|
v_run_id,
|
||||||
|
(r.value->>'interval_start')::timestamptz,
|
||||||
|
(r.value->>'battery_setpoint_w')::int,
|
||||||
|
(r.value->>'battery_soc_target_pct')::numeric,
|
||||||
|
(r.value->>'grid_setpoint_w')::int,
|
||||||
|
nullif(r.value->>'ev1_setpoint_w', '')::int,
|
||||||
|
nullif(r.value->>'ev2_setpoint_w', '')::int,
|
||||||
|
coalesce((r.value->>'ev1_via_bat_w')::int, 0),
|
||||||
|
coalesce((r.value->>'ev2_via_bat_w')::int, 0),
|
||||||
|
coalesce((r.value->>'heat_pump_enabled')::boolean, false),
|
||||||
|
(r.value->>'heat_pump_setpoint_w')::int,
|
||||||
|
(r.value->>'pv_a_curtailed_w')::int,
|
||||||
|
(r.value->>'expected_cost_czk')::numeric,
|
||||||
|
(r.value->>'effective_buy_price')::numeric,
|
||||||
|
(r.value->>'effective_sell_price')::numeric,
|
||||||
|
coalesce((r.value->>'is_predicted_price')::boolean, false)
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
update ems.planning_run
|
||||||
|
set status = 'superseded'
|
||||||
|
where site_id = p_site_id
|
||||||
|
and status = 'active'
|
||||||
|
and id <> v_run_id;
|
||||||
|
|
||||||
|
update ems.planning_run
|
||||||
|
set status = 'active'
|
||||||
|
where id = v_run_id;
|
||||||
|
|
||||||
|
return v_run_id;
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
create or replace function ems.fn_forecast_correction_log_insert(
|
||||||
|
p_site_id int,
|
||||||
|
p_window_start timestamptz,
|
||||||
|
p_window_end timestamptz,
|
||||||
|
p_actual_pv_wh numeric,
|
||||||
|
p_forecast_pv_wh numeric,
|
||||||
|
p_correction_factor numeric,
|
||||||
|
p_applied_to_run_id int
|
||||||
|
)
|
||||||
|
returns void
|
||||||
|
language sql
|
||||||
|
as $fn$
|
||||||
|
insert into ems.forecast_correction_log (
|
||||||
|
site_id, window_start, window_end,
|
||||||
|
actual_pv_wh, forecast_pv_wh, correction_factor, applied_to_run_id
|
||||||
|
) values (
|
||||||
|
p_site_id, p_window_start, p_window_end,
|
||||||
|
p_actual_pv_wh, p_forecast_pv_wh, p_correction_factor, p_applied_to_run_id
|
||||||
|
);
|
||||||
|
$fn$;
|
||||||
15
db/routines/R__fn_planning_run_horizon.sql
Normal file
15
db/routines/R__fn_planning_run_horizon.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
create or replace function ems.fn_planning_run_horizon(p_run_id int)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select jsonb_build_object(
|
||||||
|
'horizon_start', pr.horizon_start,
|
||||||
|
'horizon_end', pr.horizon_end
|
||||||
|
)
|
||||||
|
from ems.planning_run pr
|
||||||
|
where pr.id = p_run_id;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_planning_run_horizon(int) is
|
||||||
|
'Horizont po úspěšném POST /plan/run (read-model).';
|
||||||
263
db/routines/R__fn_planning_site_context.sql
Normal file
263
db/routines/R__fn_planning_site_context.sql
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
-- jeden jsonb snapshot pro LP: režim, baterie, síť, EV, TČ, tuv stats
|
||||||
|
|
||||||
|
create or replace function ems.fn_planning_site_context(p_site_id int)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_mode text;
|
||||||
|
v_b jsonb;
|
||||||
|
v_hp jsonb;
|
||||||
|
v_grid jsonb;
|
||||||
|
v_veh jsonb;
|
||||||
|
v_ev jsonb;
|
||||||
|
v_soc_pct numeric;
|
||||||
|
v_soc_wh numeric;
|
||||||
|
v_tuv numeric;
|
||||||
|
v_tuv_stats jsonb;
|
||||||
|
v_uc numeric;
|
||||||
|
v_min_soc_wh numeric;
|
||||||
|
v_arb_wh numeric;
|
||||||
|
v_soc_max_wh numeric;
|
||||||
|
begin
|
||||||
|
if not exists (select 1 from ems.site s where s.id = p_site_id) then
|
||||||
|
return jsonb_build_object('error', 'unknown_site');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select som.mode_code
|
||||||
|
into v_mode
|
||||||
|
from ems.site_operating_mode som
|
||||||
|
where som.site_id = p_site_id;
|
||||||
|
|
||||||
|
select jsonb_build_object(
|
||||||
|
'usable_capacity_wh', ab.usable_capacity_wh,
|
||||||
|
'min_soc_wh', (ab.min_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
|
||||||
|
'arb_floor_wh', (ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
|
||||||
|
'reserve_soc_wh', (ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
|
||||||
|
'soc_max_wh', (ab.max_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
|
||||||
|
'charge_efficiency', ab.charge_efficiency,
|
||||||
|
'discharge_efficiency', ab.discharge_efficiency,
|
||||||
|
'degradation_cost_czk_kwh', ab.degradation_cost_czk_kwh,
|
||||||
|
'max_charge_power_w', 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)
|
||||||
|
)
|
||||||
|
)::int,
|
||||||
|
'max_discharge_power_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)
|
||||||
|
)
|
||||||
|
)::int,
|
||||||
|
'charge_slot_buffer', ab.charge_slot_buffer,
|
||||||
|
'discharge_slot_buffer', ab.discharge_slot_buffer
|
||||||
|
)
|
||||||
|
into v_b
|
||||||
|
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 = p_site_id
|
||||||
|
order by ab.id
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_b is null then
|
||||||
|
raise exception 'No asset_battery for site_id=%', p_site_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_uc := (v_b->>'usable_capacity_wh')::numeric;
|
||||||
|
v_min_soc_wh := (v_b->>'min_soc_wh')::numeric;
|
||||||
|
v_soc_max_wh := (v_b->>'soc_max_wh')::numeric;
|
||||||
|
|
||||||
|
if (v_b->>'max_charge_power_w')::int <= 0 or (v_b->>'max_discharge_power_w')::int <= 0 then
|
||||||
|
raise exception 'Invalid battery effective limits for site_id=%', p_site_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select jsonb_build_object(
|
||||||
|
'rated_heating_power_w', greatest(coalesce(hp.rated_heating_power_w, 8000), 0)::int,
|
||||||
|
'tuv_min_temp_c', coalesce(hp.tuv_min_temp_c, 45)::numeric,
|
||||||
|
'tuv_target_temp_c', coalesce(hp.tuv_target_temp_c, 55)::numeric
|
||||||
|
)
|
||||||
|
into v_hp
|
||||||
|
from ems.asset_heat_pump hp
|
||||||
|
where hp.site_id = p_site_id
|
||||||
|
order by hp.id
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_hp is null then
|
||||||
|
v_hp := jsonb_build_object(
|
||||||
|
'rated_heating_power_w', 0,
|
||||||
|
'tuv_min_temp_c', 0,
|
||||||
|
'tuv_target_temp_c', 55
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select jsonb_build_object(
|
||||||
|
'max_import_power_w', sgc.max_import_power_w,
|
||||||
|
'max_export_power_w', sgc.max_export_power_w
|
||||||
|
)
|
||||||
|
into v_grid
|
||||||
|
from ems.site_grid_connection sgc
|
||||||
|
where sgc.site_id = p_site_id
|
||||||
|
order by sgc.id
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_grid is null then
|
||||||
|
raise exception 'No site_grid_connection for site_id=%', p_site_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'max_charge_power_w', v.max_charge_power_w,
|
||||||
|
'battery_capacity_kwh', v.battery_capacity_kwh,
|
||||||
|
'default_target_soc_pct', v.default_target_soc_pct
|
||||||
|
)
|
||||||
|
order by ch.code
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
into v_veh
|
||||||
|
from ems.asset_vehicle v
|
||||||
|
join ems.asset_ev_charger ch on ch.id = v.default_charger_id
|
||||||
|
where v.site_id = p_site_id
|
||||||
|
and ch.code in ('ev-charger-1', 'ev-charger-2');
|
||||||
|
|
||||||
|
v_ev := jsonb_build_array(
|
||||||
|
(
|
||||||
|
select case
|
||||||
|
when es.target_deadline is null then null::jsonb
|
||||||
|
when v.battery_capacity_kwh is null then null::jsonb
|
||||||
|
when es.soc_at_connect_pct is null then null::jsonb
|
||||||
|
when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null::jsonb
|
||||||
|
when greatest(
|
||||||
|
0,
|
||||||
|
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
|
||||||
|
- es.soc_at_connect_pct::numeric) / 100.0
|
||||||
|
* (v.battery_capacity_kwh * 1000)
|
||||||
|
- coalesce(es.energy_delivered_wh, 0)::numeric
|
||||||
|
) <= 0 then null::jsonb
|
||||||
|
else jsonb_build_object(
|
||||||
|
'target_deadline', es.target_deadline,
|
||||||
|
'energy_needed_wh', greatest(
|
||||||
|
0,
|
||||||
|
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
|
||||||
|
- es.soc_at_connect_pct::numeric) / 100.0
|
||||||
|
* (v.battery_capacity_kwh * 1000)
|
||||||
|
- coalesce(es.energy_delivered_wh, 0)::numeric
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
from ems.ev_session es
|
||||||
|
join ems.asset_ev_charger ch on ch.id = es.charger_id
|
||||||
|
left join ems.asset_vehicle v on v.id = es.vehicle_id
|
||||||
|
where es.site_id = p_site_id
|
||||||
|
and es.session_end is null
|
||||||
|
and ch.code = 'ev-charger-1'
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
(
|
||||||
|
select case
|
||||||
|
when es.target_deadline is null then null::jsonb
|
||||||
|
when v.battery_capacity_kwh is null then null::jsonb
|
||||||
|
when es.soc_at_connect_pct is null then null::jsonb
|
||||||
|
when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null::jsonb
|
||||||
|
when greatest(
|
||||||
|
0,
|
||||||
|
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
|
||||||
|
- es.soc_at_connect_pct::numeric) / 100.0
|
||||||
|
* (v.battery_capacity_kwh * 1000)
|
||||||
|
- coalesce(es.energy_delivered_wh, 0)::numeric
|
||||||
|
) <= 0 then null::jsonb
|
||||||
|
else jsonb_build_object(
|
||||||
|
'target_deadline', es.target_deadline,
|
||||||
|
'energy_needed_wh', greatest(
|
||||||
|
0,
|
||||||
|
(coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric
|
||||||
|
- es.soc_at_connect_pct::numeric) / 100.0
|
||||||
|
* (v.battery_capacity_kwh * 1000)
|
||||||
|
- coalesce(es.energy_delivered_wh, 0)::numeric
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
from ems.ev_session es
|
||||||
|
join ems.asset_ev_charger ch on ch.id = es.charger_id
|
||||||
|
left join ems.asset_vehicle v on v.id = es.vehicle_id
|
||||||
|
where es.site_id = p_site_id
|
||||||
|
and es.session_end is null
|
||||||
|
and ch.code = 'ev-charger-2'
|
||||||
|
limit 1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
select ti.battery_soc_percent
|
||||||
|
into v_soc_pct
|
||||||
|
from ems.telemetry_inverter ti
|
||||||
|
where ti.site_id = p_site_id
|
||||||
|
order by ti.measured_at desc
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_soc_pct is null then
|
||||||
|
v_soc_wh := v_uc * 0.5;
|
||||||
|
else
|
||||||
|
v_soc_wh := v_soc_pct::numeric / 100.0 * v_uc;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_soc_wh := greatest(v_min_soc_wh, least(v_soc_wh, v_soc_max_wh));
|
||||||
|
|
||||||
|
select thp.tuv_tank_temp_c
|
||||||
|
into v_tuv
|
||||||
|
from ems.telemetry_heat_pump thp
|
||||||
|
where thp.site_id = p_site_id
|
||||||
|
order by thp.measured_at desc
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
v_tuv := coalesce(v_tuv::numeric, 50::numeric);
|
||||||
|
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'dow', tu.day_of_week,
|
||||||
|
'hour', tu.hour_of_day,
|
||||||
|
'delta', tu.avg_temp_delta_c
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
into v_tuv_stats
|
||||||
|
from ems.tuv_usage_stats tu
|
||||||
|
where tu.site_id = p_site_id;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'operating_mode', v_mode,
|
||||||
|
'battery', v_b,
|
||||||
|
'heat_pump', v_hp,
|
||||||
|
'grid', v_grid,
|
||||||
|
'vehicles', v_veh,
|
||||||
|
'ev_sessions', v_ev,
|
||||||
|
'soc_wh', v_soc_wh,
|
||||||
|
'tuv_temp', v_tuv,
|
||||||
|
'tuv_delta_stats', v_tuv_stats,
|
||||||
|
'planning_config', coalesce(
|
||||||
|
(
|
||||||
|
select pc.config
|
||||||
|
from ems.planning_config pc
|
||||||
|
where pc.site_id = p_site_id
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
'{}'::jsonb
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_planning_site_context(int) is
|
||||||
|
'Kontext pro planning_engine / LP (bez samotného solveru).';
|
||||||
20
db/routines/R__fn_planning_slot_boundary_prague.sql
Normal file
20
db/routines/R__fn_planning_slot_boundary_prague.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- začátek aktuálního (+offset) 15min slotu v Europe/Prague jako timestamptz (UTC instants)
|
||||||
|
|
||||||
|
create or replace function ems.fn_planning_slot_boundary_prague(p_offset_slots int default 0)
|
||||||
|
returns timestamptz
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select (
|
||||||
|
(date_trunc('day', loc.ts)
|
||||||
|
+ make_interval(
|
||||||
|
hours => extract(hour from loc.ts)::int,
|
||||||
|
mins => (floor(extract(minute from loc.ts) / 15) * 15)::int
|
||||||
|
)
|
||||||
|
)::timestamp at time zone 'Europe/Prague'
|
||||||
|
) + make_interval(mins => coalesce(p_offset_slots, 0) * 15)
|
||||||
|
from (select now() at time zone 'Europe/Prague' as ts) loc;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_planning_slot_boundary_prague(int) is
|
||||||
|
'Začátek 15min slotu v časové zóně site provozu (Europe/Prague floor); offset v násobcích 15 min.';
|
||||||
74
db/routines/R__fn_pv_forecast_correction_factor.sql
Normal file
74
db/routines/R__fn_pv_forecast_correction_factor.sql
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
-- korekční faktor FVE forecast vs telemetrie (stejná logika jako compute_correction_factor v Pythonu)
|
||||||
|
|
||||||
|
create or replace function ems.fn_pv_forecast_correction_factor(
|
||||||
|
p_site_id int,
|
||||||
|
p_window_start timestamptz,
|
||||||
|
p_window_end timestamptz,
|
||||||
|
p_min_clamp numeric default 0.5,
|
||||||
|
p_max_clamp numeric default 1.5
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_actual numeric;
|
||||||
|
v_forecast numeric;
|
||||||
|
v_raw numeric;
|
||||||
|
v_factor numeric := 1.0;
|
||||||
|
v_clamped boolean := false;
|
||||||
|
begin
|
||||||
|
select coalesce(sum(ti.pv_power_w) * 0.25 / 1000.0, 0)
|
||||||
|
into v_actual
|
||||||
|
from ems.telemetry_inverter ti
|
||||||
|
where ti.site_id = p_site_id
|
||||||
|
and ti.measured_at >= p_window_start
|
||||||
|
and ti.measured_at < p_window_end;
|
||||||
|
|
||||||
|
select coalesce(sum(fpi.power_w) * 0.25 / 1000.0, 0)
|
||||||
|
into v_forecast
|
||||||
|
from ems.forecast_pv_interval fpi
|
||||||
|
join ems.forecast_pv_run fpr on fpr.id = fpi.run_id
|
||||||
|
where fpr.site_id = p_site_id
|
||||||
|
and fpi.interval_start >= p_window_start
|
||||||
|
and fpi.interval_start < p_window_end
|
||||||
|
and fpr.status = 'ok'
|
||||||
|
and fpr.id = (
|
||||||
|
select fpr2.id
|
||||||
|
from ems.forecast_pv_run fpr2
|
||||||
|
where fpr2.site_id = p_site_id
|
||||||
|
and fpr2.status = 'ok'
|
||||||
|
and fpr2.created_at <= p_window_start
|
||||||
|
order by fpr2.created_at desc
|
||||||
|
limit 1
|
||||||
|
);
|
||||||
|
|
||||||
|
if v_forecast < 0.1 or coalesce(v_actual, 0) < 0.05 then
|
||||||
|
return jsonb_build_object(
|
||||||
|
'correction_factor', 1.0,
|
||||||
|
'raw_factor', null,
|
||||||
|
'clamped', false,
|
||||||
|
'reason', 'insufficient_data',
|
||||||
|
'actual_pv_wh', coalesce(v_actual, 0) * 1000,
|
||||||
|
'forecast_pv_wh', coalesce(v_forecast, 0) * 1000,
|
||||||
|
'window_start', p_window_start,
|
||||||
|
'window_end', p_window_end
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_raw := v_actual / nullif(v_forecast, 0);
|
||||||
|
v_factor := greatest(p_min_clamp, least(p_max_clamp, v_raw));
|
||||||
|
v_clamped := (v_factor <> v_raw);
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'correction_factor', v_factor,
|
||||||
|
'raw_factor', v_raw,
|
||||||
|
'clamped', v_clamped,
|
||||||
|
'reason', 'ok',
|
||||||
|
'actual_pv_wh', v_actual * 1000,
|
||||||
|
'forecast_pv_wh', v_forecast * 1000,
|
||||||
|
'window_start', p_window_start,
|
||||||
|
'window_end', p_window_end
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
36
db/routines/R__fn_set_mode_with_context.sql
Normal file
36
db/routines/R__fn_set_mode_with_context.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- jedno volání: předchozí režim, fn_set_mode, nový režim, site.code
|
||||||
|
|
||||||
|
create or replace function ems.fn_set_mode_with_context(
|
||||||
|
p_site_id int,
|
||||||
|
p_mode_code text,
|
||||||
|
p_activated_by text default 'system',
|
||||||
|
p_valid_until timestamptz default null,
|
||||||
|
p_notes text default null
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_prev text;
|
||||||
|
v_out text;
|
||||||
|
v_code text;
|
||||||
|
begin
|
||||||
|
select mode_code into v_prev
|
||||||
|
from ems.site_operating_mode
|
||||||
|
where site_id = p_site_id;
|
||||||
|
|
||||||
|
v_out := ems.fn_set_mode(
|
||||||
|
p_site_id, p_mode_code, p_activated_by, p_valid_until, p_notes
|
||||||
|
);
|
||||||
|
|
||||||
|
select s.code into v_code
|
||||||
|
from ems.site s
|
||||||
|
where s.id = p_site_id;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'previous_mode', v_prev,
|
||||||
|
'new_mode', v_out,
|
||||||
|
'site_code', v_code
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
265
db/routines/R__fn_site_configuration.sql
Normal file
265
db/routines/R__fn_site_configuration.sql
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
create or replace function ems.fn_site_configuration(p_site_id int)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select
|
||||||
|
case
|
||||||
|
when not exists (select 1 from ems.site s0 where s0.id = p_site_id) then null::jsonb
|
||||||
|
else jsonb_build_object(
|
||||||
|
'site',
|
||||||
|
(
|
||||||
|
select to_jsonb(x)
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
s.id,
|
||||||
|
s.code,
|
||||||
|
s.name,
|
||||||
|
s.timezone,
|
||||||
|
s.latitude::float8 as latitude,
|
||||||
|
s.longitude::float8 as longitude,
|
||||||
|
s.active,
|
||||||
|
s.notes,
|
||||||
|
s.created_at
|
||||||
|
from ems.site s
|
||||||
|
where s.id = p_site_id
|
||||||
|
) x
|
||||||
|
),
|
||||||
|
'grid_connection',
|
||||||
|
(
|
||||||
|
select to_jsonb(g.*)
|
||||||
|
from ems.site_grid_connection g
|
||||||
|
where g.site_id = p_site_id
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
'market_config',
|
||||||
|
(
|
||||||
|
select to_jsonb(m.*)
|
||||||
|
from ems.site_market_config m
|
||||||
|
where m.site_id = p_site_id
|
||||||
|
and m.valid_from <= now()
|
||||||
|
and (m.valid_to is null or m.valid_to > now())
|
||||||
|
order by m.valid_from desc
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
'market_config_note',
|
||||||
|
'Zelený bonus za výrobu je u FVE polí (asset_pv_array), ne v obchodní konfiguraci.',
|
||||||
|
'endpoints',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(
|
||||||
|
to_jsonb(e.*)
|
||||||
|
|| jsonb_build_object(
|
||||||
|
'auth_reference',
|
||||||
|
case
|
||||||
|
when e.auth_reference is null or btrim(e.auth_reference::text) = '' then null::text
|
||||||
|
when length(btrim(e.auth_reference::text)) <= 4 then 'nastaveno'::text
|
||||||
|
else concat('…', right(btrim(e.auth_reference::text), 2))
|
||||||
|
end
|
||||||
|
)
|
||||||
|
order by e.id
|
||||||
|
)
|
||||||
|
from ems.site_endpoint e
|
||||||
|
where e.site_id = p_site_id
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'inverters',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(
|
||||||
|
(
|
||||||
|
(
|
||||||
|
to_jsonb(ai.*)
|
||||||
|
- 'deye_last_system_time_sync_at'::text
|
||||||
|
- 'deye_last_system_time_sync_minute'::text
|
||||||
|
- 'deye_last_tou_inactive_write_prague_date'::text
|
||||||
|
- 'deye_tou_inactive_signature'::text
|
||||||
|
)
|
||||||
|
|| jsonb_build_object(
|
||||||
|
'endpoint_connection',
|
||||||
|
(
|
||||||
|
select ep.host || case
|
||||||
|
when ep.port is not null then ':' || ep.port::text
|
||||||
|
else ''
|
||||||
|
end
|
||||||
|
from ems.site_endpoint ep
|
||||||
|
where ep.id = ai.endpoint_id
|
||||||
|
),
|
||||||
|
'deye_meta',
|
||||||
|
case
|
||||||
|
when jsonb_strip_nulls(
|
||||||
|
jsonb_build_object(
|
||||||
|
'deye_last_system_time_sync_at', to_jsonb(ai.deye_last_system_time_sync_at),
|
||||||
|
'deye_last_system_time_sync_minute', to_jsonb(ai.deye_last_system_time_sync_minute),
|
||||||
|
'deye_last_tou_inactive_write_prague_date',
|
||||||
|
to_jsonb(ai.deye_last_tou_inactive_write_prague_date),
|
||||||
|
'deye_tou_inactive_signature', to_jsonb(ai.deye_tou_inactive_signature)
|
||||||
|
)
|
||||||
|
) = '{}'::jsonb then null::jsonb
|
||||||
|
else jsonb_strip_nulls(
|
||||||
|
jsonb_build_object(
|
||||||
|
'deye_last_system_time_sync_at', to_jsonb(ai.deye_last_system_time_sync_at),
|
||||||
|
'deye_last_system_time_sync_minute', to_jsonb(ai.deye_last_system_time_sync_minute),
|
||||||
|
'deye_last_tou_inactive_write_prague_date',
|
||||||
|
to_jsonb(ai.deye_last_tou_inactive_write_prague_date),
|
||||||
|
'deye_tou_inactive_signature', to_jsonb(ai.deye_tou_inactive_signature)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order by ai.id
|
||||||
|
)
|
||||||
|
from ems.asset_inverter ai
|
||||||
|
where ai.site_id = p_site_id
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'batteries',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(to_jsonb(b.*) order by b.id)
|
||||||
|
from ems.asset_battery b
|
||||||
|
where b.site_id = p_site_id
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'pv_arrays',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(to_jsonb(p.*) order by p.id)
|
||||||
|
from ems.asset_pv_array p
|
||||||
|
where p.site_id = p_site_id
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'ev_chargers',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(
|
||||||
|
to_jsonb(ec.*)
|
||||||
|
|| jsonb_build_object(
|
||||||
|
'endpoint_connection',
|
||||||
|
se.host || case
|
||||||
|
when se.port is not null then ':' || se.port::text
|
||||||
|
else ''
|
||||||
|
end
|
||||||
|
)
|
||||||
|
order by ec.id
|
||||||
|
)
|
||||||
|
from ems.asset_ev_charger ec
|
||||||
|
left join ems.site_endpoint se on se.id = ec.endpoint_id
|
||||||
|
where ec.site_id = p_site_id
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'vehicles',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(
|
||||||
|
to_jsonb(v.*)
|
||||||
|
|| jsonb_build_object(
|
||||||
|
'api_reference',
|
||||||
|
case
|
||||||
|
when v.api_reference is null or btrim(v.api_reference::text) = '' then null::text
|
||||||
|
when length(btrim(v.api_reference::text)) <= 4 then 'nastaveno'::text
|
||||||
|
else concat('…', right(btrim(v.api_reference::text), 2))
|
||||||
|
end
|
||||||
|
)
|
||||||
|
order by v.code
|
||||||
|
)
|
||||||
|
from ems.asset_vehicle v
|
||||||
|
where v.site_id = p_site_id
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'heat_pumps',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(
|
||||||
|
to_jsonb(hp.*)
|
||||||
|
|| jsonb_build_object(
|
||||||
|
'endpoint_connection',
|
||||||
|
se.host || case
|
||||||
|
when se.port is not null then ':' || se.port::text
|
||||||
|
else ''
|
||||||
|
end
|
||||||
|
)
|
||||||
|
order by hp.id
|
||||||
|
)
|
||||||
|
from ems.asset_heat_pump hp
|
||||||
|
left join ems.site_endpoint se on se.id = hp.endpoint_id
|
||||||
|
where hp.site_id = p_site_id
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'operating_mode',
|
||||||
|
(
|
||||||
|
select to_jsonb(om)
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
m.mode_code,
|
||||||
|
m.activated_at,
|
||||||
|
m.activated_by,
|
||||||
|
m.valid_until,
|
||||||
|
m.previous_mode,
|
||||||
|
m.notes,
|
||||||
|
d.name as mode_name,
|
||||||
|
d.description as mode_description,
|
||||||
|
d.loxone_mode_value,
|
||||||
|
d.ev_enabled,
|
||||||
|
d.heat_pump_enabled,
|
||||||
|
d.battery_mode,
|
||||||
|
d.grid_mode,
|
||||||
|
d.is_autonomous
|
||||||
|
from ems.site_operating_mode m
|
||||||
|
join ems.operating_mode_def d on d.code = m.mode_code
|
||||||
|
where m.site_id = p_site_id
|
||||||
|
) om
|
||||||
|
),
|
||||||
|
'active_overrides',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(to_jsonb(o.*) order by o.valid_from desc)
|
||||||
|
from (
|
||||||
|
select *
|
||||||
|
from ems.site_override so
|
||||||
|
where so.site_id = p_site_id
|
||||||
|
and so.valid_from <= now()
|
||||||
|
and (so.valid_to is null or so.valid_to > now())
|
||||||
|
order by so.valid_from desc
|
||||||
|
limit 50
|
||||||
|
) o
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'operational',
|
||||||
|
jsonb_build_object(
|
||||||
|
'heartbeat_last_seen',
|
||||||
|
(select hb.last_seen from ems.site_heartbeat hb where hb.site_id = p_site_id limit 1),
|
||||||
|
'heartbeat_status',
|
||||||
|
(select hb.status from ems.site_heartbeat hb where hb.site_id = p_site_id limit 1),
|
||||||
|
'has_active_plan',
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from ems.planning_run pr
|
||||||
|
where pr.site_id = p_site_id
|
||||||
|
and pr.status = 'active'
|
||||||
|
),
|
||||||
|
'active_plan_created_at',
|
||||||
|
(
|
||||||
|
select pr.created_at
|
||||||
|
from ems.planning_run pr
|
||||||
|
where pr.site_id = p_site_id
|
||||||
|
and pr.status = 'active'
|
||||||
|
order by pr.created_at desc
|
||||||
|
limit 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_site_configuration(int) is
|
||||||
|
'GET /configuration jako jeden JSON bundle (maskované reference, deye_meta).';
|
||||||
28
db/routines/R__fn_site_effective_prices_day_prague.sql
Normal file
28
db/routines/R__fn_site_effective_prices_day_prague.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
create or replace function ems.fn_site_effective_prices_day_prague(
|
||||||
|
p_site_id int,
|
||||||
|
p_day date
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(u.j order by u.interval_start),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
t.interval_start,
|
||||||
|
to_jsonb(t) as j
|
||||||
|
from (
|
||||||
|
select v.*
|
||||||
|
from ems.vw_site_effective_price v
|
||||||
|
where v.site_id = p_site_id
|
||||||
|
and (v.interval_start at time zone 'Europe/Prague')::date = p_day
|
||||||
|
order by v.interval_start
|
||||||
|
) t
|
||||||
|
) u;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_site_effective_prices_day_prague(int, date) is
|
||||||
|
'Efektivní ceny pro kalendářní den v TZ Praha jako pole JSON řádků view.';
|
||||||
158
db/routines/R__fn_site_full_status.sql
Normal file
158
db/routines/R__fn_site_full_status.sql
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
create or replace function ems.fn_site_full_status(p_site_id int)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select
|
||||||
|
case
|
||||||
|
when not exists (select 1 from ems.site s0 where s0.id = p_site_id) then
|
||||||
|
jsonb_build_object('error', 'not_found')
|
||||||
|
else jsonb_build_object(
|
||||||
|
'site',
|
||||||
|
(
|
||||||
|
select jsonb_build_object(
|
||||||
|
'id', s.id,
|
||||||
|
'code', s.code,
|
||||||
|
'name', s.name,
|
||||||
|
'timezone', s.timezone
|
||||||
|
)
|
||||||
|
from ems.site s
|
||||||
|
where s.id = p_site_id
|
||||||
|
),
|
||||||
|
'operating_mode',
|
||||||
|
(
|
||||||
|
select jsonb_build_object(
|
||||||
|
'mode_code', m.mode_code,
|
||||||
|
'mode_name', d.name,
|
||||||
|
'activated_at', m.activated_at,
|
||||||
|
'activated_by', m.activated_by
|
||||||
|
)
|
||||||
|
from ems.site_operating_mode m
|
||||||
|
join ems.operating_mode_def d on d.code = m.mode_code
|
||||||
|
where m.site_id = p_site_id
|
||||||
|
),
|
||||||
|
'heartbeat',
|
||||||
|
(
|
||||||
|
select jsonb_build_object(
|
||||||
|
'last_seen', hb.last_seen,
|
||||||
|
'status', hb.status
|
||||||
|
)
|
||||||
|
from ems.site_heartbeat hb
|
||||||
|
where hb.site_id = p_site_id
|
||||||
|
),
|
||||||
|
'inverter_latest',
|
||||||
|
(
|
||||||
|
select to_jsonb(li.*)
|
||||||
|
from ems.vw_latest_inverter li
|
||||||
|
where li.site_id = p_site_id
|
||||||
|
order by li.measured_at desc nulls last
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
'ev_chargers',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'code', v.code,
|
||||||
|
'status', v.status,
|
||||||
|
'power_w', v.power_w,
|
||||||
|
'measured_at', v.measured_at
|
||||||
|
)
|
||||||
|
order by v.measured_at desc nulls last
|
||||||
|
)
|
||||||
|
from (
|
||||||
|
select distinct on (evc.charger_id)
|
||||||
|
evc.charger_code as code,
|
||||||
|
evc.status,
|
||||||
|
evc.power_w,
|
||||||
|
evc.measured_at
|
||||||
|
from ems.vw_latest_ev_charger evc
|
||||||
|
where evc.site_id = p_site_id
|
||||||
|
order by evc.charger_id, evc.measured_at desc nulls last
|
||||||
|
) v
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'heat_pump_latest',
|
||||||
|
(
|
||||||
|
select jsonb_build_object(
|
||||||
|
'power_w', hp.power_w,
|
||||||
|
'tuv_tank_temp_c', hp.tuv_tank_temp_c,
|
||||||
|
'measured_at', hp.measured_at
|
||||||
|
)
|
||||||
|
from ems.vw_latest_heat_pump hp
|
||||||
|
where hp.site_id = p_site_id
|
||||||
|
order by hp.measured_at desc nulls last
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
'battery_limits',
|
||||||
|
(
|
||||||
|
select jsonb_build_object(
|
||||||
|
'reserve_soc', min(ab.reserve_soc_percent)::float,
|
||||||
|
'min_soc', min(ab.min_soc_percent)::float
|
||||||
|
)
|
||||||
|
from ems.asset_battery ab
|
||||||
|
where ab.site_id = p_site_id
|
||||||
|
),
|
||||||
|
'active_plan',
|
||||||
|
(
|
||||||
|
select jsonb_build_object(
|
||||||
|
'id', pr.id,
|
||||||
|
'created_at', pr.created_at
|
||||||
|
)
|
||||||
|
from ems.planning_run pr
|
||||||
|
where pr.site_id = p_site_id
|
||||||
|
and pr.status = 'active'
|
||||||
|
order by pr.created_at desc
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
'planning_intervals',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'interval_start', pi.interval_start,
|
||||||
|
'battery_setpoint_w', pi.battery_setpoint_w,
|
||||||
|
'load_baseline_w', pi.load_baseline_w,
|
||||||
|
'pv_a_forecast_raw_w', pi.pv_a_forecast_raw_w,
|
||||||
|
'pv_b_forecast_raw_w', pi.pv_b_forecast_raw_w,
|
||||||
|
'pv_a_forecast_solver_w', pi.pv_a_forecast_solver_w,
|
||||||
|
'pv_b_forecast_solver_w', pi.pv_b_forecast_solver_w
|
||||||
|
)
|
||||||
|
order by pi.interval_start
|
||||||
|
)
|
||||||
|
from ems.planning_interval pi
|
||||||
|
where pi.run_id = (
|
||||||
|
select pr2.id
|
||||||
|
from ems.planning_run pr2
|
||||||
|
where pr2.site_id = p_site_id
|
||||||
|
and pr2.status = 'active'
|
||||||
|
order by pr2.created_at desc
|
||||||
|
limit 1
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'tomorrow_price_slot_count',
|
||||||
|
(
|
||||||
|
select count(*)::int
|
||||||
|
from ems.vw_site_effective_price vep
|
||||||
|
where vep.site_id = p_site_id
|
||||||
|
and (vep.interval_start at time zone coalesce(
|
||||||
|
nullif(trim((select s2.timezone from ems.site s2 where s2.id = p_site_id)), ''),
|
||||||
|
'Europe/Prague'
|
||||||
|
))::date = (
|
||||||
|
(
|
||||||
|
current_timestamp at time zone coalesce(
|
||||||
|
nullif(trim((select s3.timezone from ems.site s3 where s3.id = p_site_id)), ''),
|
||||||
|
'Europe/Prague'
|
||||||
|
)
|
||||||
|
)::date + 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_site_full_status(int) is
|
||||||
|
'Raw data pro GET /status/full (věk telemetrie a alerty dopočítá Python).';
|
||||||
138
db/routines/R__fn_site_notifications_context.sql
Normal file
138
db/routines/R__fn_site_notifications_context.sql
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
create or replace function ems.fn_site_notifications_context(p_site_id int)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
select
|
||||||
|
case
|
||||||
|
when not exists (select 1 from ems.site s0 where s0.id = p_site_id) then
|
||||||
|
jsonb_build_object('error', 'not_found')
|
||||||
|
else jsonb_build_object(
|
||||||
|
'timezone',
|
||||||
|
coalesce(
|
||||||
|
nullif(trim((select s.timezone from ems.site s where s.id = p_site_id)), ''),
|
||||||
|
'Europe/Prague'
|
||||||
|
),
|
||||||
|
'mode_code',
|
||||||
|
(select m.mode_code from ems.site_operating_mode m where m.site_id = p_site_id),
|
||||||
|
'has_plan',
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from ems.planning_run pr
|
||||||
|
where pr.site_id = p_site_id
|
||||||
|
and pr.status = 'active'
|
||||||
|
),
|
||||||
|
'tomorrow_slots',
|
||||||
|
(
|
||||||
|
select count(*)::int
|
||||||
|
from ems.vw_site_effective_price v
|
||||||
|
where v.site_id = p_site_id
|
||||||
|
and (v.interval_start at time zone coalesce(
|
||||||
|
nullif(trim((select s2.timezone from ems.site s2 where s2.id = p_site_id)), ''),
|
||||||
|
'Europe/Prague'
|
||||||
|
))::date = (
|
||||||
|
(
|
||||||
|
current_timestamp at time zone coalesce(
|
||||||
|
nullif(trim((select s3.timezone from ems.site s3 where s3.id = p_site_id)), ''),
|
||||||
|
'Europe/Prague'
|
||||||
|
)
|
||||||
|
)::date + 1
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'reserve_soc',
|
||||||
|
(select min(ab.reserve_soc_percent)::float from ems.asset_battery ab where ab.site_id = p_site_id),
|
||||||
|
'min_soc',
|
||||||
|
(select min(ab.min_soc_percent)::float from ems.asset_battery ab where ab.site_id = p_site_id),
|
||||||
|
'soc_pct',
|
||||||
|
(select li.battery_soc_percent::float from ems.vw_latest_inverter li where li.site_id = p_site_id order by li.measured_at desc nulls last limit 1),
|
||||||
|
'inv_measured_at',
|
||||||
|
(select li.measured_at from ems.vw_latest_inverter li where li.site_id = p_site_id order by li.measured_at desc nulls last limit 1),
|
||||||
|
'hb_last_seen',
|
||||||
|
(select hb.last_seen from ems.site_heartbeat hb where hb.site_id = p_site_id limit 1),
|
||||||
|
'price_slots',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'interval_start', v.interval_start,
|
||||||
|
'effective_buy_price_czk_kwh', v.effective_buy_price_czk_kwh,
|
||||||
|
'effective_sell_price_czk_kwh', v.effective_sell_price_czk_kwh
|
||||||
|
)
|
||||||
|
order by v.interval_start
|
||||||
|
)
|
||||||
|
from ems.vw_site_effective_price v
|
||||||
|
where v.site_id = p_site_id
|
||||||
|
and v.interval_start >= now()
|
||||||
|
and v.interval_start < now() + interval '48 hours'
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'avg_buy',
|
||||||
|
(
|
||||||
|
select avg(v.effective_buy_price_czk_kwh)::float
|
||||||
|
from ems.vw_site_effective_price v
|
||||||
|
where v.site_id = p_site_id
|
||||||
|
and v.interval_start::date in (current_date, current_date + 1)
|
||||||
|
),
|
||||||
|
'usable_wh',
|
||||||
|
(
|
||||||
|
select coalesce(sum(ab.usable_capacity_wh), 0)::float
|
||||||
|
from ems.asset_battery ab
|
||||||
|
join ems.asset_inverter ai on ai.id = ab.inverter_id
|
||||||
|
where ai.site_id = p_site_id
|
||||||
|
),
|
||||||
|
'ev_sessions',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', es.id,
|
||||||
|
'charger_id', es.charger_id,
|
||||||
|
'energy_delivered_wh', es.energy_delivered_wh,
|
||||||
|
'target_soc_pct', es.target_soc_pct,
|
||||||
|
'session_start', es.session_start,
|
||||||
|
'soc_at_connect_pct', es.soc_at_connect_pct,
|
||||||
|
'battery_capacity_kwh', coalesce(av_id.battery_capacity_kwh, av_def.battery_capacity_kwh),
|
||||||
|
'make', coalesce(av_id.make, av_def.make),
|
||||||
|
'model', coalesce(av_id.model, av_def.model),
|
||||||
|
'default_target_soc_pct', coalesce(av_id.default_target_soc_pct, av_def.default_target_soc_pct),
|
||||||
|
'charger_code', ac.code
|
||||||
|
)
|
||||||
|
order by es.id
|
||||||
|
)
|
||||||
|
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 = p_site_id
|
||||||
|
and es.session_end is null
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
),
|
||||||
|
'neg_windows',
|
||||||
|
coalesce(
|
||||||
|
(
|
||||||
|
select jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'predicted_date', p.predicted_date,
|
||||||
|
'window_start_hour', p.window_start_hour,
|
||||||
|
'window_end_hour', p.window_end_hour,
|
||||||
|
'probability_pct', p.probability_pct
|
||||||
|
)
|
||||||
|
order by p.predicted_date, p.window_start_hour
|
||||||
|
)
|
||||||
|
from ems.predicted_negative_price_window p
|
||||||
|
where p.site_id = p_site_id
|
||||||
|
and p.predicted_date between current_date and current_date + 2
|
||||||
|
and p.probability_pct >= 50
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_site_notifications_context(int) is
|
||||||
|
'Vstupy pro build_smart_notifications + infra pravidla (GET /notifications).';
|
||||||
35
db/routines/R__fn_telemetry_ev_charger_sample.sql
Normal file
35
db/routines/R__fn_telemetry_ev_charger_sample.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
create or replace function ems.fn_telemetry_ev_charger_sample(
|
||||||
|
p_site_id int,
|
||||||
|
p_charger_id int,
|
||||||
|
p_measured_at timestamptz,
|
||||||
|
p_connector_id int,
|
||||||
|
p_status text,
|
||||||
|
p_power_w int,
|
||||||
|
p_energy_kwh double precision
|
||||||
|
)
|
||||||
|
returns void
|
||||||
|
language sql
|
||||||
|
as $fn$
|
||||||
|
insert into ems.telemetry_ev_charger (
|
||||||
|
site_id,
|
||||||
|
charger_id,
|
||||||
|
measured_at,
|
||||||
|
connector_id,
|
||||||
|
status,
|
||||||
|
power_w,
|
||||||
|
energy_kwh
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
p_site_id,
|
||||||
|
p_charger_id,
|
||||||
|
p_measured_at,
|
||||||
|
p_connector_id,
|
||||||
|
p_status,
|
||||||
|
p_power_w,
|
||||||
|
p_energy_kwh
|
||||||
|
)
|
||||||
|
on conflict (charger_id, connector_id, measured_at) do nothing;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_telemetry_ev_charger_sample is
|
||||||
|
'Insert telemetrie nabíječky EV (placeholder Modbus).';
|
||||||
38
db/routines/R__fn_telemetry_heat_pump_sample.sql
Normal file
38
db/routines/R__fn_telemetry_heat_pump_sample.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
create or replace function ems.fn_telemetry_heat_pump_sample(
|
||||||
|
p_site_id int,
|
||||||
|
p_heat_pump_id int,
|
||||||
|
p_measured_at timestamptz,
|
||||||
|
p_power_w int,
|
||||||
|
p_outdoor_temp_c double precision,
|
||||||
|
p_water_outlet_temp_c double precision,
|
||||||
|
p_tuv_tank_temp_c double precision,
|
||||||
|
p_operating_mode text
|
||||||
|
)
|
||||||
|
returns void
|
||||||
|
language sql
|
||||||
|
as $fn$
|
||||||
|
insert into ems.telemetry_heat_pump (
|
||||||
|
site_id,
|
||||||
|
heat_pump_id,
|
||||||
|
measured_at,
|
||||||
|
power_w,
|
||||||
|
outdoor_temp_c,
|
||||||
|
water_outlet_temp_c,
|
||||||
|
tuv_tank_temp_c,
|
||||||
|
operating_mode
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
p_site_id,
|
||||||
|
p_heat_pump_id,
|
||||||
|
p_measured_at,
|
||||||
|
p_power_w,
|
||||||
|
p_outdoor_temp_c,
|
||||||
|
p_water_outlet_temp_c,
|
||||||
|
p_tuv_tank_temp_c,
|
||||||
|
p_operating_mode
|
||||||
|
)
|
||||||
|
on conflict (heat_pump_id, measured_at) do nothing;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_telemetry_heat_pump_sample is
|
||||||
|
'Insert telemetrie TČ (placeholder Modbus).';
|
||||||
62
db/routines/R__fn_telemetry_inverter_sample.sql
Normal file
62
db/routines/R__fn_telemetry_inverter_sample.sql
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
create or replace function ems.fn_telemetry_inverter_sample(
|
||||||
|
p_site_id int,
|
||||||
|
p_inverter_id int,
|
||||||
|
p_measured_at timestamptz,
|
||||||
|
p_pv_power_w int,
|
||||||
|
p_pv1_power_w int,
|
||||||
|
p_pv2_power_w int,
|
||||||
|
p_gen_port_power_w int,
|
||||||
|
p_battery_soc_percent double precision,
|
||||||
|
p_battery_power_w int,
|
||||||
|
p_batt_charge_today_wh int,
|
||||||
|
p_batt_discharge_today_wh int,
|
||||||
|
p_grid_power_w int,
|
||||||
|
p_load_power_w int,
|
||||||
|
p_grid_import_total_wh bigint,
|
||||||
|
p_grid_export_total_wh bigint,
|
||||||
|
p_run_state int
|
||||||
|
)
|
||||||
|
returns void
|
||||||
|
language sql
|
||||||
|
as $fn$
|
||||||
|
insert into ems.telemetry_inverter (
|
||||||
|
site_id,
|
||||||
|
inverter_id,
|
||||||
|
measured_at,
|
||||||
|
pv_power_w,
|
||||||
|
pv1_power_w,
|
||||||
|
pv2_power_w,
|
||||||
|
gen_port_power_w,
|
||||||
|
battery_soc_percent,
|
||||||
|
battery_power_w,
|
||||||
|
batt_charge_today_wh,
|
||||||
|
batt_discharge_today_wh,
|
||||||
|
grid_power_w,
|
||||||
|
load_power_w,
|
||||||
|
grid_import_total_wh,
|
||||||
|
grid_export_total_wh,
|
||||||
|
run_state
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
p_site_id,
|
||||||
|
p_inverter_id,
|
||||||
|
p_measured_at,
|
||||||
|
p_pv_power_w,
|
||||||
|
p_pv1_power_w,
|
||||||
|
p_pv2_power_w,
|
||||||
|
p_gen_port_power_w,
|
||||||
|
p_battery_soc_percent,
|
||||||
|
p_battery_power_w,
|
||||||
|
p_batt_charge_today_wh,
|
||||||
|
p_batt_discharge_today_wh,
|
||||||
|
p_grid_power_w,
|
||||||
|
p_load_power_w,
|
||||||
|
p_grid_import_total_wh,
|
||||||
|
p_grid_export_total_wh,
|
||||||
|
p_run_state
|
||||||
|
)
|
||||||
|
on conflict (inverter_id, measured_at) do nothing;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_telemetry_inverter_sample is
|
||||||
|
'Insert jednoho vzorku telemetrie střídače (telemetry_collector).';
|
||||||
17
db/views/R__vw_asset_ev_charger_modbus_poll.sql
Normal file
17
db/views/R__vw_asset_ev_charger_modbus_poll.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
drop view if exists ems.vw_asset_ev_charger_modbus_poll;
|
||||||
|
|
||||||
|
create view ems.vw_asset_ev_charger_modbus_poll as
|
||||||
|
select
|
||||||
|
ec.site_id,
|
||||||
|
ec.id as charger_id,
|
||||||
|
ec.code,
|
||||||
|
se.host,
|
||||||
|
se.port,
|
||||||
|
se.unit_id
|
||||||
|
from ems.asset_ev_charger ec
|
||||||
|
join ems.site_endpoint se on se.id = ec.endpoint_id
|
||||||
|
where se.enabled = true
|
||||||
|
and se.endpoint_type = 'modbus_tcp';
|
||||||
|
|
||||||
|
comment on view ems.vw_asset_ev_charger_modbus_poll is
|
||||||
|
'Nabíječky EV s Modbus TCP pro telemetry_collector.';
|
||||||
17
db/views/R__vw_asset_heat_pump_modbus_poll.sql
Normal file
17
db/views/R__vw_asset_heat_pump_modbus_poll.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
drop view if exists ems.vw_asset_heat_pump_modbus_poll;
|
||||||
|
|
||||||
|
create view ems.vw_asset_heat_pump_modbus_poll as
|
||||||
|
select
|
||||||
|
hp.site_id,
|
||||||
|
hp.id as heat_pump_id,
|
||||||
|
hp.code,
|
||||||
|
se.host,
|
||||||
|
se.port,
|
||||||
|
se.unit_id
|
||||||
|
from ems.asset_heat_pump hp
|
||||||
|
join ems.site_endpoint se on se.id = hp.endpoint_id
|
||||||
|
where se.enabled = true
|
||||||
|
and se.endpoint_type = 'modbus_tcp';
|
||||||
|
|
||||||
|
comment on view ems.vw_asset_heat_pump_modbus_poll is
|
||||||
|
'TČ s Modbus TCP pro telemetry_collector.';
|
||||||
18
db/views/R__vw_asset_inverter_modbus_poll.sql
Normal file
18
db/views/R__vw_asset_inverter_modbus_poll.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
drop view if exists ems.vw_asset_inverter_modbus_poll;
|
||||||
|
|
||||||
|
create view ems.vw_asset_inverter_modbus_poll as
|
||||||
|
select
|
||||||
|
ai.site_id,
|
||||||
|
ai.id as inverter_id,
|
||||||
|
ai.code,
|
||||||
|
se.host,
|
||||||
|
se.port,
|
||||||
|
se.unit_id
|
||||||
|
from ems.asset_inverter ai
|
||||||
|
join ems.site_endpoint se on se.id = ai.endpoint_id
|
||||||
|
where ai.active = true
|
||||||
|
and se.enabled = true
|
||||||
|
and se.endpoint_type = 'modbus_tcp';
|
||||||
|
|
||||||
|
comment on view ems.vw_asset_inverter_modbus_poll is
|
||||||
|
'Aktivní střídače s Modbus TCP endpointem pro telemetry_collector.';
|
||||||
23
db/views/R__vw_battery_cycle_daily.sql
Normal file
23
db/views/R__vw_battery_cycle_daily.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- denní součet |battery_power_w|/60 Wh → poměr k 2× usable (orientační cykly/den)
|
||||||
|
|
||||||
|
create or replace view ems.vw_battery_cycle_daily as
|
||||||
|
select
|
||||||
|
ti.site_id,
|
||||||
|
(ti.measured_at at time zone 'Europe/Prague')::date as day_prague,
|
||||||
|
sum(abs(ti.battery_power_w::numeric) / 60.0) as throughput_wh,
|
||||||
|
max(ab.usable_wh) as usable_wh,
|
||||||
|
round(
|
||||||
|
(sum(abs(ti.battery_power_w::numeric) / 60.0)
|
||||||
|
/ nullif(max(ab.usable_wh) * 2, 0))::numeric,
|
||||||
|
4
|
||||||
|
) as equiv_full_cycles
|
||||||
|
from ems.telemetry_inverter ti
|
||||||
|
cross join lateral (
|
||||||
|
select usable_capacity_wh::numeric as usable_wh
|
||||||
|
from ems.asset_battery b
|
||||||
|
where b.site_id = ti.site_id
|
||||||
|
order by b.id
|
||||||
|
limit 1
|
||||||
|
) ab
|
||||||
|
where ti.battery_power_w is not null
|
||||||
|
group by ti.site_id, (ti.measured_at at time zone 'Europe/Prague')::date;
|
||||||
21
db/views/R__vw_modbus_last_verified.sql
Normal file
21
db/views/R__vw_modbus_last_verified.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- poslední úspěšně ověřený zápis per (site, asset, register)
|
||||||
|
|
||||||
|
drop view if exists ems.vw_modbus_last_verified;
|
||||||
|
|
||||||
|
create view ems.vw_modbus_last_verified as
|
||||||
|
select distinct on (mc.site_id, mc.asset_type, mc.asset_id, mc.register)
|
||||||
|
mc.id,
|
||||||
|
mc.site_id,
|
||||||
|
mc.asset_type,
|
||||||
|
mc.asset_id,
|
||||||
|
mc.register,
|
||||||
|
mc.value_verified,
|
||||||
|
mc.verified_at,
|
||||||
|
mc.status
|
||||||
|
from ems.modbus_command mc
|
||||||
|
where mc.status = 'verified'
|
||||||
|
and mc.value_verified is not null
|
||||||
|
order by mc.site_id, mc.asset_type, mc.asset_id, mc.register, mc.verified_at desc nulls last, mc.id desc;
|
||||||
|
|
||||||
|
comment on view ems.vw_modbus_last_verified is
|
||||||
|
'DISTINCT ON (register) poslední verified řádek pro ověření / mapu registrů.';
|
||||||
19
db/views/R__vw_site_directory.sql
Normal file
19
db/views/R__vw_site_directory.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- tenké čtení lokality pro API (Phase 1 – místo select z ems.site v routerech)
|
||||||
|
|
||||||
|
drop view if exists ems.vw_site_directory;
|
||||||
|
|
||||||
|
create view ems.vw_site_directory as
|
||||||
|
select
|
||||||
|
s.id,
|
||||||
|
s.code,
|
||||||
|
s.name,
|
||||||
|
s.timezone,
|
||||||
|
s.latitude,
|
||||||
|
s.longitude,
|
||||||
|
s.active,
|
||||||
|
s.notes,
|
||||||
|
s.created_at
|
||||||
|
from ems.site s;
|
||||||
|
|
||||||
|
comment on view ems.vw_site_directory is
|
||||||
|
'Veřejná metadata lokality pro GET seznamů; čtení místo přímého dotazu na ems.site.';
|
||||||
@@ -46,6 +46,10 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Read-model JSONB (`ems.fn_*`)
|
||||||
|
|
||||||
|
FastAPI endpointy pro dashboard a konfiguraci preferují **jedno volání** `select ems.fn_*(…)` vracející **jsonb** (pole řádků, agregace, merge locků), aby v Pythonu nezůstávaly ad-hoc `SELECT`/`JOIN`/`WITH`. Pomocník `app.db_json.fetch_json` vrací `dict`/`list`. Telemetrie a IO zůstávají v Pythonu; čisté agregace a sjednocení TZ patří do SQL. Opakované migrace: `db/routines/R__fn_*.sql`, `db/views/R__vw_*.sql`.
|
||||||
|
|
||||||
## Komponenty
|
## Komponenty
|
||||||
|
|
||||||
| Komponenta | Technologie | Port | Popis |
|
| Komponenta | Technologie | Port | Popis |
|
||||||
@@ -77,7 +81,7 @@ ems-platform/
|
|||||||
R__fn_cop_estimate.sql
|
R__fn_cop_estimate.sql
|
||||||
R__fn_baseline_consumption.sql
|
R__fn_baseline_consumption.sql
|
||||||
R__fn_fill_audit_interval.sql
|
R__fn_fill_audit_interval.sql
|
||||||
R__fn_plan_day.sql
|
(historicky) R__fn_plan_day.sql – primární plánování je PuLP v Pythonu
|
||||||
R__fn_create_planning_run.sql
|
R__fn_create_planning_run.sql
|
||||||
views/
|
views/
|
||||||
R__vw_site_effective_price.sql
|
R__vw_site_effective_price.sql
|
||||||
|
|||||||
46
docs/refactor-weaknesses.md
Normal file
46
docs/refactor-weaknesses.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Refaktor EMS – slabá místa a hranice SQL vs Python
|
||||||
|
|
||||||
|
Dokument z code review před SQL-first refaktorem. Cíl produktu: maximalizovat ekonomický zisk při rozumném využití zdrojů a **bez** nadměrného cyklování baterie.
|
||||||
|
|
||||||
|
## Ekonomický cíl a cyklování
|
||||||
|
|
||||||
|
### Penalizace cyklu v LP
|
||||||
|
|
||||||
|
- V `docs/04-modules/planning.md` je uvedena symetrická penalizace cyklu (`0.5*(charge+discharge)` v textu účelové funkce).
|
||||||
|
- V `solve_dispatch` je v kódu **`0.5 * (bc[t] + bd[t]) * degradation_cost_effective * interval_h / 1000`** – s dokumentací souhlasí.
|
||||||
|
|
||||||
|
### Další páky proti cyklování
|
||||||
|
|
||||||
|
- **`degradation_cost_czk_kwh`** na `asset_battery` – jediná explicitní ekonomická cena cyklu v objective.
|
||||||
|
- **Slot pre-selection** (`charge_slot_buffer`, `discharge_slot_buffer`) – omezuje množinu slotů pro nabíjení z sítě / vybíjení na export; snižuje mikro-cyklování.
|
||||||
|
- **Dynamická arbitrážní podlaha** (`_dynamic_arb_floor_wh_series`, `ARB_LOOKAHEAD_SLOTS = 32` = 8 h) – posouvá ekonomickou podlahu mezi `min_soc_wh` a rezervou podle očekávané FVE energie vpřed; lookahead je **hard-coded** v Pythonu – kandidát na přesun do DB (`planning_config`).
|
||||||
|
- **`pv_scarcity_factor`** (0.65–1.0) – mění násobek degradace podle poměru očekávané FVE energie k kapacitě baterie; **úzký rozsah** = slabý signál; možné rozšíření v budoucnu.
|
||||||
|
- **Horizont a váhy slotů** (`SLOT_WEIGHT_FULL/MEDIUM/LOW` = 1.0 / 0.7 / 0.4) – hard-coded; vzdálenější sloty mají menší váhu v objective → konzervativnější chování k predikovaným cenám.
|
||||||
|
|
||||||
|
### Zelený bonus (pole B)
|
||||||
|
|
||||||
|
- Záměrně **není** v účelové funkci LP – bonus se účtuje v auditu (`fn_fill_audit_interval`). Solver neomezuje „výtěžek“ bonusu; riziko přeladění LP je větší než přínos.
|
||||||
|
|
||||||
|
### Audit cyklování (telemetrie)
|
||||||
|
|
||||||
|
- `fn_battery_cycle_audit` + `vw_battery_cycle_daily` – ekvivalent plných cyklů z `telemetry_inverter` pro monitoring a ladění `degradation_cost_czk_kwh`, **ne** nový hard constraint v LP.
|
||||||
|
|
||||||
|
## SQL vs Python (stav před refaktorem)
|
||||||
|
|
||||||
|
| Oblast | Problém |
|
||||||
|
|--------|---------|
|
||||||
|
| `planning_engine` | Velké inline `SELECT` (`_load_slots`, `_load_site_context`), `compute_correction_factor`, `_save_planning_run`, f-string CTE pro slot boundary |
|
||||||
|
| `control_exporter` | `DISTINCT ON` journal, interpolace SQL pro plán slotu, pack hodin/TOU v Pythonu |
|
||||||
|
| Routery | Mnoho po sobě jdoucích dotazů (`site_configuration`, `full_status`), running sumy v Pythonu (`economics`), split FVE A/B v Pythonu |
|
||||||
|
| `price_importer` | Mix `::date` vs den v `Europe/Prague` u statistik OTE |
|
||||||
|
|
||||||
|
## Cílová hranice po refaktoru
|
||||||
|
|
||||||
|
- **Python:** PuLP solver, orchestrace jobů, Modbus/HTTP/Discord, pvlib forecast.
|
||||||
|
- **PostgreSQL:** čtení/zápis dat přes `ems.fn_*` a `ems.vw_*`; read-modely jako JSONB bundles.
|
||||||
|
|
||||||
|
## Odkazy
|
||||||
|
|
||||||
|
- Plánování: `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`
|
||||||
|
- Architektura: `docs/02-architecture.md`
|
||||||
|
- Pravidla agenta: `CLAUDE.md` (sekce o `fn_*`)
|
||||||
Reference in New Issue
Block a user