uprava vypoctu slotu
This commit is contained in:
@@ -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` |
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), '
|
'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).';
|
||||||
|
|||||||
150
docs/04-modules/planning-arbitrage-accounting.md
Normal file
150
docs/04-modules/planning-arbitrage-accounting.md
Normal 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ě 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, <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 **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.*
|
||||||
@@ -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, 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).
|
- **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á.
|
- **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: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í.
|
- **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)
|
- 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í:
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user