dalsi oprava
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-27 07:45:50 +02:00
parent 8c7072da07
commit 4e5de5df90
3 changed files with 144 additions and 17 deletions

View File

@@ -71,7 +71,7 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0 PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0 PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36f" PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36g"
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy). # 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 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). # Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat).
@@ -1221,6 +1221,20 @@ def _pre_neg_pv_export_slot_indices(
return out return out
def _discharge_before_first_neg_sell_ts(
slots: list[PlanningSlot],
first_neg_sell_idx: int | None,
) -> set[int]:
"""Všechny kladné-sell sloty před 1. sell<0 (funguje i v rolling bez D1 večera v horizontu)."""
if first_neg_sell_idx is None or first_neg_sell_idx <= 0:
return set()
return {
t
for t in range(first_neg_sell_idx)
if float(slots[t].sell_price) >= 0.0
}
def _evening_discharge_before_neg_day_ts( def _evening_discharge_before_neg_day_ts(
slots: list[PlanningSlot], slots: list[PlanningSlot],
neg_sell_day_meta: dict[str, Any], neg_sell_day_meta: dict[str, Any],
@@ -1257,8 +1271,9 @@ def _neg_evening_reserve_soc_anchors(
battery: Any, battery: Any,
) -> list[tuple[int, float]]: ) -> list[tuple[int, float]]:
""" """
Kotva SoC ≤ reserve_soc na konci večera D1 (typ. 23:45) před pražským dnem D s sell<0. Kotvy SoC ≤ reserve_soc před neg oknem:
Ranní slot před 1. sell<0 nekotvíme — koliduje s prep rampou v neg okně. - večer D1 (23:45) pokud je v horizontu,
- slot těsně před 1. sell<0 (rolling: ráno bez včerejška v okně).
""" """
from datetime import timedelta from datetime import timedelta
@@ -1287,6 +1302,14 @@ def _neg_evening_reserve_soc_anchors(
if t_eve not in seen: if t_eve not in seen:
out.append((t_eve, reserve_wh)) out.append((t_eve, reserve_wh))
seen.add(t_eve) seen.add(t_eve)
if first_neg > 0:
t_pre = first_neg - 1
if (
t_pre not in seen
and float(slots[t_pre].sell_price) >= 0.0
):
out.append((t_pre, reserve_wh))
seen.add(t_pre)
return out return out
@@ -2112,6 +2135,10 @@ def solve_dispatch(
slots, slots,
neg_sell_day_meta, neg_sell_day_meta,
) )
neg_evening_before_neg_ts |= _discharge_before_first_neg_sell_ts(
slots,
first_neg_sell_idx,
)
neg_evening_reserve_anchors = _neg_evening_reserve_soc_anchors( neg_evening_reserve_anchors = _neg_evening_reserve_soc_anchors(
slots, slots,
neg_sell_day_meta, neg_sell_day_meta,
@@ -3297,6 +3324,15 @@ def solve_dispatch(
# FVE export před sell<0 jen pokud forecast v sell<0 okně pokryje dobítí (v33). # FVE export před sell<0 jen pokud forecast v sell<0 okně pokryje dobítí (v33).
allow_pre_neg_pv_export = t in pre_neg_pv_export_ts allow_pre_neg_pv_export = t in pre_neg_pv_export_ts
pv_store_val = _pv_store_value_czk_kwh(s, min_spread) pv_store_val = _pv_store_value_czk_kwh(s, min_spread)
fixed_pre_neg_pv_export = (
purchase_fixed_pre
and sell_t >= 0.0
and pv_surplus_w > 500.0
and (
first_neg_sell_idx is None
or t < first_neg_sell_idx
)
)
skip_pv_store_block = ( skip_pv_store_block = (
float(s.pv_b_forecast_w) > 0 float(s.pv_b_forecast_w) > 0
and not getattr(grid, "block_export_on_negative_sell", False) and not getattr(grid, "block_export_on_negative_sell", False)
@@ -3312,25 +3348,18 @@ def solve_dispatch(
not purchase_fixed_pre not purchase_fixed_pre
and sell_t >= 0 and sell_t >= 0
and pv_surplus_w > 500 and pv_surplus_w > 500
) or ( ) or fixed_pre_neg_pv_export
# Fixed (BA81/KV1): před prvním sell<0 a sell≥0 neblokovat ge_pv — export A+B do site, # BA81: export pole B jen při kladném sell mimo pre-neg okno (jinak jen ge_pv≤pv_b → curtail A).
# ne curtail (fixed_pv_b_export_cap pokrývá jen MI / pole B).
purchase_fixed_pre
and sell_t >= 0.0
and pv_surplus_w > 500.0
and (
first_neg_sell_idx is None
or t < first_neg_sell_idx
)
)
# BA81: export pole B jen při kladném sell (po sell<0 jinak ge==0 výše).
fixed_pv_b_export_cap = ( fixed_pv_b_export_cap = (
purchase_fixed_pre purchase_fixed_pre
and float(s.pv_b_forecast_w) > 0 and float(s.pv_b_forecast_w) > 0
and not getattr(grid, "block_export_on_negative_sell", False) and not getattr(grid, "block_export_on_negative_sell", False)
and sell_t >= 0 and sell_t >= 0
and not fixed_pre_neg_pv_export
) )
if fixed_pv_b_export_cap: if fixed_pre_neg_pv_export:
prob += ge_pv[t] <= max(0.0, pv_surplus_w)
elif fixed_pv_b_export_cap:
if z_gen_cutoff is not None: if z_gen_cutoff is not None:
prob += ge_pv[t] <= float(s.pv_b_forecast_w) * (1 - z_gen_cutoff[t]) prob += ge_pv[t] <= float(s.pv_b_forecast_w) * (1 - z_gen_cutoff[t])
else: else:

View File

@@ -3206,6 +3206,96 @@ class PreNegativeSellExportTests(unittest.TestCase):
self.assertGreater(neg.battery_setpoint_w, 500, "záporný sell: PV do baterie") self.assertGreater(neg.battery_setpoint_w, 500, "záporný sell: PV do baterie")
self.assertEqual(neg.export_mode, "NONE") self.assertEqual(neg.export_mode, "NONE")
def test_ba81_fixed_morning_exports_pv_a_not_curtail(self) -> None:
"""BA81: před sell<0 export celého přebytku FVE, ne jen MI (pv_b)."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 27, 7, 30, tzinfo=prague)
slots: list[PlanningSlot] = []
for i in range(12):
sell = 3.2 if i < 8 else -0.2
slots.append(
PlanningSlot(
interval_start=(base + timedelta(minutes=15 * i)).astimezone(timezone.utc),
buy_price=3.088,
sell_price=sell,
pv_a_forecast_w=5000,
pv_b_forecast_w=700,
load_baseline_w=200,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=3.088,
future_sell_opportunity_czk_kwh=6.5,
)
)
battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0)
battery.max_charge_power_w = 6250
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=8000,
block_export_on_negative_sell=False,
purchase_pricing_mode="fixed",
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
res, _, _ = solve_dispatch(
slots, battery, hp, grid, [None, None], vehicles,
0.95 * battery.soc_max_wh, 50.0, operating_mode="AUTO",
)
r0 = res[0]
self.assertLess(r0.pv_a_curtailed_w, 500, "pole A nesmí jít do curtail při sell>0 před neg")
self.assertLess(r0.grid_setpoint_w, -4000, "export přebytku A+B do site")
def test_rolling_horizon_drains_to_reserve_before_first_neg(self) -> None:
"""Rolling bez D1 večera: výboj před 1. sell<0 na reserve (+ slack)."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 27, 7, 0, tzinfo=prague)
slots: list[PlanningSlot] = []
for i in range(16):
local = base + timedelta(minutes=15 * i)
sell = 3.0 if i < 10 else -0.2
slots.append(
PlanningSlot(
interval_start=local.astimezone(timezone.utc),
buy_price=5.0,
sell_price=sell,
pv_a_forecast_w=3000,
pv_b_forecast_w=1500,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
)
)
bat = NegSellSocPhaseTests._phase_battery()
bat.reserve_soc_wh = 0.20 * bat.usable_capacity_wh
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=20_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
)
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),
]
res, _, snap = solve_dispatch(
slots, bat, hp, grid, [None, None], vehicles,
0.55 * bat.soc_max_wh, 50.0, operating_mode="AUTO",
)
anchors = snap["inputs"].get("neg_evening_reserve_soc_anchors") or []
self.assertGreaterEqual(len(anchors), 1)
anchor_iso = anchors[-1]["slot"]
idx = next(i for i, s in enumerate(slots) if s.interval_start.isoformat() == anchor_iso)
cap_wh = float(bat.reserve_soc_wh) + 400.0
soc_wh = res[idx].battery_soc_target / 100.0 * bat.soc_max_wh
self.assertLessEqual(soc_wh, cap_wh + 800.0)
def test_kv1_evening_battery_push_when_sell_below_fixed_buy(self) -> None: def test_kv1_evening_battery_push_when_sell_below_fixed_buy(self) -> None:
"""KV1: večerní sell < fixní buy — přesto vývoz bat (ne jen jeden peak slot).""" """KV1: večerní sell < fixní buy — přesto vývoz bat (ne jen jeden peak slot)."""
prague = ZoneInfo("Europe/Prague") prague = ZoneInfo("Europe/Prague")
@@ -4248,7 +4338,7 @@ class NegSellPrepWindowV36Tests(unittest.TestCase):
50.0, 50.0,
operating_mode="AUTO", operating_mode="AUTO",
) )
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-prep-window-v36e") self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-prep-window-v36g")
anchors = snap["inputs"].get("neg_evening_reserve_soc_anchors") or [] anchors = snap["inputs"].get("neg_evening_reserve_soc_anchors") or []
self.assertGreaterEqual(len(anchors), 1) self.assertGreaterEqual(len(anchors), 1)
anchor_iso = anchors[-1]["slot"] anchor_iso = anchors[-1]["slot"]

