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).
- **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í 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)

View File

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

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`.
### 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 **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`.
@@ -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` |

View File

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

View File

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

View File

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

View File

@@ -179,8 +179,8 @@ BEGIN
'backend/services/control_exporter.py (mapování na Deye)',
'CLAUDE.md bod 1519 (baseline, horizont, režimy, MILP export)'
],
'slot_weights_hours_from_horizon_start',
'036: váha 1.0; 3672: 0.7; 7296: 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;

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

View File

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

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

View File

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

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

View File

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

View File

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

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í.
- **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.
- **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