uz me to nebavi
Some checks failed
CI and deploy / migration-check (push) Failing after 22s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-21 15:17:09 +02:00
parent fc0761fb2a
commit 649c9e9510
4 changed files with 56 additions and 38 deletions

View File

@@ -1095,13 +1095,30 @@ def solve_dispatch(
0.0, 0.0,
float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - load_t, float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - load_t,
) )
# Mezi-slotová arbitráž: ráno prodat FVE (sell > acquisition), odpoledne levně # Mezi-slotová FVE arbitráž: export jen když (prodat teď levný nákup později)
# nabít ze sítě — necpát přebytek do baterie jen protože sell < buy ve stejném slotu. # ≥ (večerní špička acquisition). Jinak drž PV v baterii na peak sell.
fso_t = float(
s.future_sell_opportunity_czk_kwh
if s.future_sell_opportunity_czk_kwh is not None
else sell_t
)
future_chg_buys = [
float(slots[ts].buy_price)
for ts in range(t + 1, T)
if ts in charge_slots
]
min_future_chg_buy = (
min(future_chg_buys)
if future_chg_buys
else charge_acquisition_czk_kwh
)
export_refill_net = sell_t - min_future_chg_buy
store_peak_net = fso_t - charge_acquisition_czk_kwh
cross_slot_pv_export = ( cross_slot_pv_export = (
t not in charge_slots t not in charge_slots
and pv_surplus_w > 0 and pv_surplus_w > 0
and sell_t >= charge_acquisition_czk_kwh + min_spread and future_chg_buys
and any(ts in charge_slots for ts in range(t + 1, T)) and export_refill_net >= store_peak_net + min_spread
) )
# Ztrátový export FVE (sell ≪ buy): zakázat jen pokud jde energii do baterie. # Ztrátový export FVE (sell ≪ buy): zakázat jen pokud jde energii do baterie.
# Výjimky: plná baterie (ventil), neriťitelné pv_b s přebytkem, cross-slot výše. # Výjimky: plná baterie (ventil), neriťitelné pv_b s přebytkem, cross-slot výše.
@@ -1115,11 +1132,6 @@ def solve_dispatch(
block_loss_pv_export = False block_loss_pv_export = False
if block_loss_pv_export: if block_loss_pv_export:
prob += ge_pv[t] == 0 prob += ge_pv[t] == 0
if cross_slot_pv_export:
prob += bc[t] == 0
prob += ge_pv[t] >= min(
pv_surplus_w, float(grid.max_export_power_w)
)
# Drahý nákup oproti horizontu: import jen na load + EV + TČ, ne na grid-nabíjení. # Drahý nákup oproti horizontu: import jen na load + EV + TČ, ne na grid-nabíjení.
if buy_t >= 0 and buy_t > ref_buy_horizon + min_spread: if buy_t >= 0 and buy_t > ref_buy_horizon + min_spread:
prob += gi[t] <= load_t + ev_cap_t + hp_rated_w prob += gi[t] <= load_t + ev_cap_t + hp_rated_w

View File

@@ -1420,39 +1420,41 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
) )
self.assertLess(evening.battery_setpoint_w, -500) self.assertLess(evening.battery_setpoint_w, -500)
def test_morning_pv_export_when_sell_above_acquisition_and_later_grid_charge(self) -> None: def test_no_pv_export_at_low_sell_when_evening_peak_much_higher(self) -> None:
"""Ráno sell>acquisition a sell<buy, později levné grid CHARGE — FVE→síť, ne do bat.""" """Odpolední sell ~1,4 a večer ~5,5 — PV do baterie, ne FVE→síť za haléř."""
base = datetime(2026, 5, 22, 3, 0, tzinfo=timezone.utc) base = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc)
morning = PlanningSlot( afternoon = PlanningSlot(
interval_start=base, interval_start=base,
buy_price=5.2, buy_price=4.5,
sell_price=3.4, sell_price=1.4,
pv_a_forecast_w=2000, pv_a_forecast_w=8000,
pv_b_forecast_w=0, pv_b_forecast_w=0,
load_baseline_w=600, load_baseline_w=2500,
ev1_connected=False, ev1_connected=False,
ev2_connected=False, ev2_connected=False,
allow_charge=False, allow_charge=False,
allow_discharge_export=False, allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.55, charge_acquisition_buy_czk_kwh=0.82,
future_sell_opportunity_czk_kwh=5.5,
) )
cheap = PlanningSlot( cheap = PlanningSlot(
interval_start=base + timedelta(minutes=15), interval_start=base + timedelta(hours=20),
buy_price=0.55, buy_price=0.5,
sell_price=-0.2, sell_price=-0.2,
pv_a_forecast_w=5000, pv_a_forecast_w=0,
pv_b_forecast_w=0, pv_b_forecast_w=0,
load_baseline_w=1800, load_baseline_w=2000,
ev1_connected=False, ev1_connected=False,
ev2_connected=False, ev2_connected=False,
allow_charge=True, allow_charge=True,
allow_discharge_export=False, allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.55, charge_acquisition_buy_czk_kwh=0.82,
future_sell_opportunity_czk_kwh=5.5,
) )
evening = PlanningSlot( peak = PlanningSlot(
interval_start=base + timedelta(minutes=30), interval_start=base + timedelta(hours=7),
buy_price=7.0, buy_price=7.0,
sell_price=5.0, sell_price=5.5,
pv_a_forecast_w=0, pv_a_forecast_w=0,
pv_b_forecast_w=0, pv_b_forecast_w=0,
load_baseline_w=2500, load_baseline_w=2500,
@@ -1460,9 +1462,10 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
ev2_connected=False, ev2_connected=False,
allow_charge=False, allow_charge=False,
allow_discharge_export=True, allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.55, charge_acquisition_buy_czk_kwh=0.82,
future_sell_opportunity_czk_kwh=5.5,
) )
slots = [morning, cheap, evening] slots = [afternoon, peak, cheap]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0) battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.max_charge_power_w = 18_000 battery.max_charge_power_w = 18_000
battery.max_discharge_power_w = 18_000 battery.max_discharge_power_w = 18_000
@@ -1472,7 +1475,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
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),
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.25 * battery.usable_capacity_wh soc0 = 0.5 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch( results, _ms, _ = solve_dispatch(
slots, slots,
battery, battery,
@@ -1484,14 +1487,17 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
50.0, 50.0,
operating_mode="AUTO", operating_mode="AUTO",
) )
am = results[0] pm = results[0]
self.assertLess( self.assertGreaterEqual(
am.grid_setpoint_w, pm.grid_setpoint_w,
-100, -50,
"morning PV surplus should export when later cheap grid charge exists", "low sell with high evening peak: keep PV for battery, not grid dump",
)
self.assertGreater(
pm.battery_setpoint_w,
500,
"PV surplus should charge battery ahead of evening export",
) )
self.assertIn(am.export_mode, ("PV_SURPLUS", "BATTERY_SELL"))
self.assertLessEqual(am.battery_setpoint_w, 100)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -49,7 +49,7 @@ Energetická bilance je také **per slot** (15 min). Když solver v evening slot
Tvrdé zákazy typu `ge_pv = 0` když `sell[t] < buy[t]` brání **ztrátovému** exportu FVE v tomže slotu (prodat za 3 Kč při VT nákupu 5 Kč). Tvrdé zákazy typu `ge_pv = 0` když `sell[t] < buy[t]` brání **ztrátovému** exportu FVE v tomže slotu (prodat za 3 Kč při VT nákupu 5 Kč).
**Výjimka (AUTO, od 2026-05):** pokud je v budoucnu slot s `allow_charge` (levné grid nabíjení) a `sell[t] charge_acquisition + degrad`, solver **vyžaduje export PV přebytku** (`ge_pv`, `bc=0`) — typicky ranní prodej FVE nad ~3 Kč/kWh a NT nabíjení odpoledne. Implementace: `solve_dispatch()` v `planning_engine.py`. **Výjimka (AUTO, od 2026-05):** pokud je v budoucnu `allow_charge` (levný nákup), solver **povolí** FVE export i při `sell[t] < buy[t]`, ale **jen když** `(sell[t] min_buy_charge) ≥ (future_sell_opportunity charge_acquisition) + degrad` — tj. prodat teď a později levně dobít překoná uložení PV na večerní špičku. Při odpoledním sell ~1,4 Kč a večer ~5,5 Kč **export se nevnucuje** (energie do baterie). Implementace: `solve_dispatch()` v `planning_engine.py`.
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ě. 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ě.

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).
- **Grid ze sítě (vrstva B, před FVE):** spot, **AM/PM rozpočet Wh 50/50** z `grid_target`. Výběr: nejdřív **kalendářní den plánu** (`p_from` Prague), pak sloty před **výkupním oknem daného dne** (`sell > min(buy téhož dne)+degrad` — ne globální min zítra). Lookahead VT→NT jen před tímto oknem. **`charge_acquisition`:** vážený `buy` jen u `allow_grid_charge` (ne FVE vrstva A). - **Grid ze sítě (vrstva B, před FVE):** spot, **AM/PM rozpočet Wh 50/50** z `grid_target`. Výběr: nejdřív **kalendářní den plánu** (`p_from` Prague), pak sloty před **výkupním oknem daného dne** (`sell > min(buy téhož dne)+degrad` — ne globální min zítra). Lookahead VT→NT jen před tímto oknem. **`charge_acquisition`:** vážený `buy` jen u `allow_grid_charge` (ne FVE vrstva A).
- **LP (AUTO):** pokud `sell ≥ charge_acquisition + degrad` a později existuje `allow_charge`, přebytek FVE jde **do sítě** (`ge_pv`), ne do baterie — i když ve stejném slotu `sell < buy` (ranní VT výkup vs odpolední NT nákup). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md) §2.2. - **LP (AUTO):** FVE export při `sell < buy` jen pokud `(sell min_buy_v_charge) ≥ (future_sell acquisition) + degrad` — jinak PV do baterie na večerní peak. Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md) §2.2.
- **`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). - **`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.