implementace Ekonomiky
All checks were successful
test / smoke-test (push) Successful in 5s
deploy / deploy (push) Successful in 11s

This commit is contained in:
Dusan Vojacek
2026-04-05 20:10:43 +02:00
parent caf3f522e2
commit 5fcc47bce2
13 changed files with 1310 additions and 31 deletions

View File

@@ -15,6 +15,7 @@ import httpx
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.db_json import record_to_dict
from app.deps import set_pg_pool
from app.routers.economics import router as economics_router
from app.routers.ev import router as ev_router
from app.routers.full_status import router as full_status_router
from app.routers.plan import router as plan_router
@@ -423,6 +424,63 @@ async def lifespan(app: FastAPI):
id="forecast_refresh_2h",
replace_existing=True,
)
async def scheduled_daily_economics_notification() -> None:
from services.notification_service import notify_daily_economics
async with app.state.pg_pool.acquire() as conn:
sites = await conn.fetch("SELECT id, code FROM ems.site WHERE active = true")
for site in sites:
site_id = int(site["id"])
site_code = site["code"]
try:
row = await conn.fetchrow(
"""
SELECT import_kwh, export_kwh,
import_cost_czk, export_revenue_czk,
green_bonus_czk, total_balance_czk,
planned_balance_czk
FROM ems.vw_economics_daily
WHERE site_id = $1
AND day_local = (
CURRENT_DATE AT TIME ZONE 'Europe/Prague' - INTERVAL '1 day'
)::date
""",
site_id,
)
if row is None:
continue
yesterday = (
datetime.now(ZoneInfo("Europe/Prague"))
- timedelta(days=1)
).strftime("%Y-%m-%d")
await notify_daily_economics(
site_code=site_code,
day=yesterday,
import_kwh=float(row["import_kwh"] or 0),
import_cost=float(row["import_cost_czk"] or 0),
export_kwh=float(row["export_kwh"] or 0),
export_revenue=float(row["export_revenue_czk"] or 0),
green_bonus=float(row["green_bonus_czk"] or 0),
total_balance=float(row["total_balance_czk"] or 0),
planned_balance=float(row["planned_balance_czk"])
if row["planned_balance_czk"] is not None
else None,
)
except Exception:
logger.exception(
"scheduled_daily_economics_notification site=%s failed",
site_id,
)
scheduler.add_job(
scheduled_daily_economics_notification,
"cron",
hour=7,
minute=0,
id="daily_economics_notification",
replace_existing=True,
)
scheduler.start()
telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool))
@@ -454,6 +512,7 @@ app = FastAPI(title="EMS Platform", lifespan=lifespan)
app.include_router(plan_router, prefix="/api/v1")
app.include_router(ev_router, prefix="/api/v1")
app.include_router(full_status_router, prefix="/api/v1")
app.include_router(economics_router, prefix="/api/v1")
sites_router = APIRouter(prefix="/api/v1/sites", tags=["sites"])

View File

