a dalsi fix
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-25 00:10:58 +02:00
parent b844a9182f
commit 9ba65ea6bb
5 changed files with 170 additions and 8 deletions

View File

@@ -64,7 +64,7 @@ NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0
# Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž).
EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12
NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0
PLANNER_BUILD_TAG = "2026-05-27-self-consistent-grid-mask-v12"
PLANNER_BUILD_TAG = "2026-05-27-neg-sell-soc-reservation-v13"
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru

View File

@@ -1010,5 +1010,125 @@ class FixedPurchasePricingTests(unittest.TestCase):
self.assertIn(2, discharge, "oba sloty sell > buy + degrad")
class NegSellReservationTests(unittest.TestCase):
"""Mirror SQL `R__063` PV vrstva A — rezervace `v_pv_layer_cap_wh` pro `sell<0` okno.
Cíl: do prvního `sell<0` slotu dorazit s SoC = soc_max min(neg_window_pv, available_storage),
aby `sell<0` PV mohlo doplnit zbytek bez exportu / curtail pole A.
"""
@staticmethod
def _pv_layer_cap_with_reservation(
slots, # list of dict { sell, pv_surplus_w }
energy_to_fill_wh: float,
grid_filled_wh: float,
max_charge_w: float,
charge_eff: float,
) -> float:
cap = max(energy_to_fill_wh - grid_filled_wh, 0.0)
neg_window = sum(
min(float(s["pv_surplus_w"]), max_charge_w) * charge_eff * 0.25
for s in slots
if float(s["sell"]) < 0 and float(s["pv_surplus_w"]) > 0
)
return max(cap - neg_window, 0.0)
def test_neg_sell_window_reduces_pv_layer_cap(self) -> None:
# 4 ranní PV sloty (sell>=0), 2 odpolední neg-sell PV sloty
slots = [
{"sell": 1.2, "pv_surplus_w": 6000}, # 10:00 cap candidate
{"sell": 1.0, "pv_surplus_w": 7000}, # 11:00
{"sell": 0.8, "pv_surplus_w": 8000}, # 12:00
{"sell": -0.5, "pv_surplus_w": 9000}, # 13:00 neg-sell
{"sell": -1.0, "pv_surplus_w": 9000}, # 13:30 neg-sell
]
cap_before = 20_000.0 # 20 kWh deficit
cap_after = self._pv_layer_cap_with_reservation(
slots,
energy_to_fill_wh=cap_before,
grid_filled_wh=0.0,
max_charge_w=10_000.0,
charge_eff=0.95,
)
# neg-window = 2 × min(9000, 10000) × 0.95 × 0.25 = 4 275
self.assertAlmostEqual(cap_after, cap_before - 4275.0, places=1)
self.assertLess(cap_after, cap_before)
def test_neg_sell_pv_covers_full_deficit(self) -> None:
slots = [
{"sell": 1.0, "pv_surplus_w": 5000}, # ranní
{"sell": -0.4, "pv_surplus_w": 9000},
{"sell": -1.1, "pv_surplus_w": 9000},
{"sell": -0.8, "pv_surplus_w": 9000},
]
# neg-window = 3 × 9000 × 0.95 × 0.25 = 6 412.5 Wh
cap_after = self._pv_layer_cap_with_reservation(
slots,
energy_to_fill_wh=5_000.0,
grid_filled_wh=0.0,
max_charge_w=10_000.0,
charge_eff=0.95,
)
# cap (5000) - neg_window (6412.5) → clamp na 0 → žádné ranní PV nabíjení
self.assertEqual(cap_after, 0.0)
def test_no_neg_sell_window_no_change(self) -> None:
slots = [
{"sell": 1.2, "pv_surplus_w": 6000},
{"sell": 0.8, "pv_surplus_w": 7000},
]
cap_before = 8_000.0
cap_after = self._pv_layer_cap_with_reservation(
slots,
energy_to_fill_wh=cap_before,
grid_filled_wh=0.0,
max_charge_w=10_000.0,
charge_eff=0.95,
)
self.assertEqual(cap_after, cap_before)
class FallbackAcquisitionTests(unittest.TestCase):
"""Mirror SQL `R__063` fallback when `v_est_grid_wh = 0` and no positive buy grid slot."""
@staticmethod
def _fallback_acq(slots, est_grid_wh: float, est_grid_cost: float) -> float:
if est_grid_wh > 0:
return est_grid_cost / est_grid_wh
positive_buys = [float(s["buy"]) for s in slots if float(s["buy"]) >= 0]
v = min(positive_buys) if positive_buys else 0.0
return max(v, 0.0)
def test_no_grid_charging_but_horizon_has_positive_buys(self) -> None:
# PM má negativní buy (13:0014:00), ale jinde v horizontu pozitivní min
slots = [
{"buy": 4.5, "hour": 23},
{"buy": 0.3, "hour": 7},
{"buy": 0.7, "hour": 10},
{"buy": -0.36, "hour": 13},
{"buy": -0.43, "hour": 14},
]
acq = self._fallback_acq(slots, est_grid_wh=0.0, est_grid_cost=0.0)
self.assertAlmostEqual(acq, 0.3, places=4)
self.assertGreaterEqual(acq, 0.0)
def test_all_negative_buys_clamps_to_zero(self) -> None:
slots = [
{"buy": -0.4, "hour": 13},
{"buy": -0.5, "hour": 14},
]
acq = self._fallback_acq(slots, est_grid_wh=0.0, est_grid_cost=0.0)
self.assertEqual(acq, 0.0)
def test_positive_weighted_mean_unchanged(self) -> None:
# est_grid_wh > 0 — vrátit weighted mean, ne fallback
acq = self._fallback_acq(
[{"buy": 0.7, "hour": 10}],
est_grid_wh=4000.0,
est_grid_cost=4000.0 * 0.7,
)
self.assertAlmostEqual(acq, 0.7, places=4)
if __name__ == "__main__":
unittest.main()

