uprava vypoctu slotu
Some checks failed
CI and deploy / migration-check (push) Failing after 16s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-21 12:36:03 +02:00
parent 08f1b6741a
commit b78597fdda
8 changed files with 346 additions and 6 deletions

View File

@@ -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). 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`). 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` | | 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` | | 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` | | 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` | | Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `db/routines/R__044_fn_set_mode.sql` |
| EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` | | EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` |
| Curtailment A, zelený bonus B | `db/migration/V005__planning_curtailment.sql` | | Curtailment A, zelený bonus B | `db/migration/V005__planning_curtailment.sql` |

View File

@@ -335,6 +335,9 @@ class PlanningSlot:
future_avoided_buy_czk_kwh: float | None = None future_avoided_buy_czk_kwh: float | None = None
future_sell_opportunity_czk_kwh: float | None = None future_sell_opportunity_czk_kwh: float | None = None
is_daytime_pv_surplus_slot: bool = False 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). # 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 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). # 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. # 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) 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)) commit_lp.append((t, cv, cap_prev))
# --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) --- # --- Úč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 += ( prob += (
pulp.lpSum( pulp.lpSum(
gi[t] * slots[t].buy_price * INTERVAL_H / 1000 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 + gi_over[t] * IMPORT_OVER_BREAKER_PENALTY_CZK_KWH * INTERVAL_H / 1000
+ 0.5 * (bc[t] + bd[t]) * degradation_cost_effective * 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( + pulp.lpSum(
ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000 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 + 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_daytime_charge_target_enabled": daytime_en,
"planner_charge_commitment_penalty_czk_kwh": float(commit_pen), "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, "masks": masks_snap,
"soc_bounds": soc_bounds_snap, "soc_bounds": soc_bounds_snap,
@@ -1872,7 +1895,8 @@ async def _load_slots(
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, night_baseload_target_wh, night_baseload_buffer_wh, safety_soc_target_wh,
future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh, 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( from ems.fn_load_planning_slots_full(
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric $1::int, $2::timestamptz, $3::timestamptz, $4::numeric
) )
@@ -1906,6 +1930,10 @@ async def _load_slots(
d, "future_sell_opportunity_czk_kwh" d, "future_sell_opportunity_czk_kwh"
), ),
is_daytime_pv_surplus_slot=bool(d.get("is_daytime_pv_surplus_slot", False)), 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: if not out:

View File

@@ -1359,5 +1359,67 @@ class SpreadGuardHome01EconomicsTests(unittest.TestCase):
self.assertLessEqual(vt_before_nt.battery_setpoint_w, 2_000) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -30,7 +30,9 @@ returns table (
safety_soc_target_wh numeric, safety_soc_target_wh numeric,
future_avoided_buy_czk_kwh numeric, future_avoided_buy_czk_kwh numeric,
future_sell_opportunity_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 language plpgsql
volatile volatile
@@ -69,6 +71,12 @@ declare
v_buy_lookahead_eps numeric := 0.05; v_buy_lookahead_eps numeric := 0.05;
v_grid_slots_am int := 0; v_grid_slots_am int := 0;
v_grid_slots_pm 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 begin
drop table if exists _ems_plan_slot_wk; drop table if exists _ems_plan_slot_wk;
create temp table _ems_plan_slot_wk on commit drop as create temp table _ems_plan_slot_wk on commit drop as
@@ -428,6 +436,73 @@ begin
end loop; end loop;
end if; 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 return query
with night_tot as ( with night_tot as (
select coalesce(sum(w2.load_baseline_w), 0) * 0.25 as night_wh select coalesce(sum(w2.load_baseline_w), 0) * 0.25 as night_wh
@@ -511,7 +586,9 @@ begin
e.safety_soc_target_wh, e.safety_soc_target_wh,
e.future_avoided_buy_czk_kwh, e.future_avoided_buy_czk_kwh,
e.future_sell_opportunity_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 from enriched e
order by e.slot_ord; order by e.slot_ord;
end; 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). ' '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). ' 'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent). '
'Denní safety vstupy: night_baseload_* (20:0006:00 Europe/Prague), safety_soc_target_wh (619), ' 'Denní safety vstupy: night_baseload_* (20:0006:00 Europe/Prague), safety_soc_target_wh (619), '
'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).';

View File

@@ -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ě 816 slotů** (24 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:0014:00 za ~0,70,9 Kč a výprodeji 19:0022:00 za ~3,55,5 Kč je spread řádově **24 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.71.0)
select interval_start at time zone 'Europe/Prague' as t,
buy_price, allow_charge
from ems.fn_load_planning_slots_full(2, <from>, <to>, <soc_wh>)
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 = <active_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 **7090 %** 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.*

View File

@@ -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. - **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, buysell)`; 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). - **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity sell max(0, buysell)`; 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á. - **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. - 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. - **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:0006:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (619) 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 highsell š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 highsell š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í. - **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:0006:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (619) 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 highsell š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 highsell š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) - pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie)
- kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu - kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu
### 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) ### Verifikace (DB)
Pro kontrolu masek nabíjení: Pro kontrolu masek nabíjení:

View File

@@ -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) ## Budoucí vylepšení (PV kalibrace)
| Popis | Kde | Kdo | | Popis | Kde | Kdo |

View File

@@ -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) ## 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. - [ ] **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. - [ ] **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.