@@ -0,0 +1,386 @@
"""REST API denní ekonomické vyhodnocení provozu."""
from __future__ import annotations
import logging
from datetime import date, datetime
from typing import Annotated, Any
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from app.deps import get_pg_pool
router = APIRouter(
prefix="/sites/{site_id}/economics",
tags=["economics"],
)
logger = logging.getLogger(__name__)
class DailyEconomics(BaseModel):
day: date
interval_count: int
import_kwh: float
export_kwh: float
pv_kwh: float
load_kwh: float
self_consumption_kwh: float
ev_kwh: float
hp_kwh: float
import_cost_czk: float
export_revenue_czk: float
net_cost_czk: float
green_bonus_czk: float
total_balance_czk: float
planned_balance_czk: float | None
deviation_cost_czk: float | None
is_locked: bool
class DailyEconomicsResponse(BaseModel):
days: list[DailyEconomics]
has_green_bonus: bool
class IntervalEconomics(BaseModel):
interval_start: str
import_kwh: float
export_kwh: float
dynamic_cost_czk: float | None
stored_cost_czk: float | None
green_bonus_czk: float | None
planned_cost_czk: float | None
planned_grid_w: int | None
actual_grid_power_w: int | None
effective_buy_price: float | None
effective_sell_price: float | None
planned_buy_price: float | None
planned_sell_price: float | None
actual_pv_power_w: int | None
actual_load_power_w: int | None
actual_battery_power_w: int | None
actual_battery_soc_pct: float | None
class ChartDayPoint(BaseModel):
day: date
daily_balance_czk: float
cumulative_balance_czk: float
class LockResponse(BaseModel):
locked: bool
day: date
def _num(val: Any) -> float:
if val is None:
return 0.0
return float(val)
async def _check_site(conn: asyncpg.Connection, site_id: int) -> None:
ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not ok:
raise HTTPException(status_code=404, detail="Site not found")
async def _has_green_bonus(conn: asyncpg.Connection, site_id: int) -> bool:
return bool(
await conn.fetchval(
"""
SELECT EXISTS(
SELECT 1 FROM ems.asset_pv_array
WHERE site_id = $1
AND green_bonus_czk_kwh IS NOT NULL
)
""",
site_id,
)
)
@router.get("/daily", response_model=DailyEconomicsResponse)
async def get_economics_daily(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
month: str = Query(
...,
description="YYYY-MM (měsíc pro zobrazení)",
pattern=r"^\d{4}-\d{2}$",
),
) -> DailyEconomicsResponse:
try:
year, mon = month.split("-")
month_start = date(int(year), int(mon), 1)
if int(mon) == 12:
month_end = date(int(year) + 1, 1, 1)
else:
month_end = date(int(year), int(mon) + 1, 1)
except (ValueError, IndexError):
raise HTTPException(status_code=400, detail="Invalid month, expected YYYY-MM")
async with db.acquire() as conn:
await _check_site(conn, site_id)
has_bonus = await _has_green_bonus(conn, site_id)
dyn_rows = await conn.fetch(
"""
SELECT * FROM ems.vw_economics_daily
WHERE site_id = $1
AND day_local >= $2
AND day_local < $3
ORDER BY day_local
""",
site_id,
month_start,
month_end,
)
lock_rows = await conn.fetch(
"""
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] = []
for r in dyn_rows:
d = r["day_local"]
lock = locks.get(d)
if lock:
days.append(
DailyEconomics(
day=d,
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"]),
self_consumption_kwh=_num(r["self_consumption_kwh"]),
ev_kwh=_num(r["ev_kwh"]),
hp_kwh=_num(r["hp_kwh"]),
import_cost_czk=_num(lock["import_cost_czk"]),
export_revenue_czk=_num(lock["export_revenue_czk"]),
net_cost_czk=_num(lock["net_cost_czk"]),
green_bonus_czk=_num(lock["green_bonus_czk"]),
total_balance_czk=_num(lock["total_balance_czk"]),
planned_balance_czk=_num(r["planned_balance_czk"]) if r["planned_balance_czk"] is not None else None,
deviation_cost_czk=_num(r["deviation_cost_czk"]) if r["deviation_cost_czk"] is not None else None,
is_locked=True,
)
)
else:
days.append(
DailyEconomics(
day=d,
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"]),
self_consumption_kwh=_num(r["self_consumption_kwh"]),
ev_kwh=_num(r["ev_kwh"]),
hp_kwh=_num(r["hp_kwh"]),
import_cost_czk=_num(r["import_cost_czk"]),
export_revenue_czk=_num(r["export_revenue_czk"]),
net_cost_czk=_num(r["net_cost_czk"]),
green_bonus_czk=_num(r["green_bonus_czk"]),
total_balance_czk=_num(r["total_balance_czk"]),
planned_balance_czk=_num(r["planned_balance_czk"]) if r["planned_balance_czk"] is not None else None,
deviation_cost_czk=_num(r["deviation_cost_czk"]) if r["deviation_cost_czk"] is not None else None,
is_locked=False,
)
)
return DailyEconomicsResponse(days=days, has_green_bonus=has_bonus)
@router.get("/daily/{day}/intervals", response_model=list[IntervalEconomics])
async def get_economics_intervals(
site_id: int,
day: date,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> list[IntervalEconomics]:
async with db.acquire() as conn:
await _check_site(conn, site_id)
rows = await conn.fetch(
"""
SELECT *
FROM ems.vw_economics_interval
WHERE site_id = $1
AND date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague')::date = $2
ORDER BY interval_start
""",
site_id,
day,
)
return [
IntervalEconomics(
interval_start=r["interval_start"].isoformat(),
import_kwh=_num(r["import_kwh"]),
export_kwh=_num(r["export_kwh"]),
dynamic_cost_czk=float(r["dynamic_cost_czk"]) if r["dynamic_cost_czk"] is not None else None,
stored_cost_czk=float(r["stored_cost_czk"]) if r["stored_cost_czk"] is not None else None,
green_bonus_czk=float(r["green_bonus_czk"]) if r["green_bonus_czk"] is not None else None,
planned_cost_czk=float(r["planned_cost_czk"]) if r["planned_cost_czk"] is not None else None,
planned_grid_w=int(r["planned_grid_w"]) if r["planned_grid_w"] is not None else None,
actual_grid_power_w=int(r["actual_grid_power_w"]) if r["actual_grid_power_w"] is not None else None,
effective_buy_price=float(r["effective_buy_price_czk_kwh"]) if r["effective_buy_price_czk_kwh"] is not None else None,
effective_sell_price=float(r["effective_sell_price_czk_kwh"]) if r["effective_sell_price_czk_kwh"] is not None else None,
planned_buy_price=float(r["planned_buy_price"]) if r["planned_buy_price"] is not None else None,
planned_sell_price=float(r["planned_sell_price"]) if r["planned_sell_price"] is not None else None,
actual_pv_power_w=int(r["actual_pv_power_w"]) if r["actual_pv_power_w"] is not None else None,
actual_load_power_w=int(r["actual_load_power_w"]) if r["actual_load_power_w"] is not None else None,
actual_battery_power_w=int(r["actual_battery_power_w"]) if r["actual_battery_power_w"] is not None else None,
actual_battery_soc_pct=float(r["actual_battery_soc_pct"]) if r["actual_battery_soc_pct"] is not None else None,
)
for r in rows
]
@router.post("/daily/{day}/lock", response_model=LockResponse)
async def lock_day(
site_id: int,
day: date,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> LockResponse:
async with db.acquire() as conn:
await _check_site(conn, site_id)
row = await conn.fetchrow(
"""
SELECT import_cost_czk, export_revenue_czk, net_cost_czk,
green_bonus_czk, total_balance_czk
FROM ems.vw_economics_daily
WHERE site_id = $1 AND day_local = $2
""",
site_id,
day,
)
if row is None:
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)
VALUES ($1, $2, $3, $4, $5, $6, $7)
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,
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"],
)
return LockResponse(locked=True, day=day)
@router.delete("/daily/{day}/lock", response_model=LockResponse)
async def unlock_day(
site_id: int,
day: date,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> LockResponse:
async with db.acquire() as conn:
await _check_site(conn, site_id)
await conn.execute(
"DELETE FROM ems.audit_day_lock WHERE site_id = $1 AND day_local = $2",
site_id,
day,
)
return LockResponse(locked=False, day=day)
@router.get("/monthly-chart", response_model=list[ChartDayPoint])
async def get_monthly_chart(
site_id: int,
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
month: str = Query(
...,
description="YYYY-MM",
pattern=r"^\d{4}-\d{2}$",
),
) -> list[ChartDayPoint]:
try:
year, mon = month.split("-")
month_start = date(int(year), int(mon), 1)
if int(mon) == 12:
month_end = date(int(year) + 1, 1, 1)
else:
month_end = date(int(year), int(mon) + 1, 1)
except (ValueError, IndexError):
raise HTTPException(status_code=400, detail="Invalid month, expected YYYY-MM")
async with db.acquire() as conn:
await _check_site(conn, site_id)
rows = await conn.fetch(
"""
SELECT day_local, total_balance_czk
FROM ems.vw_economics_daily
WHERE site_id = $1
AND day_local >= $2
AND day_local < $3
ORDER BY day_local
""",
site_id,
month_start,
month_end,
)
lock_rows = await conn.fetch(
"""
SELECT day_local, total_balance_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"]: _num(r["total_balance_czk"]) for r in lock_rows}
points: list[ChartDayPoint] = []
cumulative = 0.0
for r in rows:
d = r["day_local"]
balance = locks.get(d, _num(r["total_balance_czk"]))
cumulative += balance
points.append(
ChartDayPoint(
day=d,
daily_balance_czk=round(balance, 2),
cumulative_balance_czk=round(cumulative, 2),
)
)
return points

View File

@@ -63,3 +63,33 @@ async def notify_self_sustain_activated(site_code: str, reason: str) -> None:
f"Důvod: {reason}"
)
await send_discord(msg, level="critical")
async def notify_daily_economics(
site_code: str,
day: str,
import_kwh: float,
import_cost: float,
export_kwh: float,
export_revenue: float,
green_bonus: float,
total_balance: float,
planned_balance: float | None,
) -> None:
lines = [
f"Ekonomika **{site_code}** {day}:",
f" Import: {import_kwh:.1f} kWh = {import_cost:.2f}",
f" Export: {export_kwh:.1f} kWh = {export_revenue:.2f}",
]
if green_bonus > 0:
lines.append(f" Zelený bonus: {green_bonus:.2f}")
sign = "+" if total_balance >= 0 else ""
lines.append(f" **BILANCE: {sign}{total_balance:.2f} Kč**")
if planned_balance is not None:
dev = total_balance - planned_balance
dev_sign = "+" if dev >= 0 else ""
lines.append(
f" Plán předpokládal: {planned_balance:+.2f}"
f"(odchylka {dev_sign}{dev:.2f} Kč)"
)
await send_discord("\n".join(lines), level="info")

