mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-30 13:23:41 +00:00
refactor: Update interval attribute keys and improve period merging logic in TibberPricesBinarySensor
This commit is contained in:
parent
ca88f136c3
commit
3df68db20b
1 changed files with 293 additions and 115 deletions
|
|
@ -14,7 +14,6 @@ from homeassistant.const import PERCENTAGE, EntityCategory
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .average_utils import calculate_leading_24h_avg, calculate_trailing_24h_avg
|
|
||||||
from .coordinator import TIME_SENSITIVE_ENTITY_KEYS
|
from .coordinator import TIME_SENSITIVE_ENTITY_KEYS
|
||||||
from .entity import TibberPricesEntity
|
from .entity import TibberPricesEntity
|
||||||
from .sensor import find_price_data_for_interval
|
from .sensor import find_price_data_for_interval
|
||||||
|
|
@ -228,11 +227,11 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
if not self.coordinator.data:
|
if not self.coordinator.data:
|
||||||
return None
|
return None
|
||||||
attrs = self._get_price_intervals_attributes(reverse_sort=False)
|
attrs = self._get_price_intervals_attributes(reverse_sort=False)
|
||||||
if not attrs or "interval_start" not in attrs or "interval_end" not in attrs:
|
if not attrs or "start" not in attrs or "end" not in attrs:
|
||||||
return None
|
return None
|
||||||
now = dt_util.now()
|
now = dt_util.now()
|
||||||
start = attrs.get("interval_start")
|
start = attrs.get("start")
|
||||||
end = attrs.get("interval_end")
|
end = attrs.get("end")
|
||||||
return start <= now < end if start and end else None
|
return start <= now < end if start and end else None
|
||||||
|
|
||||||
def _peak_price_state(self) -> bool | None:
|
def _peak_price_state(self) -> bool | None:
|
||||||
|
|
@ -240,11 +239,11 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
if not self.coordinator.data:
|
if not self.coordinator.data:
|
||||||
return None
|
return None
|
||||||
attrs = self._get_price_intervals_attributes(reverse_sort=True)
|
attrs = self._get_price_intervals_attributes(reverse_sort=True)
|
||||||
if not attrs or "interval_start" not in attrs or "interval_end" not in attrs:
|
if not attrs or "start" not in attrs or "end" not in attrs:
|
||||||
return None
|
return None
|
||||||
now = dt_util.now()
|
now = dt_util.now()
|
||||||
start = attrs.get("interval_start")
|
start = attrs.get("start")
|
||||||
end = attrs.get("interval_end")
|
end = attrs.get("end")
|
||||||
return start <= now < end if start and end else None
|
return start <= now < end if start and end else None
|
||||||
|
|
||||||
def _tomorrow_data_available_state(self) -> bool | None:
|
def _tomorrow_data_available_state(self) -> bool | None:
|
||||||
|
|
@ -321,77 +320,58 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
"""Annotate a single interval with all required attributes for Home Assistant UI and automations."""
|
"""Annotate a single interval with all required attributes for Home Assistant UI and automations."""
|
||||||
interval_copy = interval.copy()
|
interval_copy = interval.copy()
|
||||||
interval_remaining = annotation_ctx["interval_count"] - annotation_ctx["interval_idx"]
|
interval_remaining = annotation_ctx["interval_count"] - annotation_ctx["interval_idx"]
|
||||||
|
# Extract and keep internal interval fields for logic (with _ prefix)
|
||||||
interval_start = interval_copy.pop("interval_start", None)
|
interval_start = interval_copy.pop("interval_start", None)
|
||||||
interval_end = interval_copy.pop("interval_end", None)
|
interval_end = interval_copy.pop("interval_end", None)
|
||||||
interval_hour = interval_copy.pop("interval_hour", None)
|
# Remove other interval_* fields that are no longer needed
|
||||||
interval_minute = interval_copy.pop("interval_minute", None)
|
interval_copy.pop("interval_hour", None)
|
||||||
interval_time = interval_copy.pop("interval_time", None)
|
interval_copy.pop("interval_minute", None)
|
||||||
price = interval_copy.pop("price", None)
|
interval_copy.pop("interval_time", None)
|
||||||
|
# Remove startsAt - not needed anymore
|
||||||
|
interval_copy.pop("startsAt", None)
|
||||||
|
price_raw = interval_copy.pop("price", None)
|
||||||
new_interval = {
|
new_interval = {
|
||||||
"period_start": annotation_ctx["period_start"],
|
"period_start": annotation_ctx["period_start"],
|
||||||
"period_end": annotation_ctx["period_end"],
|
"period_end": annotation_ctx["period_end"],
|
||||||
"hour": annotation_ctx["period_start_hour"],
|
"hour": annotation_ctx["period_start_hour"],
|
||||||
"minute": annotation_ctx["period_start_minute"],
|
"minute": annotation_ctx["period_start_minute"],
|
||||||
"time": annotation_ctx["period_start_time"],
|
"time": annotation_ctx["period_start_time"],
|
||||||
"period_length_minute": annotation_ctx["period_length"],
|
"duration_minutes": annotation_ctx["period_length"],
|
||||||
"period_remaining_minute_after_interval": interval_remaining * MINUTES_PER_INTERVAL,
|
"remaining_minutes_after_interval": interval_remaining * MINUTES_PER_INTERVAL,
|
||||||
"periods_total": annotation_ctx["period_count"],
|
"periods_total": annotation_ctx["period_count"],
|
||||||
"periods_remaining": annotation_ctx["periods_remaining"],
|
"periods_remaining": annotation_ctx["periods_remaining"],
|
||||||
"period_position": annotation_ctx["period_idx"],
|
"period_position": annotation_ctx["period_idx"],
|
||||||
"interval_total": annotation_ctx["interval_count"],
|
"price": round(price_raw * 100, 2) if price_raw is not None else None,
|
||||||
"interval_remaining": interval_remaining,
|
# Keep internal fields for logic (state checks, filtering)
|
||||||
"interval_position": annotation_ctx["interval_idx"],
|
"_interval_start": interval_start,
|
||||||
"interval_start": interval_start,
|
"_interval_end": interval_end,
|
||||||
"interval_end": interval_end,
|
|
||||||
"interval_hour": interval_hour,
|
|
||||||
"interval_minute": interval_minute,
|
|
||||||
"interval_time": interval_time,
|
|
||||||
"price": price,
|
|
||||||
}
|
}
|
||||||
new_interval.update(interval_copy)
|
new_interval.update(interval_copy)
|
||||||
new_interval["price_minor"] = round(new_interval["price"] * 100, 2)
|
|
||||||
price_diff = new_interval["price"] - annotation_ctx["ref_price"]
|
# Add price_diff_from_min for best_price_period
|
||||||
new_interval[annotation_ctx["diff_key"]] = round(price_diff, 4)
|
if annotation_ctx.get("diff_key") == "price_diff_from_min":
|
||||||
new_interval[annotation_ctx["diff_ct_key"]] = round(price_diff * 100, 2)
|
price_diff = price_raw - annotation_ctx["ref_price"]
|
||||||
# Calculate percent difference from reference price (min or max)
|
new_interval["price_diff_from_min"] = round(price_diff * 100, 2)
|
||||||
price_diff_percent = (
|
# Calculate percent difference from min price
|
||||||
((new_interval["price"] - annotation_ctx["ref_price"]) / annotation_ctx["ref_price"]) * 100
|
price_diff_percent = (
|
||||||
if annotation_ctx["ref_price"] != 0
|
((price_raw - annotation_ctx["ref_price"]) / annotation_ctx["ref_price"]) * 100
|
||||||
else 0.0
|
if annotation_ctx["ref_price"] != 0
|
||||||
)
|
else 0.0
|
||||||
new_interval[annotation_ctx["diff_pct_key"]] = round(price_diff_percent, 2)
|
)
|
||||||
# Calculate difference from average price for the day
|
new_interval["price_diff_from_min_" + PERCENTAGE] = round(price_diff_percent, 2)
|
||||||
avg_diff = new_interval["price"] - annotation_ctx["avg_price"]
|
|
||||||
new_interval["price_diff_from_avg"] = round(avg_diff, 4)
|
# Add price_diff_from_max for peak_price_period
|
||||||
new_interval["price_diff_from_avg_minor"] = round(avg_diff * 100, 2)
|
elif annotation_ctx.get("diff_key") == "price_diff_from_max":
|
||||||
avg_diff_percent = (
|
price_diff = price_raw - annotation_ctx["ref_price"]
|
||||||
((new_interval["price"] - annotation_ctx["avg_price"]) / annotation_ctx["avg_price"]) * 100
|
new_interval["price_diff_from_max"] = round(price_diff * 100, 2)
|
||||||
if annotation_ctx["avg_price"] != 0
|
# Calculate percent difference from max price
|
||||||
else 0.0
|
price_diff_percent = (
|
||||||
)
|
((price_raw - annotation_ctx["ref_price"]) / annotation_ctx["ref_price"]) * 100
|
||||||
new_interval["price_diff_from_avg_" + PERCENTAGE] = round(avg_diff_percent, 2)
|
if annotation_ctx["ref_price"] != 0
|
||||||
# Calculate difference from trailing 24-hour average
|
else 0.0
|
||||||
trailing_avg = annotation_ctx.get("trailing_24h_avg", 0.0)
|
)
|
||||||
trailing_avg_diff = new_interval["price"] - trailing_avg
|
new_interval["price_diff_from_max_" + PERCENTAGE] = round(price_diff_percent, 2)
|
||||||
new_interval["price_diff_from_trailing_24h_avg"] = round(trailing_avg_diff, 4)
|
|
||||||
new_interval["price_diff_from_trailing_24h_avg_minor"] = round(trailing_avg_diff * 100, 2)
|
|
||||||
trailing_avg_diff_percent = (
|
|
||||||
((new_interval["price"] - trailing_avg) / trailing_avg) * 100 if trailing_avg != 0 else 0.0
|
|
||||||
)
|
|
||||||
new_interval["price_diff_from_trailing_24h_avg_" + PERCENTAGE] = round(trailing_avg_diff_percent, 2)
|
|
||||||
new_interval["trailing_24h_avg_price"] = round(trailing_avg, 4)
|
|
||||||
new_interval["trailing_24h_avg_price_minor"] = round(trailing_avg * 100, 2)
|
|
||||||
# Calculate difference from leading 24-hour average
|
|
||||||
leading_avg = annotation_ctx.get("leading_24h_avg", 0.0)
|
|
||||||
leading_avg_diff = new_interval["price"] - leading_avg
|
|
||||||
new_interval["price_diff_from_leading_24h_avg"] = round(leading_avg_diff, 4)
|
|
||||||
new_interval["price_diff_from_leading_24h_avg_minor"] = round(leading_avg_diff * 100, 2)
|
|
||||||
leading_avg_diff_percent = (
|
|
||||||
((new_interval["price"] - leading_avg) / leading_avg) * 100 if leading_avg != 0 else 0.0
|
|
||||||
)
|
|
||||||
new_interval["price_diff_from_leading_24h_avg_" + PERCENTAGE] = round(leading_avg_diff_percent, 2)
|
|
||||||
new_interval["leading_24h_avg_price"] = round(leading_avg, 4)
|
|
||||||
new_interval["leading_24h_avg_price_minor"] = round(leading_avg * 100, 2)
|
|
||||||
return new_interval
|
return new_interval
|
||||||
|
|
||||||
def _annotate_period_intervals(
|
def _annotate_period_intervals(
|
||||||
|
|
@ -399,7 +379,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
periods: list[list[dict]],
|
periods: list[list[dict]],
|
||||||
ref_prices: dict,
|
ref_prices: dict,
|
||||||
avg_price_by_day: dict,
|
avg_price_by_day: dict,
|
||||||
all_prices: list[dict],
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Return flattened and annotated intervals with period info and requested properties.
|
Return flattened and annotated intervals with period info and requested properties.
|
||||||
|
|
@ -415,15 +394,12 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
reference_type = "ref"
|
reference_type = "ref"
|
||||||
if reference_type == "min":
|
if reference_type == "min":
|
||||||
diff_key = "price_diff_from_min"
|
diff_key = "price_diff_from_min"
|
||||||
diff_ct_key = "price_diff_from_min_minor"
|
|
||||||
diff_pct_key = "price_diff_from_min_" + PERCENTAGE
|
diff_pct_key = "price_diff_from_min_" + PERCENTAGE
|
||||||
elif reference_type == "max":
|
elif reference_type == "max":
|
||||||
diff_key = "price_diff_from_max"
|
diff_key = "price_diff_from_max"
|
||||||
diff_ct_key = "price_diff_from_max_minor"
|
|
||||||
diff_pct_key = "price_diff_from_max_" + PERCENTAGE
|
diff_pct_key = "price_diff_from_max_" + PERCENTAGE
|
||||||
else:
|
else:
|
||||||
diff_key = "price_diff"
|
diff_key = "price_diff"
|
||||||
diff_ct_key = "price_diff_minor"
|
|
||||||
diff_pct_key = "price_diff_" + PERCENTAGE
|
diff_pct_key = "price_diff_" + PERCENTAGE
|
||||||
result = []
|
result = []
|
||||||
period_count = len(periods)
|
period_count = len(periods)
|
||||||
|
|
@ -441,10 +417,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
interval_date = interval_start.date() if interval_start else None
|
interval_date = interval_start.date() if interval_start else None
|
||||||
avg_price = avg_price_by_day.get(interval_date, 0)
|
avg_price = avg_price_by_day.get(interval_date, 0)
|
||||||
ref_price = ref_prices.get(interval_date, 0)
|
ref_price = ref_prices.get(interval_date, 0)
|
||||||
# Calculate trailing 24-hour average for this interval
|
|
||||||
trailing_24h_avg = calculate_trailing_24h_avg(all_prices, interval_start) if interval_start else 0.0
|
|
||||||
# Calculate leading 24-hour average for this interval
|
|
||||||
leading_24h_avg = calculate_leading_24h_avg(all_prices, interval_start) if interval_start else 0.0
|
|
||||||
annotation_ctx = {
|
annotation_ctx = {
|
||||||
"period_start": period_start,
|
"period_start": period_start,
|
||||||
"period_end": period_end,
|
"period_end": period_end,
|
||||||
|
|
@ -459,10 +431,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
"period_idx": period_idx,
|
"period_idx": period_idx,
|
||||||
"ref_price": ref_price,
|
"ref_price": ref_price,
|
||||||
"avg_price": avg_price,
|
"avg_price": avg_price,
|
||||||
"trailing_24h_avg": trailing_24h_avg,
|
|
||||||
"leading_24h_avg": leading_24h_avg,
|
|
||||||
"diff_key": diff_key,
|
"diff_key": diff_key,
|
||||||
"diff_ct_key": diff_ct_key,
|
|
||||||
"diff_pct_key": diff_pct_key,
|
"diff_pct_key": diff_pct_key,
|
||||||
}
|
}
|
||||||
new_interval = self._annotate_single_interval(
|
new_interval = self._annotate_single_interval(
|
||||||
|
|
@ -611,6 +580,61 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
# Filter out periods that are too short
|
# Filter out periods that are too short
|
||||||
return [period for period in periods if len(period) >= min_intervals]
|
return [period for period in periods if len(period) >= min_intervals]
|
||||||
|
|
||||||
|
def _merge_adjacent_periods_at_midnight(self, periods: list[list[dict]]) -> list[list[dict]]:
|
||||||
|
"""
|
||||||
|
Merge adjacent periods that meet at midnight.
|
||||||
|
|
||||||
|
When two periods are detected separately for today and tomorrow due to different
|
||||||
|
daily average prices, but they are directly adjacent at midnight, merge them into
|
||||||
|
a single period for better user experience.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
periods: List of periods (each period is a list of interval dicts)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of periods with adjacent midnight periods merged
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not periods:
|
||||||
|
return periods
|
||||||
|
|
||||||
|
merged = []
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < len(periods):
|
||||||
|
current_period = periods[i]
|
||||||
|
|
||||||
|
# Check if there's a next period and if they meet at midnight
|
||||||
|
if i + 1 < len(periods):
|
||||||
|
next_period = periods[i + 1]
|
||||||
|
|
||||||
|
# Get the last interval of current period and first interval of next period
|
||||||
|
last_interval = current_period[-1]
|
||||||
|
first_interval = next_period[0]
|
||||||
|
|
||||||
|
last_start = last_interval.get("interval_start")
|
||||||
|
next_start = first_interval.get("interval_start")
|
||||||
|
|
||||||
|
# Check if they are adjacent (15 minutes apart) and cross midnight
|
||||||
|
if last_start and next_start:
|
||||||
|
time_diff = next_start - last_start
|
||||||
|
last_date = last_start.date()
|
||||||
|
next_date = next_start.date()
|
||||||
|
|
||||||
|
# If they are 15 minutes apart and on different days (crossing midnight)
|
||||||
|
if time_diff == timedelta(minutes=MINUTES_PER_INTERVAL) and next_date > last_date:
|
||||||
|
# Merge the two periods
|
||||||
|
merged_period = current_period + next_period
|
||||||
|
merged.append(merged_period)
|
||||||
|
i += 2 # Skip both periods as we've merged them
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If no merge happened, just add the current period
|
||||||
|
merged.append(current_period)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
def _add_interval_ends(self, periods: list[list[dict]]) -> None:
|
def _add_interval_ends(self, periods: list[list[dict]]) -> None:
|
||||||
"""Add interval_end to each interval using per-interval interval_length."""
|
"""Add interval_end to each interval using per-interval interval_length."""
|
||||||
for period in periods:
|
for period in periods:
|
||||||
|
|
@ -627,35 +651,122 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
return [
|
return [
|
||||||
interval
|
interval
|
||||||
for interval in result
|
for interval in result
|
||||||
if interval.get("interval_start") and today <= interval["interval_start"].date() <= tomorrow
|
if interval.get("_interval_start") and today <= interval["_interval_start"].date() <= tomorrow
|
||||||
]
|
]
|
||||||
|
|
||||||
def _find_current_or_next_interval(self, filtered_result: list[dict]) -> dict | None:
|
def _find_current_or_next_interval(self, filtered_result: list[dict]) -> dict | None:
|
||||||
"""Find the current or next interval from the filtered list."""
|
"""Find the current or next interval from the filtered list."""
|
||||||
now = dt_util.now()
|
now = dt_util.now()
|
||||||
for interval in filtered_result:
|
for interval in filtered_result:
|
||||||
start = interval.get("interval_start")
|
start = interval.get("_interval_start")
|
||||||
end = interval.get("interval_end")
|
end = interval.get("_interval_end")
|
||||||
if start and end and start <= now < end:
|
if start and end and start <= now < end:
|
||||||
return interval.copy()
|
return interval.copy()
|
||||||
for interval in filtered_result:
|
for interval in filtered_result:
|
||||||
start = interval.get("interval_start")
|
start = interval.get("_interval_start")
|
||||||
if start and start > now:
|
if start and start > now:
|
||||||
return interval.copy()
|
return interval.copy()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _filter_periods_today_tomorrow(self, periods: list[list[dict]]) -> list[list[dict]]:
|
def _filter_periods_today_tomorrow(self, periods: list[list[dict]]) -> list[list[dict]]:
|
||||||
"""Filter periods to only those with at least one interval in today or tomorrow."""
|
"""
|
||||||
today = dt_util.now().date()
|
Filter periods to include those that are currently active or in the future.
|
||||||
|
|
||||||
|
This includes periods that started yesterday but are still active now (crossing midnight),
|
||||||
|
as well as periods happening today or tomorrow. We don't want to show all past periods
|
||||||
|
from yesterday, only those that extend into today.
|
||||||
|
"""
|
||||||
|
now = dt_util.now()
|
||||||
|
today = now.date()
|
||||||
tomorrow = today + timedelta(days=1)
|
tomorrow = today + timedelta(days=1)
|
||||||
return [
|
|
||||||
period
|
filtered = []
|
||||||
for period in periods
|
for period in periods:
|
||||||
if any(
|
if not period:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get the period's end time (last interval's end)
|
||||||
|
last_interval = period[-1]
|
||||||
|
period_end = last_interval.get("interval_end")
|
||||||
|
|
||||||
|
# Get the period's start time (first interval's start)
|
||||||
|
first_interval = period[0]
|
||||||
|
period_start = first_interval.get("interval_start")
|
||||||
|
|
||||||
|
if not period_end or not period_start:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Include period if:
|
||||||
|
# 1. It's still active (ends in the future), OR
|
||||||
|
# 2. It has intervals today or tomorrow
|
||||||
|
if period_end > now or any(
|
||||||
interval.get("interval_start") and today <= interval["interval_start"].date() <= tomorrow
|
interval.get("interval_start") and today <= interval["interval_start"].date() <= tomorrow
|
||||||
for interval in period
|
for interval in period
|
||||||
)
|
):
|
||||||
]
|
filtered.append(period)
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
def _build_final_attributes(
|
||||||
|
self,
|
||||||
|
current_interval: dict | None,
|
||||||
|
periods_summary: list[dict],
|
||||||
|
filtered_result: list[dict],
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Build the final attributes dictionary from period summary and current interval.
|
||||||
|
|
||||||
|
Combines period-level attributes with current interval-specific attributes,
|
||||||
|
ensuring price_diff reflects the current interval's position vs daily min/max.
|
||||||
|
"""
|
||||||
|
now = dt_util.now()
|
||||||
|
current_minute = (now.minute // 15) * 15
|
||||||
|
timestamp = now.replace(minute=current_minute, second=0, microsecond=0)
|
||||||
|
|
||||||
|
if current_interval and periods_summary:
|
||||||
|
# Find the current period in the summary based on period_start
|
||||||
|
current_period_start = current_interval.get("period_start")
|
||||||
|
current_period_summary = None
|
||||||
|
|
||||||
|
for period in periods_summary:
|
||||||
|
if period.get("start") == current_period_start:
|
||||||
|
current_period_summary = period
|
||||||
|
break
|
||||||
|
|
||||||
|
if current_period_summary:
|
||||||
|
# Copy all attributes from the period summary
|
||||||
|
attributes = {"timestamp": timestamp}
|
||||||
|
attributes.update(current_period_summary)
|
||||||
|
|
||||||
|
# Add interval-specific price_diff attributes (separate from period average)
|
||||||
|
# Shows the reference interval's position vs daily min/max:
|
||||||
|
# - If period is active: current 15-min interval vs daily min/max
|
||||||
|
# - If period hasn't started: first interval of the period vs daily min/max
|
||||||
|
# This value is what determines if an interval is part of a period (compared to flex setting)
|
||||||
|
if "price_diff_from_min" in current_interval:
|
||||||
|
attributes["interval_price_diff_from_daily_min"] = current_interval["price_diff_from_min"]
|
||||||
|
attributes["interval_price_diff_from_daily_min_%"] = current_interval.get("price_diff_from_min_%")
|
||||||
|
elif "price_diff_from_max" in current_interval:
|
||||||
|
attributes["interval_price_diff_from_daily_max"] = current_interval["price_diff_from_max"]
|
||||||
|
attributes["interval_price_diff_from_daily_max_%"] = current_interval.get("price_diff_from_max_%")
|
||||||
|
|
||||||
|
attributes["periods"] = periods_summary
|
||||||
|
attributes["intervals_count"] = len(filtered_result)
|
||||||
|
return attributes
|
||||||
|
|
||||||
|
# Fallback if current period not found in summary
|
||||||
|
return {
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"periods": periods_summary,
|
||||||
|
"intervals_count": len(filtered_result),
|
||||||
|
}
|
||||||
|
|
||||||
|
# No periods found
|
||||||
|
return {
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"periods": [],
|
||||||
|
"intervals_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
def _get_price_intervals_attributes(self, *, reverse_sort: bool) -> dict | None:
|
def _get_price_intervals_attributes(self, *, reverse_sort: bool) -> dict | None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -704,6 +815,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
|
|
||||||
periods = self._build_periods(all_prices, price_context, reverse_sort=reverse_sort)
|
periods = self._build_periods(all_prices, price_context, reverse_sort=reverse_sort)
|
||||||
periods = self._filter_periods_by_min_length(periods, reverse_sort=reverse_sort)
|
periods = self._filter_periods_by_min_length(periods, reverse_sort=reverse_sort)
|
||||||
|
periods = self._merge_adjacent_periods_at_midnight(periods)
|
||||||
self._add_interval_ends(periods)
|
self._add_interval_ends(periods)
|
||||||
|
|
||||||
filtered_periods = self._filter_periods_today_tomorrow(periods)
|
filtered_periods = self._filter_periods_today_tomorrow(periods)
|
||||||
|
|
@ -713,7 +825,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
filtered_periods,
|
filtered_periods,
|
||||||
ref_prices,
|
ref_prices,
|
||||||
avg_price_by_day,
|
avg_price_by_day,
|
||||||
all_prices,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
filtered_result = self._filter_intervals_today_tomorrow(result)
|
filtered_result = self._filter_intervals_today_tomorrow(result)
|
||||||
|
|
@ -722,20 +833,13 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
if not current_interval and filtered_result:
|
if not current_interval and filtered_result:
|
||||||
current_interval = filtered_result[0]
|
current_interval = filtered_result[0]
|
||||||
|
|
||||||
# Build attributes with current interval info but simplified period summary
|
# Build periods array first
|
||||||
attributes = {**current_interval} if current_interval else {}
|
periods_summary = self._build_periods_summary(filtered_result) if filtered_result else []
|
||||||
|
|
||||||
# Instead of full intervals list, provide period-level summary
|
# Build final attributes using helper method
|
||||||
# This reduces the attribute payload by 90%+
|
attributes = self._build_final_attributes(current_interval, periods_summary, filtered_result)
|
||||||
if filtered_result:
|
|
||||||
periods_summary = self._build_periods_summary(filtered_result)
|
|
||||||
attributes["periods"] = periods_summary
|
|
||||||
attributes["intervals_count"] = len(filtered_result)
|
|
||||||
else:
|
|
||||||
attributes["periods"] = []
|
|
||||||
attributes["intervals_count"] = 0
|
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result (with internal fields intact)
|
||||||
self._cache_key = cache_key
|
self._cache_key = cache_key
|
||||||
self._period_cache = attributes
|
self._period_cache = attributes
|
||||||
|
|
||||||
|
|
@ -743,13 +847,10 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
|
|
||||||
def _build_periods_summary(self, intervals: list[dict]) -> list[dict]:
|
def _build_periods_summary(self, intervals: list[dict]) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Build a summary of periods without including full interval details.
|
Build a summary of periods with consistent attribute structure.
|
||||||
|
|
||||||
Returns a list of period summaries with key information for automations:
|
Returns a list of period summaries with the same attributes as top-level,
|
||||||
- Period start/end times
|
making the structure predictable and easy to use in automations.
|
||||||
- Duration
|
|
||||||
- Average/min/max prices
|
|
||||||
- Number of intervals
|
|
||||||
"""
|
"""
|
||||||
if not intervals:
|
if not intervals:
|
||||||
return []
|
return []
|
||||||
|
|
@ -764,33 +865,106 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
periods_dict[key_str] = []
|
periods_dict[key_str] = []
|
||||||
periods_dict[key_str].append(interval)
|
periods_dict[key_str].append(interval)
|
||||||
|
|
||||||
# Build summary for each period
|
# Build summary for each period with consistent attribute names
|
||||||
summaries = []
|
summaries = []
|
||||||
for period_intervals in periods_dict.values():
|
for period_intervals in periods_dict.values():
|
||||||
if not period_intervals:
|
if not period_intervals:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
first = period_intervals[0]
|
first = period_intervals[0]
|
||||||
|
|
||||||
prices = [i["price"] for i in period_intervals if "price" in i]
|
prices = [i["price"] for i in period_intervals if "price" in i]
|
||||||
|
|
||||||
|
# Use same attribute names as top-level for consistency
|
||||||
summary = {
|
summary = {
|
||||||
"start": first.get("period_start"),
|
"start": first.get("period_start"),
|
||||||
"end": first.get("period_end"),
|
"end": first.get("period_end"),
|
||||||
"hour": first.get("hour"),
|
"hour": first.get("hour"),
|
||||||
"minute": first.get("minute"),
|
"minute": first.get("minute"),
|
||||||
"time": first.get("time"),
|
"time": first.get("time"),
|
||||||
"duration_minutes": first.get("period_length_minute"),
|
"duration_minutes": first.get("duration_minutes"),
|
||||||
|
"periods_total": first.get("periods_total"),
|
||||||
|
"periods_remaining": first.get("periods_remaining"),
|
||||||
|
"period_position": first.get("period_position"),
|
||||||
"intervals_count": len(period_intervals),
|
"intervals_count": len(period_intervals),
|
||||||
"price_avg": round(sum(prices) / len(prices), 4) if prices else 0,
|
"price_avg": round(sum(prices) / len(prices), 2) if prices else 0,
|
||||||
"price_min": round(min(prices), 4) if prices else 0,
|
"price_min": round(min(prices), 2) if prices else 0,
|
||||||
"price_max": round(max(prices), 4) if prices else 0,
|
"price_max": round(max(prices), 2) if prices else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add price_diff attributes if present
|
||||||
|
self._add_price_diff_for_period(summary, period_intervals, first)
|
||||||
|
|
||||||
summaries.append(summary)
|
summaries.append(summary)
|
||||||
|
|
||||||
return summaries
|
return summaries
|
||||||
|
|
||||||
|
def _add_price_diff_for_period(self, summary: dict, period_intervals: list[dict], first: dict) -> None:
|
||||||
|
"""
|
||||||
|
Add price difference attributes for the period based on sensor type.
|
||||||
|
|
||||||
|
Uses the reference price (min/max) from the start day of the period to ensure
|
||||||
|
consistent comparison, especially for periods spanning midnight.
|
||||||
|
|
||||||
|
Calculates how the period's average price compares to the daily min/max,
|
||||||
|
helping to explain why the period qualifies based on flex settings.
|
||||||
|
"""
|
||||||
|
# Determine sensor type and get the reference price from the first interval
|
||||||
|
# (which represents the start of the period and its day's reference value)
|
||||||
|
if "price_diff_from_min" in first:
|
||||||
|
# Best price sensor: calculate difference from the period's start day minimum
|
||||||
|
period_start = first.get("period_start")
|
||||||
|
if not period_start:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get all prices in minor units (cents/øre) from the period
|
||||||
|
prices = [i["price"] for i in period_intervals if "price" in i]
|
||||||
|
if not prices:
|
||||||
|
return
|
||||||
|
|
||||||
|
period_avg_price = sum(prices) / len(prices)
|
||||||
|
|
||||||
|
# Extract the reference min price from first interval's calculation
|
||||||
|
# We can back-calculate it from the first interval's price and diff
|
||||||
|
first_price_minor = first.get("price")
|
||||||
|
first_diff_minor = first.get("price_diff_from_min")
|
||||||
|
|
||||||
|
if first_price_minor is not None and first_diff_minor is not None:
|
||||||
|
ref_min_price = first_price_minor - first_diff_minor
|
||||||
|
period_diff = period_avg_price - ref_min_price
|
||||||
|
|
||||||
|
# Period average price difference from daily minimum
|
||||||
|
summary["period_price_diff_from_daily_min"] = round(period_diff, 2)
|
||||||
|
if ref_min_price != 0:
|
||||||
|
period_diff_pct = (period_diff / ref_min_price) * 100
|
||||||
|
summary["period_price_diff_from_daily_min_%"] = round(period_diff_pct, 2)
|
||||||
|
|
||||||
|
elif "price_diff_from_max" in first:
|
||||||
|
# Peak price sensor: calculate difference from the period's start day maximum
|
||||||
|
period_start = first.get("period_start")
|
||||||
|
if not period_start:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get all prices in minor units (cents/øre) from the period
|
||||||
|
prices = [i["price"] for i in period_intervals if "price" in i]
|
||||||
|
if not prices:
|
||||||
|
return
|
||||||
|
|
||||||
|
period_avg_price = sum(prices) / len(prices)
|
||||||
|
|
||||||
|
# Extract the reference max price from first interval's calculation
|
||||||
|
first_price_minor = first.get("price")
|
||||||
|
first_diff_minor = first.get("price_diff_from_max")
|
||||||
|
|
||||||
|
if first_price_minor is not None and first_diff_minor is not None:
|
||||||
|
ref_max_price = first_price_minor - first_diff_minor
|
||||||
|
period_diff = period_avg_price - ref_max_price
|
||||||
|
|
||||||
|
# Period average price difference from daily maximum
|
||||||
|
summary["period_price_diff_from_daily_max"] = round(period_diff, 2)
|
||||||
|
if ref_max_price != 0:
|
||||||
|
period_diff_pct = (period_diff / ref_max_price) * 100
|
||||||
|
summary["period_price_diff_from_daily_max_%"] = round(period_diff_pct, 2)
|
||||||
|
|
||||||
def _get_price_hours_attributes(self, *, attribute_name: str, reverse_sort: bool) -> dict | None:
|
def _get_price_hours_attributes(self, *, attribute_name: str, reverse_sort: bool) -> dict | None:
|
||||||
"""Get price hours attributes."""
|
"""Get price hours attributes."""
|
||||||
if not self.coordinator.data:
|
if not self.coordinator.data:
|
||||||
|
|
@ -846,7 +1020,9 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
if self._attribute_getter:
|
if self._attribute_getter:
|
||||||
dynamic_attrs = self._attribute_getter()
|
dynamic_attrs = self._attribute_getter()
|
||||||
if dynamic_attrs:
|
if dynamic_attrs:
|
||||||
attributes.update(dynamic_attrs)
|
# Copy and remove internal fields before exposing to user
|
||||||
|
clean_attrs = {k: v for k, v in dynamic_attrs.items() if not k.startswith("_")}
|
||||||
|
attributes.update(clean_attrs)
|
||||||
|
|
||||||
# Add descriptions from the custom translations file
|
# Add descriptions from the custom translations file
|
||||||
if self.entity_description.translation_key and self.hass is not None:
|
if self.entity_description.translation_key and self.hass is not None:
|
||||||
|
|
@ -918,7 +1094,9 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
if self._attribute_getter:
|
if self._attribute_getter:
|
||||||
dynamic_attrs = self._attribute_getter()
|
dynamic_attrs = self._attribute_getter()
|
||||||
if dynamic_attrs:
|
if dynamic_attrs:
|
||||||
attributes.update(dynamic_attrs)
|
# Copy and remove internal fields before exposing to user
|
||||||
|
clean_attrs = {k: v for k, v in dynamic_attrs.items() if not k.startswith("_")}
|
||||||
|
attributes.update(clean_attrs)
|
||||||
|
|
||||||
# Add descriptions from the cache (non-blocking)
|
# Add descriptions from the cache (non-blocking)
|
||||||
if self.entity_description.translation_key and self.hass is not None:
|
if self.entity_description.translation_key and self.hass is not None:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue