Add entity descriptions, long descriptions, and usage tips for the three new
price_rank_* sensors and the updated volatility sensors with IQR attributes.
Plain-language terms are used as primary labels (e.g. "typical price band",
"price rank"); technical terms are included parenthetically for experts
(e.g. "IQR", "percentile rank", "Tukey fences") in all five languages.
Impact: Sensors show descriptive help text in the entity detail view, making it
easier for users to understand what each sensor measures without consulting
external documentation.
Add entity name translations for price_rank_today, price_rank_tomorrow, and
price_rank_today_tomorrow sensors in English, German, Norwegian, Dutch, and
Swedish.
Impact: Sensor display names appear correctly in the Home Assistant UI for all
supported languages.
Add three new price rank sensors that show where today's/tomorrow's/combined
average price falls relative to all intervals in the evaluated window:
- price_rank_today: today's average price percentile rank (0–100%)
- price_rank_tomorrow: tomorrow's average price percentile rank
- price_rank_today_tomorrow: combined today+tomorrow percentile rank
Extend all volatility sensors with IQR-based band statistics:
- price_typical_spread: interquartile range (IQR) in currency subunit
- price_typical_spread_%: IQR as percentage of daily average
- price_spike_count: number of intervals outside Tukey fences (outliers)
Add calculate_iqr_stats() utility function in utils/price.py that computes
the 25th/75th percentiles, IQR, outer fences (Q1 - 1.5×IQR / Q3 + 1.5×IQR),
and outlier count for any list of price values. Entity keys and attribute
names use plain language (`price_rank`, `price_typical_spread`) as primary
labels; technical terms (percentile rank, IQR) are included parenthetically
in descriptions and documentation.
Impact: Users can now see where current day prices rank compared to their window and how tightly clustered or spike-prone a day's prices are.
Add structured reason codes to no-result responses for find_cheapest_block,
find_cheapest_hours, and find_cheapest_schedule. Each handler now classifies
why no result was returned: no_data_in_range, no_intervals_matching_level_filter,
insufficient_intervals_after_filter, or insufficient_contiguous_window.
Add include_comparison_details flag to find_cheapest_schedule. When enabled,
each scheduled task includes a price_comparison field showing the most expensive
alternative window (mean, min, max, start, end) for cost-savings context.
Document stable reason code contracts in en.json service descriptions.
Add corresponding field translations to all locales (de, nb, nl, sv).
Impact: Automations and scripts can now react to why no window was found,
and schedules can display concrete savings vs. worst-case pricing.
Consistent naming with the period_count_* family introduced in the
previous commit (period_count_total, period_count_today,
period_count_tomorrow).
periods_remaining was the last attribute in the navigation triplet
using the old plural form. Renamed to period_count_remaining to follow
the established pattern: all countable period metrics use the
period_count_* prefix.
BREAKING CHANGE: periods_remaining renamed to period_count_remaining.
Impact: All four period count attributes now share the same prefix
(period_count_total, period_count_today, period_count_tomorrow,
period_count_remaining), making automation templates more predictable.
Removed periods_found_total and replaced with period_count_today /
period_count_tomorrow. The old attribute counted all periods including
yesterday (coordinator scope), causing a discrepancy vs. the displayed
list (sensor scope, today+tomorrow only).
Renamed periods_total → period_count_total for naming consistency with
the new per-day attributes. Recalculate period_position / period_count_total /
periods_remaining after the today+tomorrow filter so all three navigation
attributes reflect the filtered scope.
period_count_tomorrow is always present (0 when no tomorrow data or no
periods found), enabling automations without default(0) guards.
Removed internal periods_found key from relaxation metadata — it was
only consumed by add_calculation_summary_attributes which is now removed.
BREAKING CHANGE: periods_found_total removed (replace with
period_count_today + period_count_tomorrow). periods_total renamed to
period_count_total.
Impact: Period navigation attributes (position/total/remaining) now
correctly reflect today+tomorrow scope. Per-day counts allow automations
to distinguish "2 periods today, 0 tomorrow" from "1+1".
Phase 3: When geometric bonus intervals cause CV gate failure, strip them
from period edges (unextended boundaries) and set geometric_extension_attempted=True
on the summary. Previously only geometric_extension_active was tracked.
Moved LOW_PRICE_QUALITY_BYPASS_THRESHOLD constant to types.py for shared access.
Phase 4: Add time_range: tuple[datetime, datetime] | None parameter to
build_periods(), calculate_periods(), and calculate_periods_with_relaxation().
Filters candidate intervals to [start, end) without affecting day-wide reference
prices. Refactored _apply_segment_forcing() to use time_range instead of the
restricted_prices list approach.
Impact: Period statistics now accurately reflect when geometric flex extension
was attempted but reverted due to quality gate failure. Segment forcing uses
a cleaner API that preserves full price context for reference calculations.
Phase 3: When geometric bonus intervals cause CV gate failure, strip them
from period edges (unextended boundaries) and set geometric_extension_attempted=True
on the summary. Previously only geometric_extension_active was tracked.
Moved LOW_PRICE_QUALITY_BYPASS_THRESHOLD constant to types.py for shared access.
Phase 4: Add time_range: tuple[datetime, datetime] | None parameter to
build_periods(), calculate_periods(), and calculate_periods_with_relaxation().
Filters candidate intervals to [start, end) without affecting day-wide reference
prices. Refactored _apply_segment_forcing() to use time_range instead of the
restricted_prices list approach.
Impact: Period statistics now accurately reflect when geometric flex extension
was attempted but reverted due to quality gate failure. Segment forcing uses
a cleaner API that preserves full price context for reference calculations.
Uses valley/peak knee points from day pattern analysis to grant extra
flex to price intervals that fall inside detected geometric zones,
making period detection more permissive within V-shape (best price)
or Λ-shape (peak price) price formations.
New options:
- CONF_BEST_PRICE_GEOMETRIC_FLEX (0-25%, default 0 = disabled)
- CONF_PEAK_PRICE_GEOMETRIC_FLEX (0-25%, default 0 = disabled)
Implementation:
- compute_geometric_flex_bonus() in level_filtering.py checks if
interval falls inside valley/peak zone and returns extra_flex
- period_building.py applies geo bonus per-interval via
criteria._replace(flex=...) and sets geometric_bonus_applied flag
- period_statistics.py reports geometric_extension_active and
geometric_extension_intervals in period summaries
- Day patterns threaded through full pipeline:
data_transformation → coordinator/core → periods →
relaxation → calculate_periods → price_context
- UI sliders in both extension_settings sections
- Translations: en, de, nb, nl, sv
Impact: Users with clearly V-shaped or Λ-shaped daily price curves
can enable geometric flex to improve period detection accuracy within
those characteristic shapes without increasing global flex.
After period detection, optionally walk left/right from each period boundary
to absorb adjacent VERY_CHEAP (best price) or VERY_EXPENSIVE (peak price)
intervals (step 7.5 in the pipeline).
New constants: CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP, CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE, CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS.
Defaults: off / 4 intervals (1 hour per side). Hard maximum: 12 intervals (3 hours).
Config stored under "extension_settings" section, reflected in period hash
for correct cache invalidation.
New module: coordinator/period_handlers/shape_extension.py handles the
boundary walk, stat recalculation, and extension_intervals_added bookkeeping.
Impact: Users can opt-in to wider best/peak price windows that include
extreme-level adjacent intervals, reducing missed very cheap/expensive slots
at period edges.
Introduces a new day_pattern.py module that analyses the 15-min price curve
for each calendar day (yesterday/today/tomorrow) and classifies its shape.
New sensors:
day_pattern_yesterday / day_pattern_today / day_pattern_tomorrow
EntityCategory.DIAGNOSTIC, SensorDeviceClass.ENUM
Patterns: valley, peak, double_valley, double_peak, flat, rising, falling, mixed
The detector uses centred-rolling smoothing, prominence-filtered extrema,
Kneedle-based knee detection, and monotone segment building.
Coordinator populates transformed_data["dayPatterns"] after priceInfo enrichment.
Impact: Users can trigger automations based on the shape of the day's price
curve, e.g. pre-heat when tomorrow is a valley day.
New services for finding optimal electricity price windows:
- find_cheapest_block: Cheapest contiguous time block (e.g., dishwasher)
- find_cheapest_hours: Cheapest N hours, non-contiguous (e.g., EV charging)
- find_cheapest_schedule: Multi-task scheduling with no-overlap (e.g., shared circuit)
- find_most_expensive_block: Most expensive contiguous block (peak avoidance)
- find_most_expensive_hours: Most expensive N hours (consumption shifting)
Key features:
- Flexible search range (today, tomorrow, today+tomorrow, rolling window)
- Power profile support for variable consumption patterns
- Price level filtering (e.g., only CHEAP/VERY_CHEAP intervals)
- Comparison details showing savings vs. alternatives
- Sliding window algorithm (O(n)) for block search, greedy scheduling
for multi-task optimization
Also includes:
- Shared validation utilities (search range, price level, power profile)
- entry_id now optional on all services (auto-selects single home)
- Input validation for existing services (time range, filter conflicts)
- Service icons for all new and existing services
- Translations for all 5 languages (en, de, nb, nl, sv)
- Removed 10 unused config.error translation keys (replaced by exceptions)
- Tests for price window algorithms and search range resolution
Impact: Users can find optimal time windows for appliances, EV charging,
and multi-device scheduling via HA service calls. Existing services
improved with optional entry_id and better input validation.
Remove unused functions, constants, and entity definitions that were
left over from previous refactorings. All removed code was either
superseded by better implementations or never actually called.
Removed functions:
- entity_utils/helpers.py: translate_level(), translate_rating_level()
(HA handles ENUM translation automatically via translations/*.json)
- entity_utils/attributes.py: build_timestamp_attribute(),
build_period_attributes() (superseded by inline implementations)
- sensor/helpers.py: get_hourly_price_value(), aggregate_window_data()
(replaced by Calculator Pattern in sensor/calculators/)
Removed constants and definitions:
- const.py: CONF_CHART_DATA_CONFIG (DATA_CHART_CONFIG is the active one),
PRICE_LEVEL_OPTIONS, PRICE_RATING_OPTIONS, VOLATILITY_OPTIONS,
PRICE_TREND_OPTIONS (never imported; options defined inline in
definitions.py due to HA import timing constraints),
async_get_home_type_translation() (sync version used instead)
- coordinator/core.py: FRESH_TO_CACHED_SECONDS (leftover from old
caching strategy, never referenced)
- switch/definitions.py: BEST_PRICE_SWITCH_ENTITIES (duplicate of
BEST_PRICE_SWITCH_ENTITY_DESCRIPTIONS using base class instead of
custom TibberPricesSwitchEntityDescription subclass)
Cleanup:
- entity_utils/__init__.py: Remove exports for deleted functions
- sensor/helpers.py: Remove now-unused imports (timedelta,
get_intervals_for_day_offsets, get_price_value, Callable)
- entity_utils/helpers.py: Remove unused get_price_level_translation
import after translate_level() removal
- sensor/definitions.py: Update 7x "Keep in sync with *_OPTIONS"
comments to reference individual PRICE_LEVEL_*/PRICE_RATING_*/
VOLATILITY_* constants instead
Impact: No user-visible changes. Reduces codebase by ~130 lines.
Improves maintainability by eliminating misleading dead code.
Add UP037 to ruff ignore list to preserve quoted TYPE_CHECKING forward
references (PEP 649 lazy eval breaks get_type_hints() at runtime for
TYPE_CHECKING-guarded imports).
Move datetime imports into TYPE_CHECKING blocks in sensor/calculators
timing.py and trend.py (TC003, type-only usage confirmed).
Apply PEP 758 parenthesis-free except clauses across 7 files via
ruff format with target-version=py314.
Update hacs.json minimum HA version to 2026.4.0, the first HA release
requiring Python 3.14.
Impact: Linter config now correctly handles Python 3.14 semantics.
Users need HA >= 2026.4 (Python 3.14) to use this integration.
Adds migrations.py with automatic entity registry migration for renamed
sensor keys. Separated from coordinator/repairs.py (runtime issues) and
__init__.py _migrate_config_options() (config format changes).
- ENTITY_KEY_RENAMES dict maps old→new entity keys (extensible)
- _auto_migrate_entity_keys() updates unique_id, preserves entity_id
- Handles partial migration (new entity already exists → remove old)
- Creates persistent HA repair issue after migration via ir.async_create_issue()
- Called in async_setup_entry() after _migrate_config_options()
Migrates: trend_change_in_minutes → next_price_trend_change_in
Repair issue informs users about:
- Auto-migrated entity renames (entity_id preserved, no action needed)
- Duration sensor value unit change (hours → minutes): update automation
thresholds from `state < 0.25` to `state < 15` for 15-minute checks
All 5 language files (en, de, nb, nl, sv) updated with translations.
BREAKING CHANGE: Duration sensors (remaining time, starts in, period
duration, trend change countdown) now report state values in minutes
instead of hours. Display unit in dashboards remains hours by default.
Update numeric comparisons in automations accordingly.
Impact: Users upgrading from previous releases see an informational
repair notice guiding them through any required automation updates.
Entity renames are handled transparently with no loss of history.
Replaced int(time.minutes_until()) with time.minutes_until_rounded()
in trend calculator (3 locations). The int() call truncated values
(14.7 → 14) while timing sensors used standard rounding (14.7 → 15).
All duration sensors now use the same rounding method
(math.floor(seconds/60 + 0.5)), matching HA's timestamp rendering
behavior.
Impact: Trend countdown values may differ by ±1 minute compared to
previous behavior. Consistency across all duration sensors improved.
Changed native_unit_of_measurement from HOURS to MINUTES for all 7
duration sensors. HA auto-converts to hours for display via
suggested_unit_of_measurement=HOURS.
Sensors affected:
- next_price_trend_change_in
- best_price_period_duration, best_price_remaining_minutes,
best_price_next_in_minutes
- peak_price_period_duration, peak_price_remaining_minutes,
peak_price_next_in_minutes
Removed _minutes_to_hours() conversion function — calculator values
(minutes) are now passed through directly.
BREAKING CHANGE: State values for all duration sensors change from
hours to minutes (e.g. 1.5 → 90). The display unit remains hours
(suggested_unit_of_measurement). Automations using numeric state
comparisons must be updated (multiply old thresholds by 60).
Impact: Users with automations comparing duration sensor states
numerically need to update thresholds. Dashboard display is unchanged
for new installations. Existing installations retain their configured
display unit but the underlying numeric value changes.
Renamed trend_change_in_minutes → next_price_trend_change_in to align
with its sibling sensor next_price_trend_change (timestamp variant).
Follows the established best/peak price naming pattern where related
sensors share a common prefix (e.g. best_price_next_start_time /
best_price_next_in_minutes).
Updated entity key, translation key, friendly names (all 5 languages),
custom translations, coordinator constants, attribute routing, and
cache-clear mapping.
BREAKING CHANGE: Entity ID changes from
sensor.<home>_trend_change_in_minutes to
sensor.<home>_next_price_trend_change_in. Automations and dashboards
referencing the old entity ID must be updated.
Impact: Users with automations or dashboard cards referencing the old
sensor name need to update references. The sensor retains identical
functionality and attributes.
Skip expensive async_write_ha_state() when native_value hasn't changed
since last write. HA's state machine has built-in change detection, but
it only runs AFTER all properties and attributes are evaluated — the
expensive part we now avoid entirely.
Sensor platform (Timer #2 + #3):
- New _write_if_changed() method compares native_value before writing
- Timer #3 (30s, 7 entities): Skips all writes when no period active
- Timer #2 (15min, ~45 entities): Skips enum levels/ratings that stay
constant across quarter-hour intervals
- Replaces data_lifecycle_status-only pattern with unified approach
Binary sensor platform (Timer #2):
- Period sensors only write at actual period boundaries, not every 15min
Coordinator push updates always write (sentinel reset ensures freshness).
Impact: Eliminates asyncio "Executing TimerHandle took 1.4s" warnings
caused by redundant property evaluation in Timer #3 callbacks. Reduces
event loop blocking from ~1.4s to microseconds when values unchanged.
Add four optional parameters to the get_chartdata service:
- include_energy: Include raw energy/spot price (default: false)
- include_tax: Include tax component (default: false)
- energy_field: Custom field name (default: energy_price)
- tax_field: Custom field name (default: tax)
Custom field names allow direct compatibility with ApexCharts
and other charting tools without post-processing.
All code paths (all/segments/none insert_nulls modes) and the
last-interval handler include energy/tax when enabled.
Added translations for all 5 languages (en, de, nl, nb, sv).
Impact: Users can include price composition data in chart exports,
enabling visual breakdowns of energy cost vs. taxes in dashboards.
Add energy_price and tax attributes to interval and daily stat sensors:
- Interval sensors (current/next/previous): energy_price and tax from
the specific 15-minute interval
- Daily min/max sensors: energy_price and tax from the extreme interval
- Daily average sensors: energy_price_mean, energy_price_median,
tax_mean, tax_median — matching the existing mean/median pattern
used for the main price attribute
Calculator caches both mean and median for energy/tax using
calculate_median() from utils/average. All new attributes are
excluded from Recorder to prevent database bloat.
Impact: Users can see price composition (spot price vs. taxes) on
all major price sensors. Enables solar feed-in and net metering
automations based on raw energy prices.
Request `energy` and `tax` fields alongside `total` in both
quarter-hourly price queries. These represent the raw spot price and
the tax/fee component that together make up the total consumer price.
Updated hourly aggregation in formatters.py to carry energy/tax
values through to aggregated output.
Impact: Enables downstream consumers (sensors, services) to expose
price composition data. Useful for solar feed-in compensation and
net metering (saldering) calculations where the raw energy price
is needed separately from taxes.
Added brand/ directory to custom_components/tibber_prices/ with all
8 supported PNG variants, generated from existing SVGs in images/:
- icon.png / dark_icon.png (256×256)
- icon@2x.png / dark_icon@2x.png (512×512)
- logo.png / dark_logo.png (500×128)
- logo@2x.png / dark_logo@2x.png (1000×256)
Local brand images automatically take priority over CDN images and
are served via the HA brands proxy API (/api/brands/integration/).
Silently ignored on HA < 2026.3, no changes to manifest.json needed.
Updated AGENTS.md to document the brand/ directory under "ALLOWED in root".
Impact: Integration icon and logo now display correctly in HA ≥ 2026.3
without requiring a separate submission to the HA brands repository.
Renamed 8 price_trend_Xh entries to price_outlook_Xh and added 15 new
price_trajectory_Xh entries (2h–12h) in all 5 languages (de, en, nb, nl, sv).
translations/ (HA-native: name + 5 states per sensor):
- EN: "Price Outlook (Xh)" / "Price Trajectory (Xh)"
- DE: "Preisausblick (Xh)" / "Preisverlauf (Xh)"
- NB: "Prisutblikk (Xt)" / "Prisforløp (Xt)"
- NL: "Prijsvooruitzicht (Xu)" / "Prijstrajectorie (Xu)"
- SV: "Prisöversikt (Xh)" / "Prisutveckling (Xh)"
custom_translations/ (description + long_description + usage_tips):
- Outlook descriptions updated to explain window-average comparison
semantics (not price direction)
- Trajectory descriptions explain first-half vs second-half logic and
the "outlook: falling + trajectory: rising = you're AT the minimum" pattern
- Trajectory long_description and usage_tips in English for all languages;
description field in native language
Impact: Entity display names update to reflect the corrected semantic meaning.
Renamed 8 sensors to clarify what they actually measure, and added 7 new
sensors for a different (and often more useful) calculation.
--- WHY THE RENAME ---
The old name "price_trend_Xh" implied the sensor shows where prices are
heading. It doesn't — it compares CURRENT price vs the FUTURE WINDOW AVERAGE.
At a price minimum, it shows "strongly_falling" (because the cheap minimum
pulls the average below your current high price), which is the opposite of
intuitive. The name "price_outlook_Xh" correctly conveys: "is now cheaper
or more expensive than the next Nh on average?"
--- NEW: price_trajectory_Xh ---
These sensors compare FIRST HALF vs SECOND HALF of the window, revealing
actual price direction within the window:
price_trajectory_2h: avg(hour 1) vs avg(hour 2)
price_trajectory_3h: avg(first 1.5h) vs avg(second 1.5h)
price_trajectory_4h: avg(first 2h) vs avg(second 2h)
price_trajectory_5h: avg(first 2.5h) vs avg(second 2.5h)
price_trajectory_6h: avg(first 3h) vs avg(second 3h)
price_trajectory_8h: avg(first 4h) vs avg(second 4h)
price_trajectory_12h: avg(first 6h) vs avg(second 6h)
The key use case: at a price minimum, price_outlook_Xh shows "strongly_falling"
but price_trajectory_Xh shows "rising" — correctly revealing the upcoming
reversal. "outlook: falling + trajectory: rising" = you're AT the minimum.
--- IMPLEMENTATION ---
sensor/calculators/trend.py:
- get_price_outlook_value() (was: get_price_trend_value())
- New: get_price_trajectory_value(*, hours: int)
- New: _calculate_first_half_average(hours, next_interval_start)
- New: get_trajectory_attributes() → first_half_avg, second_half_avg, half_diff_%
- clear_trend_cache() also resets _trajectory_attributes
sensor/definitions.py:
- 8 SensorEntityDescription entries: key/translation_key price_trend_Xh → price_outlook_Xh
- New PRICE_TRAJECTORY_SENSORS tuple (2h–5h enabled by default, 6h/8h/12h disabled)
sensor/value_getters.py:
- 8 lambda entries renamed
- 7 new trajectory lambda entries added
sensor/attributes/trend.py:
- startswith("price_trend_") → startswith("price_outlook_")
- New elif branch routing price_trajectory_* to cached trajectory_attributes
sensor/core.py:
- startswith checks updated for both prefix families
- cached_data dict extended with "trajectory_attributes"
coordinator/constants.py:
- TIME_SENSITIVE_ENTITY_KEYS: 8 renamed + 7 new trajectory keys added
config_flow_handlers/entity_check.py:
- volatility + price_trend affected-entity lists: 8 renamed + 7 new
BREAKING CHANGE: Sensors price_trend_1h, price_trend_2h, price_trend_3h,
price_trend_4h, price_trend_5h, price_trend_6h, price_trend_8h,
price_trend_12h have been removed without a deprecation period.
Migration:
Replace price_trend_Xh → price_outlook_Xh everywhere (automations,
dashboards, templates). Behavior is identical — only the entity name
changed. If you want to detect actual price direction within the window
(e.g. "are prices rising or falling right now?"), use the new
price_trajectory_Xh sensors instead.
Impact: Users must update automations and dashboards. Entity IDs change from
sensor.<home>_price_trend_Xh to sensor.<home>_price_outlook_Xh. New
price_trajectory_Xh sensors provide complementary direction information.
Added `suggested_unit_of_measurement=UnitOfTime.HOURS` to all 7 DURATION
sensors to prevent HA from auto-selecting minutes as the display unit.
Without this, HA would pick "min" for small values (e.g., 0.75 h) and then
display large values as "1238 Min." instead of the intended "20 Std. 38 Min."
Affected sensors:
- trend_change_in_minutes
- best_price_period_duration / peak_price_period_duration
- best_price_remaining_minutes / peak_price_remaining_minutes
- best_price_next_in_minutes / peak_price_next_in_minutes
BREAKING CHANGE: Sensor state unit changes from minutes to hours for users
whose entity registry stored "min" as the display unit (the previous default).
Automations using the raw state value (e.g., `state < 60` for "less than 60
minutes") must be updated to use hours (e.g., `state < 1`).
The state attributes `remaining_minutes` and `next_in_minutes` continue to
provide integer minutes and are unaffected.
Impact: Duration sensors now display dynamically as "X h Y min" (e.g.,
"1 h 15 min") instead of a large minutes value like "1238 Min.". Users who
manually customized the unit in HA settings are not affected.
Added 3 new config fields to price trend options step:
- Trend Change Confirmation (2-6 intervals slider)
- Min Price Change for trend (display-unit-aware slider)
- Min Price Change for strong trend (display-unit-aware slider)
Price change sliders scale between base currency (EUR/NOK) storage and
display unit (ct/øre) presentation using get_display_unit_factor().
Added migration in __init__.py to convert old display-unit values to
base currency format.
Impact: Users can tune trend sensitivity: higher confirmation = fewer
false changes, higher min price change = no trends from tiny fluctuations.
New duration sensor showing time until next price trend change as hours
(e.g., 2.25 h). Registered in MINUTE_UPDATE_ENTITY_KEYS for per-minute
updates. Shares cached attributes with next_price_trend_change timestamp
sensor.
Added trend attributes to _unrecorded_attributes (threshold/volatility/diff
attributes excluded from recorder). Updated timer group size test expectation
from 6 to 7.
Impact: Users can display a live countdown to the next trend change on
dashboards and use it in automations (e.g., "if < 0.25 h, prepare").
Refactored trend calculator with direction-group-based trend change detection
(rising/strongly_rising treated as same group, falling/strongly_falling as same
group). Added minimum absolute price change thresholds (noise floor) to prevent
spurious trends at low price levels. Both percentage AND absolute conditions
must now be met.
Updated strongly threshold defaults from ±6% to ±9% (3x base for perceptual
scaling). Added missing strongly thresholds and new config keys to
get_default_options(). calculate_price_trend() now returns volatility_factor
as 4th tuple element for threshold transparency.
Added CONF_PRICE_TREND_CHANGE_CONFIRMATION (default: 3 intervals = 45min)
and CONF_PRICE_TREND_MIN_PRICE_CHANGE / _STRONGLY with validation limits.
Updated tests for new 4-tuple return value.
Impact: More stable trend detection — fewer false trend changes during low-price
periods. Direction-group logic prevents noise from "rising ↔ strongly_rising"
oscillations. Users can fine-tune noise floor for their market.
- 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.
PLW0108: Three lambdas were simple pass-throughs that added no value:
lambda data: aggregate_level_data(data) → aggregate_level_data
lambda: lifecycle_calculator.get_lifecycle_state() → lifecycle_calculator.get_lifecycle_state
Affected files:
sensor/calculators/rolling_hour.py (line 115)
sensor/helpers.py (line 139)
sensor/value_getters.py (line 220)
Impact: No behaviour change. Linter now passes with zero warnings.
Four non-MONETARY diagnostic sensors had state_class set, causing HA
Recorder to add them to long-term statistics tables unnecessarily:
- home_size (m²): SensorStateClass.MEASUREMENT
- main_fuse_size (A): SensorStateClass.MEASUREMENT
- number_of_residents: SensorStateClass.MEASUREMENT
- estimated_annual_consumption(kWh):SensorStateClass.TOTAL
All four are static user metadata retrieved from Tibber's user API
(cached for 24 h, rarely or never changes in practice). They carry no
time-series value: home_size and main_fuse_size don't change, and
estimated_annual_consumption is a rough Tibber estimate, not an actual
accumulating energy counter.
Setting state_class=None removes them from long-term statistics while
keeping normal state-change recording intact.
The three intentional non-None state_class values are unchanged:
- current_interval_price (MONETARY, TOTAL): Energy Dashboard
- current_interval_price_base (MONETARY, TOTAL): Energy Dashboard
- average_price_today (MONETARY, TOTAL): useful weekly/monthly trend
Impact: Reduced Recorder database growth; no user-visible sensor
behaviour change.
The timedelta import move (previous commit) was committed correctly, but a
subsequent automated formatting pass stripped the line that used it:
current_date = current_date + timedelta(days=1)
Without the increment the while loop never terminates, causing HA to hang
indefinitely during coordinator startup when period calculation is triggered.
Additionally, the timedelta module-level import was also reverted by the
same pass, so re-add it here.
Restore both the import and the loop increment so group_periods_by_day
correctly iterates one day at a time.
PLC0415 – suppress with noqa was already suppressing the warning, but the
import was still executed on every iteration of the while loop. Standard
library imports belong at module level both for correctness and performance.
Move 'from datetime import timedelta' to the top-level import block and
remove the now-unnecessary 'noqa: PLC0415' comment.
Impact: Negligible per-call overhead removed. More importantly, the code
no longer suppresses a linter warning that signals a real anti-pattern;
future static analysis runs will correctly flag any new inline imports.
RESOLUTION_CHANGE_ISO was a naive ISO string ("2025-10-01T00:00:00").
_split_at_resolution_boundary compared it against timezone-aware interval
strings via plain string ordering, which is unreliable when the strings
carry different UTC offsets (e.g. +02:00 vs +00:00). More critically,
the naive string was then split into ranges such as ("...", "2025-10-01T00:00:00")
which were parsed back to naive datetime objects in fetch_missing_ranges.
When routing.py then compared those naive objects against the tz-aware
boundary datetime, Python raised TypeError: can't compare offset-naive
and offset-aware datetimes.
Fix:
- Remove RESOLUTION_CHANGE_ISO; derive the boundary ISO string at
runtime from RESOLUTION_CHANGE_DATETIME.isoformat(), which produces
the UTC-normalised string "2025-10-01T00:00:00+00:00".
- Rewrite _split_at_resolution_boundary to parse each range's start/end
to datetime objects, normalise any defensively-naive values to UTC,
and compare against the RESOLUTION_CHANGE_DATETIME constant directly.
- Use the tz-aware boundary_iso string as the split point so downstream
fromisoformat() calls always return tz-aware datetime objects.
Impact: Ranges spanning 2025-10-01T00:00:00 UTC are now split correctly
regardless of the UTC offset carried by the original interval strings,
and no TypeError is raised when routing.py compares the boundary
endpoints to its own tz-aware boundary calculation.
On DST fall-back nights the clocks repeat an hour (e.g. 02:00 CET/CEST).
Tibber delivers quarter-hourly intervals for both the CEST (+02:00) and
CET (+01:00) copies of that hour. Both share the same 19-char naive local
key 'YYYY-MM-DDTHH:MM:SS', so _add_intervals treated the CET arrivals as
unwanted duplicates and sent them to _touch_intervals, which kept the CEST
data and silently discarded the CET price data. The fall-back hour's prices
were permanently lost from the pool.
Fix:
- Add module constant _DST_COLLISION_MAX_SAME_UTC_S (60 s) to distinguish
true duplicate arrivals (same UTC, ≤60 s apart) from DST collision pairs
(~3600 s apart).
- Add _handle_index_collision() helper that compares the UTC datetimes of
the existing and incoming interval. If they differ by more than the
threshold it stores the new interval in self._dst_extras keyed by the
normalised local timestamp and returns True.
- _add_intervals delegates every collision to _handle_index_collision and
only routes to touch when it returns False (true duplicate).
- _get_cached_intervals yields the saved extras after the main interval.
- After each GC run, stale entries are pruned from _dst_extras.
- to_dict / from_dict persist and restore _dst_extras across HA restarts.
Impact: The full fall-back hour (e.g. 02:00-02:45 CET) now appears in the
interval pool alongside the CEST copies, so sensors that query that hour
return correct prices instead of stale or missing data.
_get_sensor_interval_stats() computed expected_count via UTC time
arithmetic ((end - start).total_seconds() / 900 = 480 for 5 days), then
iterated through fixed-offset local timestamps adding timedelta(minutes=15).
On DST spring-forward days (e.g. last Sunday March in EU), clocks skip
from 02:00 to 03:00. The 4 local quarter-hour slots 02:00-02:45 never
exist, so the Tibber API never returns intervals for them. The iteration
still visits those 4 keys, finds them absent from the index, and reports
has_gaps=True (expected=480, actual=476). Since no API call can ever
fill those non-existent slots, the pool triggers an unnecessary gap-fill
fetch every 15 minutes for the entire spring-forward day.
Fix: keep the nominal expected_count for diagnostics, but determine
has_gaps via the new _has_real_gaps_in_range() helper that sorts
cached intervals by UTC time and checks consecutive UTC differences.
The 01:45+01:00 -> 03:00+02:00 transition is exactly 15 minutes in
UTC, so no gap is reported. Start/end boundary comparisons use naive
19-char local timestamps to stay consistent with the fixed-offset
arithmetic used by get_protected_range().
Impact: No spurious API fetches on DST spring-forward Sunday. Gap
detection for real missing data (API failures, first startup) remains
fully functional.
All three re-transform sites (_handle_options_update,
async_handle_config_override_update, _perform_midnight_data_rotation)
were building raw_data with only {'price_info': ...}, omitting
'currency' and 'home_id'.
data_transformation.py's transform_data() falls back to currency='EUR'
and home_id='' when those keys are missing. This caused all non-EUR
users (Norway/NOK, Sweden/SEK) to see wrong currency units in sensors
after every midnight turnover and after any options/override change,
until the next full API poll refilled coordinator.data with correct
values (up to 15 minutes of wrong units).
Fix: explicitly carry over coordinator.data's existing currency and
home_id into each raw_data dict. Also inline the redundant lambda
wrapper on calculate_periods_fn (PLW0108 ruff lint).
Impact: Norwegian and Swedish users no longer see EUR/ct units after
midnight or config changes. Sensor unit-of-measurement stays consistent
throughout the day regardless of re-transform triggers.
All entity descriptions had hardcoded English name= strings that were
never used at runtime: HA always prefers translations via translation_key
(entity.<platform>.<key>.name in translations/en.json), making the name=
fields dead code.
Removed 106 lines across all four platforms:
- sensor/definitions.py: 85 name= lines
- number/definitions.py: 12 name= lines
- binary_sensor/definitions.py: 6 name= lines
- switch/definitions.py: 3 name= lines
No functional change. The unique_id (entry_id + key) and translation_key
remain stable, ensuring entity IDs and friendly names are unaffected.
Impact: Cleaner definitions, no drift between name= strings and
translations. Aligns with HA standard: translations are the single
source of truth for entity names.
Previously all 26 MONETARY sensors had state_class=TOTAL, causing the
statistics and statistics_short_term tables to grow unbounded (never
auto-purged by HA).
Reduced to 3 sensors that genuinely benefit from long-term history:
- current_interval_price (main price sensor, trend over weeks/months)
- current_interval_price_base (required for Energy Dashboard)
- average_price_today (daily avg tracking over seasons)
Set state_class=None on 23 sensors where long-term history adds no
value: forecast/future sensors (next_avg_*h), daily snapshots
(lowest/highest_price_today), tomorrow sensors, rolling windows
(trailing/leading 24h), and next/previous interval sensors.
Note: state_class=None does not affect the States timeline (History
panel). Only the Statistics chart on entity detail pages is removed
for the affected sensors. Existing statistics data is retained.
Impact: ~88% reduction in statistics table writes. Prevents database
bloat reported by users with long-running installations.
Add calculation summary attributes to best_price_period and
peak_price_period binary sensors for diagnostic transparency.
New attributes (all excluded from recorder history):
- min_periods_configured: User's configured target per day (always shown)
- periods_found_total: Actual periods found across all days (always shown)
- flat_days_detected: Days where CV <= 10% reduced target to 1 (only when > 0)
- relaxation_incomplete: Some days couldn't reach the target (only when true)
These attributes explain observed behavior without requiring users to
read logs: seeing "flat_days_detected: 1" alongside
"min_periods_configured: 2, periods_found_total: 1" immediately
explains why the count is lower than configured on uniform-price days.
Implementation:
- _compute_day_effective_min() now returns (dict, int) tuple to propagate
the flat day count through to the metadata dict
- flat_days_detected added to metadata["relaxation"] in
calculate_periods_with_relaxation()
- build_final_attributes_simple() gains optional period_metadata parameter
- New add_calculation_summary_attributes() function handles the rendering
- Attributes are shown even when no period is currently active
Updated recorder-optimization.md: attribute counts + clarified that
timestamp is correctly excluded (entity native_value is recorded
separately by HA as the entity state itself).
Impact: Users can understand why they received fewer periods than
configured without enabling debug logging.
Three complementary fixes for pathological price days:
1. Adaptive min_periods for flat days (CV ≤ 10%):
On days with nearly uniform prices (e.g. solar surplus), enforcing
multiple distinct cheap periods is geometrically impossible.
_compute_day_effective_min() detects CV ≤ LOW_CV_FLAT_DAY_THRESHOLD
and reduces the effective target to 1 for that day (best price only;
peak price always runs full relaxation).
2. min_distance scaling on absolute low-price days:
When the daily average drops below 0.10 EUR (10 ct), percentage-based
min_distance becomes unreliable. The threshold is scaled linearly to
zero so the filter neither accepts the entire day nor blocks everything.
3. CV quality gate bypass for absolute low-price periods:
Periods with a mean below 0.10 EUR may show high relative CV even
though the absolute price differences are fractions of a cent.
Both _check_period_quality() and _check_merge_quality_gate() now
bypass the CV gate below this threshold.
Additionally: span-aware flex warnings now emit INFO/WARNING when
base_flex >= 25%/30% and at least one "normal" (non-V-shape) day
exists (FLEX_WARNING_VSHAPE_RATIO = 0.5). Previously the constants
were defined but never used.
Updated 3 test assertions in test_best_price_e2e.py: the flat-day
fixture (CV ~5.4%) correctly produces 1 period, not 2.
Impact: Best Price periods now appear reliably on V-shape solar days
and flat-price days. No more "0 periods" on days where the single
cheapest window is a valid and useful result.