View File

@@ -19,6 +19,8 @@ DECLARE
hid integer;
chunk_ids integer[];
n integer;
has_dropped boolean;
q text;
BEGIN
SELECT h.id
INTO hid
@@ -40,19 +42,40 @@ BEGIN
PERFORM _timescaledb_functions.remove_dropped_chunk_metadata(hid);
END IF;
-- Sloupec chunk.dropped byl v novějším TimescaleDB odstraněn (metadata dropnutých chunků se maže).
SELECT EXISTS (
SELECT 1
FROM pg_catalog.pg_attribute a
JOIN pg_catalog.pg_class r ON r.oid = a.attrelid
JOIN pg_catalog.pg_namespace n ON n.oid = r.relnamespace
WHERE n.nspname = '_timescaledb_catalog'
AND r.relname = 'chunk'
AND a.attname = 'dropped'
AND a.attnum > 0
AND NOT a.attisdropped
)
INTO has_dropped;
q := format(
$fmt$
SELECT coalesce(array_agg(c.id ORDER BY c.id), ARRAY[]::integer[])
INTO chunk_ids
FROM _timescaledb_catalog.chunk c
WHERE c.hypertable_id = hid
AND NOT c.dropped
WHERE c.hypertable_id = %s
%s
AND NOT EXISTS (
SELECT 1
FROM pg_catalog.pg_class cl
JOIN pg_catalog.pg_namespace ns ON ns.oid = cl.relnamespace
WHERE ns.nspname = c.schema_name
AND cl.relname = c.table_name
)
$fmt$,
hid,
CASE WHEN has_dropped THEN 'AND NOT c.dropped' ELSE '' END
);
EXECUTE q INTO chunk_ids;
n := coalesce(array_length(chunk_ids, 1), 0);
IF n = 0 THEN
RETURN 0;

View File

@@ -0,0 +1,18 @@
-- =============================================================
-- V036 asset_pv_array.telemetry_source
-- Explicitní mapování FVE pole → sloupec v telemetry_inverter.
-- =============================================================
ALTER TABLE ems.asset_pv_array
ADD COLUMN IF NOT EXISTS telemetry_source TEXT;
COMMENT ON COLUMN ems.asset_pv_array.telemetry_source IS
'Který sloupec v telemetry_inverter odpovídá tomuto poli.
gen_port = gen_port_power_w (AC-coupled pole na GEN portu),
pv_strings = pv1_power_w + pv2_power_w (DC stringy na MPPT),
pv_total = pv_power_w (souhrnné, pokud pole nelze rozlišit).
NULL = pole nemá přímou telemetrii (fallback na forecast).';
-- Seed pro referenční site home-01:
UPDATE ems.asset_pv_array SET telemetry_source = 'pv_strings' WHERE code = 'pv-a';
UPDATE ems.asset_pv_array SET telemetry_source = 'gen_port' WHERE code = 'pv-b';

