v2: PV-risk front-load — nabít v neg okně co nejdřív (nejistota predikce)
v1 to řešil rampou (plný výkon než se řeže pole A — zelený bonus B, riziko večerního mraku). v2 byl k načasování v okně sell<0 indiferentní (PV zdarma kdykoliv) a směl nabíjení odložit — odklad ale spoléhá na predikci. Mechanismus: malá prémie za držení energie dřív (objective −= soc[t] × frontload v neg slotech). Rozbíjí indiferenci směrem k front-loadu, nikdy nepřebije skutečné ceny. Velikost z DB: asset_battery. planner_pv_risk_frontload_czk_kwh (V090, default 0.01; 0 = vypnuto), přes fn_planning_site_context (R__039). Test: 4 sloty plným tempem od startu. Eval fixtures beze změny (sloupec v nich není → 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -99,6 +99,9 @@ async def _load_site_context(site_id: int, db):
|
||||
planner_neg_sell_full_soc_tail_slots=int(
|
||||
b.get("planner_neg_sell_full_soc_tail_slots") or 4
|
||||
),
|
||||
planner_pv_risk_frontload_czk_kwh=float(
|
||||
b.get("planner_pv_risk_frontload_czk_kwh") or 0.0
|
||||
),
|
||||
planner_neg_sell_vent_min_sell_czk_kwh=(
|
||||
float(b["planner_neg_sell_vent_min_sell_czk_kwh"])
|
||||
if b.get("planner_neg_sell_vent_min_sell_czk_kwh") is not None
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
# z DB (planner_night_baseload_buffer_percent → slot.night_baseload_buffer_wh,
|
||||
# klesá k 0 do rána); porušení je PLACENÉ cenou buy daného slotu (riziko
|
||||
# zpětného nákupu), takže extrémní sell špička ho smí racionálně prodat.
|
||||
# - PV-risk front-load: v okně sell<0 je nabíjení z PV zdarma kdykoliv →
|
||||
# indiference v čase; odložení ale spoléhá na predikci (večerní mrak).
|
||||
# Malá prémie za držení energie dřív (DB planner_pv_risk_frontload_czk_kwh)
|
||||
# vede k "nabít plným výkonem hned, pak řezat A" — emergentně, bez rampy.
|
||||
#
|
||||
# Vědomé odchylky od v1 (změří harness):
|
||||
# - SQL masky allow_charge / allow_discharge_export se IGNORUJÍ (jsou to
|
||||
@@ -307,6 +311,11 @@ def solve_dispatch_v2(
|
||||
]
|
||||
if nb_terms:
|
||||
extras += pulp.lpSum(nb_terms)
|
||||
frontload = float(getattr(battery, "planner_pv_risk_frontload_czk_kwh", 0.0) or 0.0)
|
||||
neg_idx = [t for t in range(T) if float(slots[t].sell_price) < 0.0]
|
||||
if frontload > 0 and neg_idx:
|
||||
# odměna za soc[t] v neg slotech = dřívější nabití vyhrává při indiferenci
|
||||
extras -= pulp.lpSum(soc[t] / 1000.0 * frontload for t in neg_idx)
|
||||
|
||||
prob += cash + degradation + extras - terminal * soc[T - 1]
|
||||
|
||||
@@ -408,6 +417,7 @@ def solve_dispatch_v2(
|
||||
"ev_sessions": sum(1 for x in ev_sessions if x is not None),
|
||||
"masks_ignored": True,
|
||||
"night_buffer_slots": sum(1 for b in nb_buffer_wh if b > 0),
|
||||
"pv_risk_frontload_czk_kwh": frontload if neg_idx else 0.0,
|
||||
"night_buffer_max_wh": round(max(nb_buffer_wh), 1) if nb_buffer_wh else 0,
|
||||
},
|
||||
"objective_terms": {
|
||||
|
||||
@@ -193,6 +193,22 @@ class NightReserveTests(unittest.TestCase):
|
||||
self.assertEqual(len(results), 8)
|
||||
|
||||
|
||||
class PvRiskFrontloadTests(unittest.TestCase):
|
||||
def test_neg_window_charges_asap(self) -> None:
|
||||
# sell<0 okno, PV >> load, prázdnější baterie: s frontload prémií musí
|
||||
# nabíjení běžet plným tempem od začátku (ne odložené na konec okna)
|
||||
bat = _battery()
|
||||
bat.planner_pv_risk_frontload_czk_kwh = 0.05
|
||||
slots = [_slot(_BASE, i, buy=2.0, sell=-0.5, pv_a=12000, load=500) for i in range(12)]
|
||||
results, _, _ = _solve(slots, battery=bat, soc0=0.2 * bat.usable_capacity_wh)
|
||||
# max tempo: 8 kW × 0.25 h × 0.95 eff = 1.9 kWh/slot = 9.5 p.b. na 20 kWh
|
||||
soc_mid = results[3].battery_soc_target
|
||||
self.assertGreaterEqual(
|
||||
soc_mid, 20.0 + 4 * 9.0,
|
||||
"frontload: prvni 4 sloty maji nabijet plnym vykonem",
|
||||
)
|
||||
|
||||
|
||||
class EvDeadlineTests(unittest.TestCase):
|
||||
def test_ev_energy_delivered_before_deadline(self) -> None:
|
||||
slots = [_slot(_BASE, i, buy=2.0 if i < 8 else 6.0, sell=1.0, ev1=True) for i in range(16)]
|
||||
|
||||
13
db/migration/V090__pv_risk_frontload.sql
Normal file
13
db/migration/V090__pv_risk_frontload.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- PV-risk front-load: prémie za držení energie DŘÍV uvnitř okna sell<0.
|
||||
-- Solver je k načasování nabíjení v neg okně jinak indiferentní (PV je zdarma
|
||||
-- kdykoliv) — odložené nabití ale spoléhá na predikci (večerní mrak = drahý
|
||||
-- nákup). Malá prémie (Kč/kWh/slot) rozbije indiferenci směrem k "nabít plným
|
||||
-- výkonem hned" (v1 rampa), ale nikdy nepřebije skutečné ceny.
|
||||
-- 0.01 → držení 1 kWh o 6 h dřív = 0.24 Kč; 0 = vypnuto.
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_pv_risk_frontload_czk_kwh numeric(6, 4)
|
||||
not null default 0.01;
|
||||
|
||||
comment on column ems.asset_battery.planner_pv_risk_frontload_czk_kwh is
|
||||
'v2: prémie za držení energie dřív v okně sell<0 (Kč za kWh a 15min slot). Ocenění rizika chyby PV predikce — front-load nabíjení. 0 = vypnuto.';
|
||||
@@ -75,7 +75,8 @@ begin
|
||||
'planner_charge_commitment_penalty_czk_kwh', coalesce(ab.planner_charge_commitment_penalty_czk_kwh, 0.20::numeric),
|
||||
'planner_neg_sell_prep_soc_percent', coalesce(ab.planner_neg_sell_prep_soc_percent, 80::numeric),
|
||||
'planner_neg_sell_full_soc_tail_slots', coalesce(ab.planner_neg_sell_full_soc_tail_slots, 4),
|
||||
'planner_neg_sell_vent_min_sell_czk_kwh', ab.planner_neg_sell_vent_min_sell_czk_kwh
|
||||
'planner_neg_sell_vent_min_sell_czk_kwh', ab.planner_neg_sell_vent_min_sell_czk_kwh,
|
||||
'planner_pv_risk_frontload_czk_kwh', coalesce(ab.planner_pv_risk_frontload_czk_kwh, 0.01)
|
||||
)
|
||||
into v_b
|
||||
from ems.asset_battery ab
|
||||
|
||||
Reference in New Issue
Block a user