refactoring for QUARTER_HOURLY prices

This commit is contained in:
Julian Pawlowski 2025-11-02 19:33:19 +00:00
parent 70b5a0acd1
commit 8c61292acf
8 changed files with 161 additions and 100 deletions

View file

@ -182,6 +182,7 @@ def _is_data_empty(data: dict, query_type: str) -> bool:
elif query_type == "price_info": elif query_type == "price_info":
# Check for homes existence and non-emptiness before accessing # Check for homes existence and non-emptiness before accessing
subscription = None
if ( if (
"viewer" not in data "viewer" not in data
or "homes" not in data["viewer"] or "homes" not in data["viewer"]
@ -189,29 +190,34 @@ def _is_data_empty(data: dict, query_type: str) -> bool:
or len(data["viewer"]["homes"]) == 0 or len(data["viewer"]["homes"]) == 0
or "currentSubscription" not in data["viewer"]["homes"][0] or "currentSubscription" not in data["viewer"]["homes"][0]
or data["viewer"]["homes"][0]["currentSubscription"] is None or data["viewer"]["homes"][0]["currentSubscription"] is None
or "priceInfo" not in data["viewer"]["homes"][0]["currentSubscription"]
): ):
_LOGGER.debug("Missing homes/currentSubscription/priceInfo in price_info check") _LOGGER.debug("Missing homes/currentSubscription in price_info check")
is_empty = True is_empty = True
else: else:
price_info = data["viewer"]["homes"][0]["currentSubscription"]["priceInfo"] subscription = data["viewer"]["homes"][0]["currentSubscription"]
# Check historical data (either range or yesterday) # Check priceInfoRange (192 quarter-hourly intervals)
has_historical = ( has_historical = (
"range" in price_info "priceInfoRange" in subscription
and price_info["range"] is not None and subscription["priceInfoRange"] is not None
and "edges" in price_info["range"] and "edges" in subscription["priceInfoRange"]
and price_info["range"]["edges"] and subscription["priceInfoRange"]["edges"]
) )
# Check today's data # Check priceInfo for today's data
has_today = "today" in price_info and price_info["today"] is not None and len(price_info["today"]) > 0 has_price_info = "priceInfo" in subscription and subscription["priceInfo"] is not None
has_today = (
has_price_info
and "today" in subscription["priceInfo"]
and subscription["priceInfo"]["today"] is not None
and len(subscription["priceInfo"]["today"]) > 0
)
# Data is empty if we don't have historical data or today's data # Data is empty if we don't have historical data or today's data
is_empty = not has_historical or not has_today is_empty = not has_historical or not has_today
_LOGGER.debug( _LOGGER.debug(
"Price info check - historical data historical: %s, today: %s, is_empty: %s", "Price info check - priceInfoRange: %s, today: %s, is_empty: %s",
bool(has_historical), bool(has_historical),
bool(has_today), bool(has_today),
is_empty, is_empty,
@ -233,34 +239,22 @@ def _is_data_empty(data: dict, query_type: str) -> bool:
else: else:
rating = data["viewer"]["homes"][0]["currentSubscription"]["priceRating"] rating = data["viewer"]["homes"][0]["currentSubscription"]["priceRating"]
# Check threshold percentages # Check rating entries
has_thresholds = ( has_entries = (
"thresholdPercentages" in rating query_type in rating
and rating["thresholdPercentages"] is not None and rating[query_type] is not None
and "low" in rating["thresholdPercentages"] and "entries" in rating[query_type]
and "high" in rating["thresholdPercentages"] and rating[query_type]["entries"] is not None
and len(rating[query_type]["entries"]) > 0
) )
if not has_thresholds:
_LOGGER.debug("Missing or invalid threshold percentages for %s rating", query_type)
is_empty = True
else:
# Check rating entries
has_entries = (
query_type in rating
and rating[query_type] is not None
and "entries" in rating[query_type]
and rating[query_type]["entries"] is not None
and len(rating[query_type]["entries"]) > 0
)
is_empty = not has_entries is_empty = not has_entries
_LOGGER.debug( _LOGGER.debug(
"%s rating check - has_thresholds: %s, entries count: %d, is_empty: %s", "%s rating check - entries count: %d, is_empty: %s",
query_type, query_type,
has_thresholds, len(rating[query_type]["entries"]) if has_entries else 0,
len(rating[query_type]["entries"]) if has_entries else 0, is_empty,
is_empty, )
)
else: else:
_LOGGER.debug("Unknown query type %s, treating as non-empty", query_type) _LOGGER.debug("Unknown query type %s, treating as non-empty", query_type)
is_empty = False is_empty = False
@ -280,19 +274,25 @@ def _prepare_headers(access_token: str) -> dict[str, str]:
} }
def _flatten_price_info(subscription: dict) -> dict: def _flatten_price_info(subscription: dict, currency: str | None = None) -> dict:
"""Transform and flatten priceInfo from full API data structure.""" """
Transform and flatten priceInfo from full API data structure.
Now handles priceInfoRange (192 quarter-hourly intervals) separately from
priceInfo (today and tomorrow data). Currency is stored as a separate attribute.
"""
price_info = subscription.get("priceInfo", {}) price_info = subscription.get("priceInfo", {})
price_info_range = subscription.get("priceInfoRange", {})
# Get today and yesterday dates using Home Assistant's dt_util # Get today and yesterday dates using Home Assistant's dt_util
today_local = dt_util.now().date() today_local = dt_util.now().date()
yesterday_local = today_local - timedelta(days=1) yesterday_local = today_local - timedelta(days=1)
_LOGGER.debug("Processing data for yesterday's date: %s", yesterday_local) _LOGGER.debug("Processing data for yesterday's date: %s", yesterday_local)
# Transform edges data (extract yesterday's prices) # Transform priceInfoRange edges data (extract yesterday's quarter-hourly prices)
if "range" in price_info and "edges" in price_info["range"]: yesterday_prices = []
edges = price_info["range"]["edges"] if "edges" in price_info_range:
yesterday_prices = [] edges = price_info_range["edges"]
for edge in edges: for edge in edges:
if "node" not in edge: if "node" not in edge:
@ -315,14 +315,12 @@ def _flatten_price_info(subscription: dict) -> dict:
yesterday_prices.append(price_data) yesterday_prices.append(price_data)
_LOGGER.debug("Found %d price entries for yesterday", len(yesterday_prices)) _LOGGER.debug("Found %d price entries for yesterday", len(yesterday_prices))
# Replace the entire range object with yesterday prices
price_info["yesterday"] = yesterday_prices
del price_info["range"]
return { return {
"yesterday": price_info.get("yesterday", []), "yesterday": yesterday_prices,
"today": price_info.get("today", []), "today": price_info.get("today", []),
"tomorrow": price_info.get("tomorrow", []), "tomorrow": price_info.get("tomorrow", []),
"currency": currency,
} }
@ -344,7 +342,6 @@ def _flatten_price_rating(subscription: dict) -> dict:
"hourly": hourly_entries, "hourly": hourly_entries,
"daily": daily_entries, "daily": daily_entries,
"monthly": monthly_entries, "monthly": monthly_entries,
"thresholdPercentages": price_rating.get("thresholdPercentages"),
"currency": currency, "currency": currency,
} }
@ -404,13 +401,23 @@ class TibberPricesApiClient:
data = await self._api_wrapper( data = await self._api_wrapper(
data={ data={
"query": """ "query": """
{viewer{homes{id,currentSubscription{priceInfo{ {viewer{homes{
range(resolution:HOURLY,last:48){edges{node{ id
startsAt total energy tax level consumption(resolution:DAILY,last:1){
}}} pageInfo{currency}
today{startsAt total energy tax level} }
tomorrow{startsAt total energy tax level} currentSubscription{
}}}}}""" priceInfoRange(resolution:QUARTER_HOURLY,last:192){
edges{node{
startsAt total energy tax level
}}
}
priceInfo(resolution:QUARTER_HOURLY){
today{startsAt total energy tax level}
tomorrow{startsAt total energy tax level}
}
}
}}}"""
}, },
query_type=QueryType.PRICE_INFO, query_type=QueryType.PRICE_INFO,
) )
@ -421,7 +428,17 @@ class TibberPricesApiClient:
home_id = home.get("id") home_id = home.get("id")
if home_id: if home_id:
if "currentSubscription" in home: if "currentSubscription" in home:
homes_data[home_id] = _flatten_price_info(home["currentSubscription"]) # Extract currency from consumption data if available
currency = None
if home.get("consumption"):
page_info = home["consumption"].get("pageInfo")
if page_info:
currency = page_info.get("currency")
homes_data[home_id] = _flatten_price_info(
home["currentSubscription"],
currency,
)
else: else:
homes_data[home_id] = {} homes_data[home_id] = {}
@ -434,7 +451,6 @@ class TibberPricesApiClient:
data={ data={
"query": """ "query": """
{viewer{homes{id,currentSubscription{priceRating{ {viewer{homes{id,currentSubscription{priceRating{
thresholdPercentages{low high}
daily{ daily{
currency currency
entries{time total energy tax difference level} entries{time total energy tax difference level}
@ -463,7 +479,6 @@ class TibberPricesApiClient:
data={ data={
"query": """ "query": """
{viewer{homes{id,currentSubscription{priceRating{ {viewer{homes{id,currentSubscription{priceRating{
thresholdPercentages{low high}
hourly{ hourly{
currency currency
entries{time total energy tax difference level} entries{time total energy tax difference level}
@ -492,7 +507,6 @@ class TibberPricesApiClient:
data={ data={
"query": """ "query": """
{viewer{homes{id,currentSubscription{priceRating{ {viewer{homes{id,currentSubscription{priceRating{
thresholdPercentages{low high}
monthly{ monthly{
currency currency
entries{time total energy tax difference level} entries{time total energy tax difference level}

View file

@ -112,7 +112,21 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
return None return None
def _get_flex_option(self, option_key: str, default: float) -> float: def _get_flex_option(self, option_key: str, default: float) -> float:
"""Get a float option from config entry options or fallback to default. Accepts 0-100.""" """
Get a float option from config entry.
Converts percentage values to decimal fractions.
- CONF_BEST_PRICE_FLEX: positive 0-100 0.0-1.0
- CONF_PEAK_PRICE_FLEX: negative -100 to 0 -1.0 to 0.0
Args:
option_key: The config key (CONF_BEST_PRICE_FLEX or CONF_PEAK_PRICE_FLEX)
default: Default value to use if not found
Returns:
Value converted to decimal fraction (e.g., 5 0.05, -5 -0.05)
"""
options = self.coordinator.config_entry.options options = self.coordinator.config_entry.options
data = self.coordinator.config_entry.data data = self.coordinator.config_entry.data
value = options.get(option_key, data.get(option_key, default)) value = options.get(option_key, data.get(option_key, default))
@ -407,7 +421,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
price = float(price_data["total"]) price = float(price_data["total"])
percent_diff = ((price - ref_price) / ref_price) * 100 if ref_price != 0 else 0.0 percent_diff = ((price - ref_price) / ref_price) * 100 if ref_price != 0 else 0.0
percent_diff = round(percent_diff, 2) percent_diff = round(percent_diff, 2)
# For best price: percent_diff <= flex*100; for peak: percent_diff >= -flex*100 # For best price (flex >= 0): percent_diff <= flex*100 (prices up to flex% above reference)
# For peak price (flex <= 0): percent_diff >= -flex*100 (prices up to |flex|% above reference)
in_flex = percent_diff <= flex * 100 if not reverse_sort else percent_diff >= -flex * 100 in_flex = percent_diff <= flex * 100 if not reverse_sort else percent_diff >= -flex * 100
# Split period if day or interval length changes # Split period if day or interval length changes
if ( if (

View file

@ -41,9 +41,13 @@ from .const import (
CONF_BEST_PRICE_FLEX, CONF_BEST_PRICE_FLEX,
CONF_EXTENDED_DESCRIPTIONS, CONF_EXTENDED_DESCRIPTIONS,
CONF_PEAK_PRICE_FLEX, CONF_PEAK_PRICE_FLEX,
CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_BEST_PRICE_FLEX, DEFAULT_BEST_PRICE_FLEX,
DEFAULT_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS,
DEFAULT_PEAK_PRICE_FLEX, DEFAULT_PEAK_PRICE_FLEX,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DOMAIN, DOMAIN,
LOGGER, LOGGER,
) )
@ -380,38 +384,6 @@ class TibberPricesSubentryFlowHandler(ConfigSubentryFlow):
CONF_EXTENDED_DESCRIPTIONS, CONF_EXTENDED_DESCRIPTIONS,
default=subentry.data.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS), default=subentry.data.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS),
): BooleanSelector(), ): BooleanSelector(),
vol.Optional(
CONF_BEST_PRICE_FLEX,
default=int(
subentry.data.get(
CONF_BEST_PRICE_FLEX,
DEFAULT_BEST_PRICE_FLEX,
)
),
): NumberSelector(
NumberSelectorConfig(
min=0,
max=100,
step=1,
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_PEAK_PRICE_FLEX,
default=int(
subentry.data.get(
CONF_PEAK_PRICE_FLEX,
DEFAULT_PEAK_PRICE_FLEX,
)
),
): NumberSelector(
NumberSelectorConfig(
min=0,
max=100,
step=1,
mode=NumberSelectorMode.SLIDER,
),
),
} }
if user_input is not None: if user_input is not None:
@ -473,6 +445,38 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
DEFAULT_PEAK_PRICE_FLEX, DEFAULT_PEAK_PRICE_FLEX,
) )
), ),
): NumberSelector(
NumberSelectorConfig(
min=-100,
max=0,
step=1,
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_PRICE_RATING_THRESHOLD_LOW,
default=int(
self.config_entry.options.get(
CONF_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
)
),
): NumberSelector(
NumberSelectorConfig(
min=-100,
max=0,
step=1,
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_PRICE_RATING_THRESHOLD_HIGH,
default=int(
self.config_entry.options.get(
CONF_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
)
),
): NumberSelector( ): NumberSelector(
NumberSelectorConfig( NumberSelectorConfig(
min=0, min=0,

View file

@ -17,6 +17,8 @@ DOMAIN = "tibber_prices"
CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions" CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions"
CONF_BEST_PRICE_FLEX = "best_price_flex" CONF_BEST_PRICE_FLEX = "best_price_flex"
CONF_PEAK_PRICE_FLEX = "peak_price_flex" CONF_PEAK_PRICE_FLEX = "peak_price_flex"
CONF_PRICE_RATING_THRESHOLD_LOW = "price_rating_threshold_low"
CONF_PRICE_RATING_THRESHOLD_HIGH = "price_rating_threshold_high"
ATTRIBUTION = "Data provided by Tibber" ATTRIBUTION = "Data provided by Tibber"
@ -24,7 +26,9 @@ ATTRIBUTION = "Data provided by Tibber"
DEFAULT_NAME = "Tibber Price Information & Ratings" DEFAULT_NAME = "Tibber Price Information & Ratings"
DEFAULT_EXTENDED_DESCRIPTIONS = False DEFAULT_EXTENDED_DESCRIPTIONS = False
DEFAULT_BEST_PRICE_FLEX = 5 # 5% flexibility for best price (user-facing, percent) DEFAULT_BEST_PRICE_FLEX = 5 # 5% flexibility for best price (user-facing, percent)
DEFAULT_PEAK_PRICE_FLEX = 5 # 5% flexibility for peak price (user-facing, percent) DEFAULT_PEAK_PRICE_FLEX = -5 # 5% flexibility for peak price (user-facing, percent)
DEFAULT_PRICE_RATING_THRESHOLD_LOW = -10 # Default rating threshold low percentage
DEFAULT_PRICE_RATING_THRESHOLD_HIGH = 10 # Default rating threshold high percentage
# Home types # Home types
HOME_TYPE_APARTMENT = "APARTMENT" HOME_TYPE_APARTMENT = "APARTMENT"

View file

@ -23,7 +23,13 @@ from .api import (
TibberPricesApiClientCommunicationError, TibberPricesApiClientCommunicationError,
TibberPricesApiClientError, TibberPricesApiClientError,
) )
from .const import DOMAIN from .const import (
CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -268,6 +274,14 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return {} return {}
return self._transform_data_for_main_entry(self._cached_price_data) return self._transform_data_for_main_entry(self._cached_price_data)
def _get_threshold_percentages(self) -> dict[str, int]:
"""Get threshold percentages from config options."""
options = self.config_entry.options or {}
return {
"low": options.get(CONF_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_RATING_THRESHOLD_LOW),
"high": options.get(CONF_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_HIGH),
}
def _transform_data_for_main_entry(self, raw_data: dict[str, Any]) -> dict[str, Any]: def _transform_data_for_main_entry(self, raw_data: dict[str, Any]) -> dict[str, Any]:
"""Transform raw data for main entry (aggregated view of all homes).""" """Transform raw data for main entry (aggregated view of all homes)."""
# For main entry, we can show data from the first home as default # For main entry, we can show data from the first home as default
@ -290,6 +304,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"hourly": {"entries": first_home_data.get("hourly_rating", [])}, "hourly": {"entries": first_home_data.get("hourly_rating", [])},
"daily": {"entries": first_home_data.get("daily_rating", [])}, "daily": {"entries": first_home_data.get("daily_rating", [])},
"monthly": {"entries": first_home_data.get("monthly_rating", [])}, "monthly": {"entries": first_home_data.get("monthly_rating", [])},
"thresholdPercentages": self._get_threshold_percentages(),
} }
return { return {
@ -322,6 +337,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"hourly": {"entries": home_data.get("hourly_rating", [])}, "hourly": {"entries": home_data.get("hourly_rating", [])},
"daily": {"entries": home_data.get("daily_rating", [])}, "daily": {"entries": home_data.get("daily_rating", [])},
"monthly": {"entries": home_data.get("monthly_rating", [])}, "monthly": {"entries": home_data.get("monthly_rating", [])},
"thresholdPercentages": self._get_threshold_percentages(),
} }
return { return {

View file

@ -367,7 +367,7 @@ def _extract_price_data(data: dict) -> tuple[dict, dict, list, Any, Any]:
price_rating_data = data.get("priceRating") or {} price_rating_data = data.get("priceRating") or {}
hourly_ratings = price_rating_data.get("hourly") or [] hourly_ratings = price_rating_data.get("hourly") or []
rating_threshold_percentages = price_rating_data.get("thresholdPercentages") rating_threshold_percentages = price_rating_data.get("thresholdPercentages")
currency = price_rating_data.get("currency") currency = price_info_data.get("currency")
return price_info_data, price_rating_data, hourly_ratings, rating_threshold_percentages, currency return price_info_data, price_rating_data, hourly_ratings, rating_threshold_percentages, currency

View file

@ -75,7 +75,9 @@
"access_token": "API-Zugriffstoken", "access_token": "API-Zugriffstoken",
"extended_descriptions": "Erweiterte Beschreibungen in Entitätsattributen anzeigen", "extended_descriptions": "Erweiterte Beschreibungen in Entitätsattributen anzeigen",
"best_price_flex": "Flexibilität für Bestpreis (%)", "best_price_flex": "Flexibilität für Bestpreis (%)",
"peak_price_flex": "Flexibilität für Spitzenpreis (%)" "peak_price_flex": "Flexibilität für Spitzenpreis (%)",
"price_rating_threshold_low": "Preisbewertungs-Schwellenwert Niedrig (% vs. Durchschnitt)",
"price_rating_threshold_high": "Preisbewertungs-Schwellenwert Hoch (% vs. Durchschnitt)"
}, },
"title": "Optionen für Tibber Preisinformationen & Bewertungen", "title": "Optionen für Tibber Preisinformationen & Bewertungen",
"submit": "Optionen speichern" "submit": "Optionen speichern"
@ -93,7 +95,9 @@
"entry_not_found": "Tibber Konfigurationseintrag nicht gefunden." "entry_not_found": "Tibber Konfigurationseintrag nicht gefunden."
}, },
"best_price_flex": "Bestpreis Flexibilität (%)", "best_price_flex": "Bestpreis Flexibilität (%)",
"peak_price_flex": "Spitzenpreis Flexibilität (%)" "peak_price_flex": "Spitzenpreis Flexibilität (%)",
"price_rating_threshold_low": "Niedriger Preis Schwellenwert (% zum gleitenden Durchschnitt)",
"price_rating_threshold_high": "Hoher Preis Schwellenwert (% zum gleitenden Durchschnitt)"
}, },
"entity": { "entity": {
"sensor": { "sensor": {

View file

@ -75,7 +75,9 @@
"access_token": "API access token", "access_token": "API access token",
"extended_descriptions": "Show extended descriptions in entity attributes", "extended_descriptions": "Show extended descriptions in entity attributes",
"best_price_flex": "Best Price Flexibility (%)", "best_price_flex": "Best Price Flexibility (%)",
"peak_price_flex": "Peak Price Flexibility (%)" "peak_price_flex": "Peak Price Flexibility (%)",
"price_rating_threshold_low": "Price Rating Threshold Low (% vs trailing average)",
"price_rating_threshold_high": "Price Rating Threshold High (% vs trailing average)"
}, },
"title": "Options for Tibber Price Information & Ratings", "title": "Options for Tibber Price Information & Ratings",
"submit": "Save Options" "submit": "Save Options"
@ -93,7 +95,9 @@
"entry_not_found": "Tibber configuration entry not found." "entry_not_found": "Tibber configuration entry not found."
}, },
"best_price_flex": "Best Price Flexibility (%)", "best_price_flex": "Best Price Flexibility (%)",
"peak_price_flex": "Peak Price Flexibility (%)" "peak_price_flex": "Peak Price Flexibility (%)",
"price_rating_threshold_low": "Price Low Threshold (% to trailing average)",
"price_rating_threshold_high": "Price High Threshold (% to trailing average)"
}, },
"entity": { "entity": {
"sensor": { "sensor": {