fix KV1/BA81 cyklovani
This commit is contained in:
@@ -621,14 +621,30 @@ def solve_dispatch(
|
||||
daytime_en = bool(getattr(battery, "planner_daytime_charge_target_enabled", True))
|
||||
safety_pen_czk_per_wh: list[float] = []
|
||||
safety_vars: list[Optional[pulp.LpVariable]] = []
|
||||
safety_active: list[bool] = []
|
||||
high_sell_slot: list[bool] = []
|
||||
for t in range(T):
|
||||
sft = slots[t].safety_soc_target_wh if daytime_en else None
|
||||
# High-sell slot: typicky lokální maximum v SQL lookaheadu (future_sell_opportunity_czk_kwh).
|
||||
# V těchto slotech safety floor nepoužijeme, aby se zachovala arbitráž na špičkách.
|
||||
fso = slots[t].future_sell_opportunity_czk_kwh
|
||||
hs = bool(fso is not None and float(slots[t].sell_price) >= float(fso) - 1e-6)
|
||||
high_sell_slot.append(hs)
|
||||
|
||||
fb = float(slots[t].future_avoided_buy_czk_kwh or slots[t].buy_price)
|
||||
fs = float(slots[t].future_sell_opportunity_czk_kwh or slots[t].sell_price)
|
||||
bv = max(fb, fs) - float(degradation_cost_effective)
|
||||
bv = max(0.0, min(5.0, bv))
|
||||
safety_pen_czk_per_wh.append(bv / 1000.0 if sft is not None else 0.0)
|
||||
if sft is not None:
|
||||
# Safety deficit penalizujeme jen v PV surplus slotech, a ne ve high-sell špičce.
|
||||
# Záměr: safety není obecná „nabij co nejdřív“ motivace; je to preference využít přebytek PV.
|
||||
active = bool(
|
||||
sft is not None
|
||||
and bool(slots[t].is_daytime_pv_surplus_slot)
|
||||
and not hs
|
||||
)
|
||||
safety_active.append(active)
|
||||
safety_pen_czk_per_wh.append(bv / 1000.0 if active else 0.0)
|
||||
if active:
|
||||
safety_vars.append(
|
||||
pulp.LpVariable(f"safety_def_{t}", 0, float(battery.usable_capacity_wh))
|
||||
)
|
||||
@@ -801,6 +817,17 @@ def solve_dispatch(
|
||||
export_soc_floor_t = float(soc_panel_min[t])
|
||||
else:
|
||||
export_soc_floor_t = float(arb_base_wh)
|
||||
# Safety export floor: v běžných (ne high-sell) slotech nevybít exportem energii potřebnou pro
|
||||
# robustnost/noční baseload. Použije se pouze pokud je safety target v SQL vyplněný.
|
||||
tgt_s = slots[t].safety_soc_target_wh if daytime_en else None
|
||||
if tgt_s is not None and not high_sell_slot[t]:
|
||||
export_soc_floor_t = max(
|
||||
export_soc_floor_t,
|
||||
min(
|
||||
float(battery.soc_max_wh),
|
||||
max(min_soc_wh, float(tgt_s)),
|
||||
),
|
||||
)
|
||||
prob += soc[t] >= export_soc_floor_t - m_soc_bigm * (1 - z_export[t])
|
||||
|
||||
# EV – limity a připojení
|
||||
@@ -977,6 +1004,22 @@ def solve_dispatch(
|
||||
}
|
||||
)
|
||||
tgt_s = st.safety_soc_target_wh if daytime_en else None
|
||||
# Export floor pro debug snapshot (kopie logiky z constraintů výše).
|
||||
if soc_panel_min[t] < min_soc_wh - 1e-3:
|
||||
export_floor_wh = float(soc_panel_min[t])
|
||||
export_floor_reason = "deep_relax"
|
||||
else:
|
||||
export_floor_wh = float(arb_base_wh)
|
||||
export_floor_reason = "arb_base"
|
||||
if tgt_s is not None and not high_sell_slot[t]:
|
||||
export_floor_wh = max(
|
||||
export_floor_wh,
|
||||
min(
|
||||
float(battery.soc_max_wh),
|
||||
max(min_soc_wh, float(tgt_s)),
|
||||
),
|
||||
)
|
||||
export_floor_reason = "safety_export_floor"
|
||||
soc_bounds_snap.append(
|
||||
{
|
||||
"slot": st.interval_start.isoformat(),
|
||||
@@ -984,6 +1027,9 @@ def solve_dispatch(
|
||||
"arb_floor_wh": float(arb_floor_series[t]),
|
||||
"soc_panel_min_wh": float(soc_panel_min[t]),
|
||||
"safety_soc_target_wh": float(tgt_s) if tgt_s is not None else None,
|
||||
"export_soc_floor_wh": float(export_floor_wh),
|
||||
"export_floor_reason": export_floor_reason,
|
||||
"high_sell_slot": bool(high_sell_slot[t]),
|
||||
}
|
||||
)
|
||||
fb = float(st.future_avoided_buy_czk_kwh or st.buy_price)
|
||||
@@ -1004,7 +1050,8 @@ def solve_dispatch(
|
||||
st.future_sell_opportunity_czk_kwh or st.sell_price
|
||||
),
|
||||
"battery_value_czk_kwh": float(bv),
|
||||
"safety_deficit_penalty_czk_per_wh": float(pen_wh),
|
||||
"safety_deficit_penalty_czk_per_wh": float(pen_wh) if safety_active[t] else 0.0,
|
||||
"safety_penalty_active": bool(safety_active[t]),
|
||||
"safety_deficit_wh": sdv,
|
||||
"commitment_shortfall_w": cshort,
|
||||
"commitment_penalty_czk_kwh": float(commit_pen) if cshort is not None else None,
|
||||
@@ -1436,7 +1483,9 @@ async def _load_previous_plan_charge_commitment_prev_w(
|
||||
pva = int(r["pva"] or 0)
|
||||
pvb = int(r["pvb"] or 0)
|
||||
lb = int(r["lb"] or 0)
|
||||
if bw > 500 and (pva + pvb) > lb and gw <= 0:
|
||||
# Commitment má kotvit jen „nabíjení z PV přebytku“, ne situace kdy plán současně
|
||||
# výrazně exportuje do sítě (typicky charge while exporting). To by stabilizovalo špatný cyklus.
|
||||
if bw > 500 and (pva + pvb) > lb and gw <= 0 and gw >= -500:
|
||||
out.append(float(bw))
|
||||
else:
|
||||
out.append(None)
|
||||
|
||||
@@ -56,6 +56,7 @@ def _slot(
|
||||
safety: float | None = None,
|
||||
fut_buy: float | None = None,
|
||||
fut_sell: float | None = None,
|
||||
daytime_pv_surplus: bool = False,
|
||||
) -> PlanningSlot:
|
||||
return PlanningSlot(
|
||||
interval_start=t0 + timedelta(minutes=15 * idx),
|
||||
@@ -71,6 +72,7 @@ def _slot(
|
||||
safety_soc_target_wh=safety,
|
||||
future_avoided_buy_czk_kwh=fut_buy,
|
||||
future_sell_opportunity_czk_kwh=fut_sell,
|
||||
is_daytime_pv_surplus_slot=daytime_pv_surplus,
|
||||
)
|
||||
|
||||
|
||||
@@ -135,6 +137,150 @@ class PlanningSafetyCommitmentTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(snap2["chosen_slots"]["charge_commitment"], [])
|
||||
|
||||
def test_export_floor_uses_safety_target_in_non_high_sell_slot(self) -> None:
|
||||
"""Regrese: safety target nemá tlačit jen přes objective, ale chránit export floor."""
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
# Slot 0 není high-sell (future max sell je vyšší), ale safety target je nad arb_base.
|
||||
slots = [
|
||||
_slot(
|
||||
t0,
|
||||
0,
|
||||
buy=3.0,
|
||||
sell=2.0,
|
||||
pv_a=8000,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0, # high-sell somewhere later, not this slot
|
||||
daytime_pv_surplus=True,
|
||||
),
|
||||
_slot(
|
||||
t0,
|
||||
1,
|
||||
buy=3.0,
|
||||
sell=6.0,
|
||||
pv_a=0,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=False,
|
||||
),
|
||||
]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
_res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(arb_floor_wh=4000.0, reserve_soc_wh=4000.0, min_soc_wh=2000.0, soc_max_wh=19_000.0),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=8000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
b0 = snap["soc_bounds"][0]
|
||||
self.assertEqual(b0["export_floor_reason"], "safety_export_floor")
|
||||
self.assertEqual(float(b0["export_soc_floor_wh"]), 12_000.0)
|
||||
self.assertFalse(bool(b0["high_sell_slot"]))
|
||||
|
||||
def test_export_floor_keeps_arb_base_in_high_sell_slot(self) -> None:
|
||||
"""High-sell výjimka: v peak slotu nesmí safety floor blokovat arbitráž."""
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
# Slot 0 je high-sell (sell == future max), safety target je nad arb_base, ale nemá se aplikovat.
|
||||
slots = [
|
||||
_slot(
|
||||
t0,
|
||||
0,
|
||||
buy=3.0,
|
||||
sell=6.0,
|
||||
pv_a=0,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=False,
|
||||
),
|
||||
_slot(
|
||||
t0,
|
||||
1,
|
||||
buy=3.0,
|
||||
sell=2.0,
|
||||
pv_a=0,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=False,
|
||||
),
|
||||
]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
_res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(arb_floor_wh=4000.0, reserve_soc_wh=4000.0, min_soc_wh=2000.0, soc_max_wh=19_000.0),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=8000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
b0 = snap["soc_bounds"][0]
|
||||
self.assertTrue(bool(b0["high_sell_slot"]))
|
||||
self.assertEqual(b0["export_floor_reason"], "arb_base")
|
||||
self.assertEqual(float(b0["export_soc_floor_wh"]), 4000.0)
|
||||
|
||||
def test_safety_penalty_only_active_in_daytime_pv_surplus_slots(self) -> None:
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
slots = [
|
||||
_slot(
|
||||
t0,
|
||||
0,
|
||||
buy=3.0,
|
||||
sell=2.0,
|
||||
pv_a=8000,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=True,
|
||||
),
|
||||
_slot(
|
||||
t0,
|
||||
1,
|
||||
buy=3.0,
|
||||
sell=2.0,
|
||||
pv_a=0,
|
||||
load=500,
|
||||
safety=12_000.0,
|
||||
fut_sell=6.0,
|
||||
daytime_pv_surplus=False,
|
||||
),
|
||||
]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
_res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=8000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
t0o = snap["objective_terms"][0]
|
||||
t1o = snap["objective_terms"][1]
|
||||
self.assertTrue(bool(t0o["safety_penalty_active"]))
|
||||
self.assertGreater(float(t0o["safety_deficit_penalty_czk_per_wh"]), 0.0)
|
||||
self.assertFalse(bool(t1o["safety_penalty_active"]))
|
||||
self.assertEqual(float(t1o["safety_deficit_penalty_czk_per_wh"]), 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
- **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** – obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu.
|
||||
- **Terminal SoC shadow price:** v objective je člen `−(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon).
|
||||
- **Masky `allow_charge` / `allow_discharge_export` (anti-mikrocyklování):** generuje `ems.fn_load_planning_slots_full`. Důležité: pokud rolling replan startuje s baterií na 100 %, `allow_charge` se nesmí stát globálně `false` pro celý horizont – jinak solver nemá motivaci baterii před PV špičkou „uvolnit“ (headroom), protože ji pak nesmí z PV znovu nabít. Aktuálně se v tomto případě `allow_charge` ponechá povolené alespoň pro sloty s `pv_surplus_w > 0`.
|
||||
- **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`. `planning_engine.solve_dispatch()` přidá proměnné deficit vůči cíli a penalizaci `max(future_buy, future_sell) − degradace` (clamp), aby šlo prodat ve velmi drahém sell okně i přes deficit. Tvrdé `allow_charge` se kvůli tomu nemění.
|
||||
- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0`; měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu (`planner_charge_commitment_penalty_czk_kwh` na `asset_battery`). Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`.
|
||||
- **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í.
|
||||
- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0` a současně **není výrazný export** (`grid_setpoint_w ≥ −500`). To je záměr: commitment má kotvit „nabíjení z PV přebytku“, ne „charge while exporting“. Měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu je řízená `planner_charge_commitment_penalty_czk_kwh` na `asset_battery`. Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`.
|
||||
- **Debug snapshot:** každý běh ukládá JSON do `ems.planning_run.solver_params` (sekce `version`, `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`) přes `fn_planning_run_commit` (`p_run_meta->'solver_params'`). Read-model: **`select ems.fn_planning_run_debug(<run_id>);`** (`R__087_fn_planning_run_debug.sql`).
|
||||
- **Runtime guard v exportu setpointů (legacy):**
|
||||
- při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování (u nových plánů by `is_predicted_price` v horizontu nemělo nastat).
|
||||
- **Ekonomika baterie:**
|
||||
- `min_soc_percent` = nejnižší SoC v LP a runtime clamp telemetrie; u **více paralelních stringů** držet **nad** holým BMS minimem (typicky **11–12 %**; migrace **V029** + komentář v DB, u `home-01` cílený UPDATE z 10 %),
|
||||
- `reserve_soc_percent` = ekonomická („arbitrážní“) podlaha – pod ní MILP s `w_arb` omezuje vybíjení podle začátku slotu a FVE lookahead (`arb_floor_series`; typicky 20 %),
|
||||
- **Export ze site:** binárka `z_export[t]` – pokud `grid_export ≥ 1` W, musí být **koncové** `soc[t] ≥ arb_base_wh` (fixní z DB, **ne** dynamicky snížená `arb_floor_series`),
|
||||
- **Export ze site:** binárka `z_export[t]` – pokud `grid_export ≥ 1` W, musí být **koncové** `soc[t] ≥ export_soc_floor_wh`, kde:\n+ - při hluboké relaxaci (`soc_panel_min` pod `min_soc`) je `export_soc_floor_wh = soc_panel_min[t]`,\n+ - jinak je `export_soc_floor_wh = arb_base_wh`, a v běžných slotech se safety targetem navíc `max(arb_base_wh, safety_soc_target_wh)` (mimo high‑sell špičky). `arb_floor_series` se pro `z_export` nepoužívá.
|
||||
- `degradation_cost_czk_kwh` (např. 0.15) / penalizace cyklu v objective symetrická (`0.5*(charge+discharge)`).
|
||||
- **PV-aware nejistota:**
|
||||
- objective používá `pv_scarcity_factor` (0.65..1.0), odvozený z forecastu slunce,
|
||||
|
||||
Reference in New Issue
Block a user