dalsi
This commit is contained in:
@@ -668,25 +668,85 @@ def _prague_calendar_date(slot: PlanningSlot):
|
||||
return dt.astimezone(ZoneInfo("Europe/Prague")).date()
|
||||
|
||||
|
||||
MORNING_PRENEG_START_HOUR = 5
|
||||
MORNING_PRENEG_END_HOUR = 11
|
||||
NEGATIVE_BUY_GRID_CHARGE_MIN_W = 8_000.0
|
||||
PRENEG_MORNING_EXPORT_MIN_W = 5_000.0
|
||||
|
||||
|
||||
def _prague_hour(slot: PlanningSlot) -> int:
|
||||
dt = slot.interval_start
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(ZoneInfo("Europe/Prague")).hour
|
||||
|
||||
|
||||
def _morning_pre_neg_zone_peak_sell(
|
||||
slots: list[PlanningSlot],
|
||||
first_neg_sell_idx: int | None,
|
||||
) -> float | None:
|
||||
"""Max kladný sell v pásmu 5–11 Prague před prvním sell<0 (shodně s R__063)."""
|
||||
if first_neg_sell_idx is None or first_neg_sell_idx <= 0:
|
||||
return None
|
||||
neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
|
||||
sells = [
|
||||
float(slots[i].sell_price)
|
||||
for i in range(first_neg_sell_idx)
|
||||
if float(slots[i].sell_price) >= 0.0
|
||||
and _prague_calendar_date(slots[i]) == neg_day
|
||||
and MORNING_PRENEG_START_HOUR <= _prague_hour(slots[i]) <= MORNING_PRENEG_END_HOUR
|
||||
]
|
||||
if not sells:
|
||||
return None
|
||||
return max(sells)
|
||||
|
||||
|
||||
def _pre_neg_peak_sell_idx(
|
||||
slots: list[PlanningSlot],
|
||||
first_neg_sell_idx: int | None,
|
||||
) -> int | None:
|
||||
"""Nejvyšší kladný sell před prvním sell<0 ve stejném kalendářním dni (Prague)."""
|
||||
"""Nejvyšší kladný sell v ranním pásmu před prvním sell<0 (ne půlnoc celého dne)."""
|
||||
if first_neg_sell_idx is None or first_neg_sell_idx <= 0:
|
||||
return None
|
||||
zone_peak = _morning_pre_neg_zone_peak_sell(slots, first_neg_sell_idx)
|
||||
if zone_peak is None:
|
||||
return None
|
||||
neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
|
||||
positive = [
|
||||
(i, float(slots[i].sell_price))
|
||||
for i in range(first_neg_sell_idx)
|
||||
if float(slots[i].sell_price) >= 0.0
|
||||
and _prague_calendar_date(slots[i]) == neg_day
|
||||
and MORNING_PRENEG_START_HOUR <= _prague_hour(slots[i]) <= MORNING_PRENEG_END_HOUR
|
||||
]
|
||||
if not positive:
|
||||
return None
|
||||
return max(positive, key=lambda x: (x[1], x[0]))[0]
|
||||
|
||||
|
||||
def _morning_pre_neg_export_indices(
|
||||
slots: list[PlanningSlot],
|
||||
first_neg_sell_idx: int | None,
|
||||
*,
|
||||
degrad_czk_kwh: float,
|
||||
) -> list[int]:
|
||||
"""Všechny ranní peak sloty (sell ≥ zónový max − degrad) před prvním sell<0."""
|
||||
zone_peak = _morning_pre_neg_zone_peak_sell(slots, first_neg_sell_idx)
|
||||
if zone_peak is None or first_neg_sell_idx is None or first_neg_sell_idx <= 0:
|
||||
return []
|
||||
neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
|
||||
out: list[int] = []
|
||||
for i in range(first_neg_sell_idx):
|
||||
if (
|
||||
float(slots[i].sell_price) >= zone_peak - degrad_czk_kwh
|
||||
and float(slots[i].sell_price) >= 0.0
|
||||
and _prague_calendar_date(slots[i]) == neg_day
|
||||
and MORNING_PRENEG_START_HOUR <= _prague_hour(slots[i]) <= MORNING_PRENEG_END_HOUR
|
||||
):
|
||||
out.append(i)
|
||||
return out
|
||||
|
||||
|
||||
def _pv_forced_vent_export_allowed(
|
||||
t: int,
|
||||
*,
|
||||
@@ -972,8 +1032,20 @@ def solve_dispatch(
|
||||
# Slack penalizujeme v objective; samotné omezení přidáme až po definici soc.
|
||||
first_neg_sell_idx, pre_neg_export_last_t = _pre_negative_sell_export_window(slots)
|
||||
t_pre_neg_peak = _pre_neg_peak_sell_idx(slots, first_neg_sell_idx)
|
||||
morning_pre_neg_export_ts = _morning_pre_neg_export_indices(
|
||||
slots,
|
||||
first_neg_sell_idx,
|
||||
degrad_czk_kwh=float(degradation_cost_effective),
|
||||
)
|
||||
if first_neg_sell_idx is not None and first_neg_sell_idx > 0 and floor_pct is not None:
|
||||
t_anchor = first_neg_sell_idx - 1
|
||||
# Kotva na ranním peaku (ne na posledním slotu před sell<0) — jinak dump až v 07:30.
|
||||
if (
|
||||
t_pre_neg_peak is not None
|
||||
and t_pre_neg_peak < first_neg_sell_idx - 1
|
||||
):
|
||||
t_anchor = t_pre_neg_peak
|
||||
else:
|
||||
t_anchor = first_neg_sell_idx - 1
|
||||
soc_anchor_slack = pulp.LpVariable("soc_anchor_slack_wh", 0, float(battery.usable_capacity_wh))
|
||||
|
||||
daytime_en = bool(getattr(battery, "planner_daytime_charge_target_enabled", True))
|
||||
@@ -1100,12 +1172,16 @@ def solve_dispatch(
|
||||
# --- Omezení ---
|
||||
for _t, sf, cap_w in peak_export_shortfall:
|
||||
prob += sf >= cap_w - ge[_t]
|
||||
if (
|
||||
om == "AUTO"
|
||||
and t_pre_neg_peak is not None
|
||||
and t_pre_neg_peak in discharge_export_slots
|
||||
):
|
||||
prob += ge_bat[t_pre_neg_peak] >= 5000.0 * z_export[t_pre_neg_peak]
|
||||
preneg_export_min_soc_wh = float(min_soc_wh) + max(
|
||||
float(battery.max_discharge_power_w)
|
||||
* float(battery.discharge_efficiency)
|
||||
* INTERVAL_H,
|
||||
1000.0,
|
||||
)
|
||||
if om == "AUTO":
|
||||
for t_peak in morning_pre_neg_export_ts:
|
||||
if t_peak in discharge_export_slots:
|
||||
prob += ge_bat[t_peak] >= PRENEG_MORNING_EXPORT_MIN_W * z_export[t_peak]
|
||||
if t_anchor is not None and soc_anchor_slack is not None:
|
||||
target_floor_wh = float(planner_floor_effective_wh)
|
||||
prob += soc[t_anchor] <= target_floor_wh + soc_anchor_slack
|
||||
@@ -1173,12 +1249,12 @@ def solve_dispatch(
|
||||
# Měkký breaker cap: gi_over[t] >= max(0, gi[t] - breaker).
|
||||
prob += gi_over[t] >= gi[t] - float(grid.max_import_power_w)
|
||||
|
||||
# SoC kontinuita
|
||||
# SoC kontinuita (bd do domu i ge_bat do sítě vybíjí baterii)
|
||||
soc_prev = current_soc_wh if t == 0 else soc[t - 1]
|
||||
prob += soc[t] == (
|
||||
soc_prev
|
||||
+ (bc_pv[t] + bc_gi[t]) * battery.charge_efficiency * INTERVAL_H
|
||||
- bd[t] / battery.discharge_efficiency * INTERVAL_H
|
||||
- (bd[t] + ge_bat[t]) / battery.discharge_efficiency * INTERVAL_H
|
||||
)
|
||||
|
||||
sv = safety_vars[t]
|
||||
@@ -1306,7 +1382,20 @@ def solve_dispatch(
|
||||
prob += ge_bat[t] >= GE_MIN_EXPORT_W * z_export[t]
|
||||
# Bez hluboké relaxace: export končí ≥ rezerva. Při hluboké relaxaci (soc_panel_min pod min_soc)
|
||||
# sladit s LP spodkem — jinak z_export vynutil arb_base a blokoval vývoz k planner floor.
|
||||
if soc_panel_min[t] < min_soc_wh - 1e-3:
|
||||
if (
|
||||
om == "AUTO"
|
||||
and first_neg_sell_idx is not None
|
||||
and t < first_neg_sell_idx
|
||||
and floor_pct is not None
|
||||
):
|
||||
export_soc_floor_t = float(planner_floor_effective_wh)
|
||||
elif (
|
||||
om == "AUTO"
|
||||
and t in morning_pre_neg_export_ts
|
||||
and floor_pct is not None
|
||||
):
|
||||
export_soc_floor_t = float(planner_floor_effective_wh)
|
||||
elif soc_panel_min[t] < min_soc_wh - 1e-3:
|
||||
export_soc_floor_t = float(soc_panel_min[t])
|
||||
else:
|
||||
export_soc_floor_t = float(arb_base_wh)
|
||||
@@ -1375,6 +1464,29 @@ def solve_dispatch(
|
||||
prob += ge_bat[t] == 0
|
||||
prob += z_export[t] == 0
|
||||
|
||||
# Záporný buy: minimální grid import (spot arbitráž), jen pokud není baterie prakticky plná.
|
||||
for t in range(T):
|
||||
if float(slots[t].buy_price) >= 0.0:
|
||||
continue
|
||||
load_t = float(slots[t].load_baseline_w)
|
||||
min_gi = min(
|
||||
gi_upper,
|
||||
load_t + NEGATIVE_BUY_GRID_CHARGE_MIN_W,
|
||||
load_t + float(battery.max_charge_power_w) * 0.9,
|
||||
)
|
||||
if min_gi <= load_t + 500.0:
|
||||
continue
|
||||
if t == 0:
|
||||
if current_soc_wh >= float(battery.soc_max_wh) - soc_headroom_wh - 500.0:
|
||||
continue
|
||||
prob += gi[t] >= min_gi
|
||||
else:
|
||||
z_neg_chg = pulp.LpVariable(f"z_neg_chg_{t}", cat="Binary")
|
||||
prob += soc[t - 1] <= float(battery.soc_max_wh) - soc_headroom_wh - 500.0 + float(
|
||||
battery.usable_capacity_wh
|
||||
) * (1 - z_neg_chg)
|
||||
prob += gi[t] >= min_gi * z_neg_chg
|
||||
|
||||
# Ekonomické guardy: ceny v objective nestačí proti maskám / terminal SoC.
|
||||
# Referenční buy jen z ne-záporných slotů: jinak jeden buy<0 v horizontu označí
|
||||
# téměř všechny sloty jako „drahé“ (gi=0 pro dům) → Infeasible (home-01).
|
||||
|
||||
@@ -276,6 +276,22 @@ def _select_discharge_export_slots(
|
||||
]
|
||||
candidates.sort(key=lambda x: (-x[1], -x[0]))
|
||||
|
||||
first_neg = next(
|
||||
(i for i, s in enumerate(slots) if float(s.sell_price) < 0),
|
||||
None,
|
||||
)
|
||||
neg_day = _prague_date(slots[first_neg]) if first_neg is not None else None
|
||||
|
||||
candidates = [
|
||||
(t, sell)
|
||||
for t, sell in candidates
|
||||
if not (
|
||||
neg_day is not None
|
||||
and _prague_date(slots[t]) == neg_day
|
||||
and _prague_hour(slots[t]) < 5
|
||||
)
|
||||
]
|
||||
|
||||
selected: set[int] = set()
|
||||
cum = 0.0
|
||||
for t, _sell in candidates:
|
||||
@@ -284,31 +300,49 @@ def _select_discharge_export_slots(
|
||||
selected.add(t)
|
||||
cum += per_slot_wh
|
||||
|
||||
max_sell = max((float(s.sell_price) for s in slots), default=0.0)
|
||||
if max_sell > 0:
|
||||
if first_neg is not None and neg_day is not None:
|
||||
evening_by_day: dict = {}
|
||||
for t, s in enumerate(slots):
|
||||
if float(s.sell_price) >= max_sell - degrad and float(s.sell_price) > sell_min:
|
||||
selected.add(t)
|
||||
d = _prague_date(s)
|
||||
if _prague_hour(s) < 17:
|
||||
continue
|
||||
evening_by_day[d] = max(evening_by_day.get(d, 0.0), float(s.sell_price))
|
||||
for t, s in enumerate(slots):
|
||||
d = _prague_date(s)
|
||||
peak = evening_by_day.get(d, 0.0)
|
||||
if peak > 0 and _prague_hour(s) >= 17 and float(s.sell_price) >= peak - degrad:
|
||||
if float(s.sell_price) > sell_min:
|
||||
selected.add(t)
|
||||
|
||||
first_neg = next(
|
||||
(i for i, s in enumerate(slots) if float(s.sell_price) < 0),
|
||||
None,
|
||||
)
|
||||
preneg_min_soc = min_soc_wh + max(per_slot_wh, 1000.0)
|
||||
if (
|
||||
first_neg is not None
|
||||
and first_neg > 0
|
||||
and current_soc_wh >= preneg_min_soc
|
||||
and neg_day is not None
|
||||
):
|
||||
neg_day = _prague_date(slots[first_neg])
|
||||
positive = [
|
||||
i
|
||||
morning_sells = [
|
||||
float(slots[i].sell_price)
|
||||
for i in range(first_neg)
|
||||
if float(slots[i].sell_price) >= 0 and _prague_date(slots[i]) == neg_day
|
||||
if float(slots[i].sell_price) >= 0
|
||||
and _prague_date(slots[i]) == neg_day
|
||||
and 5 <= _prague_hour(slots[i]) <= 11
|
||||
]
|
||||
if positive:
|
||||
peak_t = max(positive, key=lambda i: (float(slots[i].sell_price), i))
|
||||
selected.add(peak_t)
|
||||
if morning_sells:
|
||||
zone_peak = max(morning_sells)
|
||||
for i in range(first_neg):
|
||||
if (
|
||||
_prague_date(slots[i]) == neg_day
|
||||
and 5 <= _prague_hour(slots[i]) <= 11
|
||||
and float(slots[i].sell_price) >= zone_peak - degrad
|
||||
):
|
||||
selected.add(i)
|
||||
for i in range(first_neg):
|
||||
if _prague_date(slots[i]) != neg_day:
|
||||
continue
|
||||
h = _prague_hour(slots[i])
|
||||
if 5 <= h < 17 and float(slots[i].sell_price) < zone_peak - degrad:
|
||||
selected.discard(i)
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from services.planning_engine import (
|
||||
_dynamic_arb_floor_wh_series,
|
||||
_dispatch_result_comparison,
|
||||
_pre_neg_peak_sell_idx,
|
||||
_prague_hour,
|
||||
_prewindow_deferral_slots,
|
||||
_slots_until_buy_le_threshold,
|
||||
_slots_until_sell_lt,
|
||||
@@ -620,8 +621,8 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
if results[0].grid_setpoint_w < 0:
|
||||
self.assertLess(
|
||||
results[0].battery_soc_target,
|
||||
19.0,
|
||||
msg="with relaxed soc_min, first-slot export should be able to finish below reserve %",
|
||||
22.0,
|
||||
msg="with relaxed soc_min, morning export should finish below reserve %",
|
||||
)
|
||||
|
||||
def test_negative_sell_forbids_battery_export_arbitrage(self) -> None:
|
||||
@@ -785,11 +786,12 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
# Slot index 1 je poslední před prvním sell<0 (index 2).
|
||||
first_neg = 2
|
||||
pre_neg_soc = [results[i].battery_soc_target for i in range(first_neg)]
|
||||
self.assertLessEqual(
|
||||
results[1].battery_soc_target,
|
||||
min(pre_neg_soc),
|
||||
6.0,
|
||||
msg="anchor should drive SoC close to planner floor before first negative sell",
|
||||
msg="anchor at morning peak should drive SoC near planner floor before first negative sell",
|
||||
)
|
||||
|
||||
def test_anchor_uses_planner_floor_even_without_extreme_buy(self) -> None:
|
||||
@@ -802,7 +804,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
PlanningSlot(
|
||||
interval_start=base,
|
||||
buy_price=3.0,
|
||||
sell_price=1.0,
|
||||
sell_price=3.06,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
@@ -814,7 +816,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15),
|
||||
buy_price=3.0,
|
||||
sell_price=0.5,
|
||||
sell_price=2.0,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=0,
|
||||
@@ -860,8 +862,11 @@ class PlanningDispatchMilpTests(unittest.TestCase):
|
||||
tuv_delta_stats=None,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
# Slot index 1 je poslední před prvním sell<0 (index 2).
|
||||
self.assertLessEqual(results[1].battery_soc_target, 6.0)
|
||||
self.assertLess(
|
||||
results[0].grid_setpoint_w,
|
||||
-1_000,
|
||||
msg="morning peak slot should export before first negative sell",
|
||||
)
|
||||
|
||||
def test_grid_import_soft_cap_penalizes_breaker_overdraw(self) -> None:
|
||||
"""
|
||||
@@ -1562,7 +1567,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
|
||||
charge_acquisition_cutoff_at=base + timedelta(minutes=30),
|
||||
)
|
||||
)
|
||||
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.5)
|
||||
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
|
||||
battery.max_charge_power_w = 17_000
|
||||
battery.max_discharge_power_w = 17_000
|
||||
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
@@ -2236,6 +2241,39 @@ class PlannerArbitrageImprovementsTests(unittest.TestCase):
|
||||
first_neg = 2
|
||||
self.assertEqual(_pre_neg_peak_sell_idx(slots, first_neg), 1)
|
||||
|
||||
def test_pre_neg_peak_ignores_midnight_on_same_day(self) -> None:
|
||||
"""Půlnoc může mít vyšší sell než ráno — peak musí být v pásmu 5–11, ne 00:00."""
|
||||
base = datetime(2026, 5, 22, 22, 0, tzinfo=timezone.utc)
|
||||
slots = [
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=4.0,
|
||||
sell_price=3.72 if i == 0 else (3.06 if i == 28 else 2.0),
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=1000,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
)
|
||||
for i in range(36)
|
||||
] + [
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * 36),
|
||||
buy_price=0.5,
|
||||
sell_price=-0.1,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=1000,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
),
|
||||
]
|
||||
first_neg = 36
|
||||
peak_idx = _pre_neg_peak_sell_idx(slots, first_neg)
|
||||
self.assertIsNotNone(peak_idx)
|
||||
self.assertGreater(_prague_hour(slots[peak_idx]), 4)
|
||||
self.assertLess(_prague_hour(slots[peak_idx]), 12)
|
||||
|
||||
def test_pre_neg_peak_idx_is_highest_positive_sell(self) -> None:
|
||||
base = datetime(2026, 5, 23, 4, 0, tzinfo=timezone.utc)
|
||||
slots = [
|
||||
|
||||
@@ -81,7 +81,10 @@ declare
|
||||
v_first_neg_prague_date date;
|
||||
v_pre_neg_peak_sell_ord int;
|
||||
v_preneg_export_min_soc_wh numeric;
|
||||
v_max_sell_czk_kwh numeric;
|
||||
v_morning_zone_peak_sell numeric;
|
||||
v_morning_preneg_start_hour int := 5;
|
||||
v_morning_preneg_end_hour int := 11;
|
||||
v_evening_peak_start_hour int := 17;
|
||||
v_charge_acquisition numeric;
|
||||
v_est_grid_wh numeric;
|
||||
v_est_pv_wh numeric;
|
||||
@@ -556,6 +559,14 @@ begin
|
||||
wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
||||
end
|
||||
)
|
||||
-- Na dni prvního sell<0 nepočítat noční „šrot“ (00–04) do globálního rozpočtu —
|
||||
-- jinak vyčerpá Wh před ranní špičkou (home-01: půlnoc 3,7 vs. 07:00 3,06).
|
||||
and not (
|
||||
v_first_neg_prague_date is not null
|
||||
and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
|
||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague')
|
||||
< v_morning_preneg_start_hour
|
||||
)
|
||||
order by wk.sell_price desc, wk.slot_ord desc
|
||||
loop
|
||||
exit when v_cum >= v_discharge_target_wh;
|
||||
@@ -565,46 +576,96 @@ begin
|
||||
end loop;
|
||||
end if;
|
||||
|
||||
-- Globální sell špičky (≈ max sell v horizontu): vždy export baterie, i po vyčerpání Wh rozpočtu.
|
||||
select coalesce(max(wk.sell_price), 0)
|
||||
into v_max_sell_czk_kwh
|
||||
from _ems_plan_slot_wk wk;
|
||||
-- Večerní špičky per kalendářní den (≥17:00 Prague): ne globální max horizontu (jinak půlnoc).
|
||||
for r_slot in
|
||||
select
|
||||
(wk.interval_start at time zone 'Europe/Prague')::date as plan_date,
|
||||
coalesce(max(wk.sell_price), 0) as evening_peak_sell
|
||||
from _ems_plan_slot_wk wk
|
||||
where extract(hour from wk.interval_start at time zone 'Europe/Prague')
|
||||
>= v_evening_peak_start_hour
|
||||
group by (wk.interval_start at time zone 'Europe/Prague')::date
|
||||
loop
|
||||
if r_slot.evening_peak_sell > 0 then
|
||||
update _ems_plan_slot_wk wk
|
||||
set allow_discharge_export = true
|
||||
where (wk.interval_start at time zone 'Europe/Prague')::date = r_slot.plan_date
|
||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague')
|
||||
>= v_evening_peak_start_hour
|
||||
and wk.sell_price >= r_slot.evening_peak_sell - v_degrad_czk_kwh
|
||||
and (
|
||||
case
|
||||
when v_purchase_pricing_mode = 'fixed' then
|
||||
wk.sell_price > v_degrad_czk_kwh
|
||||
else
|
||||
wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
||||
end
|
||||
);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
if v_max_sell_czk_kwh > 0 then
|
||||
update _ems_plan_slot_wk wk
|
||||
set allow_discharge_export = true
|
||||
where wk.sell_price >= v_max_sell_czk_kwh - v_degrad_czk_kwh
|
||||
and (
|
||||
case
|
||||
when v_purchase_pricing_mode = 'fixed' then
|
||||
wk.sell_price > v_degrad_czk_kwh
|
||||
else
|
||||
wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
||||
end
|
||||
);
|
||||
end if;
|
||||
|
||||
-- Před prvním sell<0 téhož dne: export v lokálním max kladného sell (např. 07:00, ne včerejší 20:45).
|
||||
-- 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
|
||||
and p_current_soc_wh >= v_preneg_export_min_soc_wh
|
||||
then
|
||||
select wk.slot_ord
|
||||
into v_pre_neg_peak_sell_ord
|
||||
select coalesce(max(wk.sell_price), 0)
|
||||
into v_morning_zone_peak_sell
|
||||
from _ems_plan_slot_wk wk
|
||||
where wk.slot_ord < v_first_neg_sell_ord
|
||||
and wk.sell_price >= 0
|
||||
and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
|
||||
order by wk.sell_price desc, wk.slot_ord
|
||||
limit 1;
|
||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague')
|
||||
between v_morning_preneg_start_hour and v_morning_preneg_end_hour;
|
||||
|
||||
if v_pre_neg_peak_sell_ord is not null then
|
||||
if v_morning_zone_peak_sell > 0 then
|
||||
update _ems_plan_slot_wk wk
|
||||
set allow_discharge_export = true
|
||||
where wk.slot_ord = v_pre_neg_peak_sell_ord;
|
||||
where wk.slot_ord < v_first_neg_sell_ord
|
||||
and wk.sell_price >= 0
|
||||
and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
|
||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague')
|
||||
between v_morning_preneg_start_hour and v_morning_preneg_end_hour
|
||||
and wk.sell_price >= v_morning_zone_peak_sell - v_degrad_czk_kwh;
|
||||
|
||||
select wk.slot_ord
|
||||
into v_pre_neg_peak_sell_ord
|
||||
from _ems_plan_slot_wk wk
|
||||
where wk.slot_ord < v_first_neg_sell_ord
|
||||
and wk.sell_price >= 0
|
||||
and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
|
||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague')
|
||||
between v_morning_preneg_start_hour and v_morning_preneg_end_hour
|
||||
order by wk.sell_price desc, wk.slot_ord
|
||||
limit 1;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Mezi ranní peak a prvním sell<0: zákaz „pozdního dumpu“ při nízkém sell (07:30 za 2 Kč).
|
||||
if v_first_neg_sell_ord is not null
|
||||
and v_first_neg_prague_date is not null
|
||||
and v_morning_zone_peak_sell is not null
|
||||
and v_morning_zone_peak_sell > 0
|
||||
then
|
||||
update _ems_plan_slot_wk wk
|
||||
set allow_discharge_export = false
|
||||
where wk.slot_ord < v_first_neg_sell_ord
|
||||
and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
|
||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague')
|
||||
between v_morning_preneg_start_hour and v_morning_preneg_end_hour
|
||||
and wk.sell_price < v_morning_zone_peak_sell - v_degrad_czk_kwh;
|
||||
|
||||
update _ems_plan_slot_wk wk
|
||||
set allow_discharge_export = false
|
||||
where wk.slot_ord < v_first_neg_sell_ord
|
||||
and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
|
||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague')
|
||||
>= v_morning_preneg_start_hour
|
||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague')
|
||||
< v_evening_peak_start_hour
|
||||
and wk.sell_price < v_morning_zone_peak_sell - v_degrad_czk_kwh;
|
||||
end if;
|
||||
|
||||
-- Acquisition: grid nabíjení před prvním exportem ve STEJNÝ den jako záporné výkupní okno
|
||||
-- (ne dřívější večerní export v horizontu rolling replanu).
|
||||
select min(wk.interval_start)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- **SQL-first:** horizont a sloty z DB funkcí (`fn_planning_horizon_end`, `fn_load_planning_slots_full`, …); viz **`CLAUDE.md`** → sekce *SQL-first a read-model*.
|
||||
- **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** – obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu.
|
||||
- **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).
|
||||
- **SoC kontinuita a export z baterie:** `soc[t]` klesá při **`bd[t]` i `ge_bat[t]`** (vybíjení do domu i do sítě). Bez `ge_bat` v bilanci SoC LP „exportovalo“ bez vybití — arbitrážní dump v pozdních slotech místo ranního peaku.
|
||||
- **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):** spot, 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`). Výběr: **nejlevnější `buy`** v pásmu (den plánu → před exportním oknem → `buy ASC`). Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **Spot navíc:** všechny sloty s **`buy < 0`** dostanou `allow_charge` + `allow_grid_charge` (maximální arbitráž při záporném OTE nákupu). **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`.
|
||||
@@ -39,7 +40,11 @@
|
||||
- měkký cíl na konci 24h přes `_soc_security_profile` + tvrdé dvouúrovňové pravidlo výše.
|
||||
- **Dynamická ekonomická podlaha (fáze 2):**
|
||||
- `_dynamic_arb_floor_wh_series`: podle součtu FVE výkonu v dalších ~8 h (`ARB_LOOKAHEAD_SLOTS`) se `arb_floor_wh[t]` posouvá mezi `min_soc_wh` a rezervou z DB – silné očekávané slunce ji sníží (ráno / po obloze); vynutit konstantní chování lze `battery.disable_dynamic_arb_floor=True` jen pro testy / ladění.
|
||||
- **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` označí jen sloty, kde smí solver **úmyslně** vybíjet baterii do sítě (SELL). Výběr je **globálně** podle `sell_price desc` (ne AM/PM 50/50), doplněno o: (1) **všechny sloty** s `sell ≥ max(sell) − degradation` (večerní špičky vždy exportovatelná), (2) **lokální maximum kladného `sell` před prvním `sell < 0` ve stejném kalendářním dni (Europe/Prague)** — horizont od `p_from` může zahrnovat víc dní (rolling večer + ráno); peak **není** včerejší večerní špička. Povoleno jen pokud `p_current_soc_wh ≥ min_soc + 1 slot discharge` (SoC). **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před **prvním exportem téhož dne** jako záporné výkupní okno (ne před včerejším exportem v horizontu). **Spot nákup:** `sell_price > ref_buy + degradation_cost_czk_kwh`. V `solve_dispatch` (AUTO): **`ge_pv`** / **`ge_bat`**; v **high-sell** exportních slotech měkká penalizace **`export_shortfall`**. **Kotva před `sell < 0`:** SoC ≤ planner floor v posledním slotu před prvním `sell < 0`; **`ge_bat` push** v peak slotu (Python, shodný den). Mimo exportní sloty: **`ge_bat = 0`**; **`bc_gi = 0`** mimo masku, **výjimka `buy < 0`**. **`deye_physical_mode`** = PASSIVE kromě CHARGE/SELL.
|
||||
- **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` (`R__063`). Tři vrstvy:
|
||||
1. **Globální rozpočet Wh** (`discharge_slot_buffer × exportovatelná kapacita`): sloty podle `sell_price desc`, ale na **dni prvního `sell < 0`** se **vynechává noc 00–04** (Prague), aby půlnoc nevyčerpala rozpočet před ranní špičkou.
|
||||
2. **Večerní špičky per den:** `sell ≥ max(sell) − degradation` jen pro hodiny **≥ 17** (Prague), ne globální max horizontu (jinak by vyhrála půlnoc 3,7 Kč místo večera).
|
||||
3. **Ranní pásmo před prvním `sell < 0`:** hodiny **5–11** téhož kalendářního dne — všechny sloty s `sell ≥ lokální_max_ráno − degradation`; ostatní sloty mezi ranním pásmem a prvním `sell < 0` s nižším sell mají export **zakázán** (žádný dump v 07:30 za 2 Kč). **`charge_acquisition`:** vážený `buy` před prvním exportem **téhož dne** jako záporné výkupní okno.
|
||||
V `solve_dispatch` (AUTO): **`ge_bat` push** ve všech ranních peak slotech; **kotva SoC** na ranním peaku (ne na posledním slotu před `sell < 0`); **`gi` minimum** při `buy < 0`; **`export_shortfall`** u high-sell. Mimo exportní sloty: **`ge_bat = 0`**; **`bc_gi = 0`** mimo masku, **výjimka `buy < 0`**.
|
||||
- **Záporná nákupní cena:**
|
||||
- horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu).
|
||||
- **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí —
|
||||
|
||||
Reference in New Issue
Block a user