dalsi fix - chtel drzet baterii prakticky porad a neprodat ani nejeet passive mode
This commit is contained in:
@@ -1,15 +1,14 @@
|
||||
"""Pre-selection nabíjecích slotů (anti-micro-cycling) – referenční Python.
|
||||
"""Pre-selection nabíjecích a exportních slotů – referenční Python.
|
||||
|
||||
Logika je v DB: ems.fn_load_planning_slots_full. Tento soubor drží kopii algoritmu
|
||||
pro rychlé unit testy bez PostgreSQL.
|
||||
Logika je v DB: ems.fn_load_planning_slots_full. Kopie algoritmu pro unit testy bez PG.
|
||||
|
||||
Algoritmus (aktuální):
|
||||
A) PV-surplus sloty (pv_surplus > 0): ranking dle sell_price ASC,
|
||||
vyberou se nejlevnější, dokud kumulativní PV surplus nepokryje
|
||||
charge target (energy_to_fill × charge_buf). Zbylé → PV do sítě.
|
||||
B) Non-PV sloty (pv_surplus <= 0): AM/PM rozpočet 50/50,
|
||||
OTE-first priorita (is_predicted_price=false před true),
|
||||
poté seřazené dle buy_price ASC.
|
||||
Charge mask:
|
||||
A) PV-surplus: sell_price ASC, dokud PV nepokryje charge target.
|
||||
B) Non-PV: AM/PM 50/50, OTE-first, buy_price ASC.
|
||||
|
||||
Discharge-export mask:
|
||||
ref_buy = min(buy) mezi allow_charge sloty (arbitráž mezi sloty, ne sell vs buy ve stejném).
|
||||
Top sloty dle sell_price desc kde sell > ref_buy + degradation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -108,6 +107,54 @@ def _select_charge_slots(
|
||||
return selected
|
||||
|
||||
|
||||
def _select_discharge_export_slots(
|
||||
slots: list[PlanningSlot],
|
||||
battery: SimpleNamespace,
|
||||
current_soc_wh: float,
|
||||
charge_slots: set[int] | None = None,
|
||||
) -> set[int]:
|
||||
"""Kopie logiky z ems.fn_load_planning_slots_full (discharge-export mask)."""
|
||||
discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
|
||||
if discharge_buf <= 0:
|
||||
return set(range(len(slots)))
|
||||
|
||||
min_soc_wh = float(getattr(battery, "min_soc_wh", 0) or 0)
|
||||
soc_max_wh = float(battery.soc_max_wh)
|
||||
exportable_wh = soc_max_wh - min_soc_wh
|
||||
if exportable_wh <= 0:
|
||||
return set()
|
||||
|
||||
degrad = float(getattr(battery, "degradation_cost_czk_kwh", 0.15) or 0.15)
|
||||
eta = float(getattr(battery, "discharge_efficiency", 1.0) or 1.0)
|
||||
max_p_w = float(getattr(battery, "max_discharge_power_w", 0.0) or 0.0)
|
||||
per_slot_wh = max_p_w * eta * INTERVAL_H
|
||||
discharge_target_wh = exportable_wh * discharge_buf
|
||||
|
||||
if charge_slots is None:
|
||||
charge_slots = _select_charge_slots(slots, battery, current_soc_wh)
|
||||
|
||||
ref_buy = min(
|
||||
(float(slots[t].buy_price) for t in charge_slots),
|
||||
default=min(float(s.buy_price) for s in slots),
|
||||
)
|
||||
|
||||
candidates = [
|
||||
(t, float(slots[t].sell_price))
|
||||
for t in range(len(slots))
|
||||
if float(slots[t].sell_price) > ref_buy + degrad
|
||||
]
|
||||
candidates.sort(key=lambda x: (-x[1], -x[0]))
|
||||
|
||||
selected: set[int] = set()
|
||||
cum = 0.0
|
||||
for t, _sell in candidates:
|
||||
if cum >= discharge_target_wh or per_slot_wh <= 0:
|
||||
break
|
||||
selected.add(t)
|
||||
cum += per_slot_wh
|
||||
return selected
|
||||
|
||||
|
||||
def _prague_hour(s: PlanningSlot) -> int:
|
||||
dt = s.interval_start
|
||||
if dt.tzinfo is None:
|
||||
@@ -140,17 +187,28 @@ def _slot(
|
||||
def _battery(
|
||||
*,
|
||||
charge_buf: float = 1.3,
|
||||
discharge_buf: float = 1.5,
|
||||
uc_wh: float = 64_000.0,
|
||||
soc_max_pct: float = 95.0,
|
||||
min_soc_pct: float = 10.0,
|
||||
max_charge_w: float = 18_000.0,
|
||||
max_discharge_w: float = 18_000.0,
|
||||
charge_eff: float = 0.95,
|
||||
discharge_eff: float = 0.95,
|
||||
degrad: float = 0.15,
|
||||
) -> SimpleNamespace:
|
||||
uc = uc_wh
|
||||
return SimpleNamespace(
|
||||
usable_capacity_wh=uc_wh,
|
||||
soc_max_wh=soc_max_pct / 100.0 * uc_wh,
|
||||
usable_capacity_wh=uc,
|
||||
min_soc_wh=min_soc_pct / 100.0 * uc,
|
||||
soc_max_wh=soc_max_pct / 100.0 * uc,
|
||||
max_charge_power_w=max_charge_w,
|
||||
max_discharge_power_w=max_discharge_w,
|
||||
charge_efficiency=charge_eff,
|
||||
discharge_efficiency=discharge_eff,
|
||||
charge_slot_buffer=charge_buf,
|
||||
discharge_slot_buffer=discharge_buf,
|
||||
degradation_cost_czk_kwh=degrad,
|
||||
)
|
||||
|
||||
|
||||
@@ -241,5 +299,36 @@ class SelectChargeSlotsTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class SelectDischargeExportSlotsTests(unittest.TestCase):
|
||||
def test_evening_sell_allowed_when_cheaper_than_ref_charge_buy(self) -> None:
|
||||
"""Regrese home-01: večer sell 3.3 > ref_buy 0.5 + degrad i když buy ve slotu je 5.6."""
|
||||
slots = [
|
||||
_slot(buy=0.50, sell=-0.30, hour_utc=6),
|
||||
_slot(buy=0.51, sell=-0.29, hour_utc=7),
|
||||
_slot(buy=5.60, sell=3.30, hour_utc=16),
|
||||
_slot(buy=5.98, sell=3.37, hour_utc=17),
|
||||
]
|
||||
battery = _battery(uc_wh=64_000.0)
|
||||
charge = _select_charge_slots(slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh)
|
||||
discharge = _select_discharge_export_slots(
|
||||
slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh, charge_slots=charge
|
||||
)
|
||||
self.assertIn(0, charge)
|
||||
self.assertIn(2, discharge, "Evening sell must qualify vs ref buy, not same-slot buy")
|
||||
self.assertIn(3, discharge)
|
||||
|
||||
def test_export_excluded_when_sell_below_ref_buy_plus_degradation(self) -> None:
|
||||
slots = [
|
||||
_slot(buy=0.40, sell=0.10, hour_utc=8),
|
||||
_slot(buy=4.00, sell=0.50, hour_utc=18),
|
||||
]
|
||||
battery = _battery(uc_wh=10_000.0, discharge_buf=2.0)
|
||||
charge = _select_charge_slots(slots, battery, current_soc_wh=0.0)
|
||||
discharge = _select_discharge_export_slots(
|
||||
slots, battery, current_soc_wh=0.0, charge_slots=charge
|
||||
)
|
||||
self.assertNotIn(1, discharge, "sell 0.5 < ref 0.4 + 0.15")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -61,6 +61,7 @@ declare
|
||||
v_daytime_en boolean;
|
||||
v_night_buf_pct numeric;
|
||||
v_degrad_czk_kwh numeric;
|
||||
v_ref_buy_czk_kwh numeric;
|
||||
begin
|
||||
drop table if exists _ems_plan_slot_wk;
|
||||
create temp table _ems_plan_slot_wk on commit drop as
|
||||
@@ -323,6 +324,17 @@ begin
|
||||
end loop;
|
||||
end if;
|
||||
|
||||
-- Referenční nákup pro arbitráž exportu: nejlevnější buy mezi sloty, kde lze nabíjet
|
||||
-- (ne buy ve stejném slotu — střídač nekupuje a neprodává současně).
|
||||
select coalesce(
|
||||
min(wk.buy_price) filter (where wk.allow_charge),
|
||||
min(wk.buy_price)
|
||||
)
|
||||
into v_ref_buy_czk_kwh
|
||||
from _ems_plan_slot_wk wk;
|
||||
|
||||
v_ref_buy_czk_kwh := coalesce(v_ref_buy_czk_kwh, 0);
|
||||
|
||||
-- discharge-export mask
|
||||
if v_discharge_buf <= 0 then
|
||||
update _ems_plan_slot_wk wk set allow_discharge_export = true;
|
||||
@@ -334,7 +346,7 @@ begin
|
||||
for r_slot in
|
||||
select wk.slot_ord
|
||||
from _ems_plan_slot_wk wk
|
||||
where wk.sell_price > wk.buy_price + v_degrad_czk_kwh
|
||||
where wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
||||
order by wk.sell_price desc, wk.slot_ord desc
|
||||
loop
|
||||
exit when v_cum >= v_discharge_target_wh;
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
- měkký cíl na konci 24h přes `_soc_security_profile` + tvrdé dvouúrovňové pravidlo výše.
|
||||
- **Dynamická ekonomická podlaha (fáze 2):**
|
||||
- `_dynamic_arb_floor_wh_series`: podle součtu FVE výkonu v dalších ~8 h (`ARB_LOOKAHEAD_SLOTS`) se `arb_floor_wh[t]` posouvá mezi `min_soc_wh` a rezervou z DB – silné očekávané slunce ji sníží (ráno / po obloze); vynutit konstantní chování lze `battery.disable_dynamic_arb_floor=True` jen pro testy / ladění.
|
||||
- **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` označí jen sloty, kde smí solver **úmyslně** vybíjet baterii do sítě (SELL). Výběr je **globálně** podle `sell_price desc` (ne AM/PM 50/50) a jen kde `sell_price > buy_price + degradation_cost_czk_kwh` (žádný export se ztrátou). V `solve_dispatch` (AUTO): mimo tyto sloty platí **`bd[t] = 0`** a **`w_arb[t] = 0`** — EMS **neplánuje** vybíjení do predikovaného `load_baseline`; skutečnou zátěž v těch slotech pokrývá střídač v režimu **PASSIVE** (`deye_physical_mode`). **CHARGE** jen v `allow_charge` slotech s importem+nabíjením; **SELL** jen v `allow_discharge_export` s exportem+vybíjením.
|
||||
- **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` označí jen sloty, kde smí solver **úmyslně** vybíjet baterii do sítě (SELL). Výběr je **globálně** podle `sell_price desc` (ne AM/PM 50/50). Ekonomická podmínka je **arbitráž mezi sloty**: `sell_price > ref_buy + degradation_cost_czk_kwh`, kde `ref_buy` = `min(buy_price)` mezi sloty s `allow_charge=true` (fallback `min` v celém horizontu) — **ne** porovnání sell vs buy ve stejném intervalu. V `solve_dispatch` (AUTO): mimo tyto sloty platí **`bd[t] = 0`** a **`w_arb[t] = 0`** — EMS **neplánuje** vybíjení do predikovaného `load_baseline`; skutečnou zátěž pokrývá střídač v **PASSIVE**. **CHARGE** jen v `allow_charge` slotech; **SELL** jen v `allow_discharge_export`.
|
||||
- **Záporná nákupní cena:**
|
||||
- horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu).
|
||||
- **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí —
|
||||
|
||||
Reference in New Issue
Block a user