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

This commit is contained in:
Dusan Vojacek
2026-05-21 14:10:22 +02:00
parent ba1cdcbee4
commit 739249a244
3 changed files with 116 additions and 16 deletions

View File

@@ -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)

View File

@@ -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 (

View File

@@ -11,7 +11,7 @@
- **Terminal SoC shadow price:** v objective je člen `(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T1]` (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[T1]` (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, buysell)`; 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, buysell)`; 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.