View File

@@ -11,6 +11,14 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
**Rozhodnutí home-01** (souhrn v [`docs/06-open-questions.md`](06-open-questions.md)): rampa/**T** odvozené z PV B (bez fixních 80 % v LP); TČ ne v pre-neg exportu; bazén min 4 h/den + Shelly; spirála Loxone; **workshop UI flex zátěží před v37** (§ 9.1 strategie). **Rozhodnutí home-01** (souhrn v [`docs/06-open-questions.md`](06-open-questions.md)): rampa/**T** odvozené z PV B (bez fixních 80 % v LP); TČ ne v pre-neg exportu; bazén min 4 h/den + Shelly; spirála Loxone; **workshop UI flex zátěží před v37** (§ 9.1 strategie).
## 2026-05-28 — BA81 export A+B + rolling drain (v36g)
**Problém (v36f):** BA81 — `skip_pv_store` nestačil: `fixed_pv_b_export_cap` držel `ge_pv ≤ pv_b` → curtail pole A. home-01 rolling — prázdné `neg_evening_*` (D1 večer mimo horizont), SoC ~29 % místo ~20 % před `sell<0`.
**Změna (v36g):** Fixed pre-neg: `ge_pv ≤ pv_surplus` (A+B). Spot neg: kotva i na `first_neg1` + výboj ve **všech** kladných sell slotech před 1. `sell<0` (ne jen D1 večer).
**Ověření:** `test_ba81_fixed_morning_exports_pv_a_not_curtail`, `test_rolling_horizon_drains_to_reserve_before_first_neg`; tag **v36g**.
## 2026-05-28 — Fixed tarif: export FVE před sell&lt;0 (v36f) ## 2026-05-28 — Fixed tarif: export FVE před sell&lt;0 (v36f)
**Problém:** BA81 (fixed, sell&gt;3 Kč ráno): plán **curtail** PV A (~3 kW) + export jen **~600 W** (`ge_pv` jen přes pole B). Střídač reálně valí celou FVE — ekonomicky správně, ale plán nesedí. Příčina: `ge_pv=0` při `sell &lt; future_sell` (pv_store); `fixed_pv_b_export_cap` uvolní jen MI. **Problém:** BA81 (fixed, sell&gt;3 Kč ráno): plán **curtail** PV A (~3 kW) + export jen **~600 W** (`ge_pv` jen přes pole B). Střídač reálně valí celou FVE — ekonomicky správně, ale plán nesedí. Příčina: `ge_pv=0` při `sell &lt; future_sell` (pv_store); `fixed_pv_b_export_cap` uvolní jen MI.