dalsi ladenik
This commit is contained in:
@@ -32,10 +32,17 @@ def _future_sell(slots: list[PlanningSlot], t: int) -> float:
|
|||||||
return max(tail) if tail else float(slots[t].sell_price)
|
return max(tail) if tail else float(slots[t].sell_price)
|
||||||
|
|
||||||
|
|
||||||
def _buy_min_next_n(slots: list[PlanningSlot], t: int, n: int = _LOOKAHEAD_SLOTS) -> float | None:
|
def _buy_min_next_n(
|
||||||
|
slots: list[PlanningSlot],
|
||||||
|
t: int,
|
||||||
|
n: int = _LOOKAHEAD_SLOTS,
|
||||||
|
*,
|
||||||
|
export_window_start: datetime | None = None,
|
||||||
|
) -> float | None:
|
||||||
tail = [
|
tail = [
|
||||||
float(slots[i].buy_price)
|
float(slots[i].buy_price)
|
||||||
for i in range(t + 1, min(t + 1 + n, len(slots)))
|
for i in range(t + 1, min(t + 1 + n, len(slots)))
|
||||||
|
if export_window_start is None or slots[i].interval_start < export_window_start
|
||||||
]
|
]
|
||||||
return min(tail) if tail else None
|
return min(tail) if tail else None
|
||||||
|
|
||||||
@@ -74,6 +81,12 @@ def _select_charge_slots(
|
|||||||
(float(s.buy_price) for s in slots if _prague_hour(s) >= 12),
|
(float(s.buy_price) for s in slots if _prague_hour(s) >= 12),
|
||||||
default=min(float(s.buy_price) for s in slots),
|
default=min(float(s.buy_price) for s in slots),
|
||||||
)
|
)
|
||||||
|
ref_buy_global = min(float(s.buy_price) for s in slots)
|
||||||
|
export_window_start: datetime | None = None
|
||||||
|
for s in slots:
|
||||||
|
if float(s.sell_price) > ref_buy_global + degrad:
|
||||||
|
if export_window_start is None or s.interval_start < export_window_start:
|
||||||
|
export_window_start = s.interval_start
|
||||||
|
|
||||||
eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0)
|
eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0)
|
||||||
max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0)
|
max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0)
|
||||||
@@ -117,18 +130,26 @@ def _select_charge_slots(
|
|||||||
buy = float(s.buy_price)
|
buy = float(s.buy_price)
|
||||||
if buy > ref_buy_seg + _BUY_CHARGE_BAND:
|
if buy > ref_buy_seg + _BUY_CHARGE_BAND:
|
||||||
return False
|
return False
|
||||||
nxt = _buy_min_next_n(slots, t)
|
nxt = _buy_min_next_n(slots, t, export_window_start=export_window_start)
|
||||||
if nxt is not None and buy > nxt + _BUY_LOOKAHEAD_EPS:
|
if nxt is not None and buy > nxt + _BUY_LOOKAHEAD_EPS:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _grid_sort_key(t: int, pred: bool, price: float) -> tuple[int, int, float, int]:
|
||||||
|
before_export = 0
|
||||||
|
if export_window_start is not None and slots[t].interval_start < export_window_start:
|
||||||
|
before_export = 0
|
||||||
|
else:
|
||||||
|
before_export = 1
|
||||||
|
return (before_export, int(pred), price, t)
|
||||||
|
|
||||||
if purchase_pricing_mode != "fixed":
|
if purchase_pricing_mode != "fixed":
|
||||||
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))
|
||||||
for t in range(len(slots))
|
for t in range(len(slots))
|
||||||
if _grid_b_ok(t, ref_buy_am) and _prague_hour(slots[t]) < 12
|
if _grid_b_ok(t, ref_buy_am) and _prague_hour(slots[t]) < 12
|
||||||
]
|
]
|
||||||
am_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0]))
|
am_candidates.sort(key=lambda x: _grid_sort_key(x[0], x[1], x[2]))
|
||||||
cum = 0.0
|
cum = 0.0
|
||||||
grid_am = 0
|
grid_am = 0
|
||||||
for t, _pred, _price in am_candidates:
|
for t, _pred, _price in am_candidates:
|
||||||
@@ -144,7 +165,7 @@ def _select_charge_slots(
|
|||||||
for t in range(len(slots))
|
for t in range(len(slots))
|
||||||
if _grid_b_ok(t, ref_buy_pm) and _prague_hour(slots[t]) >= 12
|
if _grid_b_ok(t, ref_buy_pm) and _prague_hour(slots[t]) >= 12
|
||||||
]
|
]
|
||||||
pm_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0]))
|
pm_candidates.sort(key=lambda x: _grid_sort_key(x[0], x[1], x[2]))
|
||||||
cum = 0.0
|
cum = 0.0
|
||||||
grid_pm = 0
|
grid_pm = 0
|
||||||
for t, _pred, _price in pm_candidates:
|
for t, _pred, _price in pm_candidates:
|
||||||
@@ -326,6 +347,37 @@ class SelectChargeSlotsTests(unittest.TestCase):
|
|||||||
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
|
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
|
||||||
self.assertIn(2, out, "Nejlevnější buy v horizontu (PM) musí být vybrán")
|
self.assertIn(2, out, "Nejlevnější buy v horizontu (PM) musí být vybrán")
|
||||||
|
|
||||||
|
def test_pm_grid_prefers_today_before_export_over_tomorrow_cheaper(self) -> None:
|
||||||
|
"""Dnes PM levné před večerním exportem má prioritu před zítřejším min(buy)."""
|
||||||
|
base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
|
||||||
|
slots = [
|
||||||
|
_slot(buy=0.72, sell=-0.1, pv=500, load=3000, interval_start=base),
|
||||||
|
_slot(buy=0.68, sell=-0.15, pv=500, load=3000, interval_start=base + timedelta(minutes=15)),
|
||||||
|
_slot(
|
||||||
|
buy=5.5,
|
||||||
|
sell=3.8,
|
||||||
|
pv=0,
|
||||||
|
load=2500,
|
||||||
|
interval_start=base + timedelta(hours=7, minutes=30),
|
||||||
|
),
|
||||||
|
_slot(
|
||||||
|
buy=0.50,
|
||||||
|
sell=-0.3,
|
||||||
|
pv=2000,
|
||||||
|
load=5000,
|
||||||
|
interval_start=base + timedelta(hours=26),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
battery = _battery(uc_wh=64_000.0)
|
||||||
|
out = _select_charge_slots(slots, battery, current_soc_wh=0.46 * battery.usable_capacity_wh)
|
||||||
|
self.assertIn(0, out)
|
||||||
|
self.assertIn(1, out)
|
||||||
|
self.assertEqual(
|
||||||
|
min(out),
|
||||||
|
0,
|
||||||
|
"Před exportním oknem musí být vybrány dnešní levné PM sloty dřív než zítřejší min(buy)",
|
||||||
|
)
|
||||||
|
|
||||||
def test_cheap_pm_with_pv_surplus_gets_grid_charge(self) -> None:
|
def test_cheap_pm_with_pv_surplus_gets_grid_charge(self) -> None:
|
||||||
"""Regrese home-01: levné PM VT (~0,8) i s FVE musí projít grid maskou B."""
|
"""Regrese home-01: levné PM VT (~0,8) i s FVE musí projít grid maskou B."""
|
||||||
base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
|
base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ declare
|
|||||||
v_est_pv_wh numeric;
|
v_est_pv_wh numeric;
|
||||||
v_est_grid_cost numeric;
|
v_est_grid_cost numeric;
|
||||||
v_est_pv_cost numeric;
|
v_est_pv_cost numeric;
|
||||||
|
v_export_window_start timestamptz;
|
||||||
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
|
||||||
@@ -288,11 +289,19 @@ begin
|
|||||||
from _ems_plan_slot_wk wk
|
from _ems_plan_slot_wk wk
|
||||||
where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12;
|
where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12;
|
||||||
|
|
||||||
|
-- První „výkupní“ okno v horizontu (stejná logika jako discharge maska) — grid nabíjení
|
||||||
|
-- před tím má prioritu (dnes PM levně → dnes večer prodáš), ne nejlevnější slot zítra.
|
||||||
|
select min(wk.interval_start)
|
||||||
|
into v_export_window_start
|
||||||
|
from _ems_plan_slot_wk wk
|
||||||
|
where wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh;
|
||||||
|
|
||||||
-- Lookahead min buy (VT→NT) a store_score pro vrstvu A.
|
-- Lookahead min buy (VT→NT) a store_score pro vrstvu A.
|
||||||
alter table _ems_plan_slot_wk
|
alter table _ems_plan_slot_wk
|
||||||
add column if not exists future_sell_lookahead numeric,
|
add column if not exists future_sell_lookahead numeric,
|
||||||
add column if not exists buy_min_next_n numeric,
|
add column if not exists buy_min_next_n numeric,
|
||||||
add column if not exists store_score numeric;
|
add column if not exists store_score numeric,
|
||||||
|
add column if not exists allow_grid_charge boolean default false;
|
||||||
|
|
||||||
update _ems_plan_slot_wk wk
|
update _ems_plan_slot_wk wk
|
||||||
set
|
set
|
||||||
@@ -309,6 +318,10 @@ begin
|
|||||||
from _ems_plan_slot_wk w2
|
from _ems_plan_slot_wk w2
|
||||||
where w2.slot_ord > wk.slot_ord
|
where w2.slot_ord > wk.slot_ord
|
||||||
and w2.slot_ord <= wk.slot_ord + v_lookahead_slots
|
and w2.slot_ord <= wk.slot_ord + v_lookahead_slots
|
||||||
|
and (
|
||||||
|
v_export_window_start is null
|
||||||
|
or w2.interval_start < v_export_window_start
|
||||||
|
)
|
||||||
),
|
),
|
||||||
store_score =
|
store_score =
|
||||||
coalesce(
|
coalesce(
|
||||||
@@ -385,12 +398,23 @@ begin
|
|||||||
wk.buy_min_next_n is null
|
wk.buy_min_next_n is null
|
||||||
or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps
|
or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps
|
||||||
)
|
)
|
||||||
order by wk.is_predicted_price::int, wk.buy_price, wk.slot_ord
|
order by
|
||||||
|
case
|
||||||
|
when v_export_window_start is not null
|
||||||
|
and wk.interval_start < v_export_window_start
|
||||||
|
then 0
|
||||||
|
else 1
|
||||||
|
end,
|
||||||
|
wk.is_predicted_price::int,
|
||||||
|
wk.buy_price,
|
||||||
|
wk.slot_ord
|
||||||
loop
|
loop
|
||||||
exit when v_cum >= v_chg_am_wh;
|
exit when v_cum >= v_chg_am_wh;
|
||||||
exit when v_per_slot_charge_wh <= 0;
|
exit when v_per_slot_charge_wh <= 0;
|
||||||
exit when v_grid_slots_am >= v_grid_charge_cap_am;
|
exit when v_grid_slots_am >= v_grid_charge_cap_am;
|
||||||
update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord;
|
update _ems_plan_slot_wk wk
|
||||||
|
set allow_charge = true, allow_grid_charge = true
|
||||||
|
where wk.slot_ord = r_slot.slot_ord;
|
||||||
v_cum := v_cum + v_per_slot_charge_wh;
|
v_cum := v_cum + v_per_slot_charge_wh;
|
||||||
v_grid_slots_am := v_grid_slots_am + 1;
|
v_grid_slots_am := v_grid_slots_am + 1;
|
||||||
end loop;
|
end loop;
|
||||||
@@ -408,12 +432,23 @@ begin
|
|||||||
wk.buy_min_next_n is null
|
wk.buy_min_next_n is null
|
||||||
or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps
|
or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps
|
||||||
)
|
)
|
||||||
order by wk.is_predicted_price::int, wk.buy_price, wk.slot_ord
|
order by
|
||||||
|
case
|
||||||
|
when v_export_window_start is not null
|
||||||
|
and wk.interval_start < v_export_window_start
|
||||||
|
then 0
|
||||||
|
else 1
|
||||||
|
end,
|
||||||
|
wk.is_predicted_price::int,
|
||||||
|
wk.buy_price,
|
||||||
|
wk.slot_ord
|
||||||
loop
|
loop
|
||||||
exit when v_cum >= v_chg_pm_wh;
|
exit when v_cum >= v_chg_pm_wh;
|
||||||
exit when v_per_slot_charge_wh <= 0;
|
exit when v_per_slot_charge_wh <= 0;
|
||||||
exit when v_grid_slots_pm >= v_grid_charge_cap_pm;
|
exit when v_grid_slots_pm >= v_grid_charge_cap_pm;
|
||||||
update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord;
|
update _ems_plan_slot_wk wk
|
||||||
|
set allow_charge = true, allow_grid_charge = true
|
||||||
|
where wk.slot_ord = r_slot.slot_ord;
|
||||||
v_cum := v_cum + v_per_slot_charge_wh;
|
v_cum := v_cum + v_per_slot_charge_wh;
|
||||||
v_grid_slots_pm := v_grid_slots_pm + 1;
|
v_grid_slots_pm := v_grid_slots_pm + 1;
|
||||||
end loop;
|
end loop;
|
||||||
@@ -471,11 +506,11 @@ begin
|
|||||||
from _ems_plan_slot_wk wk
|
from _ems_plan_slot_wk wk
|
||||||
where wk.allow_discharge_export;
|
where wk.allow_discharge_export;
|
||||||
|
|
||||||
-- Acquisition: vážený buy v allow_charge slotech před 1. exportem (ne future_sell z FVE).
|
-- Acquisition: jen grid vrstva B (ne odpolední FVE z vrstvy A) před 1. exportem.
|
||||||
select
|
select
|
||||||
coalesce(sum(
|
coalesce(sum(
|
||||||
case
|
case
|
||||||
when wk.allow_charge
|
when wk.allow_grid_charge
|
||||||
and (
|
and (
|
||||||
v_acquisition_cutoff is null
|
v_acquisition_cutoff is null
|
||||||
or wk.interval_start < v_acquisition_cutoff
|
or wk.interval_start < v_acquisition_cutoff
|
||||||
@@ -486,7 +521,7 @@ begin
|
|||||||
), 0),
|
), 0),
|
||||||
coalesce(sum(
|
coalesce(sum(
|
||||||
case
|
case
|
||||||
when wk.allow_charge
|
when wk.allow_grid_charge
|
||||||
and (
|
and (
|
||||||
v_acquisition_cutoff is null
|
v_acquisition_cutoff is null
|
||||||
or wk.interval_start < v_acquisition_cutoff
|
or wk.interval_start < v_acquisition_cutoff
|
||||||
@@ -494,10 +529,22 @@ begin
|
|||||||
then wk.buy_price * v_per_slot_charge_wh
|
then wk.buy_price * v_per_slot_charge_wh
|
||||||
else 0
|
else 0
|
||||||
end
|
end
|
||||||
), 0)
|
), 0),
|
||||||
|
min(
|
||||||
|
case
|
||||||
|
when wk.allow_grid_charge
|
||||||
|
and (
|
||||||
|
v_acquisition_cutoff is null
|
||||||
|
or wk.interval_start < v_acquisition_cutoff
|
||||||
|
)
|
||||||
|
then wk.buy_price
|
||||||
|
else null
|
||||||
|
end
|
||||||
|
)
|
||||||
into
|
into
|
||||||
v_est_grid_wh,
|
v_est_grid_wh,
|
||||||
v_est_grid_cost
|
v_est_grid_cost,
|
||||||
|
v_charge_acquisition
|
||||||
from _ems_plan_slot_wk wk;
|
from _ems_plan_slot_wk wk;
|
||||||
|
|
||||||
v_est_pv_wh := 0;
|
v_est_pv_wh := 0;
|
||||||
@@ -505,12 +552,13 @@ begin
|
|||||||
|
|
||||||
if v_est_grid_wh > 0 then
|
if v_est_grid_wh > 0 then
|
||||||
v_charge_acquisition := v_est_grid_cost / v_est_grid_wh;
|
v_charge_acquisition := v_est_grid_cost / v_est_grid_wh;
|
||||||
else
|
elsif v_charge_acquisition is null then
|
||||||
v_charge_acquisition := coalesce(
|
v_charge_acquisition := coalesce(
|
||||||
(v_ref_buy_am_czk_kwh + v_ref_buy_pm_czk_kwh) / 2.0,
|
(v_ref_buy_am_czk_kwh + v_ref_buy_pm_czk_kwh) / 2.0,
|
||||||
v_ref_buy_czk_kwh
|
v_ref_buy_czk_kwh
|
||||||
);
|
);
|
||||||
end if;
|
end if;
|
||||||
|
-- v_charge_acquisition z min(grid) zůstane, pokud je jen jeden grid slot před exportem
|
||||||
|
|
||||||
return query
|
return query
|
||||||
with night_tot as (
|
with night_tot as (
|
||||||
|
|||||||
@@ -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` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie.
|
- **Masky `allow_charge` / `allow_discharge_export` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie.
|
||||||
- **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity − sell − max(0, buy−sell)`; jen sloty s `sell ≥ buy − degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP).
|
- **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity − sell − max(0, buy−sell)`; jen sloty s `sell ≥ buy − degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP).
|
||||||
- **Grid ze sítě (vrstva B, před FVE):** spot, **AM/PM rozpočet Wh 50/50** z `grid_target` (polovina deficitu na dopoledne, polovina na odpoledne — cyklus „dnes PM nabít / večer prodat / zítra znovu“). `buy ≤ min(buy v pásmu AM nebo PM) + 0,40 Kč`, lookahead 4 sloty, **i při FVE surplus**; počet slotů `ceil(rozpočet_Wh / per_slot_charge_wh)` (max 24). **FVE (vrstva A):** doplní zbytek po B dle `store_score`. **KV1/fixed:** vrstva B vypnutá. **`charge_acquisition`:** vážený `buy` v `allow_charge` před 1. exportem (ne `future_sell`).
|
- **Grid ze sítě (vrstva B, před FVE):** spot, **AM/PM rozpočet Wh 50/50** z `grid_target`. Výběr slotů: nejdřív sloty **před prvním výkupním oknem** (`sell > min(buy)+degrad`), pak teprve zbytek horizontu — aby dnes PM nevyhrál zítra `min(buy)`. Lookahead VT→NT jen mezi sloty před tímto oknem. **`charge_acquisition`:** vážený `buy` jen u `allow_grid_charge` (ne FVE vrstva A).
|
||||||
- **`ref_buy_min` (brána exportu):** `min(buy_price)` horizontu — jen „existuje levný nákup?“, **ne** průměrná cena nabití přes hodiny. Export sloty: `sell > ref_buy_min + degradation` (spot). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
|
- **`ref_buy_min` (brána exportu):** `min(buy_price)` horizontu — jen „existuje levný nákup?“, **ne** průměrná cena nabití přes hodiny. Export sloty: `sell > ref_buy_min + degradation` (spot). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
|
||||||
- Pokud `energy_to_fill <= 0` nebo `charge_slot_buffer = 0`: všechny sloty povoleny.
|
- Pokud `energy_to_fill <= 0` nebo `charge_slot_buffer = 0`: všechny sloty povoleny.
|
||||||
- **LP ekonomické guardy** (`solve_dispatch`, AUTO): pokud `sell < buy − degradation` → `ge_pv=0` (výjimka: plná baterie, přebytek **pv_b**). Pokud `buy > min(buy)+degradation` → `gi` jen na load+EV+TČ. Viz `planning_engine.py` sekce po slot pre-selection.
|
- **LP ekonomické guardy** (`solve_dispatch`, AUTO): pokud `sell < buy − degradation` → `ge_pv=0` (výjimka: plná baterie, přebytek **pv_b**). Pokud `buy > min(buy)+degradation` → `gi` jen na load+EV+TČ. Viz `planning_engine.py` sekce po slot pre-selection.
|
||||||
|
|||||||
Reference in New Issue
Block a user