View File

@@ -0,0 +1,23 @@
-- =============================================================
-- V037 audit_day_lock
-- Zamknuté (finalizované) denní ekonomické výsledky.
-- =============================================================
CREATE TABLE ems.audit_day_lock (
site_id INT NOT NULL REFERENCES ems.site(id),
day_local DATE NOT NULL,
import_cost_czk NUMERIC(12,2) NOT NULL,
export_revenue_czk NUMERIC(12,2) NOT NULL,
net_cost_czk NUMERIC(12,2) NOT NULL,
green_bonus_czk NUMERIC(12,2) NOT NULL DEFAULT 0,
total_balance_czk NUMERIC(12,2) NOT NULL,
locked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_by TEXT NOT NULL DEFAULT 'user',
notes TEXT,
PRIMARY KEY (site_id, day_local)
);
COMMENT ON TABLE ems.audit_day_lock IS
'Zamknuté (finalizované) denní ekonomické výsledky.
Když řádek existuje, frontend zobrazí tyto hodnoty místo dynamických z vw_economics_daily.
Uživatel zamkne den, až má jistotu o cenách snapshot aktuálních dynamických hodnot.';

View File

@@ -92,14 +92,38 @@ BEGIN
ELSE COALESCE(v_sell_price, 0) END;
END IF;
-- Zelený bonus: výroba bonusových polí z poslední ok predikce pro slot (Wh = W × 0,25 h)
-- Zelený bonus: výroba bonusových polí z reálné telemetrie (Wh = průměr W × 0,25 h)
v_pv_b_production_wh := NULL;
FOR r_bonus IN
SELECT id
FROM ems.asset_pv_array
WHERE site_id = p_site_id
AND green_bonus_czk_kwh IS NOT NULL
SELECT pa.id, pa.inverter_id, pa.telemetry_source
FROM ems.asset_pv_array pa
WHERE pa.site_id = p_site_id
AND pa.green_bonus_czk_kwh IS NOT NULL
AND pa.green_bonus_valid_from <= p_interval_start::DATE
AND (pa.green_bonus_valid_to IS NULL
OR pa.green_bonus_valid_to > p_interval_start::DATE)
LOOP
v_array_prod_wh := NULL;
IF r_bonus.telemetry_source IS NOT NULL AND r_bonus.inverter_id IS NOT NULL THEN
SELECT AVG(
CASE r_bonus.telemetry_source
WHEN 'gen_port' THEN ti.gen_port_power_w
WHEN 'pv_strings' THEN COALESCE(ti.pv1_power_w, 0)
+ COALESCE(ti.pv2_power_w, 0)
WHEN 'pv_total' THEN ti.pv_power_w
ELSE NULL
END
)::NUMERIC * 0.25
INTO v_array_prod_wh
FROM ems.telemetry_inverter ti
WHERE ti.inverter_id = r_bonus.inverter_id
AND ti.measured_at >= p_interval_start
AND ti.measured_at < v_interval_end;
END IF;
-- Fallback na forecast pokud telemetrie není k dispozici
IF v_array_prod_wh IS NULL THEN
SELECT fpi.power_w * 0.25
INTO v_array_prod_wh
FROM ems.forecast_pv_interval fpi
@@ -110,6 +134,7 @@ BEGIN
AND fpr.status = 'ok'
ORDER BY fpr.created_at DESC
LIMIT 1;
END IF;
v_array_prod_wh := COALESCE(v_array_prod_wh, 0);
IF v_pv_b_production_wh IS NULL THEN
@@ -175,7 +200,8 @@ $$;
COMMENT ON FUNCTION ems.fn_fill_audit_interval(INT, TIMESTAMPTZ) IS
'Naplní nebo aktualizuje jeden řádek v audit_interval pro danou lokalitu a 15min interval.
Agreguje průměry z telemetrie (střídač, EV, TČ), porovná se skutečným plánem a spočítá odchylky.
Zelený bonus: součet přes všechna pole s nastaveným bonusem, výroba z poslední ok forecast_pv_interval.
Zelený bonus: součet přes pole s green_bonus_czk_kwh; výroba primárně z reálné telemetrie
(dle asset_pv_array.telemetry_source), fallback na forecast_pv_interval pokud telemetrie chybí.
Volat každých 15 minut pro interval který právě skončil.';
-- ============================================================

View File

