mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
feat(periods): add midnight-crossing periods and day volatility attributes
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.
This commit is contained in:
parent
dd12f97207
commit
47b0a298d4
7 changed files with 582 additions and 56 deletions
|
|
@ -145,6 +145,7 @@ def calculate_periods(
|
||||||
price_context = {
|
price_context = {
|
||||||
"ref_prices": ref_prices,
|
"ref_prices": ref_prices,
|
||||||
"avg_prices": avg_price_by_day,
|
"avg_prices": avg_price_by_day,
|
||||||
|
"intervals_by_day": intervals_by_day, # Needed for day volatility calculation
|
||||||
"flex": flex,
|
"flex": flex,
|
||||||
"min_distance_from_avg": min_distance_from_avg,
|
"min_distance_from_avg": min_distance_from_avg,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,10 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
||||||
"""
|
"""
|
||||||
Build periods, allowing periods to cross midnight (day boundary).
|
Build periods, allowing periods to cross midnight (day boundary).
|
||||||
|
|
||||||
Periods can span multiple days. Each period uses the reference price (min/max) from
|
Periods can span multiple days. Each interval is evaluated against the reference
|
||||||
the day when the period started, ensuring consistent filtering criteria throughout
|
price (min/max) and average price of its own day. This ensures fair filtering
|
||||||
the period even when crossing midnight.
|
criteria even when periods cross midnight, where prices can jump significantly
|
||||||
|
due to different forecasting uncertainty (prices at day end vs. day start).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
all_prices: All price data points
|
all_prices: All price data points
|
||||||
|
|
@ -105,7 +106,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
||||||
|
|
||||||
periods: list[list[dict]] = []
|
periods: list[list[dict]] = []
|
||||||
current_period: 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
|
consecutive_gaps = 0 # Track consecutive intervals that deviate by 1 level step
|
||||||
intervals_checked = 0
|
intervals_checked = 0
|
||||||
intervals_filtered_by_level = 0
|
intervals_filtered_by_level = 0
|
||||||
|
|
@ -125,11 +125,14 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
||||||
|
|
||||||
intervals_checked += 1
|
intervals_checked += 1
|
||||||
|
|
||||||
# Use reference price from period start day (for consistency across midnight)
|
# CRITICAL: Always use reference price from the interval's own day
|
||||||
# If no period active, use current interval's day
|
# Each interval must meet the criteria of its own day, not the period start day.
|
||||||
ref_date = period_start_date if period_start_date is not None else date_key
|
# 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(
|
criteria = TibberPricesIntervalCriteria(
|
||||||
ref_price=ref_prices[ref_date],
|
ref_price=ref_prices[ref_date],
|
||||||
avg_price=avg_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
|
# Add to period if all criteria are met
|
||||||
if in_flex and meets_min_distance and meets_level:
|
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(
|
current_period.append(
|
||||||
{
|
{
|
||||||
"interval_hour": starts_at.hour,
|
"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
|
# Criteria no longer met, end current period
|
||||||
periods.append(current_period)
|
periods.append(current_period)
|
||||||
current_period = []
|
current_period = []
|
||||||
period_start_date = None # Reset period start date
|
|
||||||
consecutive_gaps = 0 # Reset gap counter
|
consecutive_gaps = 0 # Reset gap counter
|
||||||
|
|
||||||
# Add final period if exists
|
# Add final period if exists
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ def build_period_summary_dict(
|
||||||
stats: TibberPricesPeriodStatistics,
|
stats: TibberPricesPeriodStatistics,
|
||||||
*,
|
*,
|
||||||
reverse_sort: bool,
|
reverse_sort: bool,
|
||||||
|
price_context: dict[str, Any] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Build the complete period summary dictionary.
|
Build the complete period summary dictionary.
|
||||||
|
|
@ -127,6 +128,7 @@ def build_period_summary_dict(
|
||||||
period_data: Period timing and position data
|
period_data: Period timing and position data
|
||||||
stats: Calculated period statistics
|
stats: Calculated period statistics
|
||||||
reverse_sort: True for peak price, False for best price (keyword-only)
|
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:
|
Returns:
|
||||||
Complete period summary dictionary following attribute ordering
|
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:
|
if stats.period_price_diff_pct is not None:
|
||||||
summary["period_price_diff_from_daily_min_%"] = stats.period_price_diff_pct
|
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
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -306,7 +332,9 @@ def extract_period_summaries(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build complete period summary
|
# 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
|
# Add smoothing information if any intervals benefited from smoothing
|
||||||
if smoothed_impactful_count > 0:
|
if smoothed_impactful_count > 0:
|
||||||
|
|
|
||||||
|
|
@ -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]]:
|
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,
|
Periods crossing midnight are assigned to ALL affected days.
|
||||||
not the day they start. Example: Period 23:00 yesterday - 02:00 today counts
|
Example: Period 23:00 yesterday - 02:00 today appears in BOTH days.
|
||||||
as "today" since it ends today.
|
|
||||||
|
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:
|
Args:
|
||||||
periods: List of period summary dicts with "start" and "end" datetime
|
periods: List of period summary dicts with "start" and "end" datetime
|
||||||
|
|
||||||
Returns:
|
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]] = {}
|
periods_by_day: dict[date, list[dict]] = {}
|
||||||
|
|
||||||
for period in periods:
|
for period in periods:
|
||||||
# Use end time for grouping so periods crossing midnight are counted
|
start_time = period.get("start")
|
||||||
# towards the day they end (more relevant for min_periods check)
|
|
||||||
end_time = period.get("end")
|
end_time = period.get("end")
|
||||||
if end_time:
|
|
||||||
day = end_time.date()
|
if not start_time or not end_time:
|
||||||
periods_by_day.setdefault(day, []).append(period)
|
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
|
return periods_by_day
|
||||||
|
|
||||||
|
|
@ -224,18 +239,10 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
|
||||||
INDENT_L0,
|
INDENT_L0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate we have price data for today/future
|
# Validate we have price data
|
||||||
today = time.now().date()
|
if not all_prices:
|
||||||
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
|
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"No price data available for today/future - cannot calculate periods",
|
"No price data available - cannot calculate periods",
|
||||||
)
|
)
|
||||||
return {"periods": [], "metadata": {}, "reference_data": {}}, {
|
return {"periods": [], "metadata": {}, "reference_data": {}}, {
|
||||||
"relaxation_active": False,
|
"relaxation_active": False,
|
||||||
|
|
@ -244,7 +251,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
|
||||||
"periods_found": 0,
|
"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)
|
prices_by_day = group_prices_by_day(all_prices, time=time)
|
||||||
total_days = len(prices_by_day)
|
total_days = len(prices_by_day)
|
||||||
|
|
||||||
|
|
@ -253,13 +260,14 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
|
||||||
total_days,
|
total_days,
|
||||||
)
|
)
|
||||||
_LOGGER.debug(
|
_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,
|
INDENT_L1,
|
||||||
len(future_prices),
|
len(all_prices),
|
||||||
)
|
)
|
||||||
|
|
||||||
# === BASELINE CALCULATION (process ALL prices together) ===
|
# === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
|
||||||
baseline_result = calculate_periods(future_prices, config=config, time=time)
|
# 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"]
|
all_periods = baseline_result["periods"]
|
||||||
|
|
||||||
# Count periods per day for min_periods check
|
# 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
|
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(
|
relaxed_result, relax_metadata = relax_all_prices(
|
||||||
all_prices=future_prices,
|
all_prices=all_prices,
|
||||||
config=config,
|
config=config,
|
||||||
min_periods=min_periods,
|
min_periods=min_periods,
|
||||||
max_relaxation_attempts=max_relaxation_attempts,
|
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.
|
Relax filters for all prices until min_periods per day is reached.
|
||||||
|
|
||||||
Strategy: Try increasing flex by 3% increments, then relax level filter.
|
Strategy: Try increasing flex by 3% increments, then relax level filter.
|
||||||
Processes all prices together, allowing periods to cross midnight boundaries.
|
Processes all prices together (yesterday+today+tomorrow), allowing periods
|
||||||
Returns when ALL days have min_periods (or max attempts exhausted).
|
to cross midnight boundaries. Returns when ALL days have min_periods
|
||||||
|
(or max attempts exhausted).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
all_prices: All price intervals (today + future)
|
all_prices: All price intervals (yesterday+today+tomorrow)
|
||||||
config: Base period configuration
|
config: Base period configuration
|
||||||
min_periods: Target number of periods PER DAY
|
min_periods: Target number of periods PER DAY
|
||||||
max_relaxation_attempts: Maximum flex levels to try
|
max_relaxation_attempts: Maximum flex levels to try
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,42 @@ meets_distance = price >= (daily_avg × (1 + min_distance/100))
|
||||||
|
|
||||||
**Logic:** See `level_filtering.py` for gap tolerance details.
|
**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
|
## 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
|
**Current Behavior:** Periods can cross midnight naturally
|
||||||
|
|
||||||
**Issue:** Period starting 23:45 continues into next day
|
**Design Principle:** Each interval is evaluated using its **own day's** reference prices (daily min/max/avg).
|
||||||
- Uses Day 1's daily_min as reference
|
|
||||||
- May be confusing when Day 2's prices very different
|
|
||||||
|
|
||||||
**Alternative Approaches Considered:**
|
**Implementation:**
|
||||||
1. **Split at midnight** - Always keep periods within calendar day
|
```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
|
- Problem: Artificially fragments natural periods
|
||||||
- Rejected: Worse user experience
|
- Rejected: Worse user experience
|
||||||
|
|
||||||
2. **Use next day's reference** - Switch reference at midnight
|
4. **Use next day's reference after midnight**
|
||||||
- Problem: Period criteria inconsistent across its duration
|
- Problem: Period criteria inconsistent across duration
|
||||||
- Rejected: Confusing and unpredictable
|
- Rejected: Confusing and unpredictable
|
||||||
|
|
||||||
3. **Current approach** - Lock to start day's reference
|
**Status:** Per-day evaluation is intentional design prioritizing mathematical correctness.
|
||||||
- Benefit: Consistent criteria throughout period
|
|
||||||
- Drawback: Period may "spill" into different price context
|
|
||||||
|
|
||||||
**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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)**.
|
> **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
|
## Price-Based Automations
|
||||||
|
|
||||||
Coming soon...
|
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
|
## Best Hour Detection
|
||||||
|
|
||||||
Coming soon...
|
Coming soon...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ApexCharts Cards
|
## ApexCharts Cards
|
||||||
|
|
||||||
Coming soon...
|
Coming soon...
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ Learn how Best Price and Peak Price periods work, and how to configure them for
|
||||||
- [Understanding Relaxation](#understanding-relaxation)
|
- [Understanding Relaxation](#understanding-relaxation)
|
||||||
- [Common Scenarios](#common-scenarios)
|
- [Common Scenarios](#common-scenarios)
|
||||||
- [Troubleshooting](#troubleshooting)
|
- [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)
|
- [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"
|
**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)
|
#### Gap Tolerance (for Level Filter)
|
||||||
|
|
||||||
**What:** Allow some "mediocre" intervals within an otherwise good period
|
**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
|
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
|
## Advanced Topics
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue