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

This commit is contained in:
Dusan Vojacek
2026-05-26 14:57:52 +02:00
parent 96b16b9ff9
commit 19108002ca
4 changed files with 110 additions and 12 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_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36d"
PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36e"
# 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
# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat).
@@ -2047,6 +2047,20 @@ def solve_dispatch(
slots,
degrad_czk_kwh=float(degradation_cost_effective),
)
purchase_fixed_pre = _purchase_pricing_fixed(grid)
block_export_neg_sell_pre = bool(
getattr(grid, "block_export_on_negative_sell", False)
)
if purchase_fixed_pre and block_export_neg_sell_pre:
evening_peak_export_ts = sorted(
set(evening_peak_export_ts)
| {
t
for t, st in enumerate(slots)
if _in_night_battery_export_window(st)
and float(st.sell_price) > 0.0
}
)
non_negative_buys_pre = [
float(s.buy_price) for s in slots if float(s.buy_price) >= 0.0
]
@@ -2056,7 +2070,6 @@ def solve_dispatch(
else min(float(s.buy_price) for s in slots)
)
min_spread_pre = float(degradation_cost_effective)
purchase_fixed_pre = _purchase_pricing_fixed(grid)
fixed_tariff_like_pre = purchase_fixed_pre or _horizon_fixed_tariff_like(slots)
neg_sell_phases_en = (
om == "AUTO"
@@ -2153,6 +2166,14 @@ def solve_dispatch(
fixed_tariff=fixed_tariff_like_pre,
):
profitable_export_ts_pre.add(_t)
elif (
purchase_fixed_pre
and block_export_neg_sell_pre
and _t in evening_peak_export_ts
and float(slots[_t].sell_price) > 0.0
):
# KV1: večerní sell může být < fixní buy; peak sloty stejně vývoz bat.
profitable_export_ts_pre.add(_t)
evening_push_ts: set[int] = set()
evening_early_export_penalty_ts: set[int] = set()
if om == "AUTO":
@@ -3287,11 +3308,18 @@ def solve_dispatch(
or t < first_neg_buy_idx
)
) or (
# Spot (home-01, KV1): při sell>=0 neblokovat ge_pv — solver export vs bc_pv;
# šetření na večerní peak = ge_bat, ne curtail FVE (pv_store jen sell<0 / fixed).
# Spot: při sell>=0 neblokovat ge_pv (export vs bc_pv; večerní peak = ge_bat).
not purchase_fixed_pre
and sell_t >= 0
and pv_surplus_w > 500
) or (
# KV1 (fixed + block_export, jen PV A): bez pole B neplatí fixed_pv_b_export_cap;
# jinak ge_pv==0 → plný curtail při plné baterii místo prodeje do site.
purchase_fixed_pre
and bool(getattr(grid, "block_export_on_negative_sell", False))
and float(s.pv_b_forecast_w) <= 0.0
and sell_t >= 0.0
and pv_surplus_w > 500
)
# BA81: export pole B jen při kladném sell (po sell<0 jinak ge==0 výše).
fixed_pv_b_export_cap = (

View File

@@ -3190,20 +3190,73 @@ class PreNegativeSellExportTests(unittest.TestCase):
max_import_power_w=17_000,
max_export_power_w=8000,
block_export_on_negative_sell=True,
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),
]
soc0 = 0.85 * battery.soc_max_wh
results, _, _ = solve_dispatch(
results, _, snap = solve_dispatch(
slots, battery, hp, grid, [None, None], vehicles, soc0, 50.0, operating_mode="AUTO"
)
self.assertLess(results[0].grid_setpoint_w, -500, "ráno: přebytek FVE do sítě před sell<0")
self.assertLess(results[0].pv_a_curtailed_w, 500, "fixed KV1: ne plný curtail při kladném sell")
neg = results[8]
self.assertGreater(neg.battery_setpoint_w, 500, "záporný sell: PV do baterie")
self.assertEqual(neg.export_mode, "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)."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 26, 17, 0, tzinfo=prague)
sells = [1.9, 3.0, 3.7, 2.0, 2.8, 3.3, 4.0, 2.9, 3.5, 4.4, 6.57, 5.4, 5.5, 5.1, 5.2, 4.3]
slots: list[PlanningSlot] = []
for i, sell in enumerate(sells):
slots.append(
PlanningSlot(
interval_start=(base + timedelta(minutes=15 * i)).astimezone(timezone.utc),
buy_price=6.35,
sell_price=sell,
pv_a_forecast_w=800 if sell < 4 else 200,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=sell > 0,
charge_acquisition_buy_czk_kwh=6.35,
future_sell_opportunity_czk_kwh=6.57,
)
)
battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0)
battery.max_discharge_power_w = 6250
battery.discharge_slot_buffer = 1.5
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=True,
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",
)
self.assertLess(res[10].grid_setpoint_w, -500, "20:15 sell<buy: vývoz bat, ne jen peak 19:45")
self.assertLess(res[0].pv_a_curtailed_w, 500, "17:15: FVE do sítě, ne curtail")
class Home01PvStoreValueTests(unittest.TestCase):
"""FVE: spot sell<0 → nabít/vent B; sell>=0 → LP volí export vs bc (ne tvrdý curtail)."""
@@ -4195,7 +4248,7 @@ class NegSellPrepWindowV36Tests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-prep-window-v36d")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-prep-window-v36e")
anchors = snap["inputs"].get("neg_evening_reserve_soc_anchors") or []
self.assertGreaterEqual(len(anchors), 1)
anchor_iso = anchors[-1]["slot"]

