zkraceni intervalu planneru na max 35h
This commit is contained in:
@@ -26,7 +26,7 @@ Pokud `error = no_active_plan`, v odpovědi uveď že aktivní plán v DB není
|
|||||||
- **Limity**: `site_grid_connection`, `asset_battery` (`min_soc_percent`, `reserve_soc_percent`, `usable_capacity_wh`, degradace).
|
- **Limity**: `site_grid_connection`, `asset_battery` (`min_soc_percent`, `reserve_soc_percent`, `usable_capacity_wh`, degradace).
|
||||||
- **EV deadline**: `ev_sessions_open` + sloupce `target_deadline` / `target_soc_pct` v kontextu intervalů (`ev1_setpoint_w`, `ev2_setpoint_w`).
|
- **EV deadline**: `ev_sessions_open` + sloupce `target_deadline` / `target_soc_pct` v kontextu intervalů (`ev1_setpoint_w`, `ev2_setpoint_w`).
|
||||||
- **Rolling vs daily**: `active_planning_run.run_type`, `triggered_by`, `forecast_correction_factor`, `replan_from`, `soc_at_replan_wh`.
|
- **Rolling vs daily**: `active_planning_run.run_type`, `triggered_by`, `forecast_correction_factor`, `replan_from`, `soc_at_replan_wh`.
|
||||||
- **Váhy LP podle vzdálenosti od začátku horizontu**: u každého intervalu je `hours_from_plan_horizon_start`; mapování 0–36h / 36–72h / 72–96h je v `ai_readme` a v `CLAUDE.md`.
|
- **Horizont a ceny**: produkční LP používá dynamický OTE horizont (`fn_planning_horizon_end`); u intervalu je `hours_from_plan_horizon_start` jen orientační. Váhy 0–36h / 36–72h / 72–96h jsou **historické** (viz `ai_readme` v JSONu a `docs/04-modules/planning-extended-horizon.md`).
|
||||||
|
|
||||||
## 3) Volitelně (UI stejné jako dashboard)
|
## 3) Volitelně (UI stejné jako dashboard)
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ DISCORD_WEBHOOK_URL= # Discord webhook URL pro alerty, prázdné = vypnuto
|
|||||||
TELEMETRY_POLL_INTERVAL_SEC=60
|
TELEMETRY_POLL_INTERVAL_SEC=60
|
||||||
|
|
||||||
# ---- Plánování ----
|
# ---- Plánování ----
|
||||||
PLANNING_HORIZON_HOURS=36
|
# Délka horizontu (strop OTE + min délka pro rolling): ems.fn_planning_horizon_end v DB, ne env.
|
||||||
PLANNING_HP_MAX_COST_CZK_KWH=3.0 # max Kč/kWh tepla pro spuštění TČ
|
PLANNING_HP_MAX_COST_CZK_KWH=3.0 # max Kč/kWh tepla pro spuštění TČ
|
||||||
PLANNING_CHEAP_PRICE_THRESHOLD=0.85
|
PLANNING_CHEAP_PRICE_THRESHOLD=0.85
|
||||||
PLANNING_EXPENSIVE_PRICE_THRESHOLD=1.15
|
PLANNING_EXPENSIVE_PRICE_THRESHOLD=1.15
|
||||||
|
|||||||
16
CLAUDE.md
16
CLAUDE.md
@@ -64,9 +64,13 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
|||||||
|
|
||||||
11. **Přepínání provozního režimu** přes DB API / `ems.fn_set_mode` – držet konzistenci s `operating_mode_def` a Loxone `loxone_mode_value`.
|
11. **Přepínání provozního režimu** přes DB API / `ems.fn_set_mode` – držet konzistenci s `operating_mode_def` a Loxone `loxone_mode_value`.
|
||||||
|
|
||||||
### SQL vs Python (read-model)
|
### SQL-first a read-model (Python jen tenká orchestrace)
|
||||||
|
|
||||||
- **Žádné ad-hoc `SELECT`/`INSERT`/`UPDATE` v `backend/services/*.py` a `backend/app/routers/*.py`** kromě: existence `SELECT 1` / `EXISTS`, volání `select ems.fn_*(…)`, a čtení z **`ems.vw_*`**. IO (Modbus, HTTP), PuLP solver a orchestrace zůstávají v Pythonu.
|
Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a stabilní čtecí rozhraní patří do **PostgreSQL** (`ems.fn_*`, případně **`ems.vw_*`**). Python (FastAPI, joby) volá DB; neskladá vlastní dotazy nad schématem mimo výjimky níže.
|
||||||
|
|
||||||
|
- **Preferuj:** novou nebo rozšířenou **`ems.fn_*(…)`** s jasnými parametry; potřebuješ často stejné sloupce z více tabulek → **`ems.vw_*`** (view zapouzdřuje joiny a strukturu DB; z Pythonu je `SELECT … FROM ems.vw_*` v pořádku).
|
||||||
|
- **Nechtěné:** skládání dotazů v Pythonu (**vlastní JOIN / WITH / poddotazy** nad `ems.*` tabulkami). Místo toho funkce nebo view v `db/routines/` / `db/views/` + jedno volání z aplikace.
|
||||||
|
- **Jediné SQL v `backend/services/*.py` a `backend/app/routers/*.py`:** `SELECT 1` / `EXISTS`; **`select ems.fn_*(…)`**; **`SELECT … FROM ems.vw_*`** (read přes view); žádné jiné ad-hoc **`SELECT`/`INSERT`/`UPDATE`**. IO (Modbus, HTTP); **PuLP**; orchestrace scheduleru.
|
||||||
- **Health a Loxone po změně režimu:** `fn_health_summary`, `fn_health_detailed_db`, `fn_vw_site_directory_active`, `fn_site_economics_yesterday_notification`, `fn_site_mode_loxone_bundle` v repeatable `db/routines/R__073_fn_health_site_jobs_mode_bundle.sql`; FastAPI je v [`app/main.py`](backend/app/main.py) + joby v [`app/lifespan.py`](backend/app/lifespan.py).
|
- **Health a Loxone po změně režimu:** `fn_health_summary`, `fn_health_detailed_db`, `fn_vw_site_directory_active`, `fn_site_economics_yesterday_notification`, `fn_site_mode_loxone_bundle` v repeatable `db/routines/R__073_fn_health_site_jobs_mode_bundle.sql`; FastAPI je v [`app/main.py`](backend/app/main.py) + joby v [`app/lifespan.py`](backend/app/lifespan.py).
|
||||||
|
|
||||||
### Provozní režimy (operating_mode)
|
### Provozní režimy (operating_mode)
|
||||||
@@ -84,7 +88,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
|||||||
|
|
||||||
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`.
|
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. **Rozšířený horizont plánování (96h):** denní plán pokrývá **96h** od začátku aktuálního 15min slotu. Sloty v prvních **36h** používají přesné efektivní ceny z `vw_site_effective_price` (OTE). Sloty **36–96h** doplňuje **predikovaná cena** z `market_price_stats` přes `fn_get_predicted_price` (prodejní strana hrubý faktor 0,85 vs. nákupní predikce). V účelové funkci LP se uplatní **váhy nejistoty** podle vzdálenosti od začátku okna: **1,0** (0–36h), **0,7** (36–72h), **0,4** (72–96h). Statistiky cen plní `fn_update_market_price_stats` (job 14:45), TUV delta `fn_update_tuv_usage_stats` (job 00:45). Detail: `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ů × 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`.
|
||||||
|
|
||||||
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`.
|
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`.
|
||||||
|
|
||||||
@@ -134,7 +138,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
|||||||
| `modbus_command` | Journal Modbus zápisů (pending → written → verified / mismatch / failed); retry a vazba na `planning_run`; u Deye exportu `deye_physical_mode` (PASSIVE/SELL/CHARGE). |
|
| `modbus_command` | Journal Modbus zápisů (pending → written → verified / mismatch / failed); retry a vazba na `planning_run`; u Deye exportu `deye_physical_mode` (PASSIVE/SELL/CHARGE). |
|
||||||
| `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. |
|
| `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. |
|
||||||
|
|
||||||
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`.
|
**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -147,7 +151,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
|||||||
| `telemetry_collector` | každých **60 s** | Smyčka polling Modbus (Deye, EV×2, TČ) – viz `docs/04-modules/telemetry.md` |
|
| `telemetry_collector` | každých **60 s** | Smyčka polling Modbus (Deye, EV×2, TČ) – viz `docs/04-modules/telemetry.md` |
|
||||||
| `price_importer` (scheduler) | **13:30 / 14:00 / 00:05** | Jeden globální zápis do `market_interval_price` za tick (ne cyklus per site); po importu obnova predikce záporných cen pro každou aktivní site. Viz `docs/04-modules/market-prices.md` |
|
| `price_importer` (scheduler) | **13:30 / 14:00 / 00:05** | Jeden globální zápis do `market_interval_price` za tick (ne cyklus per site); po importu obnova predikce záporných cen pro každou aktivní site. Viz `docs/04-modules/market-prices.md` |
|
||||||
| `forecast_service` | **14:30** + **06:00** denně | `docs/04-modules/forecast.md` |
|
| `forecast_service` | **14:30** + **06:00** denně | `docs/04-modules/forecast.md` |
|
||||||
| `run_daily_plan` | **15:00** denně | `backend/services/planning_engine.py` (horizont **96 h**, váhy slotů 1,0 / 0,7 / 0,4) |
|
| `run_daily_plan` | **15:00** denně | `backend/services/planning_engine.py` + `ems.fn_planning_horizon_end` (dynamický OTE horizont, terminal SoC) |
|
||||||
| `run_rolling_replan` | **každých 15 min** (`*/15`) | `planning_engine.py` – přepočet od aktuálního slotu |
|
| `run_rolling_replan` | **každých 15 min** (`*/15`) | `planning_engine.py` – přepočet od aktuálního slotu |
|
||||||
| `control_exporter` | **každých 15 min** (slot boundary) | `docs/04-modules/control.md` |
|
| `control_exporter` | **každých 15 min** (slot boundary) | `docs/04-modules/control.md` |
|
||||||
| `verify_modbus` | **každé 2 min** | Ověření `modbus_command` ve stavu `written` (posledních 10 min); viz `docs/04-modules/modbus-command-journal.md` |
|
| `verify_modbus` | **každé 2 min** | Ověření `modbus_command` ve stavu `written` (posledních 10 min); viz `docs/04-modules/modbus-command-journal.md` |
|
||||||
@@ -175,7 +179,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
|||||||
| Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` |
|
| Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` |
|
||||||
| Deye registry (FC 0x10, 108/109/141/142/178/143) | `docs/04-modules/modbus-registers.md` |
|
| Deye registry (FC 0x10, 108/109/141/142/178/143) | `docs/04-modules/modbus-registers.md` |
|
||||||
| Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` |
|
| Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` |
|
||||||
| LP solver, rolling replan, korekce FVE, horizont 96h | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` |
|
| LP solver, rolling replan, korekce FVE, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` |
|
||||||
| Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `db/routines/R__044_fn_set_mode.sql` |
|
| Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `db/routines/R__044_fn_set_mode.sql` |
|
||||||
| EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` |
|
| EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` |
|
||||||
| Curtailment A, zelený bonus B | `db/migration/V005__planning_curtailment.sql` |
|
| Curtailment A, zelený bonus B | `db/migration/V005__planning_curtailment.sql` |
|
||||||
|
|||||||
@@ -367,6 +367,22 @@ async def lifespan(app: FastAPI):
|
|||||||
id="ote_import_preopen",
|
id="ote_import_preopen",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
scheduled_ote_import,
|
||||||
|
"cron",
|
||||||
|
hour="13,14",
|
||||||
|
minute=15,
|
||||||
|
id="ote_import_retry_early",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
scheduled_ote_import,
|
||||||
|
"cron",
|
||||||
|
hour="13,14",
|
||||||
|
minute=45,
|
||||||
|
id="ote_import_retry_late",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
scheduled_ote_import,
|
scheduled_ote_import,
|
||||||
"cron",
|
"cron",
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
# EMS Platform – plánovací engine
|
# EMS Platform – plánovací engine
|
||||||
# Obsahuje: hlavní denní plán + rolling 15min replan
|
# Obsahuje: hlavní denní plán + rolling 15min replan
|
||||||
#
|
#
|
||||||
# Spouštění (APScheduler v main.py):
|
# Spouštění (APScheduler v lifespan.py):
|
||||||
# scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0)
|
# scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0)
|
||||||
# scheduler.add_job(run_rolling_replan, 'cron', minute='*/15')
|
# scheduler.add_job(run_rolling_replan, 'cron', minute='*/15')
|
||||||
|
# Horizont: ems.fn_planning_horizon_end (OTE + strop/min v SQL).
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -25,11 +26,11 @@ logger = logging.getLogger(__name__)
|
|||||||
# Konstanty
|
# Konstanty
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
HORIZON_HOURS = 96 # horizont denního plánu (OTE ~36h + predikce)
|
# Když DB vrátí NULL (skoro žádná OTE data), denní plán použije krátký fallback (soulad s min hodinami ve fn_planning_horizon_end).
|
||||||
|
_DAILY_FALLBACK_HORIZON_HOURS = 1.0
|
||||||
|
# Shadow cena zbytkové energie na konci horizontu: - (avg_buy * FACTOR / 1000) * soc[T-1] (Kč; soc v Wh).
|
||||||
|
TERMINAL_SOC_VALUE_FACTOR = 0.9
|
||||||
INTERVAL_H = 0.25 # 15 minut v hodinách
|
INTERVAL_H = 0.25 # 15 minut v hodinách
|
||||||
SLOT_WEIGHT_FULL = 1.0 # 0–36h od začátku okna (přesné OTE ceny)
|
|
||||||
SLOT_WEIGHT_MEDIUM = 0.7 # 36–72h
|
|
||||||
SLOT_WEIGHT_LOW = 0.4 # 72–96h
|
|
||||||
CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A
|
CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A
|
||||||
SOLVER_TIME_LIMIT = 10 # sekund
|
SOLVER_TIME_LIMIT = 10 # sekund
|
||||||
# MILP: jakýkoli významný výkon exportu ge (W) ⇒ koncové soc[t] ≥ arb_base_wh (rezerva z DB)
|
# MILP: jakýkoli významný výkon exportu ge (W) ⇒ koncové soc[t] ≥ arb_base_wh (rezerva z DB)
|
||||||
@@ -46,14 +47,22 @@ ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0..
|
|||||||
_PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
_PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||||
|
|
||||||
|
|
||||||
def slot_weight(slot_index: int, now_index: int = 0) -> float:
|
def _timestamptz_from_db(val: object) -> Optional[datetime]:
|
||||||
"""Váha slotu v účelové funkci podle vzdálenosti od začátku optimalizačního okna."""
|
if val is None:
|
||||||
hours_ahead = (slot_index - now_index) * INTERVAL_H
|
return None
|
||||||
if hours_ahead <= 36:
|
if isinstance(val, datetime):
|
||||||
return SLOT_WEIGHT_FULL
|
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
|
||||||
if hours_ahead <= 72:
|
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
||||||
return SLOT_WEIGHT_MEDIUM
|
|
||||||
return SLOT_WEIGHT_LOW
|
|
||||||
|
async def _planning_horizon_end(site_id: int, horizon_from: datetime, db) -> Optional[datetime]:
|
||||||
|
"""Konec horizontu z DB (`fn_planning_horizon_end`); NULL = rolling skip / daily fallback."""
|
||||||
|
raw = await db.fetchval(
|
||||||
|
"select ems.fn_planning_horizon_end($1::int, $2::timestamptz)",
|
||||||
|
site_id,
|
||||||
|
horizon_from,
|
||||||
|
)
|
||||||
|
return _timestamptz_from_db(raw)
|
||||||
|
|
||||||
|
|
||||||
def _pv_scarcity_penalty_multiplier(slots: list["PlanningSlot"], battery) -> float:
|
def _pv_scarcity_penalty_multiplier(slots: list["PlanningSlot"], battery) -> float:
|
||||||
@@ -282,15 +291,15 @@ def solve_dispatch(
|
|||||||
current_tuv_temp_c: float,
|
current_tuv_temp_c: float,
|
||||||
*,
|
*,
|
||||||
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
|
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
|
||||||
now_slot_index: int = 0,
|
|
||||||
operating_mode: str = "AUTO",
|
operating_mode: str = "AUTO",
|
||||||
price_failsafe_active: bool = False,
|
|
||||||
) -> tuple[list[DispatchResult], int]:
|
) -> tuple[list[DispatchResult], int]:
|
||||||
"""
|
"""
|
||||||
LP solver pro dispatch optimalizaci.
|
LP solver pro dispatch optimalizaci.
|
||||||
Vrátí (výsledky, solver_duration_ms).
|
Vrátí (výsledky, solver_duration_ms).
|
||||||
"""
|
"""
|
||||||
T = len(slots)
|
T = len(slots)
|
||||||
|
if T < 1:
|
||||||
|
raise RuntimeError("solve_dispatch requires at least one slot")
|
||||||
EV = len(vehicles) # počet EV (typicky 2)
|
EV = len(vehicles) # počet EV (typicky 2)
|
||||||
|
|
||||||
EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency)
|
EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency)
|
||||||
@@ -339,13 +348,22 @@ def solve_dispatch(
|
|||||||
vehicles[e].max_charge_power_w)
|
vehicles[e].max_charge_power_w)
|
||||||
for t in range(T)] for e in range(EV)]
|
for t in range(T)] for e in range(EV)]
|
||||||
|
|
||||||
# --- Účelová funkce (váhy slotů podle nejistoty za horizontem OTE) ---
|
horizon_slots_h24 = min(T, int(24 / INTERVAL_H))
|
||||||
prob += pulp.lpSum(
|
avg_buy_terminal = (
|
||||||
slot_weight(t, now_slot_index) * (
|
sum(float(slots[t].buy_price) for t in range(horizon_slots_h24)) / horizon_slots_h24
|
||||||
|
if horizon_slots_h24 > 0
|
||||||
|
else 4.0
|
||||||
|
)
|
||||||
|
# Kč/Wh: ocenění energie ponechané v baterii na konci horizontu (receding horizon kotva).
|
||||||
|
terminal_soc_kcz_per_wh = (
|
||||||
|
avg_buy_terminal * TERMINAL_SOC_VALUE_FACTOR / 1000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) ---
|
||||||
|
prob += (
|
||||||
|
pulp.lpSum(
|
||||||
gi[t] * slots[t].buy_price * INTERVAL_H / 1000
|
gi[t] * slots[t].buy_price * INTERVAL_H / 1000
|
||||||
- ge[t] * slots[t].sell_price * INTERVAL_H / 1000
|
- ge[t] * slots[t].sell_price * INTERVAL_H / 1000
|
||||||
# Degradační náklad rozložíme symetricky na charge/discharge (0.5 + 0.5),
|
|
||||||
# aby nebyl roundtrip penalizovaný dvojnásobně.
|
|
||||||
+ 0.5 * (bc[t] + bd[t]) * degradation_cost_effective * INTERVAL_H / 1000
|
+ 0.5 * (bc[t] + bd[t]) * degradation_cost_effective * INTERVAL_H / 1000
|
||||||
+ pulp.lpSum(
|
+ pulp.lpSum(
|
||||||
ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000
|
ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000
|
||||||
@@ -353,9 +371,11 @@ def solve_dispatch(
|
|||||||
for e in range(EV)
|
for e in range(EV)
|
||||||
)
|
)
|
||||||
+ ca[t] * CURTAILMENT_PENALTY
|
+ ca[t] * CURTAILMENT_PENALTY
|
||||||
)
|
|
||||||
for t in range(T)
|
for t in range(T)
|
||||||
) + soc_deficit_24h * soc_deficit_penalty_czk_kwh / 1000
|
)
|
||||||
|
+ soc_deficit_24h * soc_deficit_penalty_czk_kwh / 1000
|
||||||
|
- terminal_soc_kcz_per_wh * soc[T - 1]
|
||||||
|
)
|
||||||
|
|
||||||
# --- Omezení ---
|
# --- Omezení ---
|
||||||
for t in range(T):
|
for t in range(T):
|
||||||
@@ -438,11 +458,6 @@ def solve_dispatch(
|
|||||||
prob += ge[t] == 0
|
prob += ge[t] == 0
|
||||||
prob += bd[t] == 0
|
prob += bd[t] == 0
|
||||||
|
|
||||||
if price_failsafe_active:
|
|
||||||
for t in range(T):
|
|
||||||
if slots[t].is_predicted_price:
|
|
||||||
prob += ge[t] == 0
|
|
||||||
|
|
||||||
# Slot pre-selection (z DB fn_load_planning_slots_full → allow_*)
|
# Slot pre-selection (z DB fn_load_planning_slots_full → allow_*)
|
||||||
if om == "AUTO":
|
if om == "AUTO":
|
||||||
charge_slots = {t for t, s in enumerate(slots) if s.allow_charge}
|
charge_slots = {t for t, s in enumerate(slots) if s.allow_charge}
|
||||||
@@ -559,11 +574,18 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
|||||||
"""
|
"""
|
||||||
Hlavní denní plánování. Spouštět v 15:00 po importu cen (14:00)
|
Hlavní denní plánování. Spouštět v 15:00 po importu cen (14:00)
|
||||||
a aktualizaci forecastu (14:30).
|
a aktualizaci forecastu (14:30).
|
||||||
Horizont: od začátku aktuálního 15min slotu do +HORIZON_HOURS (96h; OTE + predikce).
|
Horizont: `ems.fn_planning_horizon_end` (OTE, strop a práh v SQL).
|
||||||
"""
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
horizon_from = _current_slot_start(now)
|
horizon_from = _current_slot_start(now)
|
||||||
horizon_to = horizon_from + timedelta(hours=HORIZON_HOURS)
|
horizon_to = await _planning_horizon_end(site_id, horizon_from, db)
|
||||||
|
if horizon_to is None:
|
||||||
|
horizon_to = horizon_from + timedelta(hours=_DAILY_FALLBACK_HORIZON_HOURS)
|
||||||
|
logger.warning(
|
||||||
|
"[site=%s] Daily plan: fn_planning_horizon_end NULL, fallback %.1fh",
|
||||||
|
site_id,
|
||||||
|
_DAILY_FALLBACK_HORIZON_HOURS,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"[site={site_id}] Daily plan: {horizon_from} → {horizon_to}")
|
logger.info(f"[site={site_id}] Daily plan: {horizon_from} → {horizon_to}")
|
||||||
|
|
||||||
@@ -571,21 +593,11 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
|||||||
await _load_site_context(site_id, db)
|
await _load_site_context(site_id, db)
|
||||||
)
|
)
|
||||||
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
|
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
|
||||||
critical_slots = int(36 / INTERVAL_H)
|
|
||||||
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price)
|
|
||||||
price_failsafe_active = missing_ote_count > 0
|
|
||||||
if price_failsafe_active:
|
|
||||||
logger.warning(
|
|
||||||
"[site=%s] Price fail-safe active (daily): missing OTE slots in first 36h = %s",
|
|
||||||
site_id,
|
|
||||||
missing_ote_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
results, duration_ms = solve_dispatch(
|
results, duration_ms = solve_dispatch(
|
||||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||||
tuv_delta_stats=tuv_stats,
|
tuv_delta_stats=tuv_stats,
|
||||||
operating_mode=operating_mode or "AUTO",
|
operating_mode=operating_mode or "AUTO",
|
||||||
price_failsafe_active=price_failsafe_active,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
slot_inputs = _build_slot_inputs(slots, slots)
|
slot_inputs = _build_slot_inputs(slots, slots)
|
||||||
@@ -641,11 +653,19 @@ async def run_rolling_replan(
|
|||||||
logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan")
|
logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan")
|
||||||
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
||||||
|
|
||||||
he = ar["horizon_end"]
|
horizon_to = await _planning_horizon_end(site_id, replan_from, db)
|
||||||
if isinstance(he, datetime):
|
if horizon_to is None:
|
||||||
horizon_to = he if he.tzinfo else he.replace(tzinfo=timezone.utc)
|
if allow_skip:
|
||||||
else:
|
logger.info(
|
||||||
horizon_to = datetime.fromisoformat(str(he).replace("Z", "+00:00"))
|
"[site=%s] Rolling replan: fn_planning_horizon_end NULL (krátký OTE horizont), skipping",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
logger.warning(
|
||||||
|
"[site=%s] Rolling replan: fn_planning_horizon_end NULL, running daily plan",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
||||||
|
|
||||||
if (horizon_to - replan_from).total_seconds() < 1800:
|
if (horizon_to - replan_from).total_seconds() < 1800:
|
||||||
if allow_skip:
|
if allow_skip:
|
||||||
@@ -664,15 +684,6 @@ async def run_rolling_replan(
|
|||||||
|
|
||||||
slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh)
|
slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh)
|
||||||
slots_before_pv_correction = list(slots)
|
slots_before_pv_correction = list(slots)
|
||||||
critical_slots = int(36 / INTERVAL_H)
|
|
||||||
missing_ote_count = sum(1 for s in slots[:critical_slots] if s.is_predicted_price)
|
|
||||||
price_failsafe_active = missing_ote_count > 0
|
|
||||||
if price_failsafe_active:
|
|
||||||
logger.warning(
|
|
||||||
"[site=%s] Price fail-safe active (rolling): missing OTE slots in first 36h = %s",
|
|
||||||
site_id,
|
|
||||||
missing_ote_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
slots = apply_forecast_correction(slots, now, correction_factor)
|
slots = apply_forecast_correction(slots, now, correction_factor)
|
||||||
|
|
||||||
@@ -680,7 +691,6 @@ async def run_rolling_replan(
|
|||||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||||
tuv_delta_stats=tuv_stats,
|
tuv_delta_stats=tuv_stats,
|
||||||
operating_mode=operating_mode or "AUTO",
|
operating_mode=operating_mode or "AUTO",
|
||||||
price_failsafe_active=price_failsafe_active,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
slot_inputs = _build_slot_inputs(slots_before_pv_correction, slots)
|
slot_inputs = _build_slot_inputs(slots_before_pv_correction, slots)
|
||||||
@@ -914,6 +924,11 @@ async def _load_slots(
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"No planning slots available – check market prices and horizon settings"
|
"No planning slots available – check market prices and horizon settings"
|
||||||
)
|
)
|
||||||
|
if any(s.is_predicted_price for s in out):
|
||||||
|
logger.warning(
|
||||||
|
"[site=%s] Unexpected predicted-price slots in planning horizon",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from services.planning_engine import (
|
from services.planning_engine import (
|
||||||
@@ -128,7 +128,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
|||||||
50.0,
|
50.0,
|
||||||
tuv_delta_stats=None,
|
tuv_delta_stats=None,
|
||||||
operating_mode="AUTO",
|
operating_mode="AUTO",
|
||||||
price_failsafe_active=False,
|
|
||||||
)
|
)
|
||||||
self.assertGreaterEqual(ms, 0)
|
self.assertGreaterEqual(ms, 0)
|
||||||
self.assertEqual(len(results), 1)
|
self.assertEqual(len(results), 1)
|
||||||
@@ -169,7 +168,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
|||||||
50.0,
|
50.0,
|
||||||
tuv_delta_stats=None,
|
tuv_delta_stats=None,
|
||||||
operating_mode="AUTO",
|
operating_mode="AUTO",
|
||||||
price_failsafe_active=False,
|
|
||||||
)
|
)
|
||||||
self.assertEqual(len(results), 2)
|
self.assertEqual(len(results), 2)
|
||||||
|
|
||||||
@@ -206,7 +204,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
|||||||
50.0,
|
50.0,
|
||||||
tuv_delta_stats=None,
|
tuv_delta_stats=None,
|
||||||
operating_mode="AUTO",
|
operating_mode="AUTO",
|
||||||
price_failsafe_active=False,
|
|
||||||
)
|
)
|
||||||
self.assertGreaterEqual(results[0].grid_setpoint_w, 0)
|
self.assertGreaterEqual(results[0].grid_setpoint_w, 0)
|
||||||
|
|
||||||
@@ -247,7 +244,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
|||||||
50.0,
|
50.0,
|
||||||
tuv_delta_stats=None,
|
tuv_delta_stats=None,
|
||||||
operating_mode="AUTO",
|
operating_mode="AUTO",
|
||||||
price_failsafe_active=False,
|
|
||||||
)
|
)
|
||||||
reserve_pct = 20.0
|
reserve_pct = 20.0
|
||||||
for r in results:
|
for r in results:
|
||||||
@@ -302,7 +298,6 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
|||||||
50.0,
|
50.0,
|
||||||
tuv_delta_stats=None,
|
tuv_delta_stats=None,
|
||||||
operating_mode="AUTO",
|
operating_mode="AUTO",
|
||||||
price_failsafe_active=False,
|
|
||||||
)
|
)
|
||||||
self.assertEqual(len(results), 3)
|
self.assertEqual(len(results), 3)
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
@@ -312,5 +307,79 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalSocShadowTests(unittest.TestCase):
|
||||||
|
"""Terminal SoC shadow price v objective drží konec horizontu nad holým minimem."""
|
||||||
|
|
||||||
|
def test_terminal_soc_shadow_price_prevents_drain(self) -> None:
|
||||||
|
base = datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc)
|
||||||
|
slots = []
|
||||||
|
for i in range(3):
|
||||||
|
slots.append(
|
||||||
|
PlanningSlot(
|
||||||
|
interval_start=base + timedelta(minutes=15 * i),
|
||||||
|
buy_price=2.0,
|
||||||
|
sell_price=0.6,
|
||||||
|
pv_a_forecast_w=0,
|
||||||
|
pv_b_forecast_w=0,
|
||||||
|
load_baseline_w=600,
|
||||||
|
ev1_connected=False,
|
||||||
|
ev2_connected=False,
|
||||||
|
is_predicted_price=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
slots.append(
|
||||||
|
PlanningSlot(
|
||||||
|
interval_start=base + timedelta(minutes=45),
|
||||||
|
buy_price=2.0,
|
||||||
|
sell_price=14.0,
|
||||||
|
pv_a_forecast_w=0,
|
||||||
|
pv_b_forecast_w=0,
|
||||||
|
load_baseline_w=600,
|
||||||
|
ev1_connected=False,
|
||||||
|
ev2_connected=False,
|
||||||
|
is_predicted_price=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
battery = _battery(uc_wh=12_000.0, min_pct=12.0, arb_pct=20.0)
|
||||||
|
hp = SimpleNamespace(
|
||||||
|
rated_heating_power_w=0,
|
||||||
|
tuv_min_temp_c=45.0,
|
||||||
|
tuv_target_temp_c=55.0,
|
||||||
|
)
|
||||||
|
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
|
||||||
|
vehicles = [
|
||||||
|
SimpleNamespace(
|
||||||
|
max_charge_power_w=0,
|
||||||
|
battery_capacity_kwh=1.0,
|
||||||
|
default_target_soc_pct=80.0,
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
max_charge_power_w=0,
|
||||||
|
battery_capacity_kwh=1.0,
|
||||||
|
default_target_soc_pct=80.0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
soc0 = 0.5 * battery.usable_capacity_wh
|
||||||
|
results, _ms = solve_dispatch(
|
||||||
|
slots,
|
||||||
|
battery,
|
||||||
|
hp,
|
||||||
|
grid,
|
||||||
|
[None, None],
|
||||||
|
vehicles,
|
||||||
|
soc0,
|
||||||
|
50.0,
|
||||||
|
tuv_delta_stats=None,
|
||||||
|
operating_mode="AUTO",
|
||||||
|
)
|
||||||
|
self.assertEqual(len(results), 4)
|
||||||
|
# Bez shadow price by solver mohl končit u min SoC; kotva drží znatelnou rezervu.
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
results[-1].battery_soc_target,
|
||||||
|
15.0,
|
||||||
|
msg="terminal SoC shadow price should keep end-of-horizon SoC above bare minimum",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ BEGIN
|
|||||||
'backend/services/control_exporter.py (mapování na Deye)',
|
'backend/services/control_exporter.py (mapování na Deye)',
|
||||||
'CLAUDE.md bod 15–19 (baseline, horizont, režimy, MILP export)'
|
'CLAUDE.md bod 15–19 (baseline, horizont, režimy, MILP export)'
|
||||||
],
|
],
|
||||||
'slot_weights_hours_from_horizon_start',
|
'horizon_and_objective',
|
||||||
'0–36: váha 1.0; 36–72: 0.7; 72–96: 0.4 (účelovka LP; sloupec hours_from_plan_horizon_start u každého intervalu).'
|
'Dynamický horizont: ems.fn_planning_horizon_end (OTE + strop/min v SQL). Účelovka LP bez vzdálenostních vah; terminal SoC shadow price (avg buy × 0,9 / 1000 × soc_end). Sloupec hours_from_plan_horizon_start u intervalů jen pro čtení.'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
|
|||||||
51
db/routines/R__074_fn_last_effective_ote.sql
Normal file
51
db/routines/R__074_fn_last_effective_ote.sql
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
-- Poslední konec 15min intervalu s efektivní OTE cenou pro site (dynamický horizont plánu).
|
||||||
|
|
||||||
|
create or replace function ems.fn_last_effective_ote(p_site_id int)
|
||||||
|
returns timestamptz
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select max(interval_end)
|
||||||
|
from ems.vw_site_effective_price
|
||||||
|
where site_id = p_site_id
|
||||||
|
and effective_buy_price_czk_kwh is not null
|
||||||
|
and effective_sell_price_czk_kwh is not null;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
comment on function ems.fn_last_effective_ote(int) is
|
||||||
|
'Nejvzdálenější konec intervalu s efektivní nákupní i prodejní cenou (OTE přes vw_site_effective_price).
|
||||||
|
Používá planning_engine pro dynamický horizont – jen reálná data, bez predikce.';
|
||||||
|
|
||||||
|
|
||||||
|
-- Konec horizontu pro LP: min(konec OTE, from + max_h), NULL pokud je k dispozici méně než min_h dat.
|
||||||
|
|
||||||
|
create or replace function ems.fn_planning_horizon_end(
|
||||||
|
p_site_id int,
|
||||||
|
p_horizon_from timestamptz,
|
||||||
|
p_max_hours numeric default 36,
|
||||||
|
p_min_hours numeric default 1
|
||||||
|
)
|
||||||
|
returns timestamptz
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
declare
|
||||||
|
v_last timestamptz;
|
||||||
|
v_cap timestamptz;
|
||||||
|
v_min_end timestamptz;
|
||||||
|
begin
|
||||||
|
v_last := ems.fn_last_effective_ote(p_site_id);
|
||||||
|
if v_last is null then
|
||||||
|
return null;
|
||||||
|
end if;
|
||||||
|
v_cap := least(v_last, p_horizon_from + p_max_hours * interval '1 hour');
|
||||||
|
v_min_end := p_horizon_from + p_min_hours * interval '1 hour';
|
||||||
|
if v_cap < v_min_end then
|
||||||
|
return null;
|
||||||
|
end if;
|
||||||
|
return v_cap;
|
||||||
|
end;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_planning_horizon_end(int, timestamptz, numeric, numeric) is
|
||||||
|
'Konec plánovacího okna: min(poslední OTE interval, p_horizon_from + p_max_hours). NULL pokud OTE končí dřív než p_min_hours od p_horizon_from (rolling se přeskočí). Výchozí strop 36 h, min 1 h – měnit přes volitelné argumenty nebo novou verzi funkce.';
|
||||||
@@ -92,7 +92,6 @@ services:
|
|||||||
EUR_CZK_RATE: ${EUR_CZK_RATE:-25.0}
|
EUR_CZK_RATE: ${EUR_CZK_RATE:-25.0}
|
||||||
OPEN_METEO_API_URL: ${OPEN_METEO_API_URL:-https://api.open-meteo.com/v1/forecast}
|
OPEN_METEO_API_URL: ${OPEN_METEO_API_URL:-https://api.open-meteo.com/v1/forecast}
|
||||||
TELEMETRY_POLL_INTERVAL_SEC: ${TELEMETRY_POLL_INTERVAL_SEC:-60}
|
TELEMETRY_POLL_INTERVAL_SEC: ${TELEMETRY_POLL_INTERVAL_SEC:-60}
|
||||||
PLANNING_HORIZON_HOURS: ${PLANNING_HORIZON_HOURS:-36}
|
|
||||||
PLANNING_HP_MAX_COST_CZK_KWH: ${PLANNING_HP_MAX_COST_CZK_KWH:-3.0}
|
PLANNING_HP_MAX_COST_CZK_KWH: ${PLANNING_HP_MAX_COST_CZK_KWH:-3.0}
|
||||||
LOXONE_USER: ${LOXONE_USER:-}
|
LOXONE_USER: ${LOXONE_USER:-}
|
||||||
LOXONE_PASSWORD: ${LOXONE_PASSWORD:-}
|
LOXONE_PASSWORD: ${LOXONE_PASSWORD:-}
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ services:
|
|||||||
EUR_CZK_RATE: ${EUR_CZK_RATE:-25.0}
|
EUR_CZK_RATE: ${EUR_CZK_RATE:-25.0}
|
||||||
OPEN_METEO_API_URL: ${OPEN_METEO_API_URL:-https://api.open-meteo.com/v1/forecast}
|
OPEN_METEO_API_URL: ${OPEN_METEO_API_URL:-https://api.open-meteo.com/v1/forecast}
|
||||||
TELEMETRY_POLL_INTERVAL_SEC: ${TELEMETRY_POLL_INTERVAL_SEC:-60}
|
TELEMETRY_POLL_INTERVAL_SEC: ${TELEMETRY_POLL_INTERVAL_SEC:-60}
|
||||||
PLANNING_HORIZON_HOURS: ${PLANNING_HORIZON_HOURS:-36}
|
|
||||||
PLANNING_HP_MAX_COST_CZK_KWH: ${PLANNING_HP_MAX_COST_CZK_KWH:-3.0}
|
PLANNING_HP_MAX_COST_CZK_KWH: ${PLANNING_HP_MAX_COST_CZK_KWH:-3.0}
|
||||||
LOXONE_USER: ${LOXONE_USER:-}
|
LOXONE_USER: ${LOXONE_USER:-}
|
||||||
LOXONE_PASSWORD: ${LOXONE_PASSWORD:-}
|
LOXONE_PASSWORD: ${LOXONE_PASSWORD:-}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
|
|
||||||
FastAPI endpointy pro dashboard a konfiguraci preferují **jedno volání** `select ems.fn_*(…)` vracející **jsonb** (pole řádků, agregace, merge locků), aby v Pythonu nezůstávaly ad-hoc `SELECT`/`JOIN`/`WITH`. Pomocník `app.db_json.fetch_json` vrací `dict`/`list`. Telemetrie a IO zůstávají v Pythonu; čisté agregace a sjednocení TZ patří do SQL. Opakované migrace: `db/routines/R__NNN_fn_*.sql`, `db/views/R__NNN_vw_*.sql` (prefix `NNN` = pořadí závislostí pro Flyway).
|
FastAPI endpointy pro dashboard a konfiguraci preferují **jedno volání** `select ems.fn_*(…)` vracející **jsonb** (pole řádků, agregace, merge locků), aby v Pythonu nezůstávaly ad-hoc `SELECT`/`JOIN`/`WITH`. Pomocník `app.db_json.fetch_json` vrací `dict`/`list`. Telemetrie a IO zůstávají v Pythonu; čisté agregace a sjednocení TZ patří do SQL. Opakované migrace: `db/routines/R__NNN_fn_*.sql`, `db/views/R__NNN_vw_*.sql` (prefix `NNN` = pořadí závislostí pro Flyway).
|
||||||
|
|
||||||
|
**SQL-first (stejné pravidlo jako v `CLAUDE.md`):** doménová logika ve funkcích a view ve schématu `ems`; Python neskladá vlastní joiny nad tabulkami — nová data z více zdrojů = nová `fn_*` nebo `vw_*`, ne řetězení SQL v aplikaci.
|
||||||
|
|
||||||
**Health, joby po aktivních lokalitách a Loxone po změně režimu** jsou v repeatable [`db/routines/R__073_fn_health_site_jobs_mode_bundle.sql`](../db/routines/R__073_fn_health_site_jobs_mode_bundle.sql): `fn_health_summary`, `fn_health_detailed_db`, `fn_vw_site_directory_active`, `fn_site_economics_yesterday_notification`, `fn_site_mode_loxone_bundle`. FastAPI je rozdělené: [`backend/app/main.py`](../backend/app/main.py) (routery, CORS, WebSockety, health, `POST …/mode`) a [`backend/app/lifespan.py`](../backend/app/lifespan.py) (DB pool, APScheduler joby, telemetrická smyčka).
|
**Health, joby po aktivních lokalitách a Loxone po změně režimu** jsou v repeatable [`db/routines/R__073_fn_health_site_jobs_mode_bundle.sql`](../db/routines/R__073_fn_health_site_jobs_mode_bundle.sql): `fn_health_summary`, `fn_health_detailed_db`, `fn_vw_site_directory_active`, `fn_site_economics_yesterday_notification`, `fn_site_mode_loxone_bundle`. FastAPI je rozdělené: [`backend/app/main.py`](../backend/app/main.py) (routery, CORS, WebSockety, health, `POST …/mode`) a [`backend/app/lifespan.py`](../backend/app/lifespan.py) (DB pool, APScheduler joby, telemetrická smyčka).
|
||||||
|
|
||||||
## Komponenty
|
## Komponenty
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
- Telemetrie a plány mají vždy `site_id` + `interval_start`
|
- Telemetrie a plány mají vždy `site_id` + `interval_start`
|
||||||
- TimescaleDB hypertable pro časové série (telemetrie, ceny, plány)
|
- TimescaleDB hypertable pro časové série (telemetrie, ceny, plány)
|
||||||
- Primární časová granularita: **15 minut**
|
- Primární časová granularita: **15 minut**
|
||||||
|
- Čtení a doménová logika z DB preferuj **`ems.fn_*`** a **`ems.vw_*`** (SQL-first; detail a výjimky v `CLAUDE.md` → sekce SQL-first).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
# Rozšířený horizont plánování (96h)
|
# Plánování: historie 96h horizontu a budoucí rozšíření
|
||||||
|
|
||||||
|
> **Historické (do 2026-04):** produkční solver používal horizont až **96 h** s predikcí cen za hranicí OTE a váhami 1,0 / 0,7 / 0,4 v účelové funkci. Od **2026-04** je horizont **dynamický pouze z OTE** (`ems.fn_planning_horizon_end` / `fn_last_effective_ote`), bez predikovaných cen v LP a s **terminal SoC shadow price**; limity stropu a min. délky jsou v SQL, ne v env. Níže zůstává popis původního návrhu pro referenci a budoucí rozšíření (např. fáze 3e počasí).
|
||||||
|
|
||||||
## Motivace
|
## Motivace
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,13 @@
|
|||||||
|
|
||||||
**PuLP + HiGHS solver** – lineární programování (LP) s uvolněním binárních proměnných.
|
**PuLP + HiGHS solver** – lineární programování (LP) s uvolněním binárních proměnných.
|
||||||
|
|
||||||
### Implementované provozní změny (2026-03)
|
### Implementované provozní změny (2026-03, aktualizace 2026-04)
|
||||||
|
|
||||||
- **Strict price fail-safe:**
|
- **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*.
|
||||||
- pokud v prvních 36h chybí OTE data (sloty jsou predikované), solver zapíná fail-safe režim,
|
- **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.
|
||||||
- v predikovaných slotech (`is_predicted_price=true`) je zakázán export do sítě,
|
- **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).
|
||||||
- baterie se ale dál používá standardně pro interní spotřebu (nabíjení i vybíjení do domu je povoleno).
|
- **Runtime guard v exportu setpointů (legacy):**
|
||||||
- **Runtime guard v exportu setpointů:**
|
- 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).
|
||||||
- při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování.
|
|
||||||
- **Ekonomika baterie:**
|
- **Ekonomika baterie:**
|
||||||
- `min_soc_percent` = nejnižší SoC v LP a runtime clamp telemetrie; u **více paralelních stringů** držet **nad** holým BMS minimem (typicky **11–12 %**; migrace **V029** + komentář v DB, u `home-01` cílený UPDATE z 10 %),
|
- `min_soc_percent` = nejnižší SoC v LP a runtime clamp telemetrie; u **více paralelních stringů** držet **nad** holým BMS minimem (typicky **11–12 %**; migrace **V029** + komentář v DB, u `home-01` cílený UPDATE z 10 %),
|
||||||
- `reserve_soc_percent` = ekonomická („arbitrážní“) podlaha – pod ní MILP s `w_arb` omezuje vybíjení podle začátku slotu a FVE lookahead (`arb_floor_series`; typicky 20 %),
|
- `reserve_soc_percent` = ekonomická („arbitrážní“) podlaha – pod ní MILP s `w_arb` omezuje vybíjení podle začátku slotu a FVE lookahead (`arb_floor_series`; typicky 20 %),
|
||||||
@@ -29,7 +28,7 @@
|
|||||||
- **Uložené vstupy plánu** (`planning_interval`): `load_baseline_w`, `pv_*_forecast_raw_w`, `pv_*_forecast_solver_w` pro UI a audit.
|
- **Uložené vstupy plánu** (`planning_interval`): `load_baseline_w`, `pv_*_forecast_raw_w`, `pv_*_forecast_solver_w` pro UI a audit.
|
||||||
- **Více FVE polí s různou orientací:** `planning_engine._load_slots` sčítá predikovaný výkon za 15min přes **všechna** `asset_pv_array` dané lokality — `pv_a_forecast_w` = součet řádků s `controllable = true`, `pv_b_forecast_w` = součet s `controllable = false`. Pro každé pole a slot se bere **nejnovější** `forecast_pv_run` (`ORDER BY created_at DESC`, `DISTINCT ON (pv_array_id)`). Curtailment v LP zůstává **jedno** agregované `pv_a` (součet řiditelných polí); per-string curtailment by vyžadovalo rozšíření modelu.
|
- **Více FVE polí s různou orientací:** `planning_engine._load_slots` sčítá predikovaný výkon za 15min přes **všechna** `asset_pv_array` dané lokality — `pv_a_forecast_w` = součet řádků s `controllable = true`, `pv_b_forecast_w` = součet s `controllable = false`. Pro každé pole a slot se bere **nejnovější** `forecast_pv_run` (`ORDER BY created_at DESC`, `DISTINCT ON (pv_array_id)`). Curtailment v LP zůstává **jedno** agregované `pv_a` (součet řiditelných polí); per-string curtailment by vyžadovalo rozšíření modelu.
|
||||||
|
|
||||||
Solver optimalizuje celý horizont (typicky 36h) najednou, čímž přirozeně zvládá:
|
Solver optimalizuje celý horizont (typicky do konce známých OTE dat, strop z `fn_planning_horizon_end`) najednou, čímž přirozeně zvládá:
|
||||||
- pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie)
|
- pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie)
|
||||||
- kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu
|
- kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu
|
||||||
|
|
||||||
@@ -429,7 +428,6 @@ COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS
|
|||||||
## Konfigurace (env proměnné)
|
## Konfigurace (env proměnné)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
PLANNING_HORIZON_HOURS=36
|
|
||||||
PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout
|
PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout
|
||||||
PLANNING_CURTAILMENT_PENALTY=0.001 # Kč/Wh penalizace za omezení FVE
|
PLANNING_CURTAILMENT_PENALTY=0.001 # Kč/Wh penalizace za omezení FVE
|
||||||
PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu
|
PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec
|
|||||||
| Popis |
|
| Popis |
|
||||||
|-------|
|
|-------|
|
||||||
| **Zelený bonus:** přesunuto na `asset_pv_array` (`green_bonus_*`), výpočet `fn_green_bonus_revenue()`, audit_filler (`fn_fill_audit_interval`) počítá bonus z výroby pole; legacy sloupce odstraněny ze `site_market_config` (V018). |
|
| **Zelený bonus:** přesunuto na `asset_pv_array` (`green_bonus_*`), výpočet `fn_green_bonus_revenue()`, audit_filler (`fn_fill_audit_interval`) počítá bonus z výroby pole; legacy sloupce odstraněny ze `site_market_config` (V018). |
|
||||||
| **Rozšířený horizont plánování 96h** (fáze 3a+3b+3c): tabulky `market_price_stats`, `tuv_usage_stats`, funkce `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price` (V022 + `R__018_fn_extended_planning.sql`), solver váhy 1,0 / 0,7 / 0,4, joby 14:45 / 00:45 v `main.py`. |
|
| **Rozšířený horizont plánování 96h** (fáze 3a+3b+3c): tabulky `market_price_stats`, `tuv_usage_stats`, funkce `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price` (V022 + `R__018_fn_extended_planning.sql`), joby 14:45 / 00:45. **Aktualizace 2026-04:** ve solveru už není 96h ani váhy 1,0/0,7/0,4 — horizont je **dynamický jen z OTE** (`fn_planning_horizon_end`, `fn_last_effective_ote` v `R__074`), terminal SoC v LP; predikce zůstává pro statistiky / budoucí fáze. |
|
||||||
| **Import OTE – robustní provoz:** timeouty + retry/backoff v `price_importer.py`, detailní error kódy v API, fallback D+1 → dnešek, scheduler importů 13:30 / 14:00 / 00:05. |
|
| **Import OTE – robustní provoz:** timeouty + retry/backoff v `price_importer.py`, detailní error kódy v API, fallback D+1 → dnešek, scheduler importů 13:30 / 13:15+13:45+14:15+14:45 / 14:00 / 00:05 (`lifespan.py`). |
|
||||||
| **Fail-safe bez OTE dat:** při predikovaných cenách v kritickém okně je zákaz exportu; vybíjení baterie omezeno jen v predikovaných slotech; runtime guard v `control_exporter.py` brání SELL v nejistém stavu. |
|
| **Fail-safe bez OTE dat (legacy):** runtime guard v `control_exporter.py` při `is_predicted_price`; u plánů jen z OTE by flag neměl nastat. Historicky: zákaz exportu v predikovaných slotech ve solveru. |
|
||||||
| **Forecast provoz:** refresh každé 2 hodiny (`:05`), konfigurovatelný Open-Meteo horizont (`OPEN_METEO_FORECAST_DAYS`, default 7, clamp 2..16), endpoint pro UI vrací latest-run bez duplicit slotů. |
|
| **Forecast provoz:** refresh každé 2 hodiny (`:05`), konfigurovatelný Open-Meteo horizont (`OPEN_METEO_FORECAST_DAYS`, default 7, clamp 2..16), endpoint pro UI vrací latest-run bez duplicit slotů. |
|
||||||
| **Telemetry – výroba FVE:** Registry 672/673/667 jsou **signed** W; `pv_power_w` = max(0,pv1)+max(0,pv2)+max(0,gen) (dashboard); sloupce pv1/pv2/gen ukládají signed pro audit. |
|
| **Telemetry – výroba FVE:** Registry 672/673/667 jsou **signed** W; `pv_power_w` = max(0,pv1)+max(0,pv2)+max(0,gen) (dashboard); sloupce pv1/pv2/gen ukládají signed pro audit. |
|
||||||
| **Ekonomika baterie:** snížení `reserve_soc_percent` na 10 % a `degradation_cost_czk_kwh` na 0.1500 (migrace `V026__battery_economics_tuning.sql`), úpravy objective pro ekonomicky konzistentnější nabíjení/vybíjení. |
|
| **Ekonomika baterie:** snížení `reserve_soc_percent` na 10 % a `degradation_cost_czk_kwh` na 0.1500 (migrace `V026__battery_economics_tuning.sql`), úpravy objective pro ekonomicky konzistentnější nabíjení/vybíjení. |
|
||||||
@@ -25,7 +25,7 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec
|
|||||||
|
|
||||||
| Popis | Kde | Kdo |
|
| Popis | Kde | Kdo |
|
||||||
|-------|-----|-----|
|
|-------|-----|-----|
|
||||||
| **EV v rozšířeném horizontu** (pravděpodobnost příjezdu, deadline přes 96h) – závisí na Tesla API / rozšíření modelu. | `docs/04-modules/planning-extended-horizon.md` | programátor |
|
| **EV v rozšířeném horizontu** (pravděpodobnost příjezdu, dlouhé deadline) – závisí na Tesla API / rozšíření modelu (dříve „deadline přes 96h“). | `docs/04-modules/planning-extended-horizon.md` | programátor |
|
||||||
| **Korekce predikce cen počasím** – potřeba 3+ měsíce korelačních dat. | stejný modul | programátor |
|
| **Korekce predikce cen počasím** – potřeba 3+ měsíce korelačních dat. | stejný modul | programátor |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Dokument z code review před SQL-first refaktorem. Cíl produktu: maximalizovat
|
|||||||
- **Slot pre-selection** (`charge_slot_buffer`, `discharge_slot_buffer`) – omezuje množinu slotů pro nabíjení z sítě / vybíjení na export; snižuje mikro-cyklování.
|
- **Slot pre-selection** (`charge_slot_buffer`, `discharge_slot_buffer`) – omezuje množinu slotů pro nabíjení z sítě / vybíjení na export; snižuje mikro-cyklování.
|
||||||
- **Dynamická arbitrážní podlaha** (`_dynamic_arb_floor_wh_series`, `ARB_LOOKAHEAD_SLOTS = 32` = 8 h) – posouvá ekonomickou podlahu mezi `min_soc_wh` a rezervou podle očekávané FVE energie vpřed; lookahead je **hard-coded** v Pythonu – kandidát na přesun do DB (`planning_config`).
|
- **Dynamická arbitrážní podlaha** (`_dynamic_arb_floor_wh_series`, `ARB_LOOKAHEAD_SLOTS = 32` = 8 h) – posouvá ekonomickou podlahu mezi `min_soc_wh` a rezervou podle očekávané FVE energie vpřed; lookahead je **hard-coded** v Pythonu – kandidát na přesun do DB (`planning_config`).
|
||||||
- **`pv_scarcity_factor`** (0.65–1.0) – mění násobek degradace podle poměru očekávané FVE energie k kapacitě baterie; **úzký rozsah** = slabý signál; možné rozšíření v budoucnu.
|
- **`pv_scarcity_factor`** (0.65–1.0) – mění násobek degradace podle poměru očekávané FVE energie k kapacitě baterie; **úzký rozsah** = slabý signál; možné rozšíření v budoucnu.
|
||||||
- **Horizont a váhy slotů** (`SLOT_WEIGHT_FULL/MEDIUM/LOW` = 1.0 / 0.7 / 0.4) – hard-coded; vzdálenější sloty mají menší váhu v objective → konzervativnější chování k predikovaným cenám.
|
- **Horizont plánu (2026-04):** dynamický konec z OTE (`fn_planning_horizon_end`), žádné váhy 1,0/0,7/0,4 ve solveru; terminal SoC shadow price v LP. Dříve: váhy slotů + 96h predikce ve `planning_engine` (viz `planning-extended-horizon.md` historie).
|
||||||
|
|
||||||
### Zelený bonus (pole B)
|
### Zelený bonus (pole B)
|
||||||
|
|
||||||
@@ -25,11 +25,11 @@ Dokument z code review před SQL-first refaktorem. Cíl produktu: maximalizovat
|
|||||||
|
|
||||||
- `fn_battery_cycle_audit` + `vw_battery_cycle_daily` – ekvivalent plných cyklů z `telemetry_inverter` pro monitoring a ladění `degradation_cost_czk_kwh`, **ne** nový hard constraint v LP.
|
- `fn_battery_cycle_audit` + `vw_battery_cycle_daily` – ekvivalent plných cyklů z `telemetry_inverter` pro monitoring a ladění `degradation_cost_czk_kwh`, **ne** nový hard constraint v LP.
|
||||||
|
|
||||||
## SQL vs Python (stav před refaktorem)
|
## SQL vs Python (stav před / během refaktoru)
|
||||||
|
|
||||||
| Oblast | Problém |
|
| Oblast | Problém |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `planning_engine` | Velké inline `SELECT` (`_load_slots`, `_load_site_context`), `compute_correction_factor`, `_save_planning_run`, f-string CTE pro slot boundary |
|
| `planning_engine` | `_load_slots` / `_load_site_context` / `_save_planning_run` jdou přes `fn_*` (horizont přes `fn_planning_horizon_end`). Zůstává: PuLP, korekce FVE v Pythonu, slot boundary pokud ještě není ve fn |
|
||||||
| `control_exporter` | `DISTINCT ON` journal, interpolace SQL pro plán slotu, pack hodin/TOU v Pythonu |
|
| `control_exporter` | `DISTINCT ON` journal, interpolace SQL pro plán slotu, pack hodin/TOU v Pythonu |
|
||||||
| Routery | Mnoho po sobě jdoucích dotazů (`site_configuration`, `full_status`), running sumy v Pythonu (`economics`), split FVE A/B v Pythonu |
|
| Routery | Mnoho po sobě jdoucích dotazů (`site_configuration`, `full_status`), running sumy v Pythonu (`economics`), split FVE A/B v Pythonu |
|
||||||
| `price_importer` | Mix `::date` vs den v `Europe/Prague` u statistik OTE |
|
| `price_importer` | Mix `::date` vs den v `Europe/Prague` u statistik OTE |
|
||||||
@@ -37,7 +37,7 @@ Dokument z code review před SQL-first refaktorem. Cíl produktu: maximalizovat
|
|||||||
## Cílová hranice po refaktoru
|
## Cílová hranice po refaktoru
|
||||||
|
|
||||||
- **Python:** PuLP solver, orchestrace jobů, Modbus/HTTP/Discord, pvlib forecast.
|
- **Python:** PuLP solver, orchestrace jobů, Modbus/HTTP/Discord, pvlib forecast.
|
||||||
- **PostgreSQL:** čtení/zápis dat přes `ems.fn_*` a `ems.vw_*`; read-modely jako JSONB bundles.
|
- **PostgreSQL:** čtení/zápis dat přes `ems.fn_*` a `ems.vw_*`; read-modely jako JSONB bundles; **žádné vlastní JOINy v Pythonu** nad tabulkami — viz **`CLAUDE.md` (SQL-first)**.
|
||||||
|
|
||||||
## Odkazy
|
## Odkazy
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user