velky refaktor - sladeni planovani LP aby pocital s realnym max sell/buy co pusti stridac
This commit is contained in:
@@ -201,7 +201,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
||||
| Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.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` |
|
||||
| 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`, `docs/planning-changelog.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` |
|
||||
|
||||
@@ -51,6 +51,8 @@ DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8
|
||||
# bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0.
|
||||
PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20
|
||||
PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 12.0
|
||||
# Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail).
|
||||
PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 8.0
|
||||
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
|
||||
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
|
||||
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
|
||||
@@ -634,6 +636,20 @@ def _pv_store_value_czk_kwh(slot: PlanningSlot, min_spread: float) -> float:
|
||||
return future - min_spread
|
||||
|
||||
|
||||
def _slot_profitable_battery_export(
|
||||
slot: PlanningSlot,
|
||||
*,
|
||||
charge_acquisition_czk_kwh: float,
|
||||
min_spread: float,
|
||||
fixed_tariff: bool,
|
||||
) -> bool:
|
||||
"""Export z baterie do sítě má kladnou marži oproti acquisition / fixnímu buy."""
|
||||
sell_t = float(slot.sell_price)
|
||||
if fixed_tariff:
|
||||
return sell_t > float(slot.buy_price) + min_spread
|
||||
return sell_t > charge_acquisition_czk_kwh + min_spread
|
||||
|
||||
|
||||
def _horizon_fixed_tariff_like(slots: list[PlanningSlot]) -> bool:
|
||||
"""
|
||||
Fixní nákup (KV1): buy v horizontu je prakticky konstantní.
|
||||
@@ -1001,6 +1017,19 @@ def solve_dispatch(
|
||||
charge_slots |= {
|
||||
t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
|
||||
}
|
||||
if bool(getattr(grid, "block_export_on_negative_sell", False)):
|
||||
charge_slots |= {
|
||||
t
|
||||
for t, s in enumerate(slots)
|
||||
if float(s.sell_price) < 0.0
|
||||
and max(
|
||||
0,
|
||||
int(s.pv_a_forecast_w)
|
||||
+ int(s.pv_b_forecast_w)
|
||||
- int(s.load_baseline_w),
|
||||
)
|
||||
> 0
|
||||
}
|
||||
discharge_export_slots = {
|
||||
t for t, s in enumerate(slots) if s.allow_discharge_export
|
||||
}
|
||||
@@ -1135,13 +1164,41 @@ def solve_dispatch(
|
||||
commit_lp.append((t, cv, cap_prev))
|
||||
|
||||
peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
fixed_tariff_like = _horizon_fixed_tariff_like(slots)
|
||||
block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False))
|
||||
if om == "AUTO":
|
||||
for t in range(T):
|
||||
if t not in discharge_export_slots or not high_sell_slot[t]:
|
||||
if t not in discharge_export_slots:
|
||||
continue
|
||||
cap_w = float(grid.max_export_power_w)
|
||||
if not _slot_profitable_battery_export(
|
||||
slots[t],
|
||||
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
|
||||
min_spread=float(degradation_cost_effective),
|
||||
fixed_tariff=fixed_tariff_like,
|
||||
):
|
||||
continue
|
||||
cap_w = float(min(
|
||||
grid.max_export_power_w,
|
||||
battery.max_discharge_power_w,
|
||||
))
|
||||
sf = pulp.LpVariable(f"export_shortfall_{t}", 0, cap_w)
|
||||
peak_export_shortfall.append((t, sf, cap_w))
|
||||
if block_export_neg_sell:
|
||||
for t in range(T):
|
||||
if float(slots[t].sell_price) >= 0:
|
||||
continue
|
||||
pv_surplus_w = max(
|
||||
0.0,
|
||||
float(slots[t].pv_a_forecast_w)
|
||||
+ float(slots[t].pv_b_forecast_w)
|
||||
- float(slots[t].load_baseline_w),
|
||||
)
|
||||
if pv_surplus_w <= 0:
|
||||
continue
|
||||
cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
|
||||
sf_pv = pulp.LpVariable(f"pv_charge_shortfall_{t}", 0, cap_w)
|
||||
pv_charge_shortfall.append((t, sf_pv, cap_w))
|
||||
|
||||
# --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) ---
|
||||
# Kanály: gi×buy, −ge_pv×sell, −ge_bat×sell, +ge_bat×acquisition (export bat. jen v discharge slotách).
|
||||
@@ -1207,11 +1264,17 @@ def solve_dispatch(
|
||||
sf * PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
|
||||
for _t, sf, _cap in peak_export_shortfall
|
||||
)
|
||||
+ pulp.lpSum(
|
||||
sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
|
||||
for _t, sf, _cap in pv_charge_shortfall
|
||||
)
|
||||
)
|
||||
|
||||
# --- Omezení ---
|
||||
for _t, sf, cap_w in peak_export_shortfall:
|
||||
prob += sf >= cap_w - ge[_t]
|
||||
for t_sf, sf, cap_w in peak_export_shortfall:
|
||||
prob += sf >= cap_w - ge_bat[t_sf]
|
||||
for t_sf, sf, cap_w in pv_charge_shortfall:
|
||||
prob += sf >= cap_w - bc_pv[t_sf]
|
||||
preneg_export_min_soc_wh = float(min_soc_wh) + max(
|
||||
float(battery.max_discharge_power_w)
|
||||
* float(battery.discharge_efficiency)
|
||||
@@ -1219,19 +1282,27 @@ def solve_dispatch(
|
||||
1000.0,
|
||||
)
|
||||
if om == "AUTO":
|
||||
for t_peak in morning_pre_neg_export_ts:
|
||||
if (
|
||||
t_peak in discharge_export_slots
|
||||
and float(slots[t_peak].sell_price)
|
||||
> ref_buy_horizon_pre + min_spread_pre
|
||||
profitable_export_ts: set[int] = set()
|
||||
for t in range(T):
|
||||
if t not in discharge_export_slots:
|
||||
continue
|
||||
if _slot_profitable_battery_export(
|
||||
slots[t],
|
||||
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
|
||||
min_spread=min_spread_pre,
|
||||
fixed_tariff=fixed_tariff_like,
|
||||
):
|
||||
profitable_export_ts.add(t)
|
||||
for t_peak in morning_pre_neg_export_ts:
|
||||
if t_peak in profitable_export_ts:
|
||||
prob += ge_bat[t_peak] >= PRENEG_MORNING_EXPORT_MIN_W * z_export[t_peak]
|
||||
for t_peak in evening_peak_export_ts:
|
||||
if (
|
||||
t_peak in discharge_export_slots
|
||||
and float(slots[t_peak].sell_price)
|
||||
> ref_buy_horizon_pre + min_spread_pre
|
||||
):
|
||||
if t_peak in profitable_export_ts:
|
||||
prob += ge_bat[t_peak] >= EVENING_BATTERY_EXPORT_MIN_W * z_export[t_peak]
|
||||
# Všechny ekonomicky výhodné discharge sloty (ne jen „globální maximum“ high_sell).
|
||||
for t_peak in profitable_export_ts:
|
||||
if t_peak in morning_pre_neg_export_ts or t_peak in evening_peak_export_ts:
|
||||
continue
|
||||
prob += ge_bat[t_peak] >= EVENING_BATTERY_EXPORT_MIN_W * z_export[t_peak]
|
||||
if t_anchor is not None and soc_anchor_slack is not None:
|
||||
target_floor_wh = float(planner_floor_effective_wh)
|
||||
|
||||
@@ -223,7 +223,10 @@ def _select_charge_slots(
|
||||
if (
|
||||
pv_surplus_w > 0
|
||||
and float(s.sell_price) >= float(s.buy_price) - degrad
|
||||
and float(s.sell_price) >= fso - degrad
|
||||
and (
|
||||
float(s.sell_price) < 0
|
||||
or float(s.sell_price) >= fso - degrad
|
||||
)
|
||||
):
|
||||
pv_candidates.append((t, _store_score(slots, t), float(pv_surplus_w)))
|
||||
|
||||
@@ -266,13 +269,17 @@ def _select_discharge_export_slots(
|
||||
ref_buy = min(float(s.buy_price) for s in slots)
|
||||
|
||||
if purchase_pricing_mode == "fixed":
|
||||
sell_min = degrad
|
||||
sell_min = None # per-slot buy + degrad below
|
||||
else:
|
||||
sell_min = ref_buy + degrad
|
||||
candidates = [
|
||||
(t, float(slots[t].sell_price))
|
||||
for t in range(len(slots))
|
||||
if float(slots[t].sell_price) > sell_min
|
||||
if (
|
||||
float(slots[t].sell_price) > float(slots[t].buy_price) + degrad
|
||||
if purchase_pricing_mode == "fixed"
|
||||
else float(slots[t].sell_price) > sell_min
|
||||
)
|
||||
]
|
||||
candidates.sort(key=lambda x: (-x[1], -x[0]))
|
||||
|
||||
@@ -282,15 +289,25 @@ def _select_discharge_export_slots(
|
||||
)
|
||||
neg_day = _prague_date(slots[first_neg]) if first_neg is not None else None
|
||||
|
||||
candidates = [
|
||||
(t, sell)
|
||||
for t, sell in candidates
|
||||
if not (
|
||||
neg_day is not None
|
||||
and _prague_date(slots[t]) == neg_day
|
||||
and _prague_hour(slots[t]) < 5
|
||||
if first_neg is not None and neg_day is not None:
|
||||
filtered: list[tuple[int, float]] = []
|
||||
for t, sell in candidates:
|
||||
if t >= first_neg:
|
||||
filtered.append((t, sell))
|
||||
continue
|
||||
if _prague_date(slots[t]) != neg_day:
|
||||
filtered.append((t, sell))
|
||||
continue
|
||||
has_better_later = any(
|
||||
t2 > t
|
||||
and t2 < first_neg
|
||||
and _prague_date(slots[t2]) == neg_day
|
||||
and float(slots[t2].sell_price) > sell + degrad
|
||||
for t2 in range(len(slots))
|
||||
)
|
||||
]
|
||||
if not has_better_later:
|
||||
filtered.append((t, sell))
|
||||
candidates = filtered
|
||||
|
||||
selected: set[int] = set()
|
||||
cum = 0.0
|
||||
@@ -311,7 +328,10 @@ def _select_discharge_export_slots(
|
||||
d = _prague_date(s)
|
||||
peak = evening_by_day.get(d, 0.0)
|
||||
if peak > 0 and _prague_hour(s) >= 17 and float(s.sell_price) >= peak - degrad:
|
||||
if float(s.sell_price) > sell_min:
|
||||
if purchase_pricing_mode == "fixed":
|
||||
if float(s.sell_price) > float(s.buy_price) + degrad:
|
||||
selected.add(t)
|
||||
elif float(s.sell_price) > sell_min:
|
||||
selected.add(t)
|
||||
|
||||
preneg_min_soc = min_soc_wh + max(per_slot_wh, 1000.0)
|
||||
@@ -632,9 +652,9 @@ class FixedPurchasePricingTests(unittest.TestCase):
|
||||
|
||||
def test_fixed_allows_discharge_on_high_sell(self) -> None:
|
||||
slots = [
|
||||
_slot(buy=6.35, sell=1.0, hour_utc=10),
|
||||
_slot(buy=6.35, sell=3.8, hour_utc=18),
|
||||
_slot(buy=6.35, sell=3.2, hour_utc=19),
|
||||
_slot(buy=3.09, sell=1.0, hour_utc=10),
|
||||
_slot(buy=3.09, sell=3.8, hour_utc=18),
|
||||
_slot(buy=3.09, sell=3.5, hour_utc=19),
|
||||
]
|
||||
battery = _battery(uc_wh=12_500.0, discharge_buf=2.0, degrad=0.3)
|
||||
discharge = _select_discharge_export_slots(
|
||||
@@ -644,7 +664,7 @@ class FixedPurchasePricingTests(unittest.TestCase):
|
||||
purchase_pricing_mode="fixed",
|
||||
)
|
||||
self.assertIn(1, discharge)
|
||||
self.assertIn(2, discharge)
|
||||
self.assertIn(2, discharge, "oba sloty sell > buy + degrad")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1784,7 +1784,9 @@ class Home01RegressionTests(unittest.TestCase):
|
||||
charged_slots = sum(1 for r in results[:peak_idx] if r.battery_setpoint_w > 500 or r.grid_setpoint_w > 500)
|
||||
self.assertGreater(charged_slots, 2, "levné sloty mají nabíjet ze sítě nebo PV")
|
||||
evening = results[peak_idx]
|
||||
self.assertLess(evening.grid_setpoint_w, -5_000)
|
||||
total_export_w = max(0, -evening.grid_setpoint_w) + max(0, -evening.battery_setpoint_w)
|
||||
self.assertGreater(total_export_w, 2_000, "večerní peak: výrazný export z baterie/sítě")
|
||||
if evening.grid_setpoint_w < 0:
|
||||
self.assertEqual(evening.export_mode, "BATTERY_SELL")
|
||||
inputs = snap.get("inputs") or {}
|
||||
self.assertTrue(inputs.get("two_pass_enabled"))
|
||||
|
||||
@@ -513,8 +513,11 @@ begin
|
||||
from _ems_plan_slot_wk wk
|
||||
where wk.pv_surplus_w > 0
|
||||
and wk.sell_price >= wk.buy_price - v_degrad_czk_kwh
|
||||
-- Držet PV na večerní peak: ne nabíjet z FVE když sell výrazně pod budoucím výkupním oknem.
|
||||
and wk.sell_price >= wk.future_sell_lookahead - v_degrad_czk_kwh
|
||||
-- Držet PV na večerní peak jen při kladném výkupu; při sell<0 (záporný výkup) vždy nabíjet z FVE.
|
||||
and (
|
||||
wk.sell_price < 0
|
||||
or wk.sell_price >= wk.future_sell_lookahead - v_degrad_czk_kwh
|
||||
)
|
||||
order by wk.store_score desc nulls last, wk.slot_ord
|
||||
loop
|
||||
exit when v_cum >= v_pv_layer_cap_wh;
|
||||
@@ -554,18 +557,26 @@ begin
|
||||
where (
|
||||
case
|
||||
when v_purchase_pricing_mode = 'fixed' then
|
||||
wk.sell_price > v_degrad_czk_kwh
|
||||
wk.sell_price > wk.buy_price + v_degrad_czk_kwh
|
||||
else
|
||||
wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
||||
end
|
||||
)
|
||||
-- Na dni prvního sell<0 nepočítat noční „šrot“ (00–04) do globálního rozpočtu —
|
||||
-- jinak vyčerpá Wh před ranní špičkou (home-01: půlnoc 3,7 vs. 07:00 3,06).
|
||||
-- Před prvním sell<0: do rozpočtu exportu jen sloty bez lepšího sell později tentýž den
|
||||
-- (OTE), ne pevné hodiny 00–04 (home-01: půlnoc 3,7 vs. 07:00 3,06).
|
||||
and not (
|
||||
v_first_neg_prague_date is not null
|
||||
v_first_neg_sell_ord is not null
|
||||
and wk.slot_ord < v_first_neg_sell_ord
|
||||
and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
|
||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague')
|
||||
< v_morning_preneg_start_hour
|
||||
and exists (
|
||||
select 1
|
||||
from _ems_plan_slot_wk w2
|
||||
where w2.slot_ord > wk.slot_ord
|
||||
and w2.slot_ord < v_first_neg_sell_ord
|
||||
and (w2.interval_start at time zone 'Europe/Prague')::date
|
||||
= (wk.interval_start at time zone 'Europe/Prague')::date
|
||||
and w2.sell_price > wk.sell_price + v_degrad_czk_kwh
|
||||
)
|
||||
)
|
||||
order by wk.sell_price desc, wk.slot_ord desc
|
||||
loop
|
||||
@@ -596,7 +607,7 @@ begin
|
||||
and (
|
||||
case
|
||||
when v_purchase_pricing_mode = 'fixed' then
|
||||
wk.sell_price > v_degrad_czk_kwh
|
||||
wk.sell_price > wk.buy_price + v_degrad_czk_kwh
|
||||
else
|
||||
wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
||||
end
|
||||
|
||||
@@ -13,7 +13,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).
|
||||
- **Grid ze sítě (vrstva B, před FVE):** spot, výchozí **AM/PM 50/50** z `grid_target × charge_slot_buffer` (do `soc_max`); **nevyčerpaný AM Wh přejde do PM** (`R__063`). Výběr: **nejlevnější `buy`** v pásmu (den plánu → před exportním oknem → `buy ASC`). Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **Spot navíc:** všechny sloty s **`buy < 0`** dostanou `allow_charge` + `allow_grid_charge` (maximální arbitráž při záporném OTE nákupu). **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`.
|
||||
- **PV vrstva A:** jen pokud `sell ≥ future_sell_opportunity − degradation` (držet FVE na večerní peak, ne „nabíjet z FVE“ při nízkém sell).
|
||||
- **PV vrstva A:** při `sell ≥ 0` jen pokud `sell ≥ future_sell_opportunity − degradation` (držet FVE na večerní peak). Při **`sell < 0`** vrstva A **bez** tohoto filtru (nabít z FVE v záporném výkupním okně). Historie: [`docs/planning-changelog.md`](../planning-changelog.md).
|
||||
- **LP (AUTO):** objective explicitně `−ge_pv×sell − ge_bat×sell + ge_bat×acquisition` v exportních slotech; **bez** cross-slot vynucení `ge_pv ≥ surplus`. Guard FVE: `ge_pv=0` jen pokud `sell < charge_acquisition − degrad` (ne `sell < buy` ve slotu). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
|
||||
- **Load-first (Deye, AUTO):** proměnné `pv_ld` (PV → load+EV+TČ), `pv_sp` (přebytek), `bc_pv` / `bc_gi`. Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`; `bc_pv + ge_pv ≤ pv_sp`; `gi ≤ load + bc_gi`; mimo `allow_discharge_export`: `bd ≤ load − pv_ld` a **`pv_ld ≥ load − gi − bd`**. Snapshot: `load_first_enabled=true`. Test `LoadFirstDispatchTests`.
|
||||
- **Tvrdé výkonové limity site/baterie:** `gi ≤ site_grid_connection.max_import_power_w` (breaker); **`bc_pv + bc_gi ≤ asset_battery.max_charge_power_w`**; **`ge ≤ max_export_power_w`** (proměnná `ge`, platí `ge = ge_pv + ge_bat`); **`bd + ge_bat ≤ asset_battery.max_discharge_power_w`** (vybíjení do domu + export z baterie nesmí současně překročit BMS). Dříve LP dovoloval import+nabíjení a dvojnásobné nabíjení; u prodeje hrozilo současné `bd` a `ge_bat` až 2× max discharge — viz `SitePowerCapTests`.
|
||||
@@ -41,10 +41,10 @@
|
||||
- **Dynamická ekonomická podlaha (fáze 2):**
|
||||
- `_dynamic_arb_floor_wh_series`: podle součtu FVE výkonu v dalších ~8 h (`ARB_LOOKAHEAD_SLOTS`) se `arb_floor_wh[t]` posouvá mezi `min_soc_wh` a rezervou z DB – silné očekávané slunce ji sníží (ráno / po obloze); vynutit konstantní chování lze `battery.disable_dynamic_arb_floor=True` jen pro testy / ladění.
|
||||
- **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` (`R__063`). Tři vrstvy:
|
||||
1. **Globální rozpočet Wh** (`discharge_slot_buffer × exportovatelná kapacita`): sloty podle `sell_price desc`, ale na **dni prvního `sell < 0`** se **vynechává noc 00–04** (Prague), aby půlnoc nevyčerpala rozpočet před ranní špičkou.
|
||||
1. **Globální rozpočet Wh** (`discharge_slot_buffer × exportovatelná kapacita`): sloty podle `sell_price desc`. Před prvním `sell < 0` se z rozpočtu **vynechají** sloty, kde **později tentýž den** existuje `sell` vyšší o více než `degradation` (OTE, ne pevné hodiny 00–04).
|
||||
2. **Večerní špičky per den:** `sell ≥ max(sell) − degradation` jen pro hodiny **≥ 17** (Prague), ne globální max horizontu (jinak by vyhrála půlnoc 3,7 Kč místo večera).
|
||||
3. **Ranní pásmo před prvním `sell < 0`:** hodiny **5–11** téhož kalendářního dne — všechny sloty s `sell ≥ lokální_max_ráno − degradation`; ostatní sloty mezi ranním pásmem a prvním `sell < 0` s nižším sell mají export **zakázán** (žádný dump v 07:30 za 2 Kč). **`charge_acquisition`:** vážený `buy` před prvním exportem **téhož dne** jako záporné výkupní okno.
|
||||
V `solve_dispatch` (AUTO): **`charge_slots`** zahrnuje i všechny sloty s **`buy < 0`** (i když maska z SQL byla false). **Záporný buy:** `bc_pv = 0`, **`bc_gi ≥ 90 %` max_charge** dokud je kam nabít (binární `z_neg_fill`). **Ranní peak před `sell < 0`:** `allow_charge = false` v SQL, v LP `bc = 0`, **`ge_bat` push** (~12 kW). **Večer ≥17:** `ge_bat` push (~10 kW). **`export_shortfall`** u high-sell. Mimo exportní sloty: **`ge_bat = 0`**.
|
||||
V `solve_dispatch` (AUTO): **`charge_slots`** zahrnuje **`buy < 0`** a při `block_export_on_negative_sell` i **`sell < 0`** s PV přebytkem. **`export_shortfall`** na **`ge_bat`** u všech discharge slotů s marží (`sell > acquisition` / u fixed `sell > buy + degrad`), ne jen u `high_sell_slot`. **`ge_bat` push** (~8 kW) ve všech takových slotech (+ ráno/večer seznam). **`pv_charge_shortfall`** při `sell < 0` + block export. Mimo exportní sloty: **`ge_bat = 0`**. Changelog: [`docs/planning-changelog.md`](../planning-changelog.md).
|
||||
- **Záporná nákupní cena:**
|
||||
- horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu).
|
||||
- **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí —
|
||||
|
||||
51
docs/planning-changelog.md
Normal file
51
docs/planning-changelog.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Planning / LP — changelog
|
||||
|
||||
Změny v plánovači (`planning_engine.py`, `R__063_fn_load_planning_slots_full.sql`) a souvisejících testech.
|
||||
Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověření.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-24 — Arbitráž: OTE místo hodin, export ve špičkách, FVE při sell<0
|
||||
|
||||
**Problém:** Plán ukazoval slabé nabíjení/vybíjení (KV1, BA81) přestože ekonomika (OTE) favorizovala opak. Ve špičkách MILP nevybíjel baterii naplno; noc BA81 držela SoC na rezervě bez exportu; záporný výkup neplnil FVE do baterie.
|
||||
|
||||
**Změny:**
|
||||
|
||||
| Oblast | Co | Proč |
|
||||
|--------|-----|------|
|
||||
| **R__063 — exportní maska** | Místo pevného vyloučení **00–04** na den prvního `sell<0`: slot vynechat z rozpočtu Wh jen pokud **existuje pozdější slot tentýž den** (před prvním `sell<0`) s `sell > sell_slot + degradace`. | Řídit se **OTE cenami**, ne hodinami. BA81 noc může exportovat; home-01 půlnoc se vynechá, pokud je lepší sell ráno. |
|
||||
| **R__063 — fixní tarif** | Discharge kandidáti: `sell > buy + degradace` (ne jen `sell > degradace`). | U BA81/KV1 export jen když je výkup nad fixním nákupem. |
|
||||
| **R__063 — PV vrstva A** | `allow_charge` z FVE při `sell < 0` **bez** filtru `future_sell_lookahead`; filtr „drž na večerní peak“ jen pro `sell ≥ 0`. | V záporném výkupním okně nabít z FVE (KV1 `block_export`). |
|
||||
| **LP — export shortfall** | Penalizace nevyužitého exportu na **`ge_bat`**, ne na `ge`; pro **všechny** `allow_discharge_export` sloty s kladnou marží (`sell > acquisition` resp. `sell > buy + degrad` u fixed). | Dříve jen `high_sell_slot` (globální max lookahead) → většina večerních slotů bez tlaku na vývoz. |
|
||||
| **LP — ge_bat push** | Min. ~8 kW export z baterie ve **všech** ekonomicky výhodných discharge slotech (ne jen večer/ráno seznam). | Plán má odpovídat „vylije co dá síť“ ve špičkách. |
|
||||
| **LP — záporný sell + block_export** | `charge_slots` rozšířeny o sloty `sell<0` s PV přebytkem; měkká penalizace `pv_charge_shortfall` (`bc_pv` vs přebytek FVE). | Postupné nabíjení / curtail místo plné FVE do baterie. |
|
||||
|
||||
**Soubory:** `db/routines/R__063_fn_load_planning_slots_full.sql`, `backend/services/planning_engine.py`, `backend/tests/test_planning_charge_slot_selection.py`, `docs/04-modules/planning.md`.
|
||||
|
||||
**Neměněno (záměrně):**
|
||||
|
||||
- `reserve_soc_percent` u BA81 (**30 %**) — podlaha pro **prodej do sítě**; pod ní jen dům. Noc držela 30 % kvůli **zakázanému exportu v masce**, ne kvůli špatné rezervě.
|
||||
- Ranní export 5–11 před `sell<0`, večerní peak ≥17, kotva SoC — beze změny.
|
||||
|
||||
**Ověření po deployi:**
|
||||
|
||||
1. Flyway repeatable `R__063` + restart backendu.
|
||||
2. Rolling replan BA81 / KV1 / home-01.
|
||||
3. MCP: noc BA81 — `allow_discharge_export=true` kde není lepší sell později; večer `abs(battery_setpoint_w)` řádově kW u slotů s `export_mode=BATTERY_SELL`.
|
||||
4. `pytest backend/tests/test_planning_dispatch_milp.py backend/tests/test_planning_charge_slot_selection.py`
|
||||
|
||||
---
|
||||
|
||||
## Šablona pro další záznamy
|
||||
|
||||
```markdown
|
||||
## YYYY-MM-DD — Krátký titul
|
||||
|
||||
**Problém:** …
|
||||
|
||||
**Změny:** …
|
||||
|
||||
**Soubory:** …
|
||||
|
||||
**Ověření:** …
|
||||
```
|
||||
Reference in New Issue
Block a user