a dalsi fix
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:00–14: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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:00–14: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:30–14: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:00–14:45 SoC roste (PV charging), 13:30 `grid_setpoint` < 0 jen pole B (curtail pole A = 0), bilance: `cashflow_czk(13:00–15: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é.
|
||||
|
||||
Reference in New Issue
Block a user