FIX RYCHLOST EKONOMIKA
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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).';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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é)
|
||||
|
||||
Reference in New Issue
Block a user