Skip to main content
Version: Next 🚧

Caching Strategy

This document explains all caching mechanisms in the Tibber Prices integration, their purpose, invalidation logic, and lifetime.

For timer coordination and scheduling details, see Timer Architecture.

Overview​

The integration uses 4 distinct caching layers with different purposes and lifetimes:

  1. Persistent API Data Cache (HA Storage) - Hours to days
  2. Translation Cache (Memory) - Forever (until HA restart)
  3. Config Dictionary Cache (Memory) - Until config changes
  4. Period Calculation Cache (Memory) - Until price data or config changes

1. Persistent API Data Cache​

Location: coordinator/cache.py → HA Storage (.storage/tibber_prices.<entry_id>)

Purpose: Reduce API calls to Tibber by caching user data and price data between HA restarts.

What is cached:

  • Price data (price_data): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total)
  • User data (user_data): Homes, subscriptions, features from Tibber GraphQL viewer query
  • Timestamps: Last update times for validation

Lifetime:

  • Price data: Until midnight turnover (cleared daily at 00:00 local time)
  • User data: 24 hours (refreshed daily)
  • Survives: HA restarts via persistent Storage

Invalidation triggers:

  1. Midnight turnover (Timer #2 in coordinator):

    # coordinator/day_transitions.py
    def _handle_midnight_turnover() -> None:
    self._cached_price_data = None # Force fresh fetch for new day
    self._last_price_update = None
    await self.store_cache()
  2. Cache validation on load:

    # coordinator/cache.py
    def is_cache_valid(cache_data: CacheData) -> bool:
    # Checks if price data is from a previous day
    if today_date < local_now.date(): # Yesterday's data
    return False
  3. Tomorrow data check (after 13:00):

    # coordinator/data_fetching.py
    if tomorrow_missing or tomorrow_invalid:
    return "tomorrow_check" # Update needed

Why this cache matters: Reduces API load on Tibber (~192 intervals per fetch), speeds up HA restarts, enables offline operation until cache expires.


2. Translation Cache​

Location: const.py → _TRANSLATIONS_CACHE and _STANDARD_TRANSLATIONS_CACHE (in-memory dicts)

Purpose: Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.

What is cached:

  • Standard translations (/translations/*.json): Config flow, selector options, entity names
  • Custom translations (/custom_translations/*.json): Entity descriptions, usage tips, long descriptions

Lifetime:

  • Forever (until HA restart)
  • No invalidation during runtime

When populated:

  • At integration setup: async_load_translations(hass, "en") in __init__.py
  • Lazy loading: If translation missing, attempts file load once

Access pattern:

# Non-blocking synchronous access from cached data
description = get_translation("binary_sensor.best_price_period.description", "en")

Why this cache matters: Entity attributes are accessed on every state update (~15 times per hour per entity). File I/O would block the event loop. Cache enables synchronous, non-blocking attribute generation.


3. Config Dictionary Cache​

Location: coordinator/data_transformation.py and coordinator/periods.py (per-instance fields)

Purpose: Avoid ~30-40 options.get() calls on every coordinator update (every 15 minutes).

What is cached:

DataTransformer Config Cache​

{
"thresholds": {"low": 15, "high": 35},
"volatility_thresholds": {"moderate": 15.0, "high": 25.0, "very_high": 40.0},
# ... 20+ more config fields
}

PeriodCalculator Config Cache​

{
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
"peak": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}
}

Lifetime:

  • Until invalidate_config_cache() is called
  • Built once on first use per coordinator update cycle

Invalidation trigger:

  • Options change (user reconfigures integration):
    # coordinator/core.py
    async def _handle_options_update(...) -> None:
    self._data_transformer.invalidate_config_cache()
    self._period_calculator.invalidate_config_cache()
    await self.async_request_refresh()

Performance impact:

  • Before: ~30 dict lookups + type conversions per update = ~50Ξs
  • After: 1 cache check = ~1Ξs
  • Savings: ~98% (50Ξs → 1Ξs per update)

Why this cache matters: Config is read multiple times per update (transformation + period calculation + validation). Caching eliminates redundant lookups without changing behavior.


4. Period Calculation Cache​

Location: coordinator/periods.py → PeriodCalculator._cached_periods

Purpose: Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.

What is cached:

{
"best_price": {
"periods": [...], # Calculated period objects
"intervals": [...], # All intervals in periods
"metadata": {...} # Config snapshot
},
"best_price_relaxation": {"relaxation_active": bool, ...},
"peak_price": {...},
"peak_price_relaxation": {...}
}

Cache key: Hash of relevant inputs

hash_data = (
today_signature, # (startsAt, rating_level) for each interval
tuple(best_config.items()), # Best price config
tuple(peak_config.items()), # Peak price config
best_level_filter, # Level filter overrides
peak_level_filter
)

Lifetime:

  • Until price data changes (today's intervals modified)
  • Until config changes (flex, thresholds, filters)
  • Recalculated at midnight (new today data)

Invalidation triggers:

  1. Config change (explicit):

    def invalidate_config_cache() -> None:
    self._cached_periods = None
    self._last_periods_hash = None
  2. Price data change (automatic via hash mismatch):

    current_hash = self._compute_periods_hash(price_info)
    if self._last_periods_hash != current_hash:
    # Cache miss - recalculate

Cache hit rate:

  • High: During normal operation (coordinator updates every 15min, price data unchanged)
  • Low: After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)

Performance impact:

  • Period calculation: ~100-500ms (depends on interval count, relaxation attempts)
  • Cache hit: <1ms (hash comparison + dict lookup)
  • Savings: ~70% of calculation time (most updates hit cache)

Why this cache matters: Period calculation is CPU-intensive (filtering, gap tolerance, relaxation). Caching avoids recalculating unchanged periods 3-4 times per hour.


5. Transformation Cache (Price Enrichment Only)​

Location: coordinator/data_transformation.py → _cached_transformed_data

Status: ✅ Clean separation - enrichment only, no redundancy

What is cached:

{
"timestamp": ...,
"homes": {...},
"priceInfo": {...}, # Enriched price data (trailing_avg_24h, difference, rating_level)
# NO periods - periods are exclusively managed by PeriodCalculator
}

Purpose: Avoid re-enriching price data when config unchanged between midnight checks.

Current behavior:

  • Caches only enriched price data (price + statistics)
  • Does NOT cache periods (handled by Period Calculation Cache)
  • Invalidated when:
    • Config changes (thresholds affect enrichment)
    • Midnight turnover detected
    • New update cycle begins

Architecture:

  • DataTransformer: Handles price enrichment only
  • PeriodCalculator: Handles period calculation only (with hash-based cache)
  • Coordinator: Assembles final data on-demand from both caches

Memory savings: Eliminating redundant period storage saves ~10KB per coordinator (14% reduction).


Cache Invalidation Flow​

User Changes Options (Config Flow)​

User saves options
↓
config_entry.add_update_listener() triggers
↓
coordinator._handle_options_update()
↓
├─> DataTransformer.invalidate_config_cache()
│ └─> _config_cache = None
│ _config_cache_valid = False
│ _cached_transformed_data = None
│
└─> PeriodCalculator.invalidate_config_cache()
└─> _config_cache = None
_config_cache_valid = False
_cached_periods = None
_last_periods_hash = None
↓
coordinator.async_request_refresh()
↓
Fresh data fetch with new config

Midnight Turnover (Day Transition)​

Timer #2 fires at 00:00
↓
coordinator._handle_midnight_turnover()
↓
├─> Clear persistent cache
│ └─> _cached_price_data = None
│ _last_price_update = None
│
└─> Clear transformation cache
└─> _cached_transformed_data = None
_last_transformation_config = None
↓
Period cache auto-invalidates (hash mismatch on new "today")
↓
Fresh API fetch for new day

Tomorrow Data Arrives (~13:00)​

Coordinator update cycle
↓
should_update_price_data() checks tomorrow
↓
Tomorrow data missing/invalid
↓
API fetch with new tomorrow data
↓
Price data hash changes (new intervals)
↓
Period cache auto-invalidates (hash mismatch)
↓
Periods recalculated with tomorrow included

Cache Coordination​

All caches work together:

Persistent Storage (HA restart)
↓
API Data Cache (price_data, user_data)
↓
├─> Enrichment (add rating_level, difference, etc.)
│ ↓
│ Transformation Cache (_cached_transformed_data)
│
└─> Period Calculation
↓
Period Cache (_cached_periods)
↓
Config Cache (avoid re-reading options)
↓
Translation Cache (entity descriptions)

No cache invalidation cascades:

  • Config cache invalidation is explicit (on options update)
  • Period cache invalidation is automatic (via hash mismatch)
  • Transformation cache invalidation is automatic (on midnight/config change)
  • Translation cache is never invalidated (read-only after load)

Thread safety:

  • All caches are accessed from MainThread only (Home Assistant event loop)
  • No locking needed (single-threaded execution model)

Performance Characteristics​

Typical Operation (No Changes)​

Coordinator Update (every 15 min)
├─> API fetch: SKIP (cache valid)
├─> Config dict build: ~1ξs (cached)
├─> Period calculation: ~1ms (cached, hash match)
├─> Transformation: ~10ms (enrichment only, periods cached)
└─> Entity updates: ~5ms (translation cache hit)

Total: ~16ms (down from ~600ms without caching)

After Midnight Turnover​

Coordinator Update (00:00)
├─> API fetch: ~500ms (cache cleared, fetch new day)
├─> Config dict build: ~50ξs (rebuild, no cache)
├─> Period calculation: ~200ms (cache miss, recalculate)
├─> Transformation: ~50ms (re-enrich, rebuild)
└─> Entity updates: ~5ms (translation cache still valid)

Total: ~755ms (expected once per day)

After Config Change​

Options Update
├─> Cache invalidation: `<`1ms
├─> Coordinator refresh: ~600ms
│ ├─> API fetch: SKIP (data unchanged)
│ ├─> Config rebuild: ~50ξs
│ ├─> Period recalculation: ~200ms (new thresholds)
│ ├─> Re-enrichment: ~50ms
│ └─> Entity updates: ~5ms
└─> Total: ~600ms (expected on manual reconfiguration)

Summary Table​

Cache TypeLifetimeSizeInvalidationPurpose
API DataHours to 1 day~50KBMidnight, validationReduce API calls
TranslationsForever (until HA restart)~5KBNeverAvoid file I/O
Config DictsUntil options change<1KBExplicit (options update)Avoid dict lookups
Period CalculationUntil data/config change~10KBAuto (hash mismatch)Avoid CPU-intensive calculation
TransformationUntil midnight/config change~50KBAuto (midnight/config)Avoid re-enrichment

Total memory overhead: ~116KB per coordinator instance (main + subentries)

Benefits:

  • 97% reduction in API calls (from every 15min to once per day)
  • 70% reduction in period calculation time (cache hits during normal operation)
  • 98% reduction in config access time (30+ lookups → 1 cache check)
  • Zero file I/O during runtime (translations cached at startup)

Trade-offs:

  • Memory usage: ~116KB per home (negligible for modern systems)
  • Code complexity: 5 cache invalidation points (well-tested, documented)
  • Debugging: Must understand cache lifetime when investigating stale data issues

Debugging Cache Issues​

Symptom: Stale data after config change​

Check:

  1. Is _handle_options_update() called? (should see "Options updated" log)
  2. Are invalidate_config_cache() methods executed?
  3. Does async_request_refresh() trigger?

Fix: Ensure config_entry.add_update_listener() is registered in coordinator init.

Symptom: Period calculation not updating​

Check:

  1. Verify hash changes when data changes: _compute_periods_hash()
  2. Check _last_periods_hash vs current_hash
  3. Look for "Using cached period calculation" vs "Calculating periods" logs

Fix: Hash function may not include all relevant data. Review _compute_periods_hash() inputs.

Symptom: Yesterday's prices shown as today​

Check:

  1. is_cache_valid() logic in coordinator/cache.py
  2. Midnight turnover execution (Timer #2)
  3. Cache clear confirmation in logs

Fix: Timer may not be firing. Check _schedule_midnight_turnover() registration.

Symptom: Missing translations​

Check:

  1. async_load_translations() called at startup?
  2. Translation files exist in /translations/ and /custom_translations/?
  3. Cache population: _TRANSLATIONS_CACHE keys

Fix: Re-install integration or restart HA to reload translation files.