@@ -144,6 +144,7 @@ class HybridStrategy:
144144 # Legacy constants for compatibility
145145 LOOKAHEAD_INTERVALS = 24 # Look 6 hours ahead (24 * 15min)
146146 MIN_PRICE_SPREAD_PERCENT = 15.0 # Min price spread to justify charging (%)
147+ MIN_UPS_PRICE_BAND_PCT = 0.08 # Minimum price band for UPS continuity (8%)
147148
148149 def __init__ (
149150 self ,
@@ -209,7 +210,11 @@ def optimize(
209210 has_negative = len (negative_prices ) > 0
210211
211212 # Step 1: Plan charging intervals using backward propagation
212- charging_intervals , infeasible_reason = self ._plan_charging_intervals (
213+ (
214+ charging_intervals ,
215+ infeasible_reason ,
216+ price_band_intervals ,
217+ ) = self ._plan_charging_intervals (
213218 initial_battery_kwh = initial_battery_kwh ,
214219 prices = prices ,
215220 solar_forecast = solar_forecast ,
@@ -248,6 +253,7 @@ def optimize(
248253 is_balancing = balancing_plan and i in balancing_plan .charging_intervals
249254 is_holding = balancing_plan and i in balancing_plan .holding_intervals
250255 is_charging = i in charging_intervals
256+ is_price_band = i in price_band_intervals
251257 is_negative = price < 0
252258 override_mode = (
253259 balancing_plan .mode_overrides .get (i )
@@ -276,7 +282,10 @@ def optimize(
276282 )
277283 elif is_charging :
278284 mode = CBB_MODE_HOME_UPS
279- reason = f"planned_charge_{ price :.2f} CZK"
285+ if is_price_band :
286+ reason = "price_band_hold"
287+ else :
288+ reason = f"planned_charge_{ price :.2f} CZK"
280289 else :
281290 # Default: HOME I (discharge battery when needed)
282291 mode = CBB_MODE_HOME_I
@@ -348,12 +357,14 @@ def _plan_charging_intervals(
348357 consumption_forecast : List [float ],
349358 balancing_plan : Optional [BalancingPlan ] = None ,
350359 negative_price_intervals : Optional [List [int ]] = None ,
351- ) -> Tuple [set [int ], Optional [str ]]:
360+ ) -> Tuple [set [int ], Optional [str ], set [ int ] ]:
352361 """Plan charging intervals with planning-min enforcement and price guard."""
353362 n = len (prices )
354363 charging_intervals : set [int ] = set ()
364+ price_band_intervals : set [int ] = set ()
355365 infeasible_reason : Optional [str ] = None
356366 eps_kwh = 0.01
367+ recovery_mode = initial_battery_kwh < self ._planning_min - eps_kwh
357368
358369 blocked_indices : set [int ] = set ()
359370 if balancing_plan and balancing_plan .mode_overrides :
@@ -397,7 +408,7 @@ def add_ups_interval(idx: int, *, allow_expensive: bool = False) -> None:
397408
398409 # Recovery if we start below planning minimum: charge ASAP.
399410 recovery_index : Optional [int ] = None
400- if initial_battery_kwh < self . _planning_min - eps_kwh :
411+ if recovery_mode :
401412 soc = initial_battery_kwh
402413 for i in range (n ):
403414 if soc >= self ._planning_min - eps_kwh :
@@ -435,7 +446,7 @@ def add_ups_interval(idx: int, *, allow_expensive: bool = False) -> None:
435446 infeasible_reason = (
436447 "Battery below planning minimum at start and could not recover within planning horizon"
437448 )
438- return charging_intervals , infeasible_reason
449+ return charging_intervals , infeasible_reason , price_band_intervals
439450 else :
440451 recovery_index = 0
441452
@@ -532,7 +543,93 @@ def add_ups_interval(idx: int, *, allow_expensive: bool = False) -> None:
532543 )
533544 break
534545
535- return charging_intervals , infeasible_reason
546+ if not recovery_mode :
547+ original_charging = set (charging_intervals )
548+ price_band_intervals = self ._extend_ups_blocks_by_price_band (
549+ charging_intervals = original_charging ,
550+ prices = prices ,
551+ blocked_indices = blocked_indices ,
552+ )
553+ if price_band_intervals :
554+ charging_intervals |= price_band_intervals
555+ _LOGGER .debug (
556+ "Price-band UPS extension added %d intervals (delta=%.1f%%)" ,
557+ len (price_band_intervals ),
558+ self ._get_price_band_delta_pct () * 100 ,
559+ )
560+
561+ return charging_intervals , infeasible_reason , price_band_intervals
562+
563+ def _get_price_band_delta_pct (self ) -> float :
564+ """Compute price band delta from battery efficiency (min 8%)."""
565+ eff = getattr (self .sim_config , "ac_dc_efficiency" , None )
566+ try :
567+ eff_val = float (eff )
568+ except (TypeError , ValueError ):
569+ eff_val = 0.0
570+
571+ if eff_val <= 0 or eff_val > 1.0 :
572+ return self .MIN_UPS_PRICE_BAND_PCT
573+
574+ derived = (1.0 / eff_val ) - 1.0
575+ return max (self .MIN_UPS_PRICE_BAND_PCT , derived )
576+
577+ def _extend_ups_blocks_by_price_band (
578+ self ,
579+ * ,
580+ charging_intervals : set [int ],
581+ prices : List [float ],
582+ blocked_indices : set [int ],
583+ ) -> set [int ]:
584+ """Extend UPS blocks forward when prices stay within efficiency-based band."""
585+ if not charging_intervals or not prices :
586+ return set ()
587+
588+ max_price = float (self .config .max_ups_price_czk )
589+ delta_pct = self ._get_price_band_delta_pct ()
590+ n = len (prices )
591+
592+ ups_flags = [False ] * n
593+ for idx in charging_intervals :
594+ if 0 <= idx < n :
595+ ups_flags [idx ] = True
596+
597+ def _can_extend (prev_idx : int , idx : int ) -> bool :
598+ if idx in blocked_indices :
599+ return False
600+ prev_price = prices [prev_idx ]
601+ if prev_price > max_price :
602+ return False
603+ price = prices [idx ]
604+ if price > max_price :
605+ return False
606+ return price <= prev_price * (1.0 + delta_pct )
607+
608+ extended : set [int ] = set ()
609+
610+ # Forward hysteresis: držet UPS, pokud cena neroste nad pásmo
611+ for i in range (1 , n ):
612+ if ups_flags [i - 1 ] and not ups_flags [i ] and _can_extend (i - 1 , i ):
613+ ups_flags [i ] = True
614+ if i not in charging_intervals :
615+ extended .add (i )
616+
617+ # Vyplnit jednorázové mezery mezi UPS bloky
618+ for i in range (1 , n - 1 ):
619+ if ups_flags [i - 1 ] and (not ups_flags [i ]) and ups_flags [i + 1 ]:
620+ if _can_extend (i - 1 , i ):
621+ ups_flags [i ] = True
622+ if i not in charging_intervals :
623+ extended .add (i )
624+
625+ # Ještě jednou dopředně, aby se navázalo na doplněné mezery
626+ for i in range (1 , n ):
627+ if ups_flags [i - 1 ] and not ups_flags [i ] and _can_extend (i - 1 , i ):
628+ ups_flags [i ] = True
629+ if i not in charging_intervals :
630+ extended .add (i )
631+
632+ return extended
536633
537634 def _simulate_trajectory (
538635 self ,
0 commit comments