@@ -0,0 +1,75 @@
-- =============================================================
-- R__vw_site_effective_price_economics.sql
-- EMS Platform ekonomické views (závisí na vw_site_effective_price)
-- Musí běžet až PO R__vw_site_effective_price.sql (abecední pořadí Flyway).
-- Repeatable migration
-- =============================================================
CREATE OR REPLACE VIEW ems.vw_economics_interval AS
SELECT
ai.site_id,
ai.interval_start,
ROUND(GREATEST(ai.actual_grid_power_w, 0)::NUMERIC / 4000, 4) AS import_kwh,
ROUND(ABS(LEAST(ai.actual_grid_power_w, 0))::NUMERIC / 4000, 4) AS export_kwh,
CASE WHEN ai.actual_grid_power_w >= 0
THEN ROUND((ai.actual_grid_power_w::NUMERIC / 4000)
* COALESCE(ep.effective_buy_price_czk_kwh, 0), 4)
ELSE ROUND((ai.actual_grid_power_w::NUMERIC / 4000)
* COALESCE(ep.effective_sell_price_czk_kwh, 0), 4)
END AS dynamic_cost_czk,
ai.actual_cost_czk AS stored_cost_czk,
ai.green_bonus_czk,
pi.expected_cost_czk AS planned_cost_czk,
pi.grid_setpoint_w AS planned_grid_w,
ai.actual_grid_power_w,
ep.effective_buy_price_czk_kwh,
ep.effective_sell_price_czk_kwh,
pi.effective_buy_price AS planned_buy_price,
pi.effective_sell_price AS planned_sell_price,
ai.actual_pv_power_w,
ai.actual_load_power_w,
ai.actual_ev_power_w,
ai.actual_heat_pump_power_w,
ai.actual_battery_power_w,
ai.actual_battery_soc_pct
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
LEFT JOIN ems.planning_interval pi
ON pi.run_id = ai.planning_run_id AND pi.interval_start = ai.interval_start;
COMMENT ON VIEW ems.vw_economics_interval IS
'Dynamické ekonomické vyhodnocení per 15min slot (závisí na vw_site_effective_price).';
CREATE OR REPLACE VIEW ems.vw_economics_daily AS
SELECT
site_id,
date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague')::date AS day_local,
COUNT(*)::int AS interval_count,
ROUND(SUM(import_kwh), 3) AS import_kwh,
ROUND(SUM(export_kwh), 3) AS export_kwh,
ROUND(SUM(GREATEST(actual_pv_power_w, 0)::NUMERIC / 4000), 3) AS pv_kwh,
ROUND(SUM(GREATEST(actual_load_power_w, 0)::NUMERIC / 4000), 3) AS load_kwh,
ROUND(SUM(GREATEST(actual_ev_power_w, 0)::NUMERIC / 4000), 3) AS ev_kwh,
ROUND(SUM(GREATEST(actual_heat_pump_power_w, 0)::NUMERIC / 4000), 3) AS hp_kwh,
ROUND(SUM(GREATEST(actual_pv_power_w, 0)::NUMERIC / 4000)
- SUM(export_kwh), 3) AS self_consumption_kwh,
ROUND(SUM(CASE WHEN dynamic_cost_czk > 0
THEN dynamic_cost_czk ELSE 0 END), 2) AS import_cost_czk,
ROUND(SUM(CASE WHEN dynamic_cost_czk < 0
THEN ABS(dynamic_cost_czk) ELSE 0 END), 2) AS export_revenue_czk,
ROUND(SUM(dynamic_cost_czk), 2) AS net_cost_czk,
ROUND(COALESCE(SUM(green_bonus_czk), 0), 2) AS green_bonus_czk,
ROUND(-SUM(dynamic_cost_czk)
+ COALESCE(SUM(green_bonus_czk), 0), 2) AS total_balance_czk,
ROUND(SUM(planned_cost_czk), 2) AS planned_net_cost_czk,
ROUND(-COALESCE(SUM(planned_cost_czk), 0)
+ COALESCE(SUM(green_bonus_czk), 0), 2) AS planned_balance_czk,
ROUND(SUM(dynamic_cost_czk)
- COALESCE(SUM(planned_cost_czk), 0), 2) AS deviation_cost_czk
FROM ems.vw_economics_interval
GROUP BY site_id,
date_trunc('day', interval_start AT TIME ZONE 'Europe/Prague')::date;
COMMENT ON VIEW ems.vw_economics_daily IS
'Denní souhrn ekonomiky (závisí na vw_economics_interval).';

View File

