diff --git a/backend/app/main.py b/backend/app/main.py index 6e3bdd4..e869f0b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/routers/economics.py b/backend/app/routers/economics.py new file mode 100644 index 0000000..a8f401f --- /dev/null +++ b/backend/app/routers/economics.py @@ -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 diff --git a/backend/services/notification_service.py b/backend/services/notification_service.py index d44bcb1..9d3b59e 100644 --- a/backend/services/notification_service.py +++ b/backend/services/notification_service.py @@ -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} Kč", + f" Export: {export_kwh:.1f} kWh = {export_revenue:.2f} Kč", + ] + if green_bonus > 0: + lines.append(f" Zelený bonus: {green_bonus:.2f} Kč") + 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} Kč " + f"(odchylka {dev_sign}{dev:.2f} Kč)" + ) + await send_discord("\n".join(lines), level="info") diff --git a/db/migration/V032__market_interval_price_primary_key.sql b/db/migration/V032__market_interval_price_primary_key.sql index 192a26b..bd40571 100644 --- a/db/migration/V032__market_interval_price_primary_key.sql +++ b/db/migration/V032__market_interval_price_primary_key.sql @@ -16,9 +16,11 @@ SECURITY DEFINER SET search_path = pg_catalog, public AS $$ DECLARE - hid integer; - chunk_ids integer[]; - n integer; + hid integer; + chunk_ids integer[]; + n integer; + has_dropped boolean; + q text; BEGIN SELECT h.id INTO hid @@ -40,18 +42,39 @@ BEGIN PERFORM _timescaledb_functions.remove_dropped_chunk_metadata(hid); END IF; - 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 - 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 - ); + -- 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[]) + FROM _timescaledb_catalog.chunk c + 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 diff --git a/db/migration/V036__pv_array_telemetry_source.sql b/db/migration/V036__pv_array_telemetry_source.sql new file mode 100644 index 0000000..35523a9 --- /dev/null +++ b/db/migration/V036__pv_array_telemetry_source.sql @@ -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'; diff --git a/db/migration/V037__audit_day_lock.sql b/db/migration/V037__audit_day_lock.sql new file mode 100644 index 0000000..cb33bcb --- /dev/null +++ b/db/migration/V037__audit_day_lock.sql @@ -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.'; diff --git a/db/routines/R__fn_fill_audit_interval.sql b/db/routines/R__fn_fill_audit_interval.sql index c9ac76f..6f1f52c 100644 --- a/db/routines/R__fn_fill_audit_interval.sql +++ b/db/routines/R__fn_fill_audit_interval.sql @@ -92,24 +92,49 @@ 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 - SELECT fpi.power_w * 0.25 - INTO v_array_prod_wh - 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 fpr.pv_array_id = r_bonus.id - AND fpi.interval_start = p_interval_start - AND fpr.status = 'ok' - ORDER BY fpr.created_at DESC - LIMIT 1; + 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 + JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id + WHERE fpr.site_id = p_site_id + AND fpr.pv_array_id = r_bonus.id + AND fpi.interval_start = p_interval_start + 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.'; -- ============================================================ diff --git a/db/views/R__vw_site_effective_price_economics.sql b/db/views/R__vw_site_effective_price_economics.sql new file mode 100644 index 0000000..efd0a04 --- /dev/null +++ b/db/views/R__vw_site_effective_price_economics.sql @@ -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).'; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 69d5062..29b39aa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { Plánování + + Ekonomika + Nastavení @@ -55,6 +59,7 @@ export default function App() { }> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/charts/EconomicsChart.tsx b/frontend/src/components/charts/EconomicsChart.tsx new file mode 100644 index 0000000..e74c357 --- /dev/null +++ b/frontend/src/components/charts/EconomicsChart.tsx @@ -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 ( +
+

{label}

+ {balance && ( +

= 0 ? GREEN : RED }}> + Den: {(balance.value ?? 0) >= 0 ? '+' : ''} + {(balance.value ?? 0).toFixed(2)} Kč +

+ )} + {cumulative && ( +

+ Kumulativ: {(cumulative.value ?? 0) >= 0 ? '+' : ''} + {(cumulative.value ?? 0).toFixed(2)} Kč +

+ )} +
+ ) +} + +export function EconomicsChart({ points }: Props) { + if (points.length === 0) { + return ( +
+ Žádná data pro tento měsíc +
+ ) + } + + const data = points.map((p) => ({ + ...p, + label: formatDay(p.day), + })) + + return ( + + + + + + + } /> + + + {data.map((entry, idx) => ( + = 0 ? GREEN : RED} fillOpacity={0.8} /> + ))} + + + + + ) +} diff --git a/frontend/src/hooks/useEconomics.ts b/frontend/src/hooks/useEconomics.ts new file mode 100644 index 0000000..9367700 --- /dev/null +++ b/frontend/src/hooks/useEconomics.ts @@ -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([]) + const [hasGreenBonus, setHasGreenBonus] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + if (siteId == null || !month) return + setLoading(true) + setError(null) + try { + const { data } = await backendClient.get( + `/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([]) + const [loading, setLoading] = useState(false) + + const load = useCallback(async () => { + if (siteId == null || !day) { + setIntervals([]) + return + } + setLoading(true) + try { + const { data } = await backendClient.get( + `/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([]) + const [loading, setLoading] = useState(false) + + const load = useCallback(async () => { + if (siteId == null || !month) return + setLoading(true) + try { + const { data } = await backendClient.get( + `/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 { + const { data } = await backendClient.post( + `/sites/${siteId}/economics/daily/${day}/lock`, + ) + return data +} + +export async function unlockDay(siteId: number, day: string): Promise { + const { data } = await backendClient.delete( + `/sites/${siteId}/economics/daily/${day}/lock`, + ) + return data +} diff --git a/frontend/src/pages/Economics.tsx b/frontend/src/pages/Economics.tsx new file mode 100644 index 0000000..60d228f --- /dev/null +++ b/frontend/src/pages/Economics.tsx @@ -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
Načítání intervalů…
+ } + + if (intervals.length === 0) { + return
Žádné intervaly
+ } + + return ( +
+ + + + + + + + + + {hasGreenBonus && } + + + + + + + {intervals.map((iv) => ( + + + + + + + + {hasGreenBonus && ( + + )} + + + + + ))} + +
ČasImport kWhExport kWhNáklad KčCena nákupCena prodejBonus KčPlán grid WSkuteč. grid WPlán náklad
{fmtTime(iv.interval_start)}{kwh(iv.import_kwh)}{kwh(iv.export_kwh)} + {iv.dynamic_cost_czk != null ? iv.dynamic_cost_czk.toFixed(2) : '–'} + + {iv.effective_buy_price != null ? iv.effective_buy_price.toFixed(2) : '–'} + + {iv.effective_sell_price != null ? iv.effective_sell_price.toFixed(2) : '–'} + + {iv.green_bonus_czk != null && iv.green_bonus_czk > 0 + ? iv.green_bonus_czk.toFixed(2) + : '–'} + + {iv.planned_grid_w ?? '–'} + + {iv.actual_grid_power_w ?? '–'} + + {iv.planned_cost_czk != null ? iv.planned_cost_czk.toFixed(2) : '–'} +
+
+ ) +} + +function DailyRow({ + row, + hasGreenBonus, + siteId, + expanded, + onToggle, + onLockToggle, +}: { + row: DailyEconomics + hasGreenBonus: boolean + siteId: number + expanded: boolean + onToggle: () => void + onLockToggle: () => void +}) { + return ( + <> + + + + {expanded ? : } + + {fmtDay(row.day)} + + {kwh(row.import_kwh)} + {kwh(row.export_kwh)} + {kwh(row.self_consumption_kwh)} + {row.import_cost_czk.toFixed(2)} + {row.export_revenue_czk.toFixed(2)} + {hasGreenBonus && ( + + {row.green_bonus_czk > 0 ? row.green_bonus_czk.toFixed(2) : '–'} + + )} + + {czk(row.total_balance_czk)} + + + {row.planned_balance_czk != null ? czk(row.planned_balance_czk) : '–'} + + + {row.deviation_cost_czk != null ? czk(row.deviation_cost_czk) : '–'} + + + + + + {expanded && ( + + + + + + )} + + ) +} + +export default function Economics() { + const [month, setMonth] = useState(currentMonth) + const [expandedDay, setExpandedDay] = useState(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 ( +
+ {/* Month selector */} +
+ +

+ {monthLabel(month)} +

+ +
+ + {/* Summary cards */} + {summary && ( +
+
+

Nákup celkem

+

{summary.import_cost.toFixed(2)} Kč

+
+
+

Prodej celkem

+

{summary.export_revenue.toFixed(2)} Kč

+
+ {hasGreenBonus && ( +
+

Zelený bonus

+

{summary.green_bonus.toFixed(2)} Kč

+
+ )} +
+

Bilance měsíce

+

+ {czk(summary.total_balance)} Kč +

+
+
+ )} + + {/* Chart */} +
+

+ Denní bilance + kumulativ od 1. v měsíci +

+ +
+ + {/* Daily table */} +
+ {error && ( +
+ {error} +
+ )} + {loading ? ( +
Načítání…
+ ) : days.length === 0 ? ( +
Žádná data pro tento měsíc
+ ) : ( +
+ + + + + + + + + + {hasGreenBonus && } + + + + + + + {days.map((row) => ( + setExpandedDay((prev) => (prev === row.day ? null : row.day))} + onLockToggle={() => handleLockToggle(row)} + /> + ))} + +
DenImport kWhExport kWhVl. spotřebaNáklad KčPříjem KčBonus KčBilance KčPlán KčOdchylka Kč +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/types/economics.ts b/frontend/src/types/economics.ts new file mode 100644 index 0000000..7783dc6 --- /dev/null +++ b/frontend/src/types/economics.ts @@ -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 +}