perf(interval_pool): hoist fetch_groups and precompute period criteria

- Move UTC import from inline (inside _has_real_gaps_in_range) to
  module-level in manager.py
- Hoist get_fetch_groups() out of while loop in _get_cached_intervals:
  eliminates ~384 function calls per invocation
- Pre-compute criteria_by_day dict in build_periods before the for-loop:
  eliminates ~381 redundant NamedTuple constructions per call; only
  ref_price/avg_price vary by day (max 3 entries), flex/min_distance/
  reverse_sort are constant throughout

Impact: Reduces unnecessary object creation during the hot paths called
every 15 minutes and during all relaxation phases.
This commit is contained in:
Julian Pawlowski 2026-04-06 14:35:33 +00:00
parent 636bd7a797
commit 8f05f8cac7
2 changed files with 16 additions and 12 deletions

View file

@ -112,6 +112,19 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
intervals_filtered_by_flex = 0 intervals_filtered_by_flex = 0
intervals_filtered_by_min_distance = 0 intervals_filtered_by_min_distance = 0
# Pre-compute criteria per day (flex/min_distance/reverse_sort are constant throughout;
# only ref_price and avg_price vary by day — max 3 entries: yesterday/today/tomorrow)
criteria_by_day: dict[date, TibberPricesIntervalCriteria] = {
day: TibberPricesIntervalCriteria(
ref_price=ref_prices[day],
avg_price=avg_prices[day],
flex=flex,
min_distance_from_avg=min_distance_from_avg,
reverse_sort=reverse_sort,
)
for day in ref_prices
}
for price_data in all_prices: for price_data in all_prices:
starts_at = time.get_interval_time(price_data) starts_at = time.get_interval_time(price_data)
if starts_at is None: if starts_at is None:
@ -133,13 +146,7 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
ref_date = date_key ref_date = date_key
# Check flex and minimum distance criteria (using smoothed price and interval's own day reference) # Check flex and minimum distance criteria (using smoothed price and interval's own day reference)
criteria = TibberPricesIntervalCriteria( criteria = criteria_by_day[ref_date]
ref_price=ref_prices[ref_date],
avg_price=avg_prices[ref_date],
flex=flex,
min_distance_from_avg=min_distance_from_avg,
reverse_sort=reverse_sort,
)
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, criteria) in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, criteria)
# Track why intervals are filtered # Track why intervals are filtered

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import logging import logging
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@ -459,8 +459,6 @@ class TibberPricesIntervalPool:
True if a real gap exists, False if the range is fully covered. True if a real gap exists, False if the range is fully covered.
""" """
from datetime import UTC # noqa: PLC0415 - UTC constant needed here only
cached_intervals = self._get_cached_intervals(start_iso, end_iso) cached_intervals = self._get_cached_intervals(start_iso, end_iso)
if not cached_intervals: if not cached_intervals:
@ -582,14 +580,13 @@ class TibberPricesIntervalPool:
resolution_change_naive = datetime(2025, 10, 1) # noqa: DTZ001 resolution_change_naive = datetime(2025, 10, 1) # noqa: DTZ001
interval_minutes = INTERVAL_QUARTER_HOURLY if current_naive >= resolution_change_naive else INTERVAL_HOURLY interval_minutes = INTERVAL_QUARTER_HOURLY if current_naive >= resolution_change_naive else INTERVAL_HOURLY
fetch_groups = self._cache.get_fetch_groups()
while current_naive < end_naive: while current_naive < end_naive:
# Check if this timestamp exists in index (O(1) lookup) # Check if this timestamp exists in index (O(1) lookup)
current_dt_key = current_naive.isoformat()[:19] current_dt_key = current_naive.isoformat()[:19]
location = self._index.get(current_dt_key) location = self._index.get(current_dt_key)
if location is not None: if location is not None:
# Get interval from fetch group
fetch_groups = self._cache.get_fetch_groups()
fetch_group = fetch_groups[location["fetch_group_index"]] fetch_group = fetch_groups[location["fetch_group_index"]]
interval = fetch_group["intervals"][location["interval_index"]] interval = fetch_group["intervals"][location["interval_index"]]
# CRITICAL: Return shallow copy to prevent external mutations # CRITICAL: Return shallow copy to prevent external mutations