@@ -3,6 +3,7 @@ import { NavLink, Outlet, Route, Routes } from 'react-router-dom'
import { useWsLogErrorCount } from './hooks/useWsLogErrorCount'
import { Dashboard } from './pages/Dashboard'
import Economics from './pages/Economics'
import { Logs } from './pages/Logs'
import Planning from './pages/Planning'
import { Settings } from './pages/Settings'
@@ -25,6 +26,9 @@ function AppLayout() {
<NavLink to="/planning" className={tabClass}>
Plánování
</NavLink>
<NavLink to="/economics" className={tabClass}>
Ekonomika
</NavLink>
<NavLink to="/settings" className={tabClass}>
Nastavení
</NavLink>
@@ -55,6 +59,7 @@ export default function App() {
<Route element={<AppLayout />}>
<Route index element={<Dashboard />} />
<Route path="planning" element={<Planning />} />
<Route path="economics" element={<Economics />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="logs" element={<Logs />} />

View File

@@ -0,0 +1,123 @@
import {
Bar,
CartesianGrid,
Cell,
ComposedChart,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { ChartDayPoint } from '../../types/economics'
type Props = {
points: ChartDayPoint[]
}
const GREEN = '#22c55e'
const RED = '#ef4444'
const BLUE = '#3b82f6'
function formatDay(iso: string): string {
const d = new Date(iso + 'T00:00:00')
return `${d.getDate()}.`
}
type PayloadEntry = {
name?: string
value?: number
color?: string
}
function CustomTooltip({
active,
payload,
label,
}: {
active?: boolean
payload?: PayloadEntry[]
label?: string
}) {
if (!active || !payload?.length || !label) return null
const balance = payload.find((p) => p.name === 'daily_balance_czk')
const cumulative = payload.find((p) => p.name === 'cumulative_balance_czk')
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-xs shadow-lg">
<p className="mb-1 font-medium text-slate-200">{label}</p>
{balance && (
<p style={{ color: (balance.value ?? 0) >= 0 ? GREEN : RED }}>
Den: {(balance.value ?? 0) >= 0 ? '+' : ''}
{(balance.value ?? 0).toFixed(2)}
</p>
)}
{cumulative && (
<p style={{ color: BLUE }}>
Kumulativ: {(cumulative.value ?? 0) >= 0 ? '+' : ''}
{(cumulative.value ?? 0).toFixed(2)}
</p>
)}
</div>
)
}
export function EconomicsChart({ points }: Props) {
if (points.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-sm text-slate-500">
Žádná data pro tento měsíc
</div>
)
}
const data = points.map((p) => ({
...p,
label: formatDay(p.day),
}))
return (
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={data} margin={{ top: 8, right: 16, bottom: 4, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: '#94a3b8' }} />
<YAxis
yAxisId="left"
tick={{ fontSize: 11, fill: '#94a3b8' }}
label={{
value: 'Kč/den',
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: '#94a3b8' },
}}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 11, fill: BLUE }}
label={{
value: 'Kumulativ Kč',
angle: 90,
position: 'insideRight',
style: { fontSize: 11, fill: BLUE },
}}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine yAxisId="left" y={0} stroke="#475569" strokeDasharray="2 2" />
<Bar yAxisId="left" dataKey="daily_balance_czk" radius={[3, 3, 0, 0]} maxBarSize={32}>
{data.map((entry, idx) => (
<Cell key={idx} fill={entry.daily_balance_czk >= 0 ? GREEN : RED} fillOpacity={0.8} />
))}
</Bar>
<Line
yAxisId="right"
type="monotone"
dataKey="cumulative_balance_czk"
stroke={BLUE}
strokeWidth={2}
dot={{ r: 3, fill: BLUE }}
/>
</ComposedChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,112 @@
import { useCallback, useEffect, useState } from 'react'
import { backendClient } from '../api/backend'
import type {
ChartDayPoint,
DailyEconomics,
DailyEconomicsResponse,
IntervalEconomics,
LockResponse,
} from '../types/economics'
export function useEconomicsDaily(siteId: number | null, month: string) {
const [days, setDays] = useState<DailyEconomics[]>([])
const [hasGreenBonus, setHasGreenBonus] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
if (siteId == null || !month) return
setLoading(true)
setError(null)
try {
const { data } = await backendClient.get<DailyEconomicsResponse>(
`/sites/${siteId}/economics/daily`,
{ params: { month }, timeout: 30_000 },
)
setDays(data.days ?? [])
setHasGreenBonus(data.has_green_bonus ?? false)
} catch {
setDays([])
setError('Nepodařilo se načíst ekonomiku')
} finally {
setLoading(false)
}
}, [siteId, month])
useEffect(() => {
void load()
}, [load])
return { days, hasGreenBonus, loading, error, reload: load }
}
export function useEconomicsIntervals(siteId: number | null, day: string | null) {
const [intervals, setIntervals] = useState<IntervalEconomics[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
if (siteId == null || !day) {
setIntervals([])
return
}
setLoading(true)
try {
const { data } = await backendClient.get<IntervalEconomics[]>(
`/sites/${siteId}/economics/daily/${day}/intervals`,
{ timeout: 30_000 },
)
setIntervals(Array.isArray(data) ? data : [])
} catch {
setIntervals([])
} finally {
setLoading(false)
}
}, [siteId, day])
useEffect(() => {
void load()
}, [load])
return { intervals, loading }
}
export function useEconomicsChart(siteId: number | null, month: string) {
const [points, setPoints] = useState<ChartDayPoint[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
if (siteId == null || !month) return
setLoading(true)
try {
const { data } = await backendClient.get<ChartDayPoint[]>(
`/sites/${siteId}/economics/monthly-chart`,
{ params: { month }, timeout: 30_000 },
)
setPoints(Array.isArray(data) ? data : [])
} catch {
setPoints([])
} finally {
setLoading(false)
}
}, [siteId, month])
useEffect(() => {
void load()
}, [load])
return { points, loading }
}
export async function lockDay(siteId: number, day: string): Promise<LockResponse> {
const { data } = await backendClient.post<LockResponse>(
`/sites/${siteId}/economics/daily/${day}/lock`,
)
return data
}
export async function unlockDay(siteId: number, day: string): Promise<LockResponse> {
const { data } = await backendClient.delete<LockResponse>(
`/sites/${siteId}/economics/daily/${day}/lock`,
)
return data
}

View File

