diff --git a/.cursor/rules/plan-explain-bundle.mdc b/.cursor/rules/plan-explain-bundle.mdc index 3ecefcc..2c5dad5 100644 --- a/.cursor/rules/plan-explain-bundle.mdc +++ b/.cursor/rules/plan-explain-bundle.mdc @@ -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). - **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`. -- **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) diff --git a/.env.example b/.env.example index bcddab5..4bb42f9 100644 --- a/.env.example +++ b/.env.example @@ -42,7 +42,7 @@ DISCORD_WEBHOOK_URL= # Discord webhook URL pro alerty, prázdné = vypnuto TELEMETRY_POLL_INTERVAL_SEC=60 # ---- 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_CHEAP_PRICE_THRESHOLD=0.85 PLANNING_EXPENSIVE_PRICE_THRESHOLD=1.15 diff --git a/CLAUDE.md b/CLAUDE.md index 3af4875..f2bd9d9 100644 --- a/CLAUDE.md +++ b/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`. -### 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). ### 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`. -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`. @@ -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). | | `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` | | `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` | -| `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 | | `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` | @@ -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` | | 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` | -| 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` | | 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` | diff --git a/backend/app/lifespan.py b/backend/app/lifespan.py index b740425..b3f9391 100644 --- a/backend/app/lifespan.py +++ b/backend/app/lifespan.py @@ -367,6 +367,22 @@ async def lifespan(app: FastAPI): id="ote_import_preopen", 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( scheduled_ote_import, "cron", diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 22cd686..7b2be3c 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -3,13 +3,14 @@ # EMS Platform – plánovací engine # 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_rolling_replan, 'cron', minute='*/15') +# Horizont: ems.fn_planning_horizon_end (OTE + strop/min v SQL). import json -import time import logging +import time from dataclasses import dataclass, replace from datetime import datetime, timezone, timedelta from types import SimpleNamespace @@ -25,11 +26,11 @@ logger = logging.getLogger(__name__) # 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 -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 SOLVER_TIME_LIMIT = 10 # sekund # 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") -def slot_weight(slot_index: int, now_index: int = 0) -> float: - """Váha slotu v účelové funkci podle vzdálenosti od začátku optimalizačního okna.""" - hours_ahead = (slot_index - now_index) * INTERVAL_H - if hours_ahead <= 36: - return SLOT_WEIGHT_FULL - if hours_ahead <= 72: - return SLOT_WEIGHT_MEDIUM - return SLOT_WEIGHT_LOW +def _timestamptz_from_db(val: object) -> Optional[datetime]: + if val is None: + return None + if isinstance(val, datetime): + return val if val.tzinfo else val.replace(tzinfo=timezone.utc) + return datetime.fromisoformat(str(val).replace("Z", "+00:00")) + + +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: @@ -282,15 +291,15 @@ def solve_dispatch( current_tuv_temp_c: float, *, tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None, - now_slot_index: int = 0, operating_mode: str = "AUTO", - price_failsafe_active: bool = False, ) -> tuple[list[DispatchResult], int]: """ LP solver pro dispatch optimalizaci. Vrátí (výsledky, solver_duration_ms). """ T = len(slots) + if T < 1: + raise RuntimeError("solve_dispatch requires at least one slot") EV = len(vehicles) # počet EV (typicky 2) EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency) @@ -339,13 +348,22 @@ def solve_dispatch( vehicles[e].max_charge_power_w) for t in range(T)] for e in range(EV)] - # --- Účelová funkce (váhy slotů podle nejistoty za horizontem OTE) --- - prob += pulp.lpSum( - slot_weight(t, now_slot_index) * ( + horizon_slots_h24 = min(T, int(24 / INTERVAL_H)) + avg_buy_terminal = ( + 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 - 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 + pulp.lpSum( ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000 @@ -353,9 +371,11 @@ def solve_dispatch( for e in range(EV) ) + 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í --- for t in range(T): @@ -438,11 +458,6 @@ def solve_dispatch( prob += ge[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_*) if om == "AUTO": 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) 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) 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}") @@ -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) ) 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( slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, operating_mode=operating_mode or "AUTO", - price_failsafe_active=price_failsafe_active, ) 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") return await run_daily_plan(site_id, db, triggered_by=triggered_by) - he = ar["horizon_end"] - if isinstance(he, datetime): - horizon_to = he if he.tzinfo else he.replace(tzinfo=timezone.utc) - else: - horizon_to = datetime.fromisoformat(str(he).replace("Z", "+00:00")) + horizon_to = await _planning_horizon_end(site_id, replan_from, db) + if horizon_to is None: + if allow_skip: + logger.info( + "[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 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_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) @@ -680,7 +691,6 @@ async def run_rolling_replan( slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, operating_mode=operating_mode or "AUTO", - price_failsafe_active=price_failsafe_active, ) slot_inputs = _build_slot_inputs(slots_before_pv_correction, slots) @@ -914,6 +924,11 @@ async def _load_slots( raise RuntimeError( "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 diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 15e5171..3366de5 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -3,7 +3,7 @@ from __future__ import annotations import unittest -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from types import SimpleNamespace from services.planning_engine import ( @@ -128,7 +128,6 @@ class PlanningDispatchMilpTests(unittest.TestCase): 50.0, tuv_delta_stats=None, operating_mode="AUTO", - price_failsafe_active=False, ) self.assertGreaterEqual(ms, 0) self.assertEqual(len(results), 1) @@ -169,7 +168,6 @@ class PlanningDispatchMilpTests(unittest.TestCase): 50.0, tuv_delta_stats=None, operating_mode="AUTO", - price_failsafe_active=False, ) self.assertEqual(len(results), 2) @@ -206,7 +204,6 @@ class PlanningDispatchMilpTests(unittest.TestCase): 50.0, tuv_delta_stats=None, operating_mode="AUTO", - price_failsafe_active=False, ) self.assertGreaterEqual(results[0].grid_setpoint_w, 0) @@ -247,7 +244,6 @@ class PlanningDispatchMilpTests(unittest.TestCase): 50.0, tuv_delta_stats=None, operating_mode="AUTO", - price_failsafe_active=False, ) reserve_pct = 20.0 for r in results: @@ -302,7 +298,6 @@ class PlanningDispatchMilpTests(unittest.TestCase): 50.0, tuv_delta_stats=None, operating_mode="AUTO", - price_failsafe_active=False, ) self.assertEqual(len(results), 3) 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__": unittest.main() diff --git a/db/routines/R__034_fn_plan_explain_bundle.sql b/db/routines/R__034_fn_plan_explain_bundle.sql index 8192a8b..ed47584 100644 --- a/db/routines/R__034_fn_plan_explain_bundle.sql +++ b/db/routines/R__034_fn_plan_explain_bundle.sql @@ -179,8 +179,8 @@ BEGIN 'backend/services/control_exporter.py (mapování na Deye)', 'CLAUDE.md bod 15–19 (baseline, horizont, režimy, MILP export)' ], - 'slot_weights_hours_from_horizon_start', - '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).' + 'horizon_and_objective', + '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; diff --git a/db/routines/R__074_fn_last_effective_ote.sql b/db/routines/R__074_fn_last_effective_ote.sql new file mode 100644 index 0000000..53d675b --- /dev/null +++ b/db/routines/R__074_fn_last_effective_ote.sql @@ -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.'; diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index aa2ec68..fff76b0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -92,7 +92,6 @@ services: EUR_CZK_RATE: ${EUR_CZK_RATE:-25.0} OPEN_METEO_API_URL: ${OPEN_METEO_API_URL:-https://api.open-meteo.com/v1/forecast} 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} LOXONE_USER: ${LOXONE_USER:-} LOXONE_PASSWORD: ${LOXONE_PASSWORD:-} diff --git a/docker-compose.yml b/docker-compose.yml index bf1cceb..a31fad7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,7 +81,6 @@ services: EUR_CZK_RATE: ${EUR_CZK_RATE:-25.0} OPEN_METEO_API_URL: ${OPEN_METEO_API_URL:-https://api.open-meteo.com/v1/forecast} 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} LOXONE_USER: ${LOXONE_USER:-} LOXONE_PASSWORD: ${LOXONE_PASSWORD:-} diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 5992357..cce8544 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -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). +**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). ## Komponenty diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 907777e..f484b35 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -7,6 +7,7 @@ - Telemetrie a plány mají vždy `site_id` + `interval_start` - TimescaleDB hypertable pro časové série (telemetrie, ceny, plány) - 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). --- diff --git a/docs/04-modules/planning-extended-horizon.md b/docs/04-modules/planning-extended-horizon.md index 182c8de..0dbae2d 100644 --- a/docs/04-modules/planning-extended-horizon.md +++ b/docs/04-modules/planning-extended-horizon.md @@ -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 diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 753e57e..d021e45 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -4,14 +4,13 @@ **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:** - - pokud v prvních 36h chybí OTE data (sloty jsou predikované), solver zapíná fail-safe režim, - - v predikovaných slotech (`is_predicted_price=true`) je zakázán export do sítě, - - 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ů:** - - při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování. +- **SQL-first:** horizont a sloty z DB funkcí (`fn_planning_horizon_end`, `fn_load_planning_slots_full`, …); viz **`CLAUDE.md`** → sekce *SQL-first a read-model*. +- **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** – obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu. +- **Terminal SoC shadow price:** v objective je člen `−(avg_buy_prvních_24h × 0,9 / 1000) × soc[T−1]` (Kč), aby konec horizontu nekončil zbytečně vyprázdněnou baterií (receding horizon). +- **Runtime guard v exportu setpointů (legacy):** + - při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování (u nových plánů by `is_predicted_price` v horizontu nemělo nastat). - **Ekonomika baterie:** - `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 %), @@ -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. - **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) - 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é) ```env -PLANNING_HORIZON_HOURS=36 PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout 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 diff --git a/docs/05-todo.md b/docs/05-todo.md index 1df7981..0cf3968 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -11,9 +11,9 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec | 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). | -| **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`. | -| **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. | -| **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. | +| **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 / 13:15+13:45+14:15+14:45 / 14:00 / 00:05 (`lifespan.py`). | +| **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ů. | | **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í. | @@ -25,7 +25,7 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec | 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 | --- diff --git a/docs/refactor-weaknesses.md b/docs/refactor-weaknesses.md index 7100462..fef24c4 100644 --- a/docs/refactor-weaknesses.md +++ b/docs/refactor-weaknesses.md @@ -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í. - **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. -- **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) @@ -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. -## SQL vs Python (stav před refaktorem) +## SQL vs Python (stav před / během refaktoru) | 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 | | 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 | @@ -37,7 +37,7 @@ Dokument z code review před SQL-first refaktorem. Cíl produktu: maximalizovat ## Cílová hranice po refaktoru - **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