dalsi oprava
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, 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).
|
- **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):** 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`.
|
||||||
|
|||||||
@@ -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 00–04 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
|
||||||
|
|||||||
Reference in New Issue
Block a user