dalsi oprava
This commit is contained in:
@@ -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 D−1 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 D−1 (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 D−1 (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:
|
||||||
|
|||||||
@@ -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 D−1 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"]
|
||||||
|
|||||||
@@ -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_*` (D−1 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_neg−1` + výboj ve **všech** kladných sell slotech před 1. `sell<0` (ne jen D−1 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<0 (v36f)
|
## 2026-05-28 — Fixed tarif: export FVE před sell<0 (v36f)
|
||||||
|
|
||||||
**Problém:** BA81 (fixed, sell>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 < future_sell` (pv_store); `fixed_pv_b_export_cap` uvolní jen MI.
|
**Problém:** BA81 (fixed, sell>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 < future_sell` (pv_store); `fixed_pv_b_export_cap` uvolní jen MI.
|
||||||
|
|||||||
Reference in New Issue
Block a user