diff --git a/CLAUDE.md b/CLAUDE.md index 119b9ab..2130127 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ Když uživatel napíše **„použij MCP“** nebo potřebuje **aktuální řá 7. **Záporná nákupní cena → omezit import** na realistický horní strop (viz `solve_dispatch` v `planning_engine.py` – nesmí „nekonečný“ import). -8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků – dle docs). **Ekonomika slotů:** masky v `fn_load_planning_slots_full` (store_score, ref_buy horizontu, lookahead VT→NT) + v `solve_dispatch` guardy `sell` vs `buy` (`ge_pv`, `gi`) — plán nesmí paralelně vyvážet FVE za haléře a nabíjet ze sítě za Kč; viz `docs/04-modules/planning.md`. +8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků – dle docs). **Ekonomika slotů:** masky + guardy v `solve_dispatch` — viz `docs/04-modules/planning.md`. **Arbitráž baterie:** neúčtovat `buy[t]`/`sell[t]` ve stejném 15min slotu jako nákup/prodej téže kWh; `min(buy)` horizontu ≠ cena nabití (home-01 nabíjí hodiny, ne jednu čtvrthodinu). Povinné: `docs/04-modules/planning-arbitrage-accounting.md`. 9. **Zelený bonus je na `asset_pv_array`** (sloupce `green_bonus_*`), **nikdy** v `site_market_config`. Výpočet přes `fn_green_bonus_revenue()`. Bonus se nepočítá v solveru – pouze v audit_filler (`fn_fill_audit_interval`). @@ -202,6 +202,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan | Deye registry (FC 0x10, 108/109/141/142/178/143/145/340) | `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, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` | +| Arbitráž baterie (mezi sloty ≠ buy/sell v jednom 15min) | `docs/04-modules/planning-arbitrage-accounting.md` | | Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `db/routines/R__044_fn_set_mode.sql` | | EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` | | Curtailment A, zelený bonus B | `db/migration/V005__planning_curtailment.sql` | diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 5614c80..b3c3a6d 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -335,6 +335,9 @@ class PlanningSlot: future_avoided_buy_czk_kwh: float | None = None future_sell_opportunity_czk_kwh: float | None = None is_daytime_pv_surplus_slot: bool = False + #: Vážená nákupní / opportunity cena zásoby před prvním exportním oknem (SQL odhad z masek). + charge_acquisition_buy_czk_kwh: float | None = None + charge_acquisition_cutoff_at: datetime | None = None # Lookahead pro relax spodní meze SoC: až 36 h od indexu slotu (pevné OTE ceny v horizontu). @@ -759,6 +762,13 @@ def solve_dispatch( avg_buy_terminal * terminal_factor / 1000.0 ) + charge_acq_raw = getattr(slots[0], "charge_acquisition_buy_czk_kwh", None) + charge_acquisition_czk_kwh = ( + float(charge_acq_raw) + if charge_acq_raw is not None + else min(float(s.buy_price) for s in slots) + ) + # Kotva: poslední slot před prvním sell<0 by měl končit u planner floor (pokud relaxace existuje). # Slack penalizujeme v objective; samotné omezení přidáme až po definici soc. first_neg_sell_idx = next((i for i, s in enumerate(slots) if float(s.sell_price) < 0), None) @@ -813,6 +823,8 @@ def solve_dispatch( commit_lp.append((t, cv, cap_prev)) # --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) --- + # Arbitráž baterie: ge_bat v exportních slotech + charge_acquisition (SQL, před 1. exportem). + # Viz docs/04-modules/planning-arbitrage-accounting.md — ne stejnoslotové buy/sell. prob += ( pulp.lpSum( gi[t] * slots[t].buy_price * INTERVAL_H / 1000 @@ -829,6 +841,11 @@ def solve_dispatch( ) + gi_over[t] * IMPORT_OVER_BREAKER_PENALTY_CZK_KWH * INTERVAL_H / 1000 + 0.5 * (bc[t] + bd[t]) * degradation_cost_effective * INTERVAL_H / 1000 + + ( + ge_bat[t] * charge_acquisition_czk_kwh * INTERVAL_H / 1000 + if om == "AUTO" and t in discharge_export_slots + else 0 + ) + pulp.lpSum( ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000 + ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000 @@ -1309,6 +1326,12 @@ def solve_dispatch( "planner_daytime_charge_target_enabled": daytime_en, "planner_charge_commitment_penalty_czk_kwh": float(commit_pen), }, + "charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh, + "charge_acquisition_cutoff_at": ( + slots[0].charge_acquisition_cutoff_at.isoformat() + if slots[0].charge_acquisition_cutoff_at is not None + else None + ), }, "masks": masks_snap, "soc_bounds": soc_bounds_snap, @@ -1872,7 +1895,8 @@ async def _load_slots( 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 + is_daytime_pv_surplus_slot, + charge_acquisition_buy_czk_kwh, charge_acquisition_cutoff_at from ems.fn_load_planning_slots_full( $1::int, $2::timestamptz, $3::timestamptz, $4::numeric ) @@ -1906,6 +1930,10 @@ async def _load_slots( d, "future_sell_opportunity_czk_kwh" ), is_daytime_pv_surplus_slot=bool(d.get("is_daytime_pv_surplus_slot", False)), + charge_acquisition_buy_czk_kwh=_slot_float_nullable( + d, "charge_acquisition_buy_czk_kwh" + ), + charge_acquisition_cutoff_at=d.get("charge_acquisition_cutoff_at"), ) ) if not out: diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 78f02c0..77c8ad8 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1359,5 +1359,67 @@ class SpreadGuardHome01EconomicsTests(unittest.TestCase): self.assertLessEqual(vt_before_nt.battery_setpoint_w, 2_000) +class ChargeAcquisitionArbitrageTests(unittest.TestCase): + """Mezi-slotová arbitráž: večerní export při nízké charge_acquisition z SQL.""" + + def test_evening_battery_export_when_sell_above_acquisition(self) -> None: + base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc) + cheap = (0.75, 0.25) + peak = (7.0, 4.8) + slots: list[PlanningSlot] = [] + for i in range(6): + buy, sell = cheap if i < 2 else peak + slots.append( + PlanningSlot( + interval_start=base + timedelta(minutes=15 * i), + buy_price=buy, + sell_price=sell, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=800, + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + allow_charge=i < 2, + allow_discharge_export=i >= 2, + charge_acquisition_buy_czk_kwh=0.75, + charge_acquisition_cutoff_at=base + timedelta(minutes=30), + ) + ) + battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.5) + battery.max_charge_power_w = 17_000 + battery.max_discharge_power_w = 17_000 + 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.78 * battery.usable_capacity_wh + results, _ms, snap = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + soc0, + 50.0, + operating_mode="AUTO", + ) + self.assertAlmostEqual( + snap["inputs"]["charge_acquisition_buy_czk_kwh"], + 0.75, + places=2, + ) + evening = results[3] + self.assertLess( + evening.grid_setpoint_w, + -1_000, + msg="high sell vs low acquisition should motivate grid export", + ) + self.assertLess(evening.battery_setpoint_w, -500) + + if __name__ == "__main__": unittest.main() 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 9664aa2..77e6d0f 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -30,7 +30,9 @@ returns table ( safety_soc_target_wh numeric, future_avoided_buy_czk_kwh numeric, future_sell_opportunity_czk_kwh numeric, - is_daytime_pv_surplus_slot boolean + is_daytime_pv_surplus_slot boolean, + charge_acquisition_buy_czk_kwh numeric, + charge_acquisition_cutoff_at timestamptz ) language plpgsql volatile @@ -69,6 +71,12 @@ declare v_buy_lookahead_eps numeric := 0.05; v_grid_slots_am int := 0; v_grid_slots_pm int := 0; + v_acquisition_cutoff timestamptz; + v_charge_acquisition numeric; + v_est_grid_wh numeric; + v_est_pv_wh numeric; + v_est_grid_cost numeric; + v_est_pv_cost numeric; begin drop table if exists _ems_plan_slot_wk; create temp table _ems_plan_slot_wk on commit drop as @@ -428,6 +436,73 @@ begin end loop; end if; + -- Vážená acquisition cena zásoby (grid + FVE opportunity) jen pro sloty PŘED prvním + -- plánovaným exportem z baterie — nepočítá nákup po večerním/nočním vybití do sítě. + select min(wk.interval_start) + into v_acquisition_cutoff + from _ems_plan_slot_wk wk + where wk.allow_discharge_export; + + select + coalesce(sum( + case + when wk.allow_charge + and ( + v_acquisition_cutoff is null + or wk.interval_start < v_acquisition_cutoff + ) + then v_per_slot_charge_wh + else 0 + end + ), 0), + coalesce(sum( + case + when wk.pv_surplus_w > 0 + and ( + v_acquisition_cutoff is null + or wk.interval_start < v_acquisition_cutoff + ) + then least(wk.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25 + else 0 + end + ), 0), + coalesce(sum( + case + when wk.allow_charge + and ( + v_acquisition_cutoff is null + or wk.interval_start < v_acquisition_cutoff + ) + then wk.buy_price * v_per_slot_charge_wh + else 0 + end + ), 0), + coalesce(sum( + case + when wk.pv_surplus_w > 0 + and ( + v_acquisition_cutoff is null + or wk.interval_start < v_acquisition_cutoff + ) + then coalesce(wk.future_sell_lookahead, wk.sell_price) + * least(wk.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25 + else 0 + end + ), 0) + into + v_est_grid_wh, + v_est_pv_wh, + v_est_grid_cost, + v_est_pv_cost + from _ems_plan_slot_wk wk; + + if (v_est_grid_wh + v_est_pv_wh) > 0 then + v_charge_acquisition := (v_est_grid_cost + v_est_pv_cost) + / (v_est_grid_wh + v_est_pv_wh); + else + v_charge_acquisition := v_ref_buy_czk_kwh; + end if; + return query with night_tot as ( select coalesce(sum(w2.load_baseline_w), 0) * 0.25 as night_wh @@ -511,7 +586,9 @@ begin e.safety_soc_target_wh, e.future_avoided_buy_czk_kwh, e.future_sell_opportunity_czk_kwh, - e.is_daytime_pv_surplus_slot + e.is_daytime_pv_surplus_slot, + v_charge_acquisition as charge_acquisition_buy_czk_kwh, + v_acquisition_cutoff as charge_acquisition_cutoff_at from enriched e order by e.slot_ord; end; @@ -524,4 +601,6 @@ comment on function ems.fn_load_planning_slots_full is 'ref_buy = min(buy) horizontu. Discharge-export: nejdražší sell kde sell>ref_buy+degrad (spot). ' '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.'; + 'lookahead max buy/sell pro měkké LP penalizace. ' + 'charge_acquisition_buy_czk_kwh: vážený průměr grid (allow_charge) + FVE (pv_surplus, opportunity future_sell) ' + 'jen pro sloty před charge_acquisition_cutoff_at (= začátek prvního allow_discharge_export).'; diff --git a/docs/04-modules/planning-arbitrage-accounting.md b/docs/04-modules/planning-arbitrage-accounting.md new file mode 100644 index 0000000..810a2e1 --- /dev/null +++ b/docs/04-modules/planning-arbitrage-accounting.md @@ -0,0 +1,150 @@ +# Plánování: arbitráž a účtování energie (mezi sloty vs. v jednom slotu) + +**Účel:** Trvalá poznámka pro implementaci i pro agenty — aby se neopakovala chyba „řešit arbitráž přes `buy` a `sell` ve stejném 15min slotu“ nebo přes **`min(buy)` celého horizontu** jako nákupní cenu uložené energie. + +**Související:** [`planning.md`](planning.md), [`planning_engine.py`](../../backend/services/planning_engine.py) (`solve_dispatch`), [`R__063_fn_load_planning_slots_full.sql`](../../db/routines/R__063_fn_load_planning_slots_full.sql). + +--- + +## 1. Co uživatel / provoz očekává (správný model) + +Arbitráž baterie je **časový posun**: + +1. V **levných hodinách** (může jich být **více za sebou**) nabít z site — např. home-01: baterie **64 kWh**, import z site typicky až **17 kW** → za 15 min až ~**4,25 kWh** ze sítě na slot, ale **klidně 8–16 slotů** (2–4 h) dokud cena sedí. +2. V **drahých / výkupních hodinách** (jiný čas) stejnou energii prodat do sítě nebo ušetřit drahý import domu. + +Ekonomický přínos je přibližně: + +```text +zisk ≈ (efektivní sell ve výprodejním okně) + − (efektivní buy v nabíjecím okně) + − degradace cyklu / účinnost +``` + +**Není to** rozhodnutí „v tomto jednom 15min okně koupím za 7 Kč a prodám za 4,6 Kč“ — ve výprodejním slotu se **nekupuje** energie určená k exportu z baterie; ta byla nabitá dříve za jinou cenu. + +--- + +## 2. Co dělá dnešní LP (a proč to arbitráž láme) + +### 2.1 Účelová funkce je po slotech + +V `solve_dispatch` je v každém slotu `t` zhruba: + +```text +náklad += gi[t] × buy[t] +výnos -= ge[t] × sell[t] +``` + +Energetická bilance je také **per slot** (15 min). Když solver v evening slotu zvýší `ge_bat` (export baterie), bilance často vyžaduje současně `gi` (síť krmí dům) a `bd`/`ge_bat`. Marginalně pak vypadá každá vyvezená kWh jako: + +- „koupeno“ za **`buy[t]`** večer (např. 7 Kč/kWh), +- „prodáno“ za **`sell[t]`** večer (např. 4,6 Kč/kWh), + +→ v jednom okně **ztráta**, i když energie v baterii pochází z poledních **0,7 Kč/kWh**. + +**Závěr:** Samotné opravy typu „přidat `ge_bat × (sell − ref_buy)`“ **nestačí**, pokud `ref_buy` je jedna čísla z jednoho slotu — pořád myslíme příliš v rámci jednoho okna. Cíl je **oddělit nákupní okno od výprodejního okna** v ekonomice modelu. + +### 2.2 Guardy `sell < buy` ve stejném slotu + +Tvrdé zákazy typu `ge_pv = 0` když `sell[t] < buy[t]` jsou správné pro **FVE export v tomže slotu** (fyzicky exportovat za haléř při drahém VT). + +Pro **baterii** stejný test v **exportním** slotu **nesmí** být jediná logika arbitráže — večer téměř vždy `sell[t] < buy[t]` (VT/NT vs výkupní marže), přesto má smysl **vybíjet do sítě** energii nabitou v levném okně. + +--- + +## 3. Proč `min(buy)` přes celý horizont **není** nákupní cena zásoby + +`min(buy_price)` v horizontu je **jeden** 15min slot (nejlevnější čtvrthodina). + +| home-01 (typicky) | Hodnota | +|-------------------|--------| +| Kapacita baterie | 64 kWh | +| Max import ze site | 17 kW | +| Max energie ze sítě / slot (15 min) | 17 kW × 0,25 h ≈ **4,25 kWh** | +| Počet slotů na „plné“ grid nabíjení | až **~15** slotů (≈ 64/4,25), tedy **hodiny** | + +**Min buy** tedy popisuje **špičku** v jednom čtvrthodině, ne průměrnou cenu energie, kterou skutečně natankujeme přes **dlouhé nabíjecí okno**. + +Použití `min(buy)` jako „acquisition cost“ pro večerní export: + +- **podhodnotí** skutečný náklad, pokud nabíjíme i v druhém/třetím levném slotu s vyšší cenou; +- **neříká nic** o tom, kolik energie v levném pásmu vůbec nabít — to řeší masky `allow_charge` a rozpočet Wh, ne jedna čísla. + +**Kde je `min(buy)` dnes OK:** hrubá **brána** („existuje v horizontu levný nákup?“), výběr slotů pro vrstvu B (`buy ≤ min + ε`), **ne** jako jediná proměnná pro výpočet zisku z baterie. + +--- + +## 4. Co používat místo toho (směr návrhu) + +| Pojem | Význam | Poznámka | +|--------|--------|----------| +| **`buy_charge_window`** | Nákupní cena energie do baterie | Odvozená z **množiny nabíjecích slotů** (`allow_charge` / skutečný `bc`+`gi`), ne z jednoho minima | +| **`sell_discharge_window`** | Výkup při vybíjení do sítě | Např. průměr / percentil `sell` v `allow_discharge_export` slotech | +| **Spread** | `sell_discharge − buy_charge − degradace` | Rozhoduje, zda má smysl večer `ge_bat` | + +Příklady výpočtu **`buy_charge`** (zvolit jednu politiku v implementaci): + +1. **Průměr přes `allow_charge` sloty** (vážený 0/1 — všechny povolené sloty stejně). +2. **Průměr přes N nejlevnějších slotů**, kde N = počet slotů potřebných na dobití: + `ceil(energy_to_fill_wh / (max_charge_w × η × 0,25 h))`. +3. **Vážený průměr** `sum(buy[t] × charge_wh[t]) / sum(charge_wh[t])` z výsledku LP (až po solve — iterace nebo aproximace před solve z masky). + +Pro **home-01** při nabíjení 11:00–14:00 za ~0,7–0,9 Kč a výprodeji 19:00–22:00 za ~3,5–5,5 Kč je spread řádově **2–4 Kč/kWh** — to LP dnes nevidí, pokud účtuje večerní `buy[t]`. + +--- + +## 5. Co **nedělat** v dalších iteracích + +- Navrhovat „opravu arbitráže“ jen jako **`sell[t] − min(buy horizontu)`** v objective — **min buy je jeden slot**, nabíjení je **více hodin**. +- Zaměňovat **stejnoslotové** `buy`/`sell` s **mezi-slotovou** arbitráží — uživatel to explicitně považuje za nesmysl. +- Očekávat, že zvýšení `allow_discharge_export` samo spustí večerní **SELL**, když objective pořád trestá export při `buy[t] > sell[t]` ve stejném slotu. + +--- + +## 6. Implementace (2026-05) a backlog + +### Hotovo (jednoduchá varianta před solve) + +1. **`ems.fn_load_planning_slots_full`** (`R__063`): sloupce + `charge_acquisition_buy_czk_kwh`, `charge_acquisition_cutoff_at`. +2. **Vážený průměr** jen pro sloty **před** prvním `allow_discharge_export` (`cutoff = min(interval_start)` exportních slotů): + - **grid:** `buy × per_slot_charge_wh` pro `allow_charge`; + - **FVE:** `future_sell_lookahead` (fallback `sell`) × odhad Wh z `pv_surplus` (`least(surplus, max_charge)×η×0,25 h`). +3. **`solve_dispatch`:** v exportních slotech (`allow_discharge_export`) přičíst k objective + `+ ge_bat[t] × charge_acquisition × INTERVAL_H/1000` (náklad uložené energie), ponechat `−ge×sell`. Snapshot v `solver_params.inputs`. +4. **FVE opportunity:** varianta **B** — lookahead `future_sell_opportunity`, ne jen `sell[t]` v odhadu PV Wh. + +### Zbývá / vylepšit + +1. **Iterace po solve:** přepočítat acquisition z plánovaných `bc`+`gi` / `pv` místo odhadu z masek — viz [`docs/05-todo.md`](../05-todo.md). +2. **Objective:** explicitní `ge_bat × (sell − acquisition − degrad)` vs současné `−ge×sell` + `+ge_bat×acquisition` (ekvivalentní jen pokud `ge_pv` v exportním slotu ≈ 0). +3. **Masky:** více charge slotů ∝ `energy_to_fill / per_slot_wh` — viz [`planning.md`](planning.md). +4. **Bilance / guardy:** zpřesnit, aby večerní export nebyl vázaný na falešný `gi×buy` v tomže slotu. + +--- + +## 7. Ověření po změně (home-01) + +```sql +-- levné okno: víc allow_charge, rozumný buy_charge (~0.7–1.0) +select interval_start at time zone 'Europe/Prague' as t, + buy_price, allow_charge +from ems.fn_load_planning_slots_full(2, , , ) +where buy_price < 1.2 +order by 1; + +-- večer: BATTERY_SELL, záporný grid_setpoint +select interval_start at time zone 'Europe/Prague' as t, + effective_buy_price, effective_sell_price, + battery_setpoint_w, grid_setpoint_w, export_mode +from ems.planning_interval +where run_id = + and extract(hour from interval_start at time zone 'Europe/Prague') between 19 and 22; +``` + +Očekávání: SoC před večerem **70–90 %** po levném pásmu; večer **export do sítě** v špičce sell, ne jen ~2 kW do domu. + +--- + +*Poslední aktualizace: 2026-05 — charge_acquisition před 1. exportem + LP `ge_bat` náklad; iterace po solve v TODO.* diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 1bbb5c6..c3c7117 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -12,7 +12,7 @@ - **Masky `allow_charge` / `allow_discharge_export` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie. - **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity − sell − max(0, buy−sell)`; jen sloty s `sell ≥ buy − degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP). - **Non-PV grid (vrstva B):** jen **spot** nákup (`purchase_pricing_mode <> 'fixed'`), `buy ≤ min(buy horizontu) + degradation`, **lookahead** `buy ≤ min(buy v příštích 4 slotech) + 0,05 Kč` (VT→NT), max **6 slotů** AM a PM; AM/PM rozpočet 50/50 z `grid_target`. **KV1/fixed:** vrstva B vypnutá. - - **`ref_buy` pro export baterie:** `min(buy_price)` celého horizontu (ne jen z `allow_charge`). Export sloty: `sell > ref_buy + degradation` (spot) / `sell > degradation` (fixed). + - **`ref_buy_min` (brána exportu):** `min(buy_price)` horizontu — jen „existuje levný nákup?“, **ne** průměrná cena nabití přes hodiny. Export sloty: `sell > ref_buy_min + degradation` (spot). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md). - Pokud `energy_to_fill <= 0` nebo `charge_slot_buffer = 0`: všechny sloty povoleny. - **LP ekonomické guardy** (`solve_dispatch`, AUTO): pokud `sell < buy − degradation` → `ge_pv=0` (výjimka: plná baterie, přebytek **pv_b**). Pokud `buy > min(buy)+degradation` → `gi` jen na load+EV+TČ. Viz `planning_engine.py` sekce po slot pre-selection. - **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`.\n+\n+ V solveru (`planning_engine.solve_dispatch()`):\n+ - `safety_soc_target_wh` se používá primárně jako **ochrana exportu z baterie**: v běžných slotech (mimo high‑sell špičky) se při aktivním exportu vynutí `soc[t] ≥ max(arb_base_wh, safety_soc_target_wh)`.\n+ - safety deficit penalizace v objective běží jen v `is_daytime_pv_surplus_slot` (a ne v high‑sell špičce), aby solver neměl motivaci dělat obecné „nabij co nejdřív“ chování.\n+ Tvrdé `allow_charge` se kvůli tomu nemění. @@ -47,6 +47,15 @@ Solver optimalizuje celý horizont (typicky do konce známých OTE dat, strop z - 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 +### Arbitráž baterie — účtování mezi sloty (povinné čtení) + +**Detail:** [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md). + +- **Nesmysl:** řídit arbitráž tak, že v **jednom 15min slotu** porovnáváme `buy[t]` a `sell[t]` jako nákup a prodej **téže** kWh z baterie. Ve výprodejním okně (např. sell 4,6 Kč, buy 7 Kč) je LP marginalně proti exportu, i když energie byla nabitá v poledne za ~0,7 Kč. +- **`min(buy)` horizontu není nákupní cena zásoby** — je to **jeden** čtvrthodinový slot; u home-01 lze nabíjet **hodiny** (64 kWh, až 17 kW ze site ≈ 4,25 kWh/slot). Acquisition cost musí vycházet z **nabíjecího okna** (průměr / vážený průměr / N nejlevnějších slotů podle potřebných Wh), ne z jednoho minima. +- Dnešní `ref_buy = min(buy)` ve maskách je jen **hrubá brána** pro výběr slotů, ne model zisku z cyklu. +- **Arbitráž baterie:** `charge_acquisition_buy_czk_kwh` z `fn_load_planning_slots_full` (vážený grid+FVE před `charge_acquisition_cutoff_at`); LP přičítá `ge_bat × acquisition` v `allow_discharge_export`. Detail: [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md). + ### Verifikace (DB) Pro kontrolu masek nabíjení: diff --git a/docs/05-todo.md b/docs/05-todo.md index 98ab211..1fbc92c 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -22,6 +22,15 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec --- +## Brzké vylepšení (plánování / arbitráž) + +| Popis | Kde | Kdo | +|-------|-----|-----| +| **`charge_acquisition` po solve:** druhé kolo — vážený průměr z plánovaných `bc`+`gi` a PV nabíjení místo odhadu z masek před solve; případně jedna iterace solve. Cutoff před 1. exportem už je v SQL. | `R__063_fn_load_planning_slots_full.sql`, `planning_engine.py`; [`planning-arbitrage-accounting.md`](04-modules/planning-arbitrage-accounting.md) §6 | programátor | +| **Více levných charge slotů** v masce (N ∝ `energy_to_fill / per_slot_wh`, ne jen cap 6). | `R__063` | programátor | + +--- + ## Budoucí vylepšení (PV kalibrace) | Popis | Kde | Kdo | diff --git a/docs/06-open-questions.md b/docs/06-open-questions.md index db0fa78..8c1607c 100644 --- a/docs/06-open-questions.md +++ b/docs/06-open-questions.md @@ -18,6 +18,8 @@ Tento soubor slouží jako živý seznam věcí které je potřeba rozhodnout p ## Důležité (neblokují, ale řeší se brzy) +- [x] **Arbitráž baterie — 1. vlna (před solve):** `charge_acquisition_buy_czk_kwh` + cutoff před 1. `allow_discharge_export`; LP `+ge_bat×acquisition` v exportních slotech. Zbývá iterace po solve a více charge slotů — [`planning-arbitrage-accounting.md`](04-modules/planning-arbitrage-accounting.md) §6, [`docs/05-todo.md`](05-todo.md). + - [ ] **Dvě úrovně min SoC v DB** – Dnes jedno `min_soc_percent` (provozní podlaha pro LP i TOU PASSIVE). Budoucí oddělení „tvrdé BMS minimum“ vs „plánovací minimum“ by vyžadovalo nový sloupec nebo politiku per site. - [ ] **TUV výkon** – Je TUV výkon měřitelný zvlášť nebo jen ON/OFF? Pokud jen ON/OFF, použijeme `asset_heat_pump.rated_heating_power_w` jako aproximaci.