fix nabijeni z gridu u fixnich tarifu
This commit is contained in:
@@ -27,6 +27,8 @@ def _select_charge_slots(
|
|||||||
slots: list[PlanningSlot],
|
slots: list[PlanningSlot],
|
||||||
battery: SimpleNamespace,
|
battery: SimpleNamespace,
|
||||||
current_soc_wh: float,
|
current_soc_wh: float,
|
||||||
|
*,
|
||||||
|
purchase_pricing_mode: str = "spot",
|
||||||
) -> set[int]:
|
) -> set[int]:
|
||||||
"""Kopie logiky z ems.fn_load_planning_slots_full (charge mask)."""
|
"""Kopie logiky z ems.fn_load_planning_slots_full (charge mask)."""
|
||||||
charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0)
|
charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0)
|
||||||
@@ -72,6 +74,10 @@ def _select_charge_slots(
|
|||||||
selected.add(t)
|
selected.add(t)
|
||||||
cum += min(pv_surplus_w, max_p_w) * eta * INTERVAL_H
|
cum += min(pv_surplus_w, max_p_w) * eta * INTERVAL_H
|
||||||
|
|
||||||
|
# B) Non-PV grid charge — jen spot nákup (u fixed je buy všude stejný → jen FVE)
|
||||||
|
if purchase_pricing_mode == "fixed":
|
||||||
|
return selected
|
||||||
|
|
||||||
# B) Non-PV: AM budget (OTE-first)
|
# B) Non-PV: AM budget (OTE-first)
|
||||||
am_candidates = [
|
am_candidates = [
|
||||||
(t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price))
|
(t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price))
|
||||||
@@ -112,6 +118,8 @@ def _select_discharge_export_slots(
|
|||||||
battery: SimpleNamespace,
|
battery: SimpleNamespace,
|
||||||
current_soc_wh: float,
|
current_soc_wh: float,
|
||||||
charge_slots: set[int] | None = None,
|
charge_slots: set[int] | None = None,
|
||||||
|
*,
|
||||||
|
purchase_pricing_mode: str = "spot",
|
||||||
) -> set[int]:
|
) -> set[int]:
|
||||||
"""Kopie logiky z ems.fn_load_planning_slots_full (discharge-export mask)."""
|
"""Kopie logiky z ems.fn_load_planning_slots_full (discharge-export mask)."""
|
||||||
discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
|
discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
|
||||||
@@ -138,10 +146,14 @@ def _select_discharge_export_slots(
|
|||||||
default=min(float(s.buy_price) for s in slots),
|
default=min(float(s.buy_price) for s in slots),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if purchase_pricing_mode == "fixed":
|
||||||
|
sell_min = degrad
|
||||||
|
else:
|
||||||
|
sell_min = ref_buy + degrad
|
||||||
candidates = [
|
candidates = [
|
||||||
(t, float(slots[t].sell_price))
|
(t, float(slots[t].sell_price))
|
||||||
for t in range(len(slots))
|
for t in range(len(slots))
|
||||||
if float(slots[t].sell_price) > ref_buy + degrad
|
if float(slots[t].sell_price) > sell_min
|
||||||
]
|
]
|
||||||
candidates.sort(key=lambda x: (-x[1], -x[0]))
|
candidates.sort(key=lambda x: (-x[1], -x[0]))
|
||||||
|
|
||||||
@@ -330,5 +342,39 @@ class SelectDischargeExportSlotsTests(unittest.TestCase):
|
|||||||
self.assertNotIn(1, discharge, "sell 0.5 < ref 0.4 + 0.15")
|
self.assertNotIn(1, discharge, "sell 0.5 < ref 0.4 + 0.15")
|
||||||
|
|
||||||
|
|
||||||
|
class FixedPurchasePricingTests(unittest.TestCase):
|
||||||
|
"""purchase_pricing_mode=fixed: žádné grid CHARGE, export dle sell."""
|
||||||
|
|
||||||
|
def test_fixed_skips_non_pv_grid_charge_slots(self) -> None:
|
||||||
|
slots = [
|
||||||
|
_slot(buy=6.35, sell=2.0, hour_utc=14, load=500),
|
||||||
|
_slot(buy=6.35, sell=3.5, hour_utc=18, load=500),
|
||||||
|
]
|
||||||
|
battery = _battery(charge_buf=1.3, uc_wh=12_500.0)
|
||||||
|
out = _select_charge_slots(
|
||||||
|
slots,
|
||||||
|
battery,
|
||||||
|
current_soc_wh=0.4 * battery.usable_capacity_wh,
|
||||||
|
purchase_pricing_mode="fixed",
|
||||||
|
)
|
||||||
|
self.assertEqual(out, set(), "fixed buy must not enable non-PV grid charge")
|
||||||
|
|
||||||
|
def test_fixed_allows_discharge_on_high_sell(self) -> None:
|
||||||
|
slots = [
|
||||||
|
_slot(buy=6.35, sell=1.0, hour_utc=10),
|
||||||
|
_slot(buy=6.35, sell=3.8, hour_utc=18),
|
||||||
|
_slot(buy=6.35, sell=3.2, hour_utc=19),
|
||||||
|
]
|
||||||
|
battery = _battery(uc_wh=12_500.0, discharge_buf=2.0, degrad=0.3)
|
||||||
|
discharge = _select_discharge_export_slots(
|
||||||
|
slots,
|
||||||
|
battery,
|
||||||
|
current_soc_wh=0.5 * battery.usable_capacity_wh,
|
||||||
|
purchase_pricing_mode="fixed",
|
||||||
|
)
|
||||||
|
self.assertIn(1, discharge)
|
||||||
|
self.assertIn(2, discharge)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ declare
|
|||||||
v_night_buf_pct numeric;
|
v_night_buf_pct numeric;
|
||||||
v_degrad_czk_kwh numeric;
|
v_degrad_czk_kwh numeric;
|
||||||
v_ref_buy_czk_kwh numeric;
|
v_ref_buy_czk_kwh numeric;
|
||||||
|
v_purchase_pricing_mode text;
|
||||||
begin
|
begin
|
||||||
drop table if exists _ems_plan_slot_wk;
|
drop table if exists _ems_plan_slot_wk;
|
||||||
create temp table _ems_plan_slot_wk on commit drop as
|
create temp table _ems_plan_slot_wk on commit drop as
|
||||||
@@ -227,6 +228,17 @@ begin
|
|||||||
raise exception 'No asset_battery for site_id=%', p_site_id;
|
raise exception 'No asset_battery for site_id=%', p_site_id;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
|
select coalesce(smc.purchase_pricing_mode, 'spot')
|
||||||
|
into v_purchase_pricing_mode
|
||||||
|
from ems.site_market_config smc
|
||||||
|
where smc.site_id = p_site_id
|
||||||
|
and smc.valid_from <= p_from
|
||||||
|
and (smc.valid_to is null or smc.valid_to > p_from)
|
||||||
|
order by smc.valid_from desc
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
v_purchase_pricing_mode := coalesce(v_purchase_pricing_mode, 'spot');
|
||||||
|
|
||||||
v_per_slot_charge_wh := v_max_charge_w * v_charge_eff * 0.25;
|
v_per_slot_charge_wh := v_max_charge_w * v_charge_eff * 0.25;
|
||||||
v_per_slot_discharge_wh := v_max_discharge_w * v_discharge_eff * 0.25;
|
v_per_slot_discharge_wh := v_max_discharge_w * v_discharge_eff * 0.25;
|
||||||
v_energy_to_fill := v_soc_max_wh - p_current_soc_wh;
|
v_energy_to_fill := v_soc_max_wh - p_current_soc_wh;
|
||||||
@@ -266,13 +278,9 @@ begin
|
|||||||
-- Toto je hlavní mechanismus proti mikro-cyklování z PV:
|
-- Toto je hlavní mechanismus proti mikro-cyklování z PV:
|
||||||
-- v drahých slotech se PV prodává přímo, nabíjení jen v levných.
|
-- v drahých slotech se PV prodává přímo, nabíjení jen v levných.
|
||||||
--
|
--
|
||||||
-- B) Non-PV sloty (pv_surplus_w <= 0): AM/PM budget, OTE-first.
|
-- B) Non-PV sloty (pv_surplus_w <= 0): AM/PM budget, OTE-first (jen spot nákup).
|
||||||
-- Nejlevnější non-PV sloty (dle buy_price) s prioritou OTE cen
|
-- U purchase_pricing_mode = fixed se grid nabíjení neplánuje — buy je
|
||||||
-- před predikovanými (is_predicted_price::int ASC). AM a PM mají
|
-- v každém slotu stejný, cyklus ze sítě by byl čistá ztráta; nabíjení jen z FVE.
|
||||||
-- oddělený rozpočet (50/50), aby solver nekoncentroval veškeré
|
|
||||||
-- nabíjení/vybíjení do jediné půlky dne (double-cycle ochrana).
|
|
||||||
-- OTE-first: levné OTE sloty aktuálního dne nesmí být vytlačeny
|
|
||||||
-- levnějšími predikovanými cenami vzdálených dní (den 3–4 z 96h).
|
|
||||||
if v_charge_buf <= 0 then
|
if v_charge_buf <= 0 then
|
||||||
update _ems_plan_slot_wk wk set allow_charge = true;
|
update _ems_plan_slot_wk wk set allow_charge = true;
|
||||||
elsif v_energy_to_fill <= 0 then
|
elsif v_energy_to_fill <= 0 then
|
||||||
@@ -293,6 +301,7 @@ begin
|
|||||||
v_cum := v_cum + least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25;
|
v_cum := v_cum + least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25;
|
||||||
end loop;
|
end loop;
|
||||||
|
|
||||||
|
if v_purchase_pricing_mode <> 'fixed' then
|
||||||
-- B) Non-PV AM: OTE-first, then predicted, ordered by buy_price
|
-- B) Non-PV AM: OTE-first, then predicted, ordered by buy_price
|
||||||
v_cum := 0;
|
v_cum := 0;
|
||||||
for r_slot in
|
for r_slot in
|
||||||
@@ -323,6 +332,7 @@ begin
|
|||||||
v_cum := v_cum + v_per_slot_charge_wh;
|
v_cum := v_cum + v_per_slot_charge_wh;
|
||||||
end loop;
|
end loop;
|
||||||
end if;
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
-- Referenční nákup pro arbitráž exportu: nejlevnější buy mezi sloty, kde lze nabíjet
|
-- 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ě).
|
-- (ne buy ve stejném slotu — střídač nekupuje a neprodává současně).
|
||||||
@@ -346,7 +356,14 @@ begin
|
|||||||
for r_slot in
|
for r_slot in
|
||||||
select wk.slot_ord
|
select wk.slot_ord
|
||||||
from _ems_plan_slot_wk wk
|
from _ems_plan_slot_wk wk
|
||||||
where wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
where (
|
||||||
|
case
|
||||||
|
when v_purchase_pricing_mode = 'fixed' then
|
||||||
|
wk.sell_price > v_degrad_czk_kwh
|
||||||
|
else
|
||||||
|
wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
||||||
|
end
|
||||||
|
)
|
||||||
order by wk.sell_price desc, wk.slot_ord desc
|
order by wk.sell_price desc, wk.slot_ord desc
|
||||||
loop
|
loop
|
||||||
exit when v_cum >= v_discharge_target_wh;
|
exit when v_cum >= v_discharge_target_wh;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- **Terminal SoC shadow price:** v objective je člen `−(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon).
|
- **Terminal SoC shadow price:** v objective je člen `−(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon).
|
||||||
- **Masky `allow_charge` / `allow_discharge_export` (anti-mikrocyklování):** generuje `ems.fn_load_planning_slots_full`. Dvě nezávislé vrstvy pro nabíjení:
|
- **Masky `allow_charge` / `allow_discharge_export` (anti-mikrocyklování):** generuje `ems.fn_load_planning_slots_full`. Dvě nezávislé vrstvy pro nabíjení:
|
||||||
- **PV-surplus sloty** (`pv_surplus_w > 0`): ranking dle `sell_price ASC`. Nejlevnější PV-surplus sloty se vybírají, dokud kumulativní PV surplus × η_charge nepokryje `energy_to_fill × charge_slot_buffer`. Zbylé PV-surplus sloty mají `allow_charge=false` → PV jde rovnou do sítě. V drahých slotech se PV prodává, v levných nabíjí baterie.
|
- **PV-surplus sloty** (`pv_surplus_w > 0`): ranking dle `sell_price ASC`. Nejlevnější PV-surplus sloty se vybírají, dokud kumulativní PV surplus × η_charge nepokryje `energy_to_fill × charge_slot_buffer`. Zbylé PV-surplus sloty mají `allow_charge=false` → PV jde rovnou do sítě. V drahých slotech se PV prodává, v levných nabíjí baterie.
|
||||||
- **Non-PV sloty** (`pv_surplus_w <= 0`): AM/PM rozpočet 50/50, řazení dle `is_predicted_price::int ASC, buy_price ASC`. OTE ceny mají přednost před predikovanými – levné OTE sloty aktuálního dne nemohou být vytlačeny predikovanými cenami vzdálených dnů. AM/PM split zabraňuje double-cycle (koncentrace nabíjení/vybíjení do jedné půlky dne).
|
- **Non-PV sloty** (`pv_surplus_w <= 0`): AM/PM rozpočet 50/50, řazení dle `is_predicted_price::int ASC, buy_price ASC` — **jen pokud** `site_market_config.purchase_pricing_mode <> 'fixed'`. U **fixního nákupu** (KV1) se vrstva B **nepoužívá**: `buy` je v každém slotu stejný, grid nabíjení by byl čistá ztráta cyklu; nabíjení jen z **PV přebytku** (vrstva A).
|
||||||
- Pokud `energy_to_fill <= 0` (baterie plná) nebo `charge_slot_buffer = 0`: všechny sloty povoleny.
|
- Pokud `energy_to_fill <= 0` (baterie plná) nebo `charge_slot_buffer = 0`: všechny sloty povoleny.
|
||||||
- **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:00–06:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (6–19) a flag `is_daytime_pv_surplus_slot`.\n+\n+ V solveru (`planning_engine.solve_dispatch()`):\n+ - `safety_soc_target_wh` se používá primárně jako **ochrana exportu z baterie**: v běžných slotech (mimo high‑sell špičky) se při aktivním exportu vynutí `soc[t] ≥ max(arb_base_wh, safety_soc_target_wh)`.\n+ - safety deficit penalizace v objective běží jen v `is_daytime_pv_surplus_slot` (a ne v high‑sell špičce), aby solver neměl motivaci dělat obecné „nabij co nejdřív“ chování.\n+ Tvrdé `allow_charge` se kvůli tomu nemění.
|
- **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:00–06:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (6–19) a flag `is_daytime_pv_surplus_slot`.\n+\n+ V solveru (`planning_engine.solve_dispatch()`):\n+ - `safety_soc_target_wh` se používá primárně jako **ochrana exportu z baterie**: v běžných slotech (mimo high‑sell špičky) se při aktivním exportu vynutí `soc[t] ≥ max(arb_base_wh, safety_soc_target_wh)`.\n+ - safety deficit penalizace v objective běží jen v `is_daytime_pv_surplus_slot` (a ne v high‑sell špičce), aby solver neměl motivaci dělat obecné „nabij co nejdřív“ chování.\n+ Tvrdé `allow_charge` se kvůli tomu nemění.
|
||||||
- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0` a současně **není výrazný export** (`grid_setpoint_w ≥ −500`). To je záměr: commitment má kotvit „nabíjení z PV přebytku“, ne „charge while exporting“. Měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu je řízená `planner_charge_commitment_penalty_czk_kwh` na `asset_battery`. Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`.
|
- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0` a současně **není výrazný export** (`grid_setpoint_w ≥ −500`). To je záměr: commitment má kotvit „nabíjení z PV přebytku“, ne „charge while exporting“. Měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu je řízená `planner_charge_commitment_penalty_czk_kwh` na `asset_battery`. Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`.
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
- měkký cíl na konci 24h přes `_soc_security_profile` + tvrdé dvouúrovňové pravidlo výše.
|
- 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):**
|
- **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í.
|
- `_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). 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) je export rozdělen: **`ge_pv`** (kanál FVE) a **`ge_bat`** (baterie do sítě, jen v `allow_discharge_export`, vázáno na `z_export` a SoC podlahu); platí `ge = ge_pv + ge_bat` a `ge_bat ≥ ge − (pv_a + pv_b)` — baterie nesmí „přestrojit“ FVE. Mimo exportní sloty: **`ge_bat = 0`**, **`bd`** smí pokrýt vlastní spotřebu; **`bc`** smí nabíjet jen z **PV přebytku** i bez grid-charge masky (plná baterie + přebytek pole B jinak nejde do sítě). **`deye_physical_mode`** = PASSIVE kromě CHARGE/SELL.
|
- **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). **Spot nákup:** `sell_price > ref_buy + degradation_cost_czk_kwh` (`ref_buy` = min `buy` mezi `allow_charge`, arbitráž mezi sloty). **Fixní nákup** (`purchase_pricing_mode = fixed`): `sell_price > degradation_cost_czk_kwh` (prodej na spotu, bez porovnání s fixním 6,35 Kč). V `solve_dispatch` (AUTO) je export rozdělen: **`ge_pv`** (kanál FVE) a **`ge_bat`** (baterie do sítě, jen v `allow_discharge_export`, vázáno na `z_export` a SoC podlahu); platí `ge = ge_pv + ge_bat` a `ge_bat ≥ ge − (pv_a + pv_b)` — baterie nesmí „přestrojit“ FVE. Mimo exportní sloty: **`ge_bat = 0`**, **`bd`** smí pokrýt vlastní spotřebu; **`bc`** smí nabíjet jen z **PV přebytku** i bez grid-charge masky (plná baterie + přebytek pole B jinak nejde do sítě). **`deye_physical_mode`** = PASSIVE kromě CHARGE/SELL.
|
||||||
- **Záporná nákupní cena:**
|
- **Záporná nákupní cena:**
|
||||||
- horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu).
|
- 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í —
|
- **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