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

This commit is contained in:
Dusan Vojacek
2026-05-29 23:34:16 +02:00
parent 308c24f029
commit d3e9caf0fb
5 changed files with 154 additions and 11 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-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).

View File

@@ -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."""

View File

@@ -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 (511 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

View File

@@ -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, buysell)`; 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&lt;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 **1116h** jen na dnech **bez sell&lt;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&lt;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&lt;0, když večerní peak sell &gt; buy + degrad;
- **R__063 `evening_arbitrage_unlock`:** grid nabíjení **1116h** jen na dnech **bez sell&lt;0**, když večerní peak sell &gt; buy + degrad;
- **bez predawn push** (0206h); **`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&lt;0 oknem:**
- **`neg_day_no_grid_before_neg_sell`:** na kalendářní den s sell&lt;0 **žádné grid nabíjení před 1. sell&lt;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&lt;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í)

View File

@@ -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&lt;0 (v44)
**Problém (v43 na home-01 30. 5.):** Ráno **05:4507:30** grid+bat nabíjení za **~2,63,7 Kč/kWh** → SoC **~99 %** ještě před **07:45 sell&lt;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&lt;0**, hodiny **1116** (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&lt;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&lt;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 &lt; ~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 **0206h** (sell &lt; 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.