FIX RYCHLOST EKONOMIKA
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-27 19:47:18 +02:00
parent c52946a4ce
commit 8114ec5e63
5 changed files with 177 additions and 12 deletions

View File

@@ -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[T1]` (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[T1]` (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 **6264** (č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`.

View File

@@ -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
),

View File

@@ -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).';

View File

@@ -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

View File

@@ -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[T1]` (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[T1]` (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[T1]` (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ř. 00.3), aby solver klidně „vylil“ baterii do sítě při kladné `sell_price`
Pro BA81 typicky dává smysl menší hodnota (např. **00.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é)