Branch 3: charge-slot-budget v R__063 + odstranit v58 pro BA81/KV1 + fixed evening push
Some checks failed
CI and deploy / migration-check (push) Failing after 25s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-06-06 22:32:48 +02:00
parent 09bca0a903
commit a7879f1141
7 changed files with 252 additions and 162 deletions

View File

@@ -71,7 +71,7 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
PLANNER_BUILD_TAG = "2026-06-06-future-neg-buy-evening-export-v64"
PLANNER_BUILD_TAG = "2026-06-06-charge-slot-budget-v1"
SOLVER_RELAX_STEPS: tuple[str, ...] = (
"strict",
"relaxed_expensive_import",
@@ -82,8 +82,6 @@ SOLVER_RELAX_STEPS: tuple[str, ...] = (
)
# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak).
DAWN_LOW_PV_NO_CURTAIL_W = 1500
# BA81/KV1: PV→bat jen v těsné blízkosti nejnižšího sell v horizontu (≈ poledne), ne při ~3 Kč ráno.
FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH = 0.20
# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu).
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
@@ -456,6 +454,13 @@ class PlanningSlot:
pv_charge_wh_ahead: float | None = None
neg_buy_wh_ahead: float | None = None
grid_charge_suppressed_reason: str | None = None
charge_target_wh: float | None = None
pre_window_wh: float | None = None
in_window_wh: float | None = None
charge_slot_wh: float | None = None
charge_cum_wh: float | None = None
charge_layer: str | None = None
charge_slot_reason: str | None = None
#: Pomocny atribut pro green_bonus v planning_interval (Kc/slot); lite default 0.
green_bonus_czk_per_slot: float = 0.0
@@ -1836,12 +1841,13 @@ def _slot_evening_push_profitable(
slots: list[PlanningSlot] | None = None,
first_neg_sell_idx: int | None = None,
kv1_evening_push: bool = False,
purchase_fixed: bool = False,
) -> bool:
"""
Push večerní špičky.
Spot / obecně: sell > acq+spread (zásoba z levného nabití).
KV1 (fixed + block_export, v52): sell ≥ max sell v pásmu 511 před 1. sell<0 spread
— neprodávat večer levněji než plánované ranní maximum; bez neg dne v horizontu sell ≥ 1 Kč.
Spot: sell > acq+spread (zásoba z levného nabití).
Fixní tarif (BA81/KV1): sell > buy+spread (stejně jako R__063 discharge maska).
KV1 (fixed + block_export, v52): navíc sell ≥ max sell v pásmu 511 před 1. sell<0 spread.
"""
sell_t = float(slot.sell_price)
if kv1_evening_push:
@@ -1852,6 +1858,10 @@ def _slot_evening_push_profitable(
if zone_peak is not None:
return sell_t >= float(zone_peak) - float(min_spread)
return True
if purchase_fixed:
buy_t = float(slot.buy_price)
if buy_t >= 0.0:
return sell_t > buy_t + float(min_spread)
return sell_t > float(charge_acquisition_czk_kwh) + float(min_spread)
@@ -1864,6 +1874,7 @@ def _evening_push_segment_candidates(
discharge_export_ok: set[int] | None = None,
first_neg_sell_idx: int | None = None,
kv1_evening_push: bool = False,
purchase_fixed: bool = False,
) -> list[int]:
"""Profitable sloty v nočním úseku — výběr pořadí a strop dělá rozpočet Wh (sell desc)."""
if not seg:
@@ -1881,6 +1892,7 @@ def _evening_push_segment_candidates(
slots=slots,
first_neg_sell_idx=first_neg_sell_idx,
kv1_evening_push=kv1_evening_push,
purchase_fixed=purchase_fixed,
):
continue
out.append(t)
@@ -2013,6 +2025,7 @@ def _evening_battery_export_push_indices(
evening_start_hour: int = 17,
first_neg_sell_idx: int | None = None,
kv1_evening_push: bool = False,
purchase_fixed: bool = False,
) -> list[int]:
"""
Večerní push (≥17h): plný ge_bat v nejdražších slotách (sell desc), rozpočet Wh
@@ -2049,6 +2062,7 @@ def _evening_battery_export_push_indices(
discharge_export_ok=discharge_export_ok,
first_neg_sell_idx=first_neg_sell_idx,
kv1_evening_push=kv1_evening_push,
purchase_fixed=purchase_fixed,
):
if t not in seen:
seen.add(t)
@@ -2078,6 +2092,7 @@ def _evening_push_peak_fallback_indices(
discharge_export_ok: set[int] | None,
first_neg_sell_idx: int | None,
kv1_evening_push: bool,
purchase_fixed: bool = False,
) -> set[int]:
"""Alespoň jeden večerní peak slot (sell desc), když rozpočet Wh nevybral žádný push."""
best_t: int | None = None
@@ -2094,6 +2109,7 @@ def _evening_push_peak_fallback_indices(
slots=slots,
first_neg_sell_idx=first_neg_sell_idx,
kv1_evening_push=kv1_evening_push,
purchase_fixed=purchase_fixed,
):
continue
sell_t = float(s.sell_price)
@@ -2944,6 +2960,7 @@ def solve_dispatch(
discharge_export_ok=discharge_export_slots,
first_neg_sell_idx=first_neg_sell_idx,
kv1_evening_push=kv1_evening_push_pre,
purchase_fixed=purchase_fixed_pre,
)
)
push_override_raw = _evening_push_override_for_solve(
@@ -2977,6 +2994,7 @@ def solve_dispatch(
discharge_export_ok=discharge_export_slots,
first_neg_sell_idx=first_neg_sell_idx,
kv1_evening_push=kv1_evening_push_pre,
purchase_fixed=purchase_fixed_pre,
)
# Tvrdý ge_bat push vypnout jen při neg_sell fallback (ne při prep relax — v64).
evening_push_hard_suppressed = bool(neg_sell_phases_fallback)
@@ -4216,34 +4234,8 @@ def solve_dispatch(
or fixed_pre_neg_pv_export
or fixed_block_pv_surplus_export
or fixed_mi_low_pv_surplus_export
or (
purchase_fixed_pre
and fixed_horizon_min_sell_pre is not None
and sell_t >= 0.0
and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
and sell_t
> fixed_horizon_min_sell_pre
+ FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH
)
)
fixed_sell_above_horizon_min = (
purchase_fixed_pre
and fixed_horizon_min_sell_pre is not None
and sell_t >= 0.0
and sell_t
> fixed_horizon_min_sell_pre + FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH
)
fixed_high_sell_no_pv_charge = (
fixed_sell_above_horizon_min
and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
)
fixed_grid_charge_unprofitable = (
purchase_fixed_pre
and buy_t >= 0.0
and fixed_sell_above_horizon_min
)
# Spot: mezi-slotová arbitráž — sell<buy ve slotu je normální (marže). Grid→bat jen
# když buy v tomto slotu odpovídá levnému nákupu (≤ charge_acquisition), ne 19:00 za 5,5 při acq 3,25.
# Spot: mezi-slotová arbitráž — grid→bat jen když buy ≤ charge_acquisition (v61).
spot_grid_charge_not_cheap_buy = (
not purchase_fixed_pre
and buy_t >= 0.0
@@ -4257,10 +4249,8 @@ def solve_dispatch(
and not fixed_pre_neg_pv_export
and int(s.pv_a_forecast_w) >= DAWN_LOW_PV_NO_CURTAIL_W
)
if fixed_grid_charge_unprofitable or spot_grid_charge_not_cheap_buy:
if spot_grid_charge_not_cheap_buy:
prob += bc_gi[t] == 0
if fixed_high_sell_no_pv_charge:
prob += bc_pv[t] == 0
if (
purchase_fixed_pre
and t in evening_push_ts
@@ -4773,6 +4763,25 @@ def solve_dispatch(
solver_snapshot: dict[str, Any] = {
"version": 1,
"planner_build_tag": PLANNER_BUILD_TAG,
"charge_slot_budget": {
"charge_target_wh": (
float(slots[0].charge_target_wh)
if slots[0].charge_target_wh is not None
else None
),
"pre_window_wh": (
float(slots[0].pre_window_wh)
if slots[0].pre_window_wh is not None
else None
),
"in_window_wh": (
float(slots[0].in_window_wh)
if slots[0].in_window_wh is not None
else None
),
"reliability_factor": 0.85,
"planner_build_tag": PLANNER_BUILD_TAG,
},
"inputs": {
"current_soc_wh": float(current_soc_wh),
"observed_soc_wh": float(observed_soc_wh),
@@ -4897,9 +4906,7 @@ def solve_dispatch(
and not push_override_eff
),
"fixed_horizon_min_sell_czk_kwh": fixed_horizon_min_sell_pre,
"fixed_pv_charge_near_min_sell_margin_czk_kwh": (
FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH if purchase_fixed_pre else None
),
"fixed_evening_push_sell_above_buy": bool(purchase_fixed_pre),
"charge_commitment_ignored_on_relaxed": bool(
commitment_for_solve is None and charge_commitment_prev_w is not None
),
@@ -5628,7 +5635,9 @@ async def _load_slots(
is_daytime_pv_surplus_slot,
charge_acquisition_buy_czk_kwh, charge_acquisition_cutoff_at,
min_buy_before_cutoff_czk_kwh, pv_charge_wh_ahead, neg_buy_wh_ahead,
grid_charge_suppressed_reason
grid_charge_suppressed_reason,
charge_target_wh, pre_window_wh, in_window_wh,
charge_slot_wh, charge_cum_wh, charge_layer, charge_slot_reason
from ems.fn_load_planning_slots_full(
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric
)
@@ -5672,6 +5681,13 @@ async def _load_slots(
pv_charge_wh_ahead=_slot_float_nullable(d, "pv_charge_wh_ahead"),
neg_buy_wh_ahead=_slot_float_nullable(d, "neg_buy_wh_ahead"),
grid_charge_suppressed_reason=d.get("grid_charge_suppressed_reason"),
charge_target_wh=_slot_float_nullable(d, "charge_target_wh"),
pre_window_wh=_slot_float_nullable(d, "pre_window_wh"),
in_window_wh=_slot_float_nullable(d, "in_window_wh"),
charge_slot_wh=_slot_float_nullable(d, "charge_slot_wh"),
charge_cum_wh=_slot_float_nullable(d, "charge_cum_wh"),
charge_layer=d.get("charge_layer"),
charge_slot_reason=d.get("charge_slot_reason"),
)
)
if not out:

View File

@@ -337,22 +337,32 @@ def _select_charge_slots(
elif purchase_pricing_mode == "fixed" and any(
float(s.sell_price) > float(s.buy_price) + degrad for s in slots
):
min_sell_pos = min(
float(s.sell_price) for s in slots if float(s.sell_price) >= 0.0
)
am_candidates = [
(t, getattr(slots[t], "is_predicted_price", False))
(
t,
getattr(slots[t], "is_predicted_price", False),
float(slots[t].sell_price),
)
for t in range(len(slots))
if _prague_hour(slots[t]) < 12
and float(slots[t].sell_price) >= 0.0
and float(slots[t].sell_price) <= min_sell_pos + degrad + 0.05
]
am_candidates.sort(
key=lambda x: (
_grid_sort_key(x[0], x[1], 0.0)[0],
_grid_sort_key(x[0], x[1], 0.0)[1],
_grid_sort_key(x[0], x[1], 0.0)[2],
_grid_sort_key(x[0], x[1], x[2])[0],
_grid_sort_key(x[0], x[1], x[2])[1],
_grid_sort_key(x[0], x[1], x[2])[2],
x[2],
x[0],
)
)
cum = 0.0
grid_am = 0
for t, _pred in am_candidates:
for t, _pred, _sell in am_candidates:
if cum >= chg_am or per_slot_full_wh <= 0 or grid_am >= cap_am:
break
selected.add(t)
@@ -369,21 +379,28 @@ def _select_charge_slots(
),
)
pm_candidates = [
(t, getattr(slots[t], "is_predicted_price", False))
(
t,
getattr(slots[t], "is_predicted_price", False),
float(slots[t].sell_price),
)
for t in range(len(slots))
if _prague_hour(slots[t]) >= 12
and float(slots[t].sell_price) >= 0.0
and float(slots[t].sell_price) <= min_sell_pos + degrad + 0.05
]
pm_candidates.sort(
key=lambda x: (
_grid_sort_key(x[0], x[1], 0.0)[0],
_grid_sort_key(x[0], x[1], 0.0)[1],
_grid_sort_key(x[0], x[1], 0.0)[2],
_grid_sort_key(x[0], x[1], x[2])[0],
_grid_sort_key(x[0], x[1], x[2])[1],
_grid_sort_key(x[0], x[1], x[2])[2],
x[2],
x[0],
)
)
cum = 0.0
grid_pm = 0
for t, _pred in pm_candidates:
for t, _pred, _sell in pm_candidates:
if cum >= chg_pm or per_slot_full_wh <= 0 or grid_pm >= cap_pm:
break
selected.add(t)
@@ -398,15 +415,22 @@ def _select_charge_slots(
fso = _future_sell(slots, t)
if (
pv_surplus_w > 0
and float(s.sell_price) >= float(s.buy_price) - degrad
and (
float(s.sell_price) < 0
purchase_pricing_mode == "fixed"
or float(s.sell_price) >= float(s.buy_price) - degrad
)
and (
purchase_pricing_mode == "fixed"
or float(s.sell_price) < 0
or float(s.sell_price) >= fso - degrad
)
):
pv_candidates.append((t, _store_score(slots, t), float(pv_surplus_w)))
pv_candidates.sort(key=lambda x: (-x[1], x[0]))
if purchase_pricing_mode == "fixed":
pv_candidates.sort(key=lambda x: (float(slots[x[0]].sell_price), x[0]))
else:
pv_candidates.sort(key=lambda x: (-x[1], x[0]))
cum = 0.0
for t, _score, pv_surplus_w in pv_candidates:
if cum >= pv_layer_cap:

View File

@@ -1675,7 +1675,9 @@ class NegativeSellPvChargeTests(unittest.TestCase):
)
def test_fixed_high_sell_no_pv_charge_near_min_sell(self) -> None:
"""v58: ráno sell~3 Kč → export FVE; poledne sell~1,45 Kč → nabíjení z PV."""
"""Charge-slot budget: levnější sell (poledne) dostane allow_charge dřív než drahší ráno."""
from test_planning_charge_slot_selection import _select_charge_slots
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 6, 2, 6, 0, tzinfo=prague)
slots: list[PlanningSlot] = []
@@ -1693,73 +1695,29 @@ class NegativeSellPvChargeTests(unittest.TestCase):
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=3.088,
future_sell_opportunity_czk_kwh=3.2,
)
)
battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0)
battery.max_charge_power_w = 6250
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=8000,
block_export_on_negative_sell=False,
purchase_pricing_mode="fixed",
)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
battery.charge_slot_buffer = 1.3
soc0 = 0.35 * battery.usable_capacity_wh
results, _, snap = solve_dispatch(
charge = _select_charge_slots(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
purchase_pricing_mode="fixed",
apply_dynamic_grid_filter=False,
)
self.assertEqual(
snap["inputs"].get("fixed_horizon_min_sell_czk_kwh"),
1.45,
)
r_morning = results[0]
r_noon = results[5]
self.assertLess(
r_morning.grid_setpoint_w,
-2000,
"vysoký sell: přebytek FVE do sítě",
)
self.assertLessEqual(
r_morning.battery_setpoint_w,
200,
"vysoký sell: ne nabíjet z PV",
)
self.assertGreater(
r_noon.battery_setpoint_w,
800,
"min sell: nabíjení z PV",
noon_idx = 4
cheap_slots = {4, 5, 6, 7}
self.assertIn(noon_idx, charge, "min sell slot má allow_charge z PV vrstvy")
self.assertTrue(
cheap_slots.issubset(charge),
"všechny sloty u min sell musí mít allow_charge dřív než dražší ranní",
)
def test_fixed_no_grid_charge_when_sell_above_horizon_min(self) -> None:
"""v59: KV1 — grid→bat jen u min sell, ne v noci za 6 Kč při sell 3,5."""
"""v59 SQL maska: grid→bat jen u min sell; LP nerespektuje allow_charge bez allow_grid."""
prague = ZoneInfo("Europe/Prague")
cheap = PlanningSlot(
interval_start=datetime(2026, 6, 2, 10, 15, tzinfo=prague).astimezone(
@@ -1787,7 +1745,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_charge=False,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=6.353,
)

View File

@@ -36,7 +36,14 @@ returns table (
min_buy_before_cutoff_czk_kwh numeric,
pv_charge_wh_ahead numeric,
neg_buy_wh_ahead numeric,
grid_charge_suppressed_reason text
grid_charge_suppressed_reason text,
charge_target_wh numeric,
pre_window_wh numeric,
in_window_wh numeric,
charge_slot_wh numeric,
charge_cum_wh numeric,
charge_layer text,
charge_slot_reason text
)
language plpgsql
volatile
@@ -104,6 +111,10 @@ declare
v_cum_allowed numeric;
v_pv_ahead_total numeric;
v_target_deficit numeric;
v_charge_target_wh numeric;
v_pre_window_wh numeric := 0;
v_in_window_wh numeric := 0;
v_charge_reliability_factor numeric := 0.85;
r_unlock record;
begin
v_plan_day_prague := (p_from at time zone 'Europe/Prague')::date;
@@ -308,6 +319,7 @@ begin
);
end if;
v_discharge_target_wh := v_exportable * v_discharge_buf;
v_charge_target_wh := greatest(v_grid_target_wh, 0);
-- 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)
@@ -334,7 +346,11 @@ begin
add column if not exists min_buy_before_cutoff numeric,
add column if not exists pv_charge_wh_ahead numeric,
add column if not exists neg_buy_wh_ahead numeric,
add column if not exists grid_charge_suppressed_reason text;
add column if not exists grid_charge_suppressed_reason text,
add column if not exists charge_slot_wh numeric default 0,
add column if not exists charge_cum_wh numeric,
add column if not exists charge_layer text,
add column if not exists charge_slot_reason text;
-- První výkupní okno **per kalendářní den** (Prague). Globální min přes dny by
-- zablokoval NT grid nabíjení (včerejší večerní peak → dnešní 0006 už „po okně“).
@@ -477,7 +493,12 @@ begin
exit when v_per_slot_charge_wh <= 0;
exit when v_grid_slots_am >= v_grid_charge_cap_am;
update _ems_plan_slot_wk wk
set allow_charge = true, allow_grid_charge = true
set allow_charge = true,
allow_grid_charge = true,
charge_layer = 'grid_am',
charge_slot_reason = 'grid_layer_b',
charge_slot_wh = v_per_slot_charge_wh,
charge_cum_wh = v_cum + v_per_slot_charge_wh
where wk.slot_ord = r_slot.slot_ord;
v_cum := v_cum + v_per_slot_charge_wh;
v_grid_slots_am := v_grid_slots_am + 1;
@@ -525,7 +546,12 @@ begin
exit when v_per_slot_charge_wh <= 0;
exit when v_grid_slots_pm >= v_grid_charge_cap_pm;
update _ems_plan_slot_wk wk
set allow_charge = true, allow_grid_charge = true
set allow_charge = true,
allow_grid_charge = true,
charge_layer = 'grid_pm',
charge_slot_reason = 'grid_layer_b',
charge_slot_wh = v_per_slot_charge_wh,
charge_cum_wh = v_cum + v_per_slot_charge_wh
where wk.slot_ord = r_slot.slot_ord;
v_cum := v_cum + v_per_slot_charge_wh;
v_grid_slots_pm := v_grid_slots_pm + 1;
@@ -534,7 +560,11 @@ begin
-- Spot: záporný buy → grid nabíjení ve všech slotech (maximální arbitráž), mimo AM/PM rozpočet.
update _ems_plan_slot_wk wk
set allow_charge = true, allow_grid_charge = true
set allow_charge = true,
allow_grid_charge = true,
charge_layer = coalesce(wk.charge_layer, 'buy_negative'),
charge_slot_reason = coalesce(wk.charge_slot_reason, 'buy_negative'),
charge_slot_wh = greatest(wk.charge_slot_wh, v_per_slot_charge_wh)
where wk.buy_price < 0;
-- Self-konzistentni filtr vrstvy B (spot): vyloucit drahe grid sloty, pokud PV / buy<0
@@ -697,7 +727,12 @@ begin
exit when v_per_slot_charge_wh <= 0;
exit when v_grid_slots_am >= v_grid_charge_cap_am;
update _ems_plan_slot_wk wk
set allow_charge = true, allow_grid_charge = true
set allow_charge = true,
allow_grid_charge = true,
charge_layer = 'grid_am',
charge_slot_reason = 'grid_layer_b',
charge_slot_wh = v_per_slot_charge_wh,
charge_cum_wh = v_cum + v_per_slot_charge_wh
where wk.slot_ord = r_slot.slot_ord;
v_cum := v_cum + v_per_slot_charge_wh;
v_grid_slots_am := v_grid_slots_am + 1;
@@ -749,7 +784,12 @@ begin
exit when v_per_slot_charge_wh <= 0;
exit when v_grid_slots_pm >= v_grid_charge_cap_pm;
update _ems_plan_slot_wk wk
set allow_charge = true, allow_grid_charge = true
set allow_charge = true,
allow_grid_charge = true,
charge_layer = 'grid_pm',
charge_slot_reason = 'grid_layer_b',
charge_slot_wh = v_per_slot_charge_wh,
charge_cum_wh = v_cum + v_per_slot_charge_wh
where wk.slot_ord = r_slot.slot_ord;
v_cum := v_cum + v_per_slot_charge_wh;
v_grid_slots_pm := v_grid_slots_pm + 1;
@@ -760,40 +800,53 @@ begin
-- 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);
-- Rezervace SoC pro sell<0 okno: pokud v zápor. výkup. slotech máme
-- očekávaný PV přebytek X Wh (po efektivitě), snížíme PV vrstvu A o X.
-- Důsledek: do okna nedorazíme „plní" (98 % SoC), zbude prostor přijmout PV
-- z neg-sell slotů místo exportu do mínusu / curtail pole A.
-- Sample neg-sell PV sloty (sell<0 a buy<0, kde sell<buy) jsou vyloučené
-- z hlavního A-loopu (filtr sell >= buy degrad), takže redukce je čistá.
declare
v_neg_window_pv_surplus_wh numeric := 0;
begin
select coalesce(sum(least(wk.pv_surplus_w::numeric, v_max_charge_w) * v_charge_eff * 0.25), 0)
into v_neg_window_pv_surplus_wh
from _ems_plan_slot_wk wk
where wk.sell_price < 0
and wk.pv_surplus_w > 0;
if v_neg_window_pv_surplus_wh > 0 then
v_pv_layer_cap_wh := greatest(v_pv_layer_cap_wh - v_neg_window_pv_surplus_wh, 0);
end if;
end;
-- Dodávka z forecastu v sell<0 okně snižuje potřebu nabíjení před oknem.
select coalesce(sum(least(wk.pv_surplus_w::numeric, v_max_charge_w) * v_charge_eff * 0.25), 0)
into v_in_window_wh
from _ems_plan_slot_wk wk
where wk.sell_price < 0
and wk.pv_surplus_w > 0;
if v_in_window_wh > 0 then
v_pv_layer_cap_wh := greatest(v_pv_layer_cap_wh - v_in_window_wh, 0);
v_pre_window_wh := greatest(
0,
v_charge_target_wh - v_in_window_wh * v_charge_reliability_factor
);
end if;
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
-- Držet PV na večerní peak jen při kladném výkupu; při sell<0 (záporný výkup) vždy nabíjet z FVE.
and (
wk.sell_price < 0
v_purchase_pricing_mode = 'fixed'
or wk.sell_price >= wk.buy_price - v_degrad_czk_kwh
)
-- Spot: neukládat do bat při výrazně lepším sell později; fixed: řazení sell ASC (§ charge-slot-budget).
and (
v_purchase_pricing_mode = 'fixed'
or wk.sell_price < 0
or wk.sell_price >= wk.future_sell_lookahead - v_degrad_czk_kwh
)
order by wk.store_score desc nulls last, wk.slot_ord
order by
case when v_purchase_pricing_mode = 'fixed' then wk.sell_price end asc nulls last,
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;
update _ems_plan_slot_wk wk
set allow_charge = true,
charge_layer = coalesce(wk.charge_layer, 'pv_a'),
charge_slot_reason = coalesce(wk.charge_slot_reason, 'pv_layer_a'),
charge_slot_wh = greatest(
wk.charge_slot_wh,
least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25
),
charge_cum_wh = v_cum
+ least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25
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;
@@ -1007,12 +1060,22 @@ begin
-- Záporný buy: vždy grid nabíjení (mimo rozpočet 6 slotů / PV vrstvu A).
update _ems_plan_slot_wk wk
set allow_charge = true, allow_grid_charge = true
set allow_charge = true,
allow_grid_charge = true,
charge_layer = coalesce(wk.charge_layer, 'buy_negative'),
charge_slot_reason = coalesce(wk.charge_slot_reason, 'buy_negative'),
charge_slot_wh = greatest(wk.charge_slot_wh, v_per_slot_charge_wh)
where wk.buy_price < 0;
-- Záporný výkup + PV přebytek: nabíjení z FVE (KV1/BA81 block_export), bez filtru future_sell.
update _ems_plan_slot_wk wk
set allow_charge = true
set allow_charge = true,
charge_layer = coalesce(wk.charge_layer, 'neg_window'),
charge_slot_reason = coalesce(wk.charge_slot_reason, 'neg_window_pv'),
charge_slot_wh = greatest(
wk.charge_slot_wh,
least(wk.pv_surplus_w::numeric, v_max_charge_w) * v_charge_eff * 0.25
)
where wk.sell_price < 0
and wk.pv_surplus_w > 0;
@@ -1021,6 +1084,9 @@ begin
update _ems_plan_slot_wk wk
set allow_charge = true,
allow_grid_charge = true,
charge_layer = coalesce(wk.charge_layer, 'neg_window'),
charge_slot_reason = coalesce(wk.charge_slot_reason, 'neg_window_grid_charge'),
charge_slot_wh = greatest(wk.charge_slot_wh, v_per_slot_charge_wh),
grid_charge_suppressed_reason = coalesce(
wk.grid_charge_suppressed_reason,
'neg_window_grid_charge'
@@ -1176,7 +1242,14 @@ begin
w.min_buy_before_cutoff as min_buy_before_cutoff_czk_kwh,
coalesce(w.pv_charge_wh_ahead, 0) as pv_charge_wh_ahead,
coalesce(w.neg_buy_wh_ahead, 0) as neg_buy_wh_ahead,
w.grid_charge_suppressed_reason
w.grid_charge_suppressed_reason,
v_charge_target_wh as charge_target_wh,
v_pre_window_wh as pre_window_wh,
v_in_window_wh as in_window_wh,
coalesce(w.charge_slot_wh, 0) as charge_slot_wh,
w.charge_cum_wh,
w.charge_layer,
w.charge_slot_reason
from _ems_plan_slot_wk w
cross join night_tot nt
)
@@ -1204,7 +1277,14 @@ begin
e.min_buy_before_cutoff_czk_kwh,
e.pv_charge_wh_ahead,
e.neg_buy_wh_ahead,
e.grid_charge_suppressed_reason
e.grid_charge_suppressed_reason,
e.charge_target_wh,
e.pre_window_wh,
e.in_window_wh,
e.charge_slot_wh,
e.charge_cum_wh,
e.charge_layer,
e.charge_slot_reason
from enriched e
order by e.slot_ord;
end;
@@ -1212,11 +1292,13 @@ $fn$;
comment on function ems.fn_load_planning_slots_full is
'15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). '
'Charge mask A: PV-surplus dle store_score DESC (future_sellsellmax(0,buysell)); zbytek → PV export. '
'Charge mask B: spot, nejlevnější buy v AM/PM do Wh rozpočtu (priorita den plánu, před exportním oknem). '
'Charge-slot budget: charge_target_wh, pre_window_wh (deficit forecast v sell<0), in_window_wh; '
'debug charge_layer / charge_slot_reason / charge_cum_wh. '
'Charge mask A: spot = store_score DESC; fixed = sell ASC + Wh kumulace pv_surplus. '
'Charge mask B: spot nejlevnější buy v AM/PM; fixed nejnižší sell v AM/PM do Wh rozpočtu. '
'ref_buy = min(buy) horizontu. Discharge-export: nejdražší sell kde sell>ref_buy+degrad (spot). '
'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), '
'lookahead max buy/sell pro měkké LP penalizace. '
'charge_acquisition_buy_czk_kwh: vážený buy v allow_charge slotech před charge_acquisition_cutoff_at. '
'charge_acquisition_buy_czk_kwh: vážený buy v allow_grid_charge slotech před charge_acquisition_cutoff_at. '
'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

@@ -1,6 +1,6 @@
# Plánování: rozpočet nabíjecích slotů (Wh × ceny × forecast)
**Stav:** návrh k implementaci (2026-06) — **zatím neimplementováno** v produkčním kódu.
**Stav:** **Branch 3 implementováno** (2026-06-06, tag `2026-06-06-charge-slot-budget-v1`) — fixed tarify BA81/KV1; home-01 pre-neg fronta §6 zatím ne.
**Účel:** nahradit tvrdé prahy typu `sell > min_sell + 0,20 → bc_pv = 0` (v58) a binární pre-neg „cushion“ (v33) jednotným **energetickým rozpočtem** ve `fn_load_planning_slots_full`, který pokryje fixní tarify (BA81, KV1), spot (home-01) i zkracující se okna `sell < 0` (zima).
**Související:**

View File

@@ -19,7 +19,7 @@
- **Tvrdé výkonové limity site/baterie:** `gi ≤ site_grid_connection.max_import_power_w` (breaker); **`bc_pv + bc_gi ≤ asset_battery.max_charge_power_w`**; **`ge ≤ max_export_power_w`** (proměnná `ge`, platí `ge = ge_pv + ge_bat`); **`bd + ge_bat ≤ asset_battery.max_discharge_power_w`** (vybíjení do domu + export z baterie nesmí současně překročit BMS). Dříve LP dovoloval import+nabíjení a dvojnásobné nabíjení; u prodeje hrozilo současné `bd` a `ge_bat` až 2× max discharge — viz `SitePowerCapTests`.
- **Hodnota FVE (PV store value):** tvrdé `ge_pv = 0` jen pokud `sell < future_sell_opportunity degradation` **a** `sell < 0` (spot), nebo u fixního tarifu dle `fixed_pv_b_export_cap`. Při **`sell ≥ 0` (spot home-01, KV1):** `ge_pv` **neblokuje** pv_store — solver volí export vs. `bc_pv` podle `ge_pv×sell` a degradace; **baterii** na večerní peak drží `ge_bat` (`evening_early` / push), ne curtail FVE. **v31:** při `sell ≥ 0` + PV přebytek **není** plný `ge_bat` push z `pre_neg_buy_discharge` / ranních shortfallů (export cap pro FVE). **Před prvním `sell < 0`:** `allow_pre_neg_pv_export`. Tag `2026-05-28-morning-pv-export-priority-v31`. Testy `Home01PvStoreValueTests`, `PreNegativeSellExportTests`.
- **BA81 úsvit + MI (v51):** `fixed_pv_b_export_cap` (`ge_pv ≤ pv_b`) jen pokud **`pv_a_forecast ≥ 1500 W`** (`DAWN_LOW_PV_NO_CURTAIL_W`); při slabším A + přebytku → `fixed_mi_low_pv_surplus_export` (bez pv_store bloku). Exporter: při `forecast < 1500` a bez curtail A → **bez reg 340** (`setpoints.py`). Tag `2026-05-31-ba81-dawn-no-micro-curtail-v51`. Test `test_ba81_dawn_low_pv_no_full_curtail_for_mi_cap`.
- **Fixní tarif — PV export vs. nabíjení (v58v59, dočasné):** v58: při **`sell > min_sell + 0,20`** a PV přebytku → **`bc_pv = 0`**, export FVE; profitable noc **`sell > buy`** mimo `evening_early`. v59: **`bc_gi = 0`** i bez FVE při **`sell < buy`** nebo **`sell > min_sell + 0,20`**; večerní push bez nabíjení; **`R__063`** grid maska podle nejnižšího sell (`sell ASC`), ne `slot_ord`. Tagy `…-v58`, `…-v59`. **Plánovaná náhrada:** energetický rozpočet Wh + řazení výkupů/nákupů — **[`planning-charge-slot-budget.md`](planning-charge-slot-budget.md)** (zrušení v58 v LP, rozšíření `R__063`).
- **Fixní tarif — charge-slot budget (v1, 2026-06-06):** **`R__063`** vybírá nabíjecí sloty Wh kumulací; PV vrstva A u fixed = **`sell ASC`**. LP **nezakazuje** `bc_pv`/`bc_gi` prahy v58 (`sell > min+0,20`); respektuje jen `allow_charge` / `allow_grid_charge`. Večerní push: **`sell > buy + spread`** (BA81); KV1 navíc v52 morning-peak pravidlo. Debug: `charge_slot_budget` v `solver_params`, sloupce `charge_layer` / `charge_slot_reason` ve `fn_load_planning_slots_full`. Spec: **[`planning-charge-slot-budget.md`](planning-charge-slot-budget.md)**. Tag **`2026-06-06-charge-slot-budget-v1`**. v59 grid maska (min sell) a večerní push `bc=0` v push slotech zůstávají.
- **Drahý nákup → vlastní spotřeba z baterie:** mimo `allow_charge` platí `bd + pv_ld ≥ load_baseline + hp[t]` a `gi ≤ EV + hp[t]` (ne `hp_rated`). **Spot:** drahý slot = `buy > min(buy≥0) + degradace`. **Fixní nákup (DB `purchase_pricing_mode=fixed` nebo heuristika rozptylu buy &lt; 0,25):** navíc `buy > charge_acquisition + degradace`. Na spotu **nesmí** `charge_acquisition` (~0,9 Kč) označit všechny sloty jako drahé → Infeasible (home-01). Při **Infeasible** solver jednou opakuje s `relaxed_expensive_import` (síť smí krmit baseload v drahých slotech; v `solver_params.inputs.relaxed_expensive_import=true`). Testy `AutoPassiveSelfConsumptionTests`, `test_spot_low_acquisition_does_not_mark_all_slots_expensive`, `test_negative_buy_in_horizon_does_not_block_all_grid_import`.
- **Záporný výkup (`sell < 0`) bez exportu:** `block_export_on_negative_sell` (KV1) **nebo** `purchase_pricing_mode=fixed` (BA81). **Spot (home-01):** `ge_pv=0` dokud není plná baterie; při plné jen ventil pole B (`ge_pv ≤ pv_b`, `w_pv_b_vent_neg`); výboj baterie při `sell<0` jen **12 slotů** před `buy ≤ planner_extreme_buy_threshold` (default 2), pokud spread do budoucna dává smysl — tag `2026-05-26-neg-sell-bat-dump-extreme-buy-v11`. Večerní discharge maska u spotu: denní peak ≥17:00 (ne `sell > ref_buy` v slotu). **v50:** u **KV1** při `sell≥0` a PV přebytku &gt;500 W i **po** 1. `sell<0``ge_pv` (PV_SURPLUS), ne tvrdý `ge_bat` z večerního peak/push.
- **Pole B při sell&lt;0 (home-01):** pokud `block_export_on_negative_sell = false`, LP nesmí vynutit `ge_pv = 0` (přebytek neriťitelného PV B). KV1 s `block_export = true` jen curtail A / nabíjení.
@@ -153,9 +153,9 @@ flowchart TD
**Funkce:** … home-01 **v61**; BA81/KV1 fixed **v59** (+ `R__063`).
### Rozpočet nabíjecích slotů (plánováno, 2026-06)
### Rozpočet nabíjecích slotů (charge-slot-budget v1, 2026-06-06)
Náhrada tvrdých prahů v58 a binárního pre-neg cushion (v33): **deficit Wh**, forecast v okně `sell < 0`, fronta nejlevnějších slotů (buy/sell) s `pv_surplus`. Pokrývá BA81/KV1 (slunečný den nad ~60 % SoC) i home-01 (krátké zimní neg okno → nabíjení před oknem). **Specifikace:** [`planning-charge-slot-budget.md`](planning-charge-slot-budget.md). **Stav:** neimplementováno — viz changelog *Plánováno*.
**Branch 3 (BA81/KV1):** `R__063` vrací `charge_target_wh`, `pre_window_wh`, `in_window_wh` a debug sloupce; fixed PV vrstva **`sell ASC`**. LP bez v58 — jen masky SQL. Večerní push fixed: **`sell > buy + spread`**. Tag **`2026-06-06-charge-slot-budget-v1`**. **Zbývá pro home-01:** pre-neg fronta místo v33 cushion, v44 změkčení — [`planning-charge-slot-budget.md`](planning-charge-slot-budget.md) §6.
### Arbitráž baterie — účtování mezi sloty (povinné čtení)

View File

@@ -42,24 +42,34 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
---
## Plánováno — rozpočet nabíjecích slotů (charge-slot-budget, neimplementováno)
## 2026-06-06 — charge-slot budget v1 (Branch 3: BA81/KV1)
**Stav:** pouze dokumentace (2026-06); implementace později.
**Problém:** v58 `sell > min_sell + 0,20 → bc_pv = 0` držel denní SoC ~60 % při slunci (konflikt s `R__063` vrstvou A). Fixní lokality neměly večerní push podle `sell > buy + spread`.
**Motivace:**
**Změna:**
- **BA81/KV1:** v58 `sell > min_sell + 0,20 → bc_pv = 0` drží denní SoC ~60 % při slunci — konflikt s `R__063` vrstvou A.
- **home-01:** v33 binární pre-neg cushion exportuje FVE před `sell < 0` i při středním sell; při kratším zimním okně `sell < 0` / slabší FVE chybí nabíjení **před** oknem.
1. **`R__063_fn_load_planning_slots_full.sql`:** nové sloupce `charge_target_wh`, `pre_window_wh`, `in_window_wh`, `charge_slot_wh`, `charge_cum_wh`, `charge_layer`, `charge_slot_reason`; PV vrstva A u **fixed** řazena **`sell ASC`** + Wh kumulace (spot dál `store_score DESC`).
2. **`planning_engine.py`:** odstraněn v58 (`fixed_high_sell_no_pv_charge`, `fixed_grid_charge_unprofitable`); LP respektuje jen `allow_charge` / `allow_grid_charge` ze SQL. Večerní push u fixního tarifu: **`sell > buy + spread`** (`fixed_evening_push_sell_above_buy`); KV1 zachovává v52 morning-peak pravidlo.
3. **`solver_params.charge_slot_budget`** — audit rozpočtu na aktivním runu.
**Záměr:**
Tag **`2026-06-06-charge-slot-budget-v1`**.
1. **`fn_load_planning_slots_full`:** `charge_target_wh`, `pre_window_wh` (deficit forecast v neg okně), fronta slotů řazená **`sell ASC`** (fixed) / **`buy ASC`** (spot), kumulace `pv_surplus` Wh → `allow_charge`.
2. **Python:** zrušit v58 (a související fixed `bc_pv`/`bc_gi` prahy); pre-neg export jen v pre-neg slotech **bez** `allow_charge`.
3. **Neg den:** změkčit v44 grid před `sell < 0`, pokud `pre_window_wh` > dostupná FVE v okně.
**Ověření:**
**Specifikace:** [`docs/04-modules/planning-charge-slot-budget.md`](04-modules/planning-charge-slot-budget.md).
```bash
pytest backend/tests/test_planning_charge_slot_selection.py backend/tests/test_planning_dispatch_milp.py \
-k "fixed_high_sell or fixed_tariff_evening or fixed_evening or kv1_evening" -q
```
**Plánovaný tag:** `…-charge-slot-budget-v1` (po implementaci).
MCP: `planning_run.solver_params->'charge_slot_budget'`; u BA81 večer `evening_push_ts` neprázdné při `sell > buy`.
**Zbývá (Branch 45 spec):** změkčení v44 grid před neg; plná náhrada v33 cushion přes `pre_window_wh` frontu u home-01.
---
## Plánováno — charge-slot-budget home-01 (pre-neg fronta, v44)
**Stav:** Branch 3 hotový pro fixed; spot pre-neg fronta a v44 změkčení — viz [`planning-charge-slot-budget.md`](04-modules/planning-charge-slot-budget.md) §6.
---