fix(periods): replace redundant total attributes with per-day counts
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run

Removed periods_found_total and replaced with period_count_today /
period_count_tomorrow. The old attribute counted all periods including
yesterday (coordinator scope), causing a discrepancy vs. the displayed
list (sensor scope, today+tomorrow only).

Renamed periods_total → period_count_total for naming consistency with
the new per-day attributes. Recalculate period_position / period_count_total /
periods_remaining after the today+tomorrow filter so all three navigation
attributes reflect the filtered scope.

period_count_tomorrow is always present (0 when no tomorrow data or no
periods found), enabling automations without default(0) guards.

Removed internal periods_found key from relaxation metadata — it was
only consumed by add_calculation_summary_attributes which is now removed.

BREAKING CHANGE: periods_found_total removed (replace with
period_count_today + period_count_tomorrow). periods_total renamed to
period_count_total.

Impact: Period navigation attributes (position/total/remaining) now
correctly reflect today+tomorrow scope. Per-day counts allow automations
to distinguish "2 periods today, 0 tomorrow" from "1+1".
This commit is contained in:
Julian Pawlowski 2026-04-12 09:50:31 +00:00
parent 9e1ba10f0b
commit 779e22a84e
11 changed files with 46 additions and 44 deletions

View file

@ -119,6 +119,19 @@ def get_price_intervals_attributes(
if not filtered_periods: if not filtered_periods:
return build_no_periods_result(time=time) return build_no_periods_result(time=time)
# Recalculate position metadata after filtering (coordinator stamped values include yesterday)
# Use shallow copies so coordinator dicts are not mutated
total_filtered = len(filtered_periods)
filtered_periods = [
period
| {
"period_position": i,
"period_count_total": total_filtered,
"periods_remaining": total_filtered - i,
}
for i, period in enumerate(filtered_periods, 1)
]
# Find current or next period based on current time # Find current or next period based on current time
current_period = None current_period = None
@ -251,8 +264,8 @@ def add_detail_attributes(attributes: dict, current_period: dict) -> None:
attributes["period_interval_count"] = current_period["period_interval_count"] attributes["period_interval_count"] = current_period["period_interval_count"]
if "period_position" in current_period: if "period_position" in current_period:
attributes["period_position"] = current_period["period_position"] attributes["period_position"] = current_period["period_position"]
if "periods_total" in current_period: if "period_count_total" in current_period:
attributes["periods_total"] = current_period["periods_total"] attributes["period_count_total"] = current_period["period_count_total"]
if "periods_remaining" in current_period: if "periods_remaining" in current_period:
attributes["periods_remaining"] = current_period["periods_remaining"] attributes["periods_remaining"] = current_period["periods_remaining"]
@ -298,7 +311,6 @@ def add_period_count_attributes(
count_tomorrow += 1 count_tomorrow += 1
_ = now # used for clarity only _ = now # used for clarity only
if count_today > 0 or count_tomorrow > 0:
attributes["period_count_today"] = count_today attributes["period_count_today"] = count_today
attributes["period_count_tomorrow"] = count_tomorrow attributes["period_count_tomorrow"] = count_tomorrow
@ -324,13 +336,11 @@ def add_calculation_summary_attributes(attributes: dict, period_metadata: dict)
""" """
Add calculation summary attributes (priority 7). Add calculation summary attributes (priority 7).
Provides diagnostic visibility into the period calculation: how many periods Provides diagnostic visibility into the period calculation: whether any flat days
were requested vs. found, whether any flat days triggered adaptive min_periods, triggered adaptive min_periods, and whether relaxation could not satisfy all days.
and whether relaxation could not satisfy all days.
Only adds non-default/interesting values to avoid clutter: Only adds non-default/interesting values to avoid clutter:
- min_periods_configured: always added (useful reference for automations) - min_periods_configured: always added (useful reference for automations)
- periods_found_total: always added
- flat_days_detected: only when > 0 (explains why fewer periods than configured) - flat_days_detected: only when > 0 (explains why fewer periods than configured)
- relaxation_incomplete: only when True (diagnostic flag for troubleshooting) - relaxation_incomplete: only when True (diagnostic flag for troubleshooting)
@ -342,9 +352,6 @@ def add_calculation_summary_attributes(attributes: dict, period_metadata: dict)
if "min_periods_requested" in relaxation_meta: if "min_periods_requested" in relaxation_meta:
attributes["min_periods_configured"] = relaxation_meta["min_periods_requested"] attributes["min_periods_configured"] = relaxation_meta["min_periods_requested"]
if "periods_found" in relaxation_meta:
attributes["periods_found_total"] = relaxation_meta["periods_found"]
flat_days = relaxation_meta.get("flat_days_detected", 0) flat_days = relaxation_meta.get("flat_days_detected", 0)
if flat_days > 0: if flat_days > 0:
attributes["flat_days_detected"] = flat_days attributes["flat_days_detected"] = flat_days
@ -414,10 +421,10 @@ def build_final_attributes_simple(
2. Core decision attributes (level, rating_level, rating_difference_%) 2. Core decision attributes (level, rating_level, rating_difference_%)
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility) 3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%) 4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
5. Detail information (period_interval_count, period_position, periods_total, periods_remaining) 5. Detail information (period_interval_count, period_position, period_count_total, periods_remaining)
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%, 6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
relaxation_threshold_applied_%) - only if current period was relaxed relaxation_threshold_applied_%) - only if current period was relaxed
7. Calculation summary (min_periods_configured, periods_found_total, flat_days_detected, 7. Calculation summary (min_periods_configured, flat_days_detected,
relaxation_incomplete) - diagnostic info about the overall calculation relaxation_incomplete) - diagnostic info about the overall calculation
8. Meta information (periods list) 8. Meta information (periods list)
@ -464,7 +471,7 @@ def build_final_attributes_simple(
# 6. Relaxation information (only if current period was relaxed) # 6. Relaxation information (only if current period was relaxed)
add_relaxation_attributes(attributes, current_period) add_relaxation_attributes(attributes, current_period)
# 7. Calculation summary (diagnostic: min_periods_configured, periods_found_total, etc.) # 7. Calculation summary (diagnostic: min_periods_configured, flat_days_detected, etc.)
if period_metadata: if period_metadata:
add_calculation_summary_attributes(attributes, period_metadata) add_calculation_summary_attributes(attributes, period_metadata)

View file

@ -64,7 +64,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
"relaxation_threshold_applied_%", "relaxation_threshold_applied_%",
# Calculation Summary (diagnostic, changes daily → not useful in history) # Calculation Summary (diagnostic, changes daily → not useful in history)
"min_periods_configured", "min_periods_configured",
"periods_found_total",
"flat_days_detected", "flat_days_detected",
"relaxation_incomplete", "relaxation_incomplete",
# Redundant/Derived # Redundant/Derived
@ -73,7 +72,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
"rating_difference_%", "rating_difference_%",
"period_price_diff_from_daily_min", "period_price_diff_from_daily_min",
"period_price_diff_from_daily_min_%", "period_price_diff_from_daily_min_%",
"periods_total", "period_count_total",
"periods_remaining", "periods_remaining",
} }
) )

