dalsi
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_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
|
||||
PLANNER_BUILD_TAG = "2026-05-29-night-selfconsume-evening-arb-v43"
|
||||
PLANNER_BUILD_TAG = "2026-05-29-neg-day-pv-headroom-v44"
|
||||
# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu).
|
||||
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0
|
||||
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
|
||||
@@ -895,6 +895,20 @@ def _neg_sell_pv_b_charge_wh(slot: PlanningSlot, battery: Any) -> float:
|
||||
return cap_w * INTERVAL_H * float(battery.charge_efficiency)
|
||||
|
||||
|
||||
def _neg_sell_pv_forecast_charge_wh(slot: PlanningSlot, battery: Any) -> float:
|
||||
"""Odhad Wh z FVE A+B v sell<0 slotu pro zpětnou projekci soc_need (v44)."""
|
||||
pv_surplus = max(
|
||||
0.0,
|
||||
float(slot.pv_a_forecast_w)
|
||||
+ float(slot.pv_b_forecast_w)
|
||||
- float(slot.load_baseline_w),
|
||||
)
|
||||
if pv_surplus <= 500.0:
|
||||
return 0.0
|
||||
cap_w = min(pv_surplus, float(battery.max_charge_power_w))
|
||||
return cap_w * INTERVAL_H * float(battery.charge_efficiency)
|
||||
|
||||
|
||||
def _neg_sell_day_pv_b_usable_wh(
|
||||
slots: list[PlanningSlot],
|
||||
first_neg_sell_idx: int | None,
|
||||
@@ -975,7 +989,9 @@ def _neg_sell_day_phases(
|
||||
indices.sort()
|
||||
last_t = indices[-1]
|
||||
tail_start = max(indices[0], last_t - tail_n + 1) if tail_n > 0 else last_t + 1
|
||||
charge_b = {t: _neg_sell_pv_b_charge_wh(slots[t], battery) for t in indices}
|
||||
charge_b = {
|
||||
t: _neg_sell_pv_forecast_charge_wh(slots[t], battery) for t in indices
|
||||
}
|
||||
soc_need: dict[int, float] = {last_t: soc_max}
|
||||
for i in range(len(indices) - 1, 0, -1):
|
||||
t_cur = indices[i]
|
||||
@@ -3586,6 +3602,14 @@ def solve_dispatch(
|
||||
# v33: při dostatečné FVE v sell<0 okně neukládat ranní PV do baterie — export.
|
||||
prob += bc_pv[t_pne] == 0
|
||||
|
||||
# v44: neg den — před 1. sell<0 žádné grid→bat (AM sloty za ~3 Kč vs FVE v okně).
|
||||
if neg_sell_phases_en and first_neg_sell_idx is not None:
|
||||
neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
|
||||
for t_blk in range(first_neg_sell_idx):
|
||||
if _prague_calendar_date(slots[t_blk]) != neg_day:
|
||||
continue
|
||||
prob += bc_gi[t_blk] == 0
|
||||
|
||||
# Ekonomické guardy: ceny v objective nestačí proti maskám / terminal SoC.
|
||||
# Referenční buy jen z ne-záporných slotů: jinak jeden buy<0 v horizontu označí
|
||||
# téměř všechny sloty jako „drahé“ (gi=0 pro dům) → Infeasible (home-01).
|
||||
|
||||
@@ -4260,10 +4260,19 @@ class NegSellSocPhaseTests(unittest.TestCase):
|
||||
_ph, _tg, _w, meta = _neg_sell_day_phases(slots, bat)
|
||||
self.assertIsNotNone(meta.get("t_detach_idx"))
|
||||
self.assertGreaterEqual(int(meta["t_detach_idx"]), 0)
|
||||
self.assertLess(int(meta["t_detach_idx"]), 8)
|
||||
self.assertLessEqual(int(meta["t_detach_idx"]), 8)
|
||||
self.assertGreater(float(meta.get("e_surplus_after_t_wh") or 0), 0.0)
|
||||
self.assertIn("post_detach_prep_ts", meta)
|
||||
|
||||
def test_prep_leaves_headroom_when_pv_a_b_forecast_high(self) -> None:
|
||||
"""v44: zpětná soc_need z A+B FVE, ne jen B — 1. sell<0 cíl pod soc_max."""
|
||||
slots = self._neg_sell_slots(12, pv_a=8000, pv_b=6000)
|
||||
bat = self._phase_battery(tail_slots=4)
|
||||
_ph, targets, _w, meta = _neg_sell_day_phases(slots, bat)
|
||||
first_neg = int(meta["days"][0]["first_neg_idx"])
|
||||
tgt_first = float(targets[first_neg] or 0)
|
||||
self.assertLess(tgt_first, bat.soc_max_wh * 0.95)
|
||||
|
||||
def test_t_detach_not_first_neg_on_long_sunny_day(self) -> None:
|
||||
"""Bod T až po nabití rampy (~85 % soc_max), ne na prvním sell<0 slotu."""
|
||||
slots = self._neg_sell_slots(24, pv_b=7000, pv_a=5000)
|
||||
@@ -4703,6 +4712,82 @@ class NegSellPrepWindowV36Tests(unittest.TestCase):
|
||||
self.assertIsNotNone(snap["inputs"].get("neg_evening_export_budget_wh"))
|
||||
|
||||
|
||||
class NegDayPvHeadroomV44Tests(unittest.TestCase):
|
||||
"""v44: neg den — žádný grid před sell<0; headroom pro FVE + levný buy v okně."""
|
||||
|
||||
def test_no_grid_charge_before_first_negative_sell(self) -> None:
|
||||
prague = ZoneInfo("Europe/Prague")
|
||||
base = datetime(2026, 5, 30, 5, 45, tzinfo=prague)
|
||||
slots: list[PlanningSlot] = []
|
||||
first_neg_idx: int | None = None
|
||||
for i in range(24):
|
||||
local = base + timedelta(minutes=15 * i)
|
||||
sell = (
|
||||
-0.18
|
||||
if local.hour > 7 or (local.hour == 7 and local.minute >= 45)
|
||||
else 3.0
|
||||
)
|
||||
if first_neg_idx is None and sell < 0:
|
||||
first_neg_idx = i
|
||||
buy = 3.2 if local.hour < 8 else 0.48
|
||||
allow_chg = sell < 0
|
||||
slots.append(
|
||||
PlanningSlot(
|
||||
interval_start=local.astimezone(timezone.utc),
|
||||
buy_price=buy,
|
||||
sell_price=sell,
|
||||
pv_a_forecast_w=4000 if local.hour >= 8 else 500,
|
||||
pv_b_forecast_w=3000 if local.hour >= 8 else 500,
|
||||
load_baseline_w=2000,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=allow_chg,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
)
|
||||
self.assertIsNotNone(first_neg_idx)
|
||||
bat = NegSellSocPhaseTests._phase_battery()
|
||||
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=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, _, _ = solve_dispatch(
|
||||
slots,
|
||||
bat,
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
0.50 * bat.soc_max_wh,
|
||||
50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
assert first_neg_idx is not None
|
||||
for t in range(first_neg_idx):
|
||||
self.assertLessEqual(
|
||||
res[t].battery_setpoint_w,
|
||||
200,
|
||||
msg=f"grid/PV bat charge before neg at slot {t}",
|
||||
)
|
||||
self.assertLess(
|
||||
res[first_neg_idx].battery_soc_target,
|
||||
92.0,
|
||||
"baterie nesmí být plná těsně před sell<0 oknem",
|
||||
)
|
||||
|
||||
|
||||
class ObservedSocNegPrepTests(unittest.TestCase):
|
||||
"""v40: neg-prep a večerní výboj z pozorovaného SoC (telemetrie), ne z LP trajektorie."""
|
||||
|
||||
|
||||
@@ -874,7 +874,7 @@ begin
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
-- v43: levný grid před prvním sell<0, když tentýž den večer (≥17h) dává arbitráž buy→sell.
|
||||
-- v43: levný grid před prvním sell<0 jen na dnech BEZ sell<0 (normální arbitráž odpoledne→večer).
|
||||
update _ems_plan_slot_wk wk
|
||||
set allow_charge = true,
|
||||
allow_grid_charge = true,
|
||||
@@ -899,12 +899,25 @@ begin
|
||||
and wk.buy_price >= 0
|
||||
and wk.buy_price + v_degrad_czk_kwh < ep.evening_peak_sell
|
||||
and extract(hour from wk.interval_start at time zone 'Europe/Prague')
|
||||
< v_evening_peak_start_hour
|
||||
and (
|
||||
v_first_neg_sell_ord is null
|
||||
or wk.slot_ord < v_first_neg_sell_ord
|
||||
between 11 and 16
|
||||
and not exists (
|
||||
select 1
|
||||
from _ems_plan_slot_wk wn
|
||||
where wn.sell_price < 0
|
||||
and (wn.interval_start at time zone 'Europe/Prague')::date = ep.plan_date
|
||||
);
|
||||
|
||||
-- v44: neg den — žádné grid nabíjení před 1. sell<0 (místo pro FVE; ne 3 Kč místo 0,5 Kč v okně).
|
||||
if v_first_neg_sell_ord is not null and v_first_neg_prague_date is not null then
|
||||
update _ems_plan_slot_wk wk
|
||||
set allow_charge = false,
|
||||
allow_grid_charge = false,
|
||||
grid_charge_suppressed_reason = 'neg_day_no_grid_before_neg_sell'
|
||||
where 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.allow_grid_charge;
|
||||
end if;
|
||||
|
||||
-- Ranní pásmo před prvním sell<0 (5–11 Prague): lokální peak, ne půlnoc celého dne.
|
||||
if v_first_neg_sell_ord is not null
|
||||
and v_first_neg_prague_date is not null
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- **SoC kontinuita a export z baterie:** `soc[t]` klesá při **`bd[t]`** — výkon vybíjení na AC sběrnici z energetické bilance `pv + gi + bd = load + bc + ge`. Při exportu z baterie je v `bd` už započten i tok do sítě (`ge_bat` je součást `ge`); **`ge_bat` se v SoC znovu neodečítá** (dříve double-count → plán klesal ~2× rychleji než BMS ve večerním exportu). Tag `2026-05-28-evening-export-soc-balance-v39`.
|
||||
- **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).
|
||||
- **Grid ze sítě (vrstva B, před FVE):** 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`). **Spot:** výběr **nejlevnější `buy`** (den plánu → před exportním oknem → `buy ASC`); navíc všechny sloty s **`buy < 0`** → `allow_grid_charge`. Po výběru AM/PM běží **iterativní self-konzistentní filtr** (vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 %` deficitu SoC; failsafe unlock). **v43 `evening_arbitrage_unlock`:** před prvním sell<0 povolí grid nabíjení, když tentýž den večer (≥17h) `buy + degrad < evening_peak_sell`. Debug: `grid_charge_suppressed_reason`. **Fixní tarif (BA81):** stejný AM/PM rozpočet, ale pořadí podle **`slot_ord`** (buy konstantní), jen pokud v horizontu existuje **`sell > buy + degradation`**; jinak jen PV vrstva A. Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **`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):** 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`). **Spot:** výběr **nejlevnější `buy`** (den plánu → před exportním oknem → `buy ASC`); navíc všechny sloty s **`buy < 0`** → `allow_grid_charge`. Po výběru AM/PM běží **iterativní self-konzistentní filtr** (vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 %` deficitu SoC; failsafe unlock). **v43 `evening_arbitrage_unlock`:** grid **11–16h** jen na dnech **bez sell<0**, když večer `buy + degrad < evening_peak_sell`. **v44 `neg_day_no_grid_before_neg_sell`:** na neg den **žádný grid před 1. sell<0**. Debug: `grid_charge_suppressed_reason`. **Fixní tarif (BA81):** stejný AM/PM rozpočet, ale pořadí podle **`slot_ord`** (buy konstantní), jen pokud v horizontu existuje **`sell > buy + degradation`**; jinak jen PV vrstva A. Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`.
|
||||
- **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).
|
||||
- **Load-first (Deye, AUTO, tvrdý od v34):** 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 ≤ bc_gi + max(0, max_load − pv_forecast)`** (při vysoké FVE žádný fiktivní import = load); při `pv ≥ load + 500 W` **`pv_ld ≥ load`**; mimo `allow_discharge_export`: `bd ≤ load − pv_ld`, `pv_ld ≥ load − bd`. Tag `2026-05-28-load-first-hard-v34`. Test `LoadFirstDispatchTests`.
|
||||
@@ -100,10 +100,15 @@ flowchart TD
|
||||
3. **v43 — večerní push + nocí vlastní spotřeba + odpolední arbitráž** (`evening_push_ts`):
|
||||
- push jen **≥17h Prague** + `allow_discharge_export`; rozpočet Wh **per kalendářní večer** (druhý den v horizontu ne prázdný);
|
||||
- mimo push: **`night_self_consume_discourage`** — baterie krmí dům, ne import ~5 Kč/kWh;
|
||||
- **R__063 `evening_arbitrage_unlock`:** grid nabíjení odpoledne před sell<0, když večerní peak sell > buy + degrad;
|
||||
- **R__063 `evening_arbitrage_unlock`:** grid nabíjení **11–16h** jen na dnech **bez sell<0**, když večerní peak sell > buy + degrad;
|
||||
- **bez predawn push** (02–06h); **`peak_export_shortfall`** v noci vypnutý.
|
||||
|
||||
**Funkce:** `_evening_push_calendar_segments`, `_night_self_consume_discourage_import_indices`, `_in_evening_push_hour_window`, … Tag: **`2026-05-29-night-selfconsume-evening-arb-v43`**.
|
||||
4. **v44 — neg den: místo pro FVE před sell<0 oknem:**
|
||||
- **`neg_day_no_grid_before_neg_sell`:** na kalendářní den s sell<0 **žádné grid nabíjení před 1. sell<0** (ne 3 Kč ráno místo 0,5 Kč v okně);
|
||||
- **`_neg_sell_pv_forecast_charge_wh`:** zpětná soc_need z **A+B** FVE, ne jen pole B;
|
||||
- LP **`bc_gi=0`** před 1. sell<0 na neg den.
|
||||
|
||||
**Funkce:** `_evening_push_calendar_segments`, `_night_self_consume_discourage_import_indices`, `_neg_sell_pv_forecast_charge_wh`, … Tag: **`2026-05-29-neg-day-pv-headroom-v44`**.
|
||||
|
||||
### Arbitráž baterie — účtování mezi sloty (povinné čtení)
|
||||
|
||||
|
||||
@@ -5,6 +5,22 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-29 — Neg den: headroom pro FVE, ne grid za 3 Kč před sell<0 (v44)
|
||||
|
||||
**Problém (v43 na home-01 30. 5.):** Ráno **05:45–07:30** grid+bat nabíjení za **~2,6–3,7 Kč/kWh** → SoC **~99 %** ještě před **07:45 sell<0**. Pak **PV A plně utlumena**, **PV B** do site za záporný sell; levný **buy ~0,48 Kč** v 11h nevyužit. Příčiny: (1) **`evening_arbitrage_unlock`** povolil drahý grid před neg oknem; (2) AM maska brala nejlevnější buy **před polednem**, ne v neg okně; (3) **`soc_need`** zpětně počítal jen **PV B**, ne A+B → cíl prep ≈ **soc_max**.
|
||||
|
||||
**Změna (v44):**
|
||||
- **`evening_arbitrage_unlock`** jen na dnech **bez sell<0**, hodiny **11–16** (normální odpolední→večerní arbitráž).
|
||||
- **`neg_day_no_grid_before_neg_sell`:** na neg kalendářní den **`allow_grid_charge=false`** pro všechny sloty **před 1. sell<0**.
|
||||
- **`_neg_sell_pv_forecast_charge_wh`:** zpětná projekce soc_need z **FVE A+B** surplusu, ne jen B.
|
||||
- **LP:** `bc_gi[t]=0` před 1. sell<0 na neg den (pás pro případ masky).
|
||||
|
||||
**Soubory:** `planning_engine.py`, `R__063_fn_load_planning_slots_full.sql`, `test_planning_dispatch_milp.py`, `planning.md`. Tag **`2026-05-29-neg-day-pv-headroom-v44`**.
|
||||
|
||||
**Ověření:** `pytest … -k "NegDayPvHeadroom or prep_leaves_headroom"`; MCP: před 07:45 `allow_grid_charge=false`, `grid_charge_suppressed_reason=neg_day_no_grid_before_neg_sell`; SoC před neg < ~90 %; po svítání PV A ne plný curtail.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-29 — Noc: vlastní spotřeba + večerní arbitráž + push per den (v43)
|
||||
|
||||
**Problém:** (1) Po v42 push exportu plán přes noc **držel SoC ~60 %** a krmil dům ze sítě za **~5 Kč/kWh** místo baterie (acq ~0,7 Kč). (2) Tvrdý push zahrnoval **02–06h** (sell < buy). (3) **Druhý večer** v horizontu neměl push — rozpočet Wh se vyčerpal první nocí. (4) Před neg dnem **grid 0,5 Kč** odpoledne nešel nabíjet (`allow_charge=false`, cheaper_pv_ahead), přitom večer sell **~4 Kč** — arbitráž neproběhla.
|
||||
|
||||
Reference in New Issue
Block a user