View File

@@ -71,6 +71,7 @@ declare
v_ref_buy_am_czk_kwh numeric;
v_ref_buy_pm_czk_kwh numeric;
v_purchase_pricing_mode text;
v_block_export_neg_sell boolean;
v_lookahead_slots int := 4;
v_grid_charge_cap_am int;
v_grid_charge_cap_pm int;
@@ -281,6 +282,13 @@ begin
v_purchase_pricing_mode := coalesce(v_purchase_pricing_mode, 'spot');
select coalesce(sgc.block_export_on_negative_sell, false)
into v_block_export_neg_sell
from ems.site_grid_connection sgc
where sgc.site_id = p_site_id;
v_block_export_neg_sell := coalesce(v_block_export_neg_sell, false);
v_per_slot_charge_wh := v_max_charge_w * v_charge_eff * 0.25;
v_per_slot_discharge_wh := v_max_discharge_w * v_discharge_eff * 0.25;
v_energy_to_fill := v_soc_max_wh - p_current_soc_wh;
@@ -853,15 +861,14 @@ begin
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
-- Večerní peak: vyvést i když sell < fixní buy (KV1), pokud je to denní maximum výkupu.
true
when v_purchase_pricing_mode = 'fixed'
and v_block_export_neg_sell then
-- KV1: fixní buy ~6,3; večerní sell často < buy — vývoz ve všech kladných sell slotech ≥17h.
wk.sell_price > 0
else
-- Spot (home-01): denní večerní maximum výkupu; sell často < buy v tomže slotu.
true
wk.sell_price >= r_slot.evening_peak_sell - v_degrad_czk_kwh
end
);
end if;

View File

@@ -11,6 +11,16 @@ 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).
## 2026-05-28 — KV1 fixed + block_export (v36e)
**Kód:** `planning_engine.py` tag `2026-05-28-neg-prep-window-v36e`; `R__063_fn_load_planning_slots_full.sql`.
**Problém:** KV1 (fixní buy ~6,35, jen PV A, `block_export_on_negative_sell`) — od v34/v36 logiky pro spot/home-01: ráno **curtail** místo exportu do site; večer jen **jeden** discharge slot (sell peak 6,57 vs buy 6,35). BA81 má pole B (`fixed_pv_b_export_cap`) a nižší buy → chová se správně.
**Změna:** `skip_pv_store_block` pro fixed+block_export bez PV B při `sell≥0`; večerní `evening_peak_export_ts` + profitable export pro všechny kladné sell sloty v nočním okně; SQL maska `allow_discharge_export` stejně pro KV1 večer.
**Ověření:** `PreNegativeSellExportTests` (s `purchase_pricing_mode=fixed`); po deployi KV1 plán: odpoledne `PV_SURPLUS` / export, večer více `BATTERY_SELL` slotů.
## 2026-05-28 — Přípravné okno neg dne (v36 / v36b / v36d)
**Kód:** `backend/services/planning_engine.py` — tag `2026-05-28-neg-prep-window-v36d`.