refactor(services): process chartdata intervals as unified timeline instead of per-day

Changed from iterating over each day separately to collecting all
intervals for selected days into one continuous list before processing.

Changes:
- Collect all intervals via get_intervals_for_day_offsets() with all
  day_offsets at once
- Remove outer `for day in days:` loop around interval processing
- Build date->day_key mapping during average calculation for lookup
- Add _get_day_key_for_interval() helper for average_field assignment
- Simplify midnight handling: only extend at END of entire selection
- Remove complex "next day lookup" logic at midnight boundaries

The segment boundary handling (bridge points, NULL insertion) now works
automatically across midnight since intervals are processed as one list.

Impact: Fixes bridge point rendering at midnight when rating levels
change between days. Simplifies code structure by removing ~60 lines
of per-day midnight-specific logic.
This commit is contained in:
Julian Pawlowski 2025-12-21 14:55:52 +00:00
parent 5cc71901b9
commit ada17f6d90

View file

@ -455,19 +455,26 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
all_timestamps = {interval["startsAt"] for interval in day_intervals if interval.get("startsAt")} all_timestamps = {interval["startsAt"] for interval in day_intervals if interval.get("startsAt")}
all_timestamps = sorted(all_timestamps) all_timestamps = sorted(all_timestamps)
# Calculate average if requested # Calculate average if requested (per day for average_field)
day_averages = {} # Also build a mapping from date -> day_key for later lookup
if include_average: day_averages: dict[str, float] = {}
date_to_day_key: dict[Any, str] = {} # Maps date object to "yesterday"/"today"/"tomorrow"
for day in days: for day in days:
# Use helper to get intervals for this day # Use helper to get intervals for this day
# Build minimal coordinator_data for single day query
# Map day key to offset: yesterday=-1, today=0, tomorrow=1 # Map day key to offset: yesterday=-1, today=0, tomorrow=1
day_offset = {"yesterday": -1, "today": 0, "tomorrow": 1}[day] day_offset = {"yesterday": -1, "today": 0, "tomorrow": 1}[day]
day_intervals = get_intervals_for_day_offsets(coordinator.data, [day_offset]) day_intervals = get_intervals_for_day_offsets(coordinator.data, [day_offset])
# Collect prices from intervals # Build date -> day_key mapping from actual interval data
prices = [p["total"] for p in day_intervals if p.get("total") is not None] for interval in day_intervals:
start_time = interval.get("startsAt")
if start_time and hasattr(start_time, "date"):
date_to_day_key[start_time.date()] = day
# Calculate average if requested
if include_average:
prices = [p["total"] for p in day_intervals if p.get("total") is not None]
if prices: if prices:
avg = sum(prices) / len(prices) avg = sum(prices) / len(prices)
# Apply same transformations as to regular prices # Apply same transformations as to regular prices
@ -476,20 +483,25 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
avg = round(avg, round_decimals) avg = round(avg, round_decimals)
day_averages[day] = avg day_averages[day] = avg
for day in days: # Collect ALL intervals for the selected days as one continuous list
# Use helper to get intervals for this day # This simplifies processing - no special midnight handling needed
# Map day key to offset: yesterday=-1, today=0, tomorrow=1 day_offsets = [{"yesterday": -1, "today": 0, "tomorrow": 1}[day] for day in days]
day_offset = {"yesterday": -1, "today": 0, "tomorrow": 1}[day] all_prices = get_intervals_for_day_offsets(coordinator.data, day_offsets)
day_prices = get_intervals_for_day_offsets(coordinator.data, [day_offset])
# Helper to get day key from interval timestamp for average lookup
def _get_day_key_for_interval(interval_start: Any) -> str | None:
"""Determine which day key (yesterday/today/tomorrow) an interval belongs to."""
if not interval_start or not hasattr(interval_start, "date"):
return None
# Use pre-built mapping from actual interval data (TimeService-compatible)
return date_to_day_key.get(interval_start.date())
if resolution == "interval": if resolution == "interval":
# Original 15-minute intervals # Original 15-minute intervals
if insert_nulls == "all" and (level_filter or rating_level_filter): if insert_nulls == "all" and (level_filter or rating_level_filter):
# Mode 'all': Insert NULL for all timestamps where filter doesn't match # Mode 'all': Insert NULL for all timestamps where filter doesn't match
# Build a map of timestamp -> interval for quick lookup # Build a map of timestamp -> interval for quick lookup
interval_map = { interval_map = {interval.get("startsAt"): interval for interval in all_prices if interval.get("startsAt")}
interval.get("startsAt"): interval for interval in day_prices if interval.get("startsAt")
}
# Process all timestamps, filling gaps with NULL # Process all timestamps, filling gaps with NULL
for start_time in all_timestamps: for start_time in all_timestamps:
@ -534,19 +546,21 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
data_point[rating_level_field] = interval["rating_level"] data_point[rating_level_field] = interval["rating_level"]
# Add average if requested # Add average if requested
if include_average and day in day_averages: day_key = _get_day_key_for_interval(start_time)
data_point[average_field] = day_averages[day] if include_average and day_key and day_key in day_averages:
data_point[average_field] = day_averages[day_key]
chart_data.append(data_point) chart_data.append(data_point)
elif insert_nulls == "segments" and (level_filter or rating_level_filter): elif insert_nulls == "segments" and (level_filter or rating_level_filter):
# Mode 'segments': Add NULL points at segment boundaries for clean gaps # Mode 'segments': Add NULL points at segment boundaries for clean gaps
# Determine which field to check based on filter type # Process ALL intervals as one continuous list - no special midnight handling needed
filter_field = "rating_level" if rating_level_filter else "level" filter_field = "rating_level" if rating_level_filter else "level"
filter_values = rating_level_filter if rating_level_filter else level_filter filter_values = rating_level_filter if rating_level_filter else level_filter
for i in range(len(day_prices) - 1): for i in range(len(all_prices) - 1):
interval = day_prices[i] interval = all_prices[i]
next_interval = day_prices[i + 1] next_interval = all_prices[i + 1]
start_time = interval.get("startsAt") start_time = interval.get("startsAt")
price = interval.get("total") price = interval.get("total")
@ -568,9 +582,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
# Add current point # Add current point
data_point = { data_point = {
start_time_field: start_time.isoformat() start_time_field: start_time.isoformat() if hasattr(start_time, "isoformat") else start_time,
if hasattr(start_time, "isoformat")
else start_time,
price_field: converted_price, price_field: converted_price,
} }
@ -578,8 +590,11 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
data_point[level_field] = interval["level"] data_point[level_field] = interval["level"]
if include_rating_level and "rating_level" in interval: if include_rating_level and "rating_level" in interval:
data_point[rating_level_field] = interval["rating_level"] data_point[rating_level_field] = interval["rating_level"]
if include_average and day in day_averages:
data_point[average_field] = day_averages[day] # Add average if requested
day_key = _get_day_key_for_interval(start_time)
if include_average and day_key and day_key in day_averages:
data_point[average_field] = day_averages[day_key]
chart_data.append(data_point) chart_data.append(data_point)
@ -612,8 +627,8 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
bridge_point[level_field] = interval["level"] bridge_point[level_field] = interval["level"]
if include_rating_level and "rating_level" in interval: if include_rating_level and "rating_level" in interval:
bridge_point[rating_level_field] = interval["rating_level"] bridge_point[rating_level_field] = interval["rating_level"]
if include_average and day in day_averages: if include_average and day_key and day_key in day_averages:
bridge_point[average_field] = day_averages[day] bridge_point[average_field] = day_averages[day_key]
chart_data.append(bridge_point) chart_data.append(bridge_point)
# 2. NULL point: stops the current series # 2. NULL point: stops the current series
@ -630,79 +645,69 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
hold_point[level_field] = interval["level"] hold_point[level_field] = interval["level"]
if include_rating_level and "rating_level" in interval: if include_rating_level and "rating_level" in interval:
hold_point[rating_level_field] = interval["rating_level"] hold_point[rating_level_field] = interval["rating_level"]
if include_average and day in day_averages: if include_average and day_key and day_key in day_averages:
hold_point[average_field] = day_averages[day] hold_point[average_field] = day_averages[day_key]
chart_data.append(hold_point) chart_data.append(hold_point)
# Add NULL point to create gap # Add NULL point to create gap
null_point = {start_time_field: next_start_serialized, price_field: None} null_point = {start_time_field: next_start_serialized, price_field: None}
chart_data.append(null_point) chart_data.append(null_point)
# Handle last interval of the day - extend to midnight # Handle LAST interval of the entire selection (not per-day)
if day_prices: # The main loop processes up to n-1, so we need to add the last interval
last_interval = day_prices[-1] if all_prices:
last_interval = all_prices[-1]
last_start_time = last_interval.get("startsAt") last_start_time = last_interval.get("startsAt")
last_price = last_interval.get("total") last_price = last_interval.get("total")
last_value = last_interval.get(filter_field) last_value = last_interval.get(filter_field)
if last_start_time and last_price is not None and last_value in filter_values: # type: ignore[operator] if last_start_time and last_price is not None and last_value in filter_values: # type: ignore[operator]
# Timestamp is already datetime in local timezone # Add the last interval as a data point
last_dt = last_start_time # Already datetime object converted_last_price = round(last_price * 100, 2) if subunit_currency else round(last_price, 4)
if last_dt:
# Calculate next day at 00:00
next_day = last_dt.replace(hour=0, minute=0, second=0, microsecond=0)
next_day = next_day + timedelta(days=1)
midnight_timestamp = next_day.isoformat()
# Try to get real price from tomorrow's first interval
next_day_name = None
if day == "yesterday":
next_day_name = "today"
elif day == "today":
next_day_name = "tomorrow"
# For "tomorrow", we don't have a "day after tomorrow"
midnight_price = None
midnight_interval = None
if next_day_name:
# Use helper to get first interval of next day
# Map day key to offset: yesterday=-1, today=0, tomorrow=1
next_day_offset = {"yesterday": -1, "today": 0, "tomorrow": 1}[next_day_name]
next_day_intervals = get_intervals_for_day_offsets(coordinator.data, [next_day_offset])
if next_day_intervals:
first_next = next_day_intervals[0]
first_next_value = first_next.get(filter_field)
# Only use tomorrow's price if it matches the same filter
if first_next_value == last_value:
midnight_price = first_next.get("total")
midnight_interval = first_next
# Fallback: use last interval's price if no tomorrow data or different level
if midnight_price is None:
midnight_price = last_price
midnight_interval = last_interval
# Convert price
converted_price = (
round(midnight_price * 100, 2) if subunit_currency else round(midnight_price, 4)
)
if round_decimals is not None: if round_decimals is not None:
converted_price = round(converted_price, round_decimals) converted_last_price = round(converted_last_price, round_decimals)
# Add point at midnight with appropriate price (extends graph to end of day) last_data_point = {
end_point = {start_time_field: midnight_timestamp, price_field: converted_price} start_time_field: last_start_time.isoformat()
if midnight_interval is not None: if hasattr(last_start_time, "isoformat")
if include_level and "level" in midnight_interval: else last_start_time,
end_point[level_field] = midnight_interval["level"] price_field: converted_last_price,
if include_rating_level and "rating_level" in midnight_interval: }
end_point[rating_level_field] = midnight_interval["rating_level"] if include_level and "level" in last_interval:
if include_average and day in day_averages: last_data_point[level_field] = last_interval["level"]
end_point[average_field] = day_averages[day] if include_rating_level and "rating_level" in last_interval:
last_data_point[rating_level_field] = last_interval["rating_level"]
day_key = _get_day_key_for_interval(last_start_time)
if include_average and day_key and day_key in day_averages:
last_data_point[average_field] = day_averages[day_key]
chart_data.append(last_data_point)
# Extend to end of selected time range (midnight after last day)
last_dt = last_start_time
if last_dt:
# Calculate midnight after the last interval
next_midnight = last_dt.replace(hour=0, minute=0, second=0, microsecond=0)
next_midnight = next_midnight + timedelta(days=1)
midnight_timestamp = next_midnight.isoformat()
# Add hold point at midnight
end_point = {start_time_field: midnight_timestamp, price_field: converted_last_price}
if include_level and "level" in last_interval:
end_point[level_field] = last_interval["level"]
if include_rating_level and "rating_level" in last_interval:
end_point[rating_level_field] = last_interval["rating_level"]
if include_average and day_key and day_key in day_averages:
end_point[average_field] = day_averages[day_key]
chart_data.append(end_point) chart_data.append(end_point)
# Add NULL to end series
null_point = {start_time_field: midnight_timestamp, price_field: None}
chart_data.append(null_point)
else: else:
# Mode 'none' (default): Only return matching intervals, no NULL insertion # Mode 'none' (default): Only return matching intervals, no NULL insertion
for interval in day_prices: for interval in all_prices:
start_time = interval.get("startsAt") start_time = interval.get("startsAt")
price = interval.get("total") price = interval.get("total")
@ -735,9 +740,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
price = round(price, round_decimals) price = round(price, round_decimals)
data_point = { data_point = {
start_time_field: start_time.isoformat() start_time_field: start_time.isoformat() if hasattr(start_time, "isoformat") else start_time,
if hasattr(start_time, "isoformat")
else start_time,
price_field: price, price_field: price,
} }
@ -750,16 +753,18 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
data_point[rating_level_field] = interval["rating_level"] data_point[rating_level_field] = interval["rating_level"]
# Add average if requested # Add average if requested
if include_average and day in day_averages: day_key = _get_day_key_for_interval(start_time)
data_point[average_field] = day_averages[day] if include_average and day_key and day_key in day_averages:
data_point[average_field] = day_averages[day_key]
chart_data.append(data_point) chart_data.append(data_point)
elif resolution == "hourly": elif resolution == "hourly":
# Hourly averages (4 intervals per hour: :00, :15, :30, :45) # Hourly averages (4 intervals per hour: :00, :15, :30, :45)
# Process all intervals together for hourly aggregation
chart_data.extend( chart_data.extend(
aggregate_hourly_exact( aggregate_hourly_exact(
day_prices, all_prices,
start_time_field, start_time_field,
price_field, price_field,
coordinator=coordinator, coordinator=coordinator,
@ -773,7 +778,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
level_field=level_field, level_field=level_field,
rating_level_field=rating_level_field, rating_level_field=rating_level_field,
average_field=average_field, average_field=average_field,
day_average=day_averages.get(day), day_average=None, # Not used when processing all days together
threshold_low=threshold_low, threshold_low=threshold_low,
period_timestamps=period_timestamps, period_timestamps=period_timestamps,
threshold_high=threshold_high, threshold_high=threshold_high,