zkraceni intervalu planneru na max 35h
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-19 21:09:48 +02:00
parent e33207f3fa
commit f48a7aad61
16 changed files with 247 additions and 91 deletions

View File

@@ -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í 036h / 3672h / 7296h 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 036h / 3672h / 7296h 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)

View File

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

View File

@@ -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 **3696h** 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** (036h), **0,7** (3672h), **0,4** (7296h). 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[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`.
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`. 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`.
@@ -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` |

View File

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

View File

@@ -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 # 036h od začátku okna (přesné OTE ceny)
SLOT_WEIGHT_MEDIUM = 0.7 # 3672h
SLOT_WEIGHT_LOW = 0.4 # 7296h
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

View File

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

View File

@@ -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 1519 (baseline, horizont, režimy, MILP export)' 'CLAUDE.md bod 1519 (baseline, horizont, režimy, MILP export)'
], ],
'slot_weights_hours_from_horizon_start', 'horizon_and_objective',
'036: váha 1.0; 3672: 0.7; 7296: 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;

View 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.';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[T1]` (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 **1112 %**; 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 **1112 %**; 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

View File

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

View File

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