dalsi
This commit is contained in:
@@ -71,7 +71,9 @@ 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-05-29-evening-push-budget-rank-v42"
|
||||
PLANNER_BUILD_TAG = "2026-05-29-night-selfconsume-evening-arb-v43"
|
||||
# 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).
|
||||
NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0
|
||||
# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat).
|
||||
@@ -1565,6 +1567,11 @@ def _in_night_battery_export_window(slot: PlanningSlot) -> bool:
|
||||
return h <= NIGHT_EXPORT_MORNING_END_HOUR
|
||||
|
||||
|
||||
def _in_evening_push_hour_window(slot: PlanningSlot) -> bool:
|
||||
"""Tvrdý večerní push jen ≥17h Prague — ne noční vývoz ve 02–06h (sell < buy)."""
|
||||
return _prague_hour(slot) >= NIGHT_EXPORT_EVENING_START_HOUR
|
||||
|
||||
|
||||
def _night_export_window_segments(slots: list[PlanningSlot]) -> list[list[int]]:
|
||||
"""Souvislé úseky nočního okna v horizontu (oddělené denní pauzou / východem FVE)."""
|
||||
segments: list[list[int]] = []
|
||||
@@ -1648,12 +1655,17 @@ def _evening_push_segment_candidates(
|
||||
*,
|
||||
charge_acquisition_czk_kwh: float,
|
||||
min_spread: float,
|
||||
discharge_export_ok: set[int] | None = None,
|
||||
) -> 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:
|
||||
return []
|
||||
out: list[int] = []
|
||||
for t in seg:
|
||||
if discharge_export_ok is not None and t not in discharge_export_ok:
|
||||
continue
|
||||
if not _in_evening_push_hour_window(slots[t]):
|
||||
continue
|
||||
if not _slot_evening_push_profitable(
|
||||
slots[t],
|
||||
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
|
||||
@@ -1664,6 +1676,44 @@ def _evening_push_segment_candidates(
|
||||
return out
|
||||
|
||||
|
||||
def _evening_push_calendar_segments(
|
||||
slots: list[PlanningSlot],
|
||||
discharge_export_ok: set[int] | None = None,
|
||||
) -> list[list[int]]:
|
||||
"""Kalendářní večery (≥17h) v nočním okně — každý den vlastní push rozpočet."""
|
||||
by_date: dict[object, list[int]] = {}
|
||||
for t, s in enumerate(slots):
|
||||
if not _in_evening_push_hour_window(s):
|
||||
continue
|
||||
if not _in_night_battery_export_window(s):
|
||||
continue
|
||||
if discharge_export_ok is not None and t not in discharge_export_ok:
|
||||
continue
|
||||
by_date.setdefault(_prague_calendar_date(s), []).append(t)
|
||||
return [sorted(v) for v in by_date.values() if v]
|
||||
|
||||
|
||||
def _night_self_consume_discourage_import_indices(
|
||||
slots: list[PlanningSlot],
|
||||
evening_early_export_penalty_ts: set[int],
|
||||
*,
|
||||
charge_acquisition_czk_kwh: float,
|
||||
min_spread: float,
|
||||
) -> set[int]:
|
||||
"""
|
||||
Sloty mimo evening_push, kde import pro dům nahrazuje levnou zásobu z baterie.
|
||||
"""
|
||||
out: set[int] = set()
|
||||
for t in evening_early_export_penalty_ts:
|
||||
buy_t = float(slots[t].buy_price)
|
||||
if buy_t <= float(charge_acquisition_czk_kwh) + float(min_spread):
|
||||
continue
|
||||
if float(slots[t].load_baseline_w) <= 0:
|
||||
continue
|
||||
out.add(t)
|
||||
return out
|
||||
|
||||
|
||||
def _evening_battery_export_push_indices(
|
||||
slots: list[PlanningSlot],
|
||||
*,
|
||||
@@ -1674,12 +1724,13 @@ def _evening_battery_export_push_indices(
|
||||
soc_max_wh: float,
|
||||
per_slot_discharge_wh: float,
|
||||
discharge_slot_buffer: float,
|
||||
discharge_export_ok: set[int] | None = None,
|
||||
evening_start_hour: int = 17,
|
||||
) -> list[int]:
|
||||
"""
|
||||
Noční push: plný ge_bat v tolika nejdražších slotách (sell desc v rámci úseku),
|
||||
kolik unese Wh rozpočet — ne jen jeden slot s exact max sell (v41).
|
||||
per_slot_discharge_wh: volající předá min(BMS, export cap) × účinnost × 0,25 h.
|
||||
Večerní push (≥17h): plný ge_bat v nejdražších slotách (sell desc), rozpočet Wh
|
||||
**per kalendářní večer** — druhý den v horizontu nedostane nulový push (v42 bug).
|
||||
per_slot_discharge_wh: min(BMS, export cap) × účinnost × 0,25 h.
|
||||
"""
|
||||
_ = evening_start_hour # kompatibilita volání
|
||||
if per_slot_discharge_wh <= 0.0:
|
||||
@@ -1692,14 +1743,21 @@ def _evening_battery_export_push_indices(
|
||||
)
|
||||
if push_budget_wh < per_slot_discharge_wh * 0.5:
|
||||
return []
|
||||
evening_segments = _evening_push_calendar_segments(
|
||||
slots,
|
||||
discharge_export_ok=discharge_export_ok,
|
||||
)
|
||||
if not evening_segments:
|
||||
return []
|
||||
seg_budget_wh = push_budget_wh / float(len(evening_segments))
|
||||
out: list[int] = []
|
||||
remaining_wh = float(push_budget_wh)
|
||||
for seg in _night_export_window_segments(slots):
|
||||
for seg in evening_segments:
|
||||
candidates = _evening_push_segment_candidates(
|
||||
slots,
|
||||
seg,
|
||||
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
|
||||
min_spread=degrad_czk_kwh,
|
||||
discharge_export_ok=discharge_export_ok,
|
||||
)
|
||||
if not candidates:
|
||||
continue
|
||||
@@ -1708,6 +1766,7 @@ def _evening_battery_export_push_indices(
|
||||
key=lambda i: (float(slots[i].sell_price), -i),
|
||||
reverse=True,
|
||||
)
|
||||
remaining_wh = float(seg_budget_wh)
|
||||
for t in ranked:
|
||||
if remaining_wh + 1e-6 < per_slot_discharge_wh:
|
||||
break
|
||||
@@ -2400,6 +2459,7 @@ def solve_dispatch(
|
||||
profitable_export_ts_pre.add(_t)
|
||||
evening_push_ts: set[int] = set()
|
||||
evening_early_export_penalty_ts: set[int] = set()
|
||||
night_self_consume_discourage_ts: set[int] = set()
|
||||
evening_push_hysteresis_retained = False
|
||||
if om == "AUTO":
|
||||
per_slot_discharge_wh_pre = max(
|
||||
@@ -2424,6 +2484,7 @@ def solve_dispatch(
|
||||
soc_max_wh=float(battery.soc_max_wh),
|
||||
per_slot_discharge_wh=per_slot_push_wh_pre,
|
||||
discharge_slot_buffer=discharge_buf_pre,
|
||||
discharge_export_ok=discharge_export_slots,
|
||||
)
|
||||
)
|
||||
if evening_push_ts_override is not None:
|
||||
@@ -2453,6 +2514,12 @@ def solve_dispatch(
|
||||
evening_push_ts=evening_push_ts,
|
||||
exempt_ts=evening_export_exempt_ts,
|
||||
)
|
||||
night_self_consume_discourage_ts = _night_self_consume_discourage_import_indices(
|
||||
slots,
|
||||
evening_early_export_penalty_ts,
|
||||
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
|
||||
min_spread=float(degradation_cost_effective),
|
||||
)
|
||||
pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh(
|
||||
slots,
|
||||
first_neg_buy_idx=first_neg_buy_idx,
|
||||
@@ -2842,6 +2909,20 @@ def solve_dispatch(
|
||||
)
|
||||
else 0
|
||||
)
|
||||
+ (
|
||||
gi[t]
|
||||
* max(
|
||||
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH,
|
||||
max(
|
||||
0.0,
|
||||
float(slots[t].buy_price) - charge_acquisition_czk_kwh,
|
||||
),
|
||||
)
|
||||
* INTERVAL_H
|
||||
/ 1000
|
||||
if om == "AUTO" and t in night_self_consume_discourage_ts
|
||||
else 0
|
||||
)
|
||||
+ pulp.lpSum(
|
||||
ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000
|
||||
+ ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000
|
||||
@@ -3946,6 +4027,9 @@ def solve_dispatch(
|
||||
"evening_early_export_ban": (
|
||||
t in evening_early_export_penalty_ts if om == "AUTO" else None
|
||||
),
|
||||
"night_self_consume_discourage_import": (
|
||||
t in night_self_consume_discourage_ts if om == "AUTO" else None
|
||||
),
|
||||
}
|
||||
)
|
||||
tgt_s = st.safety_soc_target_wh if daytime_en else None
|
||||
@@ -4114,6 +4198,10 @@ def solve_dispatch(
|
||||
else _evening_night_peak_sell_czk(slots)
|
||||
),
|
||||
"evening_push_hysteresis_retained": bool(evening_push_hysteresis_retained),
|
||||
"night_self_consume_discourage_ts": [
|
||||
slots[i].interval_start.isoformat()
|
||||
for i in sorted(night_self_consume_discourage_ts)
|
||||
],
|
||||
},
|
||||
"masks": masks_snap,
|
||||
"soc_bounds": soc_bounds_snap,
|
||||
|
||||
@@ -15,7 +15,9 @@ from services.planning_engine import (
|
||||
_dispatch_result_comparison,
|
||||
_evening_battery_export_push_indices,
|
||||
_evening_peak_export_indices,
|
||||
_evening_push_calendar_segments,
|
||||
_evening_push_discharge_budget_wh,
|
||||
_in_evening_push_hour_window,
|
||||
_in_night_battery_export_window,
|
||||
_neg_sell_day_phases,
|
||||
_neg_sell_phases_enabled,
|
||||
@@ -242,9 +244,85 @@ class EveningPushBudgetTests(unittest.TestCase):
|
||||
soc_max_wh=bat.soc_max_wh,
|
||||
per_slot_discharge_wh=per_slot,
|
||||
discharge_slot_buffer=1.5,
|
||||
discharge_export_ok={0, 1, 2},
|
||||
)
|
||||
self.assertIn(2, push, "00:00 max sell musí být v push")
|
||||
self.assertEqual(max(float(slots[t].sell_price) for t in push), 3.586)
|
||||
self.assertNotIn(2, push, "v43: push jen ≥17h, ne půlnoc")
|
||||
self.assertIn(1, push, "23:30 večerní push")
|
||||
self.assertEqual(max(float(slots[t].sell_price) for t in push), 3.323)
|
||||
|
||||
def test_no_predawn_push_before_17h(self) -> None:
|
||||
"""v43: žádný tvrdý push ve 02–06h (sell < buy v dead of night)."""
|
||||
prague = ZoneInfo("Europe/Prague")
|
||||
base = datetime(2026, 5, 26, 2, 0, tzinfo=prague)
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=4.8,
|
||||
sell_price=2.9 - 0.01 * i,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=500,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_discharge_export=True,
|
||||
charge_acquisition_buy_czk_kwh=0.5,
|
||||
)
|
||||
for i in range(8)
|
||||
]
|
||||
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
|
||||
per_slot = 13_500 * 0.95 * 0.25
|
||||
push = _evening_battery_export_push_indices(
|
||||
slots,
|
||||
charge_acquisition_czk_kwh=0.5,
|
||||
degrad_czk_kwh=0.15,
|
||||
current_soc_wh=0.85 * bat.soc_max_wh,
|
||||
min_soc_wh=bat.min_soc_wh,
|
||||
soc_max_wh=bat.soc_max_wh,
|
||||
per_slot_discharge_wh=per_slot,
|
||||
discharge_slot_buffer=1.5,
|
||||
discharge_export_ok=set(range(8)),
|
||||
)
|
||||
self.assertEqual(push, [])
|
||||
|
||||
def test_per_calendar_evening_push_budget_split(self) -> None:
|
||||
"""Dva večery v horizontu → každý dostane část Wh rozpočtu (druhý den ne prázdný)."""
|
||||
prague = ZoneInfo("Europe/Prague")
|
||||
slots: list[PlanningSlot] = []
|
||||
for day in (25, 26):
|
||||
for h, m in ((18, 0), (18, 15), (18, 30)):
|
||||
slots.append(
|
||||
PlanningSlot(
|
||||
interval_start=datetime(2026, 5, day, h, m, tzinfo=prague),
|
||||
buy_price=5.0,
|
||||
sell_price=4.0 + 0.1 * (h - 18),
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=800,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_discharge_export=True,
|
||||
charge_acquisition_buy_czk_kwh=0.5,
|
||||
)
|
||||
)
|
||||
segs = _evening_push_calendar_segments(slots, discharge_export_ok=set(range(len(slots))))
|
||||
self.assertEqual(len(segs), 2)
|
||||
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
|
||||
per_slot = 13_500 * 0.95 * 0.25
|
||||
push = _evening_battery_export_push_indices(
|
||||
slots,
|
||||
charge_acquisition_czk_kwh=0.5,
|
||||
degrad_czk_kwh=0.15,
|
||||
current_soc_wh=0.9 * bat.soc_max_wh,
|
||||
min_soc_wh=bat.min_soc_wh,
|
||||
soc_max_wh=bat.soc_max_wh,
|
||||
per_slot_discharge_wh=per_slot,
|
||||
discharge_slot_buffer=1.5,
|
||||
discharge_export_ok=set(range(len(slots))),
|
||||
)
|
||||
day25 = {t for t in push if slots[t].interval_start.day == 25}
|
||||
day26 = {t for t in push if slots[t].interval_start.day == 26}
|
||||
self.assertGreaterEqual(len(day25), 1)
|
||||
self.assertGreaterEqual(len(day26), 1)
|
||||
|
||||
def test_evening_push_budget_matches_r063_formula(self) -> None:
|
||||
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
|
||||
@@ -2394,7 +2472,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_midnight_higher_sell_gets_battery_export(self) -> None:
|
||||
"""home-01 archetyp: export v 00:00 (vyšší sell), ne jen 23:30."""
|
||||
"""v43: push jen ≥17h — export v 23:30, ne predawn půlnoc."""
|
||||
prague = ZoneInfo("Europe/Prague")
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
@@ -2457,9 +2535,10 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG)
|
||||
r_midnight = results[2]
|
||||
self.assertEqual(r_midnight.export_mode, "BATTERY_SELL")
|
||||
self.assertGreaterEqual(abs(r_midnight.grid_setpoint_w), 12_500)
|
||||
r_evening = results[0]
|
||||
self.assertEqual(r_evening.export_mode, "BATTERY_SELL")
|
||||
self.assertGreaterEqual(abs(r_evening.grid_setpoint_w), 12_500)
|
||||
self.assertNotEqual(results[2].export_mode, "BATTERY_SELL")
|
||||
|
||||
def test_evening_push_export_near_site_cap_home01(self) -> None:
|
||||
"""home-01 večer: export ≈ min(13.5 kW, 18 kW − load), ne (max−load)/2."""
|
||||
@@ -2716,6 +2795,60 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
|
||||
self.assertLessEqual(len(push), 4)
|
||||
self.assertEqual(push, [0, 1, 2, 3][: len(push)])
|
||||
|
||||
def test_night_self_consume_prefers_battery_over_grid(self) -> None:
|
||||
"""v43: mezi push sloty baterie krmí dům místo importu za ~5 Kč."""
|
||||
prague = ZoneInfo("Europe/Prague")
|
||||
sells = [3.9, 3.8, 3.1, 3.0]
|
||||
base = datetime(2026, 5, 29, 20, 0, tzinfo=prague)
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=5.0,
|
||||
sell_price=sells[i],
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=2000,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_discharge_export=True,
|
||||
charge_acquisition_buy_czk_kwh=0.7,
|
||||
)
|
||||
for i in range(4)
|
||||
]
|
||||
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
|
||||
battery.max_discharge_power_w = 18_000
|
||||
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=13_500)
|
||||
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),
|
||||
]
|
||||
results, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
0.75 * battery.soc_max_wh,
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
push_iso = set(snap["inputs"].get("evening_push_ts") or [])
|
||||
for i in (2, 3):
|
||||
iso = slots[i].interval_start.isoformat()
|
||||
if iso not in push_iso:
|
||||
self.assertLessEqual(
|
||||
results[i].battery_setpoint_w,
|
||||
-1500,
|
||||
msg=f"slot {i} mimo push: vlastní spotřeba z baterie",
|
||||
)
|
||||
self.assertLessEqual(
|
||||
results[i].grid_setpoint_w,
|
||||
500,
|
||||
msg=f"slot {i} mimo push: ne import pro dům",
|
||||
)
|
||||
|
||||
def test_no_pv_export_at_low_sell_when_evening_peak_much_higher(self) -> None:
|
||||
"""Odpolední sell ~1,4 a večer ~5,5 — PV do baterie, ne FVE→síť za haléř."""
|
||||
base = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
@@ -874,6 +874,37 @@ begin
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
-- v43: levný grid před prvním sell<0, když tentýž den večer (≥17h) dává arbitráž buy→sell.
|
||||
update _ems_plan_slot_wk wk
|
||||
set allow_charge = true,
|
||||
allow_grid_charge = true,
|
||||
grid_charge_suppressed_reason = coalesce(
|
||||
wk.grid_charge_suppressed_reason,
|
||||
'evening_arbitrage_unlock'
|
||||
)
|
||||
from (
|
||||
select
|
||||
(wk2.interval_start at time zone 'Europe/Prague')::date as plan_date,
|
||||
max(wk2.sell_price) filter (
|
||||
where extract(hour from wk2.interval_start at time zone 'Europe/Prague')
|
||||
>= v_evening_peak_start_hour
|
||||
and wk2.sell_price > 0
|
||||
) as evening_peak_sell
|
||||
from _ems_plan_slot_wk wk2
|
||||
group by 1
|
||||
) ep
|
||||
where (wk.interval_start at time zone 'Europe/Prague')::date = ep.plan_date
|
||||
and ep.evening_peak_sell is not null
|
||||
and ep.evening_peak_sell > v_degrad_czk_kwh + 0.05
|
||||
and wk.buy_price >= 0
|
||||
and wk.buy_price + v_degrad_czk_kwh < ep.evening_peak_sell
|
||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague')
|
||||
< v_evening_peak_start_hour
|
||||
and (
|
||||
v_first_neg_sell_ord is null
|
||||
or wk.slot_ord < v_first_neg_sell_ord
|
||||
);
|
||||
|
||||
-- Ranní pásmo před prvním sell<0 (5–11 Prague): lokální peak, ne půlnoc celého dne.
|
||||
if v_first_neg_sell_ord is not null
|
||||
and v_first_neg_prague_date is not null
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- **SoC kontinuita a export z baterie:** `soc[t]` klesá při **`bd[t]`** — výkon vybíjení na AC sběrnici z energetické bilance `pv + gi + bd = load + bc + ge`. Při exportu z baterie je v `bd` už započten i tok do sítě (`ge_bat` je součást `ge`); **`ge_bat` se v SoC znovu neodečítá** (dříve double-count → plán klesal ~2× rychleji než BMS ve večerním exportu). Tag `2026-05-28-evening-export-soc-balance-v39`.
|
||||
- **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).
|
||||
- **Grid ze sítě (vrstva B, před FVE):** výchozí **AM/PM 50/50** z `grid_target × charge_slot_buffer` (do `soc_max`); **nevyčerpaný AM Wh přejde do PM** (`R__063`). **Spot:** výběr **nejlevnější `buy`** (den plánu → před exportním oknem → `buy ASC`); navíc všechny sloty s **`buy < 0`** → `allow_grid_charge`. Po výběru AM/PM běží **iterativní self-konzistentní filtr** (vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 %` deficitu SoC; failsafe unlock). Debug: `grid_charge_suppressed_reason`. **Fixní tarif (BA81):** stejný AM/PM rozpočet, ale pořadí podle **`slot_ord`** (buy konstantní), jen pokud v horizontu existuje **`sell > buy + degradation`**; jinak jen PV vrstva A. Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`.
|
||||
- **Grid ze sítě (vrstva B, před FVE):** výchozí **AM/PM 50/50** z `grid_target × charge_slot_buffer` (do `soc_max`); **nevyčerpaný AM Wh přejde do PM** (`R__063`). **Spot:** výběr **nejlevnější `buy`** (den plánu → před exportním oknem → `buy ASC`); navíc všechny sloty s **`buy < 0`** → `allow_grid_charge`. Po výběru AM/PM běží **iterativní self-konzistentní filtr** (vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 %` deficitu SoC; failsafe unlock). **v43 `evening_arbitrage_unlock`:** před prvním sell<0 povolí grid nabíjení, když tentýž den večer (≥17h) `buy + degrad < evening_peak_sell`. Debug: `grid_charge_suppressed_reason`. **Fixní tarif (BA81):** stejný AM/PM rozpočet, ale pořadí podle **`slot_ord`** (buy konstantní), jen pokud v horizontu existuje **`sell > buy + degradation`**; jinak jen PV vrstva A. Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`.
|
||||
- **PV vrstva A:** při `sell ≥ 0` jen pokud `sell ≥ future_sell_opportunity − degradation` (držet FVE na večerní peak). Při **`sell < 0`** vrstva A **bez** tohoto filtru (nabít z FVE v záporném výkupním okně). Historie: [`docs/planning-changelog.md`](../planning-changelog.md).
|
||||
- **LP (AUTO):** objective explicitně `−ge_pv×sell − ge_bat×sell + ge_bat×acquisition` v exportních slotech; **bez** cross-slot vynucení `ge_pv ≥ surplus`. Guard FVE: `ge_pv=0` jen pokud `sell < charge_acquisition − degrad` (ne `sell < buy` ve slotu). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
|
||||
- **Load-first (Deye, AUTO, tvrdý od v34):** proměnné `pv_ld` (PV → load+EV+TČ), `pv_sp` (přebytek), `bc_pv` / `bc_gi`. Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`; `bc_pv + ge_pv ≤ pv_sp`; **`gi ≤ bc_gi + max(0, max_load − pv_forecast)`** (při vysoké FVE žádný fiktivní import = load); při `pv ≥ load + 500 W` **`pv_ld ≥ load`**; mimo `allow_discharge_export`: `bd ≤ load − pv_ld`, `pv_ld ≥ load − bd`. Tag `2026-05-28-load-first-hard-v34`. Test `LoadFirstDispatchTests`.
|
||||
@@ -97,24 +97,13 @@ flowchart TD
|
||||
- v **celém nočním okně** pro **všechny** sloty s `allow_discharge_export` **mimo** `evening_push_ts` (výjimky: pre-neg / neg-evening větve);
|
||||
- **nezakazuje** přebytek FVE do sítě (`ge_pv`).
|
||||
|
||||
3. **v42 — plný výkon v top push slotech dle Wh rozpočtu** (`evening_push_ts`):
|
||||
- kandidáti: profitable ∩ noční okno (**všechny** sloty s `sell > acq+spread`, ne jen exact max sell — oprava v41);
|
||||
- push = nejdražší kandidáti **`sell` desc**, dokud `kumulované_Wh ≤ push_budget` (globálně přes noční úseky); typicky **~11–14 slotů** při plné baterii home-01;
|
||||
- **`peak_export_shortfall`** se v nočním okně neaplikuje (jinak LP rozplizňoval export do levnějších sousedních slotů);
|
||||
- **v28 push fyzika:** cap `ge_bat ≈ min(export_cap, max_discharge − load)`;
|
||||
- **výsledek:** plný export (~13,5 kW u home-01) v nejdražších push slotech; **levnější sloty mimo push neprodávají** (`export_mode=NONE`).
|
||||
3. **v43 — večerní push + nocí vlastní spotřeba + odpolední arbitráž** (`evening_push_ts`):
|
||||
- push jen **≥17h Prague** + `allow_discharge_export`; rozpočet Wh **per kalendářní večer** (druhý den v horizontu ne prázdný);
|
||||
- mimo push: **`night_self_consume_discourage`** — baterie krmí dům, ne import ~5 Kč/kWh;
|
||||
- **R__063 `evening_arbitrage_unlock`:** grid nabíjení odpoledne před sell<0, když večerní peak sell > buy + degrad;
|
||||
- **bez predawn push** (02–06h); **`peak_export_shortfall`** v noci vypnutý.
|
||||
|
||||
**Není to** „prodávat v každém lehce ziskovém večerním slotu“ — je to „prodávat **plným výkonem** jen v **nejdražších** nočních slotech, kolik unese baterie (Wh rozpočet)“.
|
||||
|
||||
#### Co v26 opravilo oproti starému chování
|
||||
|
||||
| Dříve (problém) | Po v26 |
|
||||
|-----------------|--------|
|
||||
| Push kandidáti = široké pásmo `max − degrad` (~15 haléřů) → vývoz i v 17:30 za 3,5 Kč | Push jen u `max − 0,05` Kč/kWh |
|
||||
| Měkká `peak_export_shortfall` → často ~50 % výkonu v mnoha slotech | Na `evening_push` slotech tvrdý push na cap; shortfall na push vypnutý |
|
||||
| `grid_setpoint = gi − ge` → Deye vidí ~0 W při velkém `ge_bat` | `_dispatch_grid_setpoint_w` z reálného exportu |
|
||||
|
||||
**Funkce:** `_evening_push_segment_candidates`, `_evening_battery_export_push_indices`, `_evening_early_export_penalty_indices`, `_rolling_evening_push_override`, `_evening_push_discharge_budget_wh`, `_evening_push_battery_export_w`, `_dispatch_grid_setpoint_w` v `planning_engine.py`. Tag: **`2026-05-29-evening-push-budget-rank-v42`** (v41: exact max sell; dříve v38).
|
||||
**Funkce:** `_evening_push_calendar_segments`, `_night_self_consume_discourage_import_indices`, `_in_evening_push_hour_window`, … Tag: **`2026-05-29-night-selfconsume-evening-arb-v43`**.
|
||||
|
||||
### Arbitráž baterie — účtování mezi sloty (povinné čtení)
|
||||
|
||||
|
||||
@@ -5,6 +5,23 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-29 — Noc: vlastní spotřeba + večerní arbitráž + push per den (v43)
|
||||
|
||||
**Problém:** (1) Po v42 push exportu plán přes noc **držel SoC ~60 %** a krmil dům ze sítě za **~5 Kč/kWh** místo baterie (acq ~0,7 Kč). (2) Tvrdý push zahrnoval **02–06h** (sell < buy). (3) **Druhý večer** v horizontu neměl push — rozpočet Wh se vyčerpal první nocí. (4) Před neg dnem **grid 0,5 Kč** odpoledne nešel nabíjet (`allow_charge=false`, cheaper_pv_ahead), přitom večer sell **~4 Kč** — arbitráž neproběhla.
|
||||
|
||||
**Změna (v43):**
|
||||
- **`night_self_consume_discourage_ts`:** mimo `evening_push` penalizace importu pro dům (`gi × surcharge`), LP preferuje `bd` pro load.
|
||||
- **Push jen ≥17h Prague** (`_in_evening_push_hour_window`); ne predawn 02–06h.
|
||||
- **Push rozpočet per kalendářní večer** (`_evening_push_calendar_segments`), ne globální greedy přes celou noc.
|
||||
- **Push kandidáti** jen `allow_discharge_export` (SQL maska).
|
||||
- **R__063 `evening_arbitrage_unlock`:** před prvním sell<0 povolit grid nabíjení, když tentýž den večer (≥17h) `buy + degrad < evening_peak_sell`.
|
||||
|
||||
**Soubory:** `backend/services/planning_engine.py`, `db/routines/R__063_fn_load_planning_slots_full.sql`, `backend/tests/test_planning_dispatch_milp.py`, `docs/04-modules/planning.md`. Tag **`2026-05-29-night-selfconsume-evening-arb-v43`**.
|
||||
|
||||
**Ověření:** `pytest … -k "evening or night_self or predawn or per_calendar"`; MCP: `night_self_consume_discourage_ts`, druhý den v `evening_push_ts`; odpoledne `allow_charge=true` + `grid_charge_suppressed_reason=evening_arbitrage_unlock`; mezi push sloty `battery_setpoint_w < 0`, `grid_setpoint_w ≈ 0`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-29 — Večerní push: rozpočet Wh × sell desc (v42)
|
||||
|
||||
**Problém:** v41 bral push kandidáty jen jako sloty s **`sell = max`** v nočním úseku → při ~48 kWh rozpočtu často **jediný** push slot (~13,5 kW), zbytek energie „visel“ v baterii; levnější profitable sloty byly zákázané (`evening_early`), ale dražší sousední sloty pod maximem se nevyužily.
|
||||
|
||||
Reference in New Issue
Block a user