implementace load first
This commit is contained in:
@@ -40,6 +40,8 @@ SOLVER_TIME_LIMIT = 10 # sekund
|
||||
GE_MIN_EXPORT_W = 1.0
|
||||
# Dvouprůchodové solve: stop když acquisition z pass1 vs pass2 se liší méně než (Kč/kWh).
|
||||
ACQUISITION_TWO_PASS_EPS_KWH = 0.05
|
||||
# Load-first (Deye): PV nejdřív pokryje load+EV+TČ; bc_pv/ge_pv jen z pv_sp (přebytek).
|
||||
LOAD_FIRST_INCENTIVE_CZK_KWH = 0.05
|
||||
# Dokud je kotva pro hluboký dump (první sell < 0 v horizontu, jinak první extrémní buy) dál než
|
||||
# tento počet 15min slotů, držíme plánovací spodek na rezervě (arb_base_wh) místo planner floor —
|
||||
# priorita: beze „ztráty na prodeji“ (sell >= 0) držet buffer, hluboký vývoz až těsně před záporným prodejem.
|
||||
@@ -799,8 +801,11 @@ def solve_dispatch(
|
||||
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)]
|
||||
bc_pv = [pulp.LpVariable(f"bc_pv_{t}", 0, battery.max_charge_power_w) for t in range(T)]
|
||||
bc_gi = [pulp.LpVariable(f"bc_gi_{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)]
|
||||
pv_ld = [pulp.LpVariable(f"pv_ld_{t}", 0) for t in range(T)]
|
||||
pv_sp = [pulp.LpVariable(f"pv_sp_{t}", 0) for t in range(T)]
|
||||
soc = [
|
||||
pulp.LpVariable(f"soc_{t}", soc_panel_min[t], battery.soc_max_wh) for t in range(T)
|
||||
]
|
||||
@@ -950,7 +955,12 @@ def solve_dispatch(
|
||||
else 0
|
||||
)
|
||||
+ 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_pv[t] + bc_gi[t] + bd[t]) * degradation_cost_effective * INTERVAL_H / 1000
|
||||
- (
|
||||
pv_ld[t] * LOAD_FIRST_INCENTIVE_CZK_KWH * INTERVAL_H / 1000
|
||||
if om == "AUTO"
|
||||
else 0
|
||||
)
|
||||
+ (
|
||||
ge_bat[t] * charge_acquisition_czk_kwh * INTERVAL_H / 1000
|
||||
if om == "AUTO" and t in discharge_export_slots
|
||||
@@ -1006,13 +1016,42 @@ def solve_dispatch(
|
||||
if z_gen_cutoff is not None
|
||||
else float(s.pv_b_forecast_w)
|
||||
)
|
||||
prob += (
|
||||
pv_a_net + pv_b_effective + gi[t] + bd[t]
|
||||
== s.load_baseline_w + ev_total_t + hp[t] + bc[t] + ge[t]
|
||||
)
|
||||
pv_total_ub = float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w)
|
||||
|
||||
if om == "AUTO":
|
||||
load_site_expr = float(s.load_baseline_w) + ev_total_t + hp[t]
|
||||
prob += pv_ld[t] + pv_sp[t] == pv_a_net + pv_b_effective
|
||||
prob += pv_ld[t] <= load_site_expr
|
||||
prob += pv_ld[t] <= pv_a_net + pv_b_effective
|
||||
prob += pv_sp[t] <= pv_total_ub
|
||||
prob += pv_sp[t] >= pv_a_net + pv_b_effective - load_site_expr
|
||||
prob += bc_pv[t] <= pv_sp[t]
|
||||
prob += bc_gi[t] <= gi[t]
|
||||
prob += ge_pv[t] <= pv_sp[t]
|
||||
prob += bc_pv[t] + ge_pv[t] <= pv_sp[t]
|
||||
# Import na deficit po PV→load, nebo na grid-nabíjení (bc_gi).
|
||||
prob += gi[t] <= load_site_expr + bc_gi[t]
|
||||
# Plná bilance (pv_ld+pv_sp rozpad je ortogonální k tokům přebytku).
|
||||
prob += (
|
||||
pv_a_net + pv_b_effective + gi[t] + bd[t]
|
||||
== float(s.load_baseline_w) + ev_total_t + hp[t] + bc_pv[t] + bc_gi[t] + ge[t]
|
||||
)
|
||||
else:
|
||||
prob += pv_ld[t] == 0
|
||||
prob += pv_sp[t] == pv_a_net + pv_b_effective
|
||||
prob += bc_pv[t] <= pv_sp[t]
|
||||
prob += bc_gi[t] <= gi[t]
|
||||
prob += (
|
||||
pv_a_net + pv_b_effective + gi[t] + bd[t]
|
||||
== s.load_baseline_w + ev_total_t + hp[t] + bc_pv[t] + bc_gi[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)
|
||||
# Baterie nesmí „přestrojit“ FVE export: jen z pv_sp (po load-first).
|
||||
if om == "AUTO":
|
||||
prob += ge_bat[t] >= ge[t] - pv_sp[t]
|
||||
else:
|
||||
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)
|
||||
@@ -1021,8 +1060,8 @@ def solve_dispatch(
|
||||
soc_prev = current_soc_wh if t == 0 else soc[t - 1]
|
||||
prob += soc[t] == (
|
||||
soc_prev
|
||||
+ bc[t] * battery.charge_efficiency * INTERVAL_H
|
||||
- bd[t] / battery.discharge_efficiency * INTERVAL_H
|
||||
+ (bc_pv[t] + bc_gi[t]) * battery.charge_efficiency * INTERVAL_H
|
||||
- bd[t] / battery.discharge_efficiency * INTERVAL_H
|
||||
)
|
||||
|
||||
sv = safety_vars[t]
|
||||
@@ -1097,7 +1136,8 @@ def solve_dispatch(
|
||||
s.load_baseline_w
|
||||
+ ev_total_t
|
||||
+ hp[t]
|
||||
+ bc[t]
|
||||
+ bc_pv[t]
|
||||
+ bc_gi[t]
|
||||
)
|
||||
else:
|
||||
prob += soc_prev_expr >= (
|
||||
@@ -1107,7 +1147,8 @@ def solve_dispatch(
|
||||
s.load_baseline_w
|
||||
+ ev_total_t
|
||||
+ hp[t]
|
||||
+ bc[t]
|
||||
+ bc_pv[t]
|
||||
+ bc_gi[t]
|
||||
+ battery.max_discharge_power_w * w_arb[t]
|
||||
)
|
||||
|
||||
@@ -1148,14 +1189,15 @@ def solve_dispatch(
|
||||
prob += ev_direct[e][t] + ev_via_bat[e][t] <= vehicles[e].max_charge_power_w
|
||||
|
||||
for tt, cv, prev in commit_lp:
|
||||
prob += cv >= prev - bc[tt]
|
||||
prob += cv >= prev - (bc_pv[tt] + bc_gi[tt])
|
||||
|
||||
if om == "SELF_SUSTAIN":
|
||||
for t in range(T):
|
||||
prob += gi[t] <= slots[t].load_baseline_w
|
||||
elif om == "PRESERVE":
|
||||
for t in range(T):
|
||||
prob += bc[t] == 0
|
||||
prob += bc_pv[t] == 0
|
||||
prob += bc_gi[t] == 0
|
||||
prob += bd[t] == 0
|
||||
elif om == "CHARGE_CHEAP":
|
||||
for t in range(T):
|
||||
@@ -1176,10 +1218,11 @@ def solve_dispatch(
|
||||
- int(s.load_baseline_w),
|
||||
)
|
||||
# Mimo grid-charge masku smí nabíjet jen z PV přebytku (ne import ze sítě).
|
||||
prob += bc_gi[t] == 0
|
||||
if pv_surplus_w <= 0:
|
||||
prob += bc[t] == 0
|
||||
prob += bc_pv[t] == 0
|
||||
else:
|
||||
prob += bc[t] <= pv_surplus_w
|
||||
prob += bc_pv[t] <= float(pv_surplus_w)
|
||||
if t not in discharge_export_slots:
|
||||
prob += ge_bat[t] == 0
|
||||
prob += z_export[t] == 0
|
||||
@@ -1282,7 +1325,8 @@ def solve_dispatch(
|
||||
for t in range(T):
|
||||
hp_raw = pulp.value(hp[t])
|
||||
hp_on = hp_raw > heat_pump.rated_heating_power_w * 0.3
|
||||
batt_w = round(pulp.value(bc[t]) - pulp.value(bd[t]))
|
||||
bc_tot = float(pulp.value(bc_pv[t]) or 0) + float(pulp.value(bc_gi[t]) or 0)
|
||||
batt_w = round(bc_tot - float(pulp.value(bd[t]) or 0))
|
||||
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
|
||||
|
||||
@@ -1720,5 +1720,67 @@ class Home01RegressionTests(unittest.TestCase):
|
||||
self.assertNotEqual(results[i].export_mode, "PV_SURPLUS")
|
||||
|
||||
|
||||
class LoadFirstDispatchTests(unittest.TestCase):
|
||||
"""Deye load-first: PV do spotřeby dřív než bc_pv/ge_pv z přebytku."""
|
||||
|
||||
@staticmethod
|
||||
def _solve_auto(
|
||||
slots: list[PlanningSlot],
|
||||
battery: SimpleNamespace,
|
||||
soc0: float,
|
||||
) -> list[DispatchResult]:
|
||||
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),
|
||||
]
|
||||
results, _, _ = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
soc0,
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
return results
|
||||
|
||||
def test_high_pv_low_load_prefers_export_over_battery_charge(self) -> None:
|
||||
"""Mimo grid-charge masku nesmí LP nabíjet z celého PV při malé zátěži."""
|
||||
base = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc)
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=base,
|
||||
buy_price=2.0,
|
||||
sell_price=4.0,
|
||||
pv_a_forecast_w=8000,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=500,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=False,
|
||||
allow_charge=False,
|
||||
allow_discharge_export=False,
|
||||
)
|
||||
]
|
||||
battery = _battery(uc_wh=50_000.0)
|
||||
soc0 = 0.5 * battery.usable_capacity_wh
|
||||
r = self._solve_auto(slots, battery, soc0)[0]
|
||||
self.assertLessEqual(
|
||||
r.battery_setpoint_w,
|
||||
200,
|
||||
msg="load-first: přebytek FVE má jít do exportu, ne do bc_pv",
|
||||
)
|
||||
self.assertLess(
|
||||
r.grid_setpoint_w,
|
||||
-400,
|
||||
msg="očekáván PV export (přebytek po load-first)",
|
||||
)
|
||||
self.assertEqual(r.export_mode, "PV_SURPLUS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -113,6 +113,7 @@ Pro **home-01** při nabíjení 11:00–14:00 za ~0,7–0,9 Kč a výprodeji 19:
|
||||
3. **Guard FVE:** `ge_pv=0` jen při `sell < charge_acquisition − degrad` (ne `sell < buy` ve stejném slotu).
|
||||
4. **`solve_dispatch_two_pass`:** pass 1 → vážený `buy` z `bc`+`gi` v `allow_charge` → pass 2; volá `run_daily_plan` / `run_rolling_replan` v AUTO. Snapshot: `acquisition_pass1_czk_kwh`, `acquisition_pass2_czk_kwh`, `two_pass_enabled`.
|
||||
5. **Regrese:** `Home01RegressionTests` v `backend/tests/test_planning_dispatch_milp.py`; masky v `test_planning_charge_slot_selection.py`.
|
||||
6. **Load-first (Deye, AUTO):** proměnné `pv_ld` / `pv_sp`, `bc_pv` / `bc_gi`; přebytek FVE jen `bc_pv + ge_pv ≤ pv_sp`; `gi ≤ load + bc_gi` (žádný fiktivní import při PV exportu). Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`. Test `LoadFirstDispatchTests`.
|
||||
|
||||
### Co dál neřešit ad-hoc
|
||||
|
||||
@@ -144,4 +145,4 @@ Očekávání: SoC před večerem **70–90 %** po levném pásmu; večer **expo
|
||||
|
||||
---
|
||||
|
||||
*Poslední aktualizace: 2026-05 — LP-first přestavba (masky B/A, two-pass acquisition, explicitní ge_pv/ge_bat objective). Po deployi: `solver_params.inputs.two_pass_enabled` na novém `planning_run`.*
|
||||
*Poslední aktualizace: 2026-05 — LP-first přestavba (masky B/A, two-pass acquisition, explicitní ge_pv/ge_bat, load-first Deye). Po deployi: `solver_params.inputs.two_pass_enabled` na novém `planning_run`.*
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- **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`. **`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).
|
||||
- **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` (nabíjení jen z `pv_sp` resp. `gi`). Bilance `pv_ld + gi + bd = load + ev + hp + bc_pv + bc_gi + ge`; `ge_bat ≥ ge − pv_sp`. Měkká preference `−LOAD_FIRST_INCENTIVE × pv_ld` v objective. Mimo `allow_charge`: `bc_gi=0`, `bc_pv ≤ pv_surplus` (forecast). Implementace: `solve_dispatch()` v `planning_engine.py`; test `LoadFirstDispatchTests`.
|
||||
- **`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.
|
||||
- **LP ekonomické guardy** (`solve_dispatch`, AUTO): `ge_pv=0` pokud `sell < charge_acquisition − degradation` (výjimka: plná baterie, přebytek **pv_b**). Pokud `buy > min(buy)+degradation` mimo charge masku → `gi` jen na load+EV+TČ. Viz `planning_engine.py` po slot pre-selection.
|
||||
|
||||
Reference in New Issue
Block a user