View File

@@ -1230,7 +1230,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-self-consistent-grid-mask-v12")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-neg-sell-soc-reservation-v13")
self.assertGreater(
results[0].battery_setpoint_w,
5_500,
@@ -1380,7 +1380,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-self-consistent-grid-mask-v12")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-neg-sell-soc-reservation-v13")
self.assertEqual(len(results), len(slots))
def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None:
@@ -1444,7 +1444,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
55.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-self-consistent-grid-mask-v12")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-neg-sell-soc-reservation-v13")
self.assertEqual(len(results), len(slots))
def test_fixed_tariff_neg_sell_no_grid_export(self) -> None:

View File

@@ -737,6 +737,26 @@ begin
-- A) PV-surplus: jen zbytek kapacity po grid vrstvě B
v_pv_layer_cap_wh := greatest(v_energy_to_fill - v_grid_filled_wh, 0);
-- Rezervace SoC pro sell<0 okno: pokud v zápor. výkup. slotech máme
-- očekávaný PV přebytek X Wh (po efektivitě), snížíme PV vrstvu A o X.
-- Důsledek: do okna nedorazíme „plní" (98 % SoC), zbude prostor přijmout PV
-- z neg-sell slotů místo exportu do mínusu / curtail pole A.
-- Sample neg-sell PV sloty (sell<0 a buy<0, kde sell<buy) jsou vyloučené
-- z hlavního A-loopu (filtr sell >= buy degrad), takže redukce je čistá.
declare
v_neg_window_pv_surplus_wh numeric := 0;
begin
select coalesce(sum(least(wk.pv_surplus_w::numeric, v_max_charge_w) * v_charge_eff * 0.25), 0)
into v_neg_window_pv_surplus_wh
from _ems_plan_slot_wk wk
where wk.sell_price < 0
and wk.pv_surplus_w > 0;
if v_neg_window_pv_surplus_wh > 0 then
v_pv_layer_cap_wh := greatest(v_pv_layer_cap_wh - v_neg_window_pv_surplus_wh, 0);
end if;
end;
v_cum := 0;
for r_slot in
select wk.slot_ord, wk.pv_surplus_w
@@ -999,11 +1019,16 @@ begin
if v_est_grid_wh > 0 then
v_charge_acquisition := v_est_grid_cost / v_est_grid_wh;
elsif v_charge_acquisition is null then
v_charge_acquisition := coalesce(
(v_ref_buy_am_czk_kwh + v_ref_buy_pm_czk_kwh) / 2.0,
v_ref_buy_czk_kwh
);
-- Fallback: nejnizsi positivni buy v horizontu (nikoli avg ref_buy_am/pm,
-- ktery muze byt < 0 kdyz PM zahrnuje zaporne OTE sloty 13-15h).
-- Cena akvizice baterie nikdy nesmi byt < 0 (jinak rozhazuje arbitraz
-- objective + two_pass divergence).
select coalesce(min(wk.buy_price), 0)
into v_charge_acquisition
from _ems_plan_slot_wk wk
where wk.buy_price >= 0;
end if;
v_charge_acquisition := greatest(v_charge_acquisition, 0);
-- v_charge_acquisition z min(grid) zůstane, pokud je jen jeden grid slot před exportem
return query

