diff --git a/backend/services/planning/db_io.py b/backend/services/planning/db_io.py index 12f5721..92ccd69 100644 --- a/backend/services/planning/db_io.py +++ b/backend/services/planning/db_io.py @@ -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 ), diff --git a/backend/services/planning/solver_v2.py b/backend/services/planning/solver_v2.py index 0469a7f..017cc05 100644 --- a/backend/services/planning/solver_v2.py +++ b/backend/services/planning/solver_v2.py @@ -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, 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): # - 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": { diff --git a/backend/tests/test_solver_v2.py b/backend/tests/test_solver_v2.py index 3acc309..9760754 100644 --- a/backend/tests/test_solver_v2.py +++ b/backend/tests/test_solver_v2.py @@ -193,6 +193,44 @@ class NightReserveTests(unittest.TestCase): 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): def test_neg_window_charges_asap(self) -> None: # sell<0 okno, PV >> load, prázdnější baterie: s frontload prémií musí diff --git a/db/migration/V091__safety_soc_risk_factor.sql b/db/migration/V091__safety_soc_risk_factor.sql new file mode 100644 index 0000000..cdc8f5a --- /dev/null +++ b/db/migration/V091__safety_soc_risk_factor.sql @@ -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.'; diff --git a/db/routines/R__039_fn_planning_site_context.sql b/db/routines/R__039_fn_planning_site_context.sql index c1c2901..cc6fc81 100644 --- a/db/routines/R__039_fn_planning_site_context.sql +++ b/db/routines/R__039_fn_planning_site_context.sql @@ -76,7 +76,8 @@ begin '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_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 from ems.asset_battery ab