v2: denní SoC bezpečnostní rampa — ráno dotáhnout rezervu, pak prodávat
KV1 pozorování uživatele: ráno baterie na 11 % (min 10), prodává se do sítě — nenadálý odběr/mrak by se kupoval za fixních 6.35. v1 mělo denní rampu (safety_soc_target_wh z R__063: reserve 30 % ráno → reserve+noc večer, 6-19 h, flag planner_daytime_charge_target_enabled) — v2 ji ignoroval. Mechanismus (vzor nočního polštáře): deficit pod rampou platí za KAŽDÝ slot nájem buy×faktor (V091 asset_battery.planner_safety_soc_risk_factor, default 0.05; 0=vypnuto) → ráno se nejdřív doplní rezerva (4 h deficitu 1 kWh při buy 6.35 ≈ 5.1 Kč > sell ~2.5), extrémní sell špička smí deficit racionálně podstoupit. R__039 + db_io + 2 testy (KV1 scénář, spike). Eval fixtures beze změny (sloupec v context_json fixtures není → 0); živá produkce dostane faktor přes fn_planning_site_context. 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(
|
planner_neg_sell_full_soc_tail_slots=int(
|
||||||
b.get("planner_neg_sell_full_soc_tail_slots") or 4
|
b.get("planner_neg_sell_full_soc_tail_slots") or 4
|
||||||
),
|
),
|
||||||
|
planner_safety_soc_risk_factor=float(
|
||||||
|
b.get("planner_safety_soc_risk_factor") or 0.0
|
||||||
|
),
|
||||||
planner_pv_risk_frontload_czk_kwh=float(
|
planner_pv_risk_frontload_czk_kwh=float(
|
||||||
b.get("planner_pv_risk_frontload_czk_kwh") or 0.0
|
b.get("planner_pv_risk_frontload_czk_kwh") or 0.0
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
# indiference v čase; odložení ale spoléhá na predikci (večerní mrak).
|
# 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)
|
# 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.
|
# vede k "nabít plným výkonem hned, pak řezat A" — emergentně, bez rampy.
|
||||||
|
# - denní SoC rampa: deficit pod slot.safety_soc_target_wh (R__063: reserve →
|
||||||
|
# reserve+noc, 6–19 h) platí za slot nájem buy×faktor (DB
|
||||||
|
# planner_safety_soc_risk_factor) — ráno se nejdřív dotáhne rezerva
|
||||||
|
# (nenadálý odběr by se kupoval draho), pak se prodává.
|
||||||
#
|
#
|
||||||
# Vědomé odchylky od v1 (změří harness):
|
# Vědomé odchylky od v1 (změří harness):
|
||||||
# - SQL masky allow_charge / allow_discharge_export se IGNORUJÍ (jsou to
|
# - SQL masky allow_charge / allow_discharge_export se IGNORUJÍ (jsou to
|
||||||
@@ -157,6 +161,16 @@ def solve_dispatch_v2(
|
|||||||
]
|
]
|
||||||
ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH)
|
ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH)
|
||||||
nb_buffer_wh = [max(0.0, float(s.night_baseload_buffer_wh or 0.0)) for s in slots]
|
nb_buffer_wh = [max(0.0, float(s.night_baseload_buffer_wh or 0.0)) for s in slots]
|
||||||
|
safety_risk = float(getattr(battery, "planner_safety_soc_risk_factor", 0.0) or 0.0)
|
||||||
|
safety_tgt_wh = [
|
||||||
|
min(soc_max, max(0.0, float(s.safety_soc_target_wh or 0.0)))
|
||||||
|
if safety_risk > 0 else 0.0
|
||||||
|
for s in slots
|
||||||
|
]
|
||||||
|
ds_slack = [
|
||||||
|
pulp.LpVariable(f"dss_{t}", 0, soc_max) if safety_tgt_wh[t] > 0 else None
|
||||||
|
for t in range(T)
|
||||||
|
]
|
||||||
nb_slack = [
|
nb_slack = [
|
||||||
pulp.LpVariable(f"nbs_{t}", 0, nb_buffer_wh[t]) if nb_buffer_wh[t] > 0 else None
|
pulp.LpVariable(f"nbs_{t}", 0, nb_buffer_wh[t]) if nb_buffer_wh[t] > 0 else None
|
||||||
for t in range(T)
|
for t in range(T)
|
||||||
@@ -215,6 +229,10 @@ def solve_dispatch_v2(
|
|||||||
if nb_slack[t] is not None:
|
if nb_slack[t] is not None:
|
||||||
prob += soc[t] >= soc_min + nb_buffer_wh[t] - nb_slack[t], f"night_buf_{t}"
|
prob += soc[t] >= soc_min + nb_buffer_wh[t] - nb_slack[t], f"night_buf_{t}"
|
||||||
|
|
||||||
|
# denní SoC rampa (viz hlavička): soft floor k safety targetu
|
||||||
|
if ds_slack[t] is not None:
|
||||||
|
prob += soc[t] >= safety_tgt_wh[t] - ds_slack[t], f"day_safety_{t}"
|
||||||
|
|
||||||
# tvrdá cenová pravidla
|
# tvrdá cenová pravidla
|
||||||
if float(s.buy_price) < 0.0:
|
if float(s.buy_price) < 0.0:
|
||||||
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_buy_noexp_{t}"
|
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_buy_noexp_{t}"
|
||||||
@@ -311,6 +329,13 @@ def solve_dispatch_v2(
|
|||||||
]
|
]
|
||||||
if nb_terms:
|
if nb_terms:
|
||||||
extras += pulp.lpSum(nb_terms)
|
extras += pulp.lpSum(nb_terms)
|
||||||
|
ds_terms = [
|
||||||
|
ds_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price)) * safety_risk
|
||||||
|
for t in range(T)
|
||||||
|
if ds_slack[t] is not None
|
||||||
|
]
|
||||||
|
if ds_terms:
|
||||||
|
extras += pulp.lpSum(ds_terms)
|
||||||
frontload = float(getattr(battery, "planner_pv_risk_frontload_czk_kwh", 0.0) or 0.0)
|
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]
|
neg_idx = [t for t in range(T) if float(slots[t].sell_price) < 0.0]
|
||||||
if frontload > 0 and neg_idx:
|
if frontload > 0 and neg_idx:
|
||||||
@@ -418,6 +443,8 @@ def solve_dispatch_v2(
|
|||||||
"masks_ignored": True,
|
"masks_ignored": True,
|
||||||
"night_buffer_slots": sum(1 for b in nb_buffer_wh if b > 0),
|
"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,
|
"pv_risk_frontload_czk_kwh": frontload if neg_idx else 0.0,
|
||||||
|
"safety_soc_risk_factor": safety_risk,
|
||||||
|
"safety_soc_slots": sum(1 for x in safety_tgt_wh if x > 0),
|
||||||
"night_buffer_max_wh": round(max(nb_buffer_wh), 1) if nb_buffer_wh else 0,
|
"night_buffer_max_wh": round(max(nb_buffer_wh), 1) if nb_buffer_wh else 0,
|
||||||
},
|
},
|
||||||
"objective_terms": {
|
"objective_terms": {
|
||||||
|
|||||||
@@ -193,6 +193,44 @@ class NightReserveTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(results), 8)
|
self.assertEqual(len(results), 8)
|
||||||
|
|
||||||
|
|
||||||
|
class DaytimeSafetyRampTests(unittest.TestCase):
|
||||||
|
def test_morning_tops_up_reserve_before_selling(self) -> None:
|
||||||
|
# KV1 scénář: ráno baterie u dna, fixní buy 6.35 >> sell 2.5, PV jede;
|
||||||
|
# s rampou (target 30 % usable) musí nejdřív dotáhnout rezervu, ne prodávat
|
||||||
|
bat = _battery()
|
||||||
|
bat.planner_safety_soc_risk_factor = 0.05
|
||||||
|
target_wh = 0.30 * bat.usable_capacity_wh
|
||||||
|
slots = []
|
||||||
|
for i in range(16):
|
||||||
|
s = _slot(_BASE, i, buy=6.35, sell=2.5, pv_a=6000, load=800)
|
||||||
|
s.safety_soc_target_wh = target_wh
|
||||||
|
slots.append(s)
|
||||||
|
results, _, _ = _solve(slots, battery=bat, soc0=0.11 * bat.usable_capacity_wh)
|
||||||
|
soc_pct = [r.battery_soc_target for r in results]
|
||||||
|
first_reach = next((i for i, v in enumerate(soc_pct) if v >= 29.5), None)
|
||||||
|
self.assertIsNotNone(first_reach, "rampa má dotáhnout na rezervu")
|
||||||
|
exported_before = sum(
|
||||||
|
-r.grid_setpoint_w for r in results[:first_reach] if r.grid_setpoint_w < 0
|
||||||
|
)
|
||||||
|
self.assertLess(
|
||||||
|
exported_before, 500 * max(1, first_reach),
|
||||||
|
"před dosažením rezervy se nemá významně prodávat",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sell_spike_beats_ramp(self) -> None:
|
||||||
|
# extrémní sell nad buy → deficit je racionální podstoupit
|
||||||
|
bat = _battery()
|
||||||
|
bat.planner_safety_soc_risk_factor = 0.05
|
||||||
|
slots = []
|
||||||
|
for i in range(16):
|
||||||
|
s = _slot(_BASE, i, buy=2.0, sell=14.0, pv_a=2000, load=300)
|
||||||
|
s.safety_soc_target_wh = 0.5 * bat.usable_capacity_wh
|
||||||
|
slots.append(s)
|
||||||
|
results, _, _ = _solve(slots, battery=bat, soc0=0.45 * bat.usable_capacity_wh)
|
||||||
|
total_export = sum(-r.grid_setpoint_w for r in results if r.grid_setpoint_w < 0)
|
||||||
|
self.assertGreater(total_export, 5000, "spike má vyprodat i pod target")
|
||||||
|
|
||||||
|
|
||||||
class PvRiskFrontloadTests(unittest.TestCase):
|
class PvRiskFrontloadTests(unittest.TestCase):
|
||||||
def test_neg_window_charges_asap(self) -> None:
|
def test_neg_window_charges_asap(self) -> None:
|
||||||
# sell<0 okno, PV >> load, prázdnější baterie: s frontload prémií musí
|
# sell<0 okno, PV >> load, prázdnější baterie: s frontload prémií musí
|
||||||
|
|||||||
13
db/migration/V091__safety_soc_risk_factor.sql
Normal file
13
db/migration/V091__safety_soc_risk_factor.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Denní SoC bezpečnostní rampa ve v2: deficit pod safety_soc_target_wh
|
||||||
|
-- (R__063: rampa reserve→reserve+noční potřeba, 6–19 h) platí za každý slot
|
||||||
|
-- "nájem" = buy_cena × faktor. Ráno tak baterie nejdřív dotáhne na ~reserve
|
||||||
|
-- (KV1/BA81 30 %) a teprve pak prodává — nenadálý odběr/mrak nekupuje za
|
||||||
|
-- draho ze sítě. Extrémní sell špička smí deficit racionálně podstoupit.
|
||||||
|
-- 0 = vypnuto; default 0.05 (deficit 1 kWh držený 4 h při buy 6 Kč ≈ 4.8 Kč).
|
||||||
|
|
||||||
|
alter table ems.asset_battery
|
||||||
|
add column if not exists planner_safety_soc_risk_factor numeric(5, 3)
|
||||||
|
not null default 0.05;
|
||||||
|
|
||||||
|
comment on column ems.asset_battery.planner_safety_soc_risk_factor is
|
||||||
|
'v2: podíl buy ceny účtovaný za KAŽDÝ 15min slot deficitu pod safety_soc_target_wh (denní rampa z R__063). Ocenění rizika nenadálého odběru při slabé predikci. 0 = vypnuto.';
|
||||||
@@ -76,7 +76,8 @@ begin
|
|||||||
'planner_neg_sell_prep_soc_percent', coalesce(ab.planner_neg_sell_prep_soc_percent, 80::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_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)
|
'planner_pv_risk_frontload_czk_kwh', coalesce(ab.planner_pv_risk_frontload_czk_kwh, 0.01),
|
||||||
|
'planner_safety_soc_risk_factor', coalesce(ab.planner_safety_soc_risk_factor, 0.05)
|
||||||
)
|
)
|
||||||
into v_b
|
into v_b
|
||||||
from ems.asset_battery ab
|
from ems.asset_battery ab
|
||||||
|
|||||||
Reference in New Issue
Block a user