dalsi pokus o opravu
This commit is contained in:
@@ -685,6 +685,8 @@ def solve_dispatch(
|
||||
for t in range(T)
|
||||
]
|
||||
ge = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w) for t in range(T)]
|
||||
ge_pv = [pulp.LpVariable(f"ge_pv_{t}", 0, grid.max_export_power_w) for t in range(T)]
|
||||
ge_bat = [pulp.LpVariable(f"ge_bat_{t}", 0, grid.max_export_power_w) for t in range(T)]
|
||||
bc = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_power_w) for t in range(T)]
|
||||
bd = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)]
|
||||
soc = [
|
||||
@@ -881,6 +883,9 @@ def solve_dispatch(
|
||||
pv_a_net + pv_b_effective + gi[t] + bd[t]
|
||||
== s.load_baseline_w + ev_total_t + hp[t] + bc[t] + ge[t]
|
||||
)
|
||||
prob += ge[t] == ge_pv[t] + ge_bat[t]
|
||||
# Baterie nesmí „přestrojit“ FVE export: přebytek nad PV musí jít přes ge_bat.
|
||||
prob += ge_bat[t] >= ge[t] - (pv_a_net + pv_b_effective)
|
||||
|
||||
# Měkký breaker cap: gi_over[t] >= max(0, gi[t] - breaker).
|
||||
prob += gi_over[t] >= gi[t] - float(grid.max_import_power_w)
|
||||
@@ -936,6 +941,8 @@ def solve_dispatch(
|
||||
)
|
||||
if z_gen_cutoff is not None or block_neg_sell_export:
|
||||
prob += ge[t] == 0
|
||||
prob += ge_pv[t] == 0
|
||||
prob += ge_bat[t] == 0
|
||||
|
||||
soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1]
|
||||
arb_t = arb_floor_series[t]
|
||||
@@ -946,10 +953,7 @@ def solve_dispatch(
|
||||
arb_cap_t = min(arb_t, soc_low_t)
|
||||
else:
|
||||
arb_cap_t = arb_t
|
||||
if om == "AUTO" and t not in discharge_export_slots:
|
||||
# PASSIVE na střídači: EMS neplánuje vybíjení do load (Deye pokryje skutečnou zátěž).
|
||||
pass
|
||||
elif om == "AUTO" and t in discharge_export_slots:
|
||||
if om == "AUTO" and t in discharge_export_slots:
|
||||
prob += soc_prev_expr >= (
|
||||
arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t])
|
||||
)
|
||||
@@ -957,6 +961,17 @@ def solve_dispatch(
|
||||
battery.max_discharge_power_w * w_arb[t]
|
||||
+ pulp.lpSum(ev_via_bat[e][t] for e in range(EV))
|
||||
)
|
||||
elif om == "AUTO":
|
||||
# PASSIVE: vlastní spotřeba (bd); export baterie jen ge_bat (ge_bat=0 níže).
|
||||
prob += soc_prev_expr >= (
|
||||
arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t])
|
||||
)
|
||||
prob += bd[t] <= (
|
||||
s.load_baseline_w
|
||||
+ ev_total_t
|
||||
+ hp[t]
|
||||
+ bc[t]
|
||||
)
|
||||
else:
|
||||
prob += soc_prev_expr >= (
|
||||
arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t])
|
||||
@@ -969,11 +984,11 @@ def solve_dispatch(
|
||||
+ battery.max_discharge_power_w * w_arb[t]
|
||||
)
|
||||
|
||||
# Významný export ⇒ koncové SoC ≥ podlaha (viz soc_panel_min / arb_base).
|
||||
# Významný export z baterie ⇒ koncové SoC ≥ podlaha (FVE export ge_pv bez této podlahy).
|
||||
m_ge = float(grid.max_export_power_w)
|
||||
m_soc_bigm = float(battery.usable_capacity_wh)
|
||||
prob += ge[t] <= m_ge * z_export[t]
|
||||
prob += ge[t] >= GE_MIN_EXPORT_W * z_export[t]
|
||||
prob += ge_bat[t] <= m_ge * z_export[t]
|
||||
prob += ge_bat[t] >= GE_MIN_EXPORT_W * z_export[t]
|
||||
# Bez hluboké relaxace: export končí ≥ rezerva. Při hluboké relaxaci (soc_panel_min pod min_soc)
|
||||
# sladit s LP spodkem — jinak z_export vynutil arb_base a blokoval vývoz k planner floor.
|
||||
if soc_panel_min[t] < min_soc_wh - 1e-3:
|
||||
@@ -1018,16 +1033,29 @@ def solve_dispatch(
|
||||
elif om == "CHARGE_CHEAP":
|
||||
for t in range(T):
|
||||
prob += ge[t] == 0
|
||||
prob += ge_pv[t] == 0
|
||||
prob += ge_bat[t] == 0
|
||||
prob += bd[t] == 0
|
||||
|
||||
# Slot pre-selection (z DB fn_load_planning_slots_full → allow_*)
|
||||
if om == "AUTO":
|
||||
for t in range(T):
|
||||
if t not in charge_slots:
|
||||
prob += bc[t] == 0
|
||||
s = slots[t]
|
||||
pv_surplus_w = max(
|
||||
0,
|
||||
int(s.pv_a_forecast_w)
|
||||
+ int(s.pv_b_forecast_w)
|
||||
- int(s.load_baseline_w),
|
||||
)
|
||||
# Mimo grid-charge masku smí nabíjet jen z PV přebytku (ne import ze sítě).
|
||||
if pv_surplus_w <= 0:
|
||||
prob += bc[t] == 0
|
||||
else:
|
||||
prob += bc[t] <= pv_surplus_w
|
||||
if t not in discharge_export_slots:
|
||||
prob += bd[t] == 0
|
||||
prob += w_arb[t] == 0
|
||||
prob += ge_bat[t] == 0
|
||||
prob += z_export[t] == 0
|
||||
|
||||
# Deadline constraints pro EV
|
||||
for e, session in enumerate(ev_sessions):
|
||||
@@ -1094,9 +1122,14 @@ def solve_dispatch(
|
||||
grid_w = round(pulp.value(gi[t]) - pulp.value(ge[t]))
|
||||
soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1)
|
||||
export_limit_w = int(grid.max_export_power_w) if grid_w < 0 else 0
|
||||
ge_bat_w = round(float(pulp.value(ge_bat[t]) or 0))
|
||||
export_mode = "NONE"
|
||||
if grid_w < 0:
|
||||
export_mode = "BATTERY_SELL" if batt_w < 0 else "PV_SURPLUS"
|
||||
export_mode = (
|
||||
"BATTERY_SELL"
|
||||
if ge_bat_w >= GE_MIN_EXPORT_W
|
||||
else "PV_SURPLUS"
|
||||
)
|
||||
|
||||
# Deye: default PASSIVE (střídač pokryje load). CHARGE/SELL jen v maskovaných AUTO slotech.
|
||||
deye_mode = "PASSIVE"
|
||||
|
||||
@@ -335,7 +335,19 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
|
||||
def test_pv_surplus_export_uses_hard_export_cap(self) -> None:
|
||||
slots = [
|
||||
_slot(load=0, buy=3.0, sell=2.5, pv_a=20_000, pv_b=0),
|
||||
PlanningSlot(
|
||||
interval_start=datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc),
|
||||
buy_price=3.0,
|
||||
sell_price=2.5,
|
||||
pv_a_forecast_w=20_000,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=False,
|
||||
allow_discharge_export=False,
|
||||
),
|
||||
]
|
||||
battery = _battery()
|
||||
hp = SimpleNamespace(
|
||||
@@ -1009,10 +1021,145 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
self.assertGreater(results[0].battery_setpoint_w, 0, "surplus PV should charge")
|
||||
|
||||
|
||||
class AutoPassiveNoLoadFollowingDischargeTests(unittest.TestCase):
|
||||
"""AUTO bez allow_discharge_export: žádné plánované vybíjení do load (Deye PASSIVE)."""
|
||||
class AutoPvSurplusExportTests(unittest.TestCase):
|
||||
"""Plná baterie + vysoká FVE: export přebytku (ge_pv), ne curtailment, bez SELL."""
|
||||
|
||||
def test_no_battery_export_on_inflated_baseline_without_discharge_mask(self) -> None:
|
||||
def test_pv_surplus_exports_when_battery_export_disallowed(self) -> None:
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=datetime(2026, 5, 17, 10, 0, tzinfo=timezone.utc),
|
||||
buy_price=1.20,
|
||||
sell_price=0.80,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=12_000,
|
||||
load_baseline_w=2000,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=False,
|
||||
allow_discharge_export=False,
|
||||
),
|
||||
]
|
||||
battery = _battery(uc_wh=20_000.0, min_pct=10.0, arb_pct=20.0)
|
||||
battery.planner_terminal_soc_value_factor = 0.0
|
||||
battery.planner_daytime_charge_target_enabled = False
|
||||
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=17_000, max_export_power_w=8000)
|
||||
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.95 * battery.soc_max_wh
|
||||
results, _, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertLess(results[0].grid_setpoint_w, 0, "PV surplus should export to grid")
|
||||
self.assertEqual(results[0].deye_physical_mode, "PASSIVE")
|
||||
self.assertEqual(results[0].export_mode, "PV_SURPLUS")
|
||||
self.assertLess(results[0].pv_a_curtailed_w, 5000, "should not curtail all PV")
|
||||
|
||||
|
||||
class AutoPassiveSelfConsumptionTests(unittest.TestCase):
|
||||
"""AUTO bez allow_discharge_export: vlastní spotřeba, ne export do sítě."""
|
||||
|
||||
def test_expensive_slot_prefers_battery_over_grid_import(self) -> None:
|
||||
base = datetime(2026, 5, 16, 22, 0, tzinfo=timezone.utc)
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=base,
|
||||
buy_price=4.80,
|
||||
sell_price=2.90,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=1200,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=False,
|
||||
allow_discharge_export=False,
|
||||
),
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15),
|
||||
buy_price=0.50,
|
||||
sell_price=-0.20,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=1200,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=False,
|
||||
),
|
||||
]
|
||||
battery = _battery(uc_wh=20_000.0, min_pct=10.0, arb_pct=20.0)
|
||||
battery.planner_terminal_soc_value_factor = 0.0
|
||||
battery.planner_daytime_charge_target_enabled = False
|
||||
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=17_000, max_export_power_w=8000)
|
||||
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.23 * battery.usable_capacity_wh
|
||||
results, _, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertLess(
|
||||
results[0].battery_setpoint_w,
|
||||
0,
|
||||
msg="expensive slot should discharge for self-consumption before cheap charge",
|
||||
)
|
||||
self.assertGreaterEqual(results[0].grid_setpoint_w, 0)
|
||||
self.assertEqual(results[0].deye_physical_mode, "PASSIVE")
|
||||
|
||||
|
||||
class AutoPassiveNoLoadFollowingDischargeTests(unittest.TestCase):
|
||||
"""AUTO bez allow_discharge_export: žádný export do sítě (Deye PASSIVE)."""
|
||||
|
||||
def test_no_grid_export_on_inflated_baseline_without_discharge_mask(self) -> None:
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=datetime(2026, 5, 16, 9, 45, tzinfo=timezone.utc),
|
||||
@@ -1061,16 +1208,11 @@ class AutoPassiveNoLoadFollowingDischargeTests(unittest.TestCase):
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertGreaterEqual(
|
||||
results[0].battery_setpoint_w,
|
||||
0,
|
||||
msg="must not plan load-following discharge when allow_discharge_export=false",
|
||||
)
|
||||
self.assertEqual(results[0].deye_physical_mode, "PASSIVE")
|
||||
self.assertGreaterEqual(
|
||||
results[0].grid_setpoint_w,
|
||||
0,
|
||||
msg="must not export at a loss when discharge is disallowed",
|
||||
msg="must not export to grid when allow_discharge_export=false",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
- měkký cíl na konci 24h přes `_soc_security_profile` + tvrdé dvouúrovňové pravidlo výše.
|
||||
- **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` označí jen sloty, kde smí solver **úmyslně** vybíjet baterii do sítě (SELL). Výběr je **globálně** podle `sell_price desc` (ne AM/PM 50/50). Ekonomická podmínka je **arbitráž mezi sloty**: `sell_price > ref_buy + degradation_cost_czk_kwh`, kde `ref_buy` = `min(buy_price)` mezi sloty s `allow_charge=true` (fallback `min` v celém horizontu) — **ne** porovnání sell vs buy ve stejném intervalu. V `solve_dispatch` (AUTO): mimo tyto sloty platí **`bd[t] = 0`** a **`w_arb[t] = 0`** — EMS **neplánuje** vybíjení do predikovaného `load_baseline`; skutečnou zátěž pokrývá střídač v **PASSIVE**. **CHARGE** jen v `allow_charge` slotech; **SELL** jen v `allow_discharge_export`.
|
||||
- **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` označí jen sloty, kde smí solver **úmyslně** vybíjet baterii do sítě (SELL). Výběr je **globálně** podle `sell_price desc` (ne AM/PM 50/50). Ekonomická podmínka je **arbitráž mezi sloty**: `sell_price > ref_buy + degradation_cost_czk_kwh`, kde `ref_buy` = `min(buy_price)` mezi sloty s `allow_charge=true` (fallback `min` v celém horizontu) — **ne** porovnání sell vs buy ve stejném intervalu. V `solve_dispatch` (AUTO) je export rozdělen: **`ge_pv`** (kanál FVE) a **`ge_bat`** (baterie do sítě, jen v `allow_discharge_export`, vázáno na `z_export` a SoC podlahu); platí `ge = ge_pv + ge_bat` a `ge_bat ≥ ge − (pv_a + pv_b)` — baterie nesmí „přestrojit“ FVE. Mimo exportní sloty: **`ge_bat = 0`**, **`bd`** smí pokrýt vlastní spotřebu; **`bc`** smí nabíjet jen z **PV přebytku** i bez grid-charge masky (plná baterie + přebytek pole B jinak nejde do sítě). **`deye_physical_mode`** = PASSIVE kromě CHARGE/SELL.
|
||||
- **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í —
|
||||
|
||||
Reference in New Issue
Block a user