dalsi fixy
This commit is contained in:
@@ -3,8 +3,8 @@
|
|||||||
Logika je v DB: ems.fn_load_planning_slots_full. Kopie algoritmu pro unit testy bez PG.
|
Logika je v DB: ems.fn_load_planning_slots_full. Kopie algoritmu pro unit testy bez PG.
|
||||||
|
|
||||||
Charge mask:
|
Charge mask:
|
||||||
A) PV-surplus: store_score DESC, dokud PV nepokryje charge target.
|
B) Grid ze sítě první: AM/PM 50/50 Wh, buy≤min(buy v pásmu)+band, i s FVE.
|
||||||
B) Non-PV: AM/PM, OTE-first, buy≤ref+degrad, lookahead, cap 6 slotů.
|
A) PV-surplus: store_score DESC, doplní zbytek po vrstvě B.
|
||||||
|
|
||||||
Discharge-export mask:
|
Discharge-export mask:
|
||||||
ref_buy = min(buy) celého horizontu.
|
ref_buy = min(buy) celého horizontu.
|
||||||
@@ -22,9 +22,9 @@ from services.planning_engine import INTERVAL_H, PlanningSlot
|
|||||||
|
|
||||||
_PRAGUE = ZoneInfo("Europe/Prague")
|
_PRAGUE = ZoneInfo("Europe/Prague")
|
||||||
_LOOKAHEAD_SLOTS = 4
|
_LOOKAHEAD_SLOTS = 4
|
||||||
_GRID_CHARGE_CAP_AM = 6
|
|
||||||
_GRID_CHARGE_CAP_PM = 6
|
|
||||||
_BUY_LOOKAHEAD_EPS = 0.05
|
_BUY_LOOKAHEAD_EPS = 0.05
|
||||||
|
_BUY_CHARGE_BAND = 0.40
|
||||||
|
_MAX_GRID_CHARGE_CAP = 24
|
||||||
|
|
||||||
|
|
||||||
def _future_sell(slots: list[PlanningSlot], t: int) -> float:
|
def _future_sell(slots: list[PlanningSlot], t: int) -> float:
|
||||||
@@ -66,7 +66,14 @@ def _select_charge_slots(
|
|||||||
|
|
||||||
reserve_wh = float(getattr(battery, "reserve_soc_wh", 0) or 0)
|
reserve_wh = float(getattr(battery, "reserve_soc_wh", 0) or 0)
|
||||||
degrad = float(getattr(battery, "degradation_cost_czk_kwh", 0.15) or 0.15)
|
degrad = float(getattr(battery, "degradation_cost_czk_kwh", 0.15) or 0.15)
|
||||||
ref_buy = min(float(s.buy_price) for s in slots)
|
ref_buy_am = min(
|
||||||
|
(float(s.buy_price) for s in slots if _prague_hour(s) < 12),
|
||||||
|
default=min(float(s.buy_price) for s in slots),
|
||||||
|
)
|
||||||
|
ref_buy_pm = min(
|
||||||
|
(float(s.buy_price) for s in slots if _prague_hour(s) >= 12),
|
||||||
|
default=min(float(s.buy_price) for s in slots),
|
||||||
|
)
|
||||||
|
|
||||||
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)
|
||||||
@@ -92,8 +99,63 @@ def _select_charge_slots(
|
|||||||
chg_pm = charge_target_wh - chg_am
|
chg_pm = charge_target_wh - chg_am
|
||||||
|
|
||||||
selected: set[int] = set()
|
selected: set[int] = set()
|
||||||
|
grid_filled_wh = 0.0
|
||||||
|
|
||||||
# A) PV-surplus: highest store_score first
|
cap_am = (
|
||||||
|
max(1, min(_MAX_GRID_CHARGE_CAP, int(chg_am / per_slot_full_wh) + 1))
|
||||||
|
if per_slot_full_wh > 0
|
||||||
|
else 6
|
||||||
|
)
|
||||||
|
cap_pm = (
|
||||||
|
max(1, min(_MAX_GRID_CHARGE_CAP, int(chg_pm / per_slot_full_wh) + 1))
|
||||||
|
if per_slot_full_wh > 0
|
||||||
|
else 6
|
||||||
|
)
|
||||||
|
|
||||||
|
def _grid_b_ok(t: int, ref_buy_seg: float) -> bool:
|
||||||
|
s = slots[t]
|
||||||
|
buy = float(s.buy_price)
|
||||||
|
if buy > ref_buy_seg + _BUY_CHARGE_BAND:
|
||||||
|
return False
|
||||||
|
nxt = _buy_min_next_n(slots, t)
|
||||||
|
if nxt is not None and buy > nxt + _BUY_LOOKAHEAD_EPS:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
if purchase_pricing_mode != "fixed":
|
||||||
|
am_candidates = [
|
||||||
|
(t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price))
|
||||||
|
for t in range(len(slots))
|
||||||
|
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]))
|
||||||
|
cum = 0.0
|
||||||
|
grid_am = 0
|
||||||
|
for t, _pred, _price in am_candidates:
|
||||||
|
if cum >= chg_am or per_slot_full_wh <= 0 or grid_am >= cap_am:
|
||||||
|
break
|
||||||
|
selected.add(t)
|
||||||
|
cum += per_slot_full_wh
|
||||||
|
grid_am += 1
|
||||||
|
grid_filled_wh += cum
|
||||||
|
|
||||||
|
pm_candidates = [
|
||||||
|
(t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price))
|
||||||
|
for t in range(len(slots))
|
||||||
|
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]))
|
||||||
|
cum = 0.0
|
||||||
|
grid_pm = 0
|
||||||
|
for t, _pred, _price in pm_candidates:
|
||||||
|
if cum >= chg_pm or per_slot_full_wh <= 0 or grid_pm >= cap_pm:
|
||||||
|
break
|
||||||
|
selected.add(t)
|
||||||
|
cum += per_slot_full_wh
|
||||||
|
grid_pm += 1
|
||||||
|
grid_filled_wh += cum
|
||||||
|
|
||||||
|
pv_layer_cap = max(charge_target_wh - grid_filled_wh, 0.0)
|
||||||
pv_candidates: list[tuple[int, float, float]] = []
|
pv_candidates: list[tuple[int, float, float]] = []
|
||||||
for t, s in enumerate(slots):
|
for t, s in enumerate(slots):
|
||||||
pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w)
|
pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w)
|
||||||
@@ -103,58 +165,11 @@ def _select_charge_slots(
|
|||||||
pv_candidates.sort(key=lambda x: (-x[1], x[0]))
|
pv_candidates.sort(key=lambda x: (-x[1], x[0]))
|
||||||
cum = 0.0
|
cum = 0.0
|
||||||
for t, _score, pv_surplus_w in pv_candidates:
|
for t, _score, pv_surplus_w in pv_candidates:
|
||||||
if cum >= charge_target_wh:
|
if cum >= pv_layer_cap:
|
||||||
break
|
break
|
||||||
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
|
||||||
|
|
||||||
if purchase_pricing_mode == "fixed":
|
|
||||||
return selected
|
|
||||||
|
|
||||||
def _grid_b_ok(t: int) -> bool:
|
|
||||||
s = slots[t]
|
|
||||||
if max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w) > 0:
|
|
||||||
return False
|
|
||||||
buy = float(s.buy_price)
|
|
||||||
sell = float(s.sell_price)
|
|
||||||
if buy > ref_buy + degrad:
|
|
||||||
return False
|
|
||||||
nxt = _buy_min_next_n(slots, t)
|
|
||||||
if nxt is not None and buy > nxt + _BUY_LOOKAHEAD_EPS:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
# B) AM
|
|
||||||
am_candidates = [
|
|
||||||
(t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price))
|
|
||||||
for t in range(len(slots))
|
|
||||||
if t not in selected and _grid_b_ok(t) and _prague_hour(slots[t]) < 12
|
|
||||||
]
|
|
||||||
am_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0]))
|
|
||||||
cum = 0.0
|
|
||||||
grid_am = 0
|
|
||||||
for t, _pred, _price in am_candidates:
|
|
||||||
if cum >= chg_am or per_slot_full_wh <= 0 or grid_am >= _GRID_CHARGE_CAP_AM:
|
|
||||||
break
|
|
||||||
selected.add(t)
|
|
||||||
cum += per_slot_full_wh
|
|
||||||
grid_am += 1
|
|
||||||
|
|
||||||
pm_candidates = [
|
|
||||||
(t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price))
|
|
||||||
for t in range(len(slots))
|
|
||||||
if t not in selected and _grid_b_ok(t) and _prague_hour(slots[t]) >= 12
|
|
||||||
]
|
|
||||||
pm_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0]))
|
|
||||||
cum = 0.0
|
|
||||||
grid_pm = 0
|
|
||||||
for t, _pred, _price in pm_candidates:
|
|
||||||
if cum >= chg_pm or per_slot_full_wh <= 0 or grid_pm >= _GRID_CHARGE_CAP_PM:
|
|
||||||
break
|
|
||||||
selected.add(t)
|
|
||||||
cum += per_slot_full_wh
|
|
||||||
grid_pm += 1
|
|
||||||
|
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
|
|
||||||
@@ -311,6 +326,49 @@ 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_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."""
|
||||||
|
base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
|
||||||
|
slots = [
|
||||||
|
_slot(
|
||||||
|
buy=0.80,
|
||||||
|
sell=-0.08,
|
||||||
|
pv=2_500,
|
||||||
|
load=3_400,
|
||||||
|
interval_start=base,
|
||||||
|
),
|
||||||
|
_slot(
|
||||||
|
buy=0.72,
|
||||||
|
sell=-0.13,
|
||||||
|
pv=500,
|
||||||
|
load=3_400,
|
||||||
|
interval_start=base + timedelta(minutes=15),
|
||||||
|
),
|
||||||
|
_slot(
|
||||||
|
buy=2.50,
|
||||||
|
sell=1.40,
|
||||||
|
pv=2_000,
|
||||||
|
load=3_800,
|
||||||
|
interval_start=base + timedelta(hours=5),
|
||||||
|
),
|
||||||
|
_slot(
|
||||||
|
buy=5.50,
|
||||||
|
sell=3.80,
|
||||||
|
pv=100,
|
||||||
|
load=2_900,
|
||||||
|
interval_start=base + timedelta(hours=9),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
battery = _battery(uc_wh=64_000.0)
|
||||||
|
soc = 0.46 * battery.usable_capacity_wh
|
||||||
|
out = _select_charge_slots(slots, battery, current_soc_wh=soc)
|
||||||
|
self.assertIn(1, out, "Levnější PM slot (lookahead) má allow_charge i s FVE")
|
||||||
|
self.assertNotIn(
|
||||||
|
2,
|
||||||
|
out,
|
||||||
|
"Drahý odpolední slot nemá být v grid maskě B jen kvůli globálnímu min",
|
||||||
|
)
|
||||||
|
|
||||||
def test_vt_before_nt_skips_expensive_pm_slot(self) -> None:
|
def test_vt_before_nt_skips_expensive_pm_slot(self) -> None:
|
||||||
"""Regrese home-01: 12:45 VT drahý, za 15 min NT levný → PM grid charge ne v 12:45."""
|
"""Regrese home-01: 12:45 VT drahý, za 15 min NT levný → PM grid charge ne v 12:45."""
|
||||||
base = datetime(2026, 5, 21, 10, 45, tzinfo=timezone.utc)
|
base = datetime(2026, 5, 21, 10, 45, tzinfo=timezone.utc)
|
||||||
|
|||||||
@@ -64,11 +64,16 @@ 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_ref_buy_am_czk_kwh numeric;
|
||||||
|
v_ref_buy_pm_czk_kwh numeric;
|
||||||
v_purchase_pricing_mode text;
|
v_purchase_pricing_mode text;
|
||||||
v_lookahead_slots int := 4;
|
v_lookahead_slots int := 4;
|
||||||
v_grid_charge_cap_am int := 6;
|
v_grid_charge_cap_am int;
|
||||||
v_grid_charge_cap_pm int := 6;
|
v_grid_charge_cap_pm int;
|
||||||
v_buy_lookahead_eps numeric := 0.05;
|
v_buy_lookahead_eps numeric := 0.05;
|
||||||
|
v_buy_charge_band_czk_kwh numeric := 0.40;
|
||||||
|
v_grid_filled_wh numeric := 0;
|
||||||
|
v_pv_layer_cap_wh numeric;
|
||||||
v_grid_slots_am int := 0;
|
v_grid_slots_am int := 0;
|
||||||
v_grid_slots_pm int := 0;
|
v_grid_slots_pm int := 0;
|
||||||
v_acquisition_cutoff timestamptz;
|
v_acquisition_cutoff timestamptz;
|
||||||
@@ -268,11 +273,21 @@ begin
|
|||||||
end if;
|
end if;
|
||||||
v_discharge_target_wh := v_exportable * v_discharge_buf;
|
v_discharge_target_wh := v_exportable * v_discharge_buf;
|
||||||
|
|
||||||
-- Referenční nákup pro arbitráž (celý horizont, ne jen allow_charge).
|
-- Referenční nákup: globální min (export brána) + per AM/PM pás (grid nabíjení).
|
||||||
select coalesce(min(wk.buy_price), 0)
|
select coalesce(min(wk.buy_price), 0)
|
||||||
into v_ref_buy_czk_kwh
|
into v_ref_buy_czk_kwh
|
||||||
from _ems_plan_slot_wk wk;
|
from _ems_plan_slot_wk wk;
|
||||||
|
|
||||||
|
select coalesce(min(wk.buy_price), v_ref_buy_czk_kwh)
|
||||||
|
into v_ref_buy_am_czk_kwh
|
||||||
|
from _ems_plan_slot_wk wk
|
||||||
|
where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12;
|
||||||
|
|
||||||
|
select coalesce(min(wk.buy_price), v_ref_buy_czk_kwh)
|
||||||
|
into v_ref_buy_pm_czk_kwh
|
||||||
|
from _ems_plan_slot_wk wk
|
||||||
|
where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12;
|
||||||
|
|
||||||
-- 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,
|
||||||
@@ -330,45 +345,42 @@ begin
|
|||||||
v_chg_pm_wh := v_grid_target_wh - v_chg_am_wh;
|
v_chg_pm_wh := v_grid_target_wh - v_chg_am_wh;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- charge mask: dvě nezávislé vrstvy (tenký anti-mikrocyklus, ekonomika z cen)
|
if v_per_slot_charge_wh > 0 then
|
||||||
|
v_grid_charge_cap_am := greatest(
|
||||||
|
1,
|
||||||
|
least(24, ceil(v_chg_am_wh / v_per_slot_charge_wh)::int)
|
||||||
|
);
|
||||||
|
v_grid_charge_cap_pm := greatest(
|
||||||
|
1,
|
||||||
|
least(24, ceil(v_chg_pm_wh / v_per_slot_charge_wh)::int)
|
||||||
|
);
|
||||||
|
else
|
||||||
|
v_grid_charge_cap_am := 6;
|
||||||
|
v_grid_charge_cap_pm := 6;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- charge mask: grid arbitráž (B) před FVE (A); AM/PM rozpočet Wh zůstává 50/50.
|
||||||
--
|
--
|
||||||
-- A) PV-surplus: ranking store_score DESC (future_sell − sell − max(0,buy−sell)).
|
-- B) Grid ze sítě: spot, buy v pásmu AM/PM ≤ min(buy v pásmu)+band, lookahead VT→NT;
|
||||||
-- Sloty s nejvyšší hodnotou uložení vs export pokrývají charge target.
|
-- i při pv_surplus>0; cap slotů ∝ rozpočet Wh / per_slot_charge_wh.
|
||||||
-- Zbylé PV-surplus → allow_charge=false (PV jen do sítě / bc≤surplus v LP).
|
-- A) PV-surplus: store_score DESC, doplní jen zbytek do energy_to_fill po vrstvě B.
|
||||||
--
|
|
||||||
-- B) Non-PV grid: jen spot, buy ≤ ref_buy+degrad, buy ≤ min(next N)+ε,
|
|
||||||
-- cap K slotů AM/PM; nikdy při sell < buy − degrad (ztrátový slot).
|
|
||||||
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
|
||||||
update _ems_plan_slot_wk wk set allow_charge = true;
|
update _ems_plan_slot_wk wk set allow_charge = true;
|
||||||
else
|
else
|
||||||
update _ems_plan_slot_wk wk set allow_charge = false;
|
update _ems_plan_slot_wk wk set allow_charge = false;
|
||||||
|
v_grid_filled_wh := 0;
|
||||||
-- A) PV-surplus: nejvyšší store_score (ukládat FVE vs exportovat)
|
|
||||||
v_cum := 0;
|
|
||||||
for r_slot in
|
|
||||||
select wk.slot_ord, wk.pv_surplus_w
|
|
||||||
from _ems_plan_slot_wk wk
|
|
||||||
where wk.pv_surplus_w > 0
|
|
||||||
and wk.sell_price >= wk.buy_price - v_degrad_czk_kwh
|
|
||||||
order by wk.store_score desc nulls last, wk.slot_ord
|
|
||||||
loop
|
|
||||||
exit when v_cum >= v_grid_target_wh;
|
|
||||||
update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord;
|
|
||||||
v_cum := v_cum + least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25;
|
|
||||||
end loop;
|
|
||||||
|
|
||||||
if v_purchase_pricing_mode <> 'fixed' then
|
if v_purchase_pricing_mode <> 'fixed' then
|
||||||
-- B) Non-PV AM: OTE-first, levný buy + lookahead, cap slotů
|
-- B) Grid AM (dříve než PV, vlastní 50 % rozpočtu Wh)
|
||||||
v_cum := 0;
|
v_cum := 0;
|
||||||
v_grid_slots_am := 0;
|
v_grid_slots_am := 0;
|
||||||
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.pv_surplus_w <= 0
|
where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12
|
||||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12
|
and wk.buy_price <= v_ref_buy_am_czk_kwh + v_buy_charge_band_czk_kwh
|
||||||
and wk.buy_price <= v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
|
||||||
and (
|
and (
|
||||||
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
|
||||||
@@ -382,16 +394,16 @@ begin
|
|||||||
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;
|
||||||
|
v_grid_filled_wh := v_grid_filled_wh + v_cum;
|
||||||
|
|
||||||
-- B) Non-PV PM
|
-- B) Grid PM
|
||||||
v_cum := 0;
|
v_cum := 0;
|
||||||
v_grid_slots_pm := 0;
|
v_grid_slots_pm := 0;
|
||||||
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.pv_surplus_w <= 0
|
where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12
|
||||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12
|
and wk.buy_price <= v_ref_buy_pm_czk_kwh + v_buy_charge_band_czk_kwh
|
||||||
and wk.buy_price <= v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
|
||||||
and (
|
and (
|
||||||
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
|
||||||
@@ -405,7 +417,23 @@ begin
|
|||||||
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;
|
||||||
|
v_grid_filled_wh := v_grid_filled_wh + v_cum;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
|
-- A) PV-surplus: jen zbytek kapacity po grid vrstvě B
|
||||||
|
v_pv_layer_cap_wh := greatest(v_energy_to_fill - v_grid_filled_wh, 0);
|
||||||
|
v_cum := 0;
|
||||||
|
for r_slot in
|
||||||
|
select wk.slot_ord, wk.pv_surplus_w
|
||||||
|
from _ems_plan_slot_wk wk
|
||||||
|
where wk.pv_surplus_w > 0
|
||||||
|
and wk.sell_price >= wk.buy_price - v_degrad_czk_kwh
|
||||||
|
order by wk.store_score desc nulls last, wk.slot_ord
|
||||||
|
loop
|
||||||
|
exit when v_cum >= v_pv_layer_cap_wh;
|
||||||
|
update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord;
|
||||||
|
v_cum := v_cum + least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25;
|
||||||
|
end loop;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
-- discharge-export mask
|
-- discharge-export mask
|
||||||
@@ -443,6 +471,7 @@ 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).
|
||||||
select
|
select
|
||||||
coalesce(sum(
|
coalesce(sum(
|
||||||
case
|
case
|
||||||
@@ -455,17 +484,6 @@ begin
|
|||||||
else 0
|
else 0
|
||||||
end
|
end
|
||||||
), 0),
|
), 0),
|
||||||
coalesce(sum(
|
|
||||||
case
|
|
||||||
when wk.pv_surplus_w > 0
|
|
||||||
and (
|
|
||||||
v_acquisition_cutoff is null
|
|
||||||
or wk.interval_start < v_acquisition_cutoff
|
|
||||||
)
|
|
||||||
then least(wk.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25
|
|
||||||
else 0
|
|
||||||
end
|
|
||||||
), 0),
|
|
||||||
coalesce(sum(
|
coalesce(sum(
|
||||||
case
|
case
|
||||||
when wk.allow_charge
|
when wk.allow_charge
|
||||||
@@ -476,31 +494,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),
|
|
||||||
coalesce(sum(
|
|
||||||
case
|
|
||||||
when wk.pv_surplus_w > 0
|
|
||||||
and (
|
|
||||||
v_acquisition_cutoff is null
|
|
||||||
or wk.interval_start < v_acquisition_cutoff
|
|
||||||
)
|
|
||||||
then coalesce(wk.future_sell_lookahead, wk.sell_price)
|
|
||||||
* least(wk.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25
|
|
||||||
else 0
|
|
||||||
end
|
|
||||||
), 0)
|
), 0)
|
||||||
into
|
into
|
||||||
v_est_grid_wh,
|
v_est_grid_wh,
|
||||||
v_est_pv_wh,
|
v_est_grid_cost
|
||||||
v_est_grid_cost,
|
|
||||||
v_est_pv_cost
|
|
||||||
from _ems_plan_slot_wk wk;
|
from _ems_plan_slot_wk wk;
|
||||||
|
|
||||||
if (v_est_grid_wh + v_est_pv_wh) > 0 then
|
v_est_pv_wh := 0;
|
||||||
v_charge_acquisition := (v_est_grid_cost + v_est_pv_cost)
|
v_est_pv_cost := 0;
|
||||||
/ (v_est_grid_wh + v_est_pv_wh);
|
|
||||||
|
if v_est_grid_wh > 0 then
|
||||||
|
v_charge_acquisition := v_est_grid_cost / v_est_grid_wh;
|
||||||
else
|
else
|
||||||
v_charge_acquisition := v_ref_buy_czk_kwh;
|
v_charge_acquisition := coalesce(
|
||||||
|
(v_ref_buy_am_czk_kwh + v_ref_buy_pm_czk_kwh) / 2.0,
|
||||||
|
v_ref_buy_czk_kwh
|
||||||
|
);
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
return query
|
return query
|
||||||
@@ -602,5 +611,5 @@ comment on function ems.fn_load_planning_slots_full is
|
|||||||
'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent). '
|
'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent). '
|
||||||
'Denní safety vstupy: night_baseload_* (20:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), '
|
'Denní safety vstupy: night_baseload_* (20:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), '
|
||||||
'lookahead max buy/sell pro měkké LP penalizace. '
|
'lookahead max buy/sell pro měkké LP penalizace. '
|
||||||
'charge_acquisition_buy_czk_kwh: vážený průměr grid (allow_charge) + FVE (pv_surplus, opportunity future_sell) '
|
'charge_acquisition_buy_czk_kwh: vážený buy v allow_charge slotech před charge_acquisition_cutoff_at. '
|
||||||
'jen pro sloty před charge_acquisition_cutoff_at (= začátek prvního allow_discharge_export).';
|
'Grid maska B běží před PV vrstvou A; AM/PM rozpočet Wh 50/50; cap slotů z rozpočtu / per_slot_charge_wh.';
|
||||||
|
|||||||
@@ -108,9 +108,8 @@ Pro **home-01** při nabíjení 11:00–14:00 za ~0,7–0,9 Kč a výprodeji 19:
|
|||||||
|
|
||||||
1. **`ems.fn_load_planning_slots_full`** (`R__063`): sloupce
|
1. **`ems.fn_load_planning_slots_full`** (`R__063`): sloupce
|
||||||
`charge_acquisition_buy_czk_kwh`, `charge_acquisition_cutoff_at`.
|
`charge_acquisition_buy_czk_kwh`, `charge_acquisition_cutoff_at`.
|
||||||
2. **Vážený průměr** jen pro sloty **před** prvním `allow_discharge_export` (`cutoff = min(interval_start)` exportních slotů):
|
2. **Vážený průměr `buy`** v `allow_charge` slotech **před** prvním `allow_discharge_export` (`cutoff`):
|
||||||
- **grid:** `buy × per_slot_charge_wh` pro `allow_charge`;
|
`Σ(buy × per_slot_charge_wh) / Σ(per_slot_charge_wh)` — bez `future_sell` z odpolední FVE (jinak acquisition nafukovala večerní export).
|
||||||
- **FVE:** `future_sell_lookahead` (fallback `sell`) × odhad Wh z `pv_surplus` (`least(surplus, max_charge)×η×0,25 h`).
|
|
||||||
3. **`solve_dispatch`:** v exportních slotech (`allow_discharge_export`) přičíst k objective
|
3. **`solve_dispatch`:** v exportních slotech (`allow_discharge_export`) přičíst k objective
|
||||||
`+ ge_bat[t] × charge_acquisition × INTERVAL_H/1000` (náklad uložené energie), ponechat `−ge×sell`. Snapshot v `solver_params.inputs`.
|
`+ ge_bat[t] × charge_acquisition × INTERVAL_H/1000` (náklad uložené energie), ponechat `−ge×sell`. Snapshot v `solver_params.inputs`.
|
||||||
4. **FVE opportunity:** varianta **B** — lookahead `future_sell_opportunity`, ne jen `sell[t]` v odhadu PV Wh.
|
4. **FVE opportunity:** varianta **B** — lookahead `future_sell_opportunity`, ne jen `sell[t]` v odhadu PV 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` (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).
|
||||||
- **Non-PV grid (vrstva B):** jen **spot** nákup (`purchase_pricing_mode <> 'fixed'`), `buy ≤ min(buy horizontu) + degradation`, **lookahead** `buy ≤ min(buy v příštích 4 slotech) + 0,05 Kč` (VT→NT), max **6 slotů** AM a PM; AM/PM rozpočet 50/50 z `grid_target`. **KV1/fixed:** vrstva B vypnutá.
|
- **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`).
|
||||||
- **`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