View file

@ -104,7 +104,7 @@ class PeriodSummary(TypedDict, total=False):
# Detail information (priority 5) # Detail information (priority 5)
period_interval_count: int # Number of intervals in period period_interval_count: int # Number of intervals in period
period_position: int # Period position (1-based) period_position: int # Period position (1-based)
periods_total: int # Total number of periods period_count_total: int # Total number of periods
periods_remaining: int # Remaining periods after this one periods_remaining: int # Remaining periods after this one
# Relaxation information (priority 6 - only if period was relaxed) # Relaxation information (priority 6 - only if period was relaxed)
@ -125,7 +125,7 @@ class PeriodAttributes(BaseAttributes, total=False):
2. Core decision attributes (level, rating_level, rating_difference_%) 2. Core decision attributes (level, rating_level, rating_difference_%)
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility) 3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
4. Price comparison (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%) 4. Price comparison (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
5. Detail information (period_interval_count, period_position, periods_total, periods_remaining) 5. Detail information (period_interval_count, period_position, period_count_total, periods_remaining)
6. Relaxation information (only if period was relaxed) 6. Relaxation information (only if period was relaxed)
7. Meta information (periods list) 7. Meta information (periods list)
""" """
@ -155,7 +155,7 @@ class PeriodAttributes(BaseAttributes, total=False):
# Detail information (priority 5) # Detail information (priority 5)
period_interval_count: int # Number of intervals in current/next period period_interval_count: int # Number of intervals in current/next period
period_position: int # Period position (1-based) period_position: int # Period position (1-based)
periods_total: int # Total number of periods found period_count_total: int # Total number of periods found
periods_remaining: int # Remaining periods after current/next one periods_remaining: int # Remaining periods after current/next one
# Relaxation information (priority 6 - only if period was relaxed) # Relaxation information (priority 6 - only if period was relaxed)

View file

@ -56,7 +56,7 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
""" """
Recalculate period metadata after merging periods. Recalculate period metadata after merging periods.
Updates period_position, periods_total, and periods_remaining for all periods Updates period_position, period_count_total, and periods_remaining for all periods
based on chronological order. based on chronological order.
This must be called after resolve_period_overlaps() to ensure metadata This must be called after resolve_period_overlaps() to ensure metadata
@ -78,7 +78,7 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
for position, period in enumerate(periods, 1): for position, period in enumerate(periods, 1):
period["period_position"] = position period["period_position"] = position
period["periods_total"] = total_periods period["period_count_total"] = total_periods
period["periods_remaining"] = total_periods - position period["periods_remaining"] = total_periods - position

View file

@ -178,7 +178,7 @@ def build_period_summary_dict(
# 5. Detail information (additional context) # 5. Detail information (additional context)
"period_interval_count": period_data.period_length, "period_interval_count": period_data.period_length,
"period_position": period_data.period_idx, "period_position": period_data.period_idx,
"periods_total": period_data.total_periods, "period_count_total": period_data.total_periods,
"periods_remaining": period_data.total_periods - period_data.period_idx, "periods_remaining": period_data.total_periods - period_data.period_idx,
} }

