diff --git a/.cursor/plans/planner-battery-tuning_ae42fae3.plan.md b/.cursor/plans/planner-battery-tuning_ae42fae3.plan.md new file mode 100644 index 0000000..59729a0 --- /dev/null +++ b/.cursor/plans/planner-battery-tuning_ae42fae3.plan.md @@ -0,0 +1,279 @@ +--- +name: planner-battery-tuning +overview: Opravíme nesoulad mezi plánem a zápisem do Deye při nabíjení z FVE přebytku, doplníme SQL-first vstupy pro denní safety charge, aplikujeme je v LP jako soft penalty a uložíme debug snapshot každého běhu planneru. +todos: + - id: fix-deye-passive-charge + content: Opravit Deye PASSIVE překlad tak, aby plánované nabíjení z FVE přebytku nezapsalo reg108=0. + status: completed + - id: add-planner-debug-snapshot + content: Ukládat ke každému planning_run kompaktní debug JSON do solver_params se sekcemi inputs, masks, soc_bounds, objective_terms a chosen_slots. + status: pending + - id: prevent-charge-deferral + content: Doplnit near-term commitment / soft target před drahým sell oknem, aby rolling replan neodkládal nabíjení bez ekonomické náhrady. + status: pending + - id: add-daytime-safety-charge + content: Spočítat safety-charge vstupy v SQL, předat je do LP a aplikovat jako měkkou penalizaci deficitu proti noční energii. + status: pending + - id: add-regression-test + content: Přidat regresní testy pro PV surplus charge + současný net export a pro neodkládání nabíjení při receding horizon. + status: completed + - id: tune-small-site-terminal-soc + content: Po debug ověření upravit parametry BA81/KV1 cíleně; nezačínat slepým přepsáním `planner_terminal_soc_value_factor` na 0.9. + status: cancelled + - id: update-docs + content: Aktualizovat dokumentaci control/planning a ověřovací MCP dotazy. + status: completed + - id: verify + content: Spustit testy/validaci a sepsat očekávané MCP ověření po deployi. + status: completed +isProject: false +--- + +# Stabilizace plánovače baterie + +## Cíl +Opravit tři související problémy: + +- Plán někdy chce nabíjet baterii z PV přebytku, ale Deye dostane `reg108 = 0`, takže fyzicky nenabíjí. +- Rolling replan umí posouvat plánované nabíjení dál a dál, až levné PV okno uteče. +- Malé baterie BA81/KV1 potřebují robustní denní nabití pro noc, ale zároveň nesmí ztratit schopnost ekonomicky cyklovat a prodávat v opravdu drahých sell oknech. + +## Datové zjištění +- `BA81` = site `3`, `KV1` = site `4`, `home-01` = site `2`. +- KV1 run `8101` pro slot 17:15 plánoval `battery_setpoint_w = 4737` W, `grid_setpoint_w = -13` W, `deye_physical_mode = PASSIVE`; `modbus_command` následně zapsal a ověřil Deye `register = 108`, `value_to_write = 0`. To je konkrétní bug v control exportu. +- BA81 historie rolling runů ukazuje posun prvního charge slotu s časem. To je částečně normální receding-horizon efekt, ale nesmí prodat levný PV přebytek, který je potřeba pro pozdější sell peak nebo noční baseload. +- `planner_terminal_soc_value_factor` není jediné řešení. BA81/KV1 mají `0.2`, home-01 má `0.9`; nezvyšovat BA81/KV1 plošně na `0.9`, protože to může vrátit starou neochotu malé baterie cyklovat. + +## Architektonické rozhodnutí +- SQL-first zůstává: výpočet vstupů pro planner patří do SQL funkcí / view. +- Safety charge nesmí být hard `allow_charge` maska. SQL má spočítat vstupní hodnoty, LP je použije jako soft penalty v objective. +- Debug snapshot ukládat do existujícího `ems.planning_run.solver_params`. Samostatnou tabulku nezavádět v první iteraci. +- Hodnota energie v baterii není jedna konstanta: `battery_value = max(future_avoided_buy, future_sell_opportunity) - degradation`, plus samostatný měkký noční buffer. + +## Implementace + +### 1. Oprava Deye exportéru +Soubory: +- [`backend/services/control/inverter.py`](backend/services/control/inverter.py) +- [`backend/services/control/setpoints.py`](backend/services/control/setpoints.py) + +Požadované chování: +- Pokud `ControlSetpoints.battery_w > 0`, Deye musí dostat nenulový nabíjecí proud podle `battery_w`, i když `grid_setpoint_w < 0`. +- V tomto scénáři zůstává `deye_physical_mode = PASSIVE`, pokud plán explicitně neurčí `CHARGE`. Nejde o grid-charge režim; jde o nabíjení z PV přebytku a současný export zbytku. +- `discharge_a` v tomto scénáři nastavit na `0` nebo jinak omezit tak, aby Deye současně nevybíjel baterii. +- Existující SELL a PRESERVE chování neměnit. + +Konkrétní místo: +- V `write_inverter_setpoints()` je problém v PASSIVE větvi, která přes `_deye_zero_export_amps_for_passive()` vrací `charge_a = 0`, když `grid_w < 0` a `bat_w >= 0`. +- Přidej před tuto větev explicitní případ `bat_w > 0`: `charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a)`, `discharge_a = 0`. + +### 2. SQL vstupy pro daytime safety charge +Soubory: +- [`db/routines/R__063_fn_load_planning_slots_full.sql`](db/routines/R__063_fn_load_planning_slots_full.sql) +- případně nová repeatable funkce v [`db/routines`](db/routines) + +Neimplementovat jako hard masku. Nezakazovat / nepovolovat sloty natvrdo jen kvůli safety charge. + +Doplnit SQL výstupy, které Python LP použije: +- `night_baseload_target_wh`: kolik Wh je potřeba od večera do dalšího ranního PV okna. +- `night_baseload_buffer_wh`: bezpečnostní přirážka, např. procento z cíle. +- `safety_soc_target_wh`: doporučený SoC cíl pro slot. +- `future_avoided_buy_czk_kwh`: odhad ceny, kterou baterie ušetří, pokud energii necháme pro vlastní spotřebu. +- `future_sell_opportunity_czk_kwh`: nejlepší relevantní budoucí sell příležitost v horizontu. +- `is_daytime_pv_surplus_slot`: pomocný boolean pro debug a vážení cíle. + +Preferovaný způsob: +- Rozšířit `ems.fn_load_planning_slots_full(...)`, protože už je hlavní zdroj slotových vstupů pro `_load_slots()`. +- Pokud by rozšíření funkce bylo příliš velké, vytvořit samostatnou `ems.fn_planning_safety_charge_inputs(site_id, from, to, current_soc_wh)` a joinovat podle `interval_start` v SQL/Pythonu. + +Výpočet nočního okna: +- Praktická první verze: noc = od lokálního západu / večerního konce PV surplus do dalšího rána, zjednodušeně `20:00-06:00 Europe/Prague`. +- Přesnější verze později: od posledního dnešního slotu s významným PV forecastem do prvního zítřejšího slotu s významným PV forecastem. +- Pro první implementaci stačí konzervativní a čitelná definice, hlavně ji uložit do debug snapshotu. + +### 3. Rozšíření Python datových tříd a načítání slotů +Soubor: +- [`backend/services/planning_engine.py`](backend/services/planning_engine.py) + +Upravit `PlanningSlot`: +- Přidat volitelná pole pro SQL safety vstupy: + - `safety_soc_target_wh: float | None` + - `night_baseload_target_wh: float | None` + - `night_baseload_buffer_wh: float | None` + - `future_avoided_buy_czk_kwh: float | None` + - `future_sell_opportunity_czk_kwh: float | None` + - `is_daytime_pv_surplus_slot: bool = False` + +Upravit `_load_slots()`: +- Načíst nové sloupce ze SQL. +- Pokud SQL sloupce dočasně nejsou k dispozici, použít bezpečný fallback `None` / `False`, aby testy starších DB funkcí nespadly. +- Nepočítat noční baseload ad-hoc v Pythonu, pokud už SQL funkce hodnotu vrací. + +### 4. LP objective: soft safety target +Soubor: +- [`backend/services/planning_engine.py`](backend/services/planning_engine.py) + +Přidat do `solve_dispatch()`: +- Pro každý slot `t` s `safety_soc_target_wh is not None` vytvořit spojitou proměnnou `safety_deficit_wh[t] >= 0`. +- Přidat omezení: + - `safety_deficit_wh[t] >= safety_soc_target_wh[t] - soc[t]` +- Přidat do objective penalizaci: + - `safety_deficit_wh[t] * safety_penalty_czk_per_wh[t]` + +Výpočet penalty: +- `battery_value_czk_kwh = max(future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh) - degradation_cost_effective` +- `safety_penalty_czk_per_wh = max(0, battery_value_czk_kwh) / 1000` +- Přidat rozumný clamp, aby penalty nebyla extrémní kvůli vadné ceně. + +Chování: +- Pokud je vysoký sell peak ekonomicky lepší než držet energii pro noc, LP smí target porušit a prodat. +- Pokud je budoucí nákup drahý, typicky KV1, deficit bude drahý a LP bude energii spíš držet pro vlastní spotřebu. +- Toto není hard constraint. + +### 5. Near-term commitment proti deferralu +Soubory: +- [`backend/services/planning_engine.py`](backend/services/planning_engine.py) +- DB čtení z `ems.planning_run` / `ems.planning_interval` přes SQL funkci nebo jednoduchý read model + +Cíl: +- Rolling replan nesmí bez náhrady odsunout nejbližší plánované nabíjení z PV přebytku, pokud předchozí aktivní plán pro stejný nebo nejbližší slot chtěl nabíjet. + +První jednoduchá implementace: +- Při rolling replanu načíst předchozí aktivní plán pro stejné `site_id`. +- Najít nejbližší 1-2 sloty od `replan_from`, kde předchozí plán měl: + - `battery_setpoint_w > 500` + - `pv_a_forecast_solver_w + pv_b_forecast_solver_w > load_baseline_w` + - ideálně `grid_setpoint_w <= 0` +- V novém LP pro odpovídající slot přidat soft proměnnou `charge_commitment_shortfall_w[t] >= previous_battery_charge_w - bc[t]`. +- Penalizace má být malá, ale nenulová: má zabránit bezdůvodnému odsunu, ne přebít skutečně lepší ekonomiku. +- Uložit do debug snapshotu, kdy commitment vznikl a kolik stál. + +Neimplementovat jako hard constraint. + +### 6. Debug snapshot do solver_params +Soubory: +- [`backend/services/planning_engine.py`](backend/services/planning_engine.py) +- [`db/routines/R__037_fn_planning_run_commit.sql`](db/routines/R__037_fn_planning_run_commit.sql) + +Upravit `_save_planning_run()`: +- Rozšířit `run_meta` o `solver_params`. +- `solver_params` bude JSON serializovatelný dict. + +Upravit `ems.fn_planning_run_commit(...)`: +- Při insertu do `ems.planning_run` uložit `solver_params = p_run_meta->'solver_params'`. + +Minimální struktura JSON: + +```json +{ + "version": 1, + "inputs": { + "current_soc_wh": 0, + "operating_mode": "AUTO", + "battery": { + "usable_capacity_wh": 0, + "min_soc_wh": 0, + "reserve_soc_wh": 0, + "degradation_cost_czk_kwh": 0, + "planner_terminal_soc_value_factor": 0.2 + } + }, + "masks": [ + { + "slot": "2026-05-04T15:45:00+00:00", + "allow_charge": true, + "allow_discharge_export": false + } + ], + "soc_bounds": [ + { + "slot": "2026-05-04T15:45:00+00:00", + "soc_min_wh": 0, + "arb_floor_wh": 0, + "soc_panel_min_wh": 0, + "safety_soc_target_wh": 0 + } + ], + "objective_terms": [ + { + "slot": "2026-05-04T15:45:00+00:00", + "buy_price": 0, + "sell_price": 0, + "future_avoided_buy_czk_kwh": 0, + "future_sell_opportunity_czk_kwh": 0, + "battery_value_czk_kwh": 0, + "safety_deficit_penalty_czk_per_wh": 0, + "commitment_penalty_czk_per_w": 0 + } + ], + "chosen_slots": { + "charge_commitment": [], + "high_sell_windows": [], + "night_window": { + "start": "2026-05-04T18:00:00+00:00", + "end": "2026-05-05T04:00:00+00:00", + "target_wh": 0 + } + } +} +``` + +### 7. Debug read model +Soubor: +- nová repeatable funkce v [`db/routines`](db/routines), např. `R__086_fn_planning_run_debug.sql` + +Vytvořit `ems.fn_planning_run_debug(p_run_id int)`: +- Vrátí jeden `jsonb`. +- Obsahuje: + - metadata z `planning_run`, + - `solver_params`, + - intervaly z `planning_interval` pro daný run, + - krátký souhrn: první charge slot, první battery export slot, nejdražší sell sloty, největší safety deficit. + +Použití přes MCP: + +```sql +select ems.fn_planning_run_debug(8107); +``` + +### 8. Parametry +Nepřepisovat plošně BA81/KV1 na `planner_terminal_soc_value_factor = 0.9`. + +Nové parametry preferovaně v `ems.asset_battery` přes novou migraci: +- `planner_daytime_charge_target_enabled boolean default true` +- `planner_night_baseload_buffer_percent numeric default 20` +- `planner_daytime_charge_price_quantile numeric default 0.70` +- `planner_charge_commitment_penalty_czk_kwh numeric default 0.20` + +Pokud je rozsah příliš velký, první iterace může mít konzervativní konstanty v Pythonu, ale plánovaná cílová podoba je DB parametrizace. + +### 9. Testy +Najít existující testovací styl v repu a přidat testy co nejblíže dotčeným modulům. + +Povinné scénáře: +- Control exporter: `battery_w > 0`, `grid_setpoint_w < 0`, `deye_physical_mode = PASSIVE` vede na `reg108 > 0`, `reg109 = 0`. +- Control exporter: SELL režim se nezmění. +- Planner safety: malá baterie, PV surplus přes den, noční baseload, pozdější drahý sell slot. LP má nabíjet v rozumně levném PV slotu a neodsunout charge donekonečna. +- Planner economics: pokud `sell_now` převyšuje budoucí avoided buy plus degradaci, LP smí porušit safety target a prodat. +- Planner economics KV1-like: pokud budoucí buy je drahý a sell není dost vysoký, LP má držet energii pro vlastní spotřebu. + +### 10. Dokumentace +Aktualizovat: +- [`docs/04-modules/control.md`](docs/04-modules/control.md) +- [`docs/04-modules/planning.md`](docs/04-modules/planning.md) + +Dokumentace musí popsat: +- rozdíl mezi plánem, Deye fyzickým režimem a registry `108/109`, +- PV-surplus charging při současném exportu, +- `solver_params` debug snapshot a `fn_planning_run_debug`, +- rozdíl mezi hard maskami (`allow_charge`, `allow_discharge_export`) a soft LP penalizacemi, +- že `planner_terminal_soc_value_factor` není jediný mechanismus ochrany malé baterie. + +## Ověření +- Spustit backend testy pro control a planner. +- Spustit Flyway validate lokálně. +- Přes MCP ověřit po nasazení: + - pro BA81/KV1 sloty s `battery_setpoint_w > 0` a `grid_setpoint_w < 0` má následný `modbus_command.register = 108` hodnotu > 0, + - `planning_run.solver_params` není `NULL` a obsahuje `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`, + - `select ems.fn_planning_run_debug()` vrací vysvětlitelný JSON, + - rolling replan neodkládá nabíjení z levného PV přebytku bez viditelného ekonomického důvodu v debug snapshotu. \ No newline at end of file diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 375b38d..4d341fc 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -69,7 +69,6 @@ from services.control.setpoints import ( _deye_tou_min_soc_pct, _deye_tou_params, _deye_tou_reserve_soc_pct, - _deye_zero_export_amps_for_passive, get_deye_mode, ) from services.control.verify import ( diff --git a/backend/services/control/inverter.py b/backend/services/control/inverter.py index be43a9f..10ef893 100644 --- a/backend/services/control/inverter.py +++ b/backend/services/control/inverter.py @@ -25,7 +25,6 @@ from services.control.deye_helpers import ( _DEYE_INACTIVE_TOU_REGISTERS, _deye_should_skip_time_sync_after_read, _prague_minute_start_utc, - battery_watts_to_amps, current_slot_hhmm, next_slot_hhmm, ) @@ -44,7 +43,7 @@ from services.control.setpoints import ( _deye_tou_min_soc_pct, _deye_tou_params, _deye_tou_reserve_soc_pct, - _deye_zero_export_amps_for_passive, + deye_battery_charge_discharge_amps, get_deye_mode, ) from services.modbus_client import get_modbus_client @@ -78,25 +77,15 @@ async def write_inverter_setpoints( deye_mode = get_deye_mode(setpoints_now) bat_w = int(raw_bat) if raw_bat is not None else 0 - if setpoints_now.lock_battery: - charge_a = 0 - discharge_a = 0 - elif deye_mode == "CHARGE": - charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a) - discharge_a = 0 - elif deye_mode == "SELL": - charge_a = 0 - discharge_a = int(inv.max_discharge_a) - elif setpoints_now.self_sustain_local_use: - charge_a = int(inv.max_charge_a) - discharge_a = int(inv.max_discharge_a) - else: - charge_a, discharge_a = _deye_zero_export_amps_for_passive( - grid_w, - bat_w, - int(inv.max_charge_a), - int(inv.max_discharge_a), - ) + charge_a, discharge_a = deye_battery_charge_discharge_amps( + lock_battery=setpoints_now.lock_battery, + deye_mode=deye_mode, + self_sustain_local_use=setpoints_now.self_sustain_local_use, + bat_w=bat_w, + grid_w=grid_w, + max_charge_a=int(inv.max_charge_a), + max_discharge_a=int(inv.max_discharge_a), + ) zero_exp_mode = int(inv.deye_zero_export_mode or 1) selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode diff --git a/backend/services/control/setpoints.py b/backend/services/control/setpoints.py index 8073f9e..6c0b68a 100644 --- a/backend/services/control/setpoints.py +++ b/backend/services/control/setpoints.py @@ -255,6 +255,36 @@ def _deye_zero_export_amps_for_passive( return max_charge_a, max_discharge_a +def deye_battery_charge_discharge_amps( + *, + lock_battery: bool, + deye_mode: str, + self_sustain_local_use: bool, + bat_w: int, + grid_w: int, + max_charge_a: int, + max_discharge_a: int, +) -> tuple[int, int]: + """ + Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye. + + PASSIVE + plán chce nabíjet z PV přebytku i při exportu do sítě: nenulový charge, discharge 0. + """ + if lock_battery: + return 0, 0 + if deye_mode == "CHARGE": + return battery_watts_to_amps(bat_w, max_charge_a), 0 + if deye_mode == "SELL": + return 0, int(max_discharge_a) + if self_sustain_local_use: + return int(max_charge_a), int(max_discharge_a) + if bat_w > 0: + return battery_watts_to_amps(bat_w, max_charge_a), 0 + return _deye_zero_export_amps_for_passive( + grid_w, bat_w, int(max_charge_a), int(max_discharge_a) + ) + + def get_deye_mode(setpoints: ControlSetpoints) -> str: """Fyzický režim Deye: SELL | CHARGE | PASSIVE.""" pm = (setpoints.deye_physical_mode or "").strip().upper() diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 0fadb22..17d6843 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -14,7 +14,7 @@ import time from dataclasses import dataclass, replace from datetime import datetime, timezone, timedelta from types import SimpleNamespace -from typing import Optional +from typing import Any, Optional from zoneinfo import ZoneInfo import pulp @@ -159,6 +159,13 @@ def _soc_security_profile(slots: list["PlanningSlot"], battery) -> tuple[float, return target_wh, penalty_czk_kwh +def _slot_float_nullable(d: dict[str, Any], key: str) -> float | None: + v = d.get(key) + if v is None: + return None + return float(v) + + def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]: """DOW v konvenci PostgreSQL EXTRACT(DOW, Europe/Prague): 0=Ne … 6=So.""" dt = interval_start @@ -185,6 +192,13 @@ class PlanningSlot: is_predicted_price: bool = False allow_charge: bool = True allow_discharge_export: bool = True + #: Měkké LP vstupy z `ems.fn_load_planning_slots_full` (mimo masky allow_*). + night_baseload_target_wh: float | None = None + night_baseload_buffer_wh: float | None = None + safety_soc_target_wh: float | None = None + future_avoided_buy_czk_kwh: float | None = None + future_sell_opportunity_czk_kwh: float | None = None + is_daytime_pv_surplus_slot: bool = False # Lookahead pro relax spodní meze SoC: až 36 h od indexu slotu (pevné OTE ceny v horizontu). @@ -438,10 +452,11 @@ def solve_dispatch( *, tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None, operating_mode: str = "AUTO", -) -> tuple[list[DispatchResult], int]: + charge_commitment_prev_w: Optional[list[Optional[float]]] = None, +) -> tuple[list[DispatchResult], int, dict[str, Any]]: """ LP solver pro dispatch optimalizaci. - Vrátí (výsledky, solver_duration_ms). + Vrátí (výsledky, solver_duration_ms, solver_debug_snapshot). """ T = len(slots) if T < 1: @@ -603,6 +618,33 @@ def solve_dispatch( t_anchor = first_neg_sell_idx - 1 soc_anchor_slack = pulp.LpVariable("soc_anchor_slack_wh", 0, float(battery.usable_capacity_wh)) + daytime_en = bool(getattr(battery, "planner_daytime_charge_target_enabled", True)) + safety_pen_czk_per_wh: list[float] = [] + safety_vars: list[Optional[pulp.LpVariable]] = [] + for t in range(T): + sft = slots[t].safety_soc_target_wh if daytime_en else None + fb = float(slots[t].future_avoided_buy_czk_kwh or slots[t].buy_price) + fs = float(slots[t].future_sell_opportunity_czk_kwh or slots[t].sell_price) + bv = max(fb, fs) - float(degradation_cost_effective) + bv = max(0.0, min(5.0, bv)) + safety_pen_czk_per_wh.append(bv / 1000.0 if sft is not None else 0.0) + if sft is not None: + safety_vars.append( + pulp.LpVariable(f"safety_def_{t}", 0, float(battery.usable_capacity_wh)) + ) + else: + safety_vars.append(None) + + commit_pen = float(getattr(battery, "planner_charge_commitment_penalty_czk_kwh", 0.2)) + commit_lp: list[tuple[int, pulp.LpVariable, float]] = [] + if charge_commitment_prev_w is not None and len(charge_commitment_prev_w) == T: + for t in range(T): + prev = charge_commitment_prev_w[t] + if prev is not None and prev > 500: + cap_prev = float(prev) + cv = pulp.LpVariable(f"ccommit_{t}", 0, cap_prev) + commit_lp.append((t, cv, cap_prev)) + # --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) --- prob += ( pulp.lpSum( @@ -644,6 +686,12 @@ def solve_dispatch( if soc_anchor_slack is not None else 0 ) + + pulp.lpSum( + safety_vars[t] * safety_pen_czk_per_wh[t] + for t in range(T) + if safety_vars[t] is not None + ) + + pulp.lpSum(cv * INTERVAL_H / 1000.0 * commit_pen for _t, cv, _p in commit_lp) ) # --- Omezení --- @@ -680,6 +728,11 @@ def solve_dispatch( - bd[t] / battery.discharge_efficiency * INTERVAL_H ) + sv = safety_vars[t] + tgt_s = slots[t].safety_soc_target_wh if daytime_en else None + if sv is not None and tgt_s is not None: + prob += sv >= float(tgt_s) - soc[t] + # ev_via_bat kryto z discharge prob += pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t] @@ -762,6 +815,9 @@ def solve_dispatch( else: prob += ev_direct[e][t] + ev_via_bat[e][t] <= vehicles[e].max_charge_power_w + for tt, cv, prev in commit_lp: + prob += cv >= prev - bc[tt] + if om == "SELF_SUSTAIN": for t in range(T): prob += gi[t] <= slots[t].load_baseline_w @@ -899,7 +955,91 @@ def solve_dispatch( is_predicted_price = bool(slots[t].is_predicted_price), )) - return results, duration_ms + sell_rank = sorted(range(T), key=lambda i: float(slots[i].sell_price), reverse=True)[: min(3, T)] + charge_commit_snapshot = [ + { + "slot": slots[tt].interval_start.isoformat(), + "previous_charge_w": prev, + "shortfall_w": float(pulp.value(cv) or 0.0), + } + for tt, cv, prev in commit_lp + ] + masks_snap: list[dict[str, Any]] = [] + soc_bounds_snap: list[dict[str, Any]] = [] + objective_terms_snap: list[dict[str, Any]] = [] + for t in range(T): + st = slots[t] + masks_snap.append( + { + "slot": st.interval_start.isoformat(), + "allow_charge": bool(st.allow_charge), + "allow_discharge_export": bool(st.allow_discharge_export), + } + ) + tgt_s = st.safety_soc_target_wh if daytime_en else None + soc_bounds_snap.append( + { + "slot": st.interval_start.isoformat(), + "soc_min_wh": float(soc_panel_min[t]), + "arb_floor_wh": float(arb_floor_series[t]), + "soc_panel_min_wh": float(soc_panel_min[t]), + "safety_soc_target_wh": float(tgt_s) if tgt_s is not None else None, + } + ) + fb = float(st.future_avoided_buy_czk_kwh or st.buy_price) + fs = float(st.future_sell_opportunity_czk_kwh or st.sell_price) + bv = max(fb, fs) - float(degradation_cost_effective) + bv = max(0.0, min(5.0, bv)) + pen_wh = bv / 1000.0 if tgt_s is not None else 0.0 + sv = safety_vars[t] + sdv = float(pulp.value(sv) or 0.0) if sv is not None else None + cshort = next((float(pulp.value(cv) or 0.0) for tt, cv, _p in commit_lp if tt == t), None) + objective_terms_snap.append( + { + "slot": st.interval_start.isoformat(), + "buy_price": float(st.buy_price), + "sell_price": float(st.sell_price), + "future_avoided_buy_czk_kwh": float(st.future_avoided_buy_czk_kwh or st.buy_price), + "future_sell_opportunity_czk_kwh": float( + st.future_sell_opportunity_czk_kwh or st.sell_price + ), + "battery_value_czk_kwh": float(bv), + "safety_deficit_penalty_czk_per_wh": float(pen_wh), + "safety_deficit_wh": sdv, + "commitment_shortfall_w": cshort, + "commitment_penalty_czk_kwh": float(commit_pen) if cshort is not None else None, + } + ) + night0 = slots[0] + solver_snapshot: dict[str, Any] = { + "version": 1, + "inputs": { + "current_soc_wh": float(current_soc_wh), + "operating_mode": operating_mode, + "battery": { + "usable_capacity_wh": float(battery.usable_capacity_wh), + "min_soc_wh": float(battery.min_soc_wh), + "reserve_soc_wh": float(getattr(battery, "reserve_soc_wh", 0.0)), + "degradation_cost_czk_kwh": float(battery.degradation_cost_czk_kwh), + "planner_terminal_soc_value_factor": float(battery.planner_terminal_soc_value_factor), + "planner_daytime_charge_target_enabled": daytime_en, + "planner_charge_commitment_penalty_czk_kwh": float(commit_pen), + }, + }, + "masks": masks_snap, + "soc_bounds": soc_bounds_snap, + "objective_terms": objective_terms_snap, + "chosen_slots": { + "charge_commitment": charge_commit_snapshot, + "high_sell_windows": [slots[i].interval_start.isoformat() for i in sell_rank], + "night_window": { + "definition": "Europe/Prague 20:00–06:00 projected baseload Wh (fn_load_planning_slots_full)", + "target_wh": night0.night_baseload_target_wh, + "buffer_wh": night0.night_baseload_buffer_wh, + }, + }, + } + return results, duration_ms, solver_snapshot # ============================================================ @@ -930,7 +1070,7 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily" ) slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh) - results, duration_ms = solve_dispatch( + results, duration_ms, solver_snapshot = solve_dispatch( slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, operating_mode=operating_mode or "AUTO", @@ -950,6 +1090,7 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily" correction=1.0, db=db, slot_inputs=slot_inputs, + solver_snapshot=solver_snapshot, ) logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms") return run_id, duration_ms @@ -1023,10 +1164,13 @@ async def run_rolling_replan( slots = apply_forecast_correction(slots, now, correction_factor) - results, duration_ms = solve_dispatch( + commitment_prev = await _load_previous_plan_charge_commitment_prev_w(site_id, slots, db) + + results, duration_ms, solver_snapshot = solve_dispatch( slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, operating_mode=operating_mode or "AUTO", + charge_commitment_prev_w=commitment_prev, ) slot_inputs = _build_slot_inputs(slots_before_pv_correction, slots) @@ -1043,6 +1187,7 @@ async def run_rolling_replan( correction=correction_factor, db=db, slot_inputs=slot_inputs, + solver_snapshot=solver_snapshot, ) await db.execute( @@ -1165,6 +1310,18 @@ async def _load_site_context(site_id: int, db): if relax_prewin is not None else DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS, planner_terminal_soc_value_factor=float(b["planner_terminal_soc_value_factor"]), + planner_daytime_charge_target_enabled=bool( + b.get("planner_daytime_charge_target_enabled", True) + ), + planner_night_baseload_buffer_percent=float( + b.get("planner_night_baseload_buffer_percent") or 20.0 + ), + planner_daytime_charge_price_quantile=float( + b.get("planner_daytime_charge_price_quantile") or 0.70 + ), + planner_charge_commitment_penalty_czk_kwh=float( + b.get("planner_charge_commitment_penalty_czk_kwh") or 0.20 + ), ) hpj = ctx["heat_pump"] @@ -1227,6 +1384,51 @@ async def _load_site_context(site_id: int, db): ) +async def _load_previous_plan_charge_commitment_prev_w( + site_id: int, + slots: list[PlanningSlot], + db, +) -> list[Optional[float]]: + """ + Pro rolling replan: z aktivního plánu načte battery_setpoint_w pro shodné sloty. + Kotva měkkého commitmentu jen když předchozí plán chtěl nabíjet z PV přebytku (viz podmínky). + """ + if not slots: + return [] + rows = await db.fetch( + """ + select pi.interval_start, + pi.battery_setpoint_w, + pi.grid_setpoint_w, + coalesce(pi.pv_a_forecast_solver_w, 0) as pva, + coalesce(pi.pv_b_forecast_solver_w, 0) as pvb, + coalesce(pi.load_baseline_w, 0) as lb + from ems.planning_interval pi + inner join ems.planning_run pr on pr.id = pi.run_id + where pr.site_id = $1::int + and pr.status = 'active' + """, + site_id, + ) + by_start = {r["interval_start"]: r for r in rows} + out: list[Optional[float]] = [] + for s in slots: + r = by_start.get(s.interval_start) + if r is None: + out.append(None) + continue + bw = int(r["battery_setpoint_w"] or 0) + gw = int(r["grid_setpoint_w"] or 0) + pva = int(r["pva"] or 0) + pvb = int(r["pvb"] or 0) + lb = int(r["lb"] or 0) + if bw > 500 and (pva + pvb) > lb and gw <= 0: + out.append(float(bw)) + else: + out.append(None) + return out + + async def _load_slots( site_id: int, from_dt: datetime, @@ -1240,7 +1442,10 @@ async def _load_slots( """ select slot_ord, interval_start, buy_price, sell_price, is_predicted_price, pv_a_forecast_w, pv_b_forecast_w, load_baseline_w, - ev1_connected, ev2_connected, allow_charge, allow_discharge_export + ev1_connected, ev2_connected, allow_charge, allow_discharge_export, + night_baseload_target_wh, night_baseload_buffer_wh, safety_soc_target_wh, + future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh, + is_daytime_pv_surplus_slot from ems.fn_load_planning_slots_full( $1::int, $2::timestamptz, $3::timestamptz, $4::numeric ) @@ -1266,6 +1471,14 @@ async def _load_slots( is_predicted_price=bool(d.get("is_predicted_price")), allow_charge=bool(d.get("allow_charge", True)), allow_discharge_export=bool(d.get("allow_discharge_export", True)), + night_baseload_target_wh=_slot_float_nullable(d, "night_baseload_target_wh"), + night_baseload_buffer_wh=_slot_float_nullable(d, "night_baseload_buffer_wh"), + safety_soc_target_wh=_slot_float_nullable(d, "safety_soc_target_wh"), + future_avoided_buy_czk_kwh=_slot_float_nullable(d, "future_avoided_buy_czk_kwh"), + future_sell_opportunity_czk_kwh=_slot_float_nullable( + d, "future_sell_opportunity_czk_kwh" + ), + is_daytime_pv_surplus_slot=bool(d.get("is_daytime_pv_surplus_slot", False)), ) ) if not out: @@ -1306,11 +1519,13 @@ async def _save_planning_run( run_type, triggered_by, replan_from, soc_wh, duration_ms, correction, db, slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None, + *, + solver_snapshot: Optional[dict[str, Any]] = None, ) -> int: """Uloží výsledky solveru přes ems.fn_planning_run_commit.""" if slot_inputs is not None and len(slot_inputs) != len(results): raise ValueError("slot_inputs and results length mismatch") - run_meta = { + run_meta: dict[str, Any] = { "run_type": run_type, "triggered_by": triggered_by, "replan_from": replan_from.isoformat() if replan_from else None, @@ -1318,6 +1533,8 @@ async def _save_planning_run( "solver_duration_ms": duration_ms, "forecast_correction_factor": correction, } + if solver_snapshot is not None: + run_meta["solver_params"] = solver_snapshot intervals: list[dict] = [] for i, r in enumerate(results): row: dict = { diff --git a/backend/tests/test_control_deye_passive_pv_charge.py b/backend/tests/test_control_deye_passive_pv_charge.py new file mode 100644 index 0000000..8a10fc7 --- /dev/null +++ b/backend/tests/test_control_deye_passive_pv_charge.py @@ -0,0 +1,52 @@ +"""PASSIVE + nabíjení z PV přebytku při současném exportu → nenulový nabíjecí proud.""" + +from __future__ import annotations + +import unittest + +from services.control.setpoints import deye_battery_charge_discharge_amps + + +class PassivePvSurplusChargeAmpsTests(unittest.TestCase): + def test_passive_charge_while_exporting_grid_negative(self) -> None: + ch, dis = deye_battery_charge_discharge_amps( + lock_battery=False, + deye_mode="PASSIVE", + self_sustain_local_use=False, + bat_w=5000, + grid_w=-2000, + max_charge_a=100, + max_discharge_a=100, + ) + self.assertGreater(ch, 0) + self.assertEqual(dis, 0) + + def test_passive_zero_export_still_zero_charge_when_no_battery_charge(self) -> None: + ch, dis = deye_battery_charge_discharge_amps( + lock_battery=False, + deye_mode="PASSIVE", + self_sustain_local_use=False, + bat_w=0, + grid_w=-2000, + max_charge_a=100, + max_discharge_a=100, + ) + self.assertEqual(ch, 0) + self.assertEqual(dis, 100) + + def test_sell_unchanged(self) -> None: + ch, dis = deye_battery_charge_discharge_amps( + lock_battery=False, + deye_mode="SELL", + self_sustain_local_use=False, + bat_w=-3000, + grid_w=-2000, + max_charge_a=100, + max_discharge_a=80, + ) + self.assertEqual(ch, 0) + self.assertEqual(dis, 80) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 6ff505c..e1e2402 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -237,7 +237,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): ), ] soc0 = 0.50 * battery.usable_capacity_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, @@ -278,7 +278,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): ), ] soc0 = battery.soc_max_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, @@ -317,7 +317,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): ), ] soc0 = 0.15 * battery.usable_capacity_wh - results, ms = solve_dispatch( + results, ms, _ = solve_dispatch( slots, battery, hp, @@ -357,7 +357,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): ), ] soc0 = 0.12 * battery.usable_capacity_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, @@ -393,7 +393,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): ), ] soc0 = 0.5 * battery.usable_capacity_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, @@ -433,7 +433,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): ), ] soc0 = 0.22 * battery.usable_capacity_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, @@ -511,7 +511,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): ), ] soc0 = 0.88 * battery.usable_capacity_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, @@ -593,7 +593,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0), ] soc0 = 0.9 * battery.usable_capacity_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, @@ -680,7 +680,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0), ] soc0 = 0.9 * battery.usable_capacity_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, @@ -755,7 +755,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0), ] soc0 = 0.9 * battery.usable_capacity_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, @@ -798,7 +798,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): ), ] soc0 = 0.55 * battery.usable_capacity_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, @@ -853,7 +853,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0), ] soc0 = 0.55 * battery.usable_capacity_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, @@ -913,7 +913,7 @@ class PlanningDispatchMilpTests(unittest.TestCase): ), ] soc0 = 0.34 * battery.usable_capacity_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, @@ -983,7 +983,7 @@ class TerminalSocShadowTests(unittest.TestCase): ), ] soc0 = 0.5 * battery.usable_capacity_wh - results, _ms = solve_dispatch( + results, _ms, _ = solve_dispatch( slots, battery, hp, diff --git a/backend/tests/test_planning_safety_commitment.py b/backend/tests/test_planning_safety_commitment.py new file mode 100644 index 0000000..b01b54b --- /dev/null +++ b/backend/tests/test_planning_safety_commitment.py @@ -0,0 +1,140 @@ +"""Měkké safety SoC a rolling charge commitment v solve_dispatch.""" + +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +from services.planning_engine import PlanningSlot, solve_dispatch + + +def _bat(**kwargs: object) -> SimpleNamespace: + base = dict( + usable_capacity_wh=20_000.0, + min_soc_wh=2000.0, + arb_floor_wh=4000.0, + reserve_soc_wh=4000.0, + soc_max_wh=19_000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + degradation_cost_czk_kwh=0.1, + max_charge_power_w=5000, + max_discharge_power_w=5000, + planner_terminal_soc_value_factor=0.2, + planner_extreme_buy_threshold_czk_kwh=-5.0, + planner_discharge_floor_percent=None, + planner_discharge_relax_prewindow_slots=8, + planner_daytime_charge_target_enabled=True, + planner_charge_commitment_penalty_czk_kwh=0.5, + ) + base.update(kwargs) + return SimpleNamespace(**base) + + +def _grid() -> SimpleNamespace: + return SimpleNamespace( + max_import_power_w=11_000, + max_export_power_w=11_000, + block_export_on_negative_sell=False, + deye_gen_microinverter_cutoff_enabled=False, + ) + + +def _hp() -> SimpleNamespace: + return SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0) + + +def _slot( + t0: datetime, + idx: int, + *, + buy: float = 3.0, + sell: float = 2.5, + pv_a: int = 0, + load: int = 1500, + safety: float | None = None, + fut_buy: float | None = None, + fut_sell: float | None = None, +) -> PlanningSlot: + return PlanningSlot( + interval_start=t0 + timedelta(minutes=15 * idx), + buy_price=buy, + sell_price=sell, + pv_a_forecast_w=pv_a, + pv_b_forecast_w=0, + load_baseline_w=load, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=True, + safety_soc_target_wh=safety, + future_avoided_buy_czk_kwh=fut_buy, + future_sell_opportunity_czk_kwh=fut_sell, + ) + + +class PlanningSafetyCommitmentTests(unittest.TestCase): + def test_solver_snapshot_has_version_and_masks(self) -> None: + t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc) + slots = [_slot(t0, i, buy=2.0, sell=2.0, pv_a=6000, load=1500) for i in range(8)] + hp, grid = _hp(), _grid() + vehicles = [ + SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0) + ] * 2 + res, _ms, snap = solve_dispatch( + slots, + _bat(), + hp, + grid, + [None, None], + vehicles, + current_soc_wh=5000.0, + current_tuv_temp_c=50.0, + operating_mode="AUTO", + ) + self.assertEqual(len(res), 8) + self.assertEqual(snap.get("version"), 1) + self.assertIn("masks", snap) + self.assertEqual(len(snap["masks"]), 8) + + def test_charge_commitment_snapshot_populated(self) -> None: + """Rolling kotva: při předchozím nabíjení z PV se do snapshotu zapíše commitment.""" + t0 = datetime(2026, 5, 4, 10, 0, tzinfo=timezone.utc) + slots = [_slot(t0, i, buy=1.5, sell=1.2, pv_a=8000, load=1000) for i in range(12)] + hp, grid = _hp(), _grid() + vehicles = [ + SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0) + ] * 2 + prev = [None] * 12 + prev[0] = 4000.0 + _res1, _, snap1 = solve_dispatch( + slots, + _bat(), + hp, + grid, + [None, None], + vehicles, + current_soc_wh=4000.0, + current_tuv_temp_c=50.0, + operating_mode="AUTO", + charge_commitment_prev_w=prev, + ) + self.assertTrue(snap1["chosen_slots"]["charge_commitment"]) + _res2, _, snap2 = solve_dispatch( + slots, + _bat(), + hp, + grid, + [None, None], + vehicles, + current_soc_wh=4000.0, + current_tuv_temp_c=50.0, + operating_mode="AUTO", + charge_commitment_prev_w=None, + ) + self.assertEqual(snap2["chosen_slots"]["charge_commitment"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/db/migration/V077__planner_safety_charge_asset_battery.sql b/db/migration/V077__planner_safety_charge_asset_battery.sql new file mode 100644 index 0000000..73705e5 --- /dev/null +++ b/db/migration/V077__planner_safety_charge_asset_battery.sql @@ -0,0 +1,25 @@ +-- Parametry pro denní „safety charge“ (měkké LP penalizace) a kotvu rolling replanu. + +alter table ems.asset_battery + add column if not exists planner_daytime_charge_target_enabled boolean not null default true; + +alter table ems.asset_battery + add column if not exists planner_night_baseload_buffer_percent numeric not null default 20; + +alter table ems.asset_battery + add column if not exists planner_daytime_charge_price_quantile numeric not null default 0.70; + +alter table ems.asset_battery + add column if not exists planner_charge_commitment_penalty_czk_kwh numeric not null default 0.20; + +comment on column ems.asset_battery.planner_daytime_charge_target_enabled is + 'Zapíná SQL/LP měkké denní cíle SoC (safety) z fn_load_planning_slots_full; ne tvrdé allow_charge masky.'; + +comment on column ems.asset_battery.planner_night_baseload_buffer_percent is + 'Procentní přirážka k odhadu nočního baseload Wh (20 = +20 % k night_baseload_target_wh).'; + +comment on column ems.asset_battery.planner_daytime_charge_price_quantile is + 'Rezervováno pro budoucí výběr „drahých“ oken z cenové distribuce; v1 se v LP nepoužívá.'; + +comment on column ems.asset_battery.planner_charge_commitment_penalty_czk_kwh is + 'Koeficient měkké penalizace (Kč/kWh krátkého nedodržení) proti předchozímu plánu při rolling replanu.'; diff --git a/db/routines/R__037_fn_planning_run_commit.sql b/db/routines/R__037_fn_planning_run_commit.sql index ddaa1e7..c4ea29f 100644 --- a/db/routines/R__037_fn_planning_run_commit.sql +++ b/db/routines/R__037_fn_planning_run_commit.sql @@ -23,7 +23,8 @@ begin insert into ems.planning_run ( site_id, horizon_start, horizon_end, status, run_type, triggered_by, replan_from, - soc_at_replan_wh, solver_duration_ms, forecast_correction_factor + soc_at_replan_wh, solver_duration_ms, forecast_correction_factor, + solver_params ) values ( p_site_id, p_horizon_start, @@ -39,7 +40,12 @@ begin end, (p_run_meta->>'soc_at_replan_wh')::numeric, (p_run_meta->>'solver_duration_ms')::int, - (p_run_meta->>'forecast_correction_factor')::numeric + (p_run_meta->>'forecast_correction_factor')::numeric, + case + when p_run_meta ? 'solver_params' and jsonb_typeof(p_run_meta->'solver_params') = 'object' + then p_run_meta->'solver_params' + else null::jsonb + end ) returning id into v_run_id; diff --git a/db/routines/R__039_fn_planning_site_context.sql b/db/routines/R__039_fn_planning_site_context.sql index 5d0c0a5..2f8334e 100644 --- a/db/routines/R__039_fn_planning_site_context.sql +++ b/db/routines/R__039_fn_planning_site_context.sql @@ -67,7 +67,11 @@ begin )::int, 'charge_slot_buffer', ab.charge_slot_buffer, 'discharge_slot_buffer', ab.discharge_slot_buffer, - 'planner_terminal_soc_value_factor', ab.planner_terminal_soc_value_factor + 'planner_terminal_soc_value_factor', ab.planner_terminal_soc_value_factor, + 'planner_daytime_charge_target_enabled', coalesce(ab.planner_daytime_charge_target_enabled, true), + 'planner_night_baseload_buffer_percent', coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric), + 'planner_daytime_charge_price_quantile', coalesce(ab.planner_daytime_charge_price_quantile, 0.70::numeric), + 'planner_charge_commitment_penalty_czk_kwh', coalesce(ab.planner_charge_commitment_penalty_czk_kwh, 0.20::numeric) ) into v_b from ems.asset_battery ab diff --git a/db/routines/R__063_fn_load_planning_slots_full.sql b/db/routines/R__063_fn_load_planning_slots_full.sql index 3f9dd45..aee9aa4 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -18,7 +18,13 @@ returns table ( ev1_connected boolean, ev2_connected boolean, allow_charge boolean, - allow_discharge_export boolean + allow_discharge_export boolean, + night_baseload_target_wh numeric, + night_baseload_buffer_wh numeric, + safety_soc_target_wh numeric, + future_avoided_buy_czk_kwh numeric, + future_sell_opportunity_czk_kwh numeric, + is_daytime_pv_surplus_slot boolean ) language plpgsql volatile @@ -47,6 +53,9 @@ declare v_chg_pm_wh numeric; v_dis_am_wh numeric; v_dis_pm_wh numeric; + v_reserve_wh numeric; + v_daytime_en boolean; + v_night_buf_pct numeric; begin drop table if exists _ems_plan_slot_wk; create temp table _ems_plan_slot_wk on commit drop as @@ -280,7 +289,10 @@ begin coalesce(ai.max_battery_discharge_w, ai.max_discharge_power_w) ) )::numeric, - greatest(coalesce(ab.discharge_efficiency, 1::numeric), 0.0001::numeric) + greatest(coalesce(ab.discharge_efficiency, 1::numeric), 0.0001::numeric), + (ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric, + coalesce(ab.planner_daytime_charge_target_enabled, true), + coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric) into v_charge_buf, v_discharge_buf, @@ -290,7 +302,10 @@ begin v_charge_eff, v_max_charge_w, v_max_discharge_w, - v_discharge_eff + v_discharge_eff, + v_reserve_wh, + v_daytime_en, + v_night_buf_pct from ems.asset_battery ab join ems.asset_inverter ai on ai.id = ab.inverter_id and ai.site_id = ab.site_id where ab.site_id = p_site_id @@ -395,25 +410,97 @@ begin end if; return query + with night_tot as ( + select coalesce(sum(w2.load_baseline_w), 0) * 0.25 as night_wh + from _ems_plan_slot_wk w2 + where extract(hour from w2.interval_start at time zone 'Europe/Prague') >= 20 + or extract(hour from w2.interval_start at time zone 'Europe/Prague') < 6 + ), + enriched as ( + select + w.slot_ord, + w.interval_start, + w.buy_price, + w.sell_price, + w.is_predicted_price, + w.pv_a_forecast_w, + w.pv_b_forecast_w, + w.load_baseline_w, + w.ev1_connected, + w.ev2_connected, + w.allow_charge, + w.allow_discharge_export, + nt.night_wh as night_baseload_target_wh, + nt.night_wh * (v_night_buf_pct / 100.0) as night_baseload_buffer_wh, + case + when not v_daytime_en then null::numeric + when extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 19 then + least( + v_soc_max_wh, + v_reserve_wh + (nt.night_wh + nt.night_wh * (v_night_buf_pct / 100.0)) + * greatest( + 0::numeric, + least( + 1::numeric, + ( + extract(hour from w.interval_start at time zone 'Europe/Prague')::numeric + + ( + extract(minute from w.interval_start at time zone 'Europe/Prague')::numeric + / 60.0 + ) + - 6.0 + ) / 14.0 + ) + ) + ) + else null::numeric + end as safety_soc_target_wh, + coalesce( + max(w.buy_price) over ( + order by w.slot_ord rows between 1 following and unbounded following + ), + w.buy_price + ) as future_avoided_buy_czk_kwh, + coalesce( + max(w.sell_price) over ( + order by w.slot_ord rows between 1 following and unbounded following + ), + w.sell_price + ) as future_sell_opportunity_czk_kwh, + ( + extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 18 + and w.pv_surplus_w > 0 + ) as is_daytime_pv_surplus_slot + from _ems_plan_slot_wk w + cross join night_tot nt + ) select - w.slot_ord, - w.interval_start, - w.buy_price, - w.sell_price, - w.is_predicted_price, - w.pv_a_forecast_w, - w.pv_b_forecast_w, - w.load_baseline_w, - w.ev1_connected, - w.ev2_connected, - w.allow_charge, - w.allow_discharge_export - from _ems_plan_slot_wk w - order by w.slot_ord; + e.slot_ord, + e.interval_start, + e.buy_price, + e.sell_price, + e.is_predicted_price, + e.pv_a_forecast_w, + e.pv_b_forecast_w, + e.load_baseline_w, + e.ev1_connected, + e.ev2_connected, + e.allow_charge, + e.allow_discharge_export, + e.night_baseload_target_wh, + e.night_baseload_buffer_wh, + e.safety_soc_target_wh, + e.future_avoided_buy_czk_kwh, + e.future_sell_opportunity_czk_kwh, + e.is_daytime_pv_surplus_slot + from enriched e + order by e.slot_ord; end; $fn$; comment on function ems.fn_load_planning_slots_full(int, timestamptz, timestamptz, numeric) is '15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). ' 'Masky charge/discharge-export se berou zvlášť pro 00–12 a 12–24 Europe/Prague (polovina budgetu na segment). ' - 'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent).'; + 'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent). ' + 'Denní safety vstupy: night_baseload_* (20:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), ' + 'lookahead max buy/sell pro měkké LP penalizace.'; diff --git a/db/routines/R__087_fn_planning_run_debug.sql b/db/routines/R__087_fn_planning_run_debug.sql new file mode 100644 index 0000000..a56100c --- /dev/null +++ b/db/routines/R__087_fn_planning_run_debug.sql @@ -0,0 +1,76 @@ +-- Kompaktní JSON pro diagnostiku jednoho planning_run (MCP / UI). + +create or replace function ems.fn_planning_run_debug(p_run_id int) +returns jsonb +language plpgsql +stable +as $fn$ +declare + r_run ems.planning_run%rowtype; + v_intervals jsonb; + v_first_charge timestamptz; + v_first_bat_export timestamptz; + v_top_sell jsonb; +begin + select * into r_run from ems.planning_run where id = p_run_id; + if not found then + return null::jsonb; + end if; + + select coalesce(jsonb_agg(to_jsonb(pi.*) order by pi.interval_start), '[]'::jsonb) + into v_intervals + from ems.planning_interval pi + where pi.run_id = p_run_id; + + select pi.interval_start + into v_first_charge + from ems.planning_interval pi + where pi.run_id = p_run_id + and coalesce(pi.battery_setpoint_w, 0) > 500 + order by pi.interval_start + limit 1; + + select pi.interval_start + into v_first_bat_export + from ems.planning_interval pi + where pi.run_id = p_run_id + and coalesce(pi.battery_setpoint_w, 0) < -500 + and coalesce(pi.grid_setpoint_w, 0) < 0 + order by pi.interval_start + limit 1; + + select coalesce( + jsonb_agg( + jsonb_build_object( + 'interval_start', x.interval_start, + 'effective_sell_price', x.effective_sell_price + ) + order by x.effective_sell_price desc nulls last + ), + '[]'::jsonb + ) + into v_top_sell + from ( + select pi.interval_start, pi.effective_sell_price + from ems.planning_interval pi + where pi.run_id = p_run_id + order by pi.effective_sell_price desc nulls last + limit 3 + ) x; + + return jsonb_build_object( + 'planning_run', to_jsonb(r_run), + 'solver_params', r_run.solver_params, + 'intervals', v_intervals, + 'summary', jsonb_build_object( + 'first_charge_slot', to_jsonb(v_first_charge), + 'first_battery_export_slot', to_jsonb(v_first_bat_export), + 'top_sell_slots', v_top_sell, + 'solver_params_version', r_run.solver_params->'version' + ) + ); +end; +$fn$; + +comment on function ems.fn_planning_run_debug(int) is + 'Jeden jsonb: metadata planning_run, solver_params, všechny planning_interval řádky a krátký summary.'; diff --git a/docs/03-data-model.md b/docs/03-data-model.md index d7ae516..1cabb4f 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -127,6 +127,8 @@ CREATE TABLE asset_battery ( -- planner_max_soc_percent, planner_discharge_floor_percent, -- planner_extreme_buy_threshold_czk_kwh, -- planner_terminal_soc_value_factor + -- V077: planner_daytime_charge_target_enabled, planner_night_baseload_buffer_percent, + -- planner_daytime_charge_price_quantile, planner_charge_commitment_penalty_czk_kwh ); ``` @@ -359,7 +361,7 @@ CREATE TABLE planning_run ( horizon_end TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), status TEXT DEFAULT 'draft', -- 'draft', 'approved', 'active', 'superseded' - solver_params JSONB, + solver_params JSONB, -- po V077: JSON z planning_engine (masks, soc_bounds, objective_terms, …) notes TEXT ); ``` diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index 12fe0f3..1d99ea4 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -150,9 +150,9 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg |---|---| | **SELL** | `grid_setpoint_w` < 0 **a** `battery_w` < 0 | | **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 | -| **PASSIVE (ZERO)** | vše ostatní — reg. **108/109** dle `_deye_zero_export_amps_for_passive` (viz `operating-modes.md`) | +| **PASSIVE (ZERO)** | vše ostatní — reg. **108/109** z `deye_battery_charge_discharge_amps()` v `setpoints.py` (volá `write_inverter_setpoints`) | -**PASSIVE** (AUTO, ZERO): výchozí **108** i **109** = maximum z DB; u exportu bez vybíjení **108 = 0**, u importu bez nabíjení **109 = 0** (`_deye_zero_export_amps_for_passive`). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **143** je tvrdý limit exportu z lokality/invertoru (ne forecast). Reg. **145** (solar sell): **0** při `export_ban` mimo SELL, jinak **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*). +**PASSIVE** (AUTO, ZERO): proudy **108/109** počítá **`deye_battery_charge_discharge_amps`**: pokud plán žádá **nabíjení** (`battery_w > 0`) včetně scénáře **PV přebytek + export do sítě** (`grid_setpoint_w < 0`), nastaví se **kladný nabíjecí proud (108)** a **109 = 0** — nesmí se použít čistě „zero export“ větev, která by při exportu vynutila **108 = 0** a rozbila soulad plán ↔ Deye. Jinak platí `_deye_zero_export_amps_for_passive` (export bez nabíjení → 108 = 0, import bez vybíjení → 109 = 0). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **143** je tvrdý limit exportu z lokality/invertoru (ne forecast). Reg. **145** (solar sell): **0** při `export_ban` mimo SELL, jinak **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*). **SELF_SUSTAIN** zůstává **PASSIVE** v `get_deye_mode`; **108/109** jsou vždy **max z DB** (bez variant ZERO). Rozdíl je **`self_sustain_local_use=True`**: **TOU SOC** = **`min_soc_percent`**, `battery_w=None`. @@ -160,8 +160,8 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg | Registr | Charge | PASSIVE (ZERO) | SELL | Self-consumption | |---|---|---|---|---| -| **108** (charge A) | škálo dle `battery_w` | max / **0** (FVE přetok) | **0** | dle varianty | -| **109** (discharge A) | **0** | max / **0** (import, držet bat.) | **max z DB** | dle varianty | +| **108** (charge A) | škálo dle `battery_w` | max / **0** (export bez `battery_w>0`) / **>0** při `battery_w>0` i při exportu | **0** | dle varianty | +| **109** (discharge A) | **0** | max / **0** (import, držet bat.) / **0** při `battery_w>0` + export z PV | **max z DB** | dle varianty | | **142** (limit control) | `deye_zero_export_mode` | `deye_zero_export_mode` | **0** (selling first) | `deye_zero_export_mode` | | **143** (export cap) | max z DB | max z DB | max z DB (tvrdý limit, bez forecast heuristiky) | max z DB | | **145** (solar sell) | 1 / 0 při `export_ban` | 1 / 0 při `export_ban` | 1 | 1 / 0 při `export_ban` | diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 65c62af..8863018 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -10,6 +10,9 @@ - **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 × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon). - **Masky `allow_charge` / `allow_discharge_export` (anti-mikrocyklování):** generuje `ems.fn_load_planning_slots_full`. Důležité: pokud rolling replan startuje s baterií na 100 %, `allow_charge` se nesmí stát globálně `false` pro celý horizont – jinak solver nemá motivaci baterii před PV špičkou „uvolnit“ (headroom), protože ji pak nesmí z PV znovu nabít. Aktuálně se v tomto případě `allow_charge` ponechá povolené alespoň pro sloty s `pv_surplus_w > 0`. +- **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:00–06:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (6–19) a flag `is_daytime_pv_surplus_slot`. `planning_engine.solve_dispatch()` přidá proměnné deficit vůči cíli a penalizaci `max(future_buy, future_sell) − degradace` (clamp), aby šlo prodat ve velmi drahém sell okně i přes deficit. Tvrdé `allow_charge` se kvůli tomu nemění. +- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0`; měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu (`planner_charge_commitment_penalty_czk_kwh` na `asset_battery`). Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`. +- **Debug snapshot:** každý běh ukládá JSON do `ems.planning_run.solver_params` (sekce `version`, `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`) přes `fn_planning_run_commit` (`p_run_meta->'solver_params'`). Read-model: **`select ems.fn_planning_run_debug();`** (`R__087_fn_planning_run_debug.sql`). - **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:** @@ -498,6 +501,8 @@ COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS ## Tuning pro malé baterie (např. BA81) +Kromě **`planner_terminal_soc_value_factor`** existují od **V077** měkké mechanismy **denní safety charge** a **rolling charge commitment** (viz výše) — malé instalace nelze spolehlivě stabilizovat jen slepým zvyšováním terminal faktoru na **0.9**. + ### Terminal SoC shadow price (kritický parametr) V účelové funkci LP je člen **„terminal SoC shadow price“**: energie ponechaná v baterii na konci horizontu je oceněná jako záporný příspěvek k nákladům (solver má motivaci držet část SoC, pokud se to ekonomicky vyplatí oproti okamžitému vývozu / nákupu). diff --git a/docs/04-modules/provozni-rezimy-checklist.md b/docs/04-modules/provozni-rezimy-checklist.md new file mode 100644 index 0000000..e8263d9 --- /dev/null +++ b/docs/04-modules/provozni-rezimy-checklist.md @@ -0,0 +1,293 @@ +# Provozní režimy EMS - praktický přehled + +Tenhle dokument je zkrácený provozní cheat sheet. Cíl je jednoduchý: + +- rychle poznat, co EMS v daném slotu dělá +- umět to porovnat s `planning_interval` +- ověřit to na live registrech Deye a ve FE + +## 1. Co je zdroj pravdy + +- EMS provozní režim lokality: `ems.site_operating_mode.mode_code` +- aktivní plán: `ems.planning_interval` +- fyzická konfigurace invertoru: `asset_inverter` + `site_grid_connection` +- live stav Deye: registry 108, 109, 141, 142, 143, 145, 178, 340 + +Když něco nesedí, porovnávej vždy v tomto pořadí: + +1. `planning_interval` +2. `control/registers` na FE +3. `modbus_command` journal + +## 2. EMS režimy lokality + +### AUTO + +Normální provoz. EMS bere sloty z `planning_interval` a podle nich řídí Deye, EV, TČ a signály. + +V AUTO se pak mohou objevit sloty s různým exportním záměrem: + +- `PV_SURPLUS` +- `BATTERY_SELL` +- `NONE` + +### SELF_SUSTAIN + +Bezpečný provoz bez obchodní logiky. + +- Deye fyzicky běží v PASSIVE +- baterie se nechává pro vlastní spotřebu +- export je jen nouzový ventil, pokud je potřeba kvůli feasibility + +### CHARGE_CHEAP + +- nabíjení ze sítě +- export se nepoužívá +- fyzicky CHARGE + +### PRESERVE + +- baterie je uzamčená +- žádné nabíjení ani vybíjení +- fyzicky PASSIVE + +### MANUAL + +- EMS setpointy nezapisuje +- vše je ruční řízení + +## 3. Tvoje 5 provozních archetypů + +### 1. Standardní režim s přetokem + +Co tím myslíme: + +- baterie se normálně nabíjí i vybíjí podle plánu +- přetok do sítě je povolený +- exportní limit je jen tvrdý site / inverter cap +- když je baterie plná, přebytek FVE jde do sítě + +Jak to je v implementaci: + +- `export_mode = PV_SURPLUS` +- `export_limit_w = hard cap` +- `solar_sell = 1` +- `deye_physical_mode = PASSIVE` +- v PASSIVE se pro exportní slot typicky použije `108 = 0`, `109 = max` + +Poznámka: + +- exportní limit se už netipuje z forecastu +- neomezuješ tedy výkon do sítě podle předpovědi, jen podle hard capu + +### 2. Standardní režim s vypnutým přetokem + +Co tím myslíme: + +- `solar_sell = false` +- přebytek FVE se nesmí posílat do sítě +- jakmile je baterie plná, FVE se utlumí + +Jak to je v implementaci: + +- tohle není samostatný fyzický Deye režim +- většinou jde o kombinaci: + - `reg 143 = 0` nebo site `no_export` + - případně `export_ban = true` a `reg 145 = 0` +- fyzicky to pořád bývá PASSIVE + +Poznámka: + +- tohle je důležité ověřovat na `reg 143` a `reg 145`, ne jen na `grid_setpoint_w` + +### 3. Prodej přebytku do sítě bez nabíjení baterie + +Co tím myslíme: + +- baterie není cílem +- nechci ji nabíjet +- chci prodávat celou výrobu do sítě + +Jak to je v implementaci: + +- `export_mode = PV_SURPLUS` +- `solar_sell = 1` +- `export_limit_w = hard cap` + +Poznámka k implementaci: + +- tohle je v kódu garantované až ve chvíli, kdy planner dá `battery_setpoint_w = 0` +- pokud je `battery_setpoint_w > 0`, tak současná implementace už dovoluje i nabíjení baterie, i když exportní záměr zůstává `PV_SURPLUS` +- jinými slovy: čisté „prodávám výrobu, ale baterii nechci nabíjet“ ještě není samostatný fyzický Deye režim, je to kombinace plánovacího setpointu a exportního záměru + +Použití: + +- vhodné, když je výkupní cena vysoká +- baterii chceš šetřit na jiný slot + +### 4. Šetření baterie + +Co tím myslíme: + +- když je kupní cena nízká +- nechci brát energii z baterie +- raději budu kupovat ze sítě + +Jak to je v implementaci: + +- `battery discharge A = 0` +- fyzicky PASSIVE +- baterie se nevybíjí, ale podle slotu se může pořád nabíjet nebo držet + +Poznámka: + +- tohle je jiné než SELL +- tady jen chráníš baterii, neprodáváš ji + +### 5. Aktivní prodej do sítě z baterie + +Co tím myslíme: + +- `selling first` +- baterie prodává do sítě plným výkonem, co dovolí střídač / baterie / síť + +Jak to je v implementaci: + +- `export_mode = BATTERY_SELL` +- `deye_physical_mode = SELL` +- `reg 142 = 0` +- `reg 178 = 32` +- `reg 109` na max, `reg 108 = 0` + +## 4. Další režimy, které v praxi existují + +### CHARGE_CHEAP + +- nabíjení ze sítě +- export vypnutý +- fyzicky CHARGE + +### SELF_SUSTAIN + +- vlastní spotřeba +- fyzicky PASSIVE +- export jen jako nouzový ventil + +### PRESERVE + +- baterie uzamčená +- žádné řízení baterie + +### MANUAL + +- EMS nezasahuje + +## 5. Registry, které má smysl kontrolovat + +### 108 + +Max charge current. + +### 109 + +Max discharge current. + +### 142 + +Limit control: + +- `0` = selling first +- `1` = zero export to load +- `2` = zero export to CT + +### 143 + +Export cap. + +- tvrdý site / inverter limit +- neforecastuje se + +### 145 + +Solar sell: + +- `1` = povoleno +- `0` = zakázáno + +### 178 + +Bitové pole: + +- bits 4-5 = peak shaving switch +- bits 0-1 = GEN export cut-off u BA81 + +### 340 + +Max solar power pro řízenou FVE A. + +- není to exportní cap +- je to strop výroby pole A + +## 6. Co kontrolovat na FE + +### Planning page + +Zkontroluj: + +- `deye_physical_mode` +- `grid_setpoint_w` +- `export_limit_w` +- `export_mode` +- `deye_gen_cutoff_enabled` +- `effective_buy_price` +- `effective_sell_price` + +### Control panel + +Na živém panelu porovnej: + +- reg 142 +- reg 143 +- reg 145 +- reg 178 + +Reg 143 musí být vidět jako hard cap. + +## 7. Rychlá kontrola nesrovnalostí + +1. Najdi slot v `Planning` +2. Podívej se na: + - `battery_setpoint_w` + - `grid_setpoint_w` + - `export_limit_w` + - `export_mode` + - `deye_physical_mode` +3. Otevři `ControlPanel` +4. Porovnej live registry: + - 142 + - 143 + - 145 + - 178 +5. Podívej se do `modbus_command` + +## 8. Co je v implementaci důležité vědět + +Tady jsou dva praktické detaily: + +- `export_limit_w` se bere jako hard cap z lokality / invertoru +- export se už netipuje z forecastu + +To znamená: + +- při `PV_SURPLUS` se má pustit maximum, které dovoluje distribuce a HW +- při `BATTERY_SELL` se použije SELL a prodej z baterie +- při běžném importu / šetření baterie se exportní logika nemá „uhádnout“ z ceny nebo forecastu + +## 9. Kde hledat v kódu + +- plánování: `backend/services/planning_engine.py` +- mapování plánu na setpointy: `backend/services/control/setpoints.py` +- zápis Deye: `backend/services/control/inverter.py` +- live registry: `backend/app/routers/sites.py` +- FE plánování: `frontend/src/pages/Planning.tsx` +- FE live registry: `frontend/src/components/ControlPanel.tsx` diff --git a/docs/07-mcp-postgres-ems.md b/docs/07-mcp-postgres-ems.md index e57a217..586ce43 100644 --- a/docs/07-mcp-postgres-ems.md +++ b/docs/07-mcp-postgres-ems.md @@ -61,6 +61,11 @@ limit 10; select ems.fn_plan_explain_bundle(2, 6); ``` +```sql +-- Diagnostika posledního běhu plánovače (run_id z planning_run) +select ems.fn_planning_run_debug(8107); +``` + Měnící funkce (**`ems.fn_delete_forecast_pv_prague_calendar_day`**, **`ems.fn_rebuild_consumption_baseline_stats`**, …) MCP přes **`query` neprovede**, pokud má server jen read-only práva na DB — použij psql aplikačním účtem. ---