implementace load first
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-21 17:29:09 +02:00
parent e295e55770
commit 7c63fed296
4 changed files with 126 additions and 18 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -113,6 +113,7 @@ Pro **home-01** při nabíjení 11:0014:00 za ~0,70,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 **7090 %** 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`.*

View File

@@ -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.