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

This commit is contained in:
Dusan Vojacek
2026-05-23 22:30:46 +02:00
parent dbc004a949
commit 0f922c91f5
4 changed files with 192 additions and 2 deletions

View File

@@ -215,6 +215,63 @@ def _select_charge_slots(
if float(s.buy_price) < 0: if float(s.buy_price) < 0:
selected.add(t) selected.add(t)
elif purchase_pricing_mode == "fixed" and any(
float(s.sell_price) > float(s.buy_price) + degrad for s in slots
):
am_candidates = [
(t, getattr(slots[t], "is_predicted_price", False))
for t in range(len(slots))
if _prague_hour(slots[t]) < 12
]
am_candidates.sort(
key=lambda x: (
_grid_sort_key(x[0], x[1], 0.0)[0],
_grid_sort_key(x[0], x[1], 0.0)[1],
_grid_sort_key(x[0], x[1], 0.0)[2],
x[0],
)
)
cum = 0.0
grid_am = 0
for t, _pred in am_candidates:
if cum >= chg_am or per_slot_full_wh <= 0 or grid_am >= cap_am:
break
selected.add(t)
cum += per_slot_full_wh
grid_am += 1
grid_filled_wh += cum
chg_pm = max(chg_pm, charge_target_wh - grid_filled_wh)
if per_slot_full_wh > 0:
cap_pm = max(
cap_pm,
min(
_MAX_GRID_CHARGE_CAP,
int(chg_pm / per_slot_full_wh * buf_mult) + 1,
),
)
pm_candidates = [
(t, getattr(slots[t], "is_predicted_price", False))
for t in range(len(slots))
if _prague_hour(slots[t]) >= 12
]
pm_candidates.sort(
key=lambda x: (
_grid_sort_key(x[0], x[1], 0.0)[0],
_grid_sort_key(x[0], x[1], 0.0)[1],
_grid_sort_key(x[0], x[1], 0.0)[2],
x[0],
)
)
cum = 0.0
grid_pm = 0
for t, _pred in pm_candidates:
if cum >= chg_pm or per_slot_full_wh <= 0 or grid_pm >= cap_pm:
break
selected.add(t)
cum += per_slot_full_wh
grid_pm += 1
grid_filled_wh += cum
pv_layer_cap = max(charge_target_wh - grid_filled_wh, 0.0) pv_layer_cap = max(charge_target_wh - grid_filled_wh, 0.0)
pv_candidates: list[tuple[int, float, float]] = [] pv_candidates: list[tuple[int, float, float]] = []
for t, s in enumerate(slots): for t, s in enumerate(slots):
@@ -636,7 +693,8 @@ class SelectDischargeExportSlotsTests(unittest.TestCase):
class FixedPurchasePricingTests(unittest.TestCase): class FixedPurchasePricingTests(unittest.TestCase):
def test_fixed_skips_non_pv_grid_charge_slots(self) -> None: def test_fixed_skips_grid_charge_when_no_sell_arbitrage(self) -> None:
"""Fixní buy bez výkupu nad buy+degrad → žádné grid nabíjení."""
slots = [ slots = [
_slot(buy=6.35, sell=2.0, hour_utc=14, load=500), _slot(buy=6.35, sell=2.0, hour_utc=14, load=500),
_slot(buy=6.35, sell=3.5, hour_utc=18, load=500), _slot(buy=6.35, sell=3.5, hour_utc=18, load=500),
@@ -650,6 +708,31 @@ class FixedPurchasePricingTests(unittest.TestCase):
) )
self.assertEqual(out, set()) self.assertEqual(out, set())
def test_fixed_grid_charge_before_evening_export(self) -> None:
"""BA81: konstantní buy, večerní sell > buy+degrad → NT/AM grid sloty."""
base = datetime(2026, 5, 24, 0, 0, tzinfo=_PRAGUE)
slots: list[PlanningSlot] = []
for i in range(96):
t = base + timedelta(minutes=15 * i)
sell = 3.75 if t.hour >= 18 else 2.8
slots.append(
_slot(
buy=3.088,
sell=sell,
load=200,
interval_start=t.astimezone(timezone.utc),
)
)
battery = _battery(charge_buf=1.3, uc_wh=12_500.0)
out = _select_charge_slots(
slots,
battery,
current_soc_wh=0.33 * battery.usable_capacity_wh,
purchase_pricing_mode="fixed",
)
night = {t for t in out if _prague_hour(slots[t]) < 8}
self.assertGreater(len(night), 0, "očekáváno grid nabíjení v noci před večerním výkupem")
def test_fixed_allows_discharge_on_high_sell(self) -> None: def test_fixed_allows_discharge_on_high_sell(self) -> None:
slots = [ slots = [
_slot(buy=3.09, sell=1.0, hour_utc=10), _slot(buy=3.09, sell=1.0, hour_utc=10),

View File

@@ -503,6 +503,88 @@ begin
update _ems_plan_slot_wk wk update _ems_plan_slot_wk wk
set allow_charge = true, allow_grid_charge = true set allow_charge = true, allow_grid_charge = true
where wk.buy_price < 0; where wk.buy_price < 0;
elsif exists (
select 1
from _ems_plan_slot_wk w2
where w2.sell_price > w2.buy_price + v_degrad_czk_kwh
) then
-- Fixní nákup (BA81): buy konstantní — grid nabíjení před exportním oknem, AM/PM rozpočet.
v_cum := 0;
v_grid_slots_am := 0;
for r_slot in
select wk.slot_ord
from _ems_plan_slot_wk wk
where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12
order by
case
when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague
then 0
else 1
end,
case
when v_export_window_start is not null
and wk.interval_start < v_export_window_start
then 0
else 1
end,
wk.is_predicted_price::int,
wk.slot_ord
loop
exit when v_cum >= v_chg_am_wh;
exit when v_per_slot_charge_wh <= 0;
exit when v_grid_slots_am >= v_grid_charge_cap_am;
update _ems_plan_slot_wk wk
set allow_charge = true, allow_grid_charge = true
where wk.slot_ord = r_slot.slot_ord;
v_cum := v_cum + v_per_slot_charge_wh;
v_grid_slots_am := v_grid_slots_am + 1;
end loop;
v_grid_filled_wh := v_grid_filled_wh + v_cum;
v_chg_pm_wh := greatest(v_chg_pm_wh, v_grid_target_wh - v_grid_filled_wh);
if v_per_slot_charge_wh > 0 and v_charge_buf > 0 then
v_grid_charge_cap_pm := greatest(
v_grid_charge_cap_pm,
least(24, ceil((v_chg_pm_wh / v_per_slot_charge_wh) * v_charge_buf)::int)
);
elsif v_per_slot_charge_wh > 0 then
v_grid_charge_cap_pm := greatest(
v_grid_charge_cap_pm,
least(24, ceil(v_chg_pm_wh / v_per_slot_charge_wh)::int)
);
end if;
v_cum := 0;
v_grid_slots_pm := 0;
for r_slot in
select wk.slot_ord
from _ems_plan_slot_wk wk
where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12
order by
case
when (wk.interval_start at time zone 'Europe/Prague')::date = v_plan_day_prague
then 0
else 1
end,
case
when v_export_window_start is not null
and wk.interval_start < v_export_window_start
then 0
else 1
end,
wk.is_predicted_price::int,
wk.slot_ord
loop
exit when v_cum >= v_chg_pm_wh;
exit when v_per_slot_charge_wh <= 0;
exit when v_grid_slots_pm >= v_grid_charge_cap_pm;
update _ems_plan_slot_wk wk
set allow_charge = true, allow_grid_charge = true
where wk.slot_ord = r_slot.slot_ord;
v_cum := v_cum + v_per_slot_charge_wh;
v_grid_slots_pm := v_grid_slots_pm + 1;
end loop;
v_grid_filled_wh := v_grid_filled_wh + v_cum;
end if; end if;
-- A) PV-surplus: jen zbytek kapacity po grid vrstvě B -- A) PV-surplus: jen zbytek kapacity po grid vrstvě B

View File

@@ -12,7 +12,7 @@
- **SoC kontinuita a export z baterie:** `soc[t]` klesá při **`bd[t]` i `ge_bat[t]`** (vybíjení do domu i do sítě). Bez `ge_bat` v bilanci SoC LP „exportovalo“ bez vybití — arbitrážní dump v pozdních slotech místo ranního peaku. - **SoC kontinuita a export z baterie:** `soc[t]` klesá při **`bd[t]` i `ge_bat[t]`** (vybíjení do domu i do sítě). Bez `ge_bat` v bilanci SoC LP „exportovalo“ bez vybití — arbitrážní dump v pozdních slotech místo ranního peaku.
- **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. - **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). - **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):** spot, 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`). Výběr: **nejlevnější `buy`** v pásmu (den plánu → před exportním oknem → `buy ASC`). Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **Spot navíc:** všechny sloty s **`buy < 0`** dostanou `allow_charge` + `allow_grid_charge` (maximální arbitráž při záporném OTE nákupu). **`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`. **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). - **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). - **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):** 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 ≤ load + bc_gi`; mimo `allow_discharge_export`: `bd ≤ load pv_ld` a **`pv_ld ≥ load gi bd`**. Snapshot: `load_first_enabled=true`. Test `LoadFirstDispatchTests`. - **Load-first (Deye, AUTO):** 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 ≤ load + bc_gi`; mimo `allow_discharge_export`: `bd ≤ load pv_ld` a **`pv_ld ≥ load gi bd`**. Snapshot: `load_first_enabled=true`. Test `LoadFirstDispatchTests`.

View File

@@ -83,6 +83,31 @@ where pr.site_id=4 and pr.status='active'
--- ---
## 2026-05-24 (c) — BA81: fixní tarif bez grid nabíjení
**Problém:** Po deployi run **15810**`max_chg ≈ 3275 W`, **`allow_grid_charge = 0`** na všech slotech. Noc 0004 jen import pro dům (~100 W), žádné NT nabíjení ze sítě. HW limit BA81 je **6250 W** (`bms_max_charge_w`), ne 18 kW.
**Příčina:** V `R__063` vrstva **B (grid)** běžela jen pro `purchase_pricing_mode <> 'fixed'`. BA81 má **`fixed`** → masky povolily jen **PV vrstvu A** (Wh rozpočet rozdělený přes denní FVE sloty → postupné ~3 kW).
**Oprava:** Pro `fixed` + existuje arbitráž (`sell > buy + degrad`) → stejná AM/PM logika grid slotů jako u spotu, řazení podle času slotu (`slot_ord`), před `export_window_start`.
**Ověření po `flyway migrate` + replan:**
```sql
select count(*) filter (where (m->>'allow_grid_charge')::boolean) as grid_slots
from ems.planning_run pr, jsonb_array_elements(pr.solver_params->'masks') m
where pr.site_id = (select id from ems.site where code='BA81') and pr.status='active';
-- očekáváno > 0
select max(pi.battery_setpoint_w), max(pi.grid_setpoint_w) filter (where pi.grid_setpoint_w > 1000)
from ems.planning_interval pi
join ems.planning_run pr on pr.id = pi.run_id
where pr.site_id = (select id from ems.site where code='BA81') and pr.status='active';
-- battery/grid nabíjení řádově k 6250 W v NT slotech
```
---
## Šablona pro další záznamy ## Šablona pro další záznamy
```markdown ```markdown