View file

@ -639,7 +639,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
"relaxation_active": False, "relaxation_active": False,
"relaxation_attempted": False, "relaxation_attempted": False,
"min_periods_requested": min_periods if enable_relaxation else 0, "min_periods_requested": min_periods if enable_relaxation else 0,
"periods_found": 0,
}, },
}, },
"reference_data": {}, "reference_data": {},
@ -840,8 +839,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
final_result = baseline_result.copy() final_result = baseline_result.copy()
final_result["periods"] = all_periods final_result["periods"] = all_periods
total_periods = len(all_periods)
# Add relaxation info to metadata # Add relaxation info to metadata
if "metadata" not in final_result: if "metadata" not in final_result:
final_result["metadata"] = {} final_result["metadata"] = {}
@ -849,7 +846,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
"relaxation_active": relaxation_was_needed, "relaxation_active": relaxation_was_needed,
"relaxation_attempted": relaxation_was_needed, "relaxation_attempted": relaxation_was_needed,
"min_periods_requested": min_periods, "min_periods_requested": min_periods,
"periods_found": total_periods,
"phases_used": list(set(all_phases_used)), # Unique phases used across all days "phases_used": list(set(all_phases_used)), # Unique phases used across all days
"days_processed": total_days, "days_processed": total_days,
"days_meeting_requirement": days_meeting_requirement, "days_meeting_requirement": days_meeting_requirement,
@ -1023,5 +1019,4 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
return final_result, { return final_result, {
"phases_used": phases_used, "phases_used": phases_used,
"periods_found": len(existing_periods),
} }

View file

@ -172,7 +172,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
"rating_difference_%", "rating_difference_%",
"period_price_diff_from_daily_min", "period_price_diff_from_daily_min",
"period_price_diff_from_daily_min_%", "period_price_diff_from_daily_min_%",
"periods_total", "period_count_total",
"periods_remaining", "periods_remaining",
} }
) )

View file

@ -834,8 +834,7 @@ INFO: Day 2025-11-11: Baseline satisfied (1 period, effective minimum is 1)
**Sensor Attributes:** **Sensor Attributes:**
```yaml ```yaml
min_periods_configured: 2 # User's setting min_periods_configured: 2 # User's setting
periods_found_total: 1 # Actual result flat_days_detected: 1 # Explains why only 1 period found
flat_days_detected: 1 # Explains the difference
``` ```
**Why not for Peak Price?** **Why not for Peak Price?**
@ -957,7 +956,6 @@ When debugging period calculation issues:
| Attribute | Type | When shown | Meaning | | Attribute | Type | When shown | Meaning |
|---|---|---|---| |---|---|---|---|
| `min_periods_configured` | int | Always | User's configured target per day | | `min_periods_configured` | int | Always | User's configured target per day |
| `periods_found_total` | int | Always | Actual periods found across all days |
| `flat_days_detected` | int | Only when > 0 | Days where CV ≤ 10% reduced target to 1 | | `flat_days_detected` | int | Only when > 0 | Days where CV ≤ 10% reduced target to 1 |
| `relaxation_incomplete` | bool | Only when true | Relaxation exhausted, target not reached | | `relaxation_incomplete` | bool | Only when true | Relaxation exhausted, target not reached |
| `relaxation_active` | bool | Only when true | This specific period needed relaxed filters | | `relaxation_active` | bool | Only when true | This specific period needed relaxed filters |

View file

