dalsi rozvolneni at vic jedeme arbitraz
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:54:46 +02:00
parent 3b4d54dcc7
commit 66834ddfa6
3 changed files with 51 additions and 17 deletions

View File

@@ -14,7 +14,7 @@ Discharge-export mask:
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from datetime import datetime, timezone, timedelta from datetime import date, datetime, timezone, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -27,6 +27,24 @@ _BUY_CHARGE_BAND = 0.40
_MAX_GRID_CHARGE_CAP = 24 _MAX_GRID_CHARGE_CAP = 24
def _prague_date(s: PlanningSlot) -> date:
return s.interval_start.astimezone(_PRAGUE).date()
def _export_window_start(slots: list[PlanningSlot], degrad: float) -> datetime | None:
"""Kopie R__063: sell > min(buy) téhož kalendářního dne (Prague) + degrad."""
result: datetime | None = None
for s in slots:
day = _prague_date(s)
day_min = min(
float(x.buy_price) for x in slots if _prague_date(x) == day
)
if float(s.sell_price) > day_min + degrad:
if result is None or s.interval_start < result:
result = s.interval_start
return result
def _future_sell(slots: list[PlanningSlot], t: int) -> float: def _future_sell(slots: list[PlanningSlot], t: int) -> float:
tail = [float(slots[i].sell_price) for i in range(t + 1, len(slots))] tail = [float(slots[i].sell_price) for i in range(t + 1, len(slots))]
return max(tail) if tail else float(slots[t].sell_price) return max(tail) if tail else float(slots[t].sell_price)
@@ -82,11 +100,8 @@ def _select_charge_slots(
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) ref_buy_global = min(float(s.buy_price) for s in slots)
export_window_start: datetime | None = None export_window_start = _export_window_start(slots, degrad)
for s in slots: plan_day = _prague_date(slots[0])
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)
@@ -135,13 +150,15 @@ def _select_charge_slots(
return False return False
return True return True
def _grid_sort_key(t: int, pred: bool, price: float) -> tuple[int, int, float, int]: def _grid_sort_key(t: int, pred: bool, price: float) -> tuple[int, int, int, float, int]:
before_export = 0 today_first = 0 if _prague_date(slots[t]) == plan_day else 1
if export_window_start is not None and slots[t].interval_start < export_window_start: before_export = (
before_export = 0 0
else: if export_window_start is not None
before_export = 1 and slots[t].interval_start < export_window_start
return (before_export, int(pred), price, t) else 1
)
return (today_first, before_export, int(pred), price, t)
if purchase_pricing_mode != "fixed": if purchase_pricing_mode != "fixed":
am_candidates = [ am_candidates = [

View File

@@ -83,7 +83,9 @@ declare
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; v_export_window_start timestamptz;
v_plan_day_prague date;
begin begin
v_plan_day_prague := (p_from at time zone 'Europe/Prague')::date;
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
with with
@@ -289,12 +291,17 @@ 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íje -- První výkupní okno: sell nad min(buy) **téhož kalendářního dne** (Prague), ne globál
-- před tím má prioritu (dnes PM levně → dnes večer prodáš), ne nejlevnější slot zítra. -- min(buy) zítra (NT) — jinak okno začne už ~15:30 a dnešní PM grid dostane 1 slot.
select min(wk.interval_start) select min(wk.interval_start)
into v_export_window_start into v_export_window_start
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 wk.sell_price > (
select coalesce(min(w2.buy_price), wk.buy_price) + v_degrad_czk_kwh
from _ems_plan_slot_wk w2
where (w2.interval_start at time zone 'Europe/Prague')::date
= (wk.interval_start at time zone 'Europe/Prague')::date
);
-- 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
@@ -399,6 +406,11 @@ begin
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 order by
case
when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague
then 0
else 1
end,
case case
when v_export_window_start is not null when v_export_window_start is not null
and wk.interval_start < v_export_window_start and wk.interval_start < v_export_window_start
@@ -433,6 +445,11 @@ begin
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 order by
case
when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague
then 0
else 1
end,
case case
when v_export_window_start is not null when v_export_window_start is not null
and wk.interval_start < v_export_window_start and wk.interval_start < v_export_window_start

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`. 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). - **Grid ze sítě (vrstva B, před FVE):** spot, **AM/PM rozpočet Wh 50/50** z `grid_target`. Výběr: nejdřív **kalendářní den plánu** (`p_from` Prague), pak sloty před **výkupním oknem daného dne** (`sell > min(buy téhož dne)+degrad` — ne globální min zítra). Lookahead VT→NT jen 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.