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

This commit is contained in:
Dusan Vojacek
2026-05-26 09:05:33 +02:00
parent b4e5fc5040
commit 94eb256598
5 changed files with 71 additions and 14 deletions

View File

@@ -71,7 +71,7 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
PLANNER_BUILD_TAG = "2026-05-28-pre-neg-pv-export-forecast-v33"
PLANNER_BUILD_TAG = "2026-05-28-load-first-hard-v34"
# Před prvním sell<0: export FVE jen pokud predikce v sell<0 okně pokryje dobítí na prep cíl.
PRE_NEG_PV_EXPORT_FORECAST_MARGIN = 1.15
PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0
@@ -2437,6 +2437,16 @@ def solve_dispatch(
if om == "AUTO":
load_site_expr = float(s.load_baseline_w) + ev_total_t + hp[t]
ev_cap_slot_w = sum(
float(vehicles[e].max_charge_power_w)
for e in range(EV)
if (e == 0 and s.ev1_connected) or (e == 1 and s.ev2_connected)
)
max_load_site_w = (
float(s.load_baseline_w)
+ ev_cap_slot_w
+ float(heat_pump.rated_heating_power_w)
)
# BMS: jedno vybíjení — bilance při gi≈0 dá bd≈load+ge_bat; bd+ge_bat≤max by export
# započítalo dvakrát ((maxload)/2). Exportní sloty: load+ge_bat; jinak bd≤max.
prob += bd[t] <= battery.max_discharge_power_w
@@ -2451,12 +2461,25 @@ def solve_dispatch(
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]
# Vybíjení do domu až po pv_ld (Deye load-first); v exportních slotech smí bd→síť.
# Tvrdý load-first (Deye): při dostatečné FVE jen grid-nabíjení (bc_gi); jinak gi smí
# krmit deficit domu (noc / nízká FVE), ne fiktivně paralelně s plným PV→bc_pv.
house_grid_import_cap_w = max(
0.0,
max_load_site_w - pv_total_ub,
)
prob += gi[t] <= bc_gi[t] + house_grid_import_cap_w
pv_covers_load_site = (
pv_total_ub >= max_load_site_w + NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
)
if pv_covers_load_site:
prob += pv_ld[t] >= load_site_expr
# Vybíjení do domu až po pv_ld; v exportních slotech smí bd→síť.
if t not in discharge_export_slots:
prob += bd[t] <= load_site_expr - pv_ld[t]
prob += pv_ld[t] >= load_site_expr - gi[t] - bd[t]
if pv_covers_load_site:
prob += pv_ld[t] >= load_site_expr - bd[t]
else:
prob += pv_ld[t] >= load_site_expr - gi[t] - bd[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]

View File

@@ -1414,7 +1414,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-sell-soc-phases-v32")
self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG)
self.assertGreater(
results[0].battery_setpoint_w,
2_500,
@@ -1564,7 +1564,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-sell-soc-phases-v32")
self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG)
self.assertEqual(len(results), len(slots))
def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None:
@@ -1628,7 +1628,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
55.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-sell-soc-phases-v32")
self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG)
self.assertEqual(len(results), len(slots))
def test_fixed_tariff_neg_sell_no_grid_export(self) -> None:
@@ -2314,7 +2314,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32")
self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG)
peak_idx = sells.index(4.04)
peak = results[peak_idx]
self.assertIn(peak.export_mode, ("BATTERY_SELL", "PV_SURPLUS"))
@@ -2392,7 +2392,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32")
self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG)
r_midnight = results[2]
self.assertEqual(r_midnight.export_mode, "BATTERY_SELL")
self.assertGreaterEqual(abs(r_midnight.grid_setpoint_w), 12_500)
@@ -2435,7 +2435,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32")
self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG)
r = results[0]
self.assertEqual(r.export_mode, "BATTERY_SELL")
self.assertGreaterEqual(abs(r.grid_setpoint_w), 12_500)
@@ -3114,6 +3114,34 @@ class LoadFirstDispatchTests(unittest.TestCase):
)
self.assertEqual(r.export_mode, "PV_SURPLUS")
def test_neg_sell_prep_no_fictitious_grid_import_for_load(self) -> None:
"""sell<0 prep: FVE >> load → dům z PV, ne grid_setpoint == load_baseline."""
base = datetime(2026, 5, 26, 7, 45, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base,
buy_price=1.45,
sell_price=-0.07,
pv_a_forecast_w=3137,
pv_b_forecast_w=3418,
load_baseline_w=447,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
)
]
bat = _battery(uc_wh=64_000.0, max_pct=95.0)
bat.planner_neg_sell_prep_soc_percent = 80.0
bat.planner_neg_sell_full_soc_tail_slots = 4
r = self._solve_auto(slots, bat, 0.24 * bat.soc_max_wh)[0]
self.assertLessEqual(
abs(r.grid_setpoint_w),
100,
msg="tvrdý load-first: žádný fiktivní import = load při vysoké FVE",
)
self.assertGreater(r.battery_setpoint_w, 3000)
class PreNegativeSellExportTests(unittest.TestCase):
"""Před prvním sell<0: export přebytku (BA81/KV1 strategie), ne nabíjení + pozdní vývoz."""
@@ -3211,7 +3239,7 @@ class Home01PvStoreValueTests(unittest.TestCase):
results, _, snap = solve_dispatch(
slots, battery, hp, grid, [None, None], vehicles, 0.55 * battery.soc_max_wh, 50.0, operating_mode="AUTO"
)
self.assertEqual(snap["planner_build_tag"], "2026-05-28-neg-sell-soc-phases-v32")
self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG)
r0 = results[0]
self.assertLess(
r0.pv_a_curtailed_w,

View File

@@ -113,7 +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` při `sell < future_sell_opportunity degrad` **jen pokud `sell < 0`** (spot) nebo fixní tarif — u **`sell ≥ 0`** spot neblokuje export FVE kvůli budoucímu peak sell (solver export vs. nabíjení; baterii šetří `ge_bat`). Při `sell < 0` home-01: `ge_pv=0` / ventil pole B. Tag `2026-05-28-pv-positive-sell-solver-v29`.
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`.
6. **Load-first (Deye, AUTO, tvrdý v34):** `pv_ld` / `pv_sp`, `bc_pv` / `bc_gi`; `bc_pv + ge_pv ≤ pv_sp`; **`gi ≤ bc_gi + max(0, max_load pv_forecast)`**; při `pv ≥ load + 500 W` **`pv_ld ≥ load`**. Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`. Test `LoadFirstDispatchTests`.
7. **Self-konzistentní vrstva B (`R__063`, 2026-05):** iterativní filtr v plpgsql — vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 % deficitu SoC` (levnější alternativa dál v horizontu). Failsafe unlock pokud výsledek nepokryje safety target. Důsledek: `acquisition_pass1 ~ acquisition_pass2` v drtivé většině případů. Nové debug sloupce: `min_buy_before_cutoff_czk_kwh`, `pv_charge_wh_ahead`, `neg_buy_wh_ahead`, `grid_charge_suppressed_reason` (`cheaper_pv_ahead` / `cheaper_neg_buy_ahead` / `safety_failsafe_unlock`).
8. **Ekonomická transparentnost plánu (`V081`, 2026-05):** `planning_interval``cashflow_czk`, `battery_arbitrage_czk`, `penalty_czk`, `green_bonus_czk`; `fn_plan_explain_bundle``economics_summary`; post-processing v `solve_dispatch()`.

View File

@@ -15,7 +15,7 @@
- **Grid ze sítě (vrstva B, před FVE):** 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`). **Spot:** výběr **nejlevnější `buy`** (den plánu → před exportním oknem → `buy ASC`); navíc všechny sloty s **`buy < 0`** → `allow_grid_charge`. Po výběru AM/PM běží **iterativní self-konzistentní filtr** (vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 %` deficitu SoC; failsafe unlock). Debug: `grid_charge_suppressed_reason`. **Fixní tarif (BA81):** stejný AM/PM rozpočet, ale pořadí podle **`slot_ord`** (buy konstantní), jen pokud v horizontu existuje **`sell > buy + degradation`**; jinak jen PV vrstva A. 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:** 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`.
- **Load-first (Deye, AUTO, tvrdý od v34):** 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 ≤ bc_gi + max(0, max_load pv_forecast)`** (při vysoké FVE žádný fiktivní import = load); při `pv ≥ load + 500 W` **`pv_ld ≥ load`**; mimo `allow_discharge_export`: `bd ≤ load pv_ld`, `pv_ld ≥ load bd`. Tag `2026-05-28-load-first-hard-v34`. 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`.
- **Hodnota FVE (PV store value):** tvrdé `ge_pv = 0` jen pokud `sell < future_sell_opportunity degradation` **a** `sell < 0` (spot), nebo u fixního tarifu dle `fixed_pv_b_export_cap`. Při **`sell ≥ 0` (spot home-01, KV1):** `ge_pv` **neblokuje** pv_store — solver volí export vs. `bc_pv` podle `ge_pv×sell` a degradace; **baterii** na večerní peak drží `ge_bat` (`evening_early` / push), ne curtail FVE. **v31:** při `sell ≥ 0` + PV přebytek **není** plný `ge_bat` push z `pre_neg_buy_discharge` / ranních shortfallů (export cap pro FVE). **Před prvním `sell < 0`:** `allow_pre_neg_pv_export`. Tag `2026-05-28-morning-pv-export-priority-v31`. Testy `Home01PvStoreValueTests`, `PreNegativeSellExportTests`.
- **Drahý nákup → vlastní spotřeba z baterie:** mimo `allow_charge` platí `bd + pv_ld ≥ load_baseline + hp[t]` a `gi ≤ EV + hp[t]` (ne `hp_rated`). **Spot:** drahý slot = `buy > min(buy≥0) + degradace`. **Fixní nákup (DB `purchase_pricing_mode=fixed` nebo heuristika rozptylu buy &lt; 0,25):** navíc `buy > charge_acquisition + degradace`. Na spotu **nesmí** `charge_acquisition` (~0,9 Kč) označit všechny sloty jako drahé → Infeasible (home-01). Při **Infeasible** solver jednou opakuje s `relaxed_expensive_import` (síť smí krmit baseload v drahých slotech; v `solver_params.inputs.relaxed_expensive_import=true`). Testy `AutoPassiveSelfConsumptionTests`, `test_spot_low_acquisition_does_not_mark_all_slots_expensive`, `test_negative_buy_in_horizon_does_not_block_all_grid_import`.

View File

@@ -5,6 +5,12 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
---
## 2026-05-28 — Tvrdý load-first v LP (v34)
**Problém:** V sell&lt;0 prep plán ukazoval `grid_setpoint_w ≈ load_baseline` při FVE ≫ load — LP účetně posílal dům přes `gi`, zatímco Deye load-first krmit dům z FVE.
**Změna (tag `2026-05-28-load-first-hard-v34`):** `gi ≤ bc_gi + max(0, max_load pv_forecast)`; při dostatečné FVE `pv_ld ≥ load` (žádný fiktivní import = load při vysoké FVE). Test `LoadFirstDispatchTests::test_neg_sell_prep_no_fictitious_grid_import_for_load`.
## 2026-05-28 — Před sell&lt;0: export FVE jen při dostatečné predikci v záporném okně (v33)
**Problém:** Při kladném sell ráno LP nabíjel na večerní peak (~6,5 Kč) místo exportu (~3 Kč). Uživatel chce export teď, ale ne když forecast v sell&lt;0 okně nestačí na dobítí (déšť).