View File

@@ -5,6 +5,23 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
---
## 2026-05-27 (c) — rezervace SoC pro `sell<0` okno + fallback acquisition ≥ 0 (v13)
**Problém (home-01 run 16614, tag v12):** Aktivní plán pro 2026-05-25:
- 10:30 SoC = 96,9 %, 10:45 SoC = 98,3 % (baterie plná z PV ráno) → odpoledne v `sell<0` slotech (13:0014:45, sell až 1,08 Kč) **ge_pv export** + curtail pole A 5 kW. Ztráta 6+ Kč.
- `acquisition_pass1 = 0,035` (`R__063` fallback path: `(ref_buy_am + ref_buy_pm)/2`, ref_buy_pm < 0 protože PM zahrnuje 13:3014:00 s buy ≈ 0,36 Kč) → `two_pass_converged = false`.
**Oprava (tag `2026-05-27-neg-sell-soc-reservation-v13`):**
- **`R__063` PV vrstva A — rezervace pro `sell<0` okno:** před iterátorem vrstvy A spočítat `v_neg_window_pv_surplus_wh = sum(min(pv_surplus_w, max_charge_w) * eff * 0.25) FILTER (sell<0, pv_surplus>0)`. Snížit `v_pv_layer_cap_wh` o tuto hodnotu (lower bound 0). Důsledek: před `sell<0` oknem se nabíjí jen `deficit neg_window_pv_wh`; do okna doráží baterie nenaplněná a `sell<0` PV slot ji dorovná místo exportu / curtailu pole A.
- **`R__063` fallback acquisition:** když `v_est_grid_wh = 0` a `min(buy) FILTER (allow_grid_charge AND buy>=0)` je NULL, místo avg `ref_buy_am/pm` (může být záporný) použít `coalesce(min(buy) FILTER (buy>=0), 0)`. Navíc `v_charge_acquisition := greatest(v_charge_acquisition, 0)` jako pojistka — arbitrážní akviziční cena nesmí být < 0.
**Ověření:**
- Replan home-01 (po redeploy R__063) → 10:45 SoC < 95 %, 13:0014:45 SoC roste (PV charging), 13:30 `grid_setpoint` < 0 jen pole B (curtail pole A = 0), bilance: `cashflow_czk(13:0015:00) > 0`.
- `acquisition_pass1_czk_kwh ≥ 0`, `two_pass_converged = true`.
---
## 2026-05-27 (b) — acquisition: vyloučit záporný OTE buy z váženého průměru
**Problém (home-01 run 16588):** `two_pass_converged=false`, `acquisition_pass1≈0.035` (pass1 nabíjení v `buy<0` slotech), `pass2≈0.88`. Noční grid 4,8 Kč už v plánu není (maska B OK), ale two-pass a arbitrážní marže exportu baterie byly křivé.