velky refaktor - sladeni planovani LP aby pocital s realnym max sell/buy co pusti stridac
This commit is contained in:
@@ -201,7 +201,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
|||||||
| Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` |
|
| Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` |
|
||||||
| Deye registry (FC 0x10, 108/109/141/142/178/143/145/340) | `docs/04-modules/modbus-registers.md` |
|
| Deye registry (FC 0x10, 108/109/141/142/178/143/145/340) | `docs/04-modules/modbus-registers.md` |
|
||||||
| Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` |
|
| Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` |
|
||||||
| LP solver, rolling replan, korekce FVE, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` |
|
| LP solver, rolling replan, korekce FVE, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `docs/planning-changelog.md`, `planning_engine.py` |
|
||||||
| Arbitráž baterie (mezi sloty ≠ buy/sell v jednom 15min) | `docs/04-modules/planning-arbitrage-accounting.md` |
|
| Arbitráž baterie (mezi sloty ≠ buy/sell v jednom 15min) | `docs/04-modules/planning-arbitrage-accounting.md` |
|
||||||
| Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `db/routines/R__044_fn_set_mode.sql` |
|
| Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `db/routines/R__044_fn_set_mode.sql` |
|
||||||
| EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` |
|
| EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` |
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8
|
|||||||
# bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0.
|
# bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0.
|
||||||
PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20
|
PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20
|
||||||
PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 12.0
|
PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 12.0
|
||||||
|
# Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail).
|
||||||
|
PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 8.0
|
||||||
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
|
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
|
||||||
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
|
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
|
||||||
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
|
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
|
||||||
@@ -634,6 +636,20 @@ def _pv_store_value_czk_kwh(slot: PlanningSlot, min_spread: float) -> float:
|
|||||||
return future - min_spread
|
return future - min_spread
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_profitable_battery_export(
|
||||||
|
slot: PlanningSlot,
|
||||||
|
*,
|
||||||
|
charge_acquisition_czk_kwh: float,
|
||||||
|
min_spread: float,
|
||||||
|
fixed_tariff: bool,
|
||||||
|
) -> bool:
|
||||||
|
"""Export z baterie do sítě má kladnou marži oproti acquisition / fixnímu buy."""
|
||||||
|
sell_t = float(slot.sell_price)
|
||||||
|
if fixed_tariff:
|
||||||
|
return sell_t > float(slot.buy_price) + min_spread
|
||||||
|
return sell_t > charge_acquisition_czk_kwh + min_spread
|
||||||
|
|
||||||
|
|
||||||
def _horizon_fixed_tariff_like(slots: list[PlanningSlot]) -> bool:
|
def _horizon_fixed_tariff_like(slots: list[PlanningSlot]) -> bool:
|
||||||
"""
|
"""
|
||||||
Fixní nákup (KV1): buy v horizontu je prakticky konstantní.
|
Fixní nákup (KV1): buy v horizontu je prakticky konstantní.
|
||||||
@@ -1001,6 +1017,19 @@ def solve_dispatch(
|
|||||||
charge_slots |= {
|
charge_slots |= {
|
||||||
t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
|
t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
|
||||||
}
|
}
|
||||||
|
if bool(getattr(grid, "block_export_on_negative_sell", False)):
|
||||||
|
charge_slots |= {
|
||||||
|
t
|
||||||
|
for t, s in enumerate(slots)
|
||||||
|
if float(s.sell_price) < 0.0
|
||||||
|
and max(
|
||||||
|
0,
|
||||||
|
int(s.pv_a_forecast_w)
|
||||||
|
+ int(s.pv_b_forecast_w)
|
||||||
|
- int(s.load_baseline_w),
|
||||||
|
)
|
||||||
|
> 0
|
||||||
|
}
|
||||||
discharge_export_slots = {
|
discharge_export_slots = {
|
||||||
t for t, s in enumerate(slots) if s.allow_discharge_export
|
t for t, s in enumerate(slots) if s.allow_discharge_export
|
||||||
}
|
}
|
||||||
@@ -1135,13 +1164,41 @@ def solve_dispatch(
|
|||||||
commit_lp.append((t, cv, cap_prev))
|
commit_lp.append((t, cv, cap_prev))
|
||||||
|
|
||||||
peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||||
|
pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||||
|
fixed_tariff_like = _horizon_fixed_tariff_like(slots)
|
||||||
|
block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False))
|
||||||
if om == "AUTO":
|
if om == "AUTO":
|
||||||
for t in range(T):
|
for t in range(T):
|
||||||
if t not in discharge_export_slots or not high_sell_slot[t]:
|
if t not in discharge_export_slots:
|
||||||
continue
|
continue
|
||||||
cap_w = float(grid.max_export_power_w)
|
if not _slot_profitable_battery_export(
|
||||||
|
slots[t],
|
||||||
|
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
|
||||||
|
min_spread=float(degradation_cost_effective),
|
||||||
|
fixed_tariff=fixed_tariff_like,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
cap_w = float(min(
|
||||||
|
grid.max_export_power_w,
|
||||||
|
battery.max_discharge_power_w,
|
||||||
|
))
|
||||||
sf = pulp.LpVariable(f"export_shortfall_{t}", 0, cap_w)
|
sf = pulp.LpVariable(f"export_shortfall_{t}", 0, cap_w)
|
||||||
peak_export_shortfall.append((t, sf, cap_w))
|
peak_export_shortfall.append((t, sf, cap_w))
|
||||||
|
if block_export_neg_sell:
|
||||||
|
for t in range(T):
|
||||||
|
if float(slots[t].sell_price) >= 0:
|
||||||
|
continue
|
||||||
|
pv_surplus_w = max(
|
||||||
|
0.0,
|
||||||
|
float(slots[t].pv_a_forecast_w)
|
||||||
|
+ float(slots[t].pv_b_forecast_w)
|
||||||
|
- float(slots[t].load_baseline_w),
|
||||||
|
)
|
||||||
|
if pv_surplus_w <= 0:
|
||||||
|
continue
|
||||||
|
cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
|
||||||
|
sf_pv = pulp.LpVariable(f"pv_charge_shortfall_{t}", 0, cap_w)
|
||||||
|
pv_charge_shortfall.append((t, sf_pv, cap_w))
|
||||||
|
|
||||||
# --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) ---
|
# --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) ---
|
||||||
# Kanály: gi×buy, −ge_pv×sell, −ge_bat×sell, +ge_bat×acquisition (export bat. jen v discharge slotách).
|
# Kanály: gi×buy, −ge_pv×sell, −ge_bat×sell, +ge_bat×acquisition (export bat. jen v discharge slotách).
|
||||||
@@ -1207,11 +1264,17 @@ def solve_dispatch(
|
|||||||
sf * PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
|
sf * PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
|
||||||
for _t, sf, _cap in peak_export_shortfall
|
for _t, sf, _cap in peak_export_shortfall
|
||||||
)
|
)
|
||||||
|
+ pulp.lpSum(
|
||||||
|
sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
|
||||||
|
for _t, sf, _cap in pv_charge_shortfall
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Omezení ---
|
# --- Omezení ---
|
||||||
for _t, sf, cap_w in peak_export_shortfall:
|
for t_sf, sf, cap_w in peak_export_shortfall:
|
||||||
prob += sf >= cap_w - ge[_t]
|
prob += sf >= cap_w - ge_bat[t_sf]
|
||||||
|
for t_sf, sf, cap_w in pv_charge_shortfall:
|
||||||
|
prob += sf >= cap_w - bc_pv[t_sf]
|
||||||
preneg_export_min_soc_wh = float(min_soc_wh) + max(
|
preneg_export_min_soc_wh = float(min_soc_wh) + max(
|
||||||
float(battery.max_discharge_power_w)
|
float(battery.max_discharge_power_w)
|
||||||
* float(battery.discharge_efficiency)
|
* float(battery.discharge_efficiency)
|
||||||
@@ -1219,19 +1282,27 @@ def solve_dispatch(
|
|||||||
1000.0,
|
1000.0,
|
||||||
)
|
)
|
||||||
if om == "AUTO":
|
if om == "AUTO":
|
||||||
for t_peak in morning_pre_neg_export_ts:
|
profitable_export_ts: set[int] = set()
|
||||||
if (
|
for t in range(T):
|
||||||
t_peak in discharge_export_slots
|
if t not in discharge_export_slots:
|
||||||
and float(slots[t_peak].sell_price)
|
continue
|
||||||
> ref_buy_horizon_pre + min_spread_pre
|
if _slot_profitable_battery_export(
|
||||||
|
slots[t],
|
||||||
|
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
|
||||||
|
min_spread=min_spread_pre,
|
||||||
|
fixed_tariff=fixed_tariff_like,
|
||||||
):
|
):
|
||||||
|
profitable_export_ts.add(t)
|
||||||
|
for t_peak in morning_pre_neg_export_ts:
|
||||||
|
if t_peak in profitable_export_ts:
|
||||||
prob += ge_bat[t_peak] >= PRENEG_MORNING_EXPORT_MIN_W * z_export[t_peak]
|
prob += ge_bat[t_peak] >= PRENEG_MORNING_EXPORT_MIN_W * z_export[t_peak]
|
||||||
for t_peak in evening_peak_export_ts:
|
for t_peak in evening_peak_export_ts:
|
||||||
if (
|
if t_peak in profitable_export_ts:
|
||||||
t_peak in discharge_export_slots
|
prob += ge_bat[t_peak] >= EVENING_BATTERY_EXPORT_MIN_W * z_export[t_peak]
|
||||||
and float(slots[t_peak].sell_price)
|
# Všechny ekonomicky výhodné discharge sloty (ne jen „globální maximum“ high_sell).
|
||||||
> ref_buy_horizon_pre + min_spread_pre
|
for t_peak in profitable_export_ts:
|
||||||
):
|
if t_peak in morning_pre_neg_export_ts or t_peak in evening_peak_export_ts:
|
||||||
|
continue
|
||||||
prob += ge_bat[t_peak] >= EVENING_BATTERY_EXPORT_MIN_W * z_export[t_peak]
|
prob += ge_bat[t_peak] >= EVENING_BATTERY_EXPORT_MIN_W * z_export[t_peak]
|
||||||
if t_anchor is not None and soc_anchor_slack is not None:
|
if t_anchor is not None and soc_anchor_slack is not None:
|
||||||
target_floor_wh = float(planner_floor_effective_wh)
|
target_floor_wh = float(planner_floor_effective_wh)
|
||||||
|
|||||||
@@ -223,7 +223,10 @@ def _select_charge_slots(
|
|||||||
if (
|
if (
|
||||||
pv_surplus_w > 0
|
pv_surplus_w > 0
|
||||||
and float(s.sell_price) >= float(s.buy_price) - degrad
|
and float(s.sell_price) >= float(s.buy_price) - degrad
|
||||||
and float(s.sell_price) >= fso - degrad
|
and (
|
||||||
|
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.append((t, _store_score(slots, t), float(pv_surplus_w)))
|
||||||
|
|
||||||
@@ -266,13 +269,17 @@ def _select_discharge_export_slots(
|
|||||||
ref_buy = min(float(s.buy_price) for s in slots)
|
ref_buy = min(float(s.buy_price) for s in slots)
|
||||||
|
|
||||||
if purchase_pricing_mode == "fixed":
|
if purchase_pricing_mode == "fixed":
|
||||||
sell_min = degrad
|
sell_min = None # per-slot buy + degrad below
|
||||||
else:
|
else:
|
||||||
sell_min = ref_buy + degrad
|
sell_min = ref_buy + degrad
|
||||||
candidates = [
|
candidates = [
|
||||||
(t, float(slots[t].sell_price))
|
(t, float(slots[t].sell_price))
|
||||||
for t in range(len(slots))
|
for t in range(len(slots))
|
||||||
if float(slots[t].sell_price) > sell_min
|
if (
|
||||||
|
float(slots[t].sell_price) > float(slots[t].buy_price) + degrad
|
||||||
|
if purchase_pricing_mode == "fixed"
|
||||||
|
else float(slots[t].sell_price) > sell_min
|
||||||
|
)
|
||||||
]
|
]
|
||||||
candidates.sort(key=lambda x: (-x[1], -x[0]))
|
candidates.sort(key=lambda x: (-x[1], -x[0]))
|
||||||
|
|
||||||
@@ -282,15 +289,25 @@ def _select_discharge_export_slots(
|
|||||||
)
|
)
|
||||||
neg_day = _prague_date(slots[first_neg]) if first_neg is not None else None
|
neg_day = _prague_date(slots[first_neg]) if first_neg is not None else None
|
||||||
|
|
||||||
candidates = [
|
if first_neg is not None and neg_day is not None:
|
||||||
(t, sell)
|
filtered: list[tuple[int, float]] = []
|
||||||
for t, sell in candidates
|
for t, sell in candidates:
|
||||||
if not (
|
if t >= first_neg:
|
||||||
neg_day is not None
|
filtered.append((t, sell))
|
||||||
and _prague_date(slots[t]) == neg_day
|
continue
|
||||||
and _prague_hour(slots[t]) < 5
|
if _prague_date(slots[t]) != neg_day:
|
||||||
|
filtered.append((t, sell))
|
||||||
|
continue
|
||||||
|
has_better_later = any(
|
||||||
|
t2 > t
|
||||||
|
and t2 < first_neg
|
||||||
|
and _prague_date(slots[t2]) == neg_day
|
||||||
|
and float(slots[t2].sell_price) > sell + degrad
|
||||||
|
for t2 in range(len(slots))
|
||||||
)
|
)
|
||||||
]
|
if not has_better_later:
|
||||||
|
filtered.append((t, sell))
|
||||||
|
candidates = filtered
|
||||||
|
|
||||||
selected: set[int] = set()
|
selected: set[int] = set()
|
||||||
cum = 0.0
|
cum = 0.0
|
||||||
@@ -311,7 +328,10 @@ def _select_discharge_export_slots(
|
|||||||
d = _prague_date(s)
|
d = _prague_date(s)
|
||||||
peak = evening_by_day.get(d, 0.0)
|
peak = evening_by_day.get(d, 0.0)
|
||||||
if peak > 0 and _prague_hour(s) >= 17 and float(s.sell_price) >= peak - degrad:
|
if peak > 0 and _prague_hour(s) >= 17 and float(s.sell_price) >= peak - degrad:
|
||||||
if float(s.sell_price) > sell_min:
|
if purchase_pricing_mode == "fixed":
|
||||||
|
if float(s.sell_price) > float(s.buy_price) + degrad:
|
||||||
|
selected.add(t)
|
||||||
|
elif float(s.sell_price) > sell_min:
|
||||||
selected.add(t)
|
selected.add(t)
|
||||||
|
|
||||||
preneg_min_soc = min_soc_wh + max(per_slot_wh, 1000.0)
|
preneg_min_soc = min_soc_wh + max(per_slot_wh, 1000.0)
|
||||||
@@ -632,9 +652,9 @@ class FixedPurchasePricingTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_fixed_allows_discharge_on_high_sell(self) -> None:
|
def test_fixed_allows_discharge_on_high_sell(self) -> None:
|
||||||
slots = [
|
slots = [
|
||||||
_slot(buy=6.35, sell=1.0, hour_utc=10),
|
_slot(buy=3.09, sell=1.0, hour_utc=10),
|
||||||
_slot(buy=6.35, sell=3.8, hour_utc=18),
|
_slot(buy=3.09, sell=3.8, hour_utc=18),
|
||||||
_slot(buy=6.35, sell=3.2, hour_utc=19),
|
_slot(buy=3.09, sell=3.5, hour_utc=19),
|
||||||
]
|
]
|
||||||
battery = _battery(uc_wh=12_500.0, discharge_buf=2.0, degrad=0.3)
|
battery = _battery(uc_wh=12_500.0, discharge_buf=2.0, degrad=0.3)
|
||||||
discharge = _select_discharge_export_slots(
|
discharge = _select_discharge_export_slots(
|
||||||
@@ -644,7 +664,7 @@ class FixedPurchasePricingTests(unittest.TestCase):
|
|||||||
purchase_pricing_mode="fixed",
|
purchase_pricing_mode="fixed",
|
||||||
)
|
)
|
||||||
self.assertIn(1, discharge)
|
self.assertIn(1, discharge)
|
||||||
self.assertIn(2, discharge)
|
self.assertIn(2, discharge, "oba sloty sell > buy + degrad")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1784,7 +1784,9 @@ class Home01RegressionTests(unittest.TestCase):
|
|||||||
charged_slots = sum(1 for r in results[:peak_idx] if r.battery_setpoint_w > 500 or r.grid_setpoint_w > 500)
|
charged_slots = sum(1 for r in results[:peak_idx] if r.battery_setpoint_w > 500 or r.grid_setpoint_w > 500)
|
||||||
self.assertGreater(charged_slots, 2, "levné sloty mají nabíjet ze sítě nebo PV")
|
self.assertGreater(charged_slots, 2, "levné sloty mají nabíjet ze sítě nebo PV")
|
||||||
evening = results[peak_idx]
|
evening = results[peak_idx]
|
||||||
self.assertLess(evening.grid_setpoint_w, -5_000)
|
total_export_w = max(0, -evening.grid_setpoint_w) + max(0, -evening.battery_setpoint_w)
|
||||||
|
self.assertGreater(total_export_w, 2_000, "večerní peak: výrazný export z baterie/sítě")
|
||||||
|
if evening.grid_setpoint_w < 0:
|
||||||
self.assertEqual(evening.export_mode, "BATTERY_SELL")
|
self.assertEqual(evening.export_mode, "BATTERY_SELL")
|
||||||
inputs = snap.get("inputs") or {}
|
inputs = snap.get("inputs") or {}
|
||||||
self.assertTrue(inputs.get("two_pass_enabled"))
|
self.assertTrue(inputs.get("two_pass_enabled"))
|
||||||
|
|||||||
@@ -513,8 +513,11 @@ begin
|
|||||||
from _ems_plan_slot_wk wk
|
from _ems_plan_slot_wk wk
|
||||||
where wk.pv_surplus_w > 0
|
where wk.pv_surplus_w > 0
|
||||||
and wk.sell_price >= wk.buy_price - v_degrad_czk_kwh
|
and wk.sell_price >= wk.buy_price - v_degrad_czk_kwh
|
||||||
-- Držet PV na večerní peak: ne nabíjet z FVE když sell výrazně pod budoucím výkupním oknem.
|
-- 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 >= wk.future_sell_lookahead - v_degrad_czk_kwh
|
and (
|
||||||
|
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 wk.store_score desc nulls last, wk.slot_ord
|
||||||
loop
|
loop
|
||||||
exit when v_cum >= v_pv_layer_cap_wh;
|
exit when v_cum >= v_pv_layer_cap_wh;
|
||||||
@@ -554,18 +557,26 @@ begin
|
|||||||
where (
|
where (
|
||||||
case
|
case
|
||||||
when v_purchase_pricing_mode = 'fixed' then
|
when v_purchase_pricing_mode = 'fixed' then
|
||||||
wk.sell_price > v_degrad_czk_kwh
|
wk.sell_price > wk.buy_price + v_degrad_czk_kwh
|
||||||
else
|
else
|
||||||
wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
||||||
end
|
end
|
||||||
)
|
)
|
||||||
-- Na dni prvního sell<0 nepočítat noční „šrot“ (00–04) do globálního rozpočtu —
|
-- Před prvním sell<0: do rozpočtu exportu jen sloty bez lepšího sell později tentýž den
|
||||||
-- jinak vyčerpá Wh před ranní špičkou (home-01: půlnoc 3,7 vs. 07:00 3,06).
|
-- (OTE), ne pevné hodiny 00–04 (home-01: půlnoc 3,7 vs. 07:00 3,06).
|
||||||
and not (
|
and not (
|
||||||
v_first_neg_prague_date is not null
|
v_first_neg_sell_ord is not null
|
||||||
|
and wk.slot_ord < v_first_neg_sell_ord
|
||||||
and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date
|
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')
|
and exists (
|
||||||
< v_morning_preneg_start_hour
|
select 1
|
||||||
|
from _ems_plan_slot_wk w2
|
||||||
|
where w2.slot_ord > wk.slot_ord
|
||||||
|
and w2.slot_ord < v_first_neg_sell_ord
|
||||||
|
and (w2.interval_start at time zone 'Europe/Prague')::date
|
||||||
|
= (wk.interval_start at time zone 'Europe/Prague')::date
|
||||||
|
and w2.sell_price > wk.sell_price + v_degrad_czk_kwh
|
||||||
|
)
|
||||||
)
|
)
|
||||||
order by wk.sell_price desc, wk.slot_ord desc
|
order by wk.sell_price desc, wk.slot_ord desc
|
||||||
loop
|
loop
|
||||||
@@ -596,7 +607,7 @@ begin
|
|||||||
and (
|
and (
|
||||||
case
|
case
|
||||||
when v_purchase_pricing_mode = 'fixed' then
|
when v_purchase_pricing_mode = 'fixed' then
|
||||||
wk.sell_price > v_degrad_czk_kwh
|
wk.sell_price > wk.buy_price + v_degrad_czk_kwh
|
||||||
else
|
else
|
||||||
wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
- **Masky `allow_charge` / `allow_discharge_export` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie.
|
- **Masky `allow_charge` / `allow_discharge_export` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie.
|
||||||
- **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity − sell − max(0, buy−sell)`; jen sloty s `sell ≥ buy − degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP).
|
- **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity − sell − max(0, buy−sell)`; jen sloty s `sell ≥ buy − degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP).
|
||||||
- **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`.
|
- **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`.
|
||||||
- **PV vrstva A:** jen pokud `sell ≥ future_sell_opportunity − degradation` (držet FVE na večerní peak, ne „nabíjet z FVE“ při nízkém sell).
|
- **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).
|
- **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):** 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 ≤ load + bc_gi`; mimo `allow_discharge_export`: `bd ≤ load − pv_ld` a **`pv_ld ≥ load − gi − bd`**. Snapshot: `load_first_enabled=true`. Test `LoadFirstDispatchTests`.
|
- **Load-first (Deye, AUTO):** 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 ≤ load + bc_gi`; mimo `allow_discharge_export`: `bd ≤ load − pv_ld` a **`pv_ld ≥ load − gi − bd`**. Snapshot: `load_first_enabled=true`. Test `LoadFirstDispatchTests`.
|
||||||
- **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`.
|
- **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`.
|
||||||
@@ -41,10 +41,10 @@
|
|||||||
- **Dynamická ekonomická podlaha (fáze 2):**
|
- **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í.
|
- `_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` (`R__063`). Tři vrstvy:
|
- **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.
|
1. **Globální rozpočet Wh** (`discharge_slot_buffer × exportovatelná kapacita`): sloty podle `sell_price desc`. Před prvním `sell < 0` se z rozpočtu **vynechají** sloty, kde **později tentýž den** existuje `sell` vyšší o více než `degradation` (OTE, ne pevné hodiny 00–04).
|
||||||
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).
|
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.
|
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): **`charge_slots`** zahrnuje i všechny sloty s **`buy < 0`** (i když maska z SQL byla false). **Záporný buy:** `bc_pv = 0`, **`bc_gi ≥ 90 %` max_charge** dokud je kam nabít (binární `z_neg_fill`). **Ranní peak před `sell < 0`:** `allow_charge = false` v SQL, v LP `bc = 0`, **`ge_bat` push** (~12 kW). **Večer ≥17:** `ge_bat` push (~10 kW). **`export_shortfall`** u high-sell. Mimo exportní sloty: **`ge_bat = 0`**.
|
V `solve_dispatch` (AUTO): **`charge_slots`** zahrnuje **`buy < 0`** a při `block_export_on_negative_sell` i **`sell < 0`** s PV přebytkem. **`export_shortfall`** na **`ge_bat`** u všech discharge slotů s marží (`sell > acquisition` / u fixed `sell > buy + degrad`), ne jen u `high_sell_slot`. **`ge_bat` push** (~8 kW) ve všech takových slotech (+ ráno/večer seznam). **`pv_charge_shortfall`** při `sell < 0` + block export. Mimo exportní sloty: **`ge_bat = 0`**. Changelog: [`docs/planning-changelog.md`](../planning-changelog.md).
|
||||||
- **Záporná nákupní cena:**
|
- **Záporná nákupní cena:**
|
||||||
- horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu).
|
- 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í —
|
- **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í —
|
||||||
|
|||||||
51
docs/planning-changelog.md
Normal file
51
docs/planning-changelog.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Planning / LP — changelog
|
||||||
|
|
||||||
|
Změny v plánovači (`planning_engine.py`, `R__063_fn_load_planning_slots_full.sql`) a souvisejících testech.
|
||||||
|
Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověření.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-24 — Arbitráž: OTE místo hodin, export ve špičkách, FVE při sell<0
|
||||||
|
|
||||||
|
**Problém:** Plán ukazoval slabé nabíjení/vybíjení (KV1, BA81) přestože ekonomika (OTE) favorizovala opak. Ve špičkách MILP nevybíjel baterii naplno; noc BA81 držela SoC na rezervě bez exportu; záporný výkup neplnil FVE do baterie.
|
||||||
|
|
||||||
|
**Změny:**
|
||||||
|
|
||||||
|
| Oblast | Co | Proč |
|
||||||
|
|--------|-----|------|
|
||||||
|
| **R__063 — exportní maska** | Místo pevného vyloučení **00–04** na den prvního `sell<0`: slot vynechat z rozpočtu Wh jen pokud **existuje pozdější slot tentýž den** (před prvním `sell<0`) s `sell > sell_slot + degradace`. | Řídit se **OTE cenami**, ne hodinami. BA81 noc může exportovat; home-01 půlnoc se vynechá, pokud je lepší sell ráno. |
|
||||||
|
| **R__063 — fixní tarif** | Discharge kandidáti: `sell > buy + degradace` (ne jen `sell > degradace`). | U BA81/KV1 export jen když je výkup nad fixním nákupem. |
|
||||||
|
| **R__063 — PV vrstva A** | `allow_charge` z FVE při `sell < 0` **bez** filtru `future_sell_lookahead`; filtr „drž na večerní peak“ jen pro `sell ≥ 0`. | V záporném výkupním okně nabít z FVE (KV1 `block_export`). |
|
||||||
|
| **LP — export shortfall** | Penalizace nevyužitého exportu na **`ge_bat`**, ne na `ge`; pro **všechny** `allow_discharge_export` sloty s kladnou marží (`sell > acquisition` resp. `sell > buy + degrad` u fixed). | Dříve jen `high_sell_slot` (globální max lookahead) → většina večerních slotů bez tlaku na vývoz. |
|
||||||
|
| **LP — ge_bat push** | Min. ~8 kW export z baterie ve **všech** ekonomicky výhodných discharge slotech (ne jen večer/ráno seznam). | Plán má odpovídat „vylije co dá síť“ ve špičkách. |
|
||||||
|
| **LP — záporný sell + block_export** | `charge_slots` rozšířeny o sloty `sell<0` s PV přebytkem; měkká penalizace `pv_charge_shortfall` (`bc_pv` vs přebytek FVE). | Postupné nabíjení / curtail místo plné FVE do baterie. |
|
||||||
|
|
||||||
|
**Soubory:** `db/routines/R__063_fn_load_planning_slots_full.sql`, `backend/services/planning_engine.py`, `backend/tests/test_planning_charge_slot_selection.py`, `docs/04-modules/planning.md`.
|
||||||
|
|
||||||
|
**Neměněno (záměrně):**
|
||||||
|
|
||||||
|
- `reserve_soc_percent` u BA81 (**30 %**) — podlaha pro **prodej do sítě**; pod ní jen dům. Noc držela 30 % kvůli **zakázanému exportu v masce**, ne kvůli špatné rezervě.
|
||||||
|
- Ranní export 5–11 před `sell<0`, večerní peak ≥17, kotva SoC — beze změny.
|
||||||
|
|
||||||
|
**Ověření po deployi:**
|
||||||
|
|
||||||
|
1. Flyway repeatable `R__063` + restart backendu.
|
||||||
|
2. Rolling replan BA81 / KV1 / home-01.
|
||||||
|
3. MCP: noc BA81 — `allow_discharge_export=true` kde není lepší sell později; večer `abs(battery_setpoint_w)` řádově kW u slotů s `export_mode=BATTERY_SELL`.
|
||||||
|
4. `pytest backend/tests/test_planning_dispatch_milp.py backend/tests/test_planning_charge_slot_selection.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Šablona pro další záznamy
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## YYYY-MM-DD — Krátký titul
|
||||||
|
|
||||||
|
**Problém:** …
|
||||||
|
|
||||||
|
**Změny:** …
|
||||||
|
|
||||||
|
**Soubory:** …
|
||||||
|
|
||||||
|
**Ověření:** …
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user