@ -137,7 +137,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
### 7. Redundant/Derived Data ### 7. Redundant/Derived Data
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `periods_total`, `periods_remaining` **Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `period_count_total`, `periods_remaining`
**Reason:** **Reason:**
- Can be calculated from other attributes - Can be calculated from other attributes
@ -146,7 +146,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Impact:** ~100-200 bytes saved per state change **Impact:** ~100-200 bytes saved per state change
**Example:** `price_spread = price_max - price_min` (both are recorded, so spread can be calculated) **Example:** `price_spread = price_max - price_min` (both are recorded, so spread can be calculated). `periods_remaining = period_count_total - period_position` (both components are recorded).
## Attributes That ARE Recorded ## Attributes That ARE Recorded
@ -166,6 +166,8 @@ These attributes **remain in history** because they provide essential analytical
### Period Data ### Period Data
- `start`, `end`, `duration_minutes` - Core period timing - `start`, `end`, `duration_minutes` - Core period timing
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics - `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
- `period_position` - Position of current period in the day's sequence
- `period_count_today`, `period_count_tomorrow` - How many periods per day (useful in automations)
### High-Level Status ### High-Level Status
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation) - `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)

View file

@ -523,7 +523,7 @@ This is **expected behavior** on days with very uniform electricity prices. When
```yaml ```yaml
min_periods_configured: 2 min_periods_configured: 2
periods_found_total: 1 period_count_today: 1
flat_days_detected: 1 # Uniform prices today → 1 period is the right answer flat_days_detected: 1 # Uniform prices today → 1 period is the right answer
``` ```
@ -656,7 +656,8 @@ relaxation_level: "price_diff_18.0%+level_any" # Found at 18% flex, level filte
# Calculation summary (always shown diagnostic overview of this calculation run): # Calculation summary (always shown diagnostic overview of this calculation run):
min_periods_configured: 2 # What you configured as target min_periods_configured: 2 # What you configured as target
periods_found_total: 3 # What was actually found across all days period_count_today: 2 # How many periods are scheduled today
period_count_tomorrow: 2 # How many periods are scheduled tomorrow (when data available)
# Optional (only shown when relevant): # Optional (only shown when relevant):
period_interval_smoothed_count: 2 # Number of price spikes smoothed period_interval_smoothed_count: 2 # Number of price spikes smoothed
@ -669,7 +670,7 @@ relaxation_incomplete: true # Some days couldn't reach the configured ta
#### What the diagnostic attributes mean #### What the diagnostic attributes mean
**`min_periods_configured` / `periods_found_total`** **`min_periods_configured` / `period_count_today`**
These two values together quickly show whether the calculation achieved its goal: These two values together quickly show whether the calculation achieved its goal:
@ -678,18 +679,18 @@ These two values together quickly show whether the calculation achieved its goal
```yaml ```yaml
min_periods_configured: 2 # You asked for 2 periods per day min_periods_configured: 2 # You asked for 2 periods per day
periods_found_total: 6 # 3 days × 2 periods = fully satisfied ✅ period_count_today: 2 # ✅ Today: target reached
period_count_tomorrow: 2 # ✅ Tomorrow: target reached
``` ```
```yaml ```yaml
min_periods_configured: 2 min_periods_configured: 2
periods_found_total: 5 # 3 days, but one day got only 1 period period_count_today: 1 # ⚠️ Today: only 1 period found
period_count_tomorrow: 2 # ✅ Tomorrow: target reached
``` ```
</details> </details>
Note that `periods_found_total` counts **all periods across today and tomorrow** so 4 on a two-day view means 2 per day on average.
**`flat_days_detected`** **`flat_days_detected`**
This is the most important diagnostic for days with very uniform prices (e.g. sunny spring/summer days with high solar generation): This is the most important diagnostic for days with very uniform prices (e.g. sunny spring/summer days with high solar generation):
@ -699,7 +700,7 @@ This is the most important diagnostic for days with very uniform prices (e.g. su
```yaml ```yaml
min_periods_configured: 2 min_periods_configured: 2
periods_found_total: 1 period_count_today: 1
flat_days_detected: 1 # ← This explains why you got 1 instead of 2 flat_days_detected: 1 # ← This explains why you got 1 instead of 2
``` ```
@ -718,7 +719,7 @@ This flag appears when even after all relaxation attempts, at least one day coul
```yaml ```yaml
min_periods_configured: 2 min_periods_configured: 2
periods_found_total: 1 period_count_today: 1
relaxation_incomplete: true # ← Relaxation tried everything, still short relaxation_incomplete: true # ← Relaxation tried everything, still short
``` ```

View file

@ -130,7 +130,7 @@ Check the period sensor attributes to understand what happened:
relaxation_active: true # This day needed relaxation relaxation_active: true # This day needed relaxation
relaxation_level: "price_diff_18.0%+level_any" # Found at 18% flex, level filter removed relaxation_level: "price_diff_18.0%+level_any" # Found at 18% flex, level filter removed
min_periods_configured: 2 # Your target min_periods_configured: 2 # Your target
periods_found_total: 3 # What was actually found period_count_today: 3 # What was actually found today
``` ```
</details> </details>