Skip to content

Commit 821de03

Browse files
committed
Update planner logic and prep v2.0.6-pre.10
1 parent 4370df6 commit 821de03

File tree

8 files changed

+841
-119
lines changed

8 files changed

+841
-119
lines changed

custom_components/oig_cloud/balancing/core.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from __future__ import annotations
1717

1818
import logging
19+
import math
1920
from datetime import datetime, timedelta
2021
from typing import Any, Dict, List, Optional, Tuple
2122

@@ -110,8 +111,19 @@ def _get_cycle_days(self) -> int:
110111
return int(opts.get("balancing_cycle_days") or 7)
111112

112113
def _get_cooldown_hours(self) -> int:
113-
"""Get balancing cooldown hours from config (default 24 hours)."""
114-
return self._config_entry.options.get("balancing_cooldown_hours", 24)
114+
"""Get balancing cooldown hours from config (default ~70% of cycle, min 24h)."""
115+
configured = self._config_entry.options.get("balancing_cooldown_hours")
116+
if configured is not None:
117+
try:
118+
configured_val = float(configured)
119+
except (TypeError, ValueError):
120+
configured_val = None
121+
if configured_val and configured_val > 0:
122+
return int(configured_val)
123+
124+
cycle_days = float(self._get_cycle_days())
125+
cooldown_hours = int(math.ceil(cycle_days * 24 * 0.7))
126+
return max(24, cooldown_hours)
115127

116128
def _get_soc_threshold(self) -> int:
117129
"""Get SoC threshold for opportunistic balancing from config (default 80%)."""

custom_components/oig_cloud/battery_forecast/strategy/hybrid.py

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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,

custom_components/oig_cloud/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@
1111
"issue_tracker": "https://github.com/psimsa/oig_cloud/issues",
1212
"requirements": ["numpy>=1.24.0"],
1313
"ssdp": [],
14-
"version": "2.0.6-pre.8",
14+
"version": "2.0.6-pre.10",
1515
"zeroconf": []
1616
}

0 commit comments

Comments
 (0)