@@ -0,0 +1,344 @@
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Lock, Unlock } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { EconomicsChart } from '../components/charts/EconomicsChart'
import {
lockDay,
unlockDay,
useEconomicsChart,
useEconomicsDaily,
useEconomicsIntervals,
} from '../hooks/useEconomics'
import { pragueCalendarDay } from '../lib/pragueDate'
import type { DailyEconomics } from '../types/economics'
const SITE_ID = 1
function currentMonth(): string {
const today = pragueCalendarDay()
return today.slice(0, 7)
}
function monthLabel(ym: string): string {
const [y, m] = ym.split('-').map(Number)
const names = [
'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen',
'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec',
]
return `${names[m - 1]} ${y}`
}
function shiftMonth(ym: string, delta: number): string {
const [y, m] = ym.split('-').map(Number)
const d = new Date(y, m - 1 + delta, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function fmtDay(iso: string): string {
const d = new Date(iso + 'T00:00:00')
return d.toLocaleDateString('cs-CZ', { weekday: 'short', day: 'numeric', month: 'numeric' })
}
function fmtTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Prague' })
}
function czk(v: number | null | undefined, decimals = 2): string {
if (v == null) return ''
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(decimals)}`
}
function kwh(v: number | null | undefined): string {
if (v == null) return ''
return v.toFixed(1)
}
function balanceColor(v: number): string {
if (v > 0) return 'text-green-400'
if (v < 0) return 'text-red-400'
return 'text-slate-400'
}
function IntervalDetail({ siteId, day, hasGreenBonus }: { siteId: number; day: string; hasGreenBonus: boolean }) {
const { intervals, loading } = useEconomicsIntervals(siteId, day)
if (loading) {
return <div className="py-3 text-center text-xs text-slate-500">Načítání intervalů</div>
}
if (intervals.length === 0) {
return <div className="py-3 text-center text-xs text-slate-500">Žádné intervaly</div>
}
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-700 text-slate-400">
<th className="px-2 py-1 text-left">Čas</th>
<th className="px-2 py-1 text-right">Import kWh</th>
<th className="px-2 py-1 text-right">Export kWh</th>
<th className="px-2 py-1 text-right">Náklad </th>
<th className="px-2 py-1 text-right">Cena nákup</th>
<th className="px-2 py-1 text-right">Cena prodej</th>
{hasGreenBonus && <th className="px-2 py-1 text-right">Bonus </th>}
<th className="px-2 py-1 text-right">Plán grid W</th>
<th className="px-2 py-1 text-right">Skuteč. grid W</th>
<th className="px-2 py-1 text-right">Plán náklad</th>
</tr>
</thead>
<tbody>
{intervals.map((iv) => (
<tr key={iv.interval_start} className="border-b border-slate-800 hover:bg-slate-800/40">
<td className="px-2 py-1 text-slate-300">{fmtTime(iv.interval_start)}</td>
<td className="px-2 py-1 text-right">{kwh(iv.import_kwh)}</td>
<td className="px-2 py-1 text-right">{kwh(iv.export_kwh)}</td>
<td className={`px-2 py-1 text-right font-medium ${iv.dynamic_cost_czk != null ? balanceColor(-iv.dynamic_cost_czk) : ''}`}>
{iv.dynamic_cost_czk != null ? iv.dynamic_cost_czk.toFixed(2) : ''}
</td>
<td className="px-2 py-1 text-right text-slate-400">
{iv.effective_buy_price != null ? iv.effective_buy_price.toFixed(2) : ''}
</td>
<td className="px-2 py-1 text-right text-slate-400">
{iv.effective_sell_price != null ? iv.effective_sell_price.toFixed(2) : ''}
</td>
{hasGreenBonus && (
<td className="px-2 py-1 text-right text-amber-400">
{iv.green_bonus_czk != null && iv.green_bonus_czk > 0
? iv.green_bonus_czk.toFixed(2)
: ''}
</td>
)}
<td className="px-2 py-1 text-right text-slate-400">
{iv.planned_grid_w ?? ''}
</td>
<td className="px-2 py-1 text-right">
{iv.actual_grid_power_w ?? ''}
</td>
<td className="px-2 py-1 text-right text-slate-400">
{iv.planned_cost_czk != null ? iv.planned_cost_czk.toFixed(2) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function DailyRow({
row,
hasGreenBonus,
siteId,
expanded,
onToggle,
onLockToggle,
}: {
row: DailyEconomics
hasGreenBonus: boolean
siteId: number
expanded: boolean
onToggle: () => void
onLockToggle: () => void
}) {
return (
<>
<tr
className="cursor-pointer border-b border-slate-800 transition hover:bg-slate-800/50"
onClick={onToggle}
>
<td className="px-3 py-2 text-sm text-slate-200">
<span className="mr-1 inline-block w-4">
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
{fmtDay(row.day)}
</td>
<td className="px-3 py-2 text-right text-sm">{kwh(row.import_kwh)}</td>
<td className="px-3 py-2 text-right text-sm">{kwh(row.export_kwh)}</td>
<td className="px-3 py-2 text-right text-sm text-slate-400">{kwh(row.self_consumption_kwh)}</td>
<td className="px-3 py-2 text-right text-sm text-red-400">{row.import_cost_czk.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-sm text-green-400">{row.export_revenue_czk.toFixed(2)}</td>
{hasGreenBonus && (
<td className="px-3 py-2 text-right text-sm text-amber-400">
{row.green_bonus_czk > 0 ? row.green_bonus_czk.toFixed(2) : ''}
</td>
)}
<td className={`px-3 py-2 text-right text-sm font-semibold ${balanceColor(row.total_balance_czk)}`}>
{czk(row.total_balance_czk)}
</td>
<td className="px-3 py-2 text-right text-sm text-slate-400">
{row.planned_balance_czk != null ? czk(row.planned_balance_czk) : ''}
</td>
<td className={`px-3 py-2 text-right text-sm ${row.deviation_cost_czk != null ? balanceColor(-row.deviation_cost_czk) : ''}`}>
{row.deviation_cost_czk != null ? czk(row.deviation_cost_czk) : ''}
</td>
<td className="px-2 py-2 text-center">
<button
onClick={(e) => {
e.stopPropagation()
onLockToggle()
}}
className="rounded p-1 transition hover:bg-slate-700"
title={row.is_locked ? 'Odemknout den' : 'Zamknout den'}
>
{row.is_locked ? (
<Lock size={14} className="text-amber-400" />
) : (
<Unlock size={14} className="text-slate-500" />
)}
</button>
</td>
</tr>
{expanded && (
<tr>
<td colSpan={hasGreenBonus ? 11 : 10} className="bg-slate-900/50 px-4 py-2">
<IntervalDetail siteId={siteId} day={row.day} hasGreenBonus={hasGreenBonus} />
</td>
</tr>
)}
</>
)
}
export default function Economics() {
const [month, setMonth] = useState(currentMonth)
const [expandedDay, setExpandedDay] = useState<string | null>(null)
const { days, hasGreenBonus, loading, error, reload } = useEconomicsDaily(SITE_ID, month)
const { points } = useEconomicsChart(SITE_ID, month)
const summary = useMemo(() => {
if (days.length === 0) return null
return {
import_cost: days.reduce((s, d) => s + d.import_cost_czk, 0),
export_revenue: days.reduce((s, d) => s + d.export_revenue_czk, 0),
green_bonus: days.reduce((s, d) => s + d.green_bonus_czk, 0),
total_balance: days.reduce((s, d) => s + d.total_balance_czk, 0),
}
}, [days])
const handleLockToggle = useCallback(
async (row: DailyEconomics) => {
try {
if (row.is_locked) {
await unlockDay(SITE_ID, row.day)
toast.success(`Den ${row.day} odemčen`)
} else {
await lockDay(SITE_ID, row.day)
toast.success(`Den ${row.day} zamčen`)
}
reload()
} catch {
toast.error('Operace se nezdařila')
}
},
[reload],
)
return (
<main className="mx-auto max-w-7xl space-y-6 px-4 py-6 md:px-8">
{/* Month selector */}
<div className="flex items-center gap-4">
<button
onClick={() => setMonth((m) => shiftMonth(m, -1))}
className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
>
<ChevronLeft size={20} />
</button>
<h1 className="min-w-[180px] text-center text-lg font-semibold text-white">
{monthLabel(month)}
</h1>
<button
onClick={() => setMonth((m) => shiftMonth(m, 1))}
className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
>
<ChevronRight size={20} />
</button>
</div>
{/* Summary cards */}
{summary && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs text-slate-400">Nákup celkem</p>
<p className="mt-1 text-lg font-semibold text-red-400">{summary.import_cost.toFixed(2)} </p>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs text-slate-400">Prodej celkem</p>
<p className="mt-1 text-lg font-semibold text-green-400">{summary.export_revenue.toFixed(2)} </p>
</div>
{hasGreenBonus && (
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs text-slate-400">Zelený bonus</p>
<p className="mt-1 text-lg font-semibold text-amber-400">{summary.green_bonus.toFixed(2)} </p>
</div>
)}
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs text-slate-400">Bilance měsíce</p>
<p className={`mt-1 text-lg font-semibold ${balanceColor(summary.total_balance)}`}>
{czk(summary.total_balance)}
</p>
</div>
</div>
)}
{/* Chart */}
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<h2 className="mb-3 text-sm font-medium text-slate-300">
Denní bilance + kumulativ od 1. v měsíci
</h2>
<EconomicsChart points={points} />
</div>
{/* Daily table */}
<div className="overflow-hidden rounded-xl border border-slate-800 bg-slate-900">
{error && (
<div className="border-b border-red-900/50 bg-red-900/20 px-4 py-2 text-sm text-red-400">
{error}
</div>
)}
{loading ? (
<div className="py-12 text-center text-sm text-slate-500">Načítání</div>
) : days.length === 0 ? (
<div className="py-12 text-center text-sm text-slate-500">Žádná data pro tento měsíc</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-700 text-xs text-slate-400">
<th className="px-3 py-2 text-left">Den</th>
<th className="px-3 py-2 text-right">Import kWh</th>
<th className="px-3 py-2 text-right">Export kWh</th>
<th className="px-3 py-2 text-right">Vl. spotřeba</th>
<th className="px-3 py-2 text-right">Náklad </th>
<th className="px-3 py-2 text-right">Příjem </th>
{hasGreenBonus && <th className="px-3 py-2 text-right">Bonus </th>}
<th className="px-3 py-2 text-right">Bilance </th>
<th className="px-3 py-2 text-right">Plán </th>
<th className="px-3 py-2 text-right">Odchylka </th>
<th className="w-10 px-2 py-2 text-center" />
</tr>
</thead>
<tbody>
{days.map((row) => (
<DailyRow
key={row.day}
row={row}
hasGreenBonus={hasGreenBonus}
siteId={SITE_ID}
expanded={expandedDay === row.day}
onToggle={() => setExpandedDay((prev) => (prev === row.day ? null : row.day))}
onLockToggle={() => handleLockToggle(row)}
/>
))}
</tbody>
</table>
</div>
)}
</div>
</main>
)
}

View File

@@ -0,0 +1,55 @@
export type DailyEconomics = {
day: string
interval_count: number
import_kwh: number
export_kwh: number
pv_kwh: number
load_kwh: number
self_consumption_kwh: number
ev_kwh: number
hp_kwh: number
import_cost_czk: number
export_revenue_czk: number
net_cost_czk: number
green_bonus_czk: number
total_balance_czk: number
planned_balance_czk: number | null
deviation_cost_czk: number | null
is_locked: boolean
}
export type DailyEconomicsResponse = {
days: DailyEconomics[]
has_green_bonus: boolean
}
export type IntervalEconomics = {
interval_start: string
import_kwh: number
export_kwh: number
dynamic_cost_czk: number | null
stored_cost_czk: number | null
green_bonus_czk: number | null
planned_cost_czk: number | null
planned_grid_w: number | null
actual_grid_power_w: number | null
effective_buy_price: number | null
effective_sell_price: number | null
planned_buy_price: number | null
planned_sell_price: number | null
actual_pv_power_w: number | null
actual_load_power_w: number | null
actual_battery_power_w: number | null
actual_battery_soc_pct: number | null
}
export type ChartDayPoint = {
day: string
daily_balance_czk: number
cumulative_balance_czk: number
}
export type LockResponse = {
locked: boolean
day: string
}