diff --git a/backend/app/routers/economics.py b/backend/app/routers/economics.py index a8f401f..5e12f8a 100644 --- a/backend/app/routers/economics.py +++ b/backend/app/routers/economics.py @@ -27,11 +27,13 @@ class DailyEconomics(BaseModel): export_kwh: float pv_kwh: float load_kwh: float - self_consumption_kwh: float + pv_self_consumption_kwh: float ev_kwh: float hp_kwh: float import_cost_czk: float export_revenue_czk: float + grid_import_cashflow_czk: float + grid_export_revenue_czk: float net_cost_czk: float green_bonus_czk: float total_balance_czk: float @@ -50,6 +52,8 @@ class IntervalEconomics(BaseModel): import_kwh: float export_kwh: float dynamic_cost_czk: float | None + grid_import_cashflow_czk: float | None + grid_export_revenue_czk: float | None stored_cost_czk: float | None green_bonus_czk: float | None planned_cost_czk: float | None @@ -68,7 +72,12 @@ class IntervalEconomics(BaseModel): class ChartDayPoint(BaseModel): day: date daily_balance_czk: float + daily_grid_balance_czk: float + daily_green_bonus_czk: float + daily_import_cost_czk: float + daily_export_revenue_czk: float cumulative_balance_czk: float + cumulative_grid_balance_czk: float class LockResponse(BaseModel): @@ -82,6 +91,12 @@ def _num(val: Any) -> float: return float(val) +def _opt(val: Any) -> float | None: + if val is None: + return None + 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 @@ -105,6 +120,43 @@ async def _has_green_bonus(conn: asyncpg.Connection, site_id: int) -> bool: ) +def _safe_get(record: Any, key: str, fallback: Any = None) -> Any: + """Safely get a key from asyncpg Record (which supports [] but not .get()).""" + try: + return record[key] + except (KeyError, TypeError): + return fallback + + +def _daily_from_row(r: Any, lock: Any | None, is_locked: bool) -> DailyEconomics: + src = lock if (lock and is_locked) else r + return DailyEconomics( + day=r["day_local"], + interval_count=r["interval_count"], + import_kwh=_num(r["import_kwh"]), + export_kwh=_num(r["export_kwh"]), + pv_kwh=_num(r["pv_kwh"]), + load_kwh=_num(r["load_kwh"]), + pv_self_consumption_kwh=_num(r["pv_self_consumption_kwh"]), + ev_kwh=_num(r["ev_kwh"]), + hp_kwh=_num(r["hp_kwh"]), + import_cost_czk=_num(src["import_cost_czk"]), + export_revenue_czk=_num(src["export_revenue_czk"]), + grid_import_cashflow_czk=_num( + _safe_get(src, "grid_import_cashflow_czk", r["grid_import_cashflow_czk"]) + ), + grid_export_revenue_czk=_num( + _safe_get(src, "grid_export_revenue_czk", r["grid_export_revenue_czk"]) + ), + net_cost_czk=_num(src["net_cost_czk"]), + green_bonus_czk=_num(src["green_bonus_czk"]), + total_balance_czk=_num(src["total_balance_czk"]), + planned_balance_czk=_opt(r["planned_balance_czk"]), + deviation_cost_czk=_opt(r["deviation_cost_czk"]), + is_locked=is_locked, + ) + + @router.get("/daily", response_model=DailyEconomicsResponse) async def get_economics_daily( site_id: int, @@ -159,50 +211,7 @@ async def get_economics_daily( 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, - ) - ) + days.append(_daily_from_row(r, lock, is_locked=lock is not None)) return DailyEconomicsResponse(days=days, has_green_bonus=has_bonus) @@ -232,20 +241,22 @@ async def get_economics_intervals( 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, + dynamic_cost_czk=_opt(r["dynamic_cost_czk"]), + grid_import_cashflow_czk=_opt(r["grid_import_cashflow_czk"]), + grid_export_revenue_czk=_opt(r["grid_export_revenue_czk"]), + stored_cost_czk=_opt(r["stored_cost_czk"]), + green_bonus_czk=_opt(r["green_bonus_czk"]), + planned_cost_czk=_opt(r["planned_cost_czk"]), 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, + effective_buy_price=_opt(r["effective_buy_price_czk_kwh"]), + effective_sell_price=_opt(r["effective_sell_price_czk_kwh"]), + planned_buy_price=_opt(r["planned_buy_price"]), + planned_sell_price=_opt(r["planned_sell_price"]), 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, + actual_battery_soc_pct=_opt(r["actual_battery_soc_pct"]), ) for r in rows ] @@ -263,7 +274,8 @@ async def lock_day( row = await conn.fetchrow( """ SELECT import_cost_czk, export_revenue_czk, net_cost_czk, - green_bonus_czk, total_balance_czk + green_bonus_czk, total_balance_czk, + grid_import_cashflow_czk, grid_export_revenue_czk FROM ems.vw_economics_daily WHERE site_id = $1 AND day_local = $2 """, @@ -280,14 +292,17 @@ async def lock_day( """ INSERT INTO ems.audit_day_lock (site_id, day_local, import_cost_czk, export_revenue_czk, - net_cost_czk, green_bonus_czk, total_balance_czk) - VALUES ($1, $2, $3, $4, $5, $6, $7) + net_cost_czk, green_bonus_czk, total_balance_czk, + grid_import_cashflow_czk, grid_export_revenue_czk) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (site_id, day_local) DO UPDATE SET import_cost_czk = EXCLUDED.import_cost_czk, export_revenue_czk = EXCLUDED.export_revenue_czk, net_cost_czk = EXCLUDED.net_cost_czk, green_bonus_czk = EXCLUDED.green_bonus_czk, total_balance_czk = EXCLUDED.total_balance_czk, + grid_import_cashflow_czk = EXCLUDED.grid_import_cashflow_czk, + grid_export_revenue_czk = EXCLUDED.grid_export_revenue_czk, locked_at = now() """, site_id, @@ -297,6 +312,8 @@ async def lock_day( row["net_cost_czk"], row["green_bonus_czk"], row["total_balance_czk"], + row["grid_import_cashflow_czk"], + row["grid_export_revenue_czk"], ) return LockResponse(locked=True, day=day) @@ -343,7 +360,8 @@ async def get_monthly_chart( rows = await conn.fetch( """ - SELECT day_local, total_balance_czk + SELECT day_local, total_balance_czk, net_cost_czk, + green_bonus_czk, grid_import_cashflow_czk, grid_export_revenue_czk FROM ems.vw_economics_daily WHERE site_id = $1 AND day_local >= $2 @@ -357,7 +375,8 @@ async def get_monthly_chart( lock_rows = await conn.fetch( """ - SELECT day_local, total_balance_czk + SELECT day_local, total_balance_czk, net_cost_czk, + green_bonus_czk, grid_import_cashflow_czk, grid_export_revenue_czk FROM ems.audit_day_lock WHERE site_id = $1 AND day_local >= $2 @@ -367,19 +386,31 @@ async def get_monthly_chart( month_start, month_end, ) - locks = {r["day_local"]: _num(r["total_balance_czk"]) for r in lock_rows} + locks = {r["day_local"]: r for r in lock_rows} points: list[ChartDayPoint] = [] - cumulative = 0.0 + cum_balance = 0.0 + cum_grid = 0.0 for r in rows: d = r["day_local"] - balance = locks.get(d, _num(r["total_balance_czk"])) - cumulative += balance + src = locks.get(d, r) + balance = _num(src["total_balance_czk"]) + grid_balance = -_num(src["net_cost_czk"]) + green_bonus = _num(src["green_bonus_czk"]) + import_cost = _num(_safe_get(src, "grid_import_cashflow_czk", r["grid_import_cashflow_czk"])) + export_revenue = _num(_safe_get(src, "grid_export_revenue_czk", r["grid_export_revenue_czk"])) + cum_balance += balance + cum_grid += grid_balance points.append( ChartDayPoint( day=d, daily_balance_czk=round(balance, 2), - cumulative_balance_czk=round(cumulative, 2), + daily_grid_balance_czk=round(grid_balance, 2), + daily_green_bonus_czk=round(green_bonus, 2), + daily_import_cost_czk=round(import_cost, 2), + daily_export_revenue_czk=round(export_revenue, 2), + cumulative_balance_czk=round(cum_balance, 2), + cumulative_grid_balance_czk=round(cum_grid, 2), ) ) diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index d748d36..c6bc9fe 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -22,6 +22,10 @@ DEYE_REG_BATTERY_POWER_FLOW = 590 DEYE_REG_GRID_TOTAL_POWER = 625 DEYE_REG_GEN_PORT_POWER = 667 DEYE_REG_LOAD_TOTAL_POWER = 653 +DEYE_REG_GRID_IMPORT_TOTAL_LO = 522 +DEYE_REG_GRID_IMPORT_TOTAL_HI = 523 +DEYE_REG_GRID_EXPORT_TOTAL_LO = 524 +DEYE_REG_GRID_EXPORT_TOTAL_HI = 525 DEYE_REG_PV1_POWER = 672 DEYE_REG_PV2_POWER = 673 @@ -67,7 +71,12 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: pv1_power = await mb.read_register_signed(DEYE_REG_PV1_POWER) pv2_power = await mb.read_register_signed(DEYE_REG_PV2_POWER) gen_port_power = await mb.read_register_signed(DEYE_REG_GEN_PORT_POWER) + grid_energy_regs = await mb.read_holding_registers( + DEYE_REG_GRID_IMPORT_TOTAL_LO, 4 + ) pv_power_w = aggregate_pv_production_w(pv1_power, pv2_power, gen_port_power) + grid_import_total_wh = (grid_energy_regs[1] << 16 | grid_energy_regs[0]) * 100 + grid_export_total_wh = (grid_energy_regs[3] << 16 | grid_energy_regs[2]) * 100 logger.debug("inverter:%s Deye run_state raw=%s", code, run_state) @@ -79,6 +88,7 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: battery_soc_percent, battery_power_w, batt_charge_today_wh, batt_discharge_today_wh, grid_power_w, load_power_w, + grid_import_total_wh, grid_export_total_wh, run_state ) VALUES ( @@ -87,7 +97,8 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: $8, $9, $10, $11, $12, $13, - $14 + $14, $15, + $16 ) ON CONFLICT (inverter_id, measured_at) DO NOTHING """, @@ -104,6 +115,8 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None: batt_discharge_today, grid_power, load_power, + grid_import_total_wh, + grid_export_total_wh, run_state, ) inv_temp: float | None = None diff --git a/db/migration/V040__energy_wh_columns.sql b/db/migration/V040__energy_wh_columns.sql new file mode 100644 index 0000000..b92c40c --- /dev/null +++ b/db/migration/V040__energy_wh_columns.sql @@ -0,0 +1,38 @@ +-- ============================================================= +-- V040 – Energy Wh columns +-- Přidává kumulativní čítače grid energie do telemetrie +-- a per-slot Wh sloupce do audit_interval pro přesné +-- import/export měření (Deye reg 522-525 + per-minute fallback). +-- ============================================================= + +-- 1. telemetry_inverter: kumulativní Deye lifetime čítače +ALTER TABLE ems.telemetry_inverter + ADD COLUMN IF NOT EXISTS grid_import_total_wh BIGINT, + ADD COLUMN IF NOT EXISTS grid_export_total_wh BIGINT; + +COMMENT ON COLUMN ems.telemetry_inverter.grid_import_total_wh IS +'Kumulativní import ze sítě (Wh) z Deye reg 522+523 (32-bit × 0.1 kWh). Lifetime čítač, monotónně rostoucí.'; +COMMENT ON COLUMN ems.telemetry_inverter.grid_export_total_wh IS +'Kumulativní export do sítě (Wh) z Deye reg 524+525 (32-bit × 0.1 kWh). Lifetime čítač, monotónně rostoucí.'; + +-- 2. audit_interval: 6 základních energetických veličin (Wh za 15min slot) +ALTER TABLE ems.audit_interval + ADD COLUMN IF NOT EXISTS actual_grid_import_wh NUMERIC(10,1), + ADD COLUMN IF NOT EXISTS actual_grid_export_wh NUMERIC(10,1), + ADD COLUMN IF NOT EXISTS actual_batt_charge_wh NUMERIC(10,1), + ADD COLUMN IF NOT EXISTS actual_batt_discharge_wh NUMERIC(10,1), + ADD COLUMN IF NOT EXISTS actual_pv_production_wh NUMERIC(10,1), + ADD COLUMN IF NOT EXISTS actual_load_consumption_wh NUMERIC(10,1); + +COMMENT ON COLUMN ems.audit_interval.actual_grid_import_wh IS +'Import ze sítě za 15min slot (Wh). Primárně z delta Deye total counterů (reg 522+523), fallback per-minutový split z grid_power_w.'; +COMMENT ON COLUMN ems.audit_interval.actual_grid_export_wh IS +'Export do sítě za 15min slot (Wh). Primárně z delta Deye total counterů (reg 524+525), fallback per-minutový split z grid_power_w.'; +COMMENT ON COLUMN ems.audit_interval.actual_batt_charge_wh IS +'Nabití baterie za 15min slot (Wh). Per-minutový split z battery_power_w (záporné = nabíjení).'; +COMMENT ON COLUMN ems.audit_interval.actual_batt_discharge_wh IS +'Vybití baterie za 15min slot (Wh). Per-minutový split z battery_power_w (kladné = vybíjení).'; +COMMENT ON COLUMN ems.audit_interval.actual_pv_production_wh IS +'FVE výroba za 15min slot (Wh). SUM(pv_power_w) / 60 z minutových vzorků.'; +COMMENT ON COLUMN ems.audit_interval.actual_load_consumption_wh IS +'Celková spotřeba za 15min slot (Wh). SUM(load_power_w) / 60 z minutových vzorků.'; diff --git a/db/migration/V041__audit_day_lock_grid_direction.sql b/db/migration/V041__audit_day_lock_grid_direction.sql new file mode 100644 index 0000000..8a8faa3 --- /dev/null +++ b/db/migration/V041__audit_day_lock_grid_direction.sql @@ -0,0 +1,13 @@ +-- ============================================================= +-- V041 – audit_day_lock: směrové cashflow sloupce +-- Snapshot pro zamknuté dny rozšířen o cashflow podle směru energie. +-- ============================================================= + +ALTER TABLE ems.audit_day_lock + ADD COLUMN IF NOT EXISTS grid_import_cashflow_czk NUMERIC(12,2), + ADD COLUMN IF NOT EXISTS grid_export_revenue_czk NUMERIC(12,2); + +COMMENT ON COLUMN ems.audit_day_lock.grid_import_cashflow_czk IS +'Snapshot: celková cena za import ze sítě v Kč (může být záporná při záporné spotové ceně).'; +COMMENT ON COLUMN ems.audit_day_lock.grid_export_revenue_czk IS +'Snapshot: celkový příjem z exportu do sítě v Kč.'; diff --git a/db/routines/R__fn_fill_audit_interval.sql b/db/routines/R__fn_fill_audit_interval.sql index 6f1f52c..9dcad8b 100644 --- a/db/routines/R__fn_fill_audit_interval.sql +++ b/db/routines/R__fn_fill_audit_interval.sql @@ -29,6 +29,22 @@ DECLARE v_pv_b_production_wh NUMERIC; v_array_prod_wh NUMERIC; r_bonus RECORD; + + -- per-minute Wh veličiny + v_grid_import_wh NUMERIC; + v_grid_export_wh NUMERIC; + v_batt_charge_wh NUMERIC; + v_batt_discharge_wh NUMERIC; + v_pv_production_wh NUMERIC; + v_load_consumption_wh NUMERIC; + + -- Deye counter delta + v_counter_import_first BIGINT; + v_counter_import_last BIGINT; + v_counter_export_first BIGINT; + v_counter_export_last BIGINT; + v_delta_import NUMERIC; + v_delta_export NUMERIC; BEGIN -- Najít aktivní plán pro tento interval SELECT pi.* INTO v_plan @@ -42,24 +58,58 @@ BEGIN v_run_id := v_plan.run_id; - -- Agregovat telemetrii střídače (průměr za 15min; agregace bez GROUP BY vrací vždy 1 řádek) + -- Agregovat telemetrii střídače: průměry (pro zpětnou kompatibilitu) + per-minute split pro Wh SELECT AVG(pv_power_w)::INT, AVG(battery_power_w)::INT, AVG(grid_power_w)::INT, AVG(load_power_w)::INT, - LAST(battery_soc_percent, measured_at) + LAST(battery_soc_percent, measured_at), + -- Per-minute split: každý vzorek × 1/60 h = Wh + ROUND(SUM(GREATEST(grid_power_w, 0))::NUMERIC / 60, 1), + ROUND(SUM(ABS(LEAST(grid_power_w, 0)))::NUMERIC / 60, 1), + ROUND(SUM(ABS(LEAST(battery_power_w, 0)))::NUMERIC / 60, 1), + ROUND(SUM(GREATEST(battery_power_w, 0))::NUMERIC / 60, 1), + ROUND(SUM(GREATEST(pv_power_w, 0))::NUMERIC / 60, 1), + ROUND(SUM(GREATEST(load_power_w, 0))::NUMERIC / 60, 1), + -- Deye total energy counter delta + FIRST(grid_import_total_wh, measured_at), + LAST(grid_import_total_wh, measured_at), + FIRST(grid_export_total_wh, measured_at), + LAST(grid_export_total_wh, measured_at) INTO v_avg_pv_power_w, v_avg_battery_power_w, v_avg_grid_power_w, v_avg_load_power_w, - v_last_soc + v_last_soc, + v_grid_import_wh, + v_grid_export_wh, + v_batt_charge_wh, + v_batt_discharge_wh, + v_pv_production_wh, + v_load_consumption_wh, + v_counter_import_first, + v_counter_import_last, + v_counter_export_first, + v_counter_export_last FROM ems.telemetry_inverter WHERE site_id = p_site_id AND measured_at >= p_interval_start AND measured_at < v_interval_end; + -- Deye counter delta (primární zdroj pro grid import/export, pokud jsou čítače dostupné) + IF v_counter_import_first IS NOT NULL AND v_counter_import_last IS NOT NULL + AND v_counter_import_last >= v_counter_import_first THEN + v_delta_import := v_counter_import_last - v_counter_import_first; + v_grid_import_wh := v_delta_import; + END IF; + IF v_counter_export_first IS NOT NULL AND v_counter_export_last IS NOT NULL + AND v_counter_export_last >= v_counter_export_first THEN + v_delta_export := v_counter_export_last - v_counter_export_first; + v_grid_export_wh := v_delta_export; + END IF; + -- Agregovat EV nabíječky (součet průměrů po charger_id) SELECT COALESCE(SUM(avg_power), 0)::INT INTO v_sum_ev_power_w @@ -84,12 +134,10 @@ BEGIN v_buy_price := ems.fn_effective_buy_price(p_site_id, p_interval_start); v_sell_price := ems.fn_effective_sell_price(p_site_id, p_interval_start); - -- Skutečné náklady (kladný grid = nákup, záporný = prodej) - IF v_avg_grid_power_w IS NOT NULL THEN - v_actual_cost := (v_avg_grid_power_w::NUMERIC / 1000.0 / 4.0) - * CASE WHEN v_avg_grid_power_w >= 0 - THEN COALESCE(v_buy_price, 0) - ELSE COALESCE(v_sell_price, 0) END; + -- Skutečné náklady per-direction (import × buy - export × sell) + IF v_grid_import_wh IS NOT NULL OR v_grid_export_wh IS NOT NULL THEN + v_actual_cost := COALESCE(v_grid_import_wh, 0) / 1000.0 * COALESCE(v_buy_price, 0) + - COALESCE(v_grid_export_wh, 0) / 1000.0 * COALESCE(v_sell_price, 0); END IF; -- Zelený bonus: výroba bonusových polí z reálné telemetrie (Wh = průměr W × 0,25 h) @@ -122,7 +170,6 @@ BEGIN 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 @@ -160,7 +207,13 @@ BEGIN pv_b_production_wh, green_bonus_czk, deviation_grid_w, - deviation_cost_czk + deviation_cost_czk, + actual_grid_import_wh, + actual_grid_export_wh, + actual_batt_charge_wh, + actual_batt_discharge_wh, + actual_pv_production_wh, + actual_load_consumption_wh ) VALUES ( p_site_id, p_interval_start, v_run_id, v_avg_pv_power_w, @@ -178,7 +231,13 @@ BEGIN ELSE NULL END, CASE WHEN v_plan.run_id IS NOT NULL THEN ROUND(v_actual_cost - COALESCE(v_plan.expected_cost_czk, 0), 4) - ELSE NULL END + ELSE NULL END, + v_grid_import_wh, + v_grid_export_wh, + v_batt_charge_wh, + v_batt_discharge_wh, + v_pv_production_wh, + v_load_consumption_wh ) ON CONFLICT (site_id, interval_start) DO UPDATE SET planning_run_id = EXCLUDED.planning_run_id, @@ -193,15 +252,23 @@ BEGIN pv_b_production_wh = EXCLUDED.pv_b_production_wh, green_bonus_czk = EXCLUDED.green_bonus_czk, deviation_grid_w = EXCLUDED.deviation_grid_w, - deviation_cost_czk = EXCLUDED.deviation_cost_czk; + deviation_cost_czk = EXCLUDED.deviation_cost_czk, + actual_grid_import_wh = EXCLUDED.actual_grid_import_wh, + actual_grid_export_wh = EXCLUDED.actual_grid_export_wh, + actual_batt_charge_wh = EXCLUDED.actual_batt_charge_wh, + actual_batt_discharge_wh = EXCLUDED.actual_batt_discharge_wh, + actual_pv_production_wh = EXCLUDED.actual_pv_production_wh, + actual_load_consumption_wh = EXCLUDED.actual_load_consumption_wh; END; $$; 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 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í. +Nově: per-minutový split pro 6 energetických veličin (import/export/batt/PV/load Wh); +grid import/export primárně z delta Deye total counterů (reg 522-525), fallback per-minute. +actual_cost_czk = per-direction (import_wh × buy - export_wh × sell). +Zelený bonus: součet přes pole s green_bonus_czk_kwh. 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 index efd0a04..f1db766 100644 --- a/db/views/R__vw_site_effective_price_economics.sql +++ b/db/views/R__vw_site_effective_price_economics.sql @@ -9,14 +9,27 @@ 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, + -- Wh-based kWh (per-direction, zachytí bidirectional flow) + ROUND(COALESCE(ai.actual_grid_import_wh, GREATEST(ai.actual_grid_power_w, 0)::NUMERIC / 4) / 1000, 4) + AS import_kwh, + ROUND(COALESCE(ai.actual_grid_export_wh, ABS(LEAST(ai.actual_grid_power_w, 0))::NUMERIC / 4) / 1000, 4) + AS export_kwh, + -- Směrové cashflow: kolik Kč za import ze sítě / kolik Kč za export do sítě + ROUND( + COALESCE(ai.actual_grid_import_wh, GREATEST(ai.actual_grid_power_w, 0)::NUMERIC / 4) + / 1000.0 * COALESCE(ep.effective_buy_price_czk_kwh, 0), 4 + ) AS grid_import_cashflow_czk, + ROUND( + COALESCE(ai.actual_grid_export_wh, ABS(LEAST(ai.actual_grid_power_w, 0))::NUMERIC / 4) + / 1000.0 * COALESCE(ep.effective_sell_price_czk_kwh, 0), 4 + ) AS grid_export_revenue_czk, + -- Net cost (zpětná kompatibilita): import_cashflow - export_revenue + ROUND( + COALESCE(ai.actual_grid_import_wh, GREATEST(ai.actual_grid_power_w, 0)::NUMERIC / 4) + / 1000.0 * COALESCE(ep.effective_buy_price_czk_kwh, 0) + - COALESCE(ai.actual_grid_export_wh, ABS(LEAST(ai.actual_grid_power_w, 0))::NUMERIC / 4) + / 1000.0 * COALESCE(ep.effective_sell_price_czk_kwh, 0), 4 + ) AS dynamic_cost_czk, ai.actual_cost_czk AS stored_cost_czk, ai.green_bonus_czk, pi.expected_cost_czk AS planned_cost_czk, @@ -31,7 +44,13 @@ SELECT ai.actual_ev_power_w, ai.actual_heat_pump_power_w, ai.actual_battery_power_w, - ai.actual_battery_soc_pct + ai.actual_battery_soc_pct, + ai.actual_grid_import_wh, + ai.actual_grid_export_wh, + ai.actual_batt_charge_wh, + ai.actual_batt_discharge_wh, + ai.actual_pv_production_wh, + ai.actual_load_consumption_wh 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 @@ -39,7 +58,10 @@ 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).'; +'Dynamické ekonomické vyhodnocení per 15min slot. +import/export kWh primárně z per-direction Wh sloupců audit_interval (Deye counter / per-minute split), +fallback na průměrný výkon pro zpětnou kompatibilitu se starými daty. +grid_import_cashflow_czk / grid_export_revenue_czk = směrové cashflow podle skutečného toku energie.'; CREATE OR REPLACE VIEW ems.vw_economics_daily AS SELECT @@ -53,7 +75,11 @@ SELECT 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, + - SUM(export_kwh), 3) AS pv_self_consumption_kwh, + -- Směrové cashflow (podle směru energie, ne znaménka peněz) + ROUND(SUM(grid_import_cashflow_czk), 2) AS grid_import_cashflow_czk, + ROUND(SUM(grid_export_revenue_czk), 2) AS grid_export_revenue_czk, + -- Staré sloupce (podle znaménka peněz – zpětná kompatibilita) 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 @@ -63,8 +89,7 @@ SELECT 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(-COALESCE(SUM(planned_cost_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 @@ -72,4 +97,5 @@ 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).'; +'Denní souhrn ekonomiky. planned_balance_czk = jen síťové náklady (bez zeleného bonusu). +grid_import_cashflow_czk / grid_export_revenue_czk = směrové cashflow podle skutečného toku energie.'; diff --git a/frontend/src/components/charts/EconomicsChart.tsx b/frontend/src/components/charts/EconomicsChart.tsx index e74c357..1069876 100644 --- a/frontend/src/components/charts/EconomicsChart.tsx +++ b/frontend/src/components/charts/EconomicsChart.tsx @@ -3,6 +3,7 @@ import { CartesianGrid, Cell, ComposedChart, + Legend, Line, ReferenceLine, ResponsiveContainer, @@ -14,11 +15,14 @@ import type { ChartDayPoint } from '../../types/economics' type Props = { points: ChartDayPoint[] + hasGreenBonus: boolean } const GREEN = '#22c55e' const RED = '#ef4444' const BLUE = '#3b82f6' +const AMBER = '#f59e0b' +const SLATE = '#64748b' function formatDay(iso: string): string { const d = new Date(iso + 'T00:00:00') @@ -29,40 +33,66 @@ type PayloadEntry = { name?: string value?: number color?: string + dataKey?: string } function CustomTooltip({ active, payload, label, + hasGreenBonus, }: { active?: boolean payload?: PayloadEntry[] label?: string + hasGreenBonus: boolean }) { 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') + + const gridBalance = payload.find((p) => p.dataKey === 'daily_grid_balance_czk') + const bonus = payload.find((p) => p.dataKey === 'daily_green_bonus_czk') + const cumBalance = payload.find((p) => p.dataKey === 'cumulative_balance_czk') + const cumGrid = payload.find((p) => p.dataKey === 'cumulative_grid_balance_czk') + const importCost = payload.find((p) => p.dataKey === 'daily_import_cost_czk') + const exportRev = payload.find((p) => p.dataKey === 'daily_export_revenue_czk') + + const gridVal = gridBalance?.value ?? 0 + const bonusVal = bonus?.value ?? 0 + const total = gridVal + bonusVal + + const fmtCzk = (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(2)} Kč` + return (
{label}
- {balance && ( -= 0 ? GREEN : RED }}> - Den: {(balance.value ?? 0) >= 0 ? '+' : ''} - {(balance.value ?? 0).toFixed(2)} Kč +
= 0 ? GREEN : RED }}>Síť: {fmtCzk(gridVal)}
+ {hasGreenBonus &&Bonus: {fmtCzk(bonusVal)}
} += 0 ? GREEN : RED }}> + Celkem: {fmtCzk(total)} +
+ {importCost && ( ++ Nákup ze sítě: {(importCost.value ?? 0).toFixed(2)} Kč
)} - {cumulative && ( -- Kumulativ: {(cumulative.value ?? 0) >= 0 ? '+' : ''} - {(cumulative.value ?? 0).toFixed(2)} Kč + {exportRev && ( +
Prodej do sítě: {(exportRev.value ?? 0).toFixed(2)} Kč
+ )} + {cumBalance && ( ++ Kumulativ: {fmtCzk(cumBalance.value ?? 0)} +
+ )} + {cumGrid && ( ++ Kumulativ síť: {fmtCzk(cumGrid.value ?? 0)}
)}Nákup celkem
-{summary.import_cost.toFixed(2)} Kč
+Nákup ze sítě
+{summary.grid_import_cashflow.toFixed(2)} Kč
Prodej celkem
-{summary.export_revenue.toFixed(2)} Kč
+Prodej do sítě
+{summary.grid_export_revenue.toFixed(2)} Kč
+Náklad celkem
+{summary.import_cost.toFixed(2)} Kč
+Příjem celkem
+{summary.export_revenue.toFixed(2)} Kč