From 76dc488bb5ffe61da78a0466d0104c969d32323e Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Sun, 16 Nov 2025 12:49:43 +0000 Subject: [PATCH] feat(sensors): add momentum-based trend detection with two new sensors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added intelligent price trend analysis combining historical momentum (weighted 1h lookback) with future outlook for more accurate trend recognition. Introduced two complementary sensors for comprehensive trend monitoring. New sensors: - current_price_trend: Shows active trend direction with duration - next_price_trend_change: Predicts when trend will reverse Momentum analysis (historical perspective): - Weighted 1h lookback (4 × 15-min intervals) - Linear weight progression [0.5, 0.75, 1.0, 1.25] - ±3% threshold for momentum classification - Recognizes ongoing trends earlier than future-only analysis Two-phase trend calculation: - Phase 1: Calculate momentum from weighted trailing average - Phase 2: Validate with volatility-adaptive future comparison - Combines both for final trend determination (rising/falling/stable) - Centralized in _calculate_trend_info() with 60s cache Volatility-adaptive thresholds: - Existing trend sensors (1h-12h) now use adaptive thresholds - calculate_price_trend() adjusted by market volatility: * LOW volatility (<15% CV): factor 0.6 → more sensitive (e.g., 3%→1.8%) * MODERATE volatility (15-30%): factor 1.0 → baseline (3%) * HIGH volatility (≥30%): factor 1.4 → less sensitive (e.g., 3%→4.2%) - Uses same coefficient of variation as volatility sensors - Ensures mathematical consistency across integration Default threshold reduction: - Rising/falling thresholds: 5% → 3% (more responsive) - Momentum-based detection enables lower thresholds without noise - Adaptive adjustment compensates during high volatility Architectural improvements: - Centralized calculation: Single source of truth for both sensors - Eliminates Henne-Ei problem (duplicate calculations) - 60-second cache per coordinator update - Shared helper methods: _calculate_momentum(), _combine_momentum_with_future() Translation updates (all 5 languages): - Documented momentum feature in custom_translations (de/en/nb/nl/sv) - Explained "recognizes ongoing trends earlier" advantage - Added sensor names and state options to standard translations - Updated volatility threshold descriptions (clarify usage by trend sensors) Files changed: - custom_components/tibber_prices/sensor/core.py (930 lines added) * New: _calculate_momentum(), _combine_momentum_with_future() * New: _calculate_trend_info() (centralized with cache) * New: _get_current_trend_value(), _get_next_trend_change_value() * Modified: _get_price_trend_value() (volatility-adaptive thresholds) - custom_components/tibber_prices/sensor/definitions.py * Added: current_price_trend (ENUM sensor) * Added: next_price_trend_change (TIMESTAMP sensor) - custom_components/tibber_prices/sensor/attributes.py * New: _add_cached_trend_attributes() helper * Support for current_trend_attributes, trend_change_attributes - custom_components/tibber_prices/price_utils.py (178 lines added) * New: _calculate_lookahead_volatility_factor() * Modified: calculate_price_trend() with volatility adjustment * Added: VOLATILITY_FACTOR_* constants (0.6/1.0/1.4) - custom_components/tibber_prices/entity_utils/icons.py * Added: Dynamic icon handling for next_price_trend_change - custom_components/tibber_prices/const.py * Changed: DEFAULT_PRICE_TREND_THRESHOLD_RISING/FALLING (5→3%) - custom_components/tibber_prices/translations/*.json (5 files) * Added: Sensor names, state options, descriptions - custom_components/tibber_prices/custom_translations/*.json (5 files) * Added: Long descriptions with momentum feature explanation Impact: Users get significantly more accurate trend detection that understands they're ALREADY in a trend, not just predicting future changes. Momentum-based approach recognizes ongoing movements 15-60 minutes earlier. Adaptive thresholds prevent false signals during volatile periods. Two complementary sensors enable both status display (current trend) and event-based automation (when will it change). Perfect for use cases like "charge EV when next trend change shows falling prices" or dashboard badges showing "Rising for 2.5h". --- custom_components/tibber_prices/const.py | 4 +- .../tibber_prices/custom_translations/de.json | 10 + .../tibber_prices/custom_translations/en.json | 10 + .../tibber_prices/custom_translations/nb.json | 16 +- .../tibber_prices/custom_translations/nl.json | 18 +- .../tibber_prices/custom_translations/sv.json | 10 + .../tibber_prices/entity_utils/icons.py | 5 + .../tibber_prices/price_utils.py | 170 ++++++- .../tibber_prices/sensor/attributes.py | 15 +- .../tibber_prices/sensor/core.py | 462 +++++++++++++++++- .../tibber_prices/sensor/definitions.py | 22 + .../tibber_prices/translations/de.json | 13 +- .../tibber_prices/translations/en.json | 13 +- .../tibber_prices/translations/nb.json | 15 +- .../tibber_prices/translations/nl.json | 15 +- .../tibber_prices/translations/sv.json | 15 +- 16 files changed, 782 insertions(+), 31 deletions(-) diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index 28dc963..7236f06 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -67,8 +67,8 @@ DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH = 60 # 60 minutes minimum period length fo DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH = 30 # 30 minutes minimum period length for peak price (user-facing, minutes) DEFAULT_PRICE_RATING_THRESHOLD_LOW = -10 # Default rating threshold low percentage DEFAULT_PRICE_RATING_THRESHOLD_HIGH = 10 # Default rating threshold high percentage -DEFAULT_PRICE_TREND_THRESHOLD_RISING = 5 # Default trend threshold for rising prices (%) -DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -5 # Default trend threshold for falling prices (%, negative value) +DEFAULT_PRICE_TREND_THRESHOLD_RISING = 3 # Default trend threshold for rising prices (%) +DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -3 # Default trend threshold for falling prices (%, negative value) # Default volatility thresholds (relative values using coefficient of variation) # Coefficient of variation = (standard_deviation / mean) * 100% # These thresholds are unitless and work across different price levels diff --git a/custom_components/tibber_prices/custom_translations/de.json b/custom_components/tibber_prices/custom_translations/de.json index c5d73aa..c397545 100644 --- a/custom_components/tibber_prices/custom_translations/de.json +++ b/custom_components/tibber_prices/custom_translations/de.json @@ -250,6 +250,16 @@ "long_description": "Vergleicht aktuellen Intervallpreis mit Durchschnitt der nächsten 12 Stunden (48 Intervalle). Steigend wenn Zukunft >5% höher, fallend wenn >5% niedriger, sonst stabil.", "usage_tips": "Relative Optimierung: Langfristige strategische Entscheidungen. 'fallend' = deutlich bessere Preise kommen heute Nacht/morgen. Findet optimales Timing in jeder Marktsituation. Am besten kombiniert mit avg-Sensor Preisobergrenze." }, + "current_price_trend": { + "description": "Aktuelle Preistrend-Richtung und wie lange sie anhält", + "long_description": "Zeigt den aktuellen Preistrend (steigend/fallend/stabil) durch Kombination von historischem Momentum (gewichteter 1h-Rückblick) mit Zukunftsausblick. Erkennt laufende Trends früher als reine Zukunftsanalyse. Nutzt ±3% Momentum-Schwelle und volatilitätsabhängigen Zukunftsvergleich. Berechnet dynamisch bis zur nächsten Trendänderung (oder 3h Standard, falls keine Änderung in 24h). Der Status zeigt die aktuelle Richtung, Attribute zeigen, wann sich der Trend ändert und was als Nächstes kommt.", + "usage_tips": "Status-Anzeige: Dashboard-Sichtbarkeit von 'was passiert jetzt bis wann'. Perfekt synchronisiert mit next_price_trend_change. Beispiel: Badge mit 'Steigend für 2,5h' oder 'Fallend bis 16:45'. Besser als Zeitfenster-Sensoren, weil es versteht, dass du dich BEREITS in einem Trend befindest, nicht nur zukünftige Änderungen vorhersagt. Nutze für schnelle visuelle Übersicht, nicht für Automations-Trigger." + }, + "next_price_trend_change": { + "description": "Wann die nächste bedeutende Preistrend-Änderung eintreten wird", + "long_description": "Scannt die nächsten 24 Stunden (96 Intervalle), um zu finden, wann sich der Preistrend (steigend/fallend/stabil) vom aktuellen Momentum ändern wird. Bestimmt zuerst den aktuellen Trend mit gewichtetem 1h-Rückblick (erkennt laufende Trends), dann findet es die Umkehr. Verwendet volatilitätsadaptive Schwellwerte (3% Momentum-Erkennung, marktangepasster Zukunftsvergleich). Gibt den Zeitstempel zurück, wann die Änderung erwartet wird.", + "usage_tips": "Ereignisbasierte Automation: Aktionen WENN Trend wechselt auslösen, nicht IN X Stunden. Beispiel: 'E-Auto laden wenn nächste Trendänderung fallende Preise zeigt' oder 'Spülmaschine vor Preisanstieg starten'. Ergänzt Zeitfenster-Sensoren (price_trend_Xh), die beantworten 'WERDEN Preise in X Stunden höher sein?'" + }, "daily_rating": { "description": "Wie sich die heutigen Preise mit historischen Daten vergleichen", "long_description": "Zeigt, wie sich die heutigen Preise im Vergleich zu historischen Preisdaten als Prozentsatz verhält", diff --git a/custom_components/tibber_prices/custom_translations/en.json b/custom_components/tibber_prices/custom_translations/en.json index 6a7a163..b39b651 100644 --- a/custom_components/tibber_prices/custom_translations/en.json +++ b/custom_components/tibber_prices/custom_translations/en.json @@ -250,6 +250,16 @@ "long_description": "Compares current interval price with average of next 12 hours (48 intervals). Rising if future is >5% higher, falling if >5% lower, stable otherwise.", "usage_tips": "Relative optimization: Long-term strategic decisions. 'falling' = significantly better prices coming tonight/tomorrow. Finds optimal timing in any market condition. Best combined with avg sensor price cap." }, + "current_price_trend": { + "description": "Current price trend direction and how long it will last", + "long_description": "Shows the current price trend (rising/falling/stable) by combining historical momentum (weighted 1h lookback) with future outlook. Recognizes ongoing trends earlier than future-only analysis. Uses ±3% momentum threshold and volatility-adaptive future comparison. Calculates dynamically until the next trend change occurs (or 3h default if no change in 24h). The state shows the current direction, attributes show when it changes and what comes next.", + "usage_tips": "Status display: Dashboard visibility of 'what's happening now until when'. Perfectly synchronized with next_price_trend_change. Example: Badge showing 'Rising for 2.5h' or 'Falling until 16:45'. Better than time-window sensors because it understands you're ALREADY in a trend, not just predicting future changes. Use for quick visual overview, not automation triggers." + }, + "next_price_trend_change": { + "description": "When the next significant price trend change will occur", + "long_description": "Scans the next 24 hours (96 intervals) to find when the price trend (rising/falling/stable) will change from the current momentum. First determines current trend using weighted 1h lookback (recognizes ongoing trends), then finds when that trend reverses. Uses volatility-adaptive thresholds (3% momentum detection, market-adjusted future comparison). Returns the timestamp when the change is expected.", + "usage_tips": "Event-based automation: Trigger actions WHEN trend changes, not IN X hours. Example: 'Charge EV when next trend change shows falling prices' or 'Run dishwasher before prices start rising'. More accurate than simple future comparison because it knows if you're already in a trend. Complements time-window sensors (price_trend_Xh) which answer 'WILL prices be higher in X hours?'" + }, "daily_rating": { "description": "How today's prices compare to historical data", "long_description": "Shows how today's prices compare to historical price data as a percentage", diff --git a/custom_components/tibber_prices/custom_translations/nb.json b/custom_components/tibber_prices/custom_translations/nb.json index f2fe65b..d153a1f 100644 --- a/custom_components/tibber_prices/custom_translations/nb.json +++ b/custom_components/tibber_prices/custom_translations/nb.json @@ -246,9 +246,19 @@ "usage_tips": "Relativ optimalisering: Nattplanlegging. 'fallende' betyr at å vente til natten lønner seg (>5% billigere). Fungerer hele året uten manuelle terskeljusteringer. Start når 'stabil' eller 'stigende'." }, "price_trend_12h": { - "description": "Pristrend for neste 12 timer", - "long_description": "Sammenligner nåværende intervallpris med gjennomsnitt av neste 12 timer (48 intervaller). Stigende hvis fremtiden er >5% høyere, fallende hvis >5% lavere, ellers stabil.", - "usage_tips": "Relativ optimalisering: Langsiktige strategiske beslutninger. 'fallende' = betydelig bedre priser kommer i natt/i morgen. Finner optimal timing i enhver markedstilstand. Best kombinert med avg-sensor pristak." + "description": "Pristrend for de neste 12 timene", + "long_description": "Sammenligner nåværende intervallpris med gjennomsnittet av de neste 12 timene (48 intervaller). Økende hvis framtidig pris er >5% høyere, synkende hvis >5% lavere, ellers stabil.", + "usage_tips": "Relativ optimalisering: Langsiktige strategiske beslutninger. 'synkende' = betydelig bedre priser kommer i natt/i morgen. Finner optimal timing i enhver markedssituasjon. Best kombinert med prisgrense fra avg-sensor." + }, + "current_price_trend": { + "description": "Nåværende pristrend-retning og hvor lenge den varer", + "long_description": "Viser nåværende pristrend (økende/synkende/stabil) ved å kombinere historisk momentum (vektet 1t tilbakeblikk) med fremtidsutsikt. Gjenkjenner pågående trender tidligere enn bare fremtidsanalyse. Bruker ±3 % momentum-terskel og volatilitetsavhengig fremtidssammenligning. Beregner dynamisk til neste trendendring (eller 3t standard hvis ingen endring på 24t). Status viser nåværende retning, attributter viser når den endres og hva som kommer etterpå.", + "usage_tips": "Statusvisning: Dashboard-synlighet av 'hva skjer nå til når'. Perfekt synkronisert med next_price_trend_change. Eksempel: Badge som viser 'Økende i 2,5t' eller 'Synkende til 16:45'. Bedre enn tidsvindu-sensorer fordi den forstår at du ALLEREDE er i en trend, ikke bare forutsier fremtidige endringer. Bruk for rask visuell oversikt, ikke automatiseringsutløsere." + }, + "next_price_trend_change": { + "description": "Når neste betydelige pristrendendring vil skje", + "long_description": "Skanner de neste 24 timene (96 intervaller) for å finne når pristrenden (økende/synkende/stabil) vil endre seg fra nåværende momentum. Bestemmer først nåværende trend med vektet 1t tilbakeblikk (gjenkjenner pågående trender), deretter finner den reverseringen. Bruker volatilitetsadaptive terskelverdier (3 % momentum-deteksjon, markedsjustert fremtidssammenligning). Returnerer tidsstempelet når endringen forventes.", + "usage_tips": "Hendelsesbasert automatisering: Utløs handlinger NÅR trenden endres, ikke OM X timer. Eksempel: 'Lad EV når neste trendendring viser synkende priser' eller 'Start oppvaskmaskin før prisene stiger'. Kompletterer tidsvindu-sensorer (price_trend_Xh) som svarer på 'VIL prisene være høyere om X timer?'" }, "daily_rating": { "description": "Hvordan dagens priser sammenlignes med historiske data", diff --git a/custom_components/tibber_prices/custom_translations/nl.json b/custom_components/tibber_prices/custom_translations/nl.json index 11d02e9..9c38463 100644 --- a/custom_components/tibber_prices/custom_translations/nl.json +++ b/custom_components/tibber_prices/custom_translations/nl.json @@ -246,13 +246,23 @@ "usage_tips": "Relatieve optimalisatie: Nachtplanning. 'dalend' betekent wachten tot de nacht loont (>5% goedkoper). Werkt het hele jaar door zonder handmatige drempelaanpassingen. Start wanneer 'stabiel' of 'stijgend'." }, "price_trend_12h": { - "description": "Prijstrend voor de volgende 12 uur", - "long_description": "Vergelijkt huidige intervalprijs met gemiddelde van volgende 12 uur (48 intervallen). Stijgend als toekomst >5% hoger is, dalend als >5% lager, anders stabiel.", - "usage_tips": "Relatieve optimalisatie: Lange termijn strategische beslissingen. 'dalend' = aanzienlijk betere prijzen komen vannacht/morgen. Vindt optimale timing in elke marktconditie. Best gecombineerd met avg-sensor prijslimiet." + "description": "Prijstrend voor de komende 12 uur", + "long_description": "Vergelijkt huidige intervalprijs met gemiddelde van de komende 12 uur (48 intervallen). Stijgend als toekomst >5% hoger is, dalend als >5% lager, anders stabiel.", + "usage_tips": "Relatieve optimalisatie: Lange termijn strategische beslissingen. 'dalend' = aanzienlijk betere prijzen komen vanavond/morgen. Vindt optimale timing in elke marktsituatie. Het beste gecombineerd met prijslimiet van avg-sensor." + }, + "current_price_trend": { + "description": "Huidige prijstrend-richting en hoe lang deze aanhoudt", + "long_description": "Toont de huidige prijstrend (stijgend/dalend/stabiel) door historisch momentum (gewogen 1u terugblik) te combineren met toekomstperspectief. Herkent lopende trends eerder dan alleen toekomstanalyse. Gebruikt ±3% momentum-drempel en volatiliteit-afhankelijke toekomstvergelijking. Berekent dynamisch tot de volgende trendwijziging (of 3u standaard als geen wijziging in 24u). De status toont de huidige richting, attributen tonen wanneer het verandert en wat er daarna komt.", + "usage_tips": "Statusweergave: Dashboard-zichtbaarheid van 'wat gebeurt er nu tot wanneer'. Perfect gesynchroniseerd met next_price_trend_change. Voorbeeld: Badge met 'Stijgend voor 2,5u' of 'Dalend tot 16:45'. Beter dan tijdvenster-sensoren omdat het begrijpt dat je REEDS in een trend zit, niet alleen toekomstige veranderingen voorspelt. Gebruik voor snelle visuele overview, niet voor automatiserings-triggers." + }, + "next_price_trend_change": { + "description": "Wanneer de volgende significante prijstrendwijziging zal plaatsvinden", + "long_description": "Scant de komende 24 uur (96 intervallen) om te vinden wanneer de prijstrend (stijgend/dalend/stabiel) zal veranderen ten opzichte van het huidige momentum. Bepaalt eerst de huidige trend met gewogen 1u terugblik (herkent lopende trends), vindt dan de omkering. Gebruikt volatiliteit-adaptieve drempelwaarden (3% momentum-detectie, marktaangepaste toekomstvergelijking). Retourneert het tijdstempel wanneer de wijziging wordt verwacht.", + "usage_tips": "Gebeurtenisgestuurde automatisering: Trigger acties WANNEER trend wijzigt, niet OVER X uur. Voorbeeld: 'Laad EV wanneer volgende trendwijziging dalende prijzen toont' of 'Start vaatwasser voordat prijzen stijgen'. Vult tijdvenster-sensors aan (price_trend_Xh) die beantwoorden 'ZULLEN prijzen over X uur hoger zijn?'" }, "daily_rating": { "description": "Hoe de prijzen van vandaag zich verhouden tot historische gegevens", - "long_description": "Toont hoe de prijzen van vandaag zich verhouden tot historische prijsgegevens als een percentage", + "long_description": "Toont hoe de prijzen van vandaag zich verhouden tot historische prijsgegevens als percentage", "usage_tips": "Een positief percentage betekent dat de prijzen van vandaag boven het gemiddelde liggen, negatief betekent onder het gemiddelde" }, "monthly_rating": { diff --git a/custom_components/tibber_prices/custom_translations/sv.json b/custom_components/tibber_prices/custom_translations/sv.json index e814424..e543caf 100644 --- a/custom_components/tibber_prices/custom_translations/sv.json +++ b/custom_components/tibber_prices/custom_translations/sv.json @@ -250,6 +250,16 @@ "long_description": "Jämför nuvarande intervallpris med genomsnitt av nästa 12 timmar (48 intervaller). Stigande om framtid är >5% högre, fallande om >5% lägre, annars stabil.", "usage_tips": "Relativ optimering: Långsiktiga strategiska beslut. 'fallande' = avsevärt bättre priser kommer ikväll/imorgon. Hittar optimal timing i vilket marknadsläge som helst. Bäst kombinerad med avg-sensor prisgräns." }, + "current_price_trend": { + "description": "Nuvarande pristrend-riktning och hur länge den varar", + "long_description": "Visar nuvarande pristrend (stigande/fallande/stabil) genom att kombinera historiskt momentum (viktad 1h tillbakablick) med framtidsutsikt. Känner igen pågående trender tidigare än endast framtidsanalys. Använder ±3 % momentum-tröskel och volatilitetsanpassad framtidsjämförelse. Beräknar dynamiskt till nästa trendändring (eller 3t standard om ingen ändring på 24t). Status visar nuvarande riktning, attribut visar när den ändras och vad som kommer härnäst.", + "usage_tips": "Statusvisning: Dashboard-synlighet av 'vad händer nu till när'. Perfekt synkroniserad med next_price_trend_change. Exempel: Badge som visar 'Stigande i 2,5t' eller 'Fallande till 16:45'. Bättre än tidsfönster-sensorer eftersom den förstår att du REDAN är i en trend, inte bara förutsäger framtida ändringar. Använd för snabb visuell överblick, inte automationsutlösare." + }, + "next_price_trend_change": { + "description": "När nästa betydande pristrendändring kommer att inträffa", + "long_description": "Skannar de nästa 24 timmarna (96 intervaller) för att hitta när pristrenden (stigande/fallande/stabil) kommer att ändras från nuvarande momentum. Bestämmer först nuvarande trend med viktad 1h tillbakablick (känner igen pågående trender), hittar sedan reverseringen. Använder volatilitetsadaptiva tröskelvärden (3 % momentum-detektering, marknadsanpassad framtidsjämförelse). Returnerar tidsstämpeln när ändringen förväntas.", + "usage_tips": "Händelsestyrd automatisering: Utlös åtgärder NÄR trenden ändras, inte OM X timmar. Exempel: 'Ladda EV när nästa trendändring visar fallande priser' eller 'Starta diskmaskin innan priserna stiger'. Kompletterar tidsfönster-sensorer (price_trend_Xh) som svarar på 'KOMMER priserna att vara högre om X timmar?'" + }, "daily_rating": { "description": "Hur dagens priser jämförs med historiska data", "long_description": "Visar hur dagens priser jämförs med historiska prisdata som en procentsats", diff --git a/custom_components/tibber_prices/entity_utils/icons.py b/custom_components/tibber_prices/entity_utils/icons.py index 7e2ec38..a3aee5f 100644 --- a/custom_components/tibber_prices/entity_utils/icons.py +++ b/custom_components/tibber_prices/entity_utils/icons.py @@ -84,6 +84,11 @@ def get_dynamic_icon( def get_trend_icon(key: str, value: Any) -> str | None: """Get icon for trend sensors.""" + # Handle next_price_trend_change TIMESTAMP sensor differently + # (icon based on attributes, not value which is a timestamp) + if key == "next_price_trend_change": + return None # Will be handled by sensor's icon property using attributes + if not key.startswith("price_trend_") or not isinstance(value, str): return None diff --git a/custom_components/tibber_prices/price_utils.py b/custom_components/tibber_prices/price_utils.py index 69d3b65..867fe51 100644 --- a/custom_components/tibber_prices/price_utils.py +++ b/custom_components/tibber_prices/price_utils.py @@ -27,6 +27,21 @@ _LOGGER = logging.getLogger(__name__) MINUTES_PER_INTERVAL = 15 MIN_PRICES_FOR_VOLATILITY = 2 # Minimum number of price values needed for volatility calculation +# Volatility factors for adaptive trend thresholds +# These multipliers adjust the base trend thresholds based on price volatility. +# The volatility *ranges* are user-configurable (threshold_moderate, threshold_high), +# but the *reaction strength* (factors) is fixed for predictable behavior. +# This separation allows users to adjust volatility classification without +# unexpectedly changing trend sensitivity. +# +# Factor selection based on lookahead volatility: +# - Below moderate threshold (e.g., <15%): Use 0.6 → 40% more sensitive +# - Moderate to high (e.g., 15-30%): Use 1.0 → as configured by user +# - High and above (e.g., ≥30%): Use 1.4 → 40% less sensitive (filters noise) +VOLATILITY_FACTOR_SENSITIVE = 0.6 # Low volatility → more responsive +VOLATILITY_FACTOR_NORMAL = 1.0 # Moderate volatility → baseline +VOLATILITY_FACTOR_INSENSITIVE = 1.4 # High volatility → noise filtering + def calculate_volatility_level( prices: list[float], @@ -485,41 +500,178 @@ def aggregate_period_ratings( return rating_level.lower() if rating_level else None, avg_diff -def calculate_price_trend( +def _calculate_lookahead_volatility_factor( + all_intervals: list[dict[str, Any]], + lookahead_intervals: int, + volatility_threshold_moderate: float, + volatility_threshold_high: float, +) -> float: + """ + Calculate volatility factor for adaptive thresholds based on lookahead period. + + Uses the same volatility calculation (coefficient of variation) as volatility sensors, + ensuring consistent volatility interpretation across the integration. + + Args: + all_intervals: List of price intervals (today + tomorrow) + lookahead_intervals: Number of intervals to analyze for volatility + volatility_threshold_moderate: Threshold for moderate volatility (%, e.g., 15) + volatility_threshold_high: Threshold for high volatility (%, e.g., 30) + + Returns: + Multiplier for base threshold: + - 0.6 for low volatility (< moderate threshold) + - 1.0 for moderate volatility (moderate to high threshold) + - 1.4 for high volatility (>= high threshold) + + """ + if len(all_intervals) < lookahead_intervals: + _LOGGER.debug( + "Insufficient data for volatility calculation: need %d intervals, have %d - using factor 1.0", + lookahead_intervals, + len(all_intervals), + ) + return 1.0 # Fallback: no adjustment + + # Extract prices from next N intervals + lookahead_prices = [ + float(interval["total"]) + for interval in all_intervals[:lookahead_intervals] + if "total" in interval and interval["total"] is not None + ] + + if not lookahead_prices: + _LOGGER.debug("No valid prices in lookahead period - using factor 1.0") + return 1.0 + + # Use the same volatility calculation as volatility sensors (coefficient of variation) + # This ensures consistent interpretation of volatility across the integration + volatility_level = calculate_volatility_level( + prices=lookahead_prices, + threshold_moderate=volatility_threshold_moderate, + threshold_high=volatility_threshold_high, + # Note: We don't use VERY_HIGH threshold here, only LOW/MODERATE/HIGH matter for factor + ) + + # Map volatility level to adjustment factor + if volatility_level == VOLATILITY_LOW: + factor = VOLATILITY_FACTOR_SENSITIVE # 0.6 → More sensitive trend detection + elif volatility_level in (VOLATILITY_MODERATE, VOLATILITY_HIGH): + # Treat MODERATE and HIGH the same for trend detection + # HIGH volatility means noisy data, so we need less sensitive thresholds + factor = VOLATILITY_FACTOR_NORMAL if volatility_level == VOLATILITY_MODERATE else VOLATILITY_FACTOR_INSENSITIVE + else: # VOLATILITY_VERY_HIGH (should not occur with our thresholds, but handle it) + factor = VOLATILITY_FACTOR_INSENSITIVE # 1.4 → Less sensitive (filter noise) + + _LOGGER.debug( + "Volatility analysis: intervals=%d, prices=%d, " + "level=%s, thresholds=(moderate:%.0f%%, high:%.0f%%), factor=%.2f", + lookahead_intervals, + len(lookahead_prices), + volatility_level, + volatility_threshold_moderate, + volatility_threshold_high, + factor, + ) + + return factor + + +def calculate_price_trend( # noqa: PLR0913 - All parameters are necessary for volatility-adaptive calculation current_interval_price: float, future_average: float, - threshold_rising: float = 5.0, - threshold_falling: float = -5.0, + threshold_rising: float = 3.0, + threshold_falling: float = -3.0, + *, + volatility_adjustment: bool = True, + lookahead_intervals: int | None = None, + all_intervals: list[dict[str, Any]] | None = None, + volatility_threshold_moderate: float = DEFAULT_VOLATILITY_THRESHOLD_MODERATE, + volatility_threshold_high: float = DEFAULT_VOLATILITY_THRESHOLD_HIGH, ) -> tuple[str, float]: """ Calculate price trend by comparing current price with future average. + Supports volatility-adaptive thresholds: when enabled, the effective threshold + is adjusted based on price volatility in the lookahead period. This makes the + trend detection more sensitive during stable periods and less noisy during + volatile periods. + + Uses the same volatility thresholds as configured for volatility sensors, + ensuring consistent volatility interpretation across the integration. + Args: current_interval_price: Current interval price future_average: Average price of future intervals - threshold_rising: Percentage threshold for rising trend (positive, default 5%) - threshold_falling: Percentage threshold for falling trend (negative, default -5%) + threshold_rising: Base threshold for rising trend (%, positive, default 3%) + threshold_falling: Base threshold for falling trend (%, negative, default -3%) + volatility_adjustment: Enable volatility-adaptive thresholds (default True) + lookahead_intervals: Number of intervals in trend period for volatility calc + all_intervals: Price intervals (today + tomorrow) for volatility calculation + volatility_threshold_moderate: User-configured moderate volatility threshold (%) + volatility_threshold_high: User-configured high volatility threshold (%) Returns: Tuple of (trend_state, difference_percentage) trend_state: "rising" | "falling" | "stable" difference_percentage: % change from current to future ((future - current) / current * 100) + Note: + Volatility adjustment factor: + - Low volatility (<15%): factor 0.6 → more sensitive (e.g., 3% → 1.8%) + - Moderate volatility (15-35%): factor 1.0 → as configured (3%) + - High volatility (>35%): factor 1.4 → less sensitive (e.g., 3% → 4.2%) + """ if current_interval_price == 0: # Avoid division by zero + _LOGGER.debug("Current price is zero - returning stable trend") return "stable", 0.0 + # Apply volatility adjustment if enabled and data available + effective_rising = threshold_rising + effective_falling = threshold_falling + volatility_factor = 1.0 + + if volatility_adjustment and lookahead_intervals and all_intervals: + volatility_factor = _calculate_lookahead_volatility_factor( + all_intervals, lookahead_intervals, volatility_threshold_moderate, volatility_threshold_high + ) + effective_rising = threshold_rising * volatility_factor + effective_falling = threshold_falling * volatility_factor + + _LOGGER.debug( + "Trend threshold adjustment: base_rising=%.1f%%, base_falling=%.1f%%, " + "lookahead_intervals=%d, volatility_factor=%.2f, " + "effective_rising=%.1f%%, effective_falling=%.1f%%", + threshold_rising, + threshold_falling, + lookahead_intervals, + volatility_factor, + effective_rising, + effective_falling, + ) + # Calculate percentage difference from current to future diff_pct = ((future_average - current_interval_price) / current_interval_price) * 100 - # Determine trend based on thresholds - # threshold_falling is negative, so we compare with it directly - if diff_pct > threshold_rising: + # Determine trend based on effective thresholds + if diff_pct >= effective_rising: trend = "rising" - elif diff_pct < threshold_falling: + elif diff_pct <= effective_falling: trend = "falling" else: trend = "stable" + _LOGGER.debug( + "Trend calculation: current=%.4f, future_avg=%.4f, diff=%.1f%%, " + "threshold_rising=%.1f%%, threshold_falling=%.1f%%, trend=%s", + current_interval_price, + future_average, + diff_pct, + effective_rising, + effective_falling, + trend, + ) + return trend, diff_pct diff --git a/custom_components/tibber_prices/sensor/attributes.py b/custom_components/tibber_prices/sensor/attributes.py index d23a6ff..2c03714 100644 --- a/custom_components/tibber_prices/sensor/attributes.py +++ b/custom_components/tibber_prices/sensor/attributes.py @@ -62,6 +62,16 @@ def _add_timing_or_volatility_attributes( add_period_timing_attributes(attributes=attributes, key=key, state_value=native_value) +def _add_cached_trend_attributes(attributes: dict, key: str, cached_data: dict) -> None: + """Add cached trend attributes if available.""" + if key.startswith("price_trend_") and cached_data.get("trend_attributes"): + attributes.update(cached_data["trend_attributes"]) + elif key == "current_price_trend" and cached_data.get("current_trend_attributes"): + attributes.update(cached_data["current_trend_attributes"]) + elif key == "next_price_trend_change" and cached_data.get("trend_change_attributes"): + attributes.update(cached_data["trend_change_attributes"]) + + def build_sensor_attributes( key: str, coordinator: TibberPricesDataUpdateCoordinator, @@ -88,9 +98,8 @@ def build_sensor_attributes( try: attributes: dict[str, Any] = {} - # For trend sensors, use the cached _trend_attributes - if key.startswith("price_trend_") and cached_data.get("trend_attributes"): - attributes.update(cached_data["trend_attributes"]) + # For trend sensors, use cached attributes + _add_cached_trend_attributes(attributes, key, cached_data) # Group sensors by type and delegate to specific handlers if key in [ diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index a0d60d3..76fd851 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -23,11 +23,15 @@ from custom_components.tibber_prices.const import ( CONF_PRICE_RATING_THRESHOLD_LOW, CONF_PRICE_TREND_THRESHOLD_FALLING, CONF_PRICE_TREND_THRESHOLD_RISING, + CONF_VOLATILITY_THRESHOLD_HIGH, + CONF_VOLATILITY_THRESHOLD_MODERATE, DEFAULT_EXTENDED_DESCRIPTIONS, DEFAULT_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_TREND_THRESHOLD_FALLING, DEFAULT_PRICE_TREND_THRESHOLD_RISING, + DEFAULT_VOLATILITY_THRESHOLD_HIGH, + DEFAULT_VOLATILITY_THRESHOLD_MODERATE, DOMAIN, async_get_entity_description, format_price_unit_major, @@ -106,6 +110,11 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): self._minute_update_remove_listener: Callable | None = None self._trend_attributes: dict[str, Any] = {} # Sensor-specific trend attributes self._cached_trend_value: str | None = None # Cache for trend state + self._current_trend_attributes: dict[str, Any] | None = None # Current trend attributes + self._trend_change_attributes: dict[str, Any] | None = None # Next trend change attributes + # Centralized trend calculation cache (calculated once per coordinator update) + self._trend_calculation_cache: dict[str, Any] | None = None + self._trend_calculation_timestamp: datetime | None = None async def async_added_to_hass(self) -> None: """When entity is added to hass.""" @@ -259,6 +268,9 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): "next_avg_6h": lambda: self._get_next_avg_n_hours_value(hours=6), "next_avg_8h": lambda: self._get_next_avg_n_hours_value(hours=8), "next_avg_12h": lambda: self._get_next_avg_n_hours_value(hours=12), + # Current and next trend change sensors + "current_price_trend": self._get_current_trend_value, + "next_price_trend_change": self._get_next_trend_change_value, # Price trend sensors "price_trend_1h": lambda: self._get_price_trend_value(hours=1), "price_trend_2h": lambda: self._get_price_trend_value(hours=2), @@ -852,9 +864,34 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): DEFAULT_PRICE_TREND_THRESHOLD_FALLING, ) - # Calculate trend with configured thresholds + # Prepare data for volatility-adaptive thresholds + price_info = self.coordinator.data.get("priceInfo", {}) + today_prices = price_info.get("today", []) + tomorrow_prices = price_info.get("tomorrow", []) + all_intervals = today_prices + tomorrow_prices + lookahead_intervals = hours * 4 # Convert hours to 15-minute intervals + + # Get user-configured volatility thresholds (used for adaptive trend detection) + volatility_threshold_moderate = self.coordinator.config_entry.options.get( + CONF_VOLATILITY_THRESHOLD_MODERATE, + DEFAULT_VOLATILITY_THRESHOLD_MODERATE, + ) + volatility_threshold_high = self.coordinator.config_entry.options.get( + CONF_VOLATILITY_THRESHOLD_HIGH, + DEFAULT_VOLATILITY_THRESHOLD_HIGH, + ) + + # Calculate trend with volatility-adaptive thresholds trend_state, diff_pct = calculate_price_trend( - current_interval_price, future_avg, threshold_rising=threshold_rising, threshold_falling=threshold_falling + current_interval_price, + future_avg, + threshold_rising=threshold_rising, + threshold_falling=threshold_falling, + volatility_adjustment=True, # Always enabled + lookahead_intervals=lookahead_intervals, + all_intervals=all_intervals, + volatility_threshold_moderate=volatility_threshold_moderate, + volatility_threshold_high=volatility_threshold_high, ) # Determine icon color based on trend state @@ -942,6 +979,407 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): return None + def _get_thresholds_config(self) -> dict[str, float]: + """Get configured thresholds for trend calculation.""" + return { + "rising": self.coordinator.config_entry.options.get( + CONF_PRICE_TREND_THRESHOLD_RISING, DEFAULT_PRICE_TREND_THRESHOLD_RISING + ), + "falling": self.coordinator.config_entry.options.get( + CONF_PRICE_TREND_THRESHOLD_FALLING, DEFAULT_PRICE_TREND_THRESHOLD_FALLING + ), + "moderate": self.coordinator.config_entry.options.get( + CONF_VOLATILITY_THRESHOLD_MODERATE, DEFAULT_VOLATILITY_THRESHOLD_MODERATE + ), + "high": self.coordinator.config_entry.options.get( + CONF_VOLATILITY_THRESHOLD_HIGH, DEFAULT_VOLATILITY_THRESHOLD_HIGH + ), + } + + def _calculate_momentum(self, current_price: float, all_intervals: list, current_index: int) -> str: + """ + Calculate price momentum from weighted trailing average (last 1h). + + Args: + current_price: Current interval price + all_intervals: All price intervals + current_index: Index of current interval + + Returns: + Momentum direction: "rising", "falling", or "stable" + + """ + # Look back 1 hour (4 intervals) for quick reaction + lookback_intervals = 4 + min_intervals = 2 # Need at least 30 minutes of history + + trailing_intervals = all_intervals[max(0, current_index - lookback_intervals) : current_index] + + if len(trailing_intervals) < min_intervals: + return "stable" # Not enough history + + # Weighted average: newer intervals count more + # Weights: [0.5, 0.75, 1.0, 1.25] for 4 intervals (grows linearly) + weights = [0.5 + 0.25 * i for i in range(len(trailing_intervals))] + trailing_prices = [float(interval["total"]) for interval in trailing_intervals if "total" in interval] + + if not trailing_prices or len(trailing_prices) != len(weights): + return "stable" + + weighted_sum = sum(price * weight for price, weight in zip(trailing_prices, weights, strict=True)) + weighted_avg = weighted_sum / sum(weights) + + # Calculate momentum with 3% threshold + momentum_threshold = 0.03 + diff = (current_price - weighted_avg) / weighted_avg + + if diff > momentum_threshold: + return "rising" + if diff < -momentum_threshold: + return "falling" + return "stable" + + def _combine_momentum_with_future( + self, + *, + current_momentum: str, + current_price: float, + future_avg: float, + context: dict, + ) -> str: + """ + Combine momentum analysis with future outlook to determine final trend. + + Args: + current_momentum: Current momentum direction (rising/falling/stable) + current_price: Current interval price + future_avg: Average price in future window + context: Dict with all_intervals, current_index, lookahead_intervals, thresholds + + Returns: + Final trend direction: "rising", "falling", or "stable" + + """ + if current_momentum == "rising": + # We're in uptrend - does it continue? + return "rising" if future_avg >= current_price * 0.98 else "falling" + + if current_momentum == "falling": + # We're in downtrend - does it continue? + return "falling" if future_avg <= current_price * 1.02 else "rising" + + # current_momentum == "stable" - what's coming? + all_intervals = context["all_intervals"] + current_index = context["current_index"] + lookahead_intervals = context["lookahead_intervals"] + thresholds = context["thresholds"] + + lookahead_for_volatility = all_intervals[current_index : current_index + lookahead_intervals] + trend_state, _ = calculate_price_trend( + current_price, + future_avg, + threshold_rising=thresholds["rising"], + threshold_falling=thresholds["falling"], + volatility_adjustment=True, + lookahead_intervals=lookahead_intervals, + all_intervals=lookahead_for_volatility, + volatility_threshold_moderate=thresholds["moderate"], + volatility_threshold_high=thresholds["high"], + ) + return trend_state + + def _calculate_standard_trend( + self, + all_intervals: list, + current_index: int, + current_interval: dict, + thresholds: dict, + ) -> str: + """Calculate standard 3h trend as baseline.""" + min_intervals_for_trend = 4 + standard_lookahead = 12 # 3 hours + + standard_future_intervals = all_intervals[current_index + 1 : current_index + standard_lookahead + 1] + + if len(standard_future_intervals) < min_intervals_for_trend: + return "stable" + + standard_future_prices = [float(fi["total"]) for fi in standard_future_intervals if "total" in fi] + if not standard_future_prices: + return "stable" + + standard_future_avg = sum(standard_future_prices) / len(standard_future_prices) + current_price = float(current_interval["total"]) + + standard_lookahead_volatility = all_intervals[current_index : current_index + standard_lookahead] + current_trend_3h, _ = calculate_price_trend( + current_price, + standard_future_avg, + threshold_rising=thresholds["rising"], + threshold_falling=thresholds["falling"], + volatility_adjustment=True, + lookahead_intervals=standard_lookahead, + all_intervals=standard_lookahead_volatility, + volatility_threshold_moderate=thresholds["moderate"], + volatility_threshold_high=thresholds["high"], + ) + + return current_trend_3h + + def _calculate_trend_info(self) -> dict[str, Any] | None: + """ + Centralized trend calculation for current_price_trend and next_price_trend_change sensors. + + This method calculates all trend-related information in one place to avoid duplication + and ensure consistency between the two sensors. Results are cached per coordinator update. + + Returns: + Dictionary with trend information for both sensors. + + """ + trend_cache_duration_seconds = 60 # Cache for 1 minute + + # Check if we have a valid cache + now = dt_util.now() + if ( + self._trend_calculation_cache is not None + and self._trend_calculation_timestamp is not None + and (now - self._trend_calculation_timestamp).total_seconds() < trend_cache_duration_seconds + ): + return self._trend_calculation_cache + + # Validate coordinator data + if not self.coordinator.data: + return None + + price_info = self.coordinator.data.get("priceInfo", {}) + all_intervals = price_info.get("today", []) + price_info.get("tomorrow", []) + current_interval = find_price_data_for_interval(price_info, now) + + if not all_intervals or not current_interval: + return None + + current_interval_start = dt_util.parse_datetime(current_interval["startsAt"]) + current_interval_start = dt_util.as_local(current_interval_start) if current_interval_start else None + + if not current_interval_start: + return None + + current_index = self._find_current_interval_index(all_intervals, current_interval_start) + if current_index is None: + return None + + # Get configured thresholds + thresholds = self._get_thresholds_config() + + # Step 1: Calculate current momentum from trailing data (1h weighted) + current_price = float(current_interval["total"]) + current_momentum = self._calculate_momentum(current_price, all_intervals, current_index) + + # Step 2: Calculate 3h baseline trend for comparison + current_trend_3h = self._calculate_standard_trend(all_intervals, current_index, current_interval, thresholds) + + # Step 3: Find next trend change from current momentum + scan_params = { + "current_index": current_index, + "current_trend_state": current_momentum, # Use momentum, not 3h baseline + "current_interval": current_interval, + "now": now, + } + + next_change_time = self._scan_for_trend_change(all_intervals, scan_params, thresholds) + + # Step 4: Calculate final trend combining momentum + future outlook + min_intervals_for_trend = 4 + standard_lookahead = 12 # 3 hours + + if next_change_time: + time_diff = next_change_time - now + intervals_until_change = int(time_diff.total_seconds() / 900) # 900s = 15min + lookahead_intervals = max(min_intervals_for_trend, intervals_until_change) + else: + lookahead_intervals = standard_lookahead + + # Get future data + future_intervals = all_intervals[current_index + 1 : current_index + lookahead_intervals + 1] + future_prices = [float(fi["total"]) for fi in future_intervals if "total" in fi] + + # Combine momentum + future outlook + if len(future_intervals) >= min_intervals_for_trend and future_prices: + future_avg = sum(future_prices) / len(future_prices) + current_trend_state = self._combine_momentum_with_future( + current_momentum=current_momentum, + current_price=current_price, + future_avg=future_avg, + context={ + "all_intervals": all_intervals, + "current_index": current_index, + "lookahead_intervals": lookahead_intervals, + "thresholds": thresholds, + }, + ) + else: + # Not enough future data - use 3h baseline as fallback + current_trend_state = current_trend_3h + + # Build result dictionary + next_direction = self._trend_change_attributes.get("direction") if self._trend_change_attributes else None + + result = { + "current_trend_state": current_trend_state, + "next_change_time": next_change_time, + "next_change_direction": next_direction, + "valid_until": next_change_time.isoformat() if next_change_time else None, + "duration_hours": None, + "duration_minutes": None, + "trend_change_attributes": self._trend_change_attributes, + } + + if next_change_time: + time_diff = next_change_time - now + result["duration_hours"] = round(time_diff.total_seconds() / 3600, 1) + result["duration_minutes"] = int(time_diff.total_seconds() / 60) + + # Cache the result + self._trend_calculation_cache = result + self._trend_calculation_timestamp = now + + return result + + def _get_current_trend_value(self) -> str | None: + """ + Get the current price trend that is valid until the next change. + + Uses centralized _calculate_trend_info() for consistency with next_price_trend_change sensor. + + Returns: + Current trend state: "rising", "falling", or "stable" + + """ + trend_info = self._calculate_trend_info() + + if not trend_info: + return None + + # Set attributes for this sensor + self._current_trend_attributes = { + "valid_until": trend_info["valid_until"], + "next_direction": trend_info["next_change_direction"], + "duration_hours": trend_info["duration_hours"], + "duration_minutes": trend_info["duration_minutes"], + } + + return trend_info["current_trend_state"] + + def _find_current_interval_index(self, all_intervals: list, current_interval_start: datetime) -> int | None: + """Find the index of current interval in all_intervals list.""" + for idx, interval in enumerate(all_intervals): + interval_start = dt_util.parse_datetime(interval["startsAt"]) + if interval_start and dt_util.as_local(interval_start) == current_interval_start: + return idx + return None + + def _scan_for_trend_change( + self, + all_intervals: list, + scan_params: dict, + thresholds: dict, + ) -> datetime | None: + """ + Scan future intervals for trend change. + + Args: + all_intervals: List of all price intervals + scan_params: Dict with current_index, current_trend_state, current_interval, now + thresholds: Dict with rising, falling, moderate, high threshold values + + """ + intervals_in_3h = 12 # 3 hours = 12 intervals @ 15min each + current_index = scan_params["current_index"] + current_trend_state = scan_params["current_trend_state"] + current_interval = scan_params["current_interval"] + now = scan_params["now"] + + for i in range(current_index + 1, min(current_index + 97, len(all_intervals))): + interval = all_intervals[i] + interval_start = dt_util.parse_datetime(interval["startsAt"]) + if not interval_start: + continue + interval_start = dt_util.as_local(interval_start) + + # Skip if this interval is in the past + if interval_start <= now: + continue + + # Calculate trend at this future interval + future_intervals = all_intervals[i + 1 : i + intervals_in_3h + 1] + if len(future_intervals) < intervals_in_3h: + break # Not enough data to calculate trend + + future_prices = [float(fi["total"]) for fi in future_intervals if "total" in fi] + if not future_prices: + continue + + future_avg = sum(future_prices) / len(future_prices) + current_price = float(interval["total"]) + + # Calculate trend at this future point + lookahead_for_volatility = all_intervals[i : i + intervals_in_3h] + trend_state, _ = calculate_price_trend( + current_price, + future_avg, + threshold_rising=thresholds["rising"], + threshold_falling=thresholds["falling"], + volatility_adjustment=True, + lookahead_intervals=intervals_in_3h, + all_intervals=lookahead_for_volatility, + volatility_threshold_moderate=thresholds["moderate"], + volatility_threshold_high=thresholds["high"], + ) + + # Check if trend changed from current trend state + # We want to find ANY change from current state, including changes to/from stable + if trend_state != current_trend_state: + # Store details for attributes + time_diff = interval_start - now + hours_until = time_diff.total_seconds() / 3600 + minutes_until = int(time_diff.total_seconds() / 60) + + self._trend_change_attributes = { + "timestamp": interval_start.isoformat(), + "direction": trend_state, + "from_direction": current_trend_state, + "current_price_now": round(float(current_interval["total"]) * 100, 2), + "price_at_change": round(current_price * 100, 2), + "avg_after_change": round(future_avg * 100, 2), + "trend_diff_%": round((future_avg - current_price) / current_price * 100, 1), + "hours_until_change": round(hours_until, 1), + "minutes_until_change": minutes_until, + } + return interval_start + + return None + + def _get_next_trend_change_value(self) -> datetime | None: + """ + Calculate when the next price trend change will occur. + + Uses centralized _calculate_trend_info() for consistency with current_price_trend sensor. + + Returns: + Timestamp of next trend change, or None if no change expected in next 24h + + """ + trend_info = self._calculate_trend_info() + + if not trend_info: + return None + + # Set attributes for this sensor + self._trend_change_attributes = trend_info["trend_change_attributes"] + + return trend_info["next_change_time"] + def _get_data_timestamp(self) -> datetime | None: """Get the latest data timestamp.""" if not self.coordinator.data: @@ -1415,6 +1853,24 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): key = self.entity_description.key value = self.native_value + # Icon mapping for trend directions + trend_icons = { + "rising": "mdi:trending-up", + "falling": "mdi:trending-down", + "stable": "mdi:trending-neutral", + } + + # Special handling for next_price_trend_change: Icon based on direction attribute + if key == "next_price_trend_change" and self._trend_change_attributes: + direction = self._trend_change_attributes.get("direction") + if isinstance(direction, str): + return trend_icons.get(direction, "mdi:help-circle-outline") + return "mdi:help-circle-outline" + + # Special handling for current_price_trend: Icon based on current state value + if key == "current_price_trend" and isinstance(value, str): + return trend_icons.get(value, "mdi:help-circle-outline") + # Create callback for period active state check (used by timing sensors) period_is_active_callback = None if key.startswith("best_price_"): @@ -1532,6 +1988,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): # Prepare cached data that attribute builders might need cached_data = { "trend_attributes": getattr(self, "_trend_attributes", None), + "current_trend_attributes": getattr(self, "_current_trend_attributes", None), + "trend_change_attributes": getattr(self, "_trend_change_attributes", None), "volatility_attributes": getattr(self, "_last_volatility_attributes", None), "last_extreme_interval": getattr(self, "_last_extreme_interval", None), "last_price_level": getattr(self, "_last_price_level", None), diff --git a/custom_components/tibber_prices/sensor/definitions.py b/custom_components/tibber_prices/sensor/definitions.py index ec7255a..18126a2 100644 --- a/custom_components/tibber_prices/sensor/definitions.py +++ b/custom_components/tibber_prices/sensor/definitions.py @@ -540,6 +540,28 @@ FUTURE_AVG_SENSORS = ( ) FUTURE_TREND_SENSORS = ( + # Current trend sensor (what is the trend right now, valid until next change?) + SensorEntityDescription( + key="current_price_trend", + translation_key="current_price_trend", + name="Current Price Trend", + icon="mdi:trending-up", # Dynamic: trending-up/trending-down/trending-neutral based on current trend + device_class=SensorDeviceClass.ENUM, + state_class=None, # Enum values: no statistics + options=["rising", "falling", "stable"], + entity_registry_enabled_default=True, + ), + # Next trend change sensor (when will trend change?) + SensorEntityDescription( + key="next_price_trend_change", + translation_key="next_price_trend_change", + name="Next Price Trend Change", + icon="mdi:clock-alert", # Dynamic: trending-up/trending-down/trending-neutral based on direction + device_class=SensorDeviceClass.TIMESTAMP, + state_class=None, # Timestamp: no statistics + entity_registry_enabled_default=True, + ), + # Price trend forecast sensors (will prices be higher/lower in X hours?) # Default enabled: 1h-5h SensorEntityDescription( key="price_trend_1h", diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index 53c6fbc..f9b786d 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -156,7 +156,7 @@ }, "volatility": { "title": "Volatilität Schwellenwerte", - "description": "{step_progress}\n\nKonfiguriere Schwellenwerte für die Volatilitätsklassifizierung. Volatilität misst relative Preisschwankungen anhand des Variationskoeffizienten (VK = Standardabweichung / Durchschnitt × 100%). Diese Schwellenwerte sind Prozentwerte, die für alle Preisniveaus funktionieren und von Volatilitätssensoren sowie Zeitraumfiltern verwendet werden.", + "description": "{step_progress}\n\nKonfiguriere Schwellenwerte für die Volatilitätsklassifizierung. Volatilität misst relative Preisschwankungen anhand des Variationskoeffizienten (VK = Standardabweichung / Durchschnitt × 100%). Diese Schwellenwerte sind Prozentwerte, die für alle Preisniveaus funktionieren.\n\nVerwendet von:\n• Volatilitätssensoren (Klassifizierung)\n• Trend-Sensoren (adaptive Schwellenanpassung: