zruseni fixnich konstant
This commit is contained in:
@@ -68,7 +68,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-05-28-pre-neg-batt-discharge-v23"
|
||||
PLANNER_BUILD_TAG = "2026-05-28-evening-push-dynamic-budget-v24"
|
||||
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_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
|
||||
@@ -935,37 +935,71 @@ def _evening_peak_export_indices(
|
||||
return out
|
||||
|
||||
|
||||
def _evening_push_discharge_budget_wh(
|
||||
*,
|
||||
current_soc_wh: float,
|
||||
min_soc_wh: float,
|
||||
soc_max_wh: float,
|
||||
discharge_slot_buffer: float,
|
||||
) -> float:
|
||||
"""
|
||||
Rozpočet Wh pro tvrdý večerní push — stejný princip jako R__063 (discharge_slot_buffer).
|
||||
Tvrdý push nesmí překročit energii nad min_soc na začátku horizontu (jinak Infeasible).
|
||||
"""
|
||||
exportable_full_wh = max(0.0, float(soc_max_wh) - float(min_soc_wh))
|
||||
available_wh = max(0.0, float(current_soc_wh) - float(min_soc_wh))
|
||||
buf = float(discharge_slot_buffer)
|
||||
if buf <= 0.0:
|
||||
return available_wh
|
||||
return min(available_wh, exportable_full_wh * buf)
|
||||
|
||||
|
||||
def _evening_battery_export_push_indices(
|
||||
slots: list[PlanningSlot],
|
||||
*,
|
||||
profitable_export_ts: set[int],
|
||||
degrad_czk_kwh: float,
|
||||
current_soc_wh: float,
|
||||
min_soc_wh: float,
|
||||
soc_max_wh: float,
|
||||
per_slot_discharge_wh: float,
|
||||
discharge_slot_buffer: float,
|
||||
evening_start_hour: int = 17,
|
||||
max_slots_per_day: int = 3,
|
||||
) -> list[int]:
|
||||
"""
|
||||
Tvrdý push ge_bat jen u několika nejlepších večerních slotů/den (profitable ∩ peak).
|
||||
Jinak součet ge_bat × z_export přes celý peak pásmo může překročit dostupné SoC → Infeasible.
|
||||
Tvrdý push ge_bat u večerních peak slotů (profitable ∩ pásmo ≥17:00 − degrad).
|
||||
Počet slotů = kolik jich unese rozpočet Wh (ne pevné top-3 / ≥2 sloty).
|
||||
"""
|
||||
if per_slot_discharge_wh <= 0.0:
|
||||
return []
|
||||
peak_ts = _evening_peak_export_indices(
|
||||
slots,
|
||||
degrad_czk_kwh=degrad_czk_kwh,
|
||||
evening_start_hour=evening_start_hour,
|
||||
)
|
||||
by_day: dict = {}
|
||||
for t in peak_ts:
|
||||
if t not in profitable_export_ts:
|
||||
continue
|
||||
d = _prague_calendar_date(slots[t])
|
||||
by_day.setdefault(d, []).append(t)
|
||||
candidates = [t for t in peak_ts if t in profitable_export_ts]
|
||||
if not candidates:
|
||||
return []
|
||||
push_budget_wh = _evening_push_discharge_budget_wh(
|
||||
current_soc_wh=current_soc_wh,
|
||||
min_soc_wh=min_soc_wh,
|
||||
soc_max_wh=soc_max_wh,
|
||||
discharge_slot_buffer=discharge_slot_buffer,
|
||||
)
|
||||
if push_budget_wh < per_slot_discharge_wh * 0.5:
|
||||
return []
|
||||
ranked = sorted(
|
||||
candidates,
|
||||
key=lambda i: (float(slots[i].sell_price), -i),
|
||||
reverse=True,
|
||||
)
|
||||
out: list[int] = []
|
||||
for d in sorted(by_day.keys()):
|
||||
ranked = sorted(
|
||||
by_day[d],
|
||||
key=lambda i: float(slots[i].sell_price),
|
||||
reverse=True,
|
||||
)
|
||||
out.extend(ranked[:max_slots_per_day])
|
||||
cum_wh = 0.0
|
||||
for t in ranked:
|
||||
if cum_wh + per_slot_discharge_wh > push_budget_wh + 1e-6:
|
||||
break
|
||||
out.append(t)
|
||||
cum_wh += per_slot_discharge_wh
|
||||
return sorted(out)
|
||||
|
||||
|
||||
@@ -1687,6 +1721,12 @@ def solve_dispatch(
|
||||
* INTERVAL_H,
|
||||
1000.0,
|
||||
)
|
||||
per_slot_discharge_wh = max(
|
||||
float(battery.max_discharge_power_w)
|
||||
* float(battery.discharge_efficiency)
|
||||
* INTERVAL_H,
|
||||
0.0,
|
||||
)
|
||||
if om == "AUTO":
|
||||
profitable_export_ts = profitable_export_ts_pre
|
||||
export_push_w = _battery_export_cap_w(battery, grid)
|
||||
@@ -1695,17 +1735,21 @@ def solve_dispatch(
|
||||
prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak]
|
||||
for t_pnd in pre_neg_buy_discharge_ts:
|
||||
prob += ge_bat[t_pnd] >= export_push_w * z_export[t_pnd]
|
||||
discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
|
||||
evening_push_ts = _evening_battery_export_push_indices(
|
||||
slots,
|
||||
profitable_export_ts=profitable_export_ts,
|
||||
degrad_czk_kwh=float(degradation_cost_effective),
|
||||
current_soc_wh=float(current_soc_wh),
|
||||
min_soc_wh=float(min_soc_wh),
|
||||
soc_max_wh=float(battery.soc_max_wh),
|
||||
per_slot_discharge_wh=per_slot_discharge_wh,
|
||||
discharge_slot_buffer=discharge_buf,
|
||||
)
|
||||
# Push jen při reálném večerním okně (≥2 sloty); 1-slot regresní testy bez tvrdého push.
|
||||
if len(evening_push_ts) >= 2:
|
||||
for t_peak in evening_push_ts:
|
||||
if t_peak not in discharge_export_slots:
|
||||
continue
|
||||
prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak]
|
||||
for t_peak in evening_push_ts:
|
||||
if t_peak not in discharge_export_slots:
|
||||
continue
|
||||
prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak]
|
||||
# Ostatní profitable sloty: shortfall penalizace (ne tvrdý push na celý horizont).
|
||||
if t_anchor is not None and soc_anchor_slack is not None:
|
||||
target_floor_wh = float(planner_floor_effective_wh)
|
||||
|
||||
@@ -11,6 +11,8 @@ from services.planning_engine import (
|
||||
PlanningSlot,
|
||||
_dynamic_arb_floor_wh_series,
|
||||
_dispatch_result_comparison,
|
||||
_evening_battery_export_push_indices,
|
||||
_evening_push_discharge_budget_wh,
|
||||
_pre_neg_peak_sell_idx,
|
||||
_prague_hour,
|
||||
_prewindow_deferral_slots,
|
||||
@@ -50,6 +52,7 @@ def _battery(
|
||||
arb_pct: float = 20.0,
|
||||
max_pct: float = 95.0,
|
||||
terminal_soc_value_factor: float = 0.9,
|
||||
discharge_slot_buffer: float = 1.5,
|
||||
) -> SimpleNamespace:
|
||||
uc = uc_wh
|
||||
min_wh = min_pct / 100.0 * uc
|
||||
@@ -65,10 +68,79 @@ def _battery(
|
||||
degradation_cost_czk_kwh=0.15,
|
||||
max_charge_power_w=10_000,
|
||||
max_discharge_power_w=10_000,
|
||||
discharge_slot_buffer=discharge_slot_buffer,
|
||||
planner_terminal_soc_value_factor=terminal_soc_value_factor,
|
||||
)
|
||||
|
||||
|
||||
class EveningPushBudgetTests(unittest.TestCase):
|
||||
"""Večerní tvrdý push: počet slotů z rozpočtu Wh (ne pevné top-3)."""
|
||||
|
||||
@staticmethod
|
||||
def _evening_slots(n: int = 8) -> list[PlanningSlot]:
|
||||
base = datetime(2026, 5, 25, 15, 0, tzinfo=timezone.utc)
|
||||
slots: list[PlanningSlot] = []
|
||||
for i in range(n):
|
||||
slots.append(
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=2.0,
|
||||
sell_price=4.0 + 0.01 * i,
|
||||
pv_a_forecast_w=0,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=1000,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_discharge_export=True,
|
||||
charge_acquisition_buy_czk_kwh=0.5,
|
||||
)
|
||||
)
|
||||
return slots
|
||||
|
||||
def test_budget_scales_with_soc_not_fixed_three(self) -> None:
|
||||
slots = self._evening_slots(8)
|
||||
per_slot = 17_000 * 0.95 * 0.25
|
||||
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
|
||||
soc_high = 0.92 * bat.soc_max_wh
|
||||
profitable = set(range(len(slots)))
|
||||
push_hi = _evening_battery_export_push_indices(
|
||||
slots,
|
||||
profitable_export_ts=profitable,
|
||||
degrad_czk_kwh=0.15,
|
||||
current_soc_wh=soc_high,
|
||||
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,
|
||||
)
|
||||
self.assertGreater(len(push_hi), 3)
|
||||
soc_low = bat.min_soc_wh + 100.0
|
||||
push_lo = _evening_battery_export_push_indices(
|
||||
slots,
|
||||
profitable_export_ts=profitable,
|
||||
degrad_czk_kwh=0.15,
|
||||
current_soc_wh=soc_low,
|
||||
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,
|
||||
)
|
||||
self.assertEqual(len(push_lo), 0)
|
||||
|
||||
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)
|
||||
soc = 0.85 * bat.soc_max_wh
|
||||
budget = _evening_push_discharge_budget_wh(
|
||||
current_soc_wh=soc,
|
||||
min_soc_wh=bat.min_soc_wh,
|
||||
soc_max_wh=bat.soc_max_wh,
|
||||
discharge_slot_buffer=1.5,
|
||||
)
|
||||
exportable_full = bat.soc_max_wh - bat.min_soc_wh
|
||||
available = soc - bat.min_soc_wh
|
||||
self.assertAlmostEqual(budget, min(available, exportable_full * 1.5))
|
||||
|
||||
|
||||
class SlotsUntilSellNegativeTests(unittest.TestCase):
|
||||
def test_slots_until_first_negative_sell(self) -> None:
|
||||
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -1230,7 +1302,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-pre-neg-batt-discharge-v23")
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-evening-push-dynamic-budget-v24")
|
||||
self.assertGreater(
|
||||
results[0].battery_setpoint_w,
|
||||
5_500,
|
||||
@@ -1380,7 +1452,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-pre-neg-batt-discharge-v23")
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-evening-push-dynamic-budget-v24")
|
||||
self.assertEqual(len(results), len(slots))
|
||||
|
||||
def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None:
|
||||
@@ -1444,7 +1516,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
|
||||
55.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-pre-neg-batt-discharge-v23")
|
||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-evening-push-dynamic-budget-v24")
|
||||
self.assertEqual(len(results), len(slots))
|
||||
|
||||
def test_fixed_tariff_neg_sell_no_grid_export(self) -> None:
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
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).
|
||||
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.
|
||||
**Planner tag v24:** v23 + **večerní tvrdý push** podle rozpočtu Wh (`discharge_slot_buffer`, SoC nad `min_soc`, `per_slot_discharge`) — bez pevného top-3 / `len≥2`. Viz changelog v24.
|
||||
**Planner tag v23:** v22b + **výboj baterie do sítě** před `buy<0` (`_pre_neg_buy_discharge_indices`, sell≥1 Kč/kWh, push `ge_bat` z DB limitů). Viz changelog v23.
|
||||
V `solve_dispatch` (AUTO): **`charge_slots`** = `allow_charge` z DB + **`buy < 0`** + všechny sloty **`sell < 0`** s PV přebytkem > 500 W (i bez `block_export_on_negative_sell`, BA81). **`pv_charge_shortfall`** / **`NEG_SELL_CURTAIL_PENALTY`** platí v těchto slotech. Při **`sell < 0`**: safety deficit cílí **`soc_max_wh`** (plný planner strop). Po posledním **`sell < 0`** tentýž den: **`post_neg_pv_topup`** dobije z FVE na `soc_max` před exportem (kladný sell, ne high-sell peak). U **fixního tarifu** s polem B: **`ge_pv ≤ pv_b`** (ne pv_store **`ge_pv = 0`**). Při **`deye_gen_microinverter_cutoff_enabled`**: **`ge == 0` jen** pokud **`block_export_on_negative_sell`** (KV1), ne kvůli samotnému `z_gen_cutoff` (BA81 musí moci exportovat B při plné baterii). Vstupní **`soc_wh`** z telemetrie se před MILP omezí přes **`_planner_soc_for_solver`** (rezerva ~650 Wh pod `soc_max`, jinak Infeasible při 100 % SoC a dlouhém záporném výkupu). **`planner_build_tag`** v `solver_params`. Changelog: [`docs/planning-changelog.md`](../planning-changelog.md).
|
||||
- **Záporná nákupní cena:**
|
||||
|
||||
@@ -5,6 +5,16 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-28 — dynamický večerní push (v24)
|
||||
|
||||
**Problém:** Tvrdý večerní push používal pevné **`max_slots_per_day = 3`** a aktivaci jen při **`len(evening_push_ts) ≥ 2`** — nesouvisí s `discharge_slot_buffer`, SoC ani počtem večerních peak slotů (changelog v17 mluvil o top-6/≥7, v kódu bylo 3/2).
|
||||
|
||||
**Oprava (tag `2026-05-28-evening-push-dynamic-budget-v24`):** `_evening_push_discharge_budget_wh` + `_evening_battery_export_push_indices` — kandidáti = večerní peak ∩ maržní export; řazení `sell desc`; přidávat sloty dokud `kumulované_Wh ≤ min(available_soc, exportable_full × discharge_slot_buffer)` (`per_slot` = max_discharge × účinnost × 0,25 h). Jedna i více slotů podle rozpočtu; žádný pevný top-3.
|
||||
|
||||
**Ověření:** `pytest backend/tests/test_planning_dispatch_milp.py -k EveningPushBudget` a celý soubor MILP.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-28 — noční/ranní výboj baterie před buy<0 (v23)
|
||||
|
||||
**Požadavek:** Před ranním oknem záporných cen **vybít baterii do sítě** (ne jen ~500 W do domu), aby zůstala kapacita na levný import v `buy<0`.
|
||||
|
||||
Reference in New Issue
Block a user