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

This commit is contained in:
Dusan Vojacek
2026-05-21 13:44:13 +02:00
parent 52bedcf67d
commit ba1cdcbee4
4 changed files with 188 additions and 122 deletions

View File

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

View File

@@ -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,buysell)). -- 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:0006:00 Europe/Prague), safety_soc_target_wh (619), ' 'Denní safety vstupy: night_baseload_* (20:0006:00 Europe/Prague), safety_soc_target_wh (619), '
'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.';

View File

@@ -108,9 +108,8 @@ Pro **home-01** při nabíjení 11:0014:00 za ~0,70,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.

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).
- **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.