v2: denní SoC bezpečnostní rampa — ráno dotáhnout rezervu, pak prodávat
All checks were successful
CI and deploy / migration-check (push) Successful in 30s
CI and deploy / deploy (push) Successful in 1m32s

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:
Dusan Vojacek
2026-06-12 10:17:19 +02:00
parent 2932d48080
commit e0410f9638
5 changed files with 83 additions and 1 deletions

View File

@@ -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_safety_soc_risk_factor=float(
b.get("planner_safety_soc_risk_factor") or 0.0
),
planner_pv_risk_frontload_czk_kwh=float(
b.get("planner_pv_risk_frontload_czk_kwh") or 0.0
),

View File

@@ -26,6 +26,10 @@
# 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.
# - denní SoC rampa: deficit pod slot.safety_soc_target_wh (R__063: reserve →
# reserve+noc, 619 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):
# - 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)
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 = [
pulp.LpVariable(f"nbs_{t}", 0, nb_buffer_wh[t]) if nb_buffer_wh[t] > 0 else None
for t in range(T)
@@ -215,6 +229,10 @@ def solve_dispatch_v2(
if nb_slack[t] is not None:
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
if float(s.buy_price) < 0.0:
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_buy_noexp_{t}"
@@ -311,6 +329,13 @@ def solve_dispatch_v2(
]
if 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)
neg_idx = [t for t in range(T) if float(slots[t].sell_price) < 0.0]
if frontload > 0 and neg_idx:
@@ -418,6 +443,8 @@ def solve_dispatch_v2(
"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,
"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,
},
"objective_terms": {