mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-30 13:23:41 +00:00
BREAKING CHANGE: Period overlap resolution now merges adjacent/overlapping periods
instead of marking them as extensions. This simplifies automation logic and provides
clearer period boundaries for users.
Previous Behavior:
- Adjacent periods created by relaxation were marked with is_extension=true
- Multiple short periods instead of one continuous period
- Complex logic needed to determine actual period length in automations
New Behavior:
- Adjacent/overlapping periods are merged into single continuous periods
- Newer period's relaxation attributes override older period's
- Simpler automation: one period = one continuous time window
Changes:
- Period Overlap Resolution (new file: period_overlap.py):
* Added merge_adjacent_periods() to combine periods and preserve attributes
* Rewrote resolve_period_overlaps() with simplified merge logic
* Removed split_period_by_overlaps() (no longer needed)
* Removed is_extension marking logic
* Removed unused parameters: min_period_length, baseline_periods
- Relaxation Strategy (relaxation.py):
* Removed all is_extension filtering from period counting
* Simplified standalone counting to just len(periods)
* Changed from period_merging import to period_overlap import
* Added MAX_FLEX_HARD_LIMIT constant (0.50)
* Improved debug logging for merged periods
- Code Quality:
* Fixed all remaining linter errors (N806, PLR2004, PLR0912)
* Extracted magic values to module-level constants:
- FLEX_SCALING_THRESHOLD = 0.20
- SCALE_FACTOR_WARNING_THRESHOLD = 0.8
- MAX_FLEX_HARD_LIMIT = 0.50
* Added appropriate noqa comments for unavoidable patterns
- Configuration (from previous work in this session):
* Removed CONF_RELAXATION_STEP_BEST, CONF_RELAXATION_STEP_PEAK
* Hard-coded 3% relaxation increment for reliability
* Optimized defaults: RELAXATION_ATTEMPTS 8→11, ENABLE_MIN_PERIODS False→True,
MIN_PERIODS undefined→2
* Removed relaxation_step UI fields from config flow
* Updated all 5 translation files
- Documentation:
* Updated period_handlers/__init__.py: period_merging → period_overlap
* No user-facing docs changes needed (already described continuous periods)
Rationale - Period Merging:
User experience was complicated by fragmented periods:
- Automations had to check multiple adjacent periods
- Binary sensors showed ON/OFF transitions within same cheap time
- No clear way to determine actual continuous period length
With merging:
- One continuous cheap time = one period
- Binary sensor clearly ON during entire period
- Attributes show merge history via merged_from dict
- Relaxation info preserved from newest/highest flex period
Rationale - Hard-Coded Relaxation Increment:
The configurable relaxation_step parameter proved problematic:
- High base flex + high step → rapid explosion (40% base + 10% step → 100% in 6 steps)
- Users don't understand the multiplicative nature
- 3% increment provides optimal balance: 11 attempts to reach 50% hard cap
Impact:
- Existing installations: Periods may appear longer (merged instead of split)
- Automations benefit from simpler logic (no is_extension checks needed)
- Custom relaxation_step values will use new 3% increment
- Users may need to adjust relaxation_attempts if they relied on high step sizes
441 lines
12 KiB
Markdown
441 lines
12 KiB
Markdown
# Period Calculation Theory
|
||
|
||
## Overview
|
||
|
||
This document explains the mathematical foundations and design decisions behind the period calculation algorithm, particularly focusing on the interaction between **Flexibility (Flex)**, **Minimum Distance from Average**, and **Relaxation Strategy**.
|
||
|
||
**Target Audience:** Developers maintaining or extending the period calculation logic.
|
||
|
||
**Related Files:**
|
||
- `coordinator/period_handlers/core.py` - Main calculation entry point
|
||
- `coordinator/period_handlers/level_filtering.py` - Flex and distance filtering
|
||
- `coordinator/period_handlers/relaxation.py` - Multi-phase relaxation strategy
|
||
- `coordinator/periods.py` - Period calculator orchestration
|
||
|
||
---
|
||
|
||
## Core Filtering Criteria
|
||
|
||
Period detection uses **three independent filters** (all must pass):
|
||
|
||
### 1. Flex Filter (Price Distance from Reference)
|
||
|
||
**Purpose:** Limit how far prices can deviate from the daily min/max.
|
||
|
||
**Logic:**
|
||
```python
|
||
# Best Price: Price must be within flex% ABOVE daily minimum
|
||
in_flex = price <= (daily_min + daily_min × flex)
|
||
|
||
# Peak Price: Price must be within flex% BELOW daily maximum
|
||
in_flex = price >= (daily_max - daily_max × flex)
|
||
```
|
||
|
||
**Example (Best Price):**
|
||
- Daily Min: 10 ct/kWh
|
||
- Flex: 15%
|
||
- Acceptance Range: 0 - 11.5 ct/kWh (10 + 10×0.15)
|
||
|
||
### 2. Min Distance Filter (Distance from Daily Average)
|
||
|
||
**Purpose:** Ensure periods are **significantly** cheaper/more expensive than average, not just marginally better.
|
||
|
||
**Logic:**
|
||
```python
|
||
# Best Price: Price must be at least min_distance% BELOW daily average
|
||
meets_distance = price <= (daily_avg × (1 - min_distance/100))
|
||
|
||
# Peak Price: Price must be at least min_distance% ABOVE daily average
|
||
meets_distance = price >= (daily_avg × (1 + min_distance/100))
|
||
```
|
||
|
||
**Example (Best Price):**
|
||
- Daily Avg: 15 ct/kWh
|
||
- Min Distance: 5%
|
||
- Acceptance Range: 0 - 14.25 ct/kWh (15 × 0.95)
|
||
|
||
### 3. Level Filter (Price Level Classification)
|
||
|
||
**Purpose:** Restrict periods to specific price classifications (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE).
|
||
|
||
**Logic:** See `level_filtering.py` for gap tolerance details.
|
||
|
||
---
|
||
|
||
## The Flex × Min_Distance Conflict
|
||
|
||
### Problem Statement
|
||
|
||
**These two filters can conflict when Flex is high!**
|
||
|
||
#### Scenario: Best Price with Flex=50%, Min_Distance=5%
|
||
|
||
**Given:**
|
||
- Daily Min: 10 ct/kWh
|
||
- Daily Avg: 15 ct/kWh
|
||
- Daily Max: 20 ct/kWh
|
||
|
||
**Flex Filter (50%):**
|
||
```
|
||
Max accepted = 10 + (10 × 0.50) = 15 ct/kWh
|
||
```
|
||
|
||
**Min Distance Filter (5%):**
|
||
```
|
||
Max accepted = 15 × (1 - 0.05) = 14.25 ct/kWh
|
||
```
|
||
|
||
**Conflict:**
|
||
- Interval at 14.8 ct/kWh:
|
||
- ✅ Flex: 14.8 ≤ 15 (PASS)
|
||
- ❌ Distance: 14.8 > 14.25 (FAIL)
|
||
- **Result:** Rejected by Min_Distance even though Flex allows it!
|
||
|
||
**The Issue:** At high Flex values, Min_Distance becomes the dominant filter and blocks intervals that Flex would permit. This defeats the purpose of having high Flex.
|
||
|
||
### Mathematical Analysis
|
||
|
||
**Conflict condition for Best Price:**
|
||
```
|
||
daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
|
||
```
|
||
|
||
**Typical values:**
|
||
- Min = 10, Avg = 15, Min_Distance = 5%
|
||
- Conflict occurs when: `10 × (1 + flex) > 14.25`
|
||
- Simplify: `flex > 0.425` (42.5%)
|
||
|
||
**Below 42.5% Flex:** Both filters contribute meaningfully.
|
||
**Above 42.5% Flex:** Min_Distance dominates and blocks intervals.
|
||
|
||
### Solution: Dynamic Min_Distance Scaling
|
||
|
||
**Approach:** Reduce Min_Distance proportionally as Flex increases.
|
||
|
||
**Formula:**
|
||
```python
|
||
if flex > 0.20: # 20% threshold
|
||
flex_excess = flex - 0.20
|
||
scale_factor = max(0.25, 1.0 - (flex_excess × 2.5))
|
||
adjusted_min_distance = original_min_distance × scale_factor
|
||
```
|
||
|
||
**Scaling Table (Original Min_Distance = 5%):**
|
||
|
||
| Flex | Scale Factor | Adjusted Min_Distance | Rationale |
|
||
|-------|--------------|----------------------|-----------|
|
||
| ≤20% | 1.00 | 5.0% | Standard - both filters relevant |
|
||
| 25% | 0.88 | 4.4% | Slight reduction |
|
||
| 30% | 0.75 | 3.75% | Moderate reduction |
|
||
| 40% | 0.50 | 2.5% | Strong reduction - Flex dominates |
|
||
| 50% | 0.25 | 1.25% | Minimal distance - Flex decides |
|
||
|
||
**Why stop at 25% of original?**
|
||
- Min_Distance ensures periods are **significantly** different from average
|
||
- Even at 1.25%, prevents "flat days" (little price variation) from accepting every interval
|
||
- Maintains semantic meaning: "this is a meaningful best/peak price period"
|
||
|
||
**Implementation:** See `level_filtering.py` → `check_interval_criteria()`
|
||
|
||
---
|
||
|
||
## Flex Limits and Safety Caps
|
||
|
||
### Hard Limits (Enforced in Code)
|
||
|
||
#### 1. Absolute Maximum: 50%
|
||
|
||
**Enforcement:** `core.py` caps `abs(flex)` at 0.50 (50%)
|
||
|
||
**Rationale:**
|
||
- Above 50%, period detection becomes unreliable
|
||
- Best Price: Almost entire day qualifies (Min + 50% typically covers 60-80% of intervals)
|
||
- Peak Price: Similar issue with Max - 50%
|
||
- **Result:** Either massive periods (entire day) or no periods (min_length not met)
|
||
|
||
**Warning Message:**
|
||
```
|
||
Flex XX% exceeds maximum safe value! Capping at 50%.
|
||
Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation.
|
||
```
|
||
|
||
#### 2. Outlier Filtering Maximum: 25%
|
||
|
||
**Enforcement:** `core.py` caps outlier filtering flex at 0.25 (25%)
|
||
|
||
**Rationale:**
|
||
- Outlier filtering uses Flex to determine "stable context" threshold
|
||
- At > 25% Flex, almost any price swing is considered "stable"
|
||
- **Result:** Legitimate price shifts aren't smoothed, breaking period formation
|
||
|
||
**Note:** User's Flex still applies to period criteria (`in_flex` check), only outlier filtering is capped.
|
||
|
||
### Recommended Ranges (User Guidance)
|
||
|
||
#### With Relaxation Enabled (Recommended)
|
||
|
||
**Optimal:** 10-20%
|
||
- Relaxation increases Flex incrementally: 15% → 18% → 21% → ...
|
||
- Low baseline ensures relaxation has room to work
|
||
|
||
**Warning Threshold:** > 25%
|
||
- INFO log: "Base flex is on the high side"
|
||
|
||
**High Warning:** > 30%
|
||
- WARNING log: "Base flex is very high for relaxation mode!"
|
||
- Recommendation: Lower to 15-20%
|
||
|
||
#### Without Relaxation
|
||
|
||
**Optimal:** 20-35%
|
||
- No automatic adjustment, must be sufficient from start
|
||
- Higher baseline acceptable since no relaxation fallback
|
||
|
||
**Maximum Useful:** ~50%
|
||
- Above this, period detection degrades (see Hard Limits)
|
||
|
||
---
|
||
|
||
## Relaxation Strategy
|
||
|
||
### Purpose
|
||
|
||
Ensure **minimum periods per day** are found even when baseline filters are too strict.
|
||
|
||
**Use Case:** User configures strict filters (low Flex, restrictive Level) but wants guarantee of N periods/day for automation reliability.
|
||
|
||
### Multi-Phase Approach
|
||
|
||
**Each day processed independently:**
|
||
1. Calculate baseline periods with user's config
|
||
2. If insufficient periods found, enter relaxation loop
|
||
3. Try progressively relaxed filter combinations
|
||
4. Stop when target reached or all attempts exhausted
|
||
|
||
### Relaxation Increments
|
||
|
||
**Problem (Before Fix):**
|
||
```python
|
||
# OLD: Increment scales with base Flex
|
||
increment = base_flex × (step_pct / 100)
|
||
|
||
# Example: base_flex=40%, step_pct=25%
|
||
increment = 0.40 × 0.25 = 0.10 (10% per step!)
|
||
# After 6 steps: 40% → 50% → 60% → 70% → 80% → 90% → 100% (explosion!)
|
||
```
|
||
|
||
**Solution (Current):**
|
||
```python
|
||
# NEW: Cap increment at 3% per step
|
||
raw_increment = base_flex × (step_pct / 100)
|
||
capped_increment = min(raw_increment, 0.03) # 3% maximum
|
||
|
||
# Example: base_flex=40%, step_pct=25%
|
||
increment = min(0.10, 0.03) = 0.03 (3% per step)
|
||
# After 8 steps: 40% → 43% → 46% → 49% → 52% → 55% → 58% → 61% (controlled!)
|
||
```
|
||
|
||
**Rationale:**
|
||
- High base Flex (30%+) already very permissive
|
||
- Large increments push toward 100% too quickly
|
||
- 100% Flex = accept ALL prices (meaningless periods)
|
||
|
||
**Warning Threshold:**
|
||
- If base Flex > 30% with relaxation enabled: Warn user to lower base Flex
|
||
|
||
### Filter Combination Strategy
|
||
|
||
**Per Flex level, try in order:**
|
||
1. Original Level filter
|
||
2. Level filter = "any" (disabled)
|
||
|
||
**Early Exit:** Stop immediately when target reached (don't try unnecessary combinations)
|
||
|
||
**Example Flow (target=2 periods/day):**
|
||
```
|
||
Day 2025-11-19:
|
||
1. Baseline flex=15%: Found 1 period (need 2)
|
||
2. Flex=18% + level=cheap: Found 1 period
|
||
3. Flex=18% + level=any: Found 2 periods → SUCCESS (stop)
|
||
```
|
||
|
||
---
|
||
|
||
## Implementation Notes
|
||
|
||
### Key Files and Functions
|
||
|
||
**Period Calculation Entry Point:**
|
||
```python
|
||
# coordinator/period_handlers/core.py
|
||
def calculate_periods(
|
||
all_prices: list[dict],
|
||
config: PeriodConfig,
|
||
time: TimeService,
|
||
) -> dict[str, Any]
|
||
```
|
||
|
||
**Flex + Distance Filtering:**
|
||
```python
|
||
# coordinator/period_handlers/level_filtering.py
|
||
def check_interval_criteria(
|
||
price: float,
|
||
criteria: IntervalCriteria,
|
||
) -> tuple[bool, bool] # (in_flex, meets_min_distance)
|
||
```
|
||
|
||
**Relaxation Orchestration:**
|
||
```python
|
||
# coordinator/period_handlers/relaxation.py
|
||
def calculate_periods_with_relaxation(...) -> tuple[dict, dict]
|
||
def relax_single_day(...) -> tuple[dict, dict]
|
||
```
|
||
|
||
### Debugging Tips
|
||
|
||
**Enable DEBUG logging:**
|
||
```yaml
|
||
# configuration.yaml
|
||
logger:
|
||
default: info
|
||
logs:
|
||
custom_components.tibber_prices.coordinator.period_handlers: debug
|
||
```
|
||
|
||
**Key log messages to watch:**
|
||
1. `"Filter statistics: X intervals checked"` - Shows how many intervals filtered by each criterion
|
||
2. `"After build_periods: X raw periods found"` - Periods before min_length filtering
|
||
3. `"Day X: Success with flex=Y%"` - Relaxation succeeded
|
||
4. `"High flex X% detected: Reducing min_distance Y% → Z%"` - Distance scaling active
|
||
|
||
---
|
||
|
||
## Common Configuration Pitfalls
|
||
|
||
### ❌ Anti-Pattern 1: High Flex with Relaxation
|
||
|
||
**Configuration:**
|
||
```yaml
|
||
best_price_flex: 40
|
||
enable_relaxation_best: true
|
||
```
|
||
|
||
**Problem:**
|
||
- Base Flex 40% already very permissive
|
||
- Relaxation increments further (43%, 46%, 49%, ...)
|
||
- Quickly approaches 50% cap with diminishing returns
|
||
|
||
**Solution:**
|
||
```yaml
|
||
best_price_flex: 15 # Let relaxation increase it
|
||
enable_relaxation_best: true
|
||
```
|
||
|
||
### ❌ Anti-Pattern 2: Zero Min_Distance
|
||
|
||
**Configuration:**
|
||
```yaml
|
||
best_price_min_distance_from_avg: 0
|
||
```
|
||
|
||
**Problem:**
|
||
- "Flat days" (little price variation) accept all intervals
|
||
- Periods lose semantic meaning ("significantly cheap")
|
||
- May create periods during barely-below-average times
|
||
|
||
**Solution:**
|
||
```yaml
|
||
best_price_min_distance_from_avg: 5 # Keep at least 5%
|
||
```
|
||
|
||
### ❌ Anti-Pattern 3: Conflicting Flex + Distance
|
||
|
||
**Configuration:**
|
||
```yaml
|
||
best_price_flex: 45
|
||
best_price_min_distance_from_avg: 10
|
||
```
|
||
|
||
**Problem:**
|
||
- Distance filter dominates, making Flex irrelevant
|
||
- Dynamic scaling helps but still suboptimal
|
||
|
||
**Solution:**
|
||
```yaml
|
||
best_price_flex: 20
|
||
best_price_min_distance_from_avg: 5
|
||
```
|
||
|
||
---
|
||
|
||
## Testing Scenarios
|
||
|
||
### Scenario 1: Normal Day (Good Variation)
|
||
|
||
**Price Range:** 10 - 20 ct/kWh (100% variation)
|
||
**Average:** 15 ct/kWh
|
||
|
||
**Expected Behavior:**
|
||
- Flex 15%: Should find 2-4 clear best price periods
|
||
- Flex 30%: Should find 4-8 periods (more lenient)
|
||
- Min_Distance 5%: Effective throughout range
|
||
|
||
### Scenario 2: Flat Day (Poor Variation)
|
||
|
||
**Price Range:** 14 - 16 ct/kWh (14% variation)
|
||
**Average:** 15 ct/kWh
|
||
|
||
**Expected Behavior:**
|
||
- Flex 15%: May find 1-2 small periods (or zero if no clear winners)
|
||
- Min_Distance 5%: Critical here - ensures only truly cheaper intervals qualify
|
||
- Without Min_Distance: Would accept almost entire day as "best price"
|
||
|
||
### Scenario 3: Extreme Day (High Volatility)
|
||
|
||
**Price Range:** 5 - 40 ct/kWh (700% variation)
|
||
**Average:** 18 ct/kWh
|
||
|
||
**Expected Behavior:**
|
||
- Flex 15%: Finds multiple very cheap periods (5-6 ct)
|
||
- Outlier filtering: May smooth isolated spikes (30-40 ct)
|
||
- Distance filter: Less impactful (clear separation between cheap/expensive)
|
||
|
||
---
|
||
|
||
## Future Enhancements
|
||
|
||
### Potential Improvements
|
||
|
||
1. **Adaptive Flex Calculation:**
|
||
- Auto-adjust Flex based on daily price variation
|
||
- High variation days: Lower Flex needed
|
||
- Low variation days: Higher Flex needed
|
||
|
||
2. **Machine Learning Approach:**
|
||
- Learn optimal Flex/Distance from user feedback
|
||
- Classify days by pattern (normal/flat/volatile/bimodal)
|
||
- Apply pattern-specific defaults
|
||
|
||
3. **Multi-Objective Optimization:**
|
||
- Balance period count vs. quality
|
||
- Consider period duration vs. price level
|
||
- Optimize for user's stated use case (EV charging vs. heat pump)
|
||
|
||
### Known Limitations
|
||
|
||
1. **Fixed increment step:** 3% cap may be too aggressive for very low base Flex
|
||
2. **Linear distance scaling:** Could benefit from non-linear curve
|
||
3. **No consideration of temporal distribution:** May find all periods in one part of day
|
||
|
||
---
|
||
|
||
## References
|
||
|
||
- [User Documentation: Period Calculation](../user/period-calculation.md)
|
||
- [Architecture Overview](./architecture.md)
|
||
- [Caching Strategy](./caching-strategy.md)
|
||
- [AGENTS.md](../../AGENTS.md) - AI assistant memory (implementation patterns)
|
||
|
||
## Changelog
|
||
|
||
- **2025-11-19**: Initial documentation of Flex/Distance interaction and Relaxation strategy fixes
|