From 47b0a298d462c1356133bb527248e431380dbd87 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Fri, 21 Nov 2025 23:18:46 +0000 Subject: [PATCH] feat(periods): add midnight-crossing periods and day volatility attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Periods can now naturally cross midnight boundaries, and new diagnostic attributes help users understand price classification changes at midnight. **New Features:** 1. Midnight-Crossing Period Support (relaxation.py): - group_periods_by_day() assigns periods to ALL spanned days - Periods crossing midnight appear in both yesterday and today - Enables period formation across calendar day boundaries - Ensures min_periods checking works correctly at midnight 2. Extended Price Data Window (relaxation.py): - Period calculation now uses full 3-day data (yesterday+today+tomorrow) - Enables natural period formation without artificial midnight cutoff - Removed date filter that excluded yesterday's prices 3. Day Volatility Diagnostic Attributes (period_statistics.py, core.py): - day_volatility_%: Daily price spread as percentage (span/avg × 100) - day_price_min/max/span: Daily price range in minor currency (ct/øre) - Helps detect when midnight classification changes are economically significant - Uses period start day's reference prices for consistency **Documentation:** 4. Design Principles (period-calculation-theory.md): - Clarified per-day evaluation principle (always was the design) - Added comprehensive section on midnight boundary handling - Documented volatility threshold separation (sensor vs period filters) - Explained market context for midnight price jumps (EPEX SPOT timing) 5. User Guides (period-calculation.md, automation-examples.md): - Added \"Midnight Price Classification Changes\" troubleshooting section - Provided automation examples using volatility attributes - Explained why Best→Peak classification can change at midnight - Documented level filter volatility threshold behavior **Architecture:** - Per-day evaluation: Each interval evaluated against its OWN day's min/max/avg (not period start day) ensures mathematical correctness across midnight - Period boundaries: Periods can naturally cross midnight but may split when consecutive days differ significantly (intentional, mathematically correct) - Volatility thresholds: Sensor thresholds (user-configurable) remain separate from period filter thresholds (fixed internal) to prevent unexpected behavior Impact: Periods crossing midnight are now consistently visible before and after midnight turnover. Users can understand and handle edge cases where price classification changes at midnight on low-volatility days. --- .../coordinator/period_handlers/core.py | 1 + .../period_handlers/period_building.py | 24 +- .../period_handlers/period_statistics.py | 30 ++- .../coordinator/period_handlers/relaxation.py | 71 +++--- docs/development/period-calculation-theory.md | 150 +++++++++++- docs/user/automation-examples.md | 227 ++++++++++++++++++ docs/user/period-calculation.md | 135 +++++++++++ 7 files changed, 582 insertions(+), 56 deletions(-) diff --git a/custom_components/tibber_prices/coordinator/period_handlers/core.py b/custom_components/tibber_prices/coordinator/period_handlers/core.py index d00e7a8..968af9e 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/core.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/core.py @@ -145,6 +145,7 @@ def calculate_periods( price_context = { "ref_prices": ref_prices, "avg_prices": avg_price_by_day, + "intervals_by_day": intervals_by_day, # Needed for day volatility calculation "flex": flex, "min_distance_from_avg": min_distance_from_avg, } diff --git a/custom_components/tibber_prices/coordinator/period_handlers/period_building.py b/custom_components/tibber_prices/coordinator/period_handlers/period_building.py index 7f49830..5e251d2 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/period_building.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/period_building.py @@ -65,9 +65,10 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building """ Build periods, allowing periods to cross midnight (day boundary). - Periods can span multiple days. Each period uses the reference price (min/max) from - the day when the period started, ensuring consistent filtering criteria throughout - the period even when crossing midnight. + Periods can span multiple days. Each interval is evaluated against the reference + price (min/max) and average price of its own day. This ensures fair filtering + criteria even when periods cross midnight, where prices can jump significantly + due to different forecasting uncertainty (prices at day end vs. day start). Args: all_prices: All price data points @@ -105,7 +106,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building periods: list[list[dict]] = [] current_period: list[dict] = [] - period_start_date: date | None = None # Track start day of current period consecutive_gaps = 0 # Track consecutive intervals that deviate by 1 level step intervals_checked = 0 intervals_filtered_by_level = 0 @@ -125,11 +125,14 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building intervals_checked += 1 - # Use reference price from period start day (for consistency across midnight) - # If no period active, use current interval's day - ref_date = period_start_date if period_start_date is not None else date_key + # CRITICAL: Always use reference price from the interval's own day + # Each interval must meet the criteria of its own day, not the period start day. + # This ensures fair filtering even when periods cross midnight, where prices + # can jump significantly (last intervals of a day have more risk buffer than + # first intervals of next day, as they're set with different uncertainty levels). + ref_date = date_key - # Check flex and minimum distance criteria (using smoothed price and period start date reference) + # Check flex and minimum distance criteria (using smoothed price and interval's own day reference) criteria = TibberPricesIntervalCriteria( ref_price=ref_prices[ref_date], avg_price=avg_prices[ref_date], @@ -164,10 +167,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building # Add to period if all criteria are met if in_flex and meets_min_distance and meets_level: - # Start new period if none active - if not current_period: - period_start_date = date_key # Lock reference to start day - current_period.append( { "interval_hour": starts_at.hour, @@ -184,7 +183,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building # Criteria no longer met, end current period periods.append(current_period) current_period = [] - period_start_date = None # Reset period start date consecutive_gaps = 0 # Reset gap counter # Add final period if exists diff --git a/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py b/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py index 02a7812..92ac8f5 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py @@ -119,6 +119,7 @@ def build_period_summary_dict( stats: TibberPricesPeriodStatistics, *, reverse_sort: bool, + price_context: dict[str, Any] | None = None, ) -> dict: """ Build the complete period summary dictionary. @@ -127,6 +128,7 @@ def build_period_summary_dict( period_data: Period timing and position data stats: Calculated period statistics reverse_sort: True for peak price, False for best price (keyword-only) + price_context: Optional dict with ref_prices, avg_prices, intervals_by_day for day statistics Returns: Complete period summary dictionary following attribute ordering @@ -169,6 +171,30 @@ def build_period_summary_dict( if stats.period_price_diff_pct is not None: summary["period_price_diff_from_daily_min_%"] = stats.period_price_diff_pct + # Add day volatility and price statistics (for understanding midnight classification changes) + if price_context: + period_start_date = period_data.start_time.date() + intervals_by_day = price_context.get("intervals_by_day", {}) + avg_prices = price_context.get("avg_prices", {}) + + day_intervals = intervals_by_day.get(period_start_date, []) + if day_intervals: + # Calculate day price statistics (in EUR major units from API) + day_prices = [float(p["total"]) for p in day_intervals] + day_min = min(day_prices) + day_max = max(day_prices) + day_span = day_max - day_min + day_avg = avg_prices.get(period_start_date, sum(day_prices) / len(day_prices)) + + # Calculate volatility percentage (span / avg * 100) + day_volatility_pct = round((day_span / day_avg * 100), 1) if day_avg > 0 else 0.0 + + # Convert to minor units (ct/øre) for consistency with other price attributes + summary["day_volatility_%"] = day_volatility_pct + summary["day_price_min"] = round(day_min * 100, 2) + summary["day_price_max"] = round(day_max * 100, 2) + summary["day_price_span"] = round(day_span * 100, 2) + return summary @@ -306,7 +332,9 @@ def extract_period_summaries( ) # Build complete period summary - summary = build_period_summary_dict(period_data, stats, reverse_sort=thresholds.reverse_sort) + summary = build_period_summary_dict( + period_data, stats, reverse_sort=thresholds.reverse_sort, price_context=price_context + ) # Add smoothing information if any intervals benefited from smoothing if smoothed_impactful_count > 0: diff --git a/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py b/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py index ef9568f..86595e4 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py @@ -34,28 +34,43 @@ FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: base flex too high for r def group_periods_by_day(periods: list[dict]) -> dict[date, list[dict]]: """ - Group periods by the day they end in. + Group periods by ALL days they span (including midnight crossings). - This ensures periods crossing midnight are counted towards the day they end, - not the day they start. Example: Period 23:00 yesterday - 02:00 today counts - as "today" since it ends today. + Periods crossing midnight are assigned to ALL affected days. + Example: Period 23:00 yesterday - 02:00 today appears in BOTH days. + + This ensures that: + 1. For min_periods checking: A midnight-crossing period counts towards both days + 2. For binary sensors: Each day shows all relevant periods (including those starting/ending in other days) Args: periods: List of period summary dicts with "start" and "end" datetime Returns: - Dict mapping date to list of periods ending on that date + Dict mapping date to list of periods spanning that date """ periods_by_day: dict[date, list[dict]] = {} for period in periods: - # Use end time for grouping so periods crossing midnight are counted - # towards the day they end (more relevant for min_periods check) + start_time = period.get("start") end_time = period.get("end") - if end_time: - day = end_time.date() - periods_by_day.setdefault(day, []).append(period) + + if not start_time or not end_time: + continue + + # Assign period to ALL days it spans + start_date = start_time.date() + end_date = end_time.date() + + # Handle single-day and multi-day periods + current_date = start_date + while current_date <= end_date: + periods_by_day.setdefault(current_date, []).append(period) + # Move to next day + from datetime import timedelta # noqa: PLC0415 + + current_date = current_date + timedelta(days=1) return periods_by_day @@ -224,18 +239,10 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax INDENT_L0, ) - # Validate we have price data for today/future - today = time.now().date() - future_prices = [ - p - for p in all_prices - if (interval_time := time.get_interval_time(p)) is not None and interval_time.date() >= today - ] - - if not future_prices: - # No price data for today/future + # Validate we have price data + if not all_prices: _LOGGER.warning( - "No price data available for today/future - cannot calculate periods", + "No price data available - cannot calculate periods", ) return {"periods": [], "metadata": {}, "reference_data": {}}, { "relaxation_active": False, @@ -244,7 +251,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax "periods_found": 0, } - # Count available days for logging + # Count available days for logging (today and future only) prices_by_day = group_prices_by_day(all_prices, time=time) total_days = len(prices_by_day) @@ -253,13 +260,14 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax total_days, ) _LOGGER.debug( - "%sProcessing ALL %d price intervals together (allows midnight crossing)", + "%sProcessing ALL %d price intervals together (yesterday+today+tomorrow, allows midnight crossing)", INDENT_L1, - len(future_prices), + len(all_prices), ) - # === BASELINE CALCULATION (process ALL prices together) === - baseline_result = calculate_periods(future_prices, config=config, time=time) + # === BASELINE CALCULATION (process ALL prices together, including yesterday) === + # Periods that ended yesterday will be filtered out later by filter_periods_by_end_date() + baseline_result = calculate_periods(all_prices, config=config, time=time) all_periods = baseline_result["periods"] # Count periods per day for min_periods check @@ -295,9 +303,9 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax ) relaxation_was_needed = True - # Run relaxation on ALL prices together + # Run relaxation on ALL prices together (including yesterday) relaxed_result, relax_metadata = relax_all_prices( - all_prices=future_prices, + all_prices=all_prices, config=config, min_periods=min_periods, max_relaxation_attempts=max_relaxation_attempts, @@ -363,11 +371,12 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require Relax filters for all prices until min_periods per day is reached. Strategy: Try increasing flex by 3% increments, then relax level filter. - Processes all prices together, allowing periods to cross midnight boundaries. - Returns when ALL days have min_periods (or max attempts exhausted). + Processes all prices together (yesterday+today+tomorrow), allowing periods + to cross midnight boundaries. Returns when ALL days have min_periods + (or max attempts exhausted). Args: - all_prices: All price intervals (today + future) + all_prices: All price intervals (yesterday+today+tomorrow) config: Base period configuration min_periods: Target number of periods PER DAY max_relaxation_attempts: Maximum flex levels to try diff --git a/docs/development/period-calculation-theory.md b/docs/development/period-calculation-theory.md index 202045b..41e0d5c 100644 --- a/docs/development/period-calculation-theory.md +++ b/docs/development/period-calculation-theory.md @@ -60,6 +60,42 @@ meets_distance = price >= (daily_avg × (1 + min_distance/100)) **Logic:** See `level_filtering.py` for gap tolerance details. +**Volatility Thresholds - Important Separation:** + +The integration maintains **two independent sets** of volatility thresholds: + +1. **Sensor Thresholds** (user-configurable via `CONF_VOLATILITY_*_THRESHOLD`) + - Purpose: Display classification in `sensor.tibber_home_volatility_*` + - Default: LOW < 10%, MEDIUM < 20%, HIGH ≥ 20% + - User can adjust in config flow options + - Affects: Sensor state/attributes only + +2. **Period Filter Thresholds** (internal, fixed) + - Purpose: Level filter criteria when using `level="volatility_low"` etc. + - Source: `PRICE_LEVEL_THRESHOLDS` in `const.py` + - Values: Same as sensor defaults (LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%) + - User **cannot** adjust these + - Affects: Period candidate selection + +**Rationale for Separation:** + +- **Sensor thresholds** = Display preference ("I want to see LOW at 15% instead of 10%") +- **Period thresholds** = Algorithm configuration (tested defaults, complex interactions) +- Changing sensor display should not affect automation behavior +- Prevents unexpected side effects when user adjusts sensor classification +- Period calculation has many interacting filters (Flex, Distance, Level) - exposing all internals would be error-prone + +**Implementation:** +```python +# Sensor classification uses user config +user_low_threshold = config_entry.options.get(CONF_VOLATILITY_LOW_THRESHOLD, 10) + +# Period filter uses fixed constants +period_low_threshold = PRICE_LEVEL_THRESHOLDS["volatility_low"] # Always 10% +``` + +**Status:** Intentional design decision (Nov 2025). No plans to expose period thresholds to users. + --- ## The Flex × Min_Distance Conflict @@ -952,24 +988,116 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35))) **Current Behavior:** Periods can cross midnight naturally -**Issue:** Period starting 23:45 continues into next day -- Uses Day 1's daily_min as reference -- May be confusing when Day 2's prices very different +**Design Principle:** Each interval is evaluated using its **own day's** reference prices (daily min/max/avg). -**Alternative Approaches Considered:** -1. **Split at midnight** - Always keep periods within calendar day +**Implementation:** +```python +# In period_building.py build_periods(): +for price_data in all_prices: + starts_at = time.get_interval_time(price_data) + date_key = starts_at.date() + + # CRITICAL: Use interval's own day, not period_start_date + ref_date = date_key + + criteria = TibberPricesIntervalCriteria( + ref_price=ref_prices[ref_date], # Interval's day + avg_price=avg_prices[ref_date], # Interval's day + flex=flex, + min_distance_from_avg=min_distance_from_avg, + reverse_sort=reverse_sort, + ) +``` + +**Why Per-Day Evaluation?** + +Periods can cross midnight (e.g., 23:45 → 01:00). Each day has independent reference prices calculated from its 96 intervals. + +**Example showing the problem with period-start-day approach:** + +``` +Day 1 (2025-11-21): Cheap day + daily_min = 10 ct, daily_avg = 20 ct, flex = 15% + Criteria: price ≤ 11.5 ct (10 + 10×0.15) + +Day 2 (2025-11-22): Expensive day + daily_min = 20 ct, daily_avg = 30 ct, flex = 15% + Criteria: price ≤ 23 ct (20 + 20×0.15) + +Period crossing midnight: 23:45 Day 1 → 00:15 Day 2 + 23:45 (Day 1): 11 ct → ✅ Passes (11 ≤ 11.5) + 00:00 (Day 2): 21 ct → Should this pass? + +❌ WRONG (using period start day): + 00:00 evaluated against Day 1's 11.5 ct threshold + 21 ct > 11.5 ct → Fails + But 21ct IS cheap on Day 2 (min=20ct)! + +✅ CORRECT (using interval's own day): + 00:00 evaluated against Day 2's 23 ct threshold + 21 ct ≤ 23 ct → Passes + Correctly identified as cheap relative to Day 2 +``` + +**Trade-off: Periods May Break at Midnight** + +When days differ significantly, period can split: +``` +Day 1: Min=10ct, Avg=20ct, 23:45=11ct → ✅ Cheap (relative to Day 1) +Day 2: Min=25ct, Avg=35ct, 00:00=21ct → ❌ Expensive (relative to Day 2) +Result: Period stops at 23:45, new period starts later +``` + +This is **mathematically correct** - 21ct is genuinely expensive on a day where minimum is 25ct. + +**Market Reality Explains Price Jumps:** + +Day-ahead electricity markets (EPEX SPOT) set prices at 12:00 CET for all next-day hours: +- Late intervals (23:45): Priced ~36h before delivery → high forecast uncertainty → risk premium +- Early intervals (00:00): Priced ~12h before delivery → better forecasts → lower risk buffer + +This explains why absolute prices jump at midnight despite minimal demand changes. + +**User-Facing Solution (Nov 2025):** + +Added per-period day volatility attributes to detect when classification changes are meaningful: +- `day_volatility_%`: Percentage spread (span/avg × 100) +- `day_price_min`, `day_price_max`, `day_price_span`: Daily price range (ct/øre) + +Automations can check volatility before acting: +```yaml +condition: + - condition: template + value_template: > + {{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }} +``` + +Low volatility (< 15%) means classification changes are less economically significant. + +**Alternative Approaches Rejected:** + +1. **Use period start day for all intervals** + - Problem: Mathematically incorrect - lends cheap day's criteria to expensive day + - Rejected: Violates relative evaluation principle + +2. **Adjust flex/distance at midnight** + - Problem: Complex, unpredictable, hides market reality + - Rejected: Users should understand price context, not have it hidden + +3. **Split at midnight always** - Problem: Artificially fragments natural periods - Rejected: Worse user experience -2. **Use next day's reference** - Switch reference at midnight - - Problem: Period criteria inconsistent across its duration +4. **Use next day's reference after midnight** + - Problem: Period criteria inconsistent across duration - Rejected: Confusing and unpredictable -3. **Current approach** - Lock to start day's reference - - Benefit: Consistent criteria throughout period - - Drawback: Period may "spill" into different price context +**Status:** Per-day evaluation is intentional design prioritizing mathematical correctness. -**Status:** Current approach is intentional design choice +**See Also:** +- User documentation: `docs/user/period-calculation.md` → "Midnight Price Classification Changes" +- Implementation: `coordinator/period_handlers/period_building.py` (line ~126: `ref_date = date_key`) +- Attributes: `coordinator/period_handlers/period_statistics.py` (day volatility calculation) --- diff --git a/docs/user/automation-examples.md b/docs/user/automation-examples.md index 2a40617..6776c34 100644 --- a/docs/user/automation-examples.md +++ b/docs/user/automation-examples.md @@ -4,14 +4,241 @@ > **Tip:** For dashboard examples with dynamic icons and colors, see the **[Dynamic Icons Guide](dynamic-icons.md)** and **[Dynamic Icon Colors Guide](icon-colors.md)**. +## Table of Contents + +- [Price-Based Automations](#price-based-automations) +- [Volatility-Aware Automations](#volatility-aware-automations) +- [Best Hour Detection](#best-hour-detection) +- [ApexCharts Cards](#apexcharts-cards) + +--- + ## Price-Based Automations Coming soon... +--- + +## Volatility-Aware Automations + +These examples show how to handle low-volatility days where period classifications may flip at midnight despite minimal absolute price changes. + +### Use Case: Only Act on High-Volatility Days + +On days with low price variation (< 15% volatility), the difference between "cheap" and "expensive" periods is minimal. This automation only runs appliances when the savings are meaningful: + +```yaml +automation: + - alias: "Dishwasher - Best Price (High Volatility Only)" + description: "Start dishwasher during Best Price period, but only on days with meaningful price differences" + trigger: + - platform: state + entity_id: binary_sensor.tibber_home_best_price_period + to: "on" + condition: + # Only act if volatility > 15% (meaningful savings) + - condition: numeric_state + entity_id: sensor.tibber_home_volatility_today + above: 15 + # Optional: Ensure dishwasher is idle and door closed + - condition: state + entity_id: binary_sensor.dishwasher_door + state: "off" + action: + - service: switch.turn_on + target: + entity_id: switch.dishwasher_smart_plug + - service: notify.mobile_app + data: + message: "Dishwasher started during Best Price period ({{ states('sensor.tibber_home_current_interval_price_ct') }} ct/kWh, volatility {{ states('sensor.tibber_home_volatility_today') }}%)" +``` + +**Why this works:** +- On high-volatility days (e.g., 25% span), Best Price periods save 5-10 ct/kWh +- On low-volatility days (e.g., 8% span), savings are only 1-2 ct/kWh +- User can manually start dishwasher on low-volatility days without automation interference + +### Use Case: Absolute Price Threshold + +Instead of relying on relative classification, check if the absolute price is cheap enough: + +```yaml +automation: + - alias: "Water Heater - Cheap Enough" + description: "Heat water when price is below absolute threshold, regardless of period classification" + trigger: + - platform: state + entity_id: binary_sensor.tibber_home_best_price_period + to: "on" + condition: + # Absolute threshold: Only run if < 20 ct/kWh + - condition: numeric_state + entity_id: sensor.tibber_home_current_interval_price_ct + below: 20 + # Optional: Check water temperature + - condition: numeric_state + entity_id: sensor.water_heater_temperature + below: 55 # Only heat if below 55°C + action: + - service: switch.turn_on + target: + entity_id: switch.water_heater + - delay: + hours: 2 # Heat for 2 hours + - service: switch.turn_off + target: + entity_id: switch.water_heater +``` + +**Why this works:** +- Period classification can flip at midnight on low-volatility days +- Absolute threshold (20 ct/kWh) is stable across midnight boundary +- User sets their own "cheap enough" price based on local rates + +### Use Case: Combined Volatility and Price Check + +Most robust approach: Check both volatility and absolute price: + +```yaml +automation: + - alias: "EV Charging - Smart Strategy" + description: "Charge EV using volatility-aware logic" + trigger: + - platform: state + entity_id: binary_sensor.tibber_home_best_price_period + to: "on" + condition: + # Check battery level + - condition: numeric_state + entity_id: sensor.ev_battery_level + below: 80 + # Strategy: High volatility OR cheap enough + - condition: or + conditions: + # Path 1: High volatility day - trust period classification + - condition: numeric_state + entity_id: sensor.tibber_home_volatility_today + above: 15 + # Path 2: Low volatility but price is genuinely cheap + - condition: numeric_state + entity_id: sensor.tibber_home_current_interval_price_ct + below: 18 + action: + - service: switch.turn_on + target: + entity_id: switch.ev_charger + - service: notify.mobile_app + data: + message: > + EV charging started: {{ states('sensor.tibber_home_current_interval_price_ct') }} ct/kWh + (Volatility: {{ states('sensor.tibber_home_volatility_today') }}%) +``` + +**Why this works:** +- On high-volatility days (> 15%): Trust the Best Price classification +- On low-volatility days (< 15%): Only charge if price is actually cheap (< 18 ct/kWh) +- Handles midnight flips gracefully: Continues charging if price stays cheap + +### Use Case: Ignore Period Flips During Active Period + +Prevent automations from stopping mid-cycle when a period flips at midnight: + +```yaml +automation: + - alias: "Washing Machine - Complete Cycle" + description: "Start washing machine during Best Price, ignore midnight flips" + trigger: + - platform: state + entity_id: binary_sensor.tibber_home_best_price_period + to: "on" + condition: + # Only start if washing machine is idle + - condition: state + entity_id: sensor.washing_machine_state + state: "idle" + # And volatility is meaningful + - condition: numeric_state + entity_id: sensor.tibber_home_volatility_today + above: 15 + action: + - service: button.press + target: + entity_id: button.washing_machine_eco_program + # Create input_boolean to track active cycle + - service: input_boolean.turn_on + target: + entity_id: input_boolean.washing_machine_auto_started + + # Separate automation: Clear flag when cycle completes + - alias: "Washing Machine - Cycle Complete" + trigger: + - platform: state + entity_id: sensor.washing_machine_state + to: "finished" + condition: + # Only clear flag if we auto-started it + - condition: state + entity_id: input_boolean.washing_machine_auto_started + state: "on" + action: + - service: input_boolean.turn_off + target: + entity_id: input_boolean.washing_machine_auto_started + - service: notify.mobile_app + data: + message: "Washing cycle complete" +``` + +**Why this works:** +- Uses `input_boolean` to track auto-started cycles +- Won't trigger multiple times if period flips during the 2-3 hour wash cycle +- Only triggers on "off" → "on" transitions, not during "on" → "on" continuity + +### Use Case: Per-Period Day Volatility + +The simplest approach: Use the period's day volatility attribute directly: + +```yaml +automation: + - alias: "Heat Pump - Smart Heating" + trigger: + - platform: state + entity_id: binary_sensor.tibber_home_best_price_period + to: "on" + condition: + # Check if the PERIOD'S DAY has meaningful volatility + - condition: template + value_template: > + {{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }} + action: + - service: climate.set_temperature + target: + entity_id: climate.heat_pump + data: + temperature: 22 # Boost temperature during cheap period +``` + +**Available per-period attributes:** +- `day_volatility_%`: Percentage volatility of the period's day (e.g., 8.2 for 8.2%) +- `day_price_min`: Minimum price of the day in minor currency (ct/øre) +- `day_price_max`: Maximum price of the day in minor currency (ct/øre) +- `day_price_span`: Absolute difference (max - min) in minor currency (ct/øre) + +These attributes are available on both `binary_sensor.tibber_home_best_price_period` and `binary_sensor.tibber_home_peak_price_period`. + +**Why this works:** +- Each period knows its day's volatility +- No need to query separate sensors +- Template checks if saving is meaningful (> 15% volatility) + +--- + ## Best Hour Detection Coming soon... +--- + ## ApexCharts Cards Coming soon... diff --git a/docs/user/period-calculation.md b/docs/user/period-calculation.md index 76ece8b..56c1d24 100644 --- a/docs/user/period-calculation.md +++ b/docs/user/period-calculation.md @@ -10,6 +10,9 @@ Learn how Best Price and Peak Price periods work, and how to configure them for - [Understanding Relaxation](#understanding-relaxation) - [Common Scenarios](#common-scenarios) - [Troubleshooting](#troubleshooting) + - [No Periods Found](#no-periods-found) + - [Periods Split Into Small Pieces](#periods-split-into-small-pieces) + - [Midnight Price Classification Changes](#midnight-price-classification-changes) - [Advanced Topics](#advanced-topics) --- @@ -238,6 +241,8 @@ best_price_max_level: cheap # Only show if at least one interval is CHEAP **Use case:** "Only notify me when prices are objectively cheap/expensive" +**ℹ️ Volatility Thresholds:** The level filter also supports volatility-based levels (`volatility_low`, `volatility_medium`, `volatility_high`). These use **fixed internal thresholds** (LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%) that are separate from the sensor volatility thresholds you configure in the UI. This separation ensures that changing sensor display preferences doesn't affect period calculation behavior. + #### Gap Tolerance (for Level Filter) **What:** Allow some "mediocre" intervals within an otherwise good period @@ -523,6 +528,136 @@ period_interval_smoothed_count: 2 # Number of price spikes smoothed period_interval_level_gap_count: 1 # Number of "mediocre" intervals tolerated ``` +### Midnight Price Classification Changes + +**Symptom:** A Best Price period at 23:45 suddenly changes to Peak Price at 00:00 (or vice versa), even though the absolute price barely changed. + +**Why This Happens:** + +This is **mathematically correct behavior** caused by how electricity prices are set in the day-ahead market: + +**Market Timing:** +- The EPEX SPOT Day-Ahead auction closes at **12:00 CET** each day +- **All prices** for the next day (00:00-23:45) are set at this moment +- Late-day intervals (23:45) are priced **~36 hours before delivery** +- Early-day intervals (00:00) are priced **~12 hours before delivery** + +**Why Prices Jump at Midnight:** +1. **Forecast Uncertainty:** Weather, demand, and renewable generation forecasts are more uncertain 36 hours ahead than 12 hours ahead +2. **Risk Buffer:** Late-day prices include a risk premium for this uncertainty +3. **Independent Days:** Each day has its own min/max/avg calculated from its 96 intervals +4. **Relative Classification:** Periods are classified based on their **position within the day's price range**, not absolute prices + +**Example:** + +```yaml +# Day 1 (low volatility, narrow range) +Price range: 18-22 ct/kWh (4 ct span) +Daily average: 20 ct/kWh +23:45: 18.5 ct/kWh → 7.5% below average → BEST PRICE ✅ + +# Day 2 (low volatility, narrow range) +Price range: 17-21 ct/kWh (4 ct span) +Daily average: 19 ct/kWh +00:00: 18.6 ct/kWh → 2.1% below average → PEAK PRICE ❌ + +# Observation: Absolute price barely changed (18.5 → 18.6 ct) +# But relative position changed dramatically: +# - Day 1: Near the bottom of the range +# - Day 2: Near the middle/top of the range +``` + +**When This Occurs:** +- **Low-volatility days:** When price span is narrow (< 5 ct/kWh) +- **Stable weather:** Similar conditions across multiple days +- **Market transitions:** Switching between high/low demand seasons + +**How to Detect:** + +Check the volatility sensors to understand if a period flip is meaningful: + +```yaml +# Check daily volatility (available in integration) +sensor.tibber_home_volatility_today: 8.2% # Low volatility +sensor.tibber_home_volatility_tomorrow: 7.9% # Also low + +# Low volatility (< 15%) means: +# - Small absolute price differences between periods +# - Classification changes may not be economically significant +# - Consider ignoring period classification on such days +``` + +**Handling in Automations:** + +You can make your automations volatility-aware: + +```yaml +# Option 1: Only act on high-volatility days +automation: + - alias: "Dishwasher - Best Price (High Volatility Only)" + trigger: + - platform: state + entity_id: binary_sensor.tibber_home_best_price_period + to: "on" + condition: + - condition: numeric_state + entity_id: sensor.tibber_home_volatility_today + above: 15 # Only act if volatility > 15% + action: + - service: switch.turn_on + entity_id: switch.dishwasher + +# Option 2: Check absolute price, not just classification +automation: + - alias: "Heat Water - Cheap Enough" + trigger: + - platform: state + entity_id: binary_sensor.tibber_home_best_price_period + to: "on" + condition: + - condition: numeric_state + entity_id: sensor.tibber_home_current_interval_price_ct + below: 20 # Absolute threshold: < 20 ct/kWh + action: + - service: switch.turn_on + entity_id: switch.water_heater + +# Option 3: Use per-period day volatility (available on period sensors) +automation: + - alias: "EV Charging - Volatility-Aware" + trigger: + - platform: state + entity_id: binary_sensor.tibber_home_best_price_period + to: "on" + condition: + # Check if the period's day has meaningful volatility + - condition: template + value_template: > + {{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }} + action: + - service: switch.turn_on + entity_id: switch.ev_charger +``` + +**Available Per-Period Attributes:** + +Each period sensor exposes day volatility and price statistics: + +```yaml +binary_sensor.tibber_home_best_price_period: + day_volatility_%: 8.2 # Volatility % of the period's day + day_price_min: 1800.0 # Minimum price of the day (ct/kWh) + day_price_max: 2200.0 # Maximum price of the day (ct/kWh) + day_price_span: 400.0 # Difference (max - min) in ct +``` + +These attributes allow automations to check: "Is the classification meaningful on this particular day?" + +**Summary:** +- ✅ **Expected behavior:** Periods are evaluated per-day, midnight is a natural boundary +- ✅ **Market reality:** Late-day prices have more uncertainty than early-day prices +- ✅ **Solution:** Use volatility sensors, absolute price thresholds, or per-period day volatility attributes + --- ## Advanced Topics