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 (
+
+
+
+
+ | Čas |
+ Import kWh |
+ Export kWh |
+ Náklad Kč |
+ Cena nákup |
+ Cena prodej |
+ {hasGreenBonus && Bonus Kč | }
+ Plán grid W |
+ Skuteč. grid W |
+ Plán náklad |
+
+
+
+ {intervals.map((iv) => (
+
+ | {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) : '–'}
+ |
+ {hasGreenBonus && (
+
+ {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
+ ) : (
+
+
+
+
+ | Den |
+ Import kWh |
+ Export kWh |
+ Vl. spotřeba |
+ Náklad Kč |
+ Příjem Kč |
+ {hasGreenBonus && Bonus Kč | }
+ Bilance Kč |
+ Plán Kč |
+ Odchylka Kč |
+ |
+
+
+
+ {days.map((row) => (
+ setExpandedDay((prev) => (prev === row.day ? null : row.day))}
+ onLockToggle={() => handleLockToggle(row)}
+ />
+ ))}
+
+
+
+ )}
+
+
+ )
+}
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
+}