From 8114ec5e6368e49674562e83a3c8fead2e7c06f9 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Mon, 27 Apr 2026 19:47:18 +0200 Subject: [PATCH] FIX RYCHLOST EKONOMIKA --- CLAUDE.md | 2 +- .../R__068_fn_economics_daily_month.sql | 147 +++++++++++++++++- db/routines/R__069_fn_economics_lock_day.sql | 8 +- .../R__070_fn_economics_monthly_chart.sql | 6 +- docs/04-modules/planning.md | 26 +++- 5 files changed, 177 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b445c5d..7d594cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st 15. **Bazální spotřeba** = `load_power_w` minus řízené zátěže (součet EV z `telemetry_ev_charger`, TČ z `telemetry_heat_pump`). Tabulka `consumption_baseline_stats` se plní denně (APScheduler 00:30) přes `fn_update_baseline_stats`. **Solver** načítá průměr bazálu z `consumption_baseline_stats` (DOW + hodina v Europe/Prague), ne z `consumption_baseline_interval`. -16. **Dynamický horizont plánování (jen OTE):** konec okna z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (`min` posledního OTE konce a `start + 36 h`, NULL pokud je známé OTE kratší než **1 h** od startu – rolling se přeskočí; denní plán při NULL použije **1 h** fallback v Pythonu). Strop a práh měnit v SQL (defaultní argumenty funkce / repeatable migrace), ne přes env. Solver používá **výhradně** sloty s efektivní cenou z `vw_site_effective_price` (žádná predikce v LP). Účelová funkce má **terminal SoC shadow price**: `−(průměr buy v prvních 24 h slotů × 0,9 / 1000) × soc[T−1]` (Kč; SoC v Wh). `market_price_stats` / `fn_get_predicted_price` zůstávají pro statistiky a budoucí rozšíření; detail historie: `docs/04-modules/planning-extended-horizon.md`. +16. **Dynamický horizont plánování (jen OTE):** konec okna z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (`min` posledního OTE konce a `start + 36 h`, NULL pokud je známé OTE kratší než **1 h** od startu – rolling se přeskočí; denní plán při NULL použije **1 h** fallback v Pythonu). Strop a práh měnit v SQL (defaultní argumenty funkce / repeatable migrace), ne přes env. Solver používá **výhradně** sloty s efektivní cenou z `vw_site_effective_price` (žádná predikce v LP). Účelová funkce má **terminal SoC shadow price**: `−(průměr buy v prvních 24 h slotů × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč; SoC v Wh), kde **`planner_terminal_soc_value_factor`** je **`ems.asset_battery.planner_terminal_soc_value_factor`** načtené přes **`ems.fn_planning_site_context`** (žádný skrytý faktor v Pythonu). `market_price_stats` / `fn_get_predicted_price` zůstávají pro statistiky a budoucí rozšíření; detail historie: `docs/04-modules/planning-extended-horizon.md`. 17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord` → `fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **62–64** (čas) → po 3 neúspěšných ověřeních **bez** změny režimu, kritický **Discord** (`notify_modbus_clock_verify_exhausted`). **Obecně:** při jakékoli změně `mode_code` z Pythonu (`POST /api/v1/sites/{id}/mode`, mismatch → SELF_SUSTAIN, `fn_expire_modes`) lze Discord zapnout přes `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`. diff --git a/db/routines/R__068_fn_economics_daily_month.sql b/db/routines/R__068_fn_economics_daily_month.sql index 951966b..cf9cfcf 100644 --- a/db/routines/R__068_fn_economics_daily_month.sql +++ b/db/routines/R__068_fn_economics_daily_month.sql @@ -1,3 +1,141 @@ +-- Denní ekonomika za časové okno [p_ts_from, p_ts_to) bez joinu na vw_site_effective_price +-- (ten dělá cross join mip × site_market_config a je extrémně drahý při agregaci přes vw_economics_daily). +-- Ceny = fn_effective_buy_price / fn_effective_sell_price (stejně jako ve vw_site_effective_price). + +create or replace function ems.fn_economics_daily_for_window( + p_site_id int, + p_ts_from timestamptz, + p_ts_to timestamptz +) +returns table ( + site_id int, + day_local date, + interval_count int, + import_kwh numeric, + export_kwh numeric, + pv_kwh numeric, + load_kwh numeric, + pv_self_consumption_kwh numeric, + ev_kwh numeric, + hp_kwh numeric, + grid_import_cashflow_czk numeric, + grid_export_revenue_czk numeric, + import_cost_czk numeric, + export_revenue_czk numeric, + net_cost_czk numeric, + green_bonus_czk numeric, + total_balance_czk numeric, + planned_net_cost_czk numeric, + planned_balance_czk numeric, + deviation_cost_czk numeric +) +language sql +stable +as $fn$ + with slots as ( + select + ai.site_id, + (date_trunc('day', ai.interval_start at time zone 'Europe/Prague'))::date as day_local, + 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, + round( + coalesce(ai.actual_grid_import_wh, greatest(ai.actual_grid_power_w, 0)::numeric / 4) / 1000.0 + * coalesce(pr.buy_p, 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(pr.sell_p, 0), + 4 + ) as grid_export_revenue_czk, + round( + coalesce(ai.actual_grid_import_wh, greatest(ai.actual_grid_power_w, 0)::numeric / 4) / 1000.0 + * coalesce(pr.buy_p, 0) + - coalesce(ai.actual_grid_export_wh, abs(least(ai.actual_grid_power_w, 0))::numeric / 4) / 1000.0 + * coalesce(pr.sell_p, 0), + 4 + ) as dynamic_cost_czk, + ai.green_bonus_czk, + pi.expected_cost_czk as planned_cost_czk, + ai.actual_pv_power_w, + ai.actual_load_power_w, + ai.actual_ev_power_w, + ai.actual_heat_pump_power_w + from ems.audit_interval ai + left join ems.planning_interval pi + on pi.run_id = ai.planning_run_id + and pi.interval_start = ai.interval_start + cross join lateral ( + select + ems.fn_effective_buy_price(ai.site_id, ai.interval_start) as buy_p, + ems.fn_effective_sell_price(ai.site_id, ai.interval_start) as sell_p + ) pr + where ai.site_id = p_site_id + and ai.interval_start >= p_ts_from + and ai.interval_start < p_ts_to + ), + daily_agg as ( + select + s.site_id, + s.day_local, + count(*)::int as interval_count, + round(sum(s.import_kwh), 3) as import_kwh, + round(sum(s.export_kwh), 3) as export_kwh, + round(sum(greatest(s.actual_pv_power_w, 0)::numeric / 4000), 3) as pv_kwh, + round(sum(greatest(s.actual_load_power_w, 0)::numeric / 4000), 3) as load_kwh, + round(sum(greatest(s.actual_ev_power_w, 0)::numeric / 4000), 3) as ev_kwh, + round(sum(greatest(s.actual_heat_pump_power_w, 0)::numeric / 4000), 3) as hp_kwh, + round( + sum(greatest(s.actual_pv_power_w, 0)::numeric / 4000) - sum(s.export_kwh), + 3 + ) as pv_self_consumption_kwh, + round(sum(s.grid_import_cashflow_czk), 2) as grid_import_cashflow_czk, + round(sum(s.grid_export_revenue_czk), 2) as grid_export_revenue_czk, + round(sum(case when s.dynamic_cost_czk > 0 then s.dynamic_cost_czk else 0 end), 2) as import_cost_czk, + round(sum(case when s.dynamic_cost_czk < 0 then abs(s.dynamic_cost_czk) else 0 end), 2) as export_revenue_czk, + round(sum(s.dynamic_cost_czk), 2) as net_cost_czk, + round(coalesce(sum(s.green_bonus_czk), 0), 2) as green_bonus_czk, + round(-sum(s.dynamic_cost_czk) + coalesce(sum(s.green_bonus_czk), 0), 2) as total_balance_czk, + round(sum(s.planned_cost_czk), 2) as planned_net_cost_czk, + round(-coalesce(sum(s.planned_cost_czk), 0), 2) as planned_balance_czk, + round(sum(s.dynamic_cost_czk) - coalesce(sum(s.planned_cost_czk), 0), 2) as deviation_cost_czk + from slots s + group by s.site_id, s.day_local + ) + select + d.site_id, + d.day_local, + d.interval_count, + d.import_kwh, + d.export_kwh, + d.pv_kwh, + d.load_kwh, + d.pv_self_consumption_kwh, + d.ev_kwh, + d.hp_kwh, + d.grid_import_cashflow_czk, + d.grid_export_revenue_czk, + d.import_cost_czk, + d.export_revenue_czk, + d.net_cost_czk, + d.green_bonus_czk, + d.total_balance_czk, + d.planned_net_cost_czk, + d.planned_balance_czk, + d.deviation_cost_czk + from daily_agg d + order by d.day_local; +$fn$; + +comment on function ems.fn_economics_daily_for_window is + 'Denní souhrn ekonomiky pro site v polovičně otevřeném UTC okně [from, to); bez vw_site_effective_price.'; + create or replace function ems.fn_economics_daily_month( p_site_id int, p_month_start date, @@ -50,13 +188,14 @@ as $fn$ 'deviation_cost_czk', r.deviation_cost_czk, 'is_locked', (l.site_id is not null) ) as day_row - from ems.vw_economics_daily r + from ems.fn_economics_daily_for_window( + p_site_id, + (p_month_start::timestamp at time zone 'Europe/Prague'), + (p_month_end::timestamp at time zone 'Europe/Prague') + ) r left join ems.audit_day_lock l on l.site_id = r.site_id and l.day_local = r.day_local - where r.site_id = p_site_id - and r.day_local >= p_month_start - and r.day_local < p_month_end order by r.day_local ) sub ), diff --git a/db/routines/R__069_fn_economics_lock_day.sql b/db/routines/R__069_fn_economics_lock_day.sql index b89a8e6..02c1ce5 100644 --- a/db/routines/R__069_fn_economics_lock_day.sql +++ b/db/routines/R__069_fn_economics_lock_day.sql @@ -27,7 +27,11 @@ begin v_total, v_gic, v_ger - from ems.vw_economics_daily r + from ems.fn_economics_daily_for_window( + p_site_id, + (p_day::timestamp at time zone 'Europe/Prague'), + ((p_day + 1)::timestamp at time zone 'Europe/Prague') + ) r where r.site_id = p_site_id and r.day_local = p_day; @@ -71,4 +75,4 @@ end; $fn$; comment on function ems.fn_economics_lock_day(int, date) is - 'Zamkne den ekonomiky podle aktuálního vw_economics_daily (POST lock).'; + 'Zamkne den ekonomiky podle fn_economics_daily_for_window (POST lock).'; diff --git a/db/routines/R__070_fn_economics_monthly_chart.sql b/db/routines/R__070_fn_economics_monthly_chart.sql index a28aa7f..f3501b1 100644 --- a/db/routines/R__070_fn_economics_monthly_chart.sql +++ b/db/routines/R__070_fn_economics_monthly_chart.sql @@ -15,7 +15,11 @@ as $fn$ case when l.site_id is not null then l.green_bonus_czk else r.green_bonus_czk end as gb, coalesce(l.grid_import_cashflow_czk, r.grid_import_cashflow_czk) as gic, coalesce(l.grid_export_revenue_czk, r.grid_export_revenue_czk) as ger - from ems.vw_economics_daily r + from ems.fn_economics_daily_for_window( + p_site_id, + (p_month_start::timestamp at time zone 'Europe/Prague'), + (p_month_end::timestamp at time zone 'Europe/Prague') + ) r left join ems.audit_day_lock l on l.site_id = r.site_id and l.day_local = r.day_local diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index eb606f8..4da0d65 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -8,7 +8,7 @@ - **SQL-first:** horizont a sloty z DB funkcí (`fn_planning_horizon_end`, `fn_load_planning_slots_full`, …); viz **`CLAUDE.md`** → sekce *SQL-first a read-model*. - **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** – obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu. -- **Terminal SoC shadow price:** v objective je člen `−(avg_buy_prvních_24h × 0,9 / 1000) × soc[T−1]` (Kč), aby konec horizontu nekončil zbytečně vyprázdněnou baterií (receding horizon). +- **Terminal SoC shadow price:** v objective je člen `−(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon). - **Runtime guard v exportu setpointů (legacy):** - při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování (u nových plánů by `is_predicted_price` v horizontu nemělo nastat). - **Ekonomika baterie:** @@ -473,13 +473,31 @@ COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS ## Tuning pro malé baterie (např. BA81) +### Terminal SoC shadow price (kritický parametr) + +V účelové funkci LP je člen **„terminal SoC shadow price“**: energie ponechaná v baterii na konci horizontu je oceněná jako záporný příspěvek k nákladům (solver má motivaci držet část SoC, pokud se to ekonomicky vyplatí oproti okamžitému vývozu / nákupu). + +**Výpočet (zjednodušeně):** +`terminal_soc_kcz_per_wh = (průměr buy v prvních 24 h slotů) × planner_terminal_soc_value_factor / 1000` +a v objective se přičítá `- terminal_soc_kcz_per_wh × soc[T−1]` (viz `solve_dispatch` v `backend/services/planning_engine.py`). + +**Kde se bere faktor (jediný kanonický zdroj):** + +1. Sloupec **`ems.asset_battery.planner_terminal_soc_value_factor`** (`NOT NULL`, default **0.9** — migrace **V062**, idempotentní upevnění **V069**). +2. Hodnota se do solveru dostává výhradně přes **`ems.fn_planning_site_context(site_id)`** → pole `battery.planner_terminal_soc_value_factor` v JSONu. +3. Backend v **`_load_site_context()`** mapuje JSON na `SimpleNamespace` a **`solve_dispatch()` už nemá žádný skrytý fallback z kódu** — chybí-li klíč v JSONu, je to chyba konfigurace / nasazení. + +> **Historická chyba (opraveno):** dříve `fn_planning_site_context` sloupec z tabulky **nepropisoval** do `battery` JSONu a Python atribut **vůbec nenačítal**, takže se v praxi používala **pevná 0.9** z kódu bez ohledu na DB. To umělo zcela převrátit chování (např. BA81 s **0.2** v tabulce se chovalo jako **0.9**). Po opravě musí projít **repeatable** `R__039_fn_planning_site_context.sql` i backend. + +### Doporučené hodnoty + Pokud solver „šetří baterku“ a raději importuje ze sítě (kvůli terminal SoC shadow price), lze per baterii upravit váhu této kotvy: - `ems.asset_battery.planner_terminal_soc_value_factor` - - `0.0` = žádná motivace držet SoC na konci horizontu (agresivnější arbitráž / vybití) - - `0.9` = default (konzervativnější držení energie) + - **`0.0`** = žádná motivace držet SoC na konci horizontu (agresivnější arbitráž / vybití) + - **`0.9`** = výchozí default v DB (konzervativnější držení energie) -Pro BA81 typicky dává smysl menší hodnota (např. 0–0.3), aby solver klidně „vylil“ baterii do sítě při kladné `sell_price` +Pro BA81 typicky dává smysl menší hodnota (např. **0–0.3**), aby solver klidně „vylil“ baterii do sítě při kladné `sell_price` a nechal si kapacitu na nabití v oknech záporných cen. ## Konfigurace (env proměnné)