doladeni odpoledniho dobiti
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-21 16:18:30 +02:00
parent c9149babd3
commit e295e55770
4 changed files with 91 additions and 15 deletions

View File

@@ -106,7 +106,13 @@ def _select_charge_slots(
eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0)
max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0)
per_slot_full_wh = max_p_w * eta * INTERVAL_H
if current_soc_wh >= reserve_wh:
soc_max_wh = float(getattr(battery, "soc_max_wh", 0) or 0)
if charge_buf > 0:
charge_target_wh = min(
max(energy_to_fill, 0.0) * charge_buf,
max(soc_max_wh - float(current_soc_wh), 0.0),
)
elif current_soc_wh >= reserve_wh:
charge_target_wh = max(energy_to_fill, 0.0)
else:
charge_target_wh = min(
@@ -129,13 +135,26 @@ def _select_charge_slots(
selected: set[int] = set()
grid_filled_wh = 0.0
buf_mult = charge_buf if charge_buf > 0 else 1.0
cap_am = (
max(1, min(_MAX_GRID_CHARGE_CAP, int(chg_am / per_slot_full_wh) + 1))
max(
1,
min(
_MAX_GRID_CHARGE_CAP,
int(chg_am / per_slot_full_wh * buf_mult) + 1,
),
)
if per_slot_full_wh > 0
else 6
)
cap_pm = (
max(1, min(_MAX_GRID_CHARGE_CAP, int(chg_pm / per_slot_full_wh) + 1))
max(
1,
min(
_MAX_GRID_CHARGE_CAP,
int(chg_pm / per_slot_full_wh * buf_mult) + 1,
),
)
if per_slot_full_wh > 0
else 6
)
@@ -166,6 +185,15 @@ def _select_charge_slots(
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), float(slots[t].buy_price))
@@ -462,6 +490,24 @@ class SelectChargeSlotsTests(unittest.TestCase):
self.assertNotIn(0, out, "Při malém rozpočtu má přednost levnější NT, ne VT 1.49")
self.assertTrue({1, 2} & out, "NT slot(y) mohou být vybrány")
def test_pm_grid_gets_unused_am_wh_budget(self) -> None:
"""Nečerpaný AM rozpočet → odpolední levné PM sloty mohou dostat allow_charge."""
base = datetime(2026, 5, 22, 6, 0, tzinfo=timezone.utc)
slots = [
_slot(buy=0.55, sell=-0.2, hour_utc=6, interval_start=base),
_slot(buy=0.58, sell=-0.2, hour_utc=7, interval_start=base + timedelta(hours=1)),
_slot(buy=0.52, sell=-0.25, hour_utc=14, interval_start=base + timedelta(hours=8)),
_slot(buy=0.50, sell=-0.25, hour_utc=15, interval_start=base + timedelta(hours=9)),
_slot(buy=5.5, sell=3.8, hour_utc=20, interval_start=base + timedelta(hours=14)),
]
battery = _battery(charge_buf=1.3, uc_wh=64_000.0)
out = _select_charge_slots(slots, battery, current_soc_wh=0.12 * battery.usable_capacity_wh)
pm_cheap = {2, 3}
self.assertTrue(
pm_cheap & out,
"po levném AM má PM dostat grid charge z nevyčerpaného rozpočtu",
)
def test_ote_slots_prioritized_over_predicted(self) -> None:
"""Při stejné ceně má OTE (is_predicted=false) přednost před predikovaným."""
slots = [

View File

@@ -265,8 +265,13 @@ begin
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;
v_exportable := v_soc_max_wh - v_min_soc_wh;
-- Rozpočet masek: buffer neinfluje počet slotů nad skutečný deficit; nad reserve jen deficit.
if p_current_soc_wh >= v_reserve_wh then
-- Rozpočet masek: charge_slot_buffer zvětší Wh cíl (do soc_max) i cap počtu grid slotů.
if v_charge_buf > 0 then
v_grid_target_wh := least(
greatest(v_energy_to_fill, 0) * v_charge_buf,
greatest(v_soc_max_wh - p_current_soc_wh, 0)
);
elsif p_current_soc_wh >= v_reserve_wh then
v_grid_target_wh := greatest(v_energy_to_fill, 0);
else
v_grid_target_wh := least(
@@ -366,14 +371,25 @@ begin
end if;
if v_per_slot_charge_wh > 0 then
v_grid_charge_cap_am := greatest(
1,
least(24, ceil(v_chg_am_wh / v_per_slot_charge_wh)::int)
);
v_grid_charge_cap_pm := greatest(
1,
least(24, ceil(v_chg_pm_wh / v_per_slot_charge_wh)::int)
);
if v_charge_buf > 0 then
v_grid_charge_cap_am := greatest(
1,
least(24, ceil((v_chg_am_wh / v_per_slot_charge_wh) * v_charge_buf)::int)
);
v_grid_charge_cap_pm := greatest(
1,
least(24, ceil((v_chg_pm_wh / v_per_slot_charge_wh) * v_charge_buf)::int)
);
else
v_grid_charge_cap_am := greatest(
1,
least(24, ceil(v_chg_am_wh / v_per_slot_charge_wh)::int)
);
v_grid_charge_cap_pm := greatest(
1,
least(24, ceil(v_chg_pm_wh / v_per_slot_charge_wh)::int)
);
end if;
else
v_grid_charge_cap_am := 6;
v_grid_charge_cap_pm := 6;
@@ -427,6 +443,20 @@ begin
end loop;
v_grid_filled_wh := v_grid_filled_wh + v_cum;
-- PM dostane i nevyčerpaný AM rozpočet (levné NT dopoledne ≠ vyčerpání celého grid_target).
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;
-- B) Grid PM
v_cum := 0;
v_grid_slots_pm := 0;

View File

@@ -108,7 +108,7 @@ Pro **home-01** při nabíjení 11:0014:00 za ~0,70,9 Kč a výprodeji 19:
### Hotovo
1. **`ems.fn_load_planning_slots_full`** (`R__063`): grid **B** = nejlevnější sloty v AM/PM do Wh rozpočtu (bez `buy≤min+band` a lookahead gate na grid); **A** = PV jen pokud `sell ≥ future_sell_lookahead degrad`. `charge_acquisition` z `allow_grid_charge` před 1. exportem.
1. **`ems.fn_load_planning_slots_full`** (`R__063`): grid **B** = nejlevnější sloty v AM/PM do Wh rozpočtu; **nevyčerpaný AM rozpočet přejde do PM** (odpolední NT za ~0,5 Kč může nabíjet i po ranním dobití). `grid_target × charge_slot_buffer`, cap slotů též × buffer. **A** = PV jen pokud `sell ≥ future_sell_lookahead degrad`.
2. **`solve_dispatch` (AUTO):** objective `gi×buy ge_pv×sell ge_bat×sell + ge_bat×acquisition` (export bat. jen v `allow_discharge_export`). Odstraněn cross-slot guard `ge_pv ≥ surplus` / `bc=0` dle `export_refill_net`.
3. **Guard FVE:** `ge_pv=0` jen při `sell < charge_acquisition degrad` (ne `sell < buy` ve stejném slotu).
4. **`solve_dispatch_two_pass`:** pass 1 → vážený `buy` z `bc`+`gi` v `allow_charge` → pass 2; volá `run_daily_plan` / `run_rolling_replan` v AUTO. Snapshot: `acquisition_pass1_czk_kwh`, `acquisition_pass2_czk_kwh`, `two_pass_enabled`.

View File

@@ -11,7 +11,7 @@
- **Terminal SoC shadow price:** v objective je člen `(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon).
- **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):** spot, **AM/PM rozpočet Wh 50/50** z `grid_target`. Výběr: **nejlevnější `buy`** v pásmu (kalendářní den plánu → před výkupním oknem dne `buy ASC`), bez pásma `min+0,40` a bez lookahead gate na grid B. **`charge_acquisition`:** vážený `buy` jen u `allow_grid_charge` před 1. exportem; po solve **dvouprůchodově** přepočet z `bc`+`gi` (`solve_dispatch_two_pass` v `planning_engine.py`).
- **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`. **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`.
- **PV vrstva A:** jen pokud `sell ≥ future_sell_opportunity degradation` (držet FVE na večerní peak, ne „nabíjet z FVE“ při nízkém sell).
- **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).
- **`ref_buy_min` (brána exportu):** `min(buy_price)` horizontu — jen „existuje levný nákup?“, **ne** průměrná cena nabití přes hodiny. Export sloty: `sell > ref_buy_min + degradation` (spot). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).