mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-27 17:26:48 +00:00
Compare commits
21 commits
779e22a84e
...
adf85792d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adf85792d5 | ||
|
|
1706bd7c0e | ||
|
|
b1e0245a60 | ||
|
|
51a62d712f | ||
|
|
dd59c687e3 | ||
|
|
3ba8e91958 | ||
|
|
a2fe572dc2 | ||
|
|
aa9a1200b8 | ||
|
|
e163a47d57 | ||
|
|
a93ad1ac96 | ||
|
|
a957334990 | ||
|
|
0ca52f8d3c | ||
|
|
7b477cd4c7 | ||
|
|
6f5261785b | ||
|
|
c89248d493 | ||
|
|
32b080d178 | ||
|
|
6e990564b9 | ||
|
|
b2d63c2b6d | ||
|
|
3fda932442 | ||
|
|
a240393911 | ||
|
|
1d3c55097d |
404 changed files with 67394 additions and 60049 deletions
|
|
@ -1,6 +1,4 @@
|
|||
{
|
||||
"recommendations": [],
|
||||
"unwantedRecommendations": [
|
||||
"ms-python.pylint"
|
||||
]
|
||||
"unwantedRecommendations": ["ms-python.pylint"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,7 @@
|
|||
"PYTHONASYNCIODEBUG": "1",
|
||||
"TIBBER_PRICES_DEV": "1"
|
||||
},
|
||||
"forwardPorts": [
|
||||
8123,
|
||||
3000,
|
||||
3001
|
||||
],
|
||||
"forwardPorts": [8123, 3000, 3001],
|
||||
"portsAttributes": {
|
||||
"8123": {
|
||||
"label": "Home Assistant",
|
||||
|
|
@ -56,9 +52,7 @@
|
|||
"reportUnusedCoroutine": "none",
|
||||
"reportMissingTypeStubs": "none"
|
||||
},
|
||||
"python.analysis.include": [
|
||||
"custom_components/tibber_prices"
|
||||
],
|
||||
"python.analysis.include": ["custom_components/tibber_prices"],
|
||||
"python.analysis.exclude": [
|
||||
"**/.venv/**",
|
||||
"**/venv/**",
|
||||
|
|
@ -74,15 +68,15 @@
|
|||
],
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"--no-cov"
|
||||
],
|
||||
"python.testing.pytestArgs": ["--no-cov"],
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"[python]": {
|
||||
|
|
@ -110,18 +104,19 @@
|
|||
"markdown.validate.fragmentLinks.enabled": "ignore",
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": [
|
||||
"homeassistant/components/*/manifest.json"
|
||||
],
|
||||
"fileMatch": ["homeassistant/components/*/manifest.json"],
|
||||
"url": "${containerWorkspaceFolder}/schemas/json/manifest_schema.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"homeassistant/components/*/translations/*.json"
|
||||
],
|
||||
"fileMatch": ["homeassistant/components/*/translations/*.json"],
|
||||
"url": "${containerWorkspaceFolder}/schemas/json/translation_schema.json"
|
||||
}
|
||||
],
|
||||
"github.copilot.chat.commitMessageGeneration.instructions": [
|
||||
{
|
||||
"file": ".github/instructions/commit-messages.instructions.md"
|
||||
}
|
||||
],
|
||||
"git.useConfigOnly": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,9 +51,9 @@ if grep -q '^\[alias\]' ~/.gitconfig.host; then
|
|||
|
||||
# First, collect all aliases from host config
|
||||
TEMP_ALIASES=$(mktemp)
|
||||
sed -n '/^\[alias\]/,/^\[/p' ~/.gitconfig.host | \
|
||||
grep -v '^\[' | \
|
||||
grep -v '^$' | \
|
||||
sed -n '/^\[alias\]/,/^\[/p' ~/.gitconfig.host |
|
||||
grep -v '^\[' |
|
||||
grep -v '^$' |
|
||||
while IFS= read -r line; do
|
||||
# Skip aliases with macOS-specific paths
|
||||
if echo "$line" | grep -q -E '/(Applications|usr/local)'; then
|
||||
|
|
|
|||
95
.github/instructions/commit-messages.instructions.md
vendored
Normal file
95
.github/instructions/commit-messages.instructions.md
vendored
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
description: "Use when writing or suggesting git commit messages, deciding commit type/scope, or preparing release-note-relevant commit trailers."
|
||||
---
|
||||
|
||||
# Commit Message Rules (Release-Notes Aware)
|
||||
|
||||
Use these rules whenever you generate or suggest commit messages.
|
||||
|
||||
## Primary Goal
|
||||
|
||||
Write technically correct Conventional Commit messages while ensuring release notes only include user-relevant changes.
|
||||
|
||||
## Required Format
|
||||
|
||||
Use this structure:
|
||||
|
||||
<type>(<scope>): <short summary>
|
||||
|
||||
<body>
|
||||
|
||||
Impact: <user-facing outcome>
|
||||
|
||||
### Notes
|
||||
|
||||
- Keep summary imperative and concise.
|
||||
- Keep body technical (what changed and why).
|
||||
- Keep Impact user-facing (what users notice).
|
||||
|
||||
## Type Selection
|
||||
|
||||
- Use feat for new user-visible capability.
|
||||
- Use fix only for user-visible bug fixes.
|
||||
- Use perf for user-visible reliability/performance improvements.
|
||||
- Use docs, test, refactor, chore, ci, build for non-user-facing work.
|
||||
|
||||
## Critical Rule: Internal/Unreleased Fixes
|
||||
|
||||
If a fix addresses code that was not released to users yet, DO NOT treat it as a user-facing fix.
|
||||
|
||||
In that case:
|
||||
|
||||
- Prefer chore(...) or refactor(...) instead of fix(...), and/or
|
||||
- Add an explicit trailer in the commit body:
|
||||
- Release-Notes: skip
|
||||
- User-Impact: none
|
||||
- Released-Bug: no
|
||||
|
||||
Any one of these trailers is enough.
|
||||
|
||||
## How To Decide Released vs Unreleased
|
||||
|
||||
When uncertain whether users were affected, check if the introducing commit was part of a release tag:
|
||||
|
||||
./scripts/release/check-if-released <commit-hash>
|
||||
|
||||
Interpretation:
|
||||
|
||||
- NOT RELEASED -> treat as internal/non-user-facing.
|
||||
- ALREADY RELEASED -> user-facing fix is possible.
|
||||
|
||||
## Release Notes Alignment
|
||||
|
||||
This repository's release notes generator excludes commits with any of these trailers:
|
||||
|
||||
- Release-Notes: skip
|
||||
- User-Impact: none
|
||||
- Released-Bug: no
|
||||
|
||||
Therefore, add one of them whenever you intentionally want to exclude a commit from release notes.
|
||||
|
||||
## Examples
|
||||
|
||||
### User-facing fix
|
||||
|
||||
fix(config_flow): prevent setup failure on invalid home selection
|
||||
|
||||
Validate home selection before entry creation to avoid runtime errors when stale API data is returned.
|
||||
|
||||
Impact: Setup wizard no longer fails for users when home data changes during configuration.
|
||||
|
||||
### Internal-only fix for unreleased code
|
||||
|
||||
chore(periods): adjust extension guard for new geometric matcher
|
||||
|
||||
Tune guard conditions in the new matcher implementation to avoid edge-case misclassification during development.
|
||||
|
||||
User-Impact: none
|
||||
|
||||
### Alternative with explicit skip marker
|
||||
|
||||
fix(periods): correct follow-up edge case in unreleased geometric matcher
|
||||
|
||||
Adjust comparison threshold in iterative matcher pass.
|
||||
|
||||
Release-Notes: skip
|
||||
25
.github/workflows/auto-assign.yml
vendored
Normal file
25
.github/workflows/auto-assign.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
name: Auto-assign
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
auto-assign:
|
||||
name: Assign to owner
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Assign issue to owner
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
assignees: [context.repo.owner],
|
||||
});
|
||||
490
AGENTS.md
490
AGENTS.md
|
|
@ -49,7 +49,6 @@ When working with the codebase, Copilot MUST actively maintain consistency betwe
|
|||
**When to discuss in chat vs. direct file changes:**
|
||||
|
||||
- **Make direct changes when:**
|
||||
|
||||
- Clear, straightforward task (fix bug, add function, update config)
|
||||
- Single approach is obvious
|
||||
- User request is specific ("add X", "change Y to Z")
|
||||
|
|
@ -205,29 +204,24 @@ Skip planning for:
|
|||
**Planning Document Lifecycle:**
|
||||
|
||||
1. **Planning Phase** (WIP in `/planning/`)
|
||||
|
||||
- Create `planning/<feature>-refactoring-plan.md`
|
||||
- Iterate freely (git-ignored, no commit pressure)
|
||||
- AI can help refine without polluting git history
|
||||
- Multiple revisions until plan is solid
|
||||
|
||||
2. **Implementation Phase** (Active work)
|
||||
|
||||
- Use plan as reference during coding
|
||||
- Update plan if issues discovered
|
||||
- Track progress through phases
|
||||
- Test after each phase
|
||||
|
||||
3. **Completion Phase** (After implementation)
|
||||
|
||||
- **Option A**: Move to `docs/development/` if lasting value
|
||||
|
||||
- Example: `planning/module-splitting-plan.md` → `docs/development/module-splitting-plan.md`
|
||||
- Update status to "✅ COMPLETED"
|
||||
- Commit as historical reference
|
||||
|
||||
- **Option B**: Delete if superseded
|
||||
|
||||
- Plan served its purpose
|
||||
- Code and AGENTS.md are source of truth
|
||||
|
||||
|
|
@ -310,12 +304,14 @@ After successful refactoring:
|
|||
**Root Directory (`custom_components/tibber_prices/`):**
|
||||
|
||||
**✅ ALLOWED in root:**
|
||||
|
||||
- Platform modules: `__init__.py`, `sensor.py` (deprecated, now `sensor/`), `binary_sensor.py` (deprecated, now `binary_sensor/`), future platforms
|
||||
- Core integration files: `const.py`, `manifest.json`, `services.yaml`, `diagnostics.py`, `data.py`, `migrations.py`
|
||||
- Translation directories: `translations/`, `custom_translations/`
|
||||
- Brand images: `brand/` (icon.png, dark_icon.png, logo.png, dark_logo.png + `@2x` variants) — served via HA brands proxy API (HA ≥ 2026.4), silently ignored on older versions
|
||||
|
||||
**❌ PROHIBITED in root:**
|
||||
|
||||
- Utility modules (use `/utils/` package instead)
|
||||
- Helper functions (use `/utils/` or appropriate package)
|
||||
- Data transformation logic (use `/utils/` or `/coordinator/`)
|
||||
|
|
@ -368,6 +364,7 @@ After successful refactoring:
|
|||
**When Adding New Files:**
|
||||
|
||||
**Before creating a new file in root, ask:**
|
||||
|
||||
1. Is this a new HA platform? → OK in root (e.g., `switch.py`, `number.py`)
|
||||
2. Is this a utility/helper? → Goes in `/utils/` or `/entity_utils/`
|
||||
3. Is this coordinator-related? → Goes in `/coordinator/`
|
||||
|
|
@ -389,7 +386,6 @@ After successful refactoring:
|
|||
**Key Patterns:**
|
||||
|
||||
- **Dual translation system**: Standard HA translations in `/translations/` (config flow, UI strings per HA schema), supplemental in `/custom_translations/` (entity descriptions not supported by HA schema). Both must stay in sync. Use `async_load_translations()` and `async_load_standard_translations()` from `const.py`. When to use which: `/translations/` is bound to official HA schema requirements; anything else goes in `/custom_translations/` (requires manual translation loading). **Schema reference**: `/schemas/json/translation_schema.json` provides the structure for `/translations/*.json` files based on [HA's translation documentation](https://developers.home-assistant.io/docs/internationalization/core).
|
||||
|
||||
- **Select selector translations**: Use `selector.{translation_key}.options.{value}` structure (NOT `selector.select.{translation_key}`). Translation keys map to JSON in `/translations/*.json` following the HA schema structure.
|
||||
|
||||
**CRITICAL Rules:**
|
||||
|
|
@ -460,6 +456,7 @@ The integration uses **4 distinct caching layers** with automatic invalidation:
|
|||
- **Why**: Avoid expensive calculation (~100-500ms) when data unchanged (70% CPU saving)
|
||||
|
||||
**Cache Invalidation Coordination**:
|
||||
|
||||
- Options change → Explicit `invalidate_config_cache()` on both DataTransformer and PeriodCalculator
|
||||
- Midnight turnover → Clear persistent + transformation cache, period cache auto-invalidates via hash
|
||||
- Tomorrow data arrival → Hash mismatch triggers period recalculation only
|
||||
|
|
@ -542,6 +539,7 @@ custom_components/tibber_prices/
|
|||
### Dependency Flow (Calculator Pattern)
|
||||
|
||||
**Clean Separation:**
|
||||
|
||||
```
|
||||
sensor/calculators/ → sensor/attributes/ (Volatility only - Hybrid Pattern)
|
||||
sensor/calculators/ → sensor/helpers/ (DailyStat, RollingHour - Pure functions)
|
||||
|
|
@ -553,6 +551,7 @@ sensor/helpers/ ✗ (NO imports from calculators/)
|
|||
```
|
||||
|
||||
**Why this works:**
|
||||
|
||||
- **One-way dependencies**: Calculators can import from attributes/helpers, but NOT vice versa
|
||||
- **No circular imports**: Reverse direction is empty (verified Jan 2025)
|
||||
- **Clean testing**: Each layer can be tested independently
|
||||
|
|
@ -562,6 +561,7 @@ sensor/helpers/ ✗ (NO imports from calculators/)
|
|||
**Background:** During Nov 2025 refactoring, Trend and Volatility calculators retained attribute-building logic to avoid duplicating complex calculations. This creates a **backwards dependency** (calculator → attributes) but is INTENTIONAL.
|
||||
|
||||
**Pattern:**
|
||||
|
||||
1. **Calculator** computes value AND builds attribute dict
|
||||
2. **Core** stores attributes in `cached_data` dict
|
||||
3. **Attributes package** retrieves cached attributes via:
|
||||
|
|
@ -569,6 +569,7 @@ sensor/helpers/ ✗ (NO imports from calculators/)
|
|||
- `_add_timing_or_volatility_attributes()` for volatility sensors
|
||||
|
||||
**Example (Volatility):**
|
||||
|
||||
```python
|
||||
# sensor/calculators/volatility.py
|
||||
from custom_components.tibber_prices.sensor.attributes import (
|
||||
|
|
@ -591,6 +592,7 @@ def get_volatility_attributes(self) -> dict | None:
|
|||
```
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- ✅ **Pro**: Complex logic stays in ONE place (no duplication)
|
||||
- ✅ **Pro**: Calculator has full context for attribute decisions
|
||||
- ❌ **Con**: Violates strict separation (calculator builds attributes)
|
||||
|
|
@ -603,6 +605,7 @@ def get_volatility_attributes(self) -> dict | None:
|
|||
All calculator modules use `TYPE_CHECKING` correctly:
|
||||
|
||||
**Pattern:**
|
||||
|
||||
```python
|
||||
# Runtime imports (used in function bodies)
|
||||
from custom_components.tibber_prices.const import CONF_PRICE_RATING_THRESHOLD_HIGH
|
||||
|
|
@ -617,23 +620,27 @@ if TYPE_CHECKING:
|
|||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- ✅ **Runtime imports**: Functions, classes, constants used in code → OUTSIDE TYPE_CHECKING
|
||||
- ✅ **Type-only imports**: Only used in type hints → INSIDE TYPE_CHECKING
|
||||
- ✅ **Coordinator import**: Always in base.py, inherited by all calculators
|
||||
|
||||
**Verified Status (Jan 2025):**
|
||||
|
||||
- All 8 calculators (base, interval, rolling_hour, daily_stat, window_24h, volatility, trend, timing, metadata) use TYPE_CHECKING correctly
|
||||
- No optimization needed - imports are already categorized optimally
|
||||
|
||||
### Import Anti-Patterns to Avoid
|
||||
|
||||
❌ **DON'T:**
|
||||
|
||||
- Import from higher layers (attributes/helpers importing from calculators)
|
||||
- Use runtime imports for type-only dependencies
|
||||
- Create circular dependencies between packages
|
||||
- Import entire modules when only needing one function
|
||||
|
||||
✅ **DO:**
|
||||
|
||||
- Follow one-way dependency flow (calculators → attributes/helpers)
|
||||
- Use TYPE_CHECKING for type-only imports
|
||||
- Import specific items: `from .helpers import aggregate_price_data`
|
||||
|
|
@ -646,6 +653,7 @@ if TYPE_CHECKING:
|
|||
**Core Challenge:**
|
||||
|
||||
The period calculation applies **three independent filters** that ALL must pass:
|
||||
|
||||
1. **Flex filter**: `price ≤ daily_min × (1 + flex)`
|
||||
2. **Min_Distance filter**: `price ≤ daily_avg × (1 - min_distance/100)`
|
||||
3. **Level filter**: `rating_level IN [allowed_levels]`
|
||||
|
|
@ -655,6 +663,7 @@ The period calculation applies **three independent filters** that ALL must pass:
|
|||
When `daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)`, the flex filter permits intervals that the min_distance filter blocks, causing zero periods despite high flexibility.
|
||||
|
||||
Example: daily_min=10 ct, daily_avg=20 ct, flex=50%, min_distance=5%
|
||||
|
||||
- Flex allows: ≤15 ct
|
||||
- Distance allows: ≤19 ct
|
||||
- But combined: Only intervals ≤15 ct AND ≤19 ct AND matching level → Distance becomes dominant constraint
|
||||
|
|
@ -685,11 +694,13 @@ Example: daily_min=10 ct, daily_avg=20 ct, flex=50%, min_distance=5%
|
|||
**Configuration Guidance:**
|
||||
|
||||
**Recommended Flex Ranges:**
|
||||
|
||||
- **With relaxation enabled**: 10-20% base flex (relaxation will escalate as needed)
|
||||
- **Without relaxation**: 20-35% direct flex (no automatic escalation)
|
||||
- **Anti-pattern**: Base flex >30% with relaxation enabled → causes rapid escalation and filter conflicts
|
||||
|
||||
**Key Constants** (defined in `coordinator/period_handlers/core.py`):
|
||||
|
||||
```python
|
||||
MAX_SAFE_FLEX = 0.50 # 50% absolute maximum
|
||||
MAX_OUTLIER_FLEX = 0.25 # 25% for stable outlier detection
|
||||
|
|
@ -698,6 +709,7 @@ FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # WARNING at 30% base flex
|
|||
```
|
||||
|
||||
**Relaxation Strategy** (`coordinator/period_handlers/relaxation.py`):
|
||||
|
||||
- Per-day independent loops (each day escalates separately based on its needs)
|
||||
- Hard cap: 3% absolute maximum increment per step (prevents explosion from high base flex)
|
||||
- Default configuration: 11 flex levels (15% base → 18% → 21% → ... → 48% max)
|
||||
|
|
@ -705,6 +717,7 @@ FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # WARNING at 30% base flex
|
|||
- Each flex level tries all filter combinations before increasing flex further
|
||||
|
||||
**Period Boundary Behavior** (`coordinator/period_handlers/period_building.py`):
|
||||
|
||||
- Periods can **cross midnight** (day boundaries) naturally
|
||||
- Reference price locked to **period start day** for consistency across the entire period
|
||||
- Pattern: "Uses reference price from start day of the period for consistency" (same as period statistics)
|
||||
|
|
@ -712,6 +725,7 @@ FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # WARNING at 30% base flex
|
|||
- This prevents artificial splits at midnight when prices remain favorable across the boundary
|
||||
|
||||
**Default Configuration Values** (`const.py`):
|
||||
|
||||
```python
|
||||
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode
|
||||
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection)
|
||||
|
|
@ -722,6 +736,7 @@ DEFAULT_RELAXATION_ATTEMPTS_PEAK = 11 # 11 steps: 20% → 50% (3% increment
|
|||
The relaxation increment is **hard-coded at 3% per step** in `relaxation.py` for reliability and predictability. This prevents configuration issues with high base flex values while still allowing sufficient escalation to the 50% hard maximum.
|
||||
|
||||
**Dynamic Scaling Table** (min_distance adjustment):
|
||||
|
||||
```
|
||||
Flex Scale Example (min_distance=5%)
|
||||
-------------------------------------------
|
||||
|
|
@ -737,12 +752,14 @@ Flex Scale Example (min_distance=5%)
|
|||
**Testing Scenarios:**
|
||||
|
||||
When debugging period calculation issues:
|
||||
|
||||
1. Check flex value: Is base flex >30%? Reduce to 15-20% if using relaxation
|
||||
2. Check logs for "scaled min_distance": Is it reducing too much? May need lower base flex
|
||||
3. Check filter statistics: Which filter blocks most intervals? (flex, distance, or level)
|
||||
4. Check relaxation warnings: INFO at 25%, WARNING at 30% indicate suboptimal config
|
||||
|
||||
**See:**
|
||||
|
||||
- **Theory documentation**: `docs/developer/docs/period-calculation-theory.md` (comprehensive mathematical analysis, conflict conditions, configuration pitfalls)
|
||||
- **Implementation**: `coordinator/period_handlers/` package (core.py, relaxation.py, level_filtering.py, period_building.py)
|
||||
- **User guide**: `docs/user/docs/period-calculation.md` (simplified user-facing explanations)
|
||||
|
|
@ -845,13 +862,39 @@ If you notice commands failing or missing dependencies:
|
|||
./scripts/clean --minimal # Only critical issues (.egg-info) - used by develop
|
||||
```
|
||||
|
||||
**Type checking and linting:**
|
||||
**Script selection — which to use when:**
|
||||
|
||||
During development, prefer scripts that automatically fix and format code. Reserve check-only scripts for CI/CD and final validation before sharing changes.
|
||||
|
||||
_Preferred development flow (auto-healing):_
|
||||
|
||||
```bash
|
||||
./scripts/type-check # Run Pyright type checking
|
||||
./scripts/lint-check # Run Ruff linting (check-only, CI mode)
|
||||
./scripts/lint # Run Ruff linting with auto-fix
|
||||
./scripts/check # Run both type-check + lint-check + sensor reference freshness (recommended before commits)
|
||||
./scripts/format-all # Format Python + non-Python (JSON, JSONC, Markdown, YAML, shell scripts)
|
||||
./scripts/lint-fix # Python lint auto-fixes (Ruff check --fix)
|
||||
./scripts/lint-all # One command: format-all + lint-fix (broad formatting plus Python lint fixes)
|
||||
```
|
||||
|
||||
_Check-only flow (CI/CD-oriented):_
|
||||
|
||||
```bash
|
||||
./scripts/lint-check # Ruff format-check + lint-check, Python scope only
|
||||
./scripts/check # type-check + lint-check + sensor reference freshness (recommended before commits)
|
||||
./scripts/check-all # Python checks + non-Python formatting checks (full CI parity)
|
||||
```
|
||||
|
||||
_Agent behavior rules:_
|
||||
|
||||
- If asked to "fix", "format", "auto-heal", or "make it pass" → start with fix/format scripts.
|
||||
- If asked only to "verify", "validate", or "CI parity" → use check scripts.
|
||||
- After applying fixes, run the relevant check script once to confirm a clean state.
|
||||
- Do not rely only on editor format-on-save for project consistency.
|
||||
|
||||
_Additional single-purpose scripts:_
|
||||
|
||||
```bash
|
||||
./scripts/format # Format Python only (Ruff format)
|
||||
./scripts/type-check # Run Pyright type checking only
|
||||
./scripts/lint # Python format + lint-fix in one step (delegates to format then lint-fix)
|
||||
```
|
||||
|
||||
**Documentation generation:**
|
||||
|
|
@ -888,7 +931,6 @@ When changes are complete and ready for testing:
|
|||
|
||||
1. **Ask user to test**, don't execute `./scripts/develop` yourself
|
||||
2. **Provide specific test guidance** based on what changed in this session:
|
||||
|
||||
- Which UI screens to check (e.g., "Open config flow, step 3")
|
||||
- What behavior to verify (e.g., "Dropdown should show translated values")
|
||||
- What errors to watch for (e.g., "Check logs for JSON parsing errors")
|
||||
|
|
@ -916,392 +958,46 @@ When changes are complete and ready for testing:
|
|||
|
||||
## Git Workflow Guidance
|
||||
|
||||
**Purpose:** Maintain clean, atomic commits that enable future release note generation while preserving technical accuracy.
|
||||
**Purpose:** Keep commit guidance centralized and avoid duplicated/contradictory rules.
|
||||
|
||||
**Why This Matters:**
|
||||
**Authoritative commit-message instructions:**
|
||||
|
||||
- Commits stay **technical** (for developers, describe what changed and why)
|
||||
- Commits are **structured** (Conventional Commits format with "Impact:" sections)
|
||||
- Release notes are **user-friendly** (AI translates commits into user language later)
|
||||
- Clean history enables automatic release note generation from commit messages
|
||||
- Use `.github/instructions/commit-messages.instructions.md` for commit type/scope, Impact footer style, and release-notes skip trailers.
|
||||
|
||||
**Critical Principles:**
|
||||
**Critical behavior rules (still enforced here):**
|
||||
|
||||
1. **AI suggests commits, NEVER executes them** - User maintains full control of git operations
|
||||
2. **Commits are for developers** - Technical language, implementation details, code changes
|
||||
3. **Release notes are for users** - AI will translate commit history into user-friendly format later
|
||||
4. **Suggest conservatively** - Only at clear feature boundaries, not after every change
|
||||
5. **Trust session memory** - Don't check `git status`, recall what was accomplished this session
|
||||
1. **Commit execution**: Only run `git commit` when the user explicitly asks to commit. A one-time request to commit does not authorize future commits without asking again.
|
||||
2. **Commit message generation**: When the user asks only for a commit message, generate the message and stop — do not run `git commit`. The user will commit themselves.
|
||||
3. **git push**: Never suggest or execute `git push`. The user always handles pushing themselves.
|
||||
4. Suggest commits only at clear feature boundaries (not during active debugging/iteration).
|
||||
5. Use "Optional:" phrasing for unsolicited commit suggestions and respect a declined suggestion.
|
||||
6. When suggesting commits, include exact files to stage.
|
||||
|
||||
### When to Suggest Commits
|
||||
**Internal/unreleased fixes:**
|
||||
|
||||
**Suggest commits at clear feature boundaries:**
|
||||
|
||||
| Scenario | Suggest? | Example |
|
||||
| --------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| Feature complete and tested | ✅ YES | "Optional: Before we start the next feature, you might want to commit the translation system fixes?" |
|
||||
| Bug fixed with verification | ✅ YES | "Optional: This bug fix is complete and verified. Ready to commit before moving on?" |
|
||||
| Multiple related files changed (logical unit) | ✅ YES | "Optional: All 5 translation files updated. This forms a logical commit." |
|
||||
| About to start unrelated work | ✅ YES | "Optional: Before we start refactoring the API client, commit the current sensor changes?" |
|
||||
| User explicitly asks what's uncommitted | ✅ YES | Provide summary of changes and suggest commit message |
|
||||
| Iterating on same feature | ❌ NO | Don't suggest between attempts/refinements |
|
||||
| Debugging in progress | ❌ NO | Wait until root cause found and fixed |
|
||||
| User declined previous commit suggestion | ❌ NO | Respect their workflow preference |
|
||||
|
||||
**Suggestion Language:**
|
||||
|
||||
- Use "Optional:" prefix to make it clear this is not required
|
||||
- Ask, don't assume: "Want to commit?" not "You should commit"
|
||||
- Accept graceful decline: If user says no or ignores, don't mention again for that boundary
|
||||
- Provide commit message: Include full Conventional Commit format with "Impact:" section
|
||||
- **Specify files to stage**: When suggesting commits, list exact files for `git add`
|
||||
- **Split when logical**: If session has multiple unrelated changes, suggest separate commits with specific file lists
|
||||
|
||||
**Commit Splitting Guidelines:**
|
||||
|
||||
Split into multiple commits when:
|
||||
|
||||
- Different areas affected (config flow + docs + environment)
|
||||
- Different change types (fix + feat + docs)
|
||||
- Different impact scope (user-facing vs. developer-only)
|
||||
- Changes can work independently
|
||||
|
||||
Combine into single commit when:
|
||||
|
||||
- Tightly coupled changes (translations + code using them)
|
||||
- Single feature across files (sensor + translations + service)
|
||||
- Dependency chain (A requires B to function)
|
||||
- Small scope (2-3 related files telling one story)
|
||||
|
||||
**Example - Single Commit:**
|
||||
|
||||
> Optional: Ruff configuration migration complete. Ready to commit?
|
||||
>
|
||||
> **Stage these files:**
|
||||
>
|
||||
> ```bash
|
||||
> git add pyproject.toml AGENTS.md
|
||||
> ```
|
||||
>
|
||||
> **Commit message:**
|
||||
>
|
||||
> ```
|
||||
> refactor: migrate ruff config from .ruff.toml to pyproject.toml
|
||||
>
|
||||
> Consolidated ruff configuration into pyproject.toml following modern Python
|
||||
> conventions and integration_blueprint pattern.
|
||||
>
|
||||
> Updated all references in AGENTS.md from .ruff.toml to
|
||||
> pyproject.toml under [tool.ruff] section.
|
||||
>
|
||||
> Impact: Aligns with modern Python tooling standards. No user-visible changes.
|
||||
> ```
|
||||
|
||||
**Example - Multiple Commits:**
|
||||
|
||||
> Optional: Two separate improvements ready. Suggest splitting:
|
||||
>
|
||||
> **Commit 1: Translation Fix**
|
||||
>
|
||||
> ```bash
|
||||
> git add custom_components/tibber_prices/config_flow/
|
||||
> git add custom_components/tibber_prices/translations/*.json
|
||||
> ```
|
||||
>
|
||||
> ```
|
||||
> fix(config_flow): use flat selector structure for translation_key
|
||||
>
|
||||
> SelectOptionDict with label parameter was overriding translation_key,
|
||||
> causing config flow to fail at step 4.
|
||||
>
|
||||
> Changed to plain string lists with translation_key parameter,
|
||||
> following HA pattern: selector.{translation_key}.options.{value}
|
||||
>
|
||||
> Updated all 5 language files (de, en, nb, nl, sv).
|
||||
>
|
||||
> Impact: Config flow works through all 6 steps with translated options.
|
||||
> ```
|
||||
>
|
||||
> **Commit 2: Documentation**
|
||||
>
|
||||
> ```bash
|
||||
> git add AGENTS.md
|
||||
> ```
|
||||
>
|
||||
> ```
|
||||
> docs(patterns): document selector translation structure
|
||||
>
|
||||
> Added correct translation pattern for SelectSelector based on
|
||||
> official HA documentation and debugging session.
|
||||
>
|
||||
> Documents flat selector.{translation_key}.options.{value} structure
|
||||
> and common pitfall of SelectOptionDict overriding translations.
|
||||
>
|
||||
> Impact: Future sessions generate correct selector translations.
|
||||
> ```
|
||||
>
|
||||
> Want to commit separately or combine?
|
||||
|
||||
### Conventional Commits Format
|
||||
|
||||
**Reference:** Follow [Conventional Commits v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) specification.
|
||||
|
||||
**Structure:**
|
||||
|
||||
```
|
||||
<type>[optional scope]: <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
**Required Elements:**
|
||||
|
||||
- **type**: Lowercase, communicates intent (feat, fix, docs, etc.)
|
||||
- **description**: Short summary (max 50-72 chars), imperative mood ("add" not "added"), lowercase start, no period
|
||||
|
||||
**Optional Elements:**
|
||||
|
||||
- **scope**: Parentheses after type, e.g., `feat(sensors):` - lowercase, specific area of change
|
||||
- **body**: Detailed explanation, wrap at 72 chars, explain WHAT and WHY (not HOW - code shows that)
|
||||
- **footer**: Breaking changes, issue references, or custom fields
|
||||
|
||||
**Breaking Changes:**
|
||||
|
||||
Use `BREAKING CHANGE:` footer or `!` after type/scope:
|
||||
```
|
||||
feat(api)!: drop support for legacy endpoint
|
||||
|
||||
BREAKING CHANGE: The /v1/prices endpoint has been removed. Use /v2/prices instead.
|
||||
```
|
||||
|
||||
**Types (Conventional Commits standard):**
|
||||
|
||||
- `feat`: New feature (appears in release notes as "New Features")
|
||||
- `fix`: Bug fix (appears in release notes as "Bug Fixes")
|
||||
- `docs`: Documentation only (appears in release notes as "Documentation")
|
||||
- `style`: Code style/formatting (no behavior change, omitted from release notes)
|
||||
- `refactor`: Code restructure without behavior change (may or may not appear in release notes)
|
||||
- `perf`: Performance improvement (appears in release notes)
|
||||
- `test`: Test changes only (omitted from release notes)
|
||||
- `build`: Build system/dependencies (omitted from release notes)
|
||||
- `ci`: CI configuration (omitted from release notes)
|
||||
- `chore`: Maintenance tasks (usually omitted from release notes)
|
||||
|
||||
**Scope (project-specific, optional but recommended):**
|
||||
|
||||
- `translations`: Translation system changes
|
||||
- `config_flow`: Configuration flow changes
|
||||
- `sensors`: Sensor implementation
|
||||
- `binary_sensors`: Binary sensor implementation
|
||||
- `api`: API client changes
|
||||
- `coordinator`: Data coordinator changes
|
||||
- `services`: Service implementations
|
||||
- `docs`: Documentation files
|
||||
|
||||
**Custom Footer - Impact Section:**
|
||||
|
||||
Add `Impact:` footer for release note generation context (project-specific addition):
|
||||
|
||||
```
|
||||
feat(services): add rolling window support
|
||||
|
||||
Implement dynamic 48h window that adapts to data availability.
|
||||
|
||||
Impact: Users can create auto-adapting price charts without manual
|
||||
day selection. Requires config-template-card for ApexCharts mode.
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
- **Subject line**: Max 50 chars (hard limit 72), lowercase, imperative mood
|
||||
- **Body**: Wrap at 72 chars, optional but useful for complex changes
|
||||
- **Blank line**: Required between subject and body
|
||||
- **Impact footer**: Optional but recommended for user-facing changes
|
||||
|
||||
### Technical Commit Message Examples
|
||||
|
||||
**Example 1: Bug Fix**
|
||||
|
||||
```
|
||||
fix(config_flow): use flat selector structure for translation_key
|
||||
|
||||
SelectOptionDict with label parameter was overriding translation_key,
|
||||
causing config flow to fail at step 4 with "Unknown error occurred".
|
||||
|
||||
Changed to use plain string lists with translation_key parameter,
|
||||
following official HA pattern: selector.{translation_key}.options.{value}
|
||||
|
||||
Updated all 5 language files (de, en, nb, nl, sv) with correct
|
||||
structure.
|
||||
|
||||
Impact: Config flow now works through all 6 steps with properly
|
||||
translated dropdown options. Users can complete setup without
|
||||
encountering errors.
|
||||
```
|
||||
|
||||
**Example 2: Documentation**
|
||||
|
||||
```
|
||||
docs(workflow): add git commit guidance for release notes
|
||||
|
||||
Added comprehensive "Git Workflow Guidance" section to AGENTS.md
|
||||
documenting when AI should suggest commits, Conventional Commits format, and
|
||||
how to structure technical messages that enable future release note generation.
|
||||
|
||||
Key additions:
|
||||
- Commit boundary detection decision table
|
||||
- When NOT to suggest commits (during iteration/debugging)
|
||||
- Conventional Commits format with types and scopes
|
||||
- Technical commit message examples with "Impact:" sections
|
||||
- Release note generation guidelines for future use
|
||||
|
||||
Impact: AI can now help maintain clean, atomic commits structured for
|
||||
automatic release note generation while preserving technical accuracy.
|
||||
```
|
||||
|
||||
**Example 3: Feature**
|
||||
|
||||
```
|
||||
feat(environment): add VS Code Python environment configuration
|
||||
|
||||
Added .vscode/settings.json with universal Python/Ruff settings and updated
|
||||
.devcontainer/devcontainer.json to use workspace .venv interpreter.
|
||||
|
||||
Changes:
|
||||
- .devcontainer/devcontainer.json: Set python.defaultInterpreterPath to .venv
|
||||
- .devcontainer/devcontainer.json: Added python.analysis.extraPaths
|
||||
- .vscode/settings.json: Created with Pylance and Ruff configuration
|
||||
- Removed deprecated ruff.lint.args and ruff.format.args
|
||||
|
||||
Impact: Pylance now resolves homeassistant.* imports correctly and provides
|
||||
full autocomplete for Home Assistant APIs. Developers get proper IDE support
|
||||
without manual interpreter selection.
|
||||
```
|
||||
|
||||
**Example 4: Refactor**
|
||||
|
||||
```
|
||||
refactor: migrate ruff config from .ruff.toml to pyproject.toml
|
||||
|
||||
Consolidated ruff configuration into pyproject.toml following modern Python
|
||||
conventions and integration_blueprint pattern.
|
||||
|
||||
Updated all references in AGENTS.md from .ruff.toml to
|
||||
pyproject.toml under [tool.ruff] section.
|
||||
|
||||
Impact: Aligns with modern Python tooling standards. No user-visible changes.
|
||||
```
|
||||
|
||||
### "Impact:" Section Guidelines
|
||||
|
||||
The "Impact:" section bridges technical commits and future release notes:
|
||||
|
||||
**What to Include:**
|
||||
|
||||
- **User-visible effects**: What changes for end users of the integration
|
||||
- **Developer benefits**: What improves for contributors/maintainers
|
||||
- **Context for translation**: Information that helps future AI translate this into user-friendly release note
|
||||
- **Omit "Impact:" if**: Internal refactor with zero user/dev impact (e.g., rename private variable)
|
||||
|
||||
**Examples:**
|
||||
|
||||
✅ **Good Impact Sections:**
|
||||
|
||||
- "Config flow now works through all 6 steps without errors"
|
||||
- "Pylance provides full autocomplete for Home Assistant APIs"
|
||||
- "AI maintains clean commit history for release note generation"
|
||||
- "Aligns with HA 2025.x translation schema requirements"
|
||||
- "Reduces API calls by 70% through intelligent caching"
|
||||
|
||||
❌ **Poor Impact Sections:**
|
||||
|
||||
- "Code is better now" (vague, not actionable)
|
||||
- "Fixed the bug" (redundant with commit type)
|
||||
- "Updated file X" (describes action, not impact)
|
||||
- "This should work" (uncertain, commits should be verified)
|
||||
|
||||
### Release Note Generation (Future Use)
|
||||
|
||||
**When generating release notes from commits:**
|
||||
|
||||
1. **Filter by type**:
|
||||
- Include: `feat`, `fix`, `docs` (if significant)
|
||||
- Maybe include: `refactor` (if user-visible)
|
||||
- Exclude: `chore`, `test`, `style`
|
||||
2. **Group by type**:
|
||||
- "New Features" (feat)
|
||||
- "Bug Fixes" (fix)
|
||||
- "Documentation" (docs)
|
||||
- "Improvements" (refactor with user impact)
|
||||
3. **Translate to user language**:
|
||||
- Technical: "fix(config_flow): use flat selector structure" → User: "Fixed configuration wizard failing at step 4"
|
||||
- Technical: "feat(environment): add VS Code configuration" → User: "Improved developer experience with better IDE support"
|
||||
4. **Use "Impact:" as source**:
|
||||
- Extract user-visible effects from Impact sections
|
||||
- Preserve context (why it matters)
|
||||
- Rewrite in present tense, active voice
|
||||
5. **Add examples if helpful**:
|
||||
- Show before/after for UI changes
|
||||
- Demonstrate new capabilities with code snippets
|
||||
- Link to documentation for complex features
|
||||
|
||||
**Example Release Note (Generated from Commits):**
|
||||
|
||||
> **Tibber Prices 2.0.1**
|
||||
>
|
||||
> **Bug Fixes**
|
||||
>
|
||||
> - Fixed configuration wizard failing at step 4 when selecting price thresholds. Dropdown options now appear correctly with proper translations.
|
||||
>
|
||||
> **Improvements**
|
||||
>
|
||||
> - Improved developer environment setup with automatic Python path detection and full Home Assistant API autocomplete in VS Code
|
||||
|
||||
### Philosophy
|
||||
|
||||
**User Controls Workflow:**
|
||||
|
||||
- User decides when to commit
|
||||
- User writes final commit message (AI provides suggestion)
|
||||
- User manages branches, PRs, and releases
|
||||
- AI is an assistant, not a driver
|
||||
|
||||
**AI Suggests at Boundaries:**
|
||||
|
||||
- Suggests when logical unit complete
|
||||
- Provides structured commit message
|
||||
- Accepts decline without repeating
|
||||
- Trusts session memory over `git status`
|
||||
|
||||
**Commits Enable Release Notes:**
|
||||
|
||||
- Technical accuracy preserved (for developers)
|
||||
- Structure enables automation (Conventional Commits)
|
||||
- Impact sections provide user context (for release notes)
|
||||
- Future AI translates into user-friendly format
|
||||
- If a fix never affected released users, mark commit body with one trailer so release notes can exclude it:
|
||||
- `Release-Notes: skip`
|
||||
- `User-Impact: none`
|
||||
- `Released-Bug: no`
|
||||
- To check if introducing code was released, use: `./scripts/release/check-if-released <commit-hash>`
|
||||
|
||||
### Release Notes Generation
|
||||
|
||||
**Multiple Options Available:**
|
||||
|
||||
1. **Helper Script** (recommended, foolproof)
|
||||
|
||||
- Script: `./scripts/release/prepare VERSION`
|
||||
- Bumps manifest.json version → commits → creates tag locally
|
||||
- You review and push when ready
|
||||
- Example: `./scripts/release/prepare 0.3.0`
|
||||
|
||||
2. **Auto-Tag Workflow** (safety net)
|
||||
|
||||
- Workflow: `.github/workflows/auto-tag.yml`
|
||||
- Triggers on manifest.json changes
|
||||
- Automatically creates tag if it doesn't exist
|
||||
- Prevents "forgot to tag" mistakes
|
||||
|
||||
3. **Local Script** (testing, preview, and updating releases)
|
||||
|
||||
- Script: `./scripts/release/generate-notes [FROM_TAG] [TO_TAG]`
|
||||
- Parses Conventional Commits between tags
|
||||
- Supports multiple backends (auto-detected):
|
||||
|
|
@ -1327,7 +1023,6 @@ The "Impact:" section bridges technical commits and future release notes:
|
|||
```
|
||||
|
||||
4. **GitHub UI Button** (manual, PR-based)
|
||||
|
||||
- Uses `.github/release.yml` configuration
|
||||
- Click "Generate release notes" when creating release
|
||||
- Works best with PRs that have labels
|
||||
|
|
@ -1440,7 +1135,6 @@ USE_AI=false ./scripts/release/generate-notes
|
|||
**Backend Comparison:**
|
||||
|
||||
- **GitHub Copilot CLI** (`copilot`):
|
||||
|
||||
- ✅ AI-powered semantic understanding
|
||||
- ✅ Smart grouping of related commits into single release notes
|
||||
- ✅ Interprets "Impact:" sections for user-friendly descriptions
|
||||
|
|
@ -1449,7 +1143,6 @@ USE_AI=false ./scripts/release/generate-notes
|
|||
- ⚠️ Output may vary between runs
|
||||
|
||||
- **git-cliff** (template-based):
|
||||
|
||||
- ✅ Fast and consistent
|
||||
- ✅ 1:1 commit to release note line mapping
|
||||
- ✅ Highly configurable via `cliff.toml`
|
||||
|
|
@ -1544,6 +1237,7 @@ python -m json.tool custom_components/tibber_prices/translations/de.json > /dev/
|
|||
This project uses **two complementary tools** with different responsibilities:
|
||||
|
||||
**Pyright (Type Checker)** - Catches type safety issues:
|
||||
|
||||
- ✅ Type mismatches (`str` passed where `int` expected)
|
||||
- ✅ None-safety violations (`Optional[T]` used as `T`)
|
||||
- ✅ Missing/wrong type annotations
|
||||
|
|
@ -1553,6 +1247,7 @@ This project uses **two complementary tools** with different responsibilities:
|
|||
- 🔍 **Always run first** - catches design issues early
|
||||
|
||||
**Ruff (Linter + Formatter)** - Enforces code style and patterns:
|
||||
|
||||
- ✅ Code formatting (line length, indentation, quotes)
|
||||
- ✅ Import ordering (stdlib → third-party → local)
|
||||
- ✅ Unused imports/variables
|
||||
|
|
@ -1679,11 +1374,13 @@ def get_timestamp() -> str:
|
|||
### Pyright Configuration
|
||||
|
||||
Project uses `typeCheckingMode = "basic"` in `pyproject.toml`:
|
||||
|
||||
- Balanced between strictness and pragmatism
|
||||
- Catches real bugs without excessive noise
|
||||
- Compatible with Home Assistant's typing style
|
||||
|
||||
**Key settings:**
|
||||
|
||||
```toml
|
||||
[tool.pyright]
|
||||
include = ["custom_components/tibber_prices"]
|
||||
|
|
@ -1695,6 +1392,7 @@ typeCheckingMode = "basic"
|
|||
**CRITICAL: When generating code, always aim for Pyright `basic` mode compliance:**
|
||||
|
||||
✅ **DO:**
|
||||
|
||||
- Add type hints to all function signatures (parameters + return types)
|
||||
- Use proper type annotations: `dict[str, Any]`, `list[dict]`, `str | None`
|
||||
- Handle Optional types explicitly (None-checks before use)
|
||||
|
|
@ -1702,6 +1400,7 @@ typeCheckingMode = "basic"
|
|||
- Prefer explicit returns over implicit `None`
|
||||
|
||||
❌ **DON'T:**
|
||||
|
||||
- Leave functions without return type hints
|
||||
- Ignore potential `None` values in Optional types
|
||||
- Use `Any` as escape hatch (only when truly needed)
|
||||
|
|
@ -1720,6 +1419,7 @@ typeCheckingMode = "basic"
|
|||
3. **Home Assistant API has incomplete typing**
|
||||
|
||||
**ALWAYS include explanation:**
|
||||
|
||||
```python
|
||||
# ✅ GOOD - Explains why ignore is needed
|
||||
result = tz.localize(dt) # type: ignore[attr-defined] # pytz-specific method
|
||||
|
|
@ -1731,6 +1431,7 @@ result = tz.localize(dt) # type: ignore
|
|||
### Integration with VS Code
|
||||
|
||||
Pylance (VS Code's Python language server) uses the same Pyright engine:
|
||||
|
||||
- **Red squiggles** = Type errors (must fix)
|
||||
- **Yellow squiggles** = Warnings (should fix)
|
||||
- Hover for details, Cmd/Ctrl+Click for definitions
|
||||
|
|
@ -1860,6 +1561,7 @@ When renaming entity keys or changing sensor value units/semantics across releas
|
|||
This is a Home Assistant standard to avoid naming conflicts between integrations and ensure clear ownership of classes.
|
||||
|
||||
**Naming Pattern:**
|
||||
|
||||
```python
|
||||
# ✅ CORRECT - Integration prefix + semantic purpose
|
||||
class TibberPricesApiClient: # Integration + semantic role
|
||||
|
|
@ -1879,6 +1581,7 @@ class TibberPricesSensorCalculatorTrend: # Too verbose, import path shows loca
|
|||
```
|
||||
|
||||
**IMPORTANT:** Do NOT include package hierarchy in class names. Python's import system provides the namespace:
|
||||
|
||||
```python
|
||||
# The import path IS the full namespace:
|
||||
from custom_components.tibber_prices.coordinator.price_data_manager import TibberPricesPriceDataManager
|
||||
|
|
@ -1890,6 +1593,7 @@ from custom_components.tibber_prices.sensor.calculators.trend import TibberPrice
|
|||
```
|
||||
|
||||
**Home Assistant Core follows this pattern:**
|
||||
|
||||
- `TibberDataCoordinator` (not `TibberCoordinatorDataCoordinator`)
|
||||
- `MetWeatherData` (not `MetCoordinatorWeatherData`)
|
||||
- `MetDataUpdateCoordinator` (not `MetCoordinatorDataUpdateCoordinator`)
|
||||
|
|
@ -1897,6 +1601,7 @@ from custom_components.tibber_prices.sensor.calculators.trend import TibberPrice
|
|||
Use semantic prefixes that describe the PURPOSE, not the package location.
|
||||
|
||||
**When prefix is required:**
|
||||
|
||||
- ✅ All public classes (used across multiple modules)
|
||||
- ✅ All exception classes
|
||||
- ✅ All coordinator classes
|
||||
|
|
@ -1905,6 +1610,7 @@ Use semantic prefixes that describe the PURPOSE, not the package location.
|
|||
- ✅ All data classes (dataclasses, NamedTuples) used as public APIs
|
||||
|
||||
**When prefix can be omitted:**
|
||||
|
||||
- 🟡 Private helper classes used only within a single module (prefix class name with `_` underscore)
|
||||
- 🟡 Type aliases and callbacks (e.g., `TimeServiceCallback` is acceptable)
|
||||
- 🟡 Small NamedTuples used only for internal function returns (e.g., within calculators)
|
||||
|
|
@ -1914,6 +1620,7 @@ Use semantic prefixes that describe the PURPOSE, not the package location.
|
|||
**Private Classes (Module-Internal):**
|
||||
|
||||
If you create a helper class that is ONLY used within a single module file:
|
||||
|
||||
```python
|
||||
# ✅ CORRECT - Private class with underscore prefix
|
||||
class _InternalHelper:
|
||||
|
|
@ -1925,11 +1632,13 @@ result = _InternalHelper().process()
|
|||
```
|
||||
|
||||
**When to use private classes:**
|
||||
|
||||
- ❌ **DON'T** use for code organization alone - if it deserves a class, it's usually public
|
||||
- ✅ **DO** use for internal implementation details (e.g., state machines, internal builders)
|
||||
- ✅ **DO** use for temporary refactoring helpers (mark as `# TODO: Make public` if it grows)
|
||||
|
||||
**Example of genuine private class use case:**
|
||||
|
||||
```python
|
||||
# In coordinator/price_data_manager.py
|
||||
class _ApiRetryStateMachine:
|
||||
|
|
@ -2001,7 +1710,6 @@ We use **Pyright** for static type checking:
|
|||
**Log Level Strategy:**
|
||||
|
||||
- **INFO Level** - User-facing results and high-level progress:
|
||||
|
||||
- Compact 1-line summaries (no multi-line blocks)
|
||||
- Important results only (success/failure outcomes)
|
||||
- No indentation (scannability)
|
||||
|
|
@ -2009,7 +1717,6 @@ We use **Pyright** for static type checking:
|
|||
- Example: `"Day 2025-11-11: Success after 1 relaxation phase (2 periods)"`
|
||||
|
||||
- **DEBUG Level** - Detailed execution trace:
|
||||
|
||||
- Full context headers with all relevant configuration
|
||||
- Step-by-step progression through logic
|
||||
- Hierarchical indentation to show call depth/logic structure
|
||||
|
|
@ -2176,7 +1883,6 @@ When writing or updating user-facing documentation (`docs/user/docs/` or `docs/d
|
|||
Understanding **how** good documentation emerges is as important as knowing what makes it good:
|
||||
|
||||
- **Live Understanding vs. Code Analysis**
|
||||
|
||||
- ✅ **DO:** Write docs during/after active development
|
||||
- When implementing complex logic, document it while the "why" is fresh
|
||||
- Use real examples from debugging sessions (actual logs, real data)
|
||||
|
|
@ -2187,7 +1893,6 @@ Understanding **how** good documentation emerges is as important as knowing what
|
|||
- No user perspective: What's actually confusing?
|
||||
|
||||
- **User Feedback Loop**
|
||||
|
||||
- Key insight: Documentation improves when users question it
|
||||
- Pattern:
|
||||
1. User asks: "Does this still match the code?"
|
||||
|
|
@ -2197,19 +1902,16 @@ Understanding **how** good documentation emerges is as important as knowing what
|
|||
- Why it works: User questions force critical thinking, real confusion points get addressed
|
||||
|
||||
- **Log-Driven Documentation**
|
||||
|
||||
- Observation: When logs explain logic clearly, documentation becomes easier
|
||||
- Why: Logs show state transitions ("Baseline insufficient → Starting relaxation"), decisions ("Replaced period X with larger Y"), and are already written for humans
|
||||
- Pattern: If you spent hours making logs clear → use that clarity in documentation too
|
||||
|
||||
- **Concrete Examples > Abstract Descriptions**
|
||||
|
||||
- ✅ **Good:** "Day 2025-11-11 found 2 periods at flex=12.0% +volatility_any (stopped early, no need to try higher flex)"
|
||||
- ❌ **Bad:** "The relaxation algorithm uses a configurable threshold multiplier with filter combination strategies"
|
||||
- Use real data from debug sessions, show actual attribute values, demonstrate with timeline diagrams
|
||||
|
||||
- **Context Accumulation in Long Sessions**
|
||||
|
||||
- Advantage: AI builds mental model incrementally, sees evolution of logic (not just final state), understands trade-offs
|
||||
- Disadvantage of short sessions: Cold start every time, missing "why" context, documentation becomes spec-writing
|
||||
- Lesson: Complex documentation benefits from focused, uninterrupted work with accumulated context
|
||||
|
|
@ -2535,6 +2237,7 @@ def _get_sensor_attributes(self) -> dict | None:
|
|||
```
|
||||
|
||||
**Why direct method over Callable pattern?**
|
||||
|
||||
- **Simpler**: No lambda/Callable indirection, clearer stack traces
|
||||
- **More HA-standard**: Most Core integrations use direct methods
|
||||
- **Better performance**: ~2x faster (~0.1-0.5μs vs 0.2-0.8μs per call)
|
||||
|
|
@ -2546,6 +2249,7 @@ def _get_sensor_attributes(self) -> dict | None:
|
|||
Both platforms now use **identical signatures and patterns** (unified Nov 2025):
|
||||
|
||||
**Sensor Platform (`sensor/attributes.py`):**
|
||||
|
||||
```python
|
||||
def build_extra_state_attributes(
|
||||
entity_key: str,
|
||||
|
|
@ -2564,6 +2268,7 @@ def build_extra_state_attributes(
|
|||
```
|
||||
|
||||
**Binary Sensor Platform (`binary_sensor/attributes.py`):**
|
||||
|
||||
```python
|
||||
async def build_async_extra_state_attributes(
|
||||
entity_key: str,
|
||||
|
|
@ -2583,6 +2288,7 @@ def build_sync_extra_state_attributes(...) -> dict | None:
|
|||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- **Architectural consistency**: Both platforms use direct method pattern (not Callable)
|
||||
- **Naming consistency**: Both use `_get_sensor_attributes()` method name
|
||||
- **Parameter consistency**: Both builders accept `sensor_attrs` parameter
|
||||
|
|
@ -2716,7 +2422,6 @@ If the answer to any is "no", make the name more explicit.
|
|||
After the sensor.py refactoring (completed Nov 2025), sensors are organized by **calculation method** rather than feature type. Follow these steps:
|
||||
|
||||
1. **Determine calculation pattern** - Choose which group your sensor belongs to:
|
||||
|
||||
- **Interval-based**: Uses time offset from current interval (e.g., current/next/previous)
|
||||
- **Rolling hour**: Aggregates 5-interval window (2 before + center + 2 after)
|
||||
- **Daily statistics**: Min/max/avg within calendar day boundaries
|
||||
|
|
@ -2728,7 +2433,6 @@ After the sensor.py refactoring (completed Nov 2025), sensors are organized by *
|
|||
**IMPORTANT — After adding/renaming entities**: Run `./scripts/docs/generate-sensor-reference` to regenerate the multi-language sensor reference table. The `scripts/check` and CI will fail if the reference is stale.
|
||||
|
||||
2. **Add entity description** to appropriate sensor group in `sensor/definitions.py`:
|
||||
|
||||
- `INTERVAL_PRICE_SENSORS`, `INTERVAL_LEVEL_SENSORS`, or `INTERVAL_RATING_SENSORS`
|
||||
- `ROLLING_HOUR_PRICE_SENSORS`, `ROLLING_HOUR_LEVEL_SENSORS`, or `ROLLING_HOUR_RATING_SENSORS`
|
||||
- `DAILY_STAT_SENSORS`
|
||||
|
|
@ -2738,7 +2442,6 @@ After the sensor.py refactoring (completed Nov 2025), sensors are organized by *
|
|||
- `DIAGNOSTIC_SENSORS`
|
||||
|
||||
3. **Add handler mapping** in `sensor/core.py` → `_get_value_getter()` method:
|
||||
|
||||
- For interval-based: Use `_get_interval_value(interval_offset, value_type)`
|
||||
- For rolling hour: Use `_get_rolling_hour_value(hour_offset, value_type)`
|
||||
- For daily stats: Use `_get_daily_stat_value(day, stat_func)`
|
||||
|
|
@ -2758,19 +2461,16 @@ After the sensor.py refactoring (completed Nov 2025), sensors are organized by *
|
|||
The refactoring consolidated duplicate logic into unified methods in `sensor/core.py`:
|
||||
|
||||
- **`_get_interval_value(interval_offset, value_type, in_euro=False)`**
|
||||
|
||||
- Replaces: `_get_interval_price_value()`, `_get_interval_level_value()`, `_get_interval_rating_value()`
|
||||
- Handles: All interval-based sensors (current/next/previous)
|
||||
- Returns: Price (float), level (str), or rating (str) based on value_type
|
||||
|
||||
- **`_get_rolling_hour_value(hour_offset, value_type)`**
|
||||
|
||||
- Replaces: `_get_rolling_hour_average_value()`, `_get_rolling_hour_level_value()`, `_get_rolling_hour_rating_value()`
|
||||
- Handles: All 5-interval rolling hour windows
|
||||
- Returns: Aggregated value (average price, aggregated level/rating)
|
||||
|
||||
- **`_get_daily_stat_value(day, stat_func)`**
|
||||
|
||||
- Replaces: `_get_statistics_value()` (calendar day portion)
|
||||
- Handles: Min/max/avg for calendar days (today/tomorrow)
|
||||
- Returns: Price in subunit currency units (cents/øre)
|
||||
|
|
@ -2802,13 +2502,11 @@ Edit `utils/price.py` or `utils/average.py`. These are stateless pure functions
|
|||
The config flow is split into three separate flow handlers:
|
||||
|
||||
1. **User Flow** (`config_flow/user_flow.py`) - Initial setup and reauth
|
||||
|
||||
- `async_step_user()` - API token input
|
||||
- `async_step_select_home()` - Home selection
|
||||
- `async_step_reauth()` / `async_step_reauth_confirm()` - Reauth flow
|
||||
|
||||
2. **Subentry Flow** (`config_flow/subentry_flow.py`) - Add additional homes
|
||||
|
||||
- `async_step_user()` - Select from available homes
|
||||
- `async_step_init()` - Subentry options
|
||||
|
||||
|
|
@ -2890,6 +2588,7 @@ Only after consulting the official HA docs did we discover the correct pattern:
|
|||
- ❌ **Using standard library datetime**: Use `dt_util.now()` instead of `datetime.now()`.
|
||||
|
||||
**See code for correct patterns:**
|
||||
|
||||
- Async operations: `api/client.py`
|
||||
- Exception handling: `coordinator/core.py`
|
||||
- Translations: `sensor/definitions.py` (translation_key usage)
|
||||
|
|
@ -2901,10 +2600,12 @@ Only after consulting the official HA docs did we discover the correct pattern:
|
|||
**CRITICAL: Always exclude non-essential attributes from Recorder to prevent database bloat.**
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Use `_unrecorded_attributes = frozenset({...})` as **class attribute** in entity classes
|
||||
- See `sensor/core.py` and `binary_sensor/core.py` for current implementation
|
||||
|
||||
**What to exclude:**
|
||||
|
||||
1. **Descriptions/help text** - `description`, `usage_tips` (static, large)
|
||||
2. **Large nested structures** - `periods`, `data`, `*_attributes` dicts (>1KB)
|
||||
3. **Frequently changing diagnostics** - `icon_color`, `cache_age`, status strings
|
||||
|
|
@ -2913,14 +2614,15 @@ Only after consulting the official HA docs did we discover the correct pattern:
|
|||
6. **Redundant/derived data** - `price_spread`, `diff_%` (calculable from other attrs)
|
||||
|
||||
**What to keep:**
|
||||
|
||||
- `timestamp` (always), all price values, `cache_age_minutes`, `updates_today`
|
||||
- Period timing (`start`, `end`, `duration_minutes`), price statistics
|
||||
- Boolean status flags, `relaxation_active`
|
||||
|
||||
**When adding new attributes:**
|
||||
|
||||
- Will this be useful in history 1 week from now? No → Exclude
|
||||
- Can this be calculated from other attributes? Yes → Exclude
|
||||
- Is this >100 bytes and not essential? Yes → Exclude
|
||||
|
||||
**See:** `docs/developer/docs/recorder-optimization.md` for detailed categories and impact analysis
|
||||
|
||||
|
|
|
|||
11
CODEOWNERS
Normal file
11
CODEOWNERS
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# CODEOWNERS
|
||||
#
|
||||
# This file defines code owners for this repository.
|
||||
# Code owners are automatically requested for review when a pull request
|
||||
# modifies files they own.
|
||||
#
|
||||
# See: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
#
|
||||
# NOTE: This file is updated automatically by initialize.sh when using the blueprint.
|
||||
|
||||
* @jpawlowski
|
||||
|
|
@ -72,7 +72,18 @@ Impact: <user-visible effects>
|
|||
|
||||
**Types:** `feat`, `fix`, `docs`, `refactor`, `chore`, `test`
|
||||
|
||||
For full commit-message rules (including release-note skip trailers for internal/unreleased fixes), see:
|
||||
|
||||
- `.github/instructions/commit-messages.instructions.md`
|
||||
|
||||
Important trailers for commits that should NOT appear in release notes:
|
||||
|
||||
- `Release-Notes: skip`
|
||||
- `User-Impact: none`
|
||||
- `Released-Bug: no`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(sensors): add daily average price sensor
|
||||
|
||||
|
|
@ -81,7 +92,7 @@ Added new sensor that calculates average price for the entire day.
|
|||
Impact: Users can now track daily average prices for cost analysis."
|
||||
```
|
||||
|
||||
See [`AGENTS.md`](AGENTS.md) section "Git Workflow Guidance" for detailed guidelines.
|
||||
See `.github/instructions/commit-messages.instructions.md` for detailed commit-message guidelines.
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
|
|
@ -111,6 +122,7 @@ See [`AGENTS.md`](AGENTS.md) section "Git Workflow Guidance" for detailed guidel
|
|||
- **Python version**: 3.13+
|
||||
|
||||
Always run before committing:
|
||||
|
||||
```bash
|
||||
./scripts/lint
|
||||
```
|
||||
|
|
@ -136,6 +148,7 @@ Documentation is organized in two Docusaurus sites:
|
|||
- Navigation via `docs/developer/sidebars.ts`
|
||||
|
||||
**When adding new documentation:**
|
||||
|
||||
1. Place file in appropriate `docs/*/docs/` directory
|
||||
2. Add to corresponding `sidebars.ts` for navigation
|
||||
3. Update translations when changing `translations/en.json` (update ALL language files)
|
||||
|
|
@ -145,6 +158,7 @@ Documentation is organized in two Docusaurus sites:
|
|||
Report bugs via [GitHub Issues](../../issues/new/choose).
|
||||
|
||||
**Great bug reports include:**
|
||||
|
||||
- Quick summary and background
|
||||
- Steps to reproduce (be specific!)
|
||||
- Expected vs. actual behavior
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ The integration provides **100+ entities** across sensors, binary sensors, switc
|
|||
<img src="https://raw.githubusercontent.com/jpawlowski/hass.tibber_prices/main/docs/user/static/img/entities-overview.jpg" width="400" alt="Entity list showing dynamic icons for different price states">
|
||||
|
||||
| Category | Highlights | Count |
|
||||
|----------|-----------|-------|
|
||||
| ----------------------- | ----------------------------------------------------------------------------- | ----- |
|
||||
| **💰 Prices** | Current, next & previous interval price + rolling hour averages | 6+ |
|
||||
| **📊 Statistics** | Daily min/max/avg for today & tomorrow, 24h trailing & leading windows | 12+ |
|
||||
| **🔮 Forecasts** | Next 1h–12h average prices, price outlook & trajectory sensors | 20+ |
|
||||
|
|
|
|||
53
cliff.toml
53
cliff.toml
|
|
@ -5,13 +5,19 @@
|
|||
# Template for the changelog body
|
||||
header = ""
|
||||
body = """
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
{% for group, commits in commits | group_by(attribute="group") -%}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}\
|
||||
{% if commit.breaking %} [**BREAKING**]{% endif %} \
|
||||
([{{ commit.id | truncate(length=7, end="") }}](https://github.com/jpawlowski/hass.tibber_prices/commit/{{ commit.id }}))
|
||||
{% for commit in commits -%}
|
||||
{% set impact_text = "" -%}
|
||||
{% set footers = commit.footers | default(value=[]) -%}
|
||||
{% for footer in footers -%}
|
||||
{% if footer.token == "Impact" -%}
|
||||
{% set impact_text = footer.value -%}
|
||||
{% endif -%}
|
||||
{% endfor -%}
|
||||
- {% if impact_text %}{{ impact_text | trim | upper_first }}{% else %}{% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}{% endif %}{% if commit.breaking %} [**BREAKING**]{% endif %} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/jpawlowski/hass.tibber_prices/commit/{{ commit.id }}))
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
|
||||
---
|
||||
|
|
@ -25,7 +31,8 @@ trim = true
|
|||
[git]
|
||||
# Parse conventional commits
|
||||
conventional_commits = true
|
||||
# Include all commits (even non-conventional)
|
||||
# Keep unconventional commits in parsing pipeline; parser rules decide what to skip.
|
||||
# This avoids noisy parse-error warnings on older commit history.
|
||||
filter_unconventional = false
|
||||
split_commits = false
|
||||
|
||||
|
|
@ -33,22 +40,28 @@ split_commits = false
|
|||
commit_parsers = [
|
||||
# Skip manifest.json version bumps (release housekeeping)
|
||||
{ message = "^chore\\(release\\): bump version", skip = true },
|
||||
# Skip explicit revert commits; final net state should drive release notes
|
||||
{ message = "^revert", skip = true },
|
||||
# Skip development environment changes (not user-relevant)
|
||||
{ message = "^(feat|fix|chore|refactor)\\((devcontainer|vscode|scripts|dev-env|environment)\\):", skip = true },
|
||||
# Skip CI/CD infrastructure changes (not user-relevant)
|
||||
{ message = "^(feat|fix|chore|ci)\\((ci|workflow|actions|github-actions)\\):", skip = true },
|
||||
# Keep dependency updates - these ARE relevant for users
|
||||
{ message = "^chore\\(deps\\):", group = "📦 Dependencies" },
|
||||
# Regular commit types
|
||||
{ message = "^feat", group = "🎉 New Features" },
|
||||
{ message = "^fix", group = "🐛 Bug Fixes" },
|
||||
{ message = "^docs?", group = "📚 Documentation" },
|
||||
{ message = "^perf", group = "⚡ Performance" },
|
||||
{ message = "^refactor", group = "🔧 Maintenance & Refactoring" },
|
||||
{ message = "^style", group = "🎨 Styling" },
|
||||
{ message = "^test", group = "🧪 Testing" },
|
||||
{ message = "^chore", group = "🔧 Maintenance & Refactoring" },
|
||||
{ message = "^build", group = "📦 Build" },
|
||||
# Skip non-user-facing fix scopes
|
||||
{ message = "^fix\\((docs|lint|types|tests?|ci|workflow|scripts|devcontainer|vscode|build|release)\\):", skip = true },
|
||||
# User-facing categories aligned with AI output style
|
||||
{ message = "^feat", group = "🎉 What's New" },
|
||||
{ message = "^fix", group = "🐛 Fixed" },
|
||||
{ message = "^perf", group = "⚡ More Reliable" },
|
||||
{ message = "^chore\\(deps\\):", group = "📦 Updated Dependencies" },
|
||||
# Skip mostly developer-facing categories
|
||||
{ message = "^docs?", skip = true },
|
||||
{ message = "^refactor", skip = true },
|
||||
{ message = "^style", skip = true },
|
||||
{ message = "^test", skip = true },
|
||||
{ message = "^build", skip = true },
|
||||
{ message = "^chore", skip = true },
|
||||
# Final fallback to avoid ungrouped commits
|
||||
{ message = ".*", skip = true },
|
||||
]
|
||||
|
||||
# Protect breaking changes
|
||||
|
|
@ -56,5 +69,5 @@ commit_preprocessors = [
|
|||
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/jpawlowski/hass.tibber_prices/issues/${2}))" },
|
||||
]
|
||||
|
||||
# Filter out commits
|
||||
filter_commits = false
|
||||
# Apply commit parser filtering rules
|
||||
filter_commits = true
|
||||
|
|
|
|||
|
|
@ -1,23 +1,58 @@
|
|||
# Development-friendly config that excludes go2rtc which has compatibility issues
|
||||
# yaml-language-server: $schema=../schemas/yaml/configuration_schema.yaml
|
||||
# Development-friendly Home Assistant configuration
|
||||
#
|
||||
# We don't use default_config to avoid HA OS-specific integrations like go2rtc
|
||||
# that expect specific container environments. Instead, we explicitly load
|
||||
# the integrations useful for custom component development.
|
||||
|
||||
# https://www.home-assistant.io/integrations/homeassistant/
|
||||
homeassistant:
|
||||
debug: true
|
||||
|
||||
# Disable analytics, diagnostics and error reporting for development instance
|
||||
# Debugging integration
|
||||
# https://www.home-assistant.io/integrations/debugpy/
|
||||
debugpy:
|
||||
|
||||
# Privacy & analytics settings
|
||||
# https://www.home-assistant.io/integrations/analytics/
|
||||
analytics:
|
||||
# Disable usage analytics to prevent skewing production statistics
|
||||
# https://analytics.home-assistant.io should only reflect real user installations
|
||||
# Analytics are disabled to prevent development instances from skewing
|
||||
# production statistics at https://analytics.home-assistant.io
|
||||
|
||||
# System monitoring
|
||||
# https://www.home-assistant.io/integrations/system_health/
|
||||
system_health:
|
||||
# Provides system health information in Settings > System > Repairs
|
||||
# Safe for development - only shows local system status
|
||||
|
||||
# https://www.home-assistant.io/integrations/diagnostics/
|
||||
# Note: Diagnostics integration cannot be disabled, but without analytics
|
||||
# and with internal_url set, no data is sent externally
|
||||
# Note: The diagnostics integration is always loaded and cannot be disabled.
|
||||
# With analytics disabled, diagnostic data stays local and isn't sent anywhere.
|
||||
|
||||
# Core integrations needed for development
|
||||
# Core integrations
|
||||
http:
|
||||
# Development server settings for Codespaces/DevContainer
|
||||
server_host: "0.0.0.0"
|
||||
# Disable IP banning for development to avoid lockouts
|
||||
ip_ban_enabled: false
|
||||
# Allow access from Codespaces reverse proxy
|
||||
use_x_forwarded_for: true
|
||||
trusted_proxies:
|
||||
- 127.0.0.0/8
|
||||
- ::1
|
||||
- 192.168.0.0/16
|
||||
- 172.16.0.0/12
|
||||
- 10.0.0.0/8
|
||||
# CORS for development
|
||||
cors_allowed_origins:
|
||||
- "*"
|
||||
|
||||
# Config UI integration - useful for development
|
||||
config:
|
||||
|
||||
# Frontend - required for UI
|
||||
frontend:
|
||||
# Optional: Enable custom themes
|
||||
# themes: !include_dir_merge_named themes
|
||||
|
||||
automation:
|
||||
|
||||
|
|
@ -25,13 +60,106 @@ script:
|
|||
|
||||
scene:
|
||||
|
||||
# Useful default_config integrations for development
|
||||
# https://www.home-assistant.io/integrations/history/
|
||||
history:
|
||||
|
||||
# https://www.home-assistant.io/integrations/logbook/
|
||||
logbook:
|
||||
|
||||
# https://www.home-assistant.io/integrations/conversation/
|
||||
# conversation:
|
||||
# Note: Uncomment to enable voice assistant/conversation features
|
||||
# Dependencies (hassil, home-assistant-intents) are pre-installed in bootstrap
|
||||
|
||||
# https://www.home-assistant.io/integrations/webhook/
|
||||
webhook:
|
||||
|
||||
# https://www.home-assistant.io/integrations/my/
|
||||
my:
|
||||
|
||||
# https://www.home-assistant.io/integrations/recorder/
|
||||
recorder:
|
||||
# Development-friendly database settings
|
||||
# Reduce database size and improve performance
|
||||
purge_keep_days: 2
|
||||
commit_interval: 30
|
||||
# Exclude entities you don't need history for
|
||||
exclude:
|
||||
domains:
|
||||
# Sun position changes constantly, rarely needed in dev
|
||||
- sun
|
||||
# Backups don't need history
|
||||
- backup
|
||||
# Updates don't need full history tracking
|
||||
- update
|
||||
entity_globs:
|
||||
# Time sensors change every second/minute
|
||||
- sensor.time*
|
||||
- sensor.date*
|
||||
# Uptime sensors not interesting for development
|
||||
- sensor.*uptime*
|
||||
- sensor.*last_boot*
|
||||
# Memory/CPU sensors create a lot of data
|
||||
- sensor.*memory*
|
||||
- sensor.*cpu*
|
||||
event_types:
|
||||
# Very frequent, rarely needed in development
|
||||
- call_service
|
||||
# System events create lots of noise
|
||||
- system_log_event
|
||||
# Component loading events
|
||||
- component_loaded
|
||||
|
||||
energy:
|
||||
|
||||
# https://www.home-assistant.io/integrations/logger/
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
# Main integration logger - applies to ALL sub-loggers by default
|
||||
# Reduce noise from chatty components
|
||||
homeassistant.components.recorder: warning
|
||||
homeassistant.components.recorder.util: warning
|
||||
homeassistant.components.websocket_api: warning
|
||||
homeassistant.components.http.ban: warning
|
||||
homeassistant.components.zeroconf: warning
|
||||
homeassistant.components.ssdp: warning
|
||||
homeassistant.components.bluetooth: warning
|
||||
# Conversation can be noisy with hassil
|
||||
homeassistant.components.conversation: warning
|
||||
# Analytics/metrics are not interesting during development
|
||||
homeassistant.components.analytics: error
|
||||
|
||||
# Hide platform setup messages (scene, binary_sensor, sensor, etc.)
|
||||
homeassistant.components.scene: warning
|
||||
homeassistant.components.binary_sensor: warning
|
||||
homeassistant.components.sensor: warning
|
||||
homeassistant.components.event: warning
|
||||
homeassistant.components.switch: warning
|
||||
|
||||
# HTTP/network
|
||||
homeassistant.components.http: warning
|
||||
|
||||
# Keep loader at warning level to see real issues with our integration
|
||||
homeassistant.loader: warning
|
||||
|
||||
# Hide the verbose "Setting up X" messages during startup
|
||||
# but keep warnings/errors visible
|
||||
homeassistant.bootstrap: warning
|
||||
homeassistant.setup: warning
|
||||
|
||||
# Core system - keep visible for important messages
|
||||
homeassistant.core: info
|
||||
|
||||
# IMPORTANT for custom integration development:
|
||||
# Coordinator issues (API calls, update failures)
|
||||
homeassistant.helpers.update_coordinator: info
|
||||
# Entity registration problems
|
||||
homeassistant.helpers.entity_registry: info
|
||||
# Config flow debugging (setup, options)
|
||||
homeassistant.config_entries: info
|
||||
|
||||
# Your integration debug logging - shows EVERYTHING from your integration
|
||||
custom_components.tibber_prices: debug
|
||||
|
||||
# Reduce verbosity for details loggers (change to 'debug' for deep debugging)
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ def get_price_intervals_attributes(
|
|||
| {
|
||||
"period_position": i,
|
||||
"period_count_total": total_filtered,
|
||||
"periods_remaining": total_filtered - i,
|
||||
"period_count_remaining": total_filtered - i,
|
||||
}
|
||||
for i, period in enumerate(filtered_periods, 1)
|
||||
]
|
||||
|
|
@ -266,8 +266,8 @@ def add_detail_attributes(attributes: dict, current_period: dict) -> None:
|
|||
attributes["period_position"] = current_period["period_position"]
|
||||
if "period_count_total" in current_period:
|
||||
attributes["period_count_total"] = current_period["period_count_total"]
|
||||
if "periods_remaining" in current_period:
|
||||
attributes["periods_remaining"] = current_period["periods_remaining"]
|
||||
if "period_count_remaining" in current_period:
|
||||
attributes["period_count_remaining"] = current_period["period_count_remaining"]
|
||||
|
||||
|
||||
def add_period_count_attributes(
|
||||
|
|
@ -421,7 +421,7 @@ def build_final_attributes_simple(
|
|||
2. Core decision attributes (level, rating_level, rating_difference_%)
|
||||
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_%)
|
||||
5. Detail information (period_interval_count, period_position, period_count_total, periods_remaining)
|
||||
5. Detail information (period_interval_count, period_position, period_count_total, period_count_remaining)
|
||||
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
|
||||
relaxation_threshold_applied_%) - only if current period was relaxed
|
||||
7. Calculation summary (min_periods_configured, flat_days_detected,
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
|||
"period_price_diff_from_daily_min",
|
||||
"period_price_diff_from_daily_min_%",
|
||||
"period_count_total",
|
||||
"periods_remaining",
|
||||
"period_count_remaining",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ class PeriodSummary(TypedDict, total=False):
|
|||
period_interval_count: int # Number of intervals in period
|
||||
period_position: int # Period position (1-based)
|
||||
period_count_total: int # Total number of periods
|
||||
periods_remaining: int # Remaining periods after this one
|
||||
period_count_remaining: int # Remaining periods after this one
|
||||
|
||||
# Relaxation information (priority 6 - only if period was relaxed)
|
||||
relaxation_active: bool # Whether this period was found via relaxation
|
||||
|
|
@ -125,7 +125,7 @@ class PeriodAttributes(BaseAttributes, total=False):
|
|||
2. Core decision attributes (level, rating_level, rating_difference_%)
|
||||
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_%)
|
||||
5. Detail information (period_interval_count, period_position, period_count_total, periods_remaining)
|
||||
5. Detail information (period_interval_count, period_position, period_count_total, period_count_remaining)
|
||||
6. Relaxation information (only if period was relaxed)
|
||||
7. Meta information (periods list)
|
||||
"""
|
||||
|
|
@ -156,7 +156,7 @@ class PeriodAttributes(BaseAttributes, total=False):
|
|||
period_interval_count: int # Number of intervals in current/next period
|
||||
period_position: int # Period position (1-based)
|
||||
period_count_total: int # Total number of periods found
|
||||
periods_remaining: int # Remaining periods after current/next one
|
||||
period_count_remaining: int # Remaining periods after current/next one
|
||||
|
||||
# Relaxation information (priority 6 - only if period was relaxed)
|
||||
relaxation_active: bool # Whether current/next period was found via relaxation
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
|
|||
"""
|
||||
Recalculate period metadata after merging periods.
|
||||
|
||||
Updates period_position, period_count_total, and periods_remaining for all periods
|
||||
Updates period_position, period_count_total, and period_count_remaining for all periods
|
||||
based on chronological order.
|
||||
|
||||
This must be called after resolve_period_overlaps() to ensure metadata
|
||||
|
|
@ -79,7 +79,7 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
|
|||
for position, period in enumerate(periods, 1):
|
||||
period["period_position"] = position
|
||||
period["period_count_total"] = total_periods
|
||||
period["periods_remaining"] = total_periods - position
|
||||
period["period_count_remaining"] = total_periods - position
|
||||
|
||||
|
||||
def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ def build_period_summary_dict(
|
|||
"period_interval_count": period_data.period_length,
|
||||
"period_position": period_data.period_idx,
|
||||
"period_count_total": period_data.total_periods,
|
||||
"periods_remaining": period_data.total_periods - period_data.period_idx,
|
||||
"period_count_remaining": period_data.total_periods - period_data.period_idx,
|
||||
}
|
||||
|
||||
# Add period price difference attributes based on sensor type (step 4)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation
|
||||
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation, calculate_iqr_stats
|
||||
|
||||
from .period_overlap import (
|
||||
recalculate_period_metadata,
|
||||
|
|
@ -51,7 +51,10 @@ FLEX_WARNING_VSHAPE_RATIO = 0.5 # span/ref_price ratio below which a day is con
|
|||
# On flat price days (low variation), it is unrealistic to require multiple distinct
|
||||
# best/peak price periods. Requiring 2+ periods would force relaxation to create
|
||||
# artificial periods that don't represent genuine price structure.
|
||||
LOW_CV_FLAT_DAY_THRESHOLD = 10.0 # %: days with CV ≤ this need only 1 period
|
||||
LOW_CV_FLAT_DAY_THRESHOLD = 10.0 # %: fallback when IQR% not available (near-zero or negative median)
|
||||
# IQR% ≤ 15% ≈ CV ≤ 10% for clean data, but also catches "flat + isolated spike" days correctly:
|
||||
# a single spike inflates CV to 15-25% while leaving IQR% near 0-5%.
|
||||
LOW_IQR_PCT_FLAT_DAY_THRESHOLD = 15.0 # %: days with IQR% ≤ this need only 1 period
|
||||
|
||||
|
||||
def _check_period_quality(
|
||||
|
|
@ -448,11 +451,14 @@ def _compute_day_effective_min(
|
|||
"""
|
||||
Compute per-day effective min_periods with flat-day adaptation.
|
||||
|
||||
On days with very low price variation (CV ≤ LOW_CV_FLAT_DAY_THRESHOLD),
|
||||
On days with very low price variation (IQR% ≤ LOW_IQR_PCT_FLAT_DAY_THRESHOLD),
|
||||
requiring multiple distinct cheapest/peak periods is unrealistic. Finding
|
||||
ONE period is sufficient because there is no meaningful price structure that
|
||||
would create natural multiple periods.
|
||||
|
||||
Uses IQR% as primary metric (robust to isolated price spikes) with CV as
|
||||
fallback when IQR% is undefined (near-zero or negative median prices).
|
||||
|
||||
This applies ONLY to BEST PRICE periods (reverse_sort=False). For PEAK PRICE
|
||||
periods, full relaxation should run even on flat days because identifying the
|
||||
genuinely most expensive window requires the complete filter evaluation.
|
||||
|
|
@ -471,7 +477,6 @@ def _compute_day_effective_min(
|
|||
"""
|
||||
day_effective_min = {}
|
||||
flat_day_count = 0
|
||||
min_prices_for_cv = 2 # Need at least 2 prices to calculate CV
|
||||
|
||||
for day, day_prices in prices_by_day.items():
|
||||
if not enable_relaxation or min_periods <= 1 or reverse_sort:
|
||||
|
|
@ -481,30 +486,46 @@ def _compute_day_effective_min(
|
|||
|
||||
price_values = [float(p["total"]) for p in day_prices if p.get("total") is not None]
|
||||
|
||||
if len(price_values) < min_prices_for_cv:
|
||||
if len(price_values) < 2: # noqa: PLR2004 - need at least 2 prices for any metric
|
||||
day_effective_min[day] = min_periods
|
||||
continue
|
||||
|
||||
day_cv = calculate_coefficient_of_variation(price_values)
|
||||
# Primary flat-day metric: IQR% is robust to isolated price spikes.
|
||||
# A single spike inflates CV to 15-25% while leaving IQR% near 0-5%,
|
||||
# so IQR correctly identifies "flat core + spike" days as flat.
|
||||
iqr_stats = calculate_iqr_stats(price_values)
|
||||
iqr_pct = iqr_stats["iqr_pct"] if iqr_stats else None
|
||||
|
||||
if day_cv is not None and day_cv <= LOW_CV_FLAT_DAY_THRESHOLD:
|
||||
is_flat = False
|
||||
flat_metric = ""
|
||||
|
||||
if iqr_pct is not None:
|
||||
is_flat = iqr_pct <= LOW_IQR_PCT_FLAT_DAY_THRESHOLD
|
||||
flat_metric = f"IQR%={iqr_pct:.1f}% ≤ {LOW_IQR_PCT_FLAT_DAY_THRESHOLD:.0f}%"
|
||||
else:
|
||||
# IQR% undefined (near-zero or negative median): fall back to CV
|
||||
day_cv = calculate_coefficient_of_variation(price_values)
|
||||
if day_cv is not None:
|
||||
is_flat = day_cv <= LOW_CV_FLAT_DAY_THRESHOLD
|
||||
flat_metric = f"CV={day_cv:.1f}% ≤ {LOW_CV_FLAT_DAY_THRESHOLD:.0f}% (IQR% N/A)"
|
||||
|
||||
if is_flat:
|
||||
day_effective_min[day] = 1
|
||||
flat_day_count += 1
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%sDay %s: flat price profile (CV=%.1f%% ≤ %.1f%%) → min_periods relaxed to 1",
|
||||
"%sDay %s: flat price profile (%s) → min_periods relaxed to 1",
|
||||
INDENT_L1,
|
||||
day,
|
||||
day_cv,
|
||||
LOW_CV_FLAT_DAY_THRESHOLD,
|
||||
flat_metric,
|
||||
)
|
||||
else:
|
||||
day_effective_min[day] = min_periods
|
||||
|
||||
if flat_day_count > 0:
|
||||
_LOGGER.info(
|
||||
"Adaptive min_periods: %d flat day(s) (CV ≤ %.0f%%) need only 1 period instead of %d",
|
||||
"Adaptive min_periods: %d flat day(s) (IQR%% ≤ %.0f%%) need only 1 period instead of %d",
|
||||
flat_day_count,
|
||||
LOW_CV_FLAT_DAY_THRESHOLD,
|
||||
LOW_IQR_PCT_FLAT_DAY_THRESHOLD,
|
||||
min_periods,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
"""
|
||||
Shape-based period extension: extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals.
|
||||
Shape-based period extension: extend periods into adjacent cheap/expensive intervals.
|
||||
|
||||
After periods are identified by the core algorithm, this module optionally extends
|
||||
each period's boundaries to include any directly-adjacent intervals that carry the
|
||||
most extreme price level relevant to the period type:
|
||||
each period's boundaries to include any directly-adjacent intervals that carry a
|
||||
favourable price level relevant to the period type:
|
||||
|
||||
- Best price periods → extend into VERY_CHEAP neighbouring intervals
|
||||
- Peak price periods → extend into VERY_EXPENSIVE neighbouring intervals
|
||||
- Best price periods → extend into VERY_CHEAP neighbours; fall back to CHEAP
|
||||
on each side where no VERY_CHEAP neighbour exists.
|
||||
- Peak price periods → extend into VERY_EXPENSIVE neighbours; fall back to
|
||||
EXPENSIVE on each side where no VERY_EXPENSIVE exists.
|
||||
|
||||
The fallback is evaluated **per side independently**: one side may extend via
|
||||
VERY_CHEAP while the other side falls back to CHEAP.
|
||||
|
||||
Extension is purely additive and opt-in (disabled by default). It does not affect
|
||||
the core period-finding logic; periods that would not normally be found are not
|
||||
|
|
@ -20,6 +25,8 @@ from datetime import timedelta
|
|||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
PRICE_LEVEL_CHEAP,
|
||||
PRICE_LEVEL_EXPENSIVE,
|
||||
PRICE_LEVEL_VERY_CHEAP,
|
||||
PRICE_LEVEL_VERY_EXPENSIVE,
|
||||
)
|
||||
|
|
@ -55,13 +62,17 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context
|
|||
time: TibberPricesTimeService,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Extend each period into adjacent VERY_CHEAP or VERY_EXPENSIVE intervals.
|
||||
Extend each period into adjacent cheap/expensive intervals.
|
||||
|
||||
For best price periods (reverse_sort=False): extend into VERY_CHEAP neighbours.
|
||||
For peak price periods (reverse_sort=True): extend into VERY_EXPENSIVE neighbours.
|
||||
For best price periods (reverse_sort=False):
|
||||
Primary: extend into VERY_CHEAP neighbours.
|
||||
Fallback: extend into CHEAP neighbours (per side, only if no VERY_CHEAP found).
|
||||
For peak price periods (reverse_sort=True):
|
||||
Primary: extend into VERY_EXPENSIVE neighbours.
|
||||
Fallback: extend into EXPENSIVE neighbours (per side, only if no VERY_EXPENSIVE found).
|
||||
|
||||
Only intervals that are directly contiguous with the period and carry the
|
||||
target level are added. At most *max_extension_intervals* are consumed on
|
||||
required level are added. At most *max_extension_intervals* are consumed on
|
||||
each side independently. Period statistics are fully recalculated after
|
||||
any extension.
|
||||
|
||||
|
|
@ -82,7 +93,12 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context
|
|||
if not periods or max_extension_intervals <= 0:
|
||||
return periods
|
||||
|
||||
target_level = PRICE_LEVEL_VERY_EXPENSIVE if reverse_sort else PRICE_LEVEL_VERY_CHEAP
|
||||
if reverse_sort:
|
||||
primary_level = PRICE_LEVEL_VERY_EXPENSIVE
|
||||
fallback_level = PRICE_LEVEL_EXPENSIVE
|
||||
else:
|
||||
primary_level = PRICE_LEVEL_VERY_CHEAP
|
||||
fallback_level = PRICE_LEVEL_CHEAP
|
||||
|
||||
# Build a lookup dict: local datetime → full interval dict
|
||||
interval_index: dict[datetime, dict[str, Any]] = {}
|
||||
|
|
@ -95,7 +111,8 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context
|
|||
_extend_period_edges(
|
||||
period,
|
||||
interval_index,
|
||||
target_level=target_level,
|
||||
primary_level=primary_level,
|
||||
fallback_level=fallback_level,
|
||||
max_intervals=max_extension_intervals,
|
||||
thresholds=thresholds,
|
||||
price_context=price_context,
|
||||
|
|
@ -107,25 +124,72 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context
|
|||
# ── private helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge extension requires many args, branches, and statements
|
||||
def _walk_contiguous(
|
||||
interval_index: dict[datetime, dict[str, Any]],
|
||||
start_cursor: datetime,
|
||||
step: timedelta,
|
||||
target_level: str,
|
||||
max_intervals: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Walk contiguously from *start_cursor* in direction *step*, collecting intervals.
|
||||
|
||||
Stops when the next interval is missing from the index, does not carry
|
||||
*target_level*, or the *max_intervals* cap is reached.
|
||||
|
||||
Args:
|
||||
interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``.
|
||||
start_cursor: First position to check (already offset from the period edge).
|
||||
step: ``+_INTERVAL_DURATION`` for rightward, ``-_INTERVAL_DURATION`` for leftward.
|
||||
target_level: Required ``level`` value (e.g. ``"VERY_CHEAP"``).
|
||||
max_intervals: Maximum intervals to collect.
|
||||
|
||||
Returns:
|
||||
Collected intervals in chronological order (reversed for leftward walks).
|
||||
|
||||
"""
|
||||
additions: list[dict[str, Any]] = []
|
||||
cursor = start_cursor
|
||||
for _ in range(max_intervals):
|
||||
iv = interval_index.get(cursor)
|
||||
if iv is None or iv.get("level") != target_level:
|
||||
break
|
||||
additions.append(iv)
|
||||
cursor += step
|
||||
|
||||
# For leftward walks the list was built newest-first; reverse to chronological
|
||||
if step < timedelta(0):
|
||||
additions.reverse()
|
||||
|
||||
return additions
|
||||
|
||||
|
||||
def _extend_period_edges( # noqa: PLR0913 - Period edge extension requires many args
|
||||
period: dict[str, Any],
|
||||
interval_index: dict[datetime, dict[str, Any]],
|
||||
*,
|
||||
target_level: str,
|
||||
primary_level: str,
|
||||
fallback_level: str,
|
||||
max_intervals: int,
|
||||
thresholds: TibberPricesThresholdConfig,
|
||||
price_context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Consume adjacent target-level intervals on both edges of a period.
|
||||
Consume adjacent intervals on both edges of a period.
|
||||
|
||||
Each side is evaluated independently:
|
||||
1. Try extending into *primary_level* neighbours (VERY_CHEAP / VERY_EXPENSIVE).
|
||||
2. If no primary-level neighbours were found on that side, fall back to
|
||||
*fallback_level* neighbours (CHEAP / EXPENSIVE).
|
||||
|
||||
The original period dict is never mutated; a new dict is returned.
|
||||
If no extension is possible, the original dict is returned unchanged.
|
||||
If no extension is possible on either side, the original dict is returned.
|
||||
|
||||
Args:
|
||||
period: Period summary dict with ``start`` and ``end`` datetime keys.
|
||||
interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``.
|
||||
target_level: ``"VERY_CHEAP"`` or ``"VERY_EXPENSIVE"``.
|
||||
primary_level: Preferred level (``"VERY_CHEAP"`` or ``"VERY_EXPENSIVE"``).
|
||||
fallback_level: Fallback level (``"CHEAP"`` or ``"EXPENSIVE"``).
|
||||
max_intervals: Maximum intervals that may be added on each side.
|
||||
thresholds: Threshold config for aggregation helpers.
|
||||
price_context: Reference prices / averages per calendar day.
|
||||
|
|
@ -139,25 +203,21 @@ def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge exten
|
|||
# ``end`` is the exclusive boundary: the last included interval starts at
|
||||
# ``end - _INTERVAL_DURATION``.
|
||||
|
||||
backward_step = -_INTERVAL_DURATION
|
||||
forward_step = _INTERVAL_DURATION
|
||||
|
||||
# ── walk LEFT (earlier than period start) ─────────────────────────────────
|
||||
left_additions: list[dict[str, Any]] = []
|
||||
cursor = start - _INTERVAL_DURATION
|
||||
for _ in range(max_intervals):
|
||||
iv = interval_index.get(cursor)
|
||||
if iv is None or iv.get("level") != target_level:
|
||||
break
|
||||
left_additions.insert(0, iv)
|
||||
cursor -= _INTERVAL_DURATION
|
||||
left_cursor = start - _INTERVAL_DURATION
|
||||
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, primary_level, max_intervals)
|
||||
if not left_additions:
|
||||
# Fallback: no primary-level neighbours on this side → try fallback level
|
||||
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, fallback_level, max_intervals)
|
||||
|
||||
# ── walk RIGHT (later than period end) ────────────────────────────────────
|
||||
right_additions: list[dict[str, Any]] = []
|
||||
cursor = end # first interval AFTER the period
|
||||
for _ in range(max_intervals):
|
||||
iv = interval_index.get(cursor)
|
||||
if iv is None or iv.get("level") != target_level:
|
||||
break
|
||||
right_additions.append(iv)
|
||||
cursor += _INTERVAL_DURATION
|
||||
right_additions = _walk_contiguous(interval_index, end, forward_step, primary_level, max_intervals)
|
||||
if not right_additions:
|
||||
# Fallback: no primary-level neighbours on this side → try fallback level
|
||||
right_additions = _walk_contiguous(interval_index, end, forward_step, fallback_level, max_intervals)
|
||||
|
||||
total_added = len(left_additions) + len(right_additions)
|
||||
if total_added == 0:
|
||||
|
|
@ -199,7 +259,7 @@ def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge exten
|
|||
cv_pct = round(statistics.stdev(prices_for_vol) / mean_p * 100, 1)
|
||||
|
||||
# ── assemble updated period dict (keep structural fields, update statistics) ─
|
||||
reverse_sort = target_level == PRICE_LEVEL_VERY_EXPENSIVE
|
||||
reverse_sort = primary_level == PRICE_LEVEL_VERY_EXPENSIVE
|
||||
updated: dict[str, Any] = {
|
||||
**period,
|
||||
# Time fields
|
||||
|
|
|
|||
|
|
@ -510,6 +510,61 @@
|
|||
"description": "Leichtgewichtige Metadaten für Diagrammkonfiguration",
|
||||
"long_description": "Liefert wesentliche Diagrammkonfigurationswerte als Sensor-Attribute. Nützlich für jede Diagrammkarte, die Y-Achsen-Grenzen benötigt. Der Sensor ruft get_chartdata im Nur-Metadaten-Modus auf (keine Datenverarbeitung) und extrahiert: yaxis_min, yaxis_max (vorgeschlagener Y-Achsenbereich für optimale Skalierung). Der Status spiegelt das Service-Call-Ergebnis wider: 'ready' bei Erfolg, 'error' bei Fehler, 'pending' während der Initialisierung.",
|
||||
"usage_tips": "Konfiguriere über configuration.yaml unter tibber_prices.chart_metadata_config (optional: day, subunit_currency, resolution). Der Sensor aktualisiert sich automatisch bei Preisdatenänderungen. Greife auf Metadaten aus Attributen zu: yaxis_min, yaxis_max. Verwende mit config-template-card oder jedem Tool, das Entity-Attribute liest - perfekt für dynamische Diagrammkonfiguration ohne manuelle Berechnungen."
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"description": "Position des aktuellen Intervallpreises in der heutigen Rangliste — Perzentilrang (0 % = günstigster Moment)",
|
||||
"long_description": "Zeigt, wie günstig oder teuer der aktuelle Viertelstunden-Intervallpreis im Vergleich zu allen 96 heutigen Slots ist. 0 % bedeutet: Dieser Moment ist der günstigste des Tages. 50 % bedeutet: Die Hälfte der Slots ist günstiger. ca. 99 % bedeutet: Dieser Slot ist der teuerste des Tages. Formel (Perzentilrang): Anzahl günstigerer Slots ÷ Gesamtanzahl × 100. Attribute: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Ideal für Automationen: 'Wenn current_interval_price_rank_today < 25, Spülmaschine starten' (günstigstes Viertel des Tages). Oder 'Wenn current_interval_price_rank_today > 75, Wärmepumpe pausieren'. Ein Wert von 0 garantiert den günstigsten Slot des Tages."
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"description": "Perzentilrang des aktuellen Intervallpreises in der morgigen Rangliste (0 % = günstigster von morgen)",
|
||||
"long_description": "Zeigt, wie der aktuelle Intervallpreis im Vergleich zu allen 96 morgigen Viertelstunden-Slots abschneidet. Nützlich, um zu entscheiden, ob man bis morgen warten soll. 0 % bedeutet: Der aktuelle Preis ist günstiger als jeder morgige Slot. Gibt 'Unbekannt' zurück, bis die morgigen Daten verfügbar sind (typischerweise nach 13:00 Uhr). Attribute: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Warten lohnt sich? 'Wenn current_interval_price_rank_tomorrow < 10, gibt es morgen noch günstigere Slots — Aufgabe verschieben'. Am besten mit einem Binärsensor kombinieren."
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"description": "Perzentilrang des aktuellen Intervallpreises über heute+morgen zusammen (0 % = günstigstes des Zweitages-Fensters)",
|
||||
"long_description": "Zeigt, wie günstig oder teuer der aktuelle Intervallpreis im Vergleich zu allen Slots über heute und morgen zusammen ist (bis zu 192 Viertelstunden-Slots). Fällt auf nur heute zurück, wenn morgige Daten noch nicht verfügbar sind. 0 % = günstigster Slot des kombinierten Zweitages-Fensters. Attribute: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Das breiteste Signal für 'Ist jetzt ein guter Zeitpunkt?'. Verwende 'Wenn current_interval_price_rank_today_tomorrow < 20, energieintensive Aufgabe jetzt starten'."
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"description": "Perzentilrang des nächsten Intervallpreises in der heutigen Rangliste (0 % = günstigster Moment heute)",
|
||||
"long_description": "Zeigt den Perzentilrang des nächsten Viertelstunden-Intervalls innerhalb der 96 heutigen Slots. Ermöglicht einen Blick voraus, bevor das nächste Intervall beginnt. Attribute: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Für Vorbereitung: 'Wenn next_interval_price_rank_today < 15, jetzt vorheizen, damit das Gerät im nächsten günstigen Slot läuft'."
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"description": "Perzentilrang des nächsten Intervallpreises über heute+morgen zusammen (0 % = günstigstes des Zweitages-Fensters)",
|
||||
"long_description": "Zeigt den Perzentilrang des nächsten Viertelstunden-Intervalls innerhalb des kombinierten heute+morgen-Pools (bis zu 192 Slots). Fällt auf nur heute zurück, wenn morgige Daten nicht verfügbar sind. Attribute: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Weitester Vorausblick: 'Wenn next_interval_price_rank_today_tomorrow < 10, ist das nächste Intervall eines der günstigsten im Zweitages-Fenster'."
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"description": "Perzentilrang des letzten Intervallpreises in der heutigen Rangliste (0 % = günstigster Moment heute)",
|
||||
"long_description": "Zeigt den Perzentilrang des gerade beendeten Viertelstunden-Intervalls innerhalb der 96 heutigen Slots. Nützlich für Protokollierung. Attribute: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Für retrospektive Automationen: 'Preisniveau des letzten Intervalls für Energieberichte aufzeichnen'."
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"description": "Perzentilrang des letzten Intervallpreises über heute+morgen zusammen (0 % = günstigstes des Zweitages-Fensters)",
|
||||
"long_description": "Zeigt den Perzentilrang des gerade beendeten Viertelstunden-Intervalls innerhalb des kombinierten heute+morgen-Pools (bis zu 192 Slots). Fällt auf nur heute zurück. Attribute: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Für retrospektive Vergleiche über ein Zweitages-Fenster."
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"description": "Perzentilrang des gleitenden Stunden-Durchschnittspreises in der heutigen Rangliste (0 % = günstigste Stunde heute)",
|
||||
"long_description": "Zeigt, wo der gleitende 5-Intervall-Durchschnitt (2 Intervalle vor + aktuell + 2 danach, ca. 1 Stunde) in der heutigen Preisrangliste liegt. Glättet kurze Preisspitzen für eine breitere Einschätzung. Attribute: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Für Aufgaben, die ca. eine Stunde dauern: 'Wenn current_hour_price_rank_today < 20, ist jetzt eine günstige Stunde — Waschmaschine starten'."
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"description": "Gleitender Stunden-Durchschnittspreisrang über heute+morgen zusammen (0 % = günstigste Stunde im Zweitages-Fenster)",
|
||||
"long_description": "Zeigt, wo der gleitende 5-Intervall-Durchschnitt (±2 Intervalle, ca. 1 Stunde) in der kombinierten heute+morgen-Rangliste liegt (bis zu 192 Slots). Fällt auf nur heute zurück. Attribute: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Weitestes Stundensignal: 'Wenn current_hour_price_rank_today_tomorrow < 15, ist dies eine der günstigsten Stunden im Zweitages-Fenster'."
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"description": "Perzentilrang des nächsten gleitenden Stunden-Durchschnittspreises in der heutigen Rangliste (0 % = günstigste Stunde heute)",
|
||||
"long_description": "Zeigt, wo der auf das nächste Intervall zentrierte gleitende 5-Intervall-Durchschnitt in der heutigen Preisrangliste liegt. Ermöglicht Planung eine Stunde im Voraus. Attribute: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Eine Stunde vorausplanen: 'Wenn next_hour_price_rank_today < 20, ist die kommende Stunde günstig — Aufgabe jetzt starten'."
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"description": "Nächster gleitender Stunden-Durchschnittspreisrang über heute+morgen zusammen (0 % = günstigste Stunde im Zweitages-Fenster)",
|
||||
"long_description": "Zeigt, wo der auf das nächste Intervall zentrierte gleitende 5-Intervall-Durchschnitt in der kombinierten heute+morgen-Rangliste liegt (bis zu 192 Slots). Fällt auf nur heute zurück. Attribute: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Weitester Stundenvorausblick: 'Wenn next_hour_price_rank_today_tomorrow < 10, ist die kommende Stunde eine der günstigsten im Zweitages-Fenster'."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -556,7 +611,7 @@
|
|||
"usage_tips": "Erhöhe den Wert, wenn du strengere Bestpreis-Kriterien möchtest. Verringere ihn, wenn zu wenige Perioden erkannt werden."
|
||||
},
|
||||
"best_price_min_period_length_override": {
|
||||
"description": "Minimale Periodenl\u00e4nge in 15-Minuten-Intervallen. Perioden kürzer als diese werden nicht gemeldet. Beispiel: 2 = mindestens 30 Minuten.",
|
||||
"description": "Minimale Periodenlänge in 15-Minuten-Intervallen. Perioden kürzer als diese werden nicht gemeldet. Beispiel: 2 = mindestens 30 Minuten.",
|
||||
"long_description": "Wenn diese Entität aktiviert ist, überschreibt ihr Wert die Einstellung 'Mindestperiodenlänge' aus dem Optionen-Dialog für die Bestpreis-Periodenberechnung.",
|
||||
"usage_tips": "Passe an die typische Laufzeit deiner Geräte an: 2 (30 Min) für Schnellprogramme, 4-8 (1-2 Std) für normale Zyklen, 8+ für lange ECO-Programme."
|
||||
},
|
||||
|
|
@ -586,7 +641,7 @@
|
|||
"usage_tips": "Erhöhe den Wert, um nur extreme Preisspitzen zu erfassen. Verringere ihn, um mehr Hochpreiszeiten einzubeziehen."
|
||||
},
|
||||
"peak_price_min_period_length_override": {
|
||||
"description": "Minimale Periodenl\u00e4nge in 15-Minuten-Intervallen für Spitzenpreise. Kürzere Preisspitzen werden nicht als Perioden gemeldet.",
|
||||
"description": "Minimale Periodenlänge in 15-Minuten-Intervallen für Spitzenpreise. Kürzere Preisspitzen werden nicht als Perioden gemeldet.",
|
||||
"long_description": "Wenn diese Entität aktiviert ist, überschreibt ihr Wert die Einstellung 'Mindestperiodenlänge' aus dem Optionen-Dialog für die Spitzenpreis-Periodenberechnung.",
|
||||
"usage_tips": "Kürzere Werte erfassen kurze Preisspitzen. Längere Werte fokussieren auf anhaltende Hochpreisphasen."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -493,8 +493,8 @@
|
|||
},
|
||||
"day_pattern_today": {
|
||||
"description": "Detected price shape of today's electricity prices",
|
||||
"long_description": "Classifies today into a price shape: Valley (cheap in the middle of the day), Peak (expensive in the middle), Double Valley (W-shape, two cheap windows), Double Peak (M-shape, two expensive peaks), Flat (prices barely move), Rising (prices climb through the day), Falling (prices drop through the day), or Mixed. Attributes include confidence (0\u20131), coefficient of variation, knee-point times, and intra-day segments.",
|
||||
"usage_tips": "Use today's pattern to decide when to shift loads. A Valley day means cheap prices around midday \u2014 ideal for running the dishwasher, washing machine, or charging the EV. A Peak day means expensive midday \u2014 run appliances early morning or late evening. Use valley_start and valley_end attributes to schedule automations precisely."
|
||||
"long_description": "Classifies today into a price shape: Valley (cheap in the middle of the day), Peak (expensive in the middle), Double Valley (W-shape, two cheap windows), Double Peak (M-shape, two expensive peaks), Flat (prices barely move), Rising (prices climb through the day), Falling (prices drop through the day), or Mixed. Attributes include confidence (0–1), coefficient of variation, knee-point times, and intra-day segments.",
|
||||
"usage_tips": "Use today's pattern to decide when to shift loads. A Valley day means cheap prices around midday — ideal for running the dishwasher, washing machine, or charging the EV. A Peak day means expensive midday — run appliances early morning or late evening. Use valley_start and valley_end attributes to schedule automations precisely."
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"description": "Detected price shape of tomorrow's electricity prices",
|
||||
|
|
@ -510,6 +510,61 @@
|
|||
"description": "Lightweight metadata for chart configuration",
|
||||
"long_description": "Provides essential chart configuration values as sensor attributes. Useful for any chart card that needs Y-axis bounds. The sensor calls get_chartdata with metadata-only mode (no data processing) and extracts: yaxis_min, yaxis_max (suggested Y-axis range for optimal scaling). The state reflects the service call result: 'ready' when successful, 'error' on failure, 'pending' during initialization.",
|
||||
"usage_tips": "Configure via configuration.yaml under tibber_prices.chart_metadata_config (optional: day, subunit_currency, resolution). The sensor automatically refreshes when price data updates. Access metadata from attributes: yaxis_min, yaxis_max. Use with config-template-card or any tool that reads entity attributes - perfect for dynamic chart configuration without manual calculations."
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"description": "Where the current interval's price sits in today's ranking — its percentile rank (0% = cheapest moment)",
|
||||
"long_description": "Shows how cheap or expensive the current quarter-hour interval's price is compared to all of today's 96 quarter-hour slots. 0% means this is the cheapest moment of the day — every other slot costs more. 50% means half of today's slots are cheaper. ~99% means it's the most expensive slot of the day. Formula (percentile rank): how many slots are cheaper ÷ total slots × 100. Attributes: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Ideal for automations: 'If current_interval_price_rank_today < 25, start dishwasher' (cheapest quarter of the day). Or 'If current_interval_price_rank_today > 75, pause heat pump' (most expensive quarter). A value of 0 guarantees you're at the cheapest slot of the day."
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"description": "Where the current interval's price sits in tomorrow's percentile ranking (0% = cheapest of tomorrow)",
|
||||
"long_description": "Shows how the current interval's price compares to all of tomorrow's 96 quarter-hour slots — its percentile rank within tomorrow's distribution. Useful for deciding whether to wait until tomorrow. 0% means the current price is cheaper than every slot tomorrow. Returns 'Unknown' until tomorrow's data arrives (typically after 13:00). Attributes: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Use to decide whether to wait: 'If current_interval_price_rank_tomorrow < 10, tomorrow has even cheaper slots — postpone the task'. Best combined with a binary sensor to confirm the task can actually run tomorrow."
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"description": "Current interval's percentile rank across today and tomorrow combined (0% = cheapest of the two-day window)",
|
||||
"long_description": "Shows how cheap or expensive the current interval's price is compared to all slots across today and tomorrow together (up to 192 quarter-hour slots when both days are available) — the percentile rank within the two-day distribution. Gives the broadest view for flexible tasks. Falls back to today-only when tomorrow's data isn't available yet. 0% = cheapest of the combined two-day window. Attributes: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "The broadest signal for 'is now a good time?'. Use 'If current_interval_price_rank_today_tomorrow < 20, run energy-intensive task now'. Especially valuable when tasks can wait a full day — a value near 0 across two days is a genuinely exceptional price."
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"description": "Where the next interval's price sits in today's ranking (0% = cheapest moment of today)",
|
||||
"long_description": "Shows the percentile rank of the upcoming quarter-hour interval's price within today's 96 slots. Lets you see at a glance how the next interval compares to the rest of the day before it starts. Attributes: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Use to prepare for the next interval: 'If next_interval_price_rank_today < 15, start pre-heating now so the device runs during the next cheap slot'."
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"description": "Next interval's percentile rank across today and tomorrow combined (0% = cheapest of the two-day window)",
|
||||
"long_description": "Shows the percentile rank of the upcoming quarter-hour interval's price within the combined today+tomorrow pool (up to 192 slots). Falls back to today-only when tomorrow's data isn't available. Attributes: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Broadest look-ahead: 'If next_interval_price_rank_today_tomorrow < 10, the next interval is among the cheapest slots of the two-day window — optimal time to start long tasks'."
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"description": "Where the previous interval's price sat in today's ranking (0% = cheapest moment of today)",
|
||||
"long_description": "Shows the percentile rank of the just-ended quarter-hour interval's price within today's 96 slots. Useful for logging how cheap/expensive the last interval was. Attributes: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Useful for retrospective automations or logging: 'Record the cost tier of the last interval for energy reports'."
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"description": "Previous interval's percentile rank across today and tomorrow combined (0% = cheapest of the two-day window)",
|
||||
"long_description": "Shows the percentile rank of the just-ended quarter-hour interval's price within the combined today+tomorrow pool (up to 192 slots). Falls back to today-only when tomorrow's data isn't available. Attributes: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Useful for retrospective comparisons across a two-day window."
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"description": "Percentile rank of the current rolling hour's average price within today's distribution (0% = cheapest hour)",
|
||||
"long_description": "Shows where the 5-interval rolling average (2 intervals before + current + 2 after, ~1 hour) sits in today's price ranking. Smooths out short spikes and gives a broader view of whether this hour is cheap or expensive relative to the day. Attributes: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "For tasks that take about an hour: 'If current_hour_price_rank_today < 20, this is a cheap hour — run the washing machine'."
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"description": "Current rolling hour's average price rank across today and tomorrow combined (0% = cheapest hour of the two-day window)",
|
||||
"long_description": "Shows where the 5-interval rolling average (±2 intervals, ~1 hour) sits in the combined today+tomorrow price ranking (up to 192 slots). Falls back to today-only when tomorrow's data isn't available. Attributes: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Broadest hourly signal: 'If current_hour_price_rank_today_tomorrow < 15, this is one of the cheapest hours across two days — ideal for long flexible tasks'."
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"description": "Percentile rank of the next rolling hour's average price within today's distribution (0% = cheapest hour of today)",
|
||||
"long_description": "Shows where the 5-interval rolling average centered on the next interval sits in today's price ranking. Lets you plan one hour ahead — is the upcoming hour cheap or expensive relative to today? Attributes: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Plan one hour ahead: 'If next_hour_price_rank_today < 20, the upcoming hour is cheap — start a task now to run through it'."
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"description": "Next rolling hour's average price rank across today and tomorrow combined (0% = cheapest hour of the two-day window)",
|
||||
"long_description": "Shows where the 5-interval rolling average centered on the next interval sits in the combined today+tomorrow price ranking (up to 192 slots). Falls back to today-only when tomorrow's data isn't available. Attributes: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Broadest hourly look-ahead: 'If next_hour_price_rank_today_tomorrow < 10, the upcoming hour is among the cheapest of the two-day window'."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
|
|||
|
|
@ -510,6 +510,61 @@
|
|||
"description": "Lettvekts metadata for diagramkonfigurasjon",
|
||||
"long_description": "Gir essensielle diagramkonfigurasjonsverdier som sensorattributter. Nyttig for ethvert diagramkort som trenger Y-aksegrenser. Sensoren kaller get_chartdata med kun-metadata-modus (ingen databehandling) og trekker ut: yaxis_min, yaxis_max (foreslått Y-akseområde for optimal skalering). Status reflekterer tjenestekallresultatet: 'ready' ved suksess, 'error' ved feil, 'pending' under initialisering.",
|
||||
"usage_tips": "Konfigurer via configuration.yaml under tibber_prices.chart_metadata_config (valgfritt: day, subunit_currency, resolution). Sensoren oppdateres automatisk når prisdata endres. Få tilgang til metadata fra attributter: yaxis_min, yaxis_max. Bruk med config-template-card eller ethvert verktøy som leser entitetsattributter - perfekt for dynamisk diagramkonfigurasjon uten manuelle beregninger."
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"description": "Hvor nåværende intervallpris plasserer seg i dagens rangering — som prosentilrang (0 % = billigste øyeblikk)",
|
||||
"long_description": "Viser hvor billig eller dyr prisen for det gjældende kvarter er sammenlignet med alle 96 kvarterstimer i dag. 0 % betyr at dette er det billigste øyeblikket i dag. 50 % betyr at halvparten av dagens tidsluker er billigere. ca. 99 % betyr det dyreste tidssluket i dag. Formel: antall billigere tidsluker ÷ totalt antall × 100. Attributter: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Ideelt for automatiseringer: 'Hvis current_interval_price_rank_today < 25, start oppvaskmaskinen'. A value of 0 garanterer at du er på det billigste tidssluket i dag."
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"description": "Prosentilrang for gjældende intervallpris i morgendagens rangering (0 % = billigste av i morgen)",
|
||||
"long_description": "Viser hvordan gjældende intervallpris sammenlignes med alle 96 kvarterstimer i morgen. 0 % betyr at gjældende pris er billigere enn alle morgendagens tidsluker. Returnerer 'Ukjent' til morgendagens data er tilgjengelig (vanligvis etter kl. 13:00). Attributter: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bruk for å avgjøre om det er verdt å vente: 'Hvis current_interval_price_rank_tomorrow < 10, finnes det enda billigere tidsluker i morgen — utsett oppgaven'."
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"description": "Prosentilrang for gjældende intervallpris over i dag+i morgen samlet (0 % = billigste i to-dagers-vinduet)",
|
||||
"long_description": "Viser hvor billig eller dyr gjældende intervallpris er sammenlignet med alle tidsluker over i dag og i morgen samlet (opptil 192 kvarterstimer). Fæller tilbake til kun i dag når morgendagens data ikke er tilgjengelig. Attributter: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Det bredeste signalet for 'er det nå et godt tidspunkt?'. Bruk 'Hvis current_interval_price_rank_today_tomorrow < 20, kjør energikrevende oppgave nå'."
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"description": "Prosentilrang for neste intervalls pris i dagens rangering (0 % = billigste øyeblikk i dag)",
|
||||
"long_description": "Viser prosentilrangen for det kommende kvarter innenfor dagens 96 tidsluker. Gir forhåndsvisning før neste intervall begynner. Attributter: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "For forberedelse: 'Hvis next_interval_price_rank_today < 15, start forvarming nå så enheten kjører i neste billige tidsluke'."
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"description": "Prosentilrang for neste intervalls pris over i dag+i morgen samlet (0 % = billigste i to-dagers-vinduet)",
|
||||
"long_description": "Viser prosentilrangen for det kommende kvarter innenfor det kombinerte i dag+i morgen-bassenget (opptil 192 tidsluker). Fæller tilbake til kun i dag. Attributter: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bredeste fremtidsvisning: 'Hvis next_interval_price_rank_today_tomorrow < 10, er neste intervall blant de billigste i to-dagers-vinduet'."
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"description": "Prosentilrang for forrige intervalls pris i dagens rangering (0 % = billigste øyeblikk i dag)",
|
||||
"long_description": "Viser prosentilrangen for det nettopp avsluttede kvarter innenfor dagens 96 tidsluker. Nyttig for logging. Attributter: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "For retrospektive automatiseringer: 'Registrer prisnivyå for forrige intervall i energirapporter'."
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"description": "Prosentilrang for forrige intervalls pris over i dag+i morgen samlet (0 % = billigste i to-dagers-vinduet)",
|
||||
"long_description": "Viser prosentilrangen for det nettopp avsluttede kvarter innenfor det kombinerte i dag+i morgen-bassenget (opptil 192 tidsluker). Fæller tilbake til kun i dag. Attributter: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "For retrospektive sammenligninger over et to-dagers-vindu."
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"description": "Prosentilrang for glidende timegjennomsnittpris i dagens rangering (0 % = billigste time i dag)",
|
||||
"long_description": "Viser plasseringen til det glidende 5-intervall-gjennomsnittet (2 intervaller før + gjældende + 2 etter, ca. 1 time) i dagens prisrangering. Jevner ut korte pristopper. Attributter: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "For oppgaver som tar omtrent en time: 'Hvis current_hour_price_rank_today < 20, er dette en billig time — start vaskemaskinen'."
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"description": "Glidende timegjennomsnittprisrang over i dag+i morgen samlet (0 % = billigste time i to-dagers-vinduet)",
|
||||
"long_description": "Viser plasseringen til det glidende 5-intervall-gjennomsnittet (±2 intervaller, ca. 1 time) i den kombinerte i dag+i morgen-rangeringen (opptil 192 tidsluker). Fæller tilbake til kun i dag. Attributter: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bredeste timesignal: 'Hvis current_hour_price_rank_today_tomorrow < 15, er dette en av de billigste timene i to-dagers-vinduet'."
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"description": "Prosentilrang for neste glidende timegjennomsnittpris i dagens rangering (0 % = billigste time i dag)",
|
||||
"long_description": "Viser plasseringen til det 5-intervall-gjennomsnittet sentrert på neste intervall i dagens prisrangering. Muliggjør planlegging en time frem i tid. Attributter: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Forutse en time frem: 'Hvis next_hour_price_rank_today < 20, er den kommende timen billig — start en oppgave nå'."
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"description": "Neste glidende timegjennomsnittprisrang over i dag+i morgen samlet (0 % = billigste time i to-dagers-vinduet)",
|
||||
"long_description": "Viser plasseringen til det 5-intervall-gjennomsnittet sentrert på neste intervall i den kombinerte i dag+i morgen-rangeringen (opptil 192 tidsluker). Fæller tilbake til kun i dag. Attributter: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bredeste timefremtidsvisning: 'Hvis next_hour_price_rank_today_tomorrow < 10, er den kommende timen blant de billigste i to-dagers-vinduet'."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
|
|||
|
|
@ -510,6 +510,61 @@
|
|||
"description": "Lichtgewicht metadata voor diagramconfiguratie",
|
||||
"long_description": "Biedt essentiële diagramconfiguratiewaarden als sensorattributen. Nuttig voor elke grafiekkaart die Y-as-grenzen nodig heeft. De sensor roept get_chartdata aan in alleen-metadata-modus (geen dataverwerking) en extraheert: yaxis_min, yaxis_max (gesuggereerd Y-asbereik voor optimale schaling). De status weerspiegelt het service-aanroepresultaat: 'ready' bij succes, 'error' bij fouten, 'pending' tijdens initialisatie.",
|
||||
"usage_tips": "Configureer via configuration.yaml onder tibber_prices.chart_metadata_config (optioneel: day, subunit_currency, resolution). De sensor wordt automatisch bijgewerkt bij prijsgegevenswijzigingen. Krijg toegang tot metadata vanuit attributen: yaxis_min, yaxis_max. Gebruik met config-template-card of elk hulpmiddel dat entiteitsattributen leest - perfect voor dynamische diagramconfiguratie zonder handmatige berekeningen."
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"description": "Waar de huidige intervalprijs staat in de ranglijst van vandaag — percentielrang (0% = goedkoopste moment)",
|
||||
"long_description": "Toont hoe goedkoop of duur de prijs van het huidige kwartier is vergeleken met alle 96 kwartierslots van vandaag. 0% betekent dat dit het goedkoopste moment van de dag is. 50% betekent dat de helft van de slots goedkoper is. ca. 99% betekent het duurste slot van de dag. Formule: aantal goedkopere slots ÷ totaal slots × 100. Attributen: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Ideaal voor automatiseringen: 'Als current_interval_price_rank_today < 25, start de vaatwasser'. Een waarde van 0 garandeert het goedkoopste slot van de dag."
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"description": "Percentielrang van de huidige intervalprijs in de ranglijst van morgen (0% = goedkoopste van morgen)",
|
||||
"long_description": "Toont hoe de huidige intervalprijs zich verhoudt tot alle 96 kwartierslots van morgen. 0% betekent dat de huidige prijs goedkoper is dan elk slot van morgen. Geeft 'Onbekend' terug totdat de data van morgen beschikbaar is (doorgaans na 13:00). Attributen: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Gebruik om te beslissen of wachten loont: 'Als current_interval_price_rank_tomorrow < 10, zijn er morgen nog goedkopere slots — stel de taak uit'."
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"description": "Percentielrang van de huidige intervalprijs over vandaag+morgen samen (0% = goedkoopste van het twee-dagenvenster)",
|
||||
"long_description": "Toont hoe goedkoop of duur de huidige intervalprijs is vergeleken met alle slots over vandaag en morgen samen (tot 192 kwartierslots). Valt terug op alleen vandaag als de data van morgen nog niet beschikbaar is. Attributen: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Het breedste signaal voor 'is dit nu een goed moment?'. Gebruik 'Als current_interval_price_rank_today_tomorrow < 20, voer energieintensieve taak nu uit'."
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"description": "Percentielrang van de volgende intervalprijs in de ranglijst van vandaag (0% = goedkoopste moment van vandaag)",
|
||||
"long_description": "Toont de percentielrang van het komende kwartier binnen de 96 slots van vandaag. Biedt een vooruitblik voordat het volgende interval begint. Attributen: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Voor voorbereiding: 'Als next_interval_price_rank_today < 15, begin nu met voorverwarmen zodat het apparaat in het volgende goedkope slot draait'."
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"description": "Percentielrang van de volgende intervalprijs over vandaag+morgen samen (0% = goedkoopste van het twee-dagenvenster)",
|
||||
"long_description": "Toont de percentielrang van het komende kwartier binnen de gecombineerde pool van vandaag+morgen (tot 192 slots). Valt terug op alleen vandaag. Attributen: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Breedste vooruitblik: 'Als next_interval_price_rank_today_tomorrow < 10, is het volgende interval één van de goedkoopste van het twee-dagenvenster'."
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"description": "Percentielrang van de vorige intervalprijs in de ranglijst van vandaag (0% = goedkoopste moment van vandaag)",
|
||||
"long_description": "Toont de percentielrang van het zojuist afgelopen kwartier binnen de 96 slots van vandaag. Nuttig voor logging. Attributen: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Voor retrospectieve automatiseringen: 'Leg het prijsniveau van het vorige interval vast voor energierapporten'."
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"description": "Percentielrang van de vorige intervalprijs over vandaag+morgen samen (0% = goedkoopste van het twee-dagenvenster)",
|
||||
"long_description": "Toont de percentielrang van het zojuist afgelopen kwartier binnen de gecombineerde pool van vandaag+morgen (tot 192 slots). Valt terug op alleen vandaag. Attributen: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Voor retrospectieve vergelijkingen over een twee-dagenvenster."
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"description": "Percentielrang van het huidige voortschrijdend uurgemiddelde in de ranglijst van vandaag (0% = goedkoopste uur vandaag)",
|
||||
"long_description": "Toont waar het voortschrijdend gemiddelde van 5 intervallen (2 voor + huidig + 2 na, ca. 1 uur) staat in de prijsranglijst van vandaag. Egaliseer prijspieken voor een bredere inschatting. Attributen: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Voor taken van ongeveer een uur: 'Als current_hour_price_rank_today < 20, is dit een goedkoop uur — start de wasmachine'."
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"description": "Voortschrijdend uurgemiddelde prijsrang over vandaag+morgen samen (0% = goedkoopste uur van het twee-dagenvenster)",
|
||||
"long_description": "Toont waar het voortschrijdend gemiddelde van 5 intervallen (±2 intervallen, ca. 1 uur) staat in de gecombineerde ranglijst vandaag+morgen (tot 192 slots). Valt terug op alleen vandaag. Attributen: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Breedste uursignaal: 'Als current_hour_price_rank_today_tomorrow < 15, is dit één van de goedkoopste uren van het twee-dagenvenster'."
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"description": "Percentielrang van het volgende voortschrijdend uurgemiddelde in de ranglijst van vandaag (0% = goedkoopste uur vandaag)",
|
||||
"long_description": "Toont waar het voortschrijdend gemiddelde van 5 intervallen gecentreerd op het volgende interval staat in de prijsranglijst van vandaag. Maakt planning een uur vooruit mogelijk. Attributen: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Plan een uur vooruit: 'Als next_hour_price_rank_today < 20, is het komende uur goedkoop — start nu een taak'."
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"description": "Volgend voortschrijdend uurgemiddelde prijsrang over vandaag+morgen samen (0% = goedkoopste uur van het twee-dagenvenster)",
|
||||
"long_description": "Toont waar het voortschrijdend gemiddelde van 5 intervallen gecentreerd op het volgende interval staat in de gecombineerde ranglijst vandaag+morgen (tot 192 slots). Valt terug op alleen vandaag. Attributen: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Breedste uurvooruitblik: 'Als next_hour_price_rank_today_tomorrow < 10, is het komende uur één van de goedkoopste van het twee-dagenvenster'."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
|
|||
|
|
@ -510,6 +510,61 @@
|
|||
"description": "Lättviktig metadata för diagramkonfiguration",
|
||||
"long_description": "Tillhandahåller väsentliga diagramkonfigurationsvärden som sensorattribut. Användbart för vilket diagramkort som helst som behöver Y-axelgränser. Sensorn anropar get_chartdata med endast-metadata-läge (ingen databehandling) och extraherar: yaxis_min, yaxis_max (föreslagen Y-axelomfång för optimal skalning). Statusen återspeglar tjänstanropsresultatet: 'ready' vid framgång, 'error' vid fel, 'pending' under initialisering.",
|
||||
"usage_tips": "Konfigurera via configuration.yaml under tibber_prices.chart_metadata_config (valfritt: day, subunit_currency, resolution). Sensorn uppdateras automatiskt vid pris dataändringar. Få tillgång till metadata från attribut: yaxis_min, yaxis_max. Använd med config-template-card eller vilket verktyg som helst som läser entitetsattribut - perfekt för dynamisk diagramkonfiguration utan manuella beräkningar."
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"description": "Var det aktuella intervallpriset placerar sig i dagens rangordning — percentilrang (0 % = billigaste tillfället)",
|
||||
"long_description": "Visar hur billigt eller dyrt det aktuella kvartspriset är jämfört med alla 96 kvartsslotar idag. 0 % innebär att detta är det billigaste tillfället under dagen. 50 % innebär att hälften av dagens slotar är billigare. ca. 99 % innebär det dyraste slottet. Formel: antal billigare slotar ÷ totalt antal × 100. Attribut: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Idealiskt för automatiseringar: 'Om current_interval_price_rank_today < 25, starta diskmaskinen'. Ett värde på 0 garanterar att du är på det billigaste slottet under dagen."
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"description": "Percentilrang för aktuellt intervallpris i morgondagens rangordning (0 % = billigaste imorgon)",
|
||||
"long_description": "Visar hur det aktuella intervallpriset jämförs med alla 96 kvartslotar imorgon. 0 % innebär att det aktuella priset är billigare än varje slot imorgon. Returnerar 'Okänd' tills morgondagens data är tillgänglig (vanligtvis efter kl. 13:00). Attribut: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Används för att avgöra om väntan lönar sig: 'Om current_interval_price_rank_tomorrow < 10, finns det ännu billigare slotar imorgon — skjut upp uppgiften'."
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"description": "Percentilrang för aktuellt intervallpris över idag+imorgon sammantaget (0 % = billigaste i tvådagarsperioden)",
|
||||
"long_description": "Visar hur billigt eller dyrt det aktuella intervallpriset är jämfört med alla slotar idag och imorgon tillsammans (upp till 192 kvartsslotar). Faller tillbaka på enbart idag när morgondagens data inte är tillgänglig. Attribut: `current_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Den bredaste signalen för 'är det ett bra tillfälle nu?'. Använd 'Om current_interval_price_rank_today_tomorrow < 20, kör energikrävande uppgift nu'."
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"description": "Percentilrang för nästa intervalls pris i dagens rangordning (0 % = billigaste tillfället idag)",
|
||||
"long_description": "Visar percentilrangen för det kommande kvartalet inom dagens 96 slotar. Ger en förhandstitt innan nästa intervall börjar. Attribut: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "För förberedelse: 'Om next_interval_price_rank_today < 15, börja förvärmningen nu så att enheten körs under nästa billiga slot'."
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"description": "Percentilrang för nästa intervalls pris över idag+imorgon sammantaget (0 % = billigaste i tvådagarsperioden)",
|
||||
"long_description": "Visar percentilrangen för det kommande kvartalet inom den kombinerade idag+imorgon-poolen (upp till 192 slotar). Faller tillbaka på enbart idag. Attribut: `next_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bredaste framtidsskick: 'Om next_interval_price_rank_today_tomorrow < 10, är nästa intervall bland de billigaste i tvådagarsfönstret'."
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"description": "Percentilrang för föregående intervalls pris i dagens rangordning (0 % = billigaste tillfället idag)",
|
||||
"long_description": "Visar percentilrangen för det nyligen avslutade kvartalet inom dagens 96 slotar. Användbart för loggning. Attribut: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "För retrospektiva automatiseringar: 'Registrera prisnivån för det förra intervallet i energirapporter'."
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"description": "Percentilrang för föregående intervalls pris över idag+imorgon sammantaget (0 % = billigaste i tvådagarsperioden)",
|
||||
"long_description": "Visar percentilrangen för det nyligen avslutade kvartalet inom den kombinerade idag+imorgon-poolen (upp till 192 slotar). Faller tillbaka på enbart idag. Attribut: `previous_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "För retrospektiva jämförelser inom ett tvådagarsfönster."
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"description": "Percentilrang för aktuellt rullande timgenomsnittspris i dagens rangordning (0 % = billigaste timmen idag)",
|
||||
"long_description": "Visar var det rullande 5-intervallets genomsnitt (2 intervall före + aktuellt + 2 efter, ca. 1 timme) placerar sig i dagens prisrangordning. Jämnar ut korta pristoppar. Attribut: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "För uppgifter som tar ungefär en timme: 'Om current_hour_price_rank_today < 20, är detta en billig timme — starta tvättmaskinen'."
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"description": "Rullande timgenomsnittsprisrang över idag+imorgon sammantaget (0 % = billigaste timmen i tvådagarsfönstret)",
|
||||
"long_description": "Visar var det rullande 5-intervallets genomsnitt (±2 intervall, ca. 1 timme) placerar sig i den kombinerade idag+imorgon-rangordningen (upp till 192 slotar). Faller tillbaka på enbart idag. Attribut: `current_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bredaste timsignal: 'Om current_hour_price_rank_today_tomorrow < 15, är detta en av de billigaste timmarna i tvådagarsfönstret'."
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"description": "Percentilrang för nästa rullande timgenomsnittspris i dagens rangordning (0 % = billigaste timmen idag)",
|
||||
"long_description": "Visar var det 5-intervallsgenomsnitt centrerat på nästa intervall placerar sig i dagens prisrangordning. Möjliggör planering en timme framåt. Attribut: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Planera en timme framåt: 'Om next_hour_price_rank_today < 20, är den kommande timmen billig — starta en uppgift nu'."
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"description": "Nästa rullande timgenomsnittsprisrang över idag+imorgon sammantaget (0 % = billigaste timmen i tvådagarsfönstret)",
|
||||
"long_description": "Visar var det 5-intervallsgenomsnitt centrerat på nästa intervall placerar sig i den kombinerade idag+imorgon-rangordningen (upp till 192 slotar). Faller tillbaka på enbart idag. Attribut: `next_hour_avg_price`, `prices_below_count`, `interval_count`, `reference_min`, `reference_max`, `reference_mean`.",
|
||||
"usage_tips": "Bredaste timframtidsskick: 'Om next_hour_price_rank_today_tomorrow < 10, är den kommande timmen bland de billigaste i tvådagarsfönstret'."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
{
|
||||
"domain": "tibber_prices",
|
||||
"name": "Tibber Price Information & Ratings",
|
||||
"codeowners": [
|
||||
"@jpawlowski"
|
||||
],
|
||||
"codeowners": ["@jpawlowski"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/jpawlowski/hass.tibber_prices",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues",
|
||||
"requirements": [
|
||||
"aiofiles>=23.2.1"
|
||||
],
|
||||
"requirements": ["aiofiles>=23.2.1"],
|
||||
"version": "0.30.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ from .lifecycle import build_lifecycle_attributes
|
|||
from .metadata import get_day_pattern_attributes
|
||||
from .timing import _is_timing_or_volatility_sensor
|
||||
from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes
|
||||
from .volatility import add_volatility_type_attributes, get_prices_for_volatility
|
||||
from .volatility import add_percentile_rank_attributes, add_volatility_type_attributes, get_prices_for_volatility
|
||||
from .window_24h import add_average_price_attributes
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -65,6 +65,7 @@ __all__ = [
|
|||
"TrendAttributes",
|
||||
"VolatilityAttributes",
|
||||
"Window24hAttributes",
|
||||
"add_percentile_rank_attributes",
|
||||
"add_volatility_type_attributes",
|
||||
"build_extra_state_attributes",
|
||||
"build_sensor_attributes",
|
||||
|
|
@ -190,6 +191,9 @@ def build_sensor_attributes( # noqa: PLR0912
|
|||
elif _is_timing_or_volatility_sensor(key):
|
||||
_add_timing_or_volatility_attributes(attributes, key, cached_data, native_value, time=time)
|
||||
|
||||
elif "_price_rank_" in key:
|
||||
add_percentile_rank_attributes(attributes, cached_data, time=time)
|
||||
|
||||
elif key in ("day_pattern_yesterday", "day_pattern_today", "day_pattern_tomorrow"):
|
||||
day = key.removeprefix("day_pattern_")
|
||||
day_attrs = get_day_pattern_attributes(coordinator, day)
|
||||
|
|
|
|||
|
|
@ -164,3 +164,54 @@ def add_volatility_type_attributes(
|
|||
# Add time window info
|
||||
now = time.now()
|
||||
volatility_attributes["timestamp"] = now
|
||||
|
||||
|
||||
def add_percentile_rank_attributes(
|
||||
attributes: dict,
|
||||
cached_data: dict,
|
||||
*,
|
||||
time: TibberPricesTimeService,
|
||||
) -> None:
|
||||
"""
|
||||
Add attributes for percentile rank sensors.
|
||||
|
||||
Sets the timestamp based on the percentile type stored in cached_data:
|
||||
- "today" / "today_tomorrow": today's first interval start (midnight context)
|
||||
- "tomorrow": tomorrow's first interval start
|
||||
|
||||
Args:
|
||||
attributes: Dictionary to add attributes to
|
||||
cached_data: Dictionary containing cached sensor data (percentile_rank_attributes,
|
||||
percentile_rank_type, coordinator_data)
|
||||
time: TibberPricesTimeService instance (required)
|
||||
|
||||
"""
|
||||
from datetime import timedelta # noqa: PLC0415 - local import to avoid circular
|
||||
|
||||
rank_attrs = cached_data.get("percentile_rank_attributes")
|
||||
if rank_attrs:
|
||||
attributes.update(rank_attrs)
|
||||
|
||||
# Set timestamp based on period type
|
||||
percentile_type = cached_data.get("percentile_rank_type", "today")
|
||||
coordinator_data = cached_data.get("coordinator_data")
|
||||
|
||||
if coordinator_data:
|
||||
from custom_components.tibber_prices.coordinator.helpers import ( # noqa: PLC0415
|
||||
get_intervals_for_day_offsets,
|
||||
)
|
||||
|
||||
all_intervals = get_intervals_for_day_offsets(coordinator_data, [-1, 0, 1])
|
||||
now = time.now()
|
||||
today_date = now.date()
|
||||
tomorrow_date = (now + timedelta(days=1)).date()
|
||||
|
||||
if percentile_type == "tomorrow":
|
||||
tomorrow_data = [p for p in all_intervals if p.get("startsAt") and p["startsAt"].date() == tomorrow_date]
|
||||
if tomorrow_data:
|
||||
attributes["timestamp"] = tomorrow_data[0].get("startsAt")
|
||||
else:
|
||||
# today / today_tomorrow → use today's midnight
|
||||
today_data = [p for p in all_intervals if p.get("startsAt") and p["startsAt"].date() == today_date]
|
||||
if today_data:
|
||||
attributes["timestamp"] = today_data[0].get("startsAt")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
|
|
@ -13,13 +14,18 @@ from custom_components.tibber_prices.const import (
|
|||
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
get_display_unit_factor,
|
||||
)
|
||||
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute, find_rolling_hour_center_index
|
||||
from custom_components.tibber_prices.sensor.attributes import (
|
||||
add_volatility_type_attributes,
|
||||
get_prices_for_volatility,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.average import calculate_mean
|
||||
from custom_components.tibber_prices.utils.price import calculate_volatility_with_cv
|
||||
from custom_components.tibber_prices.utils.price import (
|
||||
calculate_iqr_stats,
|
||||
calculate_percentile_rank,
|
||||
calculate_volatility_with_cv,
|
||||
)
|
||||
|
||||
from .base import TibberPricesBaseCalculator
|
||||
|
||||
|
|
@ -46,6 +52,7 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
|
|||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self._last_volatility_attributes: dict[str, Any] = {}
|
||||
self._last_percentile_rank_attributes: dict[str, Any] = {}
|
||||
|
||||
def get_volatility_value(self, *, volatility_type: str) -> str | None:
|
||||
"""
|
||||
|
|
@ -101,17 +108,33 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
|
|||
# Calculate volatility level AND coefficient of variation
|
||||
volatility, cv = calculate_volatility_with_cv(prices_to_analyze, **thresholds)
|
||||
|
||||
# Calculate IQR statistics (robust to outliers)
|
||||
iqr_stats = calculate_iqr_stats(prices_to_analyze)
|
||||
|
||||
# Store attributes for this sensor
|
||||
self._last_volatility_attributes = {
|
||||
"price_spread": round(spread_display, 2),
|
||||
"price_coefficient_variation_%": round(cv, 2) if cv is not None else None,
|
||||
# Build attributes with all price_* together, interval_count last
|
||||
attrs: dict[str, Any] = {
|
||||
"price_volatility": volatility.lower(),
|
||||
"price_coefficient_variation_%": round(cv, 2) if cv is not None else None,
|
||||
"price_spread": round(spread_display, 2),
|
||||
"price_min": round(price_min * factor, 2),
|
||||
"price_max": round(price_max * factor, 2),
|
||||
"price_mean": round(price_mean * factor, 2),
|
||||
"interval_count": len(prices_to_analyze),
|
||||
}
|
||||
|
||||
# Add IQR attributes when enough data is available (stay in price_* group)
|
||||
if iqr_stats is not None:
|
||||
attrs["price_median"] = round(iqr_stats["median"] * factor, 2)
|
||||
attrs["price_q25"] = round(iqr_stats["q25"] * factor, 2)
|
||||
attrs["price_q75"] = round(iqr_stats["q75"] * factor, 2)
|
||||
attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, 2)
|
||||
if iqr_stats["iqr_pct"] is not None:
|
||||
attrs["price_typical_spread_%"] = round(iqr_stats["iqr_pct"], 2)
|
||||
attrs["price_spike_count"] = iqr_stats["outlier_count"]
|
||||
|
||||
attrs["interval_count"] = len(prices_to_analyze)
|
||||
self._last_volatility_attributes = attrs
|
||||
|
||||
# Add icon_color for dynamic styling
|
||||
add_icon_color_attribute(self._last_volatility_attributes, key="volatility", state_value=volatility)
|
||||
|
||||
|
|
@ -136,3 +159,146 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
|
|||
|
||||
"""
|
||||
return self._last_volatility_attributes
|
||||
|
||||
def get_percentile_rank_value(
|
||||
self,
|
||||
*,
|
||||
percentile_type: str,
|
||||
subject: str = "current_interval",
|
||||
) -> float | None:
|
||||
"""
|
||||
Calculate the percentile rank of a subject price within a reference set.
|
||||
|
||||
The result is 0-100: percentage of reference prices strictly cheaper than
|
||||
the subject price. 0% = cheapest, ~99% = most expensive.
|
||||
|
||||
Also stores detailed attributes in self._last_percentile_rank_attributes
|
||||
for use in extra_state_attributes.
|
||||
|
||||
Args:
|
||||
percentile_type: Reference window - one of "today", "tomorrow", "today_tomorrow".
|
||||
subject: Price to rank - one of "current_interval" (default), "next_interval",
|
||||
"previous_interval", "current_hour", "next_hour".
|
||||
|
||||
Returns:
|
||||
Percentile rank (0.0-100.0) or None if unavailable.
|
||||
|
||||
"""
|
||||
if not self.has_data():
|
||||
return None
|
||||
|
||||
# Get the price of the subject to rank
|
||||
subject_price = self._get_subject_price(subject)
|
||||
if subject_price is None:
|
||||
return None
|
||||
|
||||
# Get reference prices for this type (reuse volatility helper)
|
||||
reference_prices = get_prices_for_volatility(
|
||||
percentile_type,
|
||||
self.coordinator.data,
|
||||
time=self.coordinator.time,
|
||||
)
|
||||
if not reference_prices:
|
||||
return None
|
||||
|
||||
# Calculate percentile rank
|
||||
rank = calculate_percentile_rank(subject_price, reference_prices)
|
||||
if rank is None:
|
||||
return None
|
||||
|
||||
# Convert to display units for attribute storage
|
||||
factor = get_display_unit_factor(self.config_entry)
|
||||
price_attr_key = self._get_subject_price_attr_key(subject)
|
||||
|
||||
self._last_percentile_rank_attributes = {
|
||||
price_attr_key: round(subject_price * factor, 2),
|
||||
"prices_below_count": bisect.bisect_left(sorted(reference_prices), subject_price),
|
||||
"interval_count": len(reference_prices),
|
||||
"reference_min": round(min(reference_prices) * factor, 2),
|
||||
"reference_max": round(max(reference_prices) * factor, 2),
|
||||
"reference_mean": round(calculate_mean(reference_prices) * factor, 2),
|
||||
}
|
||||
|
||||
return rank
|
||||
|
||||
def _get_subject_price(self, subject: str) -> float | None:
|
||||
"""
|
||||
Get the price of the subject to rank.
|
||||
|
||||
Args:
|
||||
subject: One of "current_interval", "next_interval", "previous_interval",
|
||||
"current_hour", "next_hour".
|
||||
|
||||
Returns:
|
||||
Price as float or None if unavailable.
|
||||
|
||||
"""
|
||||
if subject == "current_interval":
|
||||
interval = self.find_interval_at_offset(0)
|
||||
elif subject == "next_interval":
|
||||
interval = self.find_interval_at_offset(1)
|
||||
elif subject == "previous_interval":
|
||||
interval = self.find_interval_at_offset(-1)
|
||||
elif subject in ("current_hour", "next_hour"):
|
||||
hour_offset = 0 if subject == "current_hour" else 1
|
||||
return self._get_rolling_hour_avg_price(hour_offset)
|
||||
else:
|
||||
return None
|
||||
|
||||
if interval is None:
|
||||
return None
|
||||
raw = interval.get("total")
|
||||
return float(raw) if raw is not None else None
|
||||
|
||||
def _get_subject_price_attr_key(self, subject: str) -> str:
|
||||
"""Return the attribute key name for the subject's price."""
|
||||
return {
|
||||
"current_interval": "current_price",
|
||||
"next_interval": "next_price",
|
||||
"previous_interval": "previous_price",
|
||||
"current_hour": "current_hour_avg_price",
|
||||
"next_hour": "next_hour_avg_price",
|
||||
}.get(subject, "ranked_price")
|
||||
|
||||
def _get_rolling_hour_avg_price(self, hour_offset: int) -> float | None:
|
||||
"""
|
||||
Get the rolling 1h average price for the given hour offset.
|
||||
|
||||
Uses the same 5-interval window as current_hour_average_price.
|
||||
|
||||
Args:
|
||||
hour_offset: 0 for current hour, 1 for next hour.
|
||||
|
||||
Returns:
|
||||
Average price as float or None if unavailable.
|
||||
|
||||
"""
|
||||
all_prices = get_intervals_for_day_offsets(self.coordinator_data, [-1, 0, 1])
|
||||
if not all_prices:
|
||||
return None
|
||||
|
||||
time = self.coordinator.time
|
||||
now = time.now()
|
||||
center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset, time=time)
|
||||
if center_idx is None:
|
||||
return None
|
||||
|
||||
window: list[float] = []
|
||||
for offset in range(-2, 3):
|
||||
idx = center_idx + offset
|
||||
if 0 <= idx < len(all_prices):
|
||||
raw = all_prices[idx].get("total")
|
||||
if raw is not None:
|
||||
window.append(float(raw))
|
||||
|
||||
return calculate_mean(window) if window else None
|
||||
|
||||
def get_percentile_rank_attributes(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get stored percentile rank attributes from last calculation.
|
||||
|
||||
Returns:
|
||||
Dictionary of percentile rank attributes, or empty dict if no calculation yet.
|
||||
|
||||
"""
|
||||
return self._last_percentile_rank_attributes
|
||||
|
|
|
|||
|
|
@ -100,6 +100,22 @@ MIN_HOURS_FOR_LATER_HALF = 3 # Minimum hours needed to calculate later half ave
|
|||
_SENTINEL = object()
|
||||
|
||||
|
||||
def _extract_percentile_rank_type(key: str) -> str | None:
|
||||
"""
|
||||
Extract the reference-window type from a price rank sensor key.
|
||||
|
||||
Returns "today_tomorrow", "tomorrow", or "today" based on the key suffix.
|
||||
Returns None if the key is not a price rank sensor key.
|
||||
"""
|
||||
if "_rank_today_tomorrow" in key:
|
||||
return "today_tomorrow"
|
||||
if "_rank_tomorrow" in key:
|
||||
return "tomorrow"
|
||||
if "_rank_today" in key:
|
||||
return "today"
|
||||
return None
|
||||
|
||||
|
||||
class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||
"""tibber_prices Sensor class with state restoration."""
|
||||
|
||||
|
|
@ -173,7 +189,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
"period_price_diff_from_daily_min",
|
||||
"period_price_diff_from_daily_min_%",
|
||||
"period_count_total",
|
||||
"periods_remaining",
|
||||
"period_count_remaining",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1164,6 +1180,9 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
"current_trend_attributes": self._trend_calculator.get_current_trend_attributes(),
|
||||
"trend_change_attributes": self._trend_calculator.get_trend_change_attributes(),
|
||||
"volatility_attributes": self._volatility_calculator.get_volatility_attributes(),
|
||||
"percentile_rank_attributes": self._volatility_calculator.get_percentile_rank_attributes(),
|
||||
"percentile_rank_type": _extract_percentile_rank_type(key),
|
||||
"coordinator_data": self.coordinator.data,
|
||||
"last_extreme_interval": self._daily_stat_calculator.get_last_extreme_interval(),
|
||||
"last_energy_tax_averages": self._daily_stat_calculator.get_last_energy_tax_averages(),
|
||||
"last_price_level": self._interval_calculator.get_last_price_level(),
|
||||
|
|
|
|||
|
|
@ -736,6 +736,139 @@ VOLATILITY_SENSORS = (
|
|||
),
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 6b. PRICE PERCENTILE RANK SENSORS
|
||||
# ----------------------------------------------------------------------------
|
||||
# These sensors show where the current price ranks within a reference period.
|
||||
# The state (0-100%) answers: "What percentage of reference prices are cheaper
|
||||
# than the current price?"
|
||||
#
|
||||
# 0% = current price is the cheapest in the reference period
|
||||
# 50% = half the prices are cheaper (current price at median level)
|
||||
# ~99% = almost everything is cheaper (current price near the maximum)
|
||||
#
|
||||
# Reference periods:
|
||||
# - today: 96 intervals of today (local calendar day)
|
||||
# - tomorrow: 96 intervals of tomorrow (once data is available)
|
||||
# - today_tomorrow: 192 combined intervals when tomorrow is available
|
||||
#
|
||||
# Use case: "Is now the right time to run a large appliance?"
|
||||
# - current_interval_price_rank_today < 25 → bottom quartile, great time to use energy
|
||||
# - current_interval_price_rank_today > 75 → top quartile, consider delaying consumption
|
||||
|
||||
PERCENTILE_RANK_SENSORS = (
|
||||
# ----------------------------------------------------------------
|
||||
# Current interval rank sensors
|
||||
# ----------------------------------------------------------------
|
||||
SensorEntityDescription(
|
||||
key="current_interval_price_rank_today",
|
||||
translation_key="current_interval_price_rank_today",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None, # Position metric: no statistics
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_interval_price_rank_tomorrow",
|
||||
translation_key="current_interval_price_rank_tomorrow",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None, # Position metric: no statistics
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False, # Available once tomorrow's data arrives
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_interval_price_rank_today_tomorrow",
|
||||
translation_key="current_interval_price_rank_today_tomorrow",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None, # Position metric: no statistics
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False, # Advanced overview use case
|
||||
),
|
||||
# ----------------------------------------------------------------
|
||||
# Next interval rank sensors
|
||||
# ----------------------------------------------------------------
|
||||
SensorEntityDescription(
|
||||
key="next_interval_price_rank_today",
|
||||
translation_key="next_interval_price_rank_today",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="next_interval_price_rank_today_tomorrow",
|
||||
translation_key="next_interval_price_rank_today_tomorrow",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# ----------------------------------------------------------------
|
||||
# Previous interval rank sensors
|
||||
# ----------------------------------------------------------------
|
||||
SensorEntityDescription(
|
||||
key="previous_interval_price_rank_today",
|
||||
translation_key="previous_interval_price_rank_today",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="previous_interval_price_rank_today_tomorrow",
|
||||
translation_key="previous_interval_price_rank_today_tomorrow",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# ----------------------------------------------------------------
|
||||
# Rolling-hour rank sensors (rank of 1h rolling average)
|
||||
# ----------------------------------------------------------------
|
||||
SensorEntityDescription(
|
||||
key="current_hour_price_rank_today",
|
||||
translation_key="current_hour_price_rank_today",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_hour_price_rank_today_tomorrow",
|
||||
translation_key="current_hour_price_rank_today_tomorrow",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="next_hour_price_rank_today",
|
||||
translation_key="next_hour_price_rank_today",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="next_hour_price_rank_today_tomorrow",
|
||||
translation_key="next_hour_price_rank_today_tomorrow",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 7. BEST/PEAK PRICE TIMING SENSORS (period-based time tracking)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
|
@ -1116,6 +1249,7 @@ ENTITY_DESCRIPTIONS = (
|
|||
*FUTURE_TREND_SENSORS,
|
||||
*PRICE_TRAJECTORY_SENSORS,
|
||||
*VOLATILITY_SENSORS,
|
||||
*PERCENTILE_RANK_SENSORS,
|
||||
*BEST_PRICE_TIMING_SENSORS,
|
||||
*PEAK_PRICE_TIMING_SENSORS,
|
||||
*DAY_PATTERN_SENSORS,
|
||||
|
|
|
|||
|
|
@ -249,6 +249,44 @@ def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parame
|
|||
"today_tomorrow_volatility": lambda: volatility_calculator.get_volatility_value(
|
||||
volatility_type="today_tomorrow"
|
||||
),
|
||||
# Price rank sensors (via VolatilityCalculator - reuses same price extraction)
|
||||
# Current interval rank
|
||||
"current_interval_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="current_interval", percentile_type="today"
|
||||
),
|
||||
"current_interval_price_rank_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="current_interval", percentile_type="tomorrow"
|
||||
),
|
||||
"current_interval_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="current_interval", percentile_type="today_tomorrow"
|
||||
),
|
||||
# Next interval rank
|
||||
"next_interval_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="next_interval", percentile_type="today"
|
||||
),
|
||||
"next_interval_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="next_interval", percentile_type="today_tomorrow"
|
||||
),
|
||||
# Previous interval rank
|
||||
"previous_interval_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="previous_interval", percentile_type="today"
|
||||
),
|
||||
"previous_interval_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="previous_interval", percentile_type="today_tomorrow"
|
||||
),
|
||||
# Rolling-hour rank (1h average)
|
||||
"current_hour_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="current_hour", percentile_type="today"
|
||||
),
|
||||
"current_hour_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="current_hour", percentile_type="today_tomorrow"
|
||||
),
|
||||
"next_hour_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="next_hour", percentile_type="today"
|
||||
),
|
||||
"next_hour_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="next_hour", percentile_type="today_tomorrow"
|
||||
),
|
||||
# ================================================================
|
||||
# BEST/PEAK PRICE TIMING SENSORS - via TimingCalculator
|
||||
# ================================================================
|
||||
|
|
|
|||
|
|
@ -930,6 +930,11 @@ find_cheapest_schedule:
|
|||
output:
|
||||
collapsed: true
|
||||
fields:
|
||||
include_comparison_details:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
use_base_unit:
|
||||
required: false
|
||||
default: false
|
||||
|
|
|
|||
|
|
@ -126,6 +126,23 @@ def _compute_price_comparison(
|
|||
return result
|
||||
|
||||
|
||||
def _determine_no_window_reason(
|
||||
price_info: list[dict],
|
||||
filtered_price_info: list[dict],
|
||||
duration_intervals: int,
|
||||
*,
|
||||
level_filter_active: bool,
|
||||
) -> str:
|
||||
"""Classify why no block window could be found."""
|
||||
if not price_info:
|
||||
return "no_data_in_range"
|
||||
if level_filter_active and not filtered_price_info:
|
||||
return "no_intervals_matching_level_filter"
|
||||
if len(filtered_price_info) < duration_intervals:
|
||||
return "insufficient_intervals_after_filter"
|
||||
return "insufficient_contiguous_window"
|
||||
|
||||
|
||||
async def _handle_find_block( # noqa: PLR0915
|
||||
call: ServiceCall,
|
||||
*,
|
||||
|
|
@ -146,6 +163,7 @@ async def _handle_find_block( # noqa: PLR0915
|
|||
min_price_level: str | None = call.data.get("min_price_level")
|
||||
include_comparison_details: bool = call.data.get("include_comparison_details", False)
|
||||
power_profile: list[int] | None = call.data.get("power_profile")
|
||||
level_filter_active = min_price_level is not None or max_price_level is not None
|
||||
|
||||
duration_minutes_requested = int(duration_td.total_seconds() / 60)
|
||||
# Round up to nearest quarter-hour interval
|
||||
|
|
@ -217,9 +235,16 @@ async def _handle_find_block( # noqa: PLR0915
|
|||
result = find_cheapest_contiguous_window(filtered_price_info, duration_intervals, reverse=reverse)
|
||||
|
||||
if result is None:
|
||||
reason = _determine_no_window_reason(
|
||||
price_info,
|
||||
filtered_price_info,
|
||||
duration_intervals,
|
||||
level_filter_active=level_filter_active,
|
||||
)
|
||||
_LOGGER.info(
|
||||
"%s: no window found (need %d intervals, have %d after level filter)",
|
||||
"%s: no window found (reason=%s, need %d intervals, have %d after level filter)",
|
||||
service_label,
|
||||
reason,
|
||||
duration_intervals,
|
||||
len(filtered_price_info),
|
||||
)
|
||||
|
|
@ -232,6 +257,7 @@ async def _handle_find_block( # noqa: PLR0915
|
|||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"window_found": False,
|
||||
"reason": reason,
|
||||
"window": None,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,23 @@ _COMMON_HOURS_SCHEMA = {
|
|||
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
|
||||
|
||||
|
||||
def _determine_no_intervals_reason(
|
||||
price_info: list[dict],
|
||||
filtered_price_info: list[dict],
|
||||
total_intervals: int,
|
||||
*,
|
||||
level_filter_active: bool,
|
||||
) -> str:
|
||||
"""Classify why no interval selection could be found."""
|
||||
if not price_info:
|
||||
return "no_data_in_range"
|
||||
if level_filter_active and not filtered_price_info:
|
||||
return "no_intervals_matching_level_filter"
|
||||
if len(filtered_price_info) < total_intervals:
|
||||
return "insufficient_intervals_after_filter"
|
||||
return "insufficient_intervals_for_constraints"
|
||||
|
||||
|
||||
def _build_found_response( # noqa: PLR0913
|
||||
*,
|
||||
result: dict,
|
||||
|
|
@ -207,6 +224,7 @@ async def _handle_find_hours(
|
|||
min_price_level: str | None = call.data.get("min_price_level")
|
||||
include_comparison_details: bool = call.data.get("include_comparison_details", False)
|
||||
power_profile: list[int] | None = call.data.get("power_profile")
|
||||
level_filter_active = min_price_level is not None or max_price_level is not None
|
||||
|
||||
total_minutes_requested = int(duration_td.total_seconds() / 60)
|
||||
min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES
|
||||
|
|
@ -283,9 +301,16 @@ async def _handle_find_hours(
|
|||
result = find_cheapest_n_intervals(filtered_price_info, total_intervals, min_segment_intervals, reverse=reverse)
|
||||
|
||||
if result is None:
|
||||
reason = _determine_no_intervals_reason(
|
||||
price_info,
|
||||
filtered_price_info,
|
||||
total_intervals,
|
||||
level_filter_active=level_filter_active,
|
||||
)
|
||||
_LOGGER.info(
|
||||
"%s: not enough intervals (need %d, have %d after level filter)",
|
||||
"%s: no interval selection found (reason=%s, need %d, have %d after level filter)",
|
||||
service_label,
|
||||
reason,
|
||||
total_intervals,
|
||||
len(filtered_price_info),
|
||||
)
|
||||
|
|
@ -300,6 +325,7 @@ async def _handle_find_hours(
|
|||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"intervals_found": False,
|
||||
"reason": reason,
|
||||
"schedule": None,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from custom_components.tibber_prices.const import (
|
|||
)
|
||||
from custom_components.tibber_prices.utils.price_window import (
|
||||
calculate_window_statistics,
|
||||
find_cheapest_contiguous_window,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
|
@ -83,11 +84,77 @@ FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
|
|||
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
|
||||
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||
vol.Optional("include_comparison_details", default=False): cv.boolean,
|
||||
vol.Optional("use_base_unit", default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _compute_task_price_comparison(
|
||||
task_intervals: list[dict[str, Any]],
|
||||
full_price_info: list[dict[str, Any]],
|
||||
unit_factor: int,
|
||||
*,
|
||||
include_details: bool,
|
||||
) -> dict[str, float | str | None] | None:
|
||||
"""Compute per-task comparison against most expensive window of same duration."""
|
||||
duration_intervals = len(task_intervals)
|
||||
comparison_result = find_cheapest_contiguous_window(full_price_info, duration_intervals, reverse=True)
|
||||
if comparison_result is None:
|
||||
return None
|
||||
|
||||
task_stats = calculate_window_statistics(task_intervals, unit_factor=unit_factor, round_decimals=4)
|
||||
comparison_stats = calculate_window_statistics(
|
||||
comparison_result["intervals"], unit_factor=unit_factor, round_decimals=4
|
||||
)
|
||||
task_mean = task_stats.get("price_mean")
|
||||
comparison_mean = comparison_stats.get("price_mean")
|
||||
if task_mean is None or comparison_mean is None:
|
||||
return None
|
||||
|
||||
comparison_window_start = comparison_result["intervals"][0]["startsAt"]
|
||||
if not isinstance(comparison_window_start, str):
|
||||
comparison_window_start = comparison_window_start.isoformat()
|
||||
|
||||
result: dict[str, float | str | None] = {
|
||||
"comparison_price_mean": comparison_mean,
|
||||
"price_difference": abs(round(float(comparison_mean) - float(task_mean), 4)),
|
||||
"comparison_window_start": comparison_window_start,
|
||||
}
|
||||
|
||||
if include_details:
|
||||
result["comparison_price_min"] = comparison_stats.get("price_min")
|
||||
result["comparison_price_max"] = comparison_stats.get("price_max")
|
||||
last_start = comparison_result["intervals"][-1]["startsAt"]
|
||||
if not isinstance(last_start, str):
|
||||
last_start = last_start.isoformat()
|
||||
result["comparison_window_end"] = (
|
||||
datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
|
||||
).isoformat()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _determine_schedule_reason(
|
||||
*,
|
||||
all_tasks_scheduled: bool,
|
||||
assignments_count: int,
|
||||
price_info: list[dict[str, Any]],
|
||||
filtered_price_info: list[dict[str, Any]],
|
||||
level_filter_active: bool,
|
||||
) -> str | None:
|
||||
"""Classify schedule outcome reason for automation-friendly no-result handling."""
|
||||
if all_tasks_scheduled:
|
||||
return None
|
||||
if not price_info:
|
||||
return "no_data_in_range"
|
||||
if level_filter_active and not filtered_price_info:
|
||||
return "no_intervals_matching_level_filter"
|
||||
if assignments_count == 0:
|
||||
return "insufficient_contiguous_window"
|
||||
return "insufficient_contiguous_window_for_some_tasks"
|
||||
|
||||
|
||||
def _find_cheapest_window_in_pool(
|
||||
pool: list[dict[str, Any]],
|
||||
duration_intervals: int,
|
||||
|
|
@ -156,6 +223,8 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
use_base_unit: bool = call.data.get("use_base_unit", False)
|
||||
max_price_level: str | None = call.data.get("max_price_level")
|
||||
min_price_level: str | None = call.data.get("min_price_level")
|
||||
include_comparison_details: bool = call.data.get("include_comparison_details", False)
|
||||
level_filter_active = min_price_level is not None or max_price_level is not None
|
||||
|
||||
# Round gap up to nearest quarter interval
|
||||
gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0
|
||||
|
|
@ -236,6 +305,13 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
|
||||
|
||||
if not filtered_price_info:
|
||||
reason = _determine_schedule_reason(
|
||||
all_tasks_scheduled=False,
|
||||
assignments_count=0,
|
||||
price_info=price_info,
|
||||
filtered_price_info=filtered_price_info,
|
||||
level_filter_active=level_filter_active,
|
||||
)
|
||||
return {
|
||||
"home_id": home_id,
|
||||
"search_start": search_start.isoformat(),
|
||||
|
|
@ -243,6 +319,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"all_tasks_scheduled": False,
|
||||
"reason": reason,
|
||||
"tasks": [],
|
||||
"total_estimated_cost": None,
|
||||
}
|
||||
|
|
@ -295,6 +372,12 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
"duration_minutes": task["duration_minutes"],
|
||||
**stats,
|
||||
"intervals": task_response_intervals,
|
||||
"price_comparison": _compute_task_price_comparison(
|
||||
task_intervals,
|
||||
price_info,
|
||||
unit_factor,
|
||||
include_details=include_comparison_details,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -308,6 +391,13 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None
|
||||
|
||||
all_scheduled = len(unscheduled) == 0
|
||||
reason = _determine_schedule_reason(
|
||||
all_tasks_scheduled=all_scheduled,
|
||||
assignments_count=len(assignments),
|
||||
price_info=price_info,
|
||||
filtered_price_info=filtered_price_info,
|
||||
level_filter_active=level_filter_active,
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"%s: scheduled %d/%d tasks, total_cost=%s",
|
||||
|
|
@ -324,6 +414,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"all_tasks_scheduled": all_scheduled,
|
||||
"reason": reason,
|
||||
"unscheduled_tasks": unscheduled or None,
|
||||
"tasks": assignments,
|
||||
"total_estimated_cost": total_estimated_cost,
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,39 @@
|
|||
"ready": "Bereit",
|
||||
"error": "Fehler"
|
||||
}
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"name": "Aktueller Preisrang (heute)"
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"name": "Aktueller Preisrang (morgen)"
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"name": "Aktueller Preisrang (heute+morgen)"
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"name": "Nächster Preisrang (heute)"
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"name": "Nächster Preisrang (heute+morgen)"
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"name": "Letzter Preisrang (heute)"
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"name": "Letzter Preisrang (heute+morgen)"
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"name": "⌀ Stündlicher Preisrang Aktuell (heute)"
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Stündlicher Preisrang Aktuell (heute+morgen)"
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"name": "⌀ Stündlicher Preisrang Nächste (heute)"
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Stündlicher Preisrang Nächste (heute+morgen)"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -1847,6 +1880,10 @@
|
|||
"name": "Minimale Preisstufe",
|
||||
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Vergleichsdetails einbeziehen",
|
||||
"description": "Fuegt pro Aufgabe zusaetzliche price_comparison-Details hinzu (comparison_price_min, comparison_price_max, comparison_window_end), um das gefundene Zeitfenster mit dem gegenteiligen Extremfenster gleicher Dauer zu vergleichen."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basiswährung verwenden",
|
||||
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,39 @@
|
|||
"ready": "Ready",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"name": "Current Price Rank (Today)"
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"name": "Current Price Rank (Tomorrow)"
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"name": "Current Price Rank (Today+Tomorrow)"
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"name": "Next Price Rank (Today)"
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"name": "Next Price Rank (Today+Tomorrow)"
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"name": "Last Price Rank (Today)"
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"name": "Last Price Rank (Today+Tomorrow)"
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"name": "⌀ Hourly Price Current Rank (Today)"
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Hourly Price Current Rank (Today+Tomorrow)"
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"name": "⌀ Hourly Price Next Rank (Today)"
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Hourly Price Next Rank (Today+Tomorrow)"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -1387,7 +1420,7 @@
|
|||
},
|
||||
"find_cheapest_block": {
|
||||
"name": "Find Cheapest Block",
|
||||
"description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics.",
|
||||
"description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics. If no window is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_contiguous_window).",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Search Range",
|
||||
|
|
@ -1571,7 +1604,7 @@
|
|||
},
|
||||
"find_cheapest_hours": {
|
||||
"name": "Find Cheapest Hours",
|
||||
"description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments.",
|
||||
"description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments. If no schedule is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_intervals_for_constraints).",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Search Range",
|
||||
|
|
@ -1763,7 +1796,7 @@
|
|||
},
|
||||
"find_cheapest_schedule": {
|
||||
"name": "Find Cheapest Schedule",
|
||||
"description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats.",
|
||||
"description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats. If scheduling is incomplete, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_contiguous_window, insufficient_contiguous_window_for_some_tasks).",
|
||||
"sections": {
|
||||
"scheduling_options": {
|
||||
"name": "Scheduling Options",
|
||||
|
|
@ -1847,6 +1880,10 @@
|
|||
"name": "Minimum Price Level",
|
||||
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Include Comparison Details",
|
||||
"description": "Add per-task price_comparison details (comparison_price_min, comparison_price_max, comparison_window_end) to compare each selected task window against the opposite extreme window of the same duration."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Use Base Currency Unit",
|
||||
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,39 @@
|
|||
"ready": "Klar",
|
||||
"error": "Feil"
|
||||
}
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"name": "Aktuell prisrang (i dag)"
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"name": "Aktuell prisrang (i morgen)"
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"name": "Aktuell prisrang (i dag+i morgen)"
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"name": "Neste prisrang (i dag)"
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"name": "Neste prisrang (i dag+i morgen)"
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"name": "Forrige prisrang (i dag)"
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"name": "Forrige prisrang (i dag+i morgen)"
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"name": "⌀ Timesprisrang nå (i dag)"
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Timesprisrang nå (i dag+i morgen)"
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"name": "⌀ Timesprisrang neste (i dag)"
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Timesprisrang neste (i dag+i morgen)"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -1847,6 +1880,10 @@
|
|||
"name": "Minimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Inkluder sammenligningsdetaljer",
|
||||
"description": "Legger til ekstra price_comparison-detaljer per oppgave (comparison_price_min, comparison_price_max, comparison_window_end) for aa sammenligne valgt vindu med motsatt ekstremvindu med samme varighet."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Bruk basisvaluta",
|
||||
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,39 @@
|
|||
"ready": "Gereed",
|
||||
"error": "Fout"
|
||||
}
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"name": "Huidige prijsrang (vandaag)"
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"name": "Huidige prijsrang (morgen)"
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"name": "Huidige prijsrang (vandaag+morgen)"
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"name": "Volgende prijsrang (vandaag)"
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"name": "Volgende prijsrang (vandaag+morgen)"
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"name": "Vorige prijsrang (vandaag)"
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"name": "Vorige prijsrang (vandaag+morgen)"
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"name": "⌀ Uurlijkse prijsrang huidig (vandaag)"
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Uurlijkse prijsrang huidig (vandaag+morgen)"
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"name": "⌀ Uurlijkse prijsrang volgende (vandaag)"
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Uurlijkse prijsrang volgende (vandaag+morgen)"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -1847,6 +1880,10 @@
|
|||
"name": "Minimaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Vergelijkingsdetails opnemen",
|
||||
"description": "Voegt per taak extra price_comparison-details toe (comparison_price_min, comparison_price_max, comparison_window_end) om het gekozen venster te vergelijken met het tegenovergestelde extreme venster met dezelfde duur."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basisvaluta gebruiken",
|
||||
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,39 @@
|
|||
"ready": "Redo",
|
||||
"error": "Fel"
|
||||
}
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"name": "Aktuellt prisrang (idag)"
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"name": "Aktuellt prisrang (imorgon)"
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"name": "Aktuellt prisrang (idag+imorgon)"
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"name": "Nästa prisrang (idag)"
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"name": "Nästa prisrang (idag+imorgon)"
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"name": "Förra prisrang (idag)"
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"name": "Förra prisrang (idag+imorgon)"
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"name": "⌀ Timprisrang aktuell (idag)"
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Timprisrang aktuell (idag+imorgon)"
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"name": "⌀ Timprisrang nästa (idag)"
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Timprisrang nästa (idag+imorgon)"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -1847,6 +1880,10 @@
|
|||
"name": "Minimal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Inkludera jaemfoerelsedetaljer",
|
||||
"description": "Laegger till extra price_comparison-detaljer per uppgift (comparison_price_min, comparison_price_max, comparison_window_end) foer att jaemfoera valt foenster med motsatt extremfoenster med samma laengd."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Använd basvaluta",
|
||||
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import logging
|
||||
import statistics
|
||||
from datetime import datetime, timedelta
|
||||
|
|
@ -176,6 +177,104 @@ def calculate_volatility_level(
|
|||
return level
|
||||
|
||||
|
||||
MIN_PRICES_FOR_IQR = 4 # Minimum price values needed for meaningful IQR calculation
|
||||
|
||||
|
||||
def calculate_iqr_stats(prices: list[float]) -> dict[str, Any] | None:
|
||||
"""
|
||||
Calculate Interquartile Range (IQR) statistics from a price list.
|
||||
|
||||
IQR = Q75 - Q25, representing the spread of the central 50% of prices.
|
||||
This is more robust to outliers than coefficient of variation because
|
||||
extreme values (price spikes or negative prices) don't distort the result.
|
||||
|
||||
Args:
|
||||
prices: List of price values (in any unit, e.g. EUR or NOK per kWh)
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
- q25: 25th percentile (lower quartile)
|
||||
- median: 50th percentile (median)
|
||||
- q75: 75th percentile (upper quartile)
|
||||
- iqr: Interquartile range (q75 - q25)
|
||||
- iqr_pct: Relative IQR as percentage of median (None if median is 0)
|
||||
- outlier_count: Intervals outside Tukey fences [Q25 - 1.5xIQR, Q75 + 1.5xIQR]
|
||||
Returns None if fewer than MIN_PRICES_FOR_IQR prices are provided.
|
||||
|
||||
Examples:
|
||||
- iqr_pct ~5%: Very tight price band, stability similar to CV
|
||||
- iqr_pct ~20%: Moderate spread in the core price range
|
||||
- iqr_pct ~50%: Wide core spread, significant optimization potential
|
||||
- outlier_count > 0: Isolated price spikes/dips exist (CV-heavy days)
|
||||
|
||||
"""
|
||||
if len(prices) < MIN_PRICES_FOR_IQR:
|
||||
return None
|
||||
|
||||
quartiles = statistics.quantiles(prices, n=4) # Returns [Q25, Q50, Q75]
|
||||
q25 = quartiles[0]
|
||||
median = quartiles[1]
|
||||
q75 = quartiles[2]
|
||||
iqr = q75 - q25
|
||||
|
||||
# Relative IQR: normalized by median for cross-price-level comparison
|
||||
iqr_pct = (iqr / abs(median) * 100) if median != 0 else None
|
||||
|
||||
# Tukey fence outlier detection (standard method)
|
||||
lower_fence = q25 - 1.5 * iqr
|
||||
upper_fence = q75 + 1.5 * iqr
|
||||
outlier_count = sum(1 for p in prices if p < lower_fence or p > upper_fence)
|
||||
|
||||
return {
|
||||
"q25": q25,
|
||||
"median": median,
|
||||
"q75": q75,
|
||||
"iqr": iqr,
|
||||
"iqr_pct": iqr_pct,
|
||||
"outlier_count": outlier_count,
|
||||
}
|
||||
|
||||
|
||||
def calculate_percentile_rank(current_price: float, prices: list[float]) -> float | None:
|
||||
"""
|
||||
Calculate where the current price ranks among a reference price set.
|
||||
|
||||
Returns the percentage of prices in the reference set that are strictly
|
||||
cheaper than current_price. A value of 0% means the current price is at
|
||||
or below the cheapest reference price; ~99% means nearly everything is
|
||||
cheaper (current price near the maximum).
|
||||
|
||||
The current interval's own price is included in today's reference set,
|
||||
so the cheapest interval of the day always returns 0%.
|
||||
|
||||
Args:
|
||||
current_price: The price to rank (any unit, must match prices unit)
|
||||
prices: Reference price list to rank against
|
||||
|
||||
Returns:
|
||||
Percentile rank as float 0.0-100.0 (1 decimal precision), or None if
|
||||
reference list is empty.
|
||||
|
||||
Examples (8 intervals: [8, 10, 12, 15, 15, 18, 20, 22]):
|
||||
- current=8: 0/8 x 100 = 0.0% (cheapest)
|
||||
- current=15: 3/8 x 100 = 37.5% (above 3 cheaper intervals)
|
||||
- current=22: 7/8 x 100 = 87.5% (most expensive)
|
||||
|
||||
Note:
|
||||
Equal prices: All duplicate prices at the current level are counted as
|
||||
"not below" the current price (bisect_left semantics). This matches
|
||||
automation logic: "is now cheap?" returns False if current == minimum
|
||||
is debatable but ensures 0% always means strictly cheapest.
|
||||
|
||||
"""
|
||||
if not prices:
|
||||
return None
|
||||
|
||||
sorted_prices = sorted(prices)
|
||||
count_below = bisect.bisect_left(sorted_prices, current_price)
|
||||
return round(count_below / len(sorted_prices) * 100, 1)
|
||||
|
||||
|
||||
def calculate_trailing_average_for_interval(
|
||||
interval_start: datetime,
|
||||
all_prices: list[dict[str, Any]],
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ query($homeId: ID!) {
|
|||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `homeId`: Tibber home identifier
|
||||
- `resolution`: Always `QUARTER_HOURLY`
|
||||
- `first`: 384 intervals (4 days of data)
|
||||
|
|
@ -85,10 +86,12 @@ query($homeId: ID!) {
|
|||
## Rate Limits
|
||||
|
||||
Tibber API rate limits (as of 2024):
|
||||
|
||||
- **5000 requests per hour** per token
|
||||
- **Burst limit:** 100 requests per minute
|
||||
|
||||
Integration stays well below these limits:
|
||||
|
||||
- Polls every 15 minutes = 96 requests/day
|
||||
- User data cached for 24h = 1 request/day
|
||||
- **Total:** ~100 requests/day per home
|
||||
|
|
@ -106,6 +109,7 @@ Integration stays well below these limits:
|
|||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
|
||||
- `startsAt`: ISO 8601 timestamp with timezone
|
||||
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
|
||||
|
|
@ -119,6 +123,7 @@ Integration stays well below these limits:
|
|||
```
|
||||
|
||||
Supported currencies:
|
||||
|
||||
- `EUR` (Euro) - displayed as ct/kWh
|
||||
- `NOK` (Norwegian Krone) - displayed as øre/kWh
|
||||
- `SEK` (Swedish Krona) - displayed as öre/kWh
|
||||
|
|
@ -128,42 +133,52 @@ Supported currencies:
|
|||
### Common Error Responses
|
||||
|
||||
**Invalid Token:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Rate Limit Exceeded:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Home Not Found:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Integration handles these with:
|
||||
|
||||
- Exponential backoff retry (3 attempts)
|
||||
- ConfigEntryAuthFailed for auth errors
|
||||
- ConfigEntryNotReady for temporary failures
|
||||
|
|
@ -171,6 +186,7 @@ Integration handles these with:
|
|||
## Data Transformation
|
||||
|
||||
Raw API data is enriched with:
|
||||
|
||||
- **Trailing 24h average** - Calculated from previous intervals
|
||||
- **Leading 24h average** - Calculated from future intervals
|
||||
- **Price difference %** - Deviation from average
|
||||
|
|
@ -181,6 +197,7 @@ See `utils/price.py` for enrichment logic.
|
|||
---
|
||||
|
||||
💡 **External Resources:**
|
||||
|
||||
- [Tibber API Documentation](https://developer.tibber.com/docs/overview)
|
||||
- [GraphQL Explorer](https://developer.tibber.com/explorer)
|
||||
- [Get API Token](https://developer.tibber.com/settings/access-token)
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ flowchart TB
|
|||
The integration uses **5 independent caching layers** for optimal performance:
|
||||
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
|-------|----------|----------|--------------|--------|
|
||||
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ |
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
|
|
@ -196,7 +196,7 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
### Core Components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|-----------|------|----------------|
|
||||
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
|
|
@ -210,7 +210,7 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
|
||||
|
||||
| Component | Files | Lines | Responsibility |
|
||||
|-----------|-------|-------|----------------|
|
||||
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- |
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
|
|
@ -219,6 +219,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
|
||||
**Calculator Package** (`sensor/calculators/`):
|
||||
|
||||
- `base.py` - Abstract BaseCalculator with coordinator access
|
||||
- `interval.py` - Single interval calculations (current/next/previous)
|
||||
- `rolling_hour.py` - 5-interval rolling windows
|
||||
|
|
@ -230,6 +231,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
- `metadata.py` - Home/metering metadata
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 58% reduction in core.py (2,170 → 909 lines)
|
||||
- Clear separation: Calculators (logic) vs Attributes (presentation)
|
||||
- Independent testability for each calculator
|
||||
|
|
@ -238,7 +240,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
### Helper Utilities
|
||||
|
||||
| Utility | File | Purpose |
|
||||
|---------|------|---------|
|
||||
| ----------------- | ------------------ | ------------------------------------------------- |
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
|
|
@ -296,26 +298,31 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
Sensors organized by **calculation method** (refactored Nov 2025):
|
||||
|
||||
**Unified Handler Methods** (`sensor/core.py`):
|
||||
|
||||
- `_get_interval_value(offset, type)` - current/next/previous intervals
|
||||
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
|
||||
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
|
||||
- `_get_24h_window_value(stat_func)` - trailing/leading statistics
|
||||
|
||||
**Routing** (`sensor/value_getters.py`):
|
||||
|
||||
- Single source of truth mapping 80+ entity keys to calculator methods
|
||||
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
|
||||
|
||||
**Calculators** (`sensor/calculators/`):
|
||||
|
||||
- Each calculator inherits from `BaseCalculator` with coordinator access
|
||||
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
|
||||
- Complex logic isolated (e.g., `TrendCalculator` has internal caching)
|
||||
|
||||
**Attributes** (`sensor/attributes/`):
|
||||
|
||||
- Separate from business logic, handles state presentation
|
||||
- Builds extra_state_attributes dicts for entity classes
|
||||
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Minimal code duplication across 80+ sensors
|
||||
- Clear separation of concerns (calculation vs presentation)
|
||||
- Easy to extend: Add sensor → choose pattern → add to routing
|
||||
|
|
@ -334,7 +341,7 @@ Sensors organized by **calculation method** (refactored Nov 2025):
|
|||
### CPU Optimization
|
||||
|
||||
| Optimization | Location | Savings |
|
||||
|--------------|----------|---------|
|
||||
| ------------------- | ------------------------ | ---------------------------- |
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total)
|
||||
- **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
|
||||
- **Timestamps**: Last update times for validation
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
|
||||
- **User data**: 24 hours (refreshed daily)
|
||||
- **Survives**: HA restarts via persistent Storage
|
||||
|
|
@ -36,6 +38,7 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Midnight turnover** (Timer #2 in coordinator):
|
||||
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
|
|
@ -45,6 +48,7 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
```
|
||||
|
||||
2. **Cache validation on load**:
|
||||
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
|
|
@ -71,18 +75,22 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
|
||||
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Forever** (until HA restart)
|
||||
- No invalidation during runtime
|
||||
|
||||
**When populated:**
|
||||
|
||||
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
|
||||
- Lazy loading: If translation missing, attempts file load once
|
||||
|
||||
**Access pattern:**
|
||||
|
||||
```python
|
||||
# Non-blocking synchronous access from cached data
|
||||
description = get_translation("binary_sensor.best_price_period.description", "en")
|
||||
|
|
@ -101,6 +109,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**What is cached:**
|
||||
|
||||
### DataTransformer Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"thresholds": {"low": 15, "high": 35},
|
||||
|
|
@ -110,6 +119,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
### PeriodCalculator Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||
|
|
@ -118,10 +128,12 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until `invalidate_config_cache()` is called
|
||||
- Built once on first use per coordinator update cycle
|
||||
|
||||
**Invalidation trigger:**
|
||||
|
||||
- **Options change** (user reconfigures integration):
|
||||
```python
|
||||
# coordinator/core.py
|
||||
|
|
@ -132,6 +144,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Before:** ~30 dict lookups + type conversions per update = ~50μs
|
||||
- **After:** 1 cache check = ~1μs
|
||||
- **Savings:** ~98% (50μs → 1μs per update)
|
||||
|
|
@ -147,6 +160,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"best_price": {
|
||||
|
|
@ -161,6 +175,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Cache key:** Hash of relevant inputs
|
||||
|
||||
```python
|
||||
hash_data = (
|
||||
today_signature, # (startsAt, rating_level) for each interval
|
||||
|
|
@ -172,6 +187,7 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until price data changes (today's intervals modified)
|
||||
- Until config changes (flex, thresholds, filters)
|
||||
- Recalculated at midnight (new today data)
|
||||
|
|
@ -179,6 +195,7 @@ hash_data = (
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Config change** (explicit):
|
||||
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
|
|
@ -193,10 +210,12 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Cache hit rate:**
|
||||
|
||||
- **High:** During normal operation (coordinator updates every 15min, price data unchanged)
|
||||
- **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
|
||||
- **Cache hit:** `<`1ms (hash comparison + dict lookup)
|
||||
- **Savings:** ~70% of calculation time (most updates hit cache)
|
||||
|
|
@ -212,6 +231,7 @@ hash_data = (
|
|||
**Status:** ✅ **Clean separation** - enrichment only, no redundancy
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"timestamp": ...,
|
||||
|
|
@ -224,6 +244,7 @@ hash_data = (
|
|||
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
|
||||
|
||||
**Current behavior:**
|
||||
|
||||
- Caches **only enriched price data** (price + statistics)
|
||||
- **Does NOT cache periods** (handled by Period Calculation Cache)
|
||||
- Invalidated when:
|
||||
|
|
@ -232,6 +253,7 @@ hash_data = (
|
|||
- New update cycle begins
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- DataTransformer: Handles price enrichment only
|
||||
- PeriodCalculator: Handles period calculation only (with hash-based cache)
|
||||
- Coordinator: Assembles final data on-demand from both caches
|
||||
|
|
@ -243,6 +265,7 @@ hash_data = (
|
|||
## Cache Invalidation Flow
|
||||
|
||||
### User Changes Options (Config Flow)
|
||||
|
||||
```
|
||||
User saves options
|
||||
↓
|
||||
|
|
@ -267,6 +290,7 @@ Fresh data fetch with new config
|
|||
```
|
||||
|
||||
### Midnight Turnover (Day Transition)
|
||||
|
||||
```
|
||||
Timer #2 fires at 00:00
|
||||
↓
|
||||
|
|
@ -286,6 +310,7 @@ Fresh API fetch for new day
|
|||
```
|
||||
|
||||
### Tomorrow Data Arrives (~13:00)
|
||||
|
||||
```
|
||||
Coordinator update cycle
|
||||
↓
|
||||
|
|
@ -327,12 +352,14 @@ API Data Cache (price_data, user_data)
|
|||
```
|
||||
|
||||
**No cache invalidation cascades:**
|
||||
|
||||
- Config cache invalidation is **explicit** (on options update)
|
||||
- Period cache invalidation is **automatic** (via hash mismatch)
|
||||
- Transformation cache invalidation is **automatic** (on midnight/config change)
|
||||
- Translation cache is **never invalidated** (read-only after load)
|
||||
|
||||
**Thread safety:**
|
||||
|
||||
- All caches are accessed from `MainThread` only (Home Assistant event loop)
|
||||
- No locking needed (single-threaded execution model)
|
||||
|
||||
|
|
@ -341,6 +368,7 @@ API Data Cache (price_data, user_data)
|
|||
## Performance Characteristics
|
||||
|
||||
### Typical Operation (No Changes)
|
||||
|
||||
```
|
||||
Coordinator Update (every 15 min)
|
||||
├─> API fetch: SKIP (cache valid)
|
||||
|
|
@ -353,6 +381,7 @@ Total: ~16ms (down from ~600ms without caching)
|
|||
```
|
||||
|
||||
### After Midnight Turnover
|
||||
|
||||
```
|
||||
Coordinator Update (00:00)
|
||||
├─> API fetch: ~500ms (cache cleared, fetch new day)
|
||||
|
|
@ -365,6 +394,7 @@ Total: ~755ms (expected once per day)
|
|||
```
|
||||
|
||||
### After Config Change
|
||||
|
||||
```
|
||||
Options Update
|
||||
├─> Cache invalidation: `<`1ms
|
||||
|
|
@ -382,7 +412,7 @@ Options Update
|
|||
## Summary Table
|
||||
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
|------------|----------|------|--------------|---------|
|
||||
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- |
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
|
|
@ -392,12 +422,14 @@ Options Update
|
|||
**Total memory overhead:** ~116KB per coordinator instance (main + subentries)
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 97% reduction in API calls (from every 15min to once per day)
|
||||
- 70% reduction in period calculation time (cache hits during normal operation)
|
||||
- 98% reduction in config access time (30+ lookups → 1 cache check)
|
||||
- Zero file I/O during runtime (translations cached at startup)
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- Memory usage: ~116KB per home (negligible for modern systems)
|
||||
- Code complexity: 5 cache invalidation points (well-tested, documented)
|
||||
- Debugging: Must understand cache lifetime when investigating stale data issues
|
||||
|
|
@ -407,7 +439,9 @@ Options Update
|
|||
## Debugging Cache Issues
|
||||
|
||||
### Symptom: Stale data after config change
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Is `_handle_options_update()` called? (should see "Options updated" log)
|
||||
2. Are `invalidate_config_cache()` methods executed?
|
||||
3. Does `async_request_refresh()` trigger?
|
||||
|
|
@ -415,7 +449,9 @@ Options Update
|
|||
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
|
||||
|
||||
### Symptom: Period calculation not updating
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Verify hash changes when data changes: `_compute_periods_hash()`
|
||||
2. Check `_last_periods_hash` vs `current_hash`
|
||||
3. Look for "Using cached period calculation" vs "Calculating periods" logs
|
||||
|
|
@ -423,7 +459,9 @@ Options Update
|
|||
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
|
||||
|
||||
### Symptom: Yesterday's prices shown as today
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `is_cache_valid()` logic in `coordinator/cache.py`
|
||||
2. Midnight turnover execution (Timer #2)
|
||||
3. Cache clear confirmation in logs
|
||||
|
|
@ -431,7 +469,9 @@ Options Update
|
|||
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
|
||||
|
||||
### Symptom: Missing translations
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `async_load_translations()` called at startup?
|
||||
2. Translation files exist in `/translations/` and `/custom_translations/`?
|
||||
3. Cache population: `_TRANSLATIONS_CACHE` keys
|
||||
|
|
|
|||
|
|
@ -41,12 +41,14 @@ class TimeService:
|
|||
```
|
||||
|
||||
**When prefix is required:**
|
||||
|
||||
- Public classes used across multiple modules
|
||||
- All exception classes
|
||||
- All coordinator and entity classes
|
||||
- Data classes (dataclasses, NamedTuples) used as public APIs
|
||||
|
||||
**When prefix can be omitted:**
|
||||
|
||||
- Private helper classes within a single module (prefix with `_` underscore)
|
||||
- Type aliases and callbacks (e.g., `TimeServiceCallback`)
|
||||
- Small internal NamedTuples for function returns
|
||||
|
|
@ -71,6 +73,7 @@ class DataFetcher: # Should be TibberPricesDataFetcher
|
|||
**Current Technical Debt:**
|
||||
|
||||
Many existing classes lack the `TibberPrices` prefix. Before refactoring:
|
||||
|
||||
1. Document the plan in `/planning/class-naming-refactoring.md`
|
||||
2. Use `multi_replace_string_in_file` for bulk renames
|
||||
3. Test thoroughly after each module
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ git checkout -b fix/issue-123-description
|
|||
```
|
||||
|
||||
**Branch naming:**
|
||||
|
||||
- `feature/` - New features
|
||||
- `fix/` - Bug fixes
|
||||
- `docs/` - Documentation only
|
||||
|
|
@ -45,6 +46,7 @@ git checkout -b fix/issue-123-description
|
|||
Edit code, following [Coding Guidelines](coding-guidelines.md).
|
||||
|
||||
**Run checks frequently:**
|
||||
|
||||
```bash
|
||||
./scripts/type-check # Pyright type checking
|
||||
./scripts/lint # Ruff linting (auto-fix)
|
||||
|
|
@ -78,6 +80,7 @@ async def test_your_feature(hass, coordinator):
|
|||
```
|
||||
|
||||
Run your test:
|
||||
|
||||
```bash
|
||||
./scripts/test tests/test_your_feature.py -v
|
||||
```
|
||||
|
|
@ -97,6 +100,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
```
|
||||
|
||||
**Commit types:**
|
||||
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation
|
||||
|
|
@ -105,6 +109,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
- `chore:` - Maintenance
|
||||
|
||||
**Add scope when relevant:**
|
||||
|
||||
- `feat(sensors):` - Sensor platform
|
||||
- `fix(coordinator):` - Data coordinator
|
||||
- `docs(user):` - User documentation
|
||||
|
|
@ -124,32 +129,40 @@ Then open Pull Request on GitHub.
|
|||
Title: Short, descriptive (50 chars max)
|
||||
|
||||
Description should include:
|
||||
|
||||
```markdown
|
||||
## What
|
||||
|
||||
Brief description of changes
|
||||
|
||||
## Why
|
||||
|
||||
Problem being solved or feature rationale
|
||||
|
||||
## How
|
||||
|
||||
Implementation approach
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Manual testing in Home Assistant
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] Type checking passes
|
||||
- [ ] Linting passes
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
(If any - describe migration path)
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
### PR Checklist
|
||||
|
||||
Before submitting:
|
||||
|
||||
- [ ] Code follows [Coding Guidelines](coding-guidelines.md)
|
||||
- [ ] All tests pass (`./scripts/test`)
|
||||
- [ ] Type checking passes (`./scripts/type-check`)
|
||||
|
|
@ -170,6 +183,7 @@ Before submitting:
|
|||
### What Reviewers Look For
|
||||
|
||||
✅ **Good:**
|
||||
|
||||
- Clear, self-explanatory code
|
||||
- Appropriate comments for complex logic
|
||||
- Tests covering edge cases
|
||||
|
|
@ -177,6 +191,7 @@ Before submitting:
|
|||
- Follows existing patterns
|
||||
|
||||
❌ **Avoid:**
|
||||
|
||||
- Large PRs (>500 lines) - split into smaller ones
|
||||
- Mixing unrelated changes
|
||||
- Missing tests for new features
|
||||
|
|
@ -193,6 +208,7 @@ Before submitting:
|
|||
## Finding Issues to Work On
|
||||
|
||||
Good first issues are labeled:
|
||||
|
||||
- `good first issue` - Beginner-friendly
|
||||
- `help wanted` - Maintainers welcome contributions
|
||||
- `documentation` - Docs improvements
|
||||
|
|
@ -210,6 +226,7 @@ Be respectful, constructive, and patient. We're all volunteers! 🙏
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Setup Guide](setup.md) - DevContainer setup
|
||||
- [Coding Guidelines](coding-guidelines.md) - Style guide
|
||||
- [Testing](testing.md) - Writing tests
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ comments: false
|
|||
## 🎯 Why Are These Tests Critical?
|
||||
|
||||
Home Assistant integrations run **continuously** in the background. Resource leaks lead to:
|
||||
|
||||
- **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable
|
||||
- **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases
|
||||
- **Timer Leaks**: Timers continue running after unload → unnecessary background tasks
|
||||
|
|
@ -26,6 +27,7 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.1 Listener Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`)
|
||||
- Minute-update listeners are correctly removed (`async_add_minute_update_listener()`)
|
||||
- Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`)
|
||||
|
|
@ -33,11 +35,13 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
- Binary sensor cleanup removes ALL registered listeners
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Each registered listener holds references to Entity + Coordinator
|
||||
- Without cleanup: Entities are not freed by GC → Memory Leak
|
||||
- With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `async_add_time_sensitive_listener()`, `async_add_minute_update_listener()`
|
||||
- `coordinator/core.py` → `register_lifecycle_callback()`
|
||||
- `sensor/core.py` → `async_will_remove_from_hass()`
|
||||
|
|
@ -46,32 +50,38 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.2 Timer Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is cancelled and reference cleared
|
||||
- Minute timer is cancelled and reference cleared
|
||||
- Both timers are cancelled together
|
||||
- Cleanup works even when timers are `None`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Uncancelled timers continue running after integration unload
|
||||
- HA's `async_track_utc_time_change()` creates persistent callbacks
|
||||
- Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `cancel_timers()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
#### 1.3 Config Entry Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Options update listener is registered via `async_on_unload()`
|
||||
- Cleanup function is correctly passed to `async_on_unload()`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- `entry.add_update_listener()` registers permanent callback
|
||||
- Without `async_on_unload()`: Listener remains active after reload → duplicate updates
|
||||
- Pattern: `entry.async_on_unload(entry.add_update_listener(handler))`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/core.py` → `__init__()` (listener registration)
|
||||
- `__init__.py` → `async_unload_entry()`
|
||||
|
||||
|
|
@ -82,16 +92,19 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 2.1 Config Cache Invalidation
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- DataTransformer config cache is invalidated on options change
|
||||
- PeriodCalculator config + period cache is invalidated
|
||||
- Trend calculator cache is cleared on coordinator update
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Stale config → Sensors use old user settings
|
||||
- Stale period cache → Incorrect best/peak price periods
|
||||
- Stale trend cache → Outdated trend analysis
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/data_transformation.py` → `invalidate_config_cache()`
|
||||
- `coordinator/periods.py` → `invalidate_config_cache()`
|
||||
- `sensor/calculators/trend.py` → `clear_trend_cache()`
|
||||
|
|
@ -103,15 +116,18 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 3.1 Persistent Storage Removal
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Storage file is deleted on config entry removal
|
||||
- Cache is saved on shutdown (no data loss)
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Without storage removal: Old files remain after uninstallation
|
||||
- Without cache save on shutdown: Data loss on HA restart
|
||||
- Storage path: `.storage/tibber_prices.{entry_id}`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `__init__.py` → `async_remove_entry()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
|
|
@ -120,12 +136,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_timer_scheduling.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is registered with correct parameters
|
||||
- Minute timer is registered with correct parameters
|
||||
- Timers can be re-scheduled (override old timer)
|
||||
- Midnight turnover detection works correctly
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer parameters → Entities update at wrong times
|
||||
- Without timer override on re-schedule → Multiple parallel timers → Performance problem
|
||||
|
||||
|
|
@ -134,12 +152,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_sensor_timer_assignment.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys
|
||||
- All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys
|
||||
- Both lists are disjoint (no overlap)
|
||||
- Sensor and binary sensor platforms are checked
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer assignment → Sensors update at wrong times
|
||||
- Overlap → Duplicate updates → Performance problem
|
||||
|
||||
|
|
@ -150,10 +170,12 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 6. Async Task Management
|
||||
|
||||
**Current Status:** Fire-and-forget pattern for short tasks
|
||||
|
||||
- `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds)
|
||||
- `coordinator/core.py` → Cache storage (short-lived, max 100ms)
|
||||
|
||||
**Why no tests needed:**
|
||||
|
||||
- No long-running tasks (all < 2 seconds)
|
||||
- HA's event loop handles short tasks automatically
|
||||
- Task exceptions are already logged
|
||||
|
|
@ -163,6 +185,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 7. API Session Cleanup
|
||||
|
||||
**Current Status:** ✅ Correctly implemented
|
||||
|
||||
- `async_get_clientsession(hass)` is used (shared session)
|
||||
- No new sessions are created
|
||||
- HA manages session lifecycle automatically
|
||||
|
|
@ -172,6 +195,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 8. Translation Cache Memory
|
||||
|
||||
**Current Status:** ✅ Bounded cache
|
||||
|
||||
- Max ~5-10 languages × 5KB = 50KB total
|
||||
- Module-level cache without re-loading
|
||||
- Practically no memory issue
|
||||
|
|
@ -181,11 +205,13 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 9. Coordinator Data Structure Integrity
|
||||
|
||||
**Current Status:** Manually tested via `./scripts/develop`
|
||||
|
||||
- Midnight turnover works correctly (observed over several days)
|
||||
- Missing keys are handled via `.get()` with defaults
|
||||
- 80+ sensors access `coordinator.data` without errors
|
||||
|
||||
**Structure:**
|
||||
|
||||
```python
|
||||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
|
|
@ -197,6 +223,7 @@ coordinator.data = {
|
|||
### 10. Service Response Memory
|
||||
|
||||
**Current Status:** HA's response lifecycle
|
||||
|
||||
- HA automatically frees service responses after return
|
||||
- ApexCharts ~20KB response is one-time per call
|
||||
- No response accumulation in integration code
|
||||
|
|
@ -208,7 +235,7 @@ coordinator.data = {
|
|||
### ✅ Implemented Tests (41 total)
|
||||
|
||||
| Category | Status | Tests | File | Coverage |
|
||||
|----------|--------|-------|------|----------|
|
||||
| ----------------------- | ------ | ------ | --------------------------------- | ------------------- |
|
||||
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
|
||||
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
|
||||
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
|
|
@ -222,7 +249,7 @@ coordinator.data = {
|
|||
### 📋 Analyzed but Not Implemented (Nice-to-Have)
|
||||
|
||||
| Category | Status | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| ------------------------ | ------ | ---------------------------------------------------- |
|
||||
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
|
||||
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
|
||||
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
|
||||
|
|
@ -230,6 +257,7 @@ coordinator.data = {
|
|||
| Service Response Memory | 📋 | HA automatically frees service responses |
|
||||
|
||||
**Legend:**
|
||||
|
||||
- ✅ = Fully tested or pattern verified correct
|
||||
- 📋 = Analyzed, low priority for testing (no known issues)
|
||||
|
||||
|
|
@ -238,6 +266,7 @@ coordinator.data = {
|
|||
### ✅ All Critical Patterns Tested
|
||||
|
||||
All essential memory leak prevention patterns are covered by 41 tests:
|
||||
|
||||
- ✅ Listeners are correctly removed (no callback leaks)
|
||||
- ✅ Timers are cancelled (no background task leaks)
|
||||
- ✅ Config entry cleanup works (no dangling listeners)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ Restart Home Assistant to apply.
|
|||
### Key Log Messages
|
||||
|
||||
**Coordinator Updates:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator] Successfully fetched price data
|
||||
[custom_components.tibber_prices.coordinator] Cache valid, using cached data
|
||||
|
|
@ -27,6 +28,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**Period Calculation:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator.periods] Calculating BEST PRICE periods: flex=15.0%
|
||||
[custom_components.tibber_prices.coordinator.periods] Day 2024-12-06: Found 2 periods
|
||||
|
|
@ -34,6 +36,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**API Errors:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.api] API request failed: Unauthorized
|
||||
[custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s
|
||||
|
|
@ -67,6 +70,7 @@ Restart Home Assistant to apply.
|
|||
### Set Breakpoints
|
||||
|
||||
**Coordinator update:**
|
||||
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _async_update_data(self) -> dict:
|
||||
|
|
@ -75,6 +79,7 @@ async def _async_update_data(self) -> dict:
|
|||
```
|
||||
|
||||
**Period calculation:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(...) -> list[dict]:
|
||||
|
|
@ -91,6 +96,7 @@ def calculate_periods(...) -> list[dict]:
|
|||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `-v` - Verbose output
|
||||
- `-s` - Show print statements
|
||||
- `-k pattern` - Run tests matching pattern
|
||||
|
|
@ -102,6 +108,7 @@ Set breakpoint in test file, use "Debug Test" CodeLens.
|
|||
### Useful Test Patterns
|
||||
|
||||
**Print coordinator data:**
|
||||
|
||||
```python
|
||||
def test_something(coordinator):
|
||||
print(f"Coordinator data: {coordinator.data}")
|
||||
|
|
@ -109,6 +116,7 @@ def test_something(coordinator):
|
|||
```
|
||||
|
||||
**Inspect period attributes:**
|
||||
|
||||
```python
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
|
|
@ -122,11 +130,13 @@ def test_periods(hass, coordinator):
|
|||
### Integration Not Loading
|
||||
|
||||
**Check:**
|
||||
|
||||
```bash
|
||||
grep "tibber_prices" config/home-assistant.log
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
|
||||
- Syntax error in Python code → Check logs for traceback
|
||||
- Missing dependency → Run `uv sync`
|
||||
- Wrong file permissions → `chmod +x scripts/*`
|
||||
|
|
@ -134,12 +144,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
### Sensors Not Updating
|
||||
|
||||
**Check coordinator state:**
|
||||
|
||||
```python
|
||||
# In Developer Tools > Template
|
||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
||||
```
|
||||
|
||||
**Debug in code:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
|
|
@ -149,6 +161,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
### Period Calculation Wrong
|
||||
|
||||
**Enable detailed period logs:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
|
|
@ -156,6 +169,7 @@ _LOGGER.debug("Candidate intervals: %s",
|
|||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
||||
```
|
||||
[period_building] Flex filter blocked: 45 intervals
|
||||
[period_building] Min distance blocked: 12 intervals
|
||||
|
|
@ -200,6 +214,7 @@ python -m pstats profile.stats
|
|||
### Remote Debugging with debugpy
|
||||
|
||||
Add to coordinator code:
|
||||
|
||||
```python
|
||||
import debugpy
|
||||
debugpy.listen(5678)
|
||||
|
|
@ -212,11 +227,13 @@ Connect from VS Code with remote attach configuration.
|
|||
### IPython REPL
|
||||
|
||||
Install in container:
|
||||
|
||||
```bash
|
||||
uv pip install ipython
|
||||
```
|
||||
|
||||
Add breakpoint:
|
||||
|
||||
```python
|
||||
from IPython import embed
|
||||
embed() # Drops into interactive shell
|
||||
|
|
@ -225,6 +242,7 @@ embed() # Drops into interactive shell
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Testing Guide](testing.md) - Writing and running tests
|
||||
- [Setup Guide](setup.md) - Development environment
|
||||
- [Architecture](architecture.md) - Code structure
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ Documentation is organized in two Docusaurus sites:
|
|||
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
|
||||
|
||||
**Best practices:**
|
||||
|
||||
- Use clear examples and code snippets
|
||||
- Keep docs up-to-date with code changes
|
||||
- Add new pages to appropriate `sidebars.ts` for navigation
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Guidelines for maintaining and improving integration performance.
|
|||
## Performance Goals
|
||||
|
||||
Target metrics:
|
||||
|
||||
- **Coordinator update**: <500ms (typical: 200-300ms)
|
||||
- **Sensor update**: <10ms per sensor
|
||||
- **Period calculation**: <100ms (typical: 20-50ms)
|
||||
|
|
@ -64,6 +65,7 @@ python -m aioprof homeassistant -c config
|
|||
### Caching
|
||||
|
||||
**1. Persistent Cache** (API data):
|
||||
|
||||
```python
|
||||
# Already implemented in coordinator/cache.py
|
||||
store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
|
|
@ -71,6 +73,7 @@ data = await store.async_load()
|
|||
```
|
||||
|
||||
**2. Translation Cache** (in-memory):
|
||||
|
||||
```python
|
||||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
|
@ -83,6 +86,7 @@ def get_translation(path: str, language: str) -> dict:
|
|||
```
|
||||
|
||||
**3. Config Cache** (invalidated on options change):
|
||||
|
||||
```python
|
||||
class DataTransformer:
|
||||
def __init__(self):
|
||||
|
|
@ -100,6 +104,7 @@ class DataTransformer:
|
|||
### Lazy Loading
|
||||
|
||||
**Load data only when needed:**
|
||||
|
||||
```python
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict | None:
|
||||
|
|
@ -113,6 +118,7 @@ def extra_state_attributes(self) -> dict | None:
|
|||
### Bulk Operations
|
||||
|
||||
**Process multiple items at once:**
|
||||
|
||||
```python
|
||||
# ❌ Slow - loop with individual operations
|
||||
for interval in intervals:
|
||||
|
|
@ -126,6 +132,7 @@ results = enrich_intervals_bulk(intervals)
|
|||
### Async Best Practices
|
||||
|
||||
**1. Concurrent API calls:**
|
||||
|
||||
```python
|
||||
# ❌ Sequential (slow)
|
||||
user_data = await fetch_user_data()
|
||||
|
|
@ -139,6 +146,7 @@ user_data, price_data = await asyncio.gather(
|
|||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
||||
```python
|
||||
# ❌ Blocking
|
||||
result = heavy_computation() # Blocks for seconds
|
||||
|
|
@ -152,6 +160,7 @@ result = await hass.async_add_executor_job(heavy_computation)
|
|||
### Avoid Memory Leaks
|
||||
|
||||
**1. Clear references:**
|
||||
|
||||
```python
|
||||
class Coordinator:
|
||||
async def async_shutdown(self):
|
||||
|
|
@ -162,6 +171,7 @@ class Coordinator:
|
|||
```
|
||||
|
||||
**2. Use weak references for callbacks:**
|
||||
|
||||
```python
|
||||
import weakref
|
||||
|
||||
|
|
@ -176,6 +186,7 @@ class Manager:
|
|||
### Efficient Data Structures
|
||||
|
||||
**Use appropriate types:**
|
||||
|
||||
```python
|
||||
# ❌ List for lookups (O(n))
|
||||
if timestamp in timestamp_list:
|
||||
|
|
@ -197,11 +208,13 @@ results = (x for x in items if condition(x))
|
|||
### Minimize API Calls
|
||||
|
||||
**Already implemented:**
|
||||
|
||||
- Cache valid until midnight
|
||||
- User data cached for 24h
|
||||
- Only poll when tomorrow data expected
|
||||
|
||||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
|
|
@ -210,6 +223,7 @@ _LOGGER.debug("API call: %s (cache_age=%s)",
|
|||
### Smart Updates
|
||||
|
||||
**Only update when needed:**
|
||||
|
||||
```python
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch data from API."""
|
||||
|
|
@ -226,6 +240,7 @@ async def _async_update_data(self) -> dict:
|
|||
### State Class Selection
|
||||
|
||||
**Affects long-term statistics storage:**
|
||||
|
||||
```python
|
||||
# ❌ MEASUREMENT for prices (stores every change)
|
||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
||||
|
|
@ -240,6 +255,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
### Attribute Size
|
||||
|
||||
**Keep attributes minimal:**
|
||||
|
||||
```python
|
||||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
|
|
@ -317,6 +333,7 @@ _LOGGER.debug("Current memory usage: %.2f MB", memory_mb)
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Caching Strategy](caching-strategy.md) - Cache layers
|
||||
- [Architecture](architecture.md) - System design
|
||||
- [Debugging](debugging.md) - Profiling tools
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ This document explains the mathematical foundations and design decisions behind
|
|||
**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
|
||||
|
|
@ -23,6 +24,7 @@ Period detection uses **three independent filters** (all must pass):
|
|||
**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)
|
||||
|
|
@ -32,6 +34,7 @@ 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)
|
||||
|
|
@ -41,6 +44,7 @@ in_flex = price >= (daily_max - daily_max × flex)
|
|||
**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))
|
||||
|
|
@ -50,6 +54,7 @@ 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)
|
||||
|
|
@ -86,6 +91,7 @@ The integration maintains **two independent sets** of volatility thresholds:
|
|||
- 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)
|
||||
|
|
@ -107,21 +113,25 @@ period_low_threshold = PRICE_LEVEL_THRESHOLDS["volatility_low"] # Always 10%
|
|||
#### 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)
|
||||
|
|
@ -132,11 +142,13 @@ Max accepted = 15 × (1 - 0.05) = 14.25 ct/kWh
|
|||
### 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%)
|
||||
|
|
@ -149,6 +161,7 @@ daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
|
|||
**Approach:** Reduce Min_Distance proportionally as Flex increases.
|
||||
|
||||
**Formula:**
|
||||
|
||||
```python
|
||||
if flex > 0.20: # 20% threshold
|
||||
flex_excess = flex - 0.20
|
||||
|
|
@ -159,7 +172,7 @@ if flex > 0.20: # 20% threshold
|
|||
**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 |
|
||||
|
|
@ -167,6 +180,7 @@ if flex > 0.20: # 20% threshold
|
|||
| 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"
|
||||
|
|
@ -174,6 +188,7 @@ if flex > 0.20: # 20% threshold
|
|||
**Implementation:** See `level_filtering.py` → `check_interval_criteria()`
|
||||
|
||||
**Code Extract:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/level_filtering.py
|
||||
|
||||
|
|
@ -209,12 +224,14 @@ def check_interval_criteria(price, criteria):
|
|||
```
|
||||
|
||||
**Why Linear Scaling?**
|
||||
|
||||
- Simple and predictable
|
||||
- No abrupt behavior changes
|
||||
- Easy to reason about for users and developers
|
||||
- Alternative considered: Exponential scaling (rejected as too aggressive)
|
||||
|
||||
**Why 25% Minimum?**
|
||||
|
||||
- Below this, min_distance loses semantic meaning
|
||||
- Even on flat days, some quality filter needed
|
||||
- Prevents "every interval is a period" scenario
|
||||
|
|
@ -227,12 +244,14 @@ def check_interval_criteria(price, criteria):
|
|||
### Implementation Constants
|
||||
|
||||
**Defined in `coordinator/period_handlers/core.py`:**
|
||||
|
||||
```python
|
||||
MAX_SAFE_FLEX = 0.50 # 50% - hard cap: above this, period detection becomes unreliable
|
||||
MAX_OUTLIER_FLEX = 0.25 # 25% - cap for outlier filtering: above this, spike detection too permissive
|
||||
```
|
||||
|
||||
**Defined in `const.py`:**
|
||||
|
||||
```python
|
||||
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode (default enabled)
|
||||
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection)
|
||||
|
|
@ -255,16 +274,19 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Goal:** Find practical time windows for running appliances
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Appliances need time to complete cycles (dishwasher: 2-3h, EV charging: 4-8h)
|
||||
- Short periods are impractical (not worth automation overhead)
|
||||
- User wants genuinely cheap times, not just "slightly below average"
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- **60 min minimum** - Ensures period is long enough for meaningful use
|
||||
- **15% flex** - Stricter selection, focuses on truly cheap times
|
||||
- **Reasoning:** Better to find fewer, higher-quality periods than many mediocre ones
|
||||
|
||||
**User behavior:**
|
||||
|
||||
- Automations trigger actions (turn on devices)
|
||||
- Wrong automation = wasted energy/money
|
||||
- Preference: Conservative (miss some savings) over aggressive (false positives)
|
||||
|
|
@ -274,16 +296,19 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Goal:** Alert users to expensive periods for consumption reduction
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Brief price spikes still matter (even 15-30 min is worth avoiding)
|
||||
- Early warning more valuable than perfect accuracy
|
||||
- User can manually decide whether to react
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- **30 min minimum** - Catches shorter expensive spikes
|
||||
- **20% flex** - More permissive, earlier detection
|
||||
- **Reasoning:** Better to warn early (even if not peak) than miss expensive periods
|
||||
|
||||
**User behavior:**
|
||||
|
||||
- Notifications/alerts (informational)
|
||||
- Wrong alert = minor inconvenience, not cost
|
||||
- Preference: Sensitive (catch more) over specific (catch only extremes)
|
||||
|
|
@ -293,17 +318,20 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Peak Price Volatility:**
|
||||
|
||||
Price curves tend to have:
|
||||
|
||||
- **Sharp spikes** during peak hours (morning/evening)
|
||||
- **Shorter duration** at maximum (1-2 hours typical)
|
||||
- **Higher variance** in peak times than cheap times
|
||||
|
||||
**Example day:**
|
||||
|
||||
```
|
||||
Cheap period: 02:00-07:00 (5 hours at 10-12 ct) ← Gradual, stable
|
||||
Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
||||
```
|
||||
|
||||
**Implication:**
|
||||
|
||||
- Stricter flex on peak (15%) might miss real expensive periods (too brief)
|
||||
- Longer min_length (60 min) might exclude legitimate spikes
|
||||
- Solution: More flexible thresholds for peak detection
|
||||
|
|
@ -311,16 +339,19 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
|||
#### Design Alternatives Considered
|
||||
|
||||
**Option 1: Symmetric defaults (rejected)**
|
||||
|
||||
- Both 60 min, both 15% flex
|
||||
- Problem: Misses short but expensive spikes
|
||||
- User feedback: "Why didn't I get warned about the 30-min price spike?"
|
||||
|
||||
**Option 2: Same defaults, let users figure it out (rejected)**
|
||||
|
||||
- No guidance on best practices
|
||||
- Users would need to experiment to find good values
|
||||
- Most users stick with defaults, so defaults matter
|
||||
|
||||
**Option 3: Current approach (adopted)**
|
||||
|
||||
- **All values user-configurable** via config flow options
|
||||
- **Different installation defaults** for Best Price vs. Peak Price
|
||||
- Defaults reflect recommended practices for each use case
|
||||
|
|
@ -336,12 +367,14 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
|||
**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.
|
||||
|
|
@ -352,6 +385,7 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
|
|||
**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
|
||||
|
|
@ -363,23 +397,28 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
|
|||
#### 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)
|
||||
|
||||
---
|
||||
|
|
@ -397,6 +436,7 @@ These three mechanisms handle pathological price situations where standard filte
|
|||
**Problem:** When all prices are nearly identical (e.g. 28–32 ct, CV=5.4%), requiring 2 distinct "best price" windows is geometrically impossible. Even after exhausting all 11 relaxation phases, only 1 period exists because there is no second cheap cluster.
|
||||
|
||||
**Solution:** Before the baseline counting loop, compute per-day effective min_periods:
|
||||
|
||||
```python
|
||||
if day_cv <= 10%:
|
||||
day_effective_min[day] = 1 # Flat day: 1 period is enough
|
||||
|
|
@ -417,13 +457,14 @@ else:
|
|||
**Problem:** On solar surplus days (avg 2–5 ct/kWh), a percentage-based min_distance like 5% means only 0.1 ct absolute separation is required. The filter either accepts almost the entire day (if ref_price is 2 ct, 5% = 0.1 ct – nearly everything qualifies) or blocks everything (if the spread is within that 0.1 ct band).
|
||||
|
||||
**Solution:** Linear scaling toward zero as avg_price approaches zero:
|
||||
|
||||
```
|
||||
scale_factor = avg_price / LOW_PRICE_AVG_THRESHOLD
|
||||
adjusted_min_distance = original_min_distance × scale_factor
|
||||
```
|
||||
|
||||
| avg_price | scale | Effect on 5% min_distance |
|
||||
|---|---|---|
|
||||
| ------------------ | ----- | ------------------------- |
|
||||
| ≥ 10 ct (0.10 EUR) | 100% | 5% (full distance) |
|
||||
| 5 ct (0.05 EUR) | 50% | 2.5% |
|
||||
| 2 ct (0.02 EUR) | 20% | 1% |
|
||||
|
|
@ -435,11 +476,12 @@ adjusted_min_distance = original_min_distance × scale_factor
|
|||
|
||||
**Trigger:** Period mean price < `LOW_PRICE_QUALITY_BYPASS_THRESHOLD` (0.10 EUR)
|
||||
|
||||
**Problem:** A period at 0.5–4 ct has high *relative* variation (CV ≈ 70–80%), but the absolute differences are fractions of a cent. The quality gate (CV ≤ `PERIOD_MAX_CV`) with a relative metric would wrongly reject this as a "heterogeneous" period.
|
||||
**Problem:** A period at 0.5–4 ct has high _relative_ variation (CV ≈ 70–80%), but the absolute differences are fractions of a cent. The quality gate (CV ≤ `PERIOD_MAX_CV`) with a relative metric would wrongly reject this as a "heterogeneous" period.
|
||||
|
||||
**Distinguishes from flat normal days:** A flat day at 33–36 ct also has low absolute range, but mean is 34.5 ct (>> 0.10 EUR threshold). The bypass only applies when the mean itself is below the threshold – i.e. the day is genuinely cheap in absolute terms.
|
||||
|
||||
**Solution:** Short-circuit the quality gate check:
|
||||
|
||||
```python
|
||||
period_mean = sum(period_prices) / len(period_prices)
|
||||
if period_mean < LOW_PRICE_QUALITY_BYPASS_THRESHOLD:
|
||||
|
|
@ -470,6 +512,7 @@ Ensure **minimum periods per day** are found even when baseline filters are too
|
|||
### 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
|
||||
|
|
@ -493,6 +536,7 @@ for attempt in range(max_relaxation_attempts):
|
|||
```
|
||||
|
||||
**Constants:**
|
||||
|
||||
```python
|
||||
FLEX_WARNING_THRESHOLD_RELAXATION = 0.25 # 25% - INFO: suggest lowering to 15-20%
|
||||
FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: very high for relaxation mode
|
||||
|
|
@ -522,6 +566,7 @@ MAX_FLEX_HARD_LIMIT = 0.50 # 50% - absolute maximum (enforced in core.py)
|
|||
**Historical Context (Pre-November 2025):**
|
||||
|
||||
The algorithm previously used percentage-based increments that scaled with base flex:
|
||||
|
||||
```python
|
||||
increment = base_flex × (step_pct / 100) # REMOVED
|
||||
```
|
||||
|
|
@ -529,6 +574,7 @@ increment = base_flex × (step_pct / 100) # REMOVED
|
|||
This caused exponential escalation with high base flex values (e.g., 40% → 50% → 60% → 70% in just 6 steps), making behavior unpredictable. The fixed 3% increment solves this by providing consistent, controlled escalation regardless of starting point.
|
||||
|
||||
**Warning Messages:**
|
||||
|
||||
```python
|
||||
if base_flex >= FLEX_HIGH_THRESHOLD_RELAXATION: # 30%
|
||||
_LOGGER.warning(
|
||||
|
|
@ -547,12 +593,14 @@ elif base_flex >= FLEX_WARNING_THRESHOLD_RELAXATION: # 25%
|
|||
### 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)
|
||||
|
|
@ -567,6 +615,7 @@ Day 2025-11-19:
|
|||
### Key Files and Functions
|
||||
|
||||
**Period Calculation Entry Point:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(
|
||||
|
|
@ -577,6 +626,7 @@ def calculate_periods(
|
|||
```
|
||||
|
||||
**Flex + Distance Filtering:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/level_filtering.py
|
||||
def check_interval_criteria(
|
||||
|
|
@ -586,6 +636,7 @@ def check_interval_criteria(
|
|||
```
|
||||
|
||||
**Relaxation Orchestration:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/relaxation.py
|
||||
def calculate_periods_with_relaxation(...) -> tuple[dict, dict]
|
||||
|
|
@ -616,6 +667,7 @@ def relax_single_day(...) -> tuple[dict, dict]
|
|||
- Rejects asymmetric outliers (threshold: 1.5 std dev)
|
||||
- Preserves legitimate price shifts (morning/evening peaks)
|
||||
- Algorithm:
|
||||
|
||||
```python
|
||||
residual = abs(actual - predicted)
|
||||
symmetry_threshold = 1.5 × std_dev
|
||||
|
|
@ -638,6 +690,7 @@ def relax_single_day(...) -> tuple[dict, dict]
|
|||
- Catches patterns like: 18, 35, 19, 34, 18 (alternating spikes)
|
||||
|
||||
**Constants:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/outlier_filtering.py
|
||||
|
||||
|
|
@ -648,18 +701,21 @@ MIN_CONTEXT_SIZE = 3 # Minimum intervals for regression
|
|||
```
|
||||
|
||||
**Data Integrity:**
|
||||
|
||||
- Original prices stored in `_original_price` field
|
||||
- All statistics (daily min/max/avg) use original prices
|
||||
- Smoothing only affects period formation logic
|
||||
- Smart counting: Only counts smoothing that changed period outcome
|
||||
|
||||
**Performance:**
|
||||
|
||||
- Single pass through price data
|
||||
- O(n) complexity with small context window
|
||||
- No iterative refinement needed
|
||||
- Typical processing time: `<`1ms for 96 intervals
|
||||
|
||||
**Example Debug Output:**
|
||||
|
||||
```
|
||||
DEBUG: [2025-11-11T14:30:00+01:00] Outlier detected: 35.2 ct
|
||||
DEBUG: Context: 18.5, 19.1, 19.3, 19.8, 20.2 ct
|
||||
|
|
@ -699,6 +755,7 @@ DEBUG: Asymmetry ratio: 3.2 (>1.5 threshold) → confirmed outlier
|
|||
## Debugging Tips
|
||||
|
||||
**Enable DEBUG logging:**
|
||||
|
||||
```yaml
|
||||
# configuration.yaml
|
||||
logger:
|
||||
|
|
@ -708,6 +765,7 @@ logger:
|
|||
```
|
||||
|
||||
**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
|
||||
|
|
@ -720,17 +778,20 @@ logger:
|
|||
### ❌ 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
|
||||
|
|
@ -739,16 +800,19 @@ 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 # Use default 5%
|
||||
```
|
||||
|
|
@ -756,16 +820,19 @@ best_price_min_distance_from_avg: 5 # Use default 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
|
||||
|
|
@ -781,11 +848,13 @@ best_price_min_distance_from_avg: 5
|
|||
**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
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Filter statistics: 96 intervals checked
|
||||
DEBUG: Filtered by FLEX: 12/96 (12.5%) ← Low percentage = good variation
|
||||
|
|
@ -799,11 +868,13 @@ DEBUG: After build_periods: 3 raw periods found
|
|||
**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"
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Filter statistics: 96 intervals checked
|
||||
DEBUG: Filtered by FLEX: 45/96 (46.9%) ← High percentage = poor variation
|
||||
|
|
@ -825,6 +896,7 @@ Relaxation would exhaust all 11 phases trying to find a second period. All price
|
|||
`_compute_day_effective_min()` detects CV ≤ 10% and sets `day_effective_min = 1` for this day. The result is accepted after finding the single cheapest cluster.
|
||||
|
||||
**Expected Logs:**
|
||||
|
||||
```
|
||||
DEBUG: Day 2025-11-11: flat price profile (CV=5.4% ≤ 10.0%) → min_periods relaxed to 1
|
||||
INFO: Adaptive min_periods: 1 flat day(s) (CV ≤ 10%) need only 1 period instead of 2
|
||||
|
|
@ -832,6 +904,7 @@ INFO: Day 2025-11-11: Baseline satisfied (1 period, effective minimum is 1)
|
|||
```
|
||||
|
||||
**Sensor Attributes:**
|
||||
|
||||
```yaml
|
||||
min_periods_configured: 2 # User's setting
|
||||
flat_days_detected: 1 # Explains why only 1 period found
|
||||
|
|
@ -847,18 +920,20 @@ Peak price always runs full relaxation. On a flat day, the integration still nee
|
|||
**Configuration:** `min_periods_best: 2`, 5% min_distance
|
||||
|
||||
**Problems without fixes:**
|
||||
1. **min_distance conflict:** 5% of 2.1 ct = 0.105 ct minimum distance. Only prices ≤ 1.995 ct qualify. The daily minimum is 0.5 ct – well within range. But the *relative* threshold becomes meaninglessly tiny: the entire day could qualify.
|
||||
|
||||
1. **min_distance conflict:** 5% of 2.1 ct = 0.105 ct minimum distance. Only prices ≤ 1.995 ct qualify. The daily minimum is 0.5 ct – well within range. But the _relative_ threshold becomes meaninglessly tiny: the entire day could qualify.
|
||||
2. **CV quality gate:** Prices 0.5–4.2 ct show high relative variation (CV ≈ 70-80%), but the absolute differences are fractions of a cent. The quality gate would wrongly reject valid periods.
|
||||
|
||||
**Implemented behavior:**
|
||||
|
||||
*`LOW_PRICE_AVG_THRESHOLD = 0.10 EUR` (level_filtering.py):*
|
||||
_`LOW_PRICE_AVG_THRESHOLD = 0.10 EUR` (level_filtering.py):_
|
||||
When `avg_price < 0.10 EUR`, min_distance is scaled linearly to 0. At avg=2.1 ct (0.021 EUR), scale ≈ 21% → min_distance effectively 1%. Prevents the distance filter from blocking the entire day or accepting the entire day.
|
||||
|
||||
*`LOW_PRICE_QUALITY_BYPASS_THRESHOLD = 0.10 EUR` (relaxation.py):*
|
||||
_`LOW_PRICE_QUALITY_BYPASS_THRESHOLD = 0.10 EUR` (relaxation.py):_
|
||||
When period mean < 0.10 EUR, the CV quality gate is bypassed entirely. A period at 0.5–2 ct with CV=60% is practically homogeneous from a cost perspective.
|
||||
|
||||
**Expected Logs:**
|
||||
|
||||
```
|
||||
DEBUG: Low-price day (avg=0.021 EUR < 0.10 threshold): min_distance scaled 5% → 1.1%
|
||||
DEBUG: Period 02:00-05:00: mean=0.009 EUR < bypass threshold → quality gate bypassed
|
||||
|
|
@ -870,11 +945,13 @@ DEBUG: Period 02:00-05:00: mean=0.009 EUR < bypass threshold → quality gate
|
|||
**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)
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Outlier detected: 38.5 ct (threshold: 4.2 ct)
|
||||
DEBUG: Smoothed to: 20.1 ct (trend prediction)
|
||||
|
|
@ -889,6 +966,7 @@ DEBUG: After build_periods: 4 raw periods found
|
|||
**Initial State:** Baseline finds 1 period, target is 2
|
||||
|
||||
**Expected Flow:**
|
||||
|
||||
```
|
||||
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
|
||||
DEBUG: Day 2025-11-11: Baseline found 1 period (need 2)
|
||||
|
|
@ -904,6 +982,7 @@ INFO: Day 2025-11-11: Success after 1 relaxation phase (2 periods)
|
|||
**Initial State:** Strict filters, very flat day
|
||||
|
||||
**Expected Flow:**
|
||||
|
||||
```
|
||||
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
|
||||
DEBUG: Day 2025-11-11: Baseline found 0 periods (need 2)
|
||||
|
|
@ -954,7 +1033,7 @@ When debugging period calculation issues:
|
|||
**Diagnostic Sensor Attributes Summary:**
|
||||
|
||||
| Attribute | Type | When shown | Meaning |
|
||||
|---|---|---|---|
|
||||
| ------------------------ | ------ | ---------------- | ------------------------------------------- |
|
||||
| `min_periods_configured` | int | Always | User's configured target per day |
|
||||
| `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 |
|
||||
|
|
@ -999,6 +1078,7 @@ When debugging period calculation issues:
|
|||
**Concept:** Auto-adjust Flex based on daily price variation
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
```python
|
||||
# Pseudo-code for adaptive flex
|
||||
variation = (daily_max - daily_min) / daily_avg
|
||||
|
|
@ -1012,11 +1092,13 @@ else: # Normal day
|
|||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Eliminates need for relaxation on most days
|
||||
- Self-adjusting to market conditions
|
||||
- Better user experience (less configuration needed)
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Harder to predict behavior (less transparent)
|
||||
- May conflict with user's mental model
|
||||
- Needs extensive testing across different markets
|
||||
|
|
@ -1028,17 +1110,20 @@ else: # Normal day
|
|||
**Concept:** Learn optimal Flex/Distance from user feedback
|
||||
|
||||
**Approach:**
|
||||
|
||||
- Track which periods user actually uses (automation triggers)
|
||||
- Classify days by pattern (normal/flat/volatile/bimodal)
|
||||
- Apply pattern-specific defaults
|
||||
- Learn per-user preferences over time
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Personalized to user's actual behavior
|
||||
- Adapts to local market patterns
|
||||
- Could discover non-obvious patterns
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Requires user feedback mechanism (not implemented)
|
||||
- Privacy concerns (storing usage patterns)
|
||||
- Complexity for users to understand "why this period?"
|
||||
|
|
@ -1051,22 +1136,26 @@ else: # Normal day
|
|||
**Concept:** Balance multiple goals simultaneously
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Period count vs. quality (cheap vs. very cheap)
|
||||
- Period duration vs. price level (long mediocre vs. short excellent)
|
||||
- Temporal distribution (spread throughout day vs. clustered)
|
||||
- User's stated use case (EV charging vs. heat pump vs. dishwasher)
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
- Pareto optimization (find trade-off frontier)
|
||||
- User chooses point on frontier via preferences
|
||||
- Genetic algorithm or simulated annealing
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- More sophisticated period selection
|
||||
- Better match to user's actual needs
|
||||
- Could handle complex appliance requirements
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Much more complex to implement
|
||||
- Harder to explain to users
|
||||
- Computational cost (may need caching)
|
||||
|
|
@ -1081,14 +1170,17 @@ else: # Normal day
|
|||
**Current:** 3% cap may be too aggressive for very low base Flex
|
||||
|
||||
**Example:**
|
||||
|
||||
- Base flex 5% + 3% increment = 8% (60% increase!)
|
||||
- Base flex 15% + 3% increment = 18% (20% increase)
|
||||
|
||||
**Possible Solution:**
|
||||
|
||||
- Percentage-based increment: `increment = max(base_flex × 0.20, 0.03)`
|
||||
- This gives: 5% → 6% (20%), 15% → 18% (20%), 40% → 43% (7.5%)
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Very low base flex (`<`10%) unusual
|
||||
- Users with strict requirements likely disable relaxation
|
||||
- Simplicity preferred over edge case optimization
|
||||
|
|
@ -1098,6 +1190,7 @@ else: # Normal day
|
|||
**Current:** Linear scaling may be too aggressive/conservative
|
||||
|
||||
**Alternative:** Non-linear curve
|
||||
|
||||
```python
|
||||
# Example: Exponential scaling
|
||||
scale_factor = 0.25 + 0.75 × exp(-5 × (flex - 0.20))
|
||||
|
|
@ -1107,6 +1200,7 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
```
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Linear is easier to reason about
|
||||
- No evidence that non-linear is better
|
||||
- Would need extensive testing
|
||||
|
|
@ -1116,15 +1210,18 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
**Issue:** May find all periods in one part of day
|
||||
|
||||
**Example:**
|
||||
|
||||
- All 3 "best price" periods between 02:00-08:00
|
||||
- No periods in evening (when user might want to run appliances)
|
||||
|
||||
**Possible Solution:**
|
||||
|
||||
- Add "spread" parameter (prefer distributed periods)
|
||||
- Weight periods by time-of-day preferences
|
||||
- Consider user's typical usage patterns
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Adds complexity
|
||||
- Users can work around with multiple automations
|
||||
- Different users have different needs (no one-size-fits-all)
|
||||
|
|
@ -1136,6 +1233,7 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
**Design Principle:** Each interval is evaluated using its **own day's** reference prices (daily min/max/avg).
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In period_building.py build_periods():
|
||||
for price_data in all_prices:
|
||||
|
|
@ -1187,6 +1285,7 @@ Period crossing midnight: 23:45 Day 1 → 00:15 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)
|
||||
|
|
@ -1198,6 +1297,7 @@ This is **mathematically correct** - 21ct is genuinely expensive on a day where
|
|||
**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
|
||||
|
||||
|
|
@ -1206,10 +1306,12 @@ This explains why absolute prices jump at midnight despite minimal demand change
|
|||
**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
|
||||
|
|
@ -1240,6 +1342,7 @@ Low volatility (< 15%) means classification changes are less economically signif
|
|||
**Status:** Per-day evaluation is intentional design prioritizing mathematical correctness.
|
||||
|
||||
**See Also:**
|
||||
|
||||
- User documentation: `docs/user/docs/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)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- Must be a **class attribute** (not instance attribute)
|
||||
- Use `frozenset` for immutability and performance
|
||||
- Applied automatically by Home Assistant's Recorder component
|
||||
|
|
@ -40,6 +41,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `description`, `usage_tips`
|
||||
|
||||
**Reason:** Static, large text strings (100-500 chars each) that:
|
||||
|
||||
- Never change or change very rarely
|
||||
- Don't provide analytical value in history
|
||||
- Consume significant database space when recorded every state change
|
||||
|
|
@ -50,6 +52,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
### 2. Large Nested Structures
|
||||
|
||||
**Attributes:**
|
||||
|
||||
- `periods` (binary_sensor) - Array of all period summaries
|
||||
- `data` (chart_data_export) - Complete price data arrays
|
||||
- `trend_attributes` - Detailed trend analysis
|
||||
|
|
@ -58,6 +61,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
- `volatility_attributes` - Detailed volatility breakdown
|
||||
|
||||
**Reason:** Complex nested data structures that are:
|
||||
|
||||
- Serialized to JSON for storage (expensive)
|
||||
- Create large database rows (2-20 KB each)
|
||||
- Slow down history queries
|
||||
|
|
@ -66,6 +70,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Impact:** ~10-30 KB saved per state change for affected sensors
|
||||
|
||||
**Example - periods array:**
|
||||
|
||||
```json
|
||||
{
|
||||
"periods": [
|
||||
|
|
@ -76,7 +81,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
"price_mean": 18.5,
|
||||
"price_median": 18.3,
|
||||
"price_min": 17.2,
|
||||
"price_max": 19.8,
|
||||
"price_max": 19.8
|
||||
// ... 10+ more attributes × 10-20 periods
|
||||
}
|
||||
]
|
||||
|
|
@ -88,6 +93,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Change every update cycle (every 15 minutes or more frequently)
|
||||
- Don't provide long-term analytical value
|
||||
- Create state changes even when core values haven't changed
|
||||
|
|
@ -103,6 +109,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Configuration values that rarely change
|
||||
- Wastes space when recorded repeatedly
|
||||
- Can be derived from other attributes or from entity state
|
||||
|
|
@ -114,6 +121,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `timestamp`, `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- `timestamp` is the rounded-quarter reference time used at the moment of the state write — it's stale as soon as the next update fires and has no analytical value in history
|
||||
- `next_api_poll`, `next_midnight_turnover` etc. are only relevant at the moment of reading; they're superseded by the next update
|
||||
- Similar to `entity_picture` in HA core image entities
|
||||
|
|
@ -129,6 +137,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Detailed technical information not needed for historical analysis
|
||||
- Only useful for debugging during active development
|
||||
- Boolean `relaxation_active` is kept for high-level analysis
|
||||
|
|
@ -137,39 +146,45 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
|
||||
### 7. Redundant/Derived Data
|
||||
|
||||
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `period_count_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`, `period_count_remaining`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Can be calculated from other attributes
|
||||
- Redundant information
|
||||
- Doesn't add analytical value to history
|
||||
|
||||
**Impact:** ~100-200 bytes saved per state change
|
||||
|
||||
**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).
|
||||
**Example:** `price_spread = price_max - price_min` (both are recorded, so spread can be calculated). `period_count_remaining = period_count_total - period_position` (both components are recorded).
|
||||
|
||||
## Attributes That ARE Recorded
|
||||
|
||||
These attributes **remain in history** because they provide essential analytical value:
|
||||
|
||||
### Time-Series Core
|
||||
|
||||
- All price values - Core sensor states (the entity's `native_value` is always recorded separately)
|
||||
|
||||
### Diagnostics & Tracking
|
||||
|
||||
- `cache_age_minutes` - Numeric value for diagnostics tracking over time
|
||||
- `updates_today` - Tracking API usage patterns
|
||||
|
||||
### Data Completeness
|
||||
|
||||
- `interval_count`, `intervals_available` - Data completeness metrics
|
||||
- `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status
|
||||
|
||||
### Period Data
|
||||
|
||||
- `start`, `end`, `duration_minutes` - Core period timing
|
||||
- `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
|
||||
|
||||
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
|
||||
|
||||
## Expected Database Impact
|
||||
|
|
@ -177,6 +192,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Space Savings
|
||||
|
||||
**Per state change:**
|
||||
|
||||
- Before: ~3-8 KB average
|
||||
- After: ~0.5-1.5 KB average
|
||||
- **Reduction: 60-85%**
|
||||
|
|
@ -198,6 +214,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Real-World Impact
|
||||
|
||||
For a typical installation with:
|
||||
|
||||
- 80+ sensors
|
||||
- Updates every 15 minutes
|
||||
- ~10 sensors updating every minute
|
||||
|
|
@ -216,7 +233,7 @@ For a typical installation with:
|
|||
- Class: `TibberPricesBinarySensor`
|
||||
- 29 attributes excluded
|
||||
|
||||
## When to Update _unrecorded_attributes
|
||||
## When to Update \_unrecorded_attributes
|
||||
|
||||
### Add to Exclusion List When:
|
||||
|
||||
|
|
@ -267,6 +284,7 @@ After modifying `_unrecorded_attributes`:
|
|||
4. **Confirm excluded attributes** don't appear in new state writes
|
||||
|
||||
**SQL Query to check attribute presence:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
state_id,
|
||||
|
|
@ -301,7 +319,7 @@ This makes `state_class=TOTAL` on many sensors the primary cause of long-term da
|
|||
For sensors with `device_class=SensorDeviceClass.MONETARY`, only two `state_class` values are valid:
|
||||
|
||||
| `state_class` | Statistics written | Frontend effect |
|
||||
|---|---|---|
|
||||
| ------------- | ------------------------- | ------------------------------------------------- |
|
||||
| `TOTAL` | ✅ Yes — unbounded growth | Statistics line-chart on entity detail page |
|
||||
| `None` | ❌ No | States timeline only (History panel, "Show More") |
|
||||
| `MEASUREMENT` | ❌ Blocked by hassfest | — |
|
||||
|
|
@ -313,18 +331,20 @@ For sensors with `device_class=SensorDeviceClass.MONETARY`, only two `state_clas
|
|||
Only 3 of 26 MONETARY sensors keep `state_class=TOTAL` — those where long-term history is genuinely useful:
|
||||
|
||||
| Sensor | Reason |
|
||||
|---|---|
|
||||
| ----------------------------- | ------------------------------------ |
|
||||
| `current_interval_price` | Long-term price trend (weeks/months) |
|
||||
| `current_interval_price_base` | Required for Energy Dashboard |
|
||||
| `average_price_today` | Seasonal daily average tracking |
|
||||
|
||||
All other 23 MONETARY sensors use `state_class=None`:
|
||||
|
||||
- Forecast/future sensors (`next_avg_*h`)
|
||||
- Daily snapshots (`lowest/highest_price_today/tomorrow`)
|
||||
- Rolling windows (`trailing/leading_24h_*`)
|
||||
- Next/previous interval sensors
|
||||
|
||||
**Effect of `state_class=None`:**
|
||||
|
||||
- ✅ Short-term state history (States timeline, ~10 days) still works normally
|
||||
- ✅ Templates, automations, and attributes are unaffected
|
||||
- ❌ Statistics line-chart removed from entity detail page for these sensors
|
||||
|
|
@ -333,6 +353,7 @@ All other 23 MONETARY sensors use `state_class=None`:
|
|||
### Expected Impact
|
||||
|
||||
Going from 26 → 3 sensors writing to the statistics tables:
|
||||
|
||||
- **~88% reduction** in statistics table writes
|
||||
- Prevents the primary cause of long-term database bloat
|
||||
- Existing statistics data is retained (only new writes stop)
|
||||
|
|
@ -342,7 +363,7 @@ Going from 26 → 3 sensors writing to the statistics tables:
|
|||
These are two independent mechanisms targeting different tables:
|
||||
|
||||
| Mechanism | Table affected | Purged? | Controls |
|
||||
|---|---|---|---|
|
||||
| ------------------------ | ------------------------------------- | ----------- | ----------------------------------------------- |
|
||||
| `_unrecorded_attributes` | `state_attributes` | ✅ ~10 days | Which attributes are stored per state write |
|
||||
| `state_class=None` | `statistics`, `statistics_short_term` | ❌ Never | Whether long-term statistics are written at all |
|
||||
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ In CI/CD (`$CI` or `$GITHUB_ACTIONS`), AI is automatically disabled.
|
|||
**In DevContainer (automatic):**
|
||||
|
||||
git-cliff is automatically installed when the DevContainer is built:
|
||||
|
||||
- **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile)
|
||||
- **git-cliff**: Installed via cargo in `scripts/setup/setup`
|
||||
|
||||
|
|
@ -120,6 +121,7 @@ Simply rebuild the container (VS Code: "Dev Containers: Rebuild Container") and
|
|||
**Manual installation (outside DevContainer):**
|
||||
|
||||
**git-cliff** (template-based):
|
||||
|
||||
```bash
|
||||
# See: https://git-cliff.org/docs/installation
|
||||
|
||||
|
|
@ -191,7 +193,7 @@ All methods produce GitHub-flavored Markdown with emoji categories:
|
|||
## 🎯 When to Use Which
|
||||
|
||||
| Method | Use Case | Pros | Cons |
|
||||
|--------|----------|------|------|
|
||||
| --------------------- | --------------------- | ----------------------------- | ------------------------ |
|
||||
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
|
||||
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
|
||||
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
|
||||
|
|
@ -219,6 +221,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. Script bumps manifest.json → commits → creates tag locally
|
||||
2. You push commit + tag together
|
||||
3. Release workflow sees tag → generates notes → creates release
|
||||
|
|
@ -242,6 +245,7 @@ git push
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You push manifest.json change
|
||||
2. Auto-Tag workflow detects change → creates tag automatically
|
||||
3. Release workflow sees new tag → creates release
|
||||
|
|
@ -263,6 +267,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You create and push tag manually
|
||||
2. Release workflow creates release
|
||||
3. Auto-Tag workflow skips (tag already exists)
|
||||
|
|
@ -282,19 +287,24 @@ git push origin main v0.3.0
|
|||
## 🛡️ Safety Features
|
||||
|
||||
### 1. **Version Validation**
|
||||
|
||||
Both helper script and auto-tag workflow validate version format (X.Y.Z).
|
||||
|
||||
### 2. **No Duplicate Tags**
|
||||
|
||||
- Helper script checks if tag exists (local + remote)
|
||||
- Auto-tag workflow checks if tag exists before creating
|
||||
|
||||
### 3. **Atomic Operations**
|
||||
|
||||
Helper script creates commit + tag locally. You decide when to push.
|
||||
|
||||
### 4. **Version Bumps Filtered**
|
||||
|
||||
Release notes automatically exclude `chore(release): bump version` commits.
|
||||
|
||||
### 5. **Rollback Instructions**
|
||||
|
||||
Helper script shows how to undo if you change your mind.
|
||||
|
||||
---
|
||||
|
|
@ -330,6 +340,7 @@ git push -f origin main v0.3.0
|
|||
**Auto-tag didn't create tag:**
|
||||
|
||||
Check workflow runs in GitHub Actions. Common causes:
|
||||
|
||||
- Tag already exists remotely
|
||||
- Invalid version format in manifest.json
|
||||
- manifest.json not in the commit that was pushed
|
||||
|
|
@ -348,6 +359,7 @@ Check workflow runs in GitHub Actions. Common causes:
|
|||
## 💡 Tips
|
||||
|
||||
1. **Conventional Commits:** Use proper commit format for best results:
|
||||
|
||||
```
|
||||
feat(scope): Add new feature
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ The Tibber Prices integration includes a proactive repair notification system th
|
|||
The repairs system is implemented in `coordinator/repairs.py` via the `TibberPricesRepairManager` class, which is instantiated in the coordinator and integrated into the update cycle.
|
||||
|
||||
**Design Principles:**
|
||||
|
||||
- **Proactive**: Detect issues before they become critical
|
||||
- **User-friendly**: Clear explanations with actionable guidance
|
||||
- **Auto-clearing**: Repairs automatically disappear when conditions resolve
|
||||
|
|
@ -19,10 +20,12 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
**Issue ID:** `tomorrow_data_missing_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`)
|
||||
- Tomorrow's electricity price data is still not available
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Tomorrow's data becomes available
|
||||
- Automatically checks on every successful API update
|
||||
|
||||
|
|
@ -30,6 +33,7 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
Users cannot plan ahead for tomorrow's electricity usage optimization. Automations relying on tomorrow's prices will not work.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In coordinator update cycle
|
||||
has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"])
|
||||
|
|
@ -40,6 +44,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `warning_hour`: Hour after which warning appears (default: 18)
|
||||
|
||||
|
|
@ -48,10 +53,12 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
**Issue ID:** `rate_limit_exceeded_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Integration encounters 3 or more consecutive rate limit errors (HTTP 429)
|
||||
- Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD`
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Successful API call completes (no rate limit error)
|
||||
- Error counter resets to 0
|
||||
|
||||
|
|
@ -59,6 +66,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
|
|
@ -74,6 +82,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `error_count`: Number of consecutive rate limit errors
|
||||
|
||||
|
|
@ -82,10 +91,12 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
**Issue ID:** `home_not_found_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Home configured in this integration is no longer present in Tibber account
|
||||
- Detected during user data refresh (daily check)
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Home reappears in Tibber account (unlikely - manual cleanup expected)
|
||||
- Integration entry is removed (shutdown cleanup)
|
||||
|
||||
|
|
@ -93,6 +104,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
Integration cannot fetch data for a non-existent home. User must remove the config entry and re-add if needed.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# After user data update
|
||||
home_exists = self._data_fetcher._check_home_exists(home_id)
|
||||
|
|
@ -103,6 +115,7 @@ else:
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the missing home
|
||||
- `entry_id`: Config entry ID for reference
|
||||
|
||||
|
|
@ -153,6 +166,7 @@ Each repair type maintains internal state to avoid redundant operations:
|
|||
### Lifecycle Integration
|
||||
|
||||
**Coordinator Initialization:**
|
||||
|
||||
```python
|
||||
self._repair_manager = TibberPricesRepairManager(
|
||||
hass=hass,
|
||||
|
|
@ -162,6 +176,7 @@ self._repair_manager = TibberPricesRepairManager(
|
|||
```
|
||||
|
||||
**Update Cycle Integration:**
|
||||
|
||||
```python
|
||||
# Success path - check conditions
|
||||
if result and "priceInfo" in result:
|
||||
|
|
@ -178,6 +193,7 @@ if is_rate_limit:
|
|||
```
|
||||
|
||||
**Shutdown Cleanup:**
|
||||
|
||||
```python
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shut down coordinator and clean up."""
|
||||
|
|
@ -196,6 +212,7 @@ Repairs use Home Assistant's standard translation system. Translations are defin
|
|||
- `/translations/sv.json`
|
||||
|
||||
**Structure:**
|
||||
|
||||
```json
|
||||
{
|
||||
"issues": {
|
||||
|
|
@ -210,10 +227,12 @@ Repairs use Home Assistant's standard translation system. Translations are defin
|
|||
## Home Assistant Integration
|
||||
|
||||
Repairs appear in:
|
||||
|
||||
- **Settings → System → Repairs** (main repairs panel)
|
||||
- **Notifications** (bell icon in UI shows repair count)
|
||||
|
||||
Repair properties:
|
||||
|
||||
- **`is_fixable=False`**: No automated fix available (user action required)
|
||||
- **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical)
|
||||
- **`translation_key`**: References `issues.{key}` in translation files
|
||||
|
|
@ -228,6 +247,7 @@ Repair properties:
|
|||
4. When tomorrow data arrives (next API fetch), repair clears
|
||||
|
||||
**Manual trigger:**
|
||||
|
||||
```python
|
||||
# Temporarily set warning hour to current hour for testing
|
||||
TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
||||
|
|
@ -240,6 +260,7 @@ TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
|||
3. Successful API call clears the repair
|
||||
|
||||
**Manual test:**
|
||||
|
||||
- Reduce API polling interval to trigger rate limiting
|
||||
- Or temporarily return HTTP 429 in API client
|
||||
|
||||
|
|
@ -263,6 +284,7 @@ To add a new repair type:
|
|||
7. **Document** in this file
|
||||
|
||||
**Example template:**
|
||||
|
||||
```python
|
||||
async def check_new_condition(self, *, param: bool) -> None:
|
||||
"""Check new condition and create/clear repair."""
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ This document explains the timer/scheduler system in the Tibber Prices integrati
|
|||
The integration uses **three independent timer mechanisms** for different purposes:
|
||||
|
||||
| Timer | Type | Interval | Purpose | Trigger Method |
|
||||
|-------|------|----------|---------|----------------|
|
||||
| ------------ | ----------- | ------------------ | -------------------- | ------------------------------- |
|
||||
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` |
|
||||
| **Timer #2** | Custom | :00, :15, :30, :45 | Entity state refresh | `async_track_utc_time_change()` |
|
||||
| **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
|
||||
|
|
@ -27,6 +27,7 @@ The integration uses **three independent timer mechanisms** for different purpos
|
|||
**Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes`
|
||||
|
||||
**What it is:**
|
||||
|
||||
- HA provides this timer system automatically when you inherit from `DataUpdateCoordinator`
|
||||
- Triggers `_async_update_data()` method every 15 minutes
|
||||
- **Not** synchronized to clock boundaries (each installation has different start time)
|
||||
|
|
@ -53,16 +54,19 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
```
|
||||
|
||||
**Load Distribution:**
|
||||
|
||||
- Each HA installation starts Timer #1 at different times → natural distribution
|
||||
- Tomorrow data check adds 0-30s random delay → prevents "thundering herd" on Tibber API
|
||||
- Result: API load spread over ~30 minutes instead of all at once
|
||||
|
||||
**Midnight Coordination:**
|
||||
|
||||
- Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects)
|
||||
- If midnight turnover needed → performs it and returns early
|
||||
- Timer #2 will see turnover already done and skip gracefully
|
||||
|
||||
**Why we use HA's timer:**
|
||||
|
||||
- Automatic restart after HA restart
|
||||
- Built-in retry logic for temporary failures
|
||||
- Standard HA integration pattern
|
||||
|
|
@ -79,6 +83,7 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
**Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll**
|
||||
|
||||
**Problem it solves:**
|
||||
|
||||
- Timer #1 runs every 15 minutes but NOT synchronized to clock (:03, :18, :33, :48)
|
||||
- Current price changes at :00, :15, :30, :45 → entities would show stale data for up to 15 minutes
|
||||
- Example: 14:00 new price, but Timer #1 ran at 13:58 → next update at 14:13 → users see old price until 14:13
|
||||
|
|
@ -100,22 +105,26 @@ async def _handle_quarter_hour_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Smart Boundary Tolerance:**
|
||||
|
||||
- Uses `round_to_nearest_quarter_hour()` with ±2 second tolerance
|
||||
- HA may schedule timer at 14:59:58 → rounds to 15:00:00 (shows new interval)
|
||||
- HA restart at 14:59:30 → stays at 14:45:00 (shows current interval)
|
||||
- See [Architecture](./architecture.md#3-quarter-hour-precision) for details
|
||||
|
||||
**Absolute Time Scheduling:**
|
||||
|
||||
- `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...)
|
||||
- NOT relative delays ("in 15 minutes")
|
||||
- If triggered at 14:59:58 → next trigger is 15:15:00, NOT 15:00:00 (prevents double updates)
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- All sensors that depend on "current interval" (e.g., `current_interval_price`, `next_interval_price`)
|
||||
- Binary sensors that check "is now in period?" (e.g., `best_price_period_active`)
|
||||
- ~50-60 entities out of 120+ total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- HA's built-in coordinator doesn't support exact boundary timing
|
||||
- We need **absolute time** triggers, not periodic intervals
|
||||
- Allows fast entity updates without expensive data transformation
|
||||
|
|
@ -140,6 +149,7 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- `best_price_remaining_minutes` - Countdown timer
|
||||
- `peak_price_remaining_minutes` - Countdown timer
|
||||
- `best_price_progress` - Progress bar (0-100%)
|
||||
|
|
@ -147,11 +157,13 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
- ~10 entities total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- Users want smooth countdowns (not jumping 15 minutes at a time)
|
||||
- Progress bars need minute-by-minute updates
|
||||
- Very lightweight (no data processing, just state recalculation)
|
||||
|
||||
**Why NOT every second:**
|
||||
|
||||
- Minute precision sufficient for countdown UX
|
||||
- Reduces CPU load (60× fewer updates than seconds)
|
||||
- Home Assistant best practice (avoid sub-minute updates)
|
||||
|
|
@ -194,6 +206,7 @@ class ListenerManager:
|
|||
```
|
||||
|
||||
**Why this pattern:**
|
||||
|
||||
- Decouples timer logic from entity logic
|
||||
- One timer can notify many entities efficiently
|
||||
- Entities can unregister when removed (cleanup)
|
||||
|
|
@ -279,11 +292,13 @@ class ListenerManager:
|
|||
### Reason 1: Load Distribution on Tibber API
|
||||
|
||||
If all installations used synchronized timers:
|
||||
|
||||
- ❌ Everyone fetches at 13:00:00 → Tibber API overload
|
||||
- ❌ Everyone fetches at 14:00:00 → Tibber API overload
|
||||
- ❌ "Thundering herd" problem
|
||||
|
||||
With HA's unsynchronized timer:
|
||||
|
||||
- ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ...
|
||||
- ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ...
|
||||
- ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ...
|
||||
|
|
@ -316,6 +331,7 @@ def _should_update_price_data(self) -> str:
|
|||
**Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data.
|
||||
|
||||
**API fetch only when:**
|
||||
|
||||
- Tomorrow data missing/invalid (after 13:00)
|
||||
- Cache expired (midnight turnover)
|
||||
- Explicit user refresh
|
||||
|
|
@ -339,6 +355,7 @@ def _should_update_price_data(self) -> str:
|
|||
## Performance Characteristics
|
||||
|
||||
### Timer #1 (DataUpdateCoordinator)
|
||||
|
||||
- **Triggers:** Every 15 minutes (unsynchronized)
|
||||
- **Fast path:** ~2ms (cache check, return existing data)
|
||||
- **Slow path:** ~600ms (API fetch + transform + calculate)
|
||||
|
|
@ -346,12 +363,14 @@ def _should_update_price_data(self) -> str:
|
|||
- **API calls:** ~1-2 times/day (cached otherwise)
|
||||
|
||||
### Timer #2 (Quarter-Hour Refresh)
|
||||
|
||||
- **Triggers:** 96 times/day (exact boundaries)
|
||||
- **Processing:** ~5ms (notify 60 entities)
|
||||
- **No API calls:** Uses cached/transformed data
|
||||
- **No transformation:** Just entity state updates
|
||||
|
||||
### Timer #3 (Minute Refresh)
|
||||
|
||||
- **Triggers:** 1440 times/day (every minute)
|
||||
- **Processing:** ~1ms (notify 10 entities)
|
||||
- **No API calls:** No data processing at all
|
||||
|
|
@ -417,17 +436,20 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
## Summary
|
||||
|
||||
**Three independent timers:**
|
||||
|
||||
1. **Timer #1** (HA built-in, 15 min, unsynchronized) → Data fetching (when needed)
|
||||
2. **Timer #2** (Custom, :00/:15/:30/:45) → Entity state updates (always)
|
||||
3. **Timer #3** (Custom, every minute) → Countdown/progress (always)
|
||||
|
||||
**Key insights:**
|
||||
|
||||
- Timer #1 unsynchronized = good (load distribution on API)
|
||||
- Timer #2 synchronized = good (user sees correct data immediately)
|
||||
- Timer #3 synchronized = good (smooth countdown UX)
|
||||
- All three coordinate gracefully (atomic midnight checks, no conflicts)
|
||||
|
||||
**"Listener" terminology:**
|
||||
|
||||
- Timer = mechanism that triggers
|
||||
- Listener = callback that gets called
|
||||
- Observer pattern = entities register, coordinator notifies
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ query($homeId: ID!) {
|
|||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `homeId`: Tibber home identifier
|
||||
- `resolution`: Always `QUARTER_HOURLY`
|
||||
- `first`: 384 intervals (4 days of data)
|
||||
|
|
@ -85,10 +86,12 @@ query($homeId: ID!) {
|
|||
## Rate Limits
|
||||
|
||||
Tibber API rate limits (as of 2024):
|
||||
|
||||
- **5000 requests per hour** per token
|
||||
- **Burst limit:** 100 requests per minute
|
||||
|
||||
Integration stays well below these limits:
|
||||
|
||||
- Polls every 15 minutes = 96 requests/day
|
||||
- User data cached for 24h = 1 request/day
|
||||
- **Total:** ~100 requests/day per home
|
||||
|
|
@ -106,6 +109,7 @@ Integration stays well below these limits:
|
|||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
|
||||
- `startsAt`: ISO 8601 timestamp with timezone
|
||||
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
|
||||
|
|
@ -119,6 +123,7 @@ Integration stays well below these limits:
|
|||
```
|
||||
|
||||
Supported currencies:
|
||||
|
||||
- `EUR` (Euro) - displayed as ct/kWh
|
||||
- `NOK` (Norwegian Krone) - displayed as øre/kWh
|
||||
- `SEK` (Swedish Krona) - displayed as öre/kWh
|
||||
|
|
@ -128,42 +133,52 @@ Supported currencies:
|
|||
### Common Error Responses
|
||||
|
||||
**Invalid Token:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Rate Limit Exceeded:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Home Not Found:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Integration handles these with:
|
||||
|
||||
- Exponential backoff retry (3 attempts)
|
||||
- ConfigEntryAuthFailed for auth errors
|
||||
- ConfigEntryNotReady for temporary failures
|
||||
|
|
@ -171,6 +186,7 @@ Integration handles these with:
|
|||
## Data Transformation
|
||||
|
||||
Raw API data is enriched with:
|
||||
|
||||
- **Trailing 24h average** - Calculated from previous intervals
|
||||
- **Leading 24h average** - Calculated from future intervals
|
||||
- **Price difference %** - Deviation from average
|
||||
|
|
@ -181,6 +197,7 @@ See `utils/price.py` for enrichment logic.
|
|||
---
|
||||
|
||||
💡 **External Resources:**
|
||||
|
||||
- [Tibber API Documentation](https://developer.tibber.com/docs/overview)
|
||||
- [GraphQL Explorer](https://developer.tibber.com/explorer)
|
||||
- [Get API Token](https://developer.tibber.com/settings/access-token)
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ flowchart TB
|
|||
The integration uses **5 independent caching layers** for optimal performance:
|
||||
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
|-------|----------|----------|--------------|--------|
|
||||
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ |
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
|
|
@ -196,7 +196,7 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
### Core Components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|-----------|------|----------------|
|
||||
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
|
|
@ -210,7 +210,7 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
|
||||
|
||||
| Component | Files | Lines | Responsibility |
|
||||
|-----------|-------|-------|----------------|
|
||||
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- |
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
|
|
@ -219,6 +219,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
|
||||
**Calculator Package** (`sensor/calculators/`):
|
||||
|
||||
- `base.py` - Abstract BaseCalculator with coordinator access
|
||||
- `interval.py` - Single interval calculations (current/next/previous)
|
||||
- `rolling_hour.py` - 5-interval rolling windows
|
||||
|
|
@ -230,6 +231,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
- `metadata.py` - Home/metering metadata
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 58% reduction in core.py (2,170 → 909 lines)
|
||||
- Clear separation: Calculators (logic) vs Attributes (presentation)
|
||||
- Independent testability for each calculator
|
||||
|
|
@ -238,7 +240,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
### Helper Utilities
|
||||
|
||||
| Utility | File | Purpose |
|
||||
|---------|------|---------|
|
||||
| ----------------- | ------------------ | ------------------------------------------------- |
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
|
|
@ -296,26 +298,31 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
Sensors organized by **calculation method** (refactored Nov 2025):
|
||||
|
||||
**Unified Handler Methods** (`sensor/core.py`):
|
||||
|
||||
- `_get_interval_value(offset, type)` - current/next/previous intervals
|
||||
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
|
||||
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
|
||||
- `_get_24h_window_value(stat_func)` - trailing/leading statistics
|
||||
|
||||
**Routing** (`sensor/value_getters.py`):
|
||||
|
||||
- Single source of truth mapping 80+ entity keys to calculator methods
|
||||
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
|
||||
|
||||
**Calculators** (`sensor/calculators/`):
|
||||
|
||||
- Each calculator inherits from `BaseCalculator` with coordinator access
|
||||
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
|
||||
- Complex logic isolated (e.g., `TrendCalculator` has internal caching)
|
||||
|
||||
**Attributes** (`sensor/attributes/`):
|
||||
|
||||
- Separate from business logic, handles state presentation
|
||||
- Builds extra_state_attributes dicts for entity classes
|
||||
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Minimal code duplication across 80+ sensors
|
||||
- Clear separation of concerns (calculation vs presentation)
|
||||
- Easy to extend: Add sensor → choose pattern → add to routing
|
||||
|
|
@ -334,7 +341,7 @@ Sensors organized by **calculation method** (refactored Nov 2025):
|
|||
### CPU Optimization
|
||||
|
||||
| Optimization | Location | Savings |
|
||||
|--------------|----------|---------|
|
||||
| ------------------- | ------------------------ | ---------------------------- |
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total)
|
||||
- **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
|
||||
- **Timestamps**: Last update times for validation
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
|
||||
- **User data**: 24 hours (refreshed daily)
|
||||
- **Survives**: HA restarts via persistent Storage
|
||||
|
|
@ -36,6 +38,7 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Midnight turnover** (Timer #2 in coordinator):
|
||||
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
|
|
@ -45,6 +48,7 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
```
|
||||
|
||||
2. **Cache validation on load**:
|
||||
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
|
|
@ -71,18 +75,22 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
|
||||
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Forever** (until HA restart)
|
||||
- No invalidation during runtime
|
||||
|
||||
**When populated:**
|
||||
|
||||
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
|
||||
- Lazy loading: If translation missing, attempts file load once
|
||||
|
||||
**Access pattern:**
|
||||
|
||||
```python
|
||||
# Non-blocking synchronous access from cached data
|
||||
description = get_translation("binary_sensor.best_price_period.description", "en")
|
||||
|
|
@ -101,6 +109,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**What is cached:**
|
||||
|
||||
### DataTransformer Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"thresholds": {"low": 15, "high": 35},
|
||||
|
|
@ -110,6 +119,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
### PeriodCalculator Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||
|
|
@ -118,10 +128,12 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until `invalidate_config_cache()` is called
|
||||
- Built once on first use per coordinator update cycle
|
||||
|
||||
**Invalidation trigger:**
|
||||
|
||||
- **Options change** (user reconfigures integration):
|
||||
```python
|
||||
# coordinator/core.py
|
||||
|
|
@ -132,6 +144,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Before:** ~30 dict lookups + type conversions per update = ~50μs
|
||||
- **After:** 1 cache check = ~1μs
|
||||
- **Savings:** ~98% (50μs → 1μs per update)
|
||||
|
|
@ -147,6 +160,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"best_price": {
|
||||
|
|
@ -161,6 +175,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Cache key:** Hash of relevant inputs
|
||||
|
||||
```python
|
||||
hash_data = (
|
||||
today_signature, # (startsAt, rating_level) for each interval
|
||||
|
|
@ -172,6 +187,7 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until price data changes (today's intervals modified)
|
||||
- Until config changes (flex, thresholds, filters)
|
||||
- Recalculated at midnight (new today data)
|
||||
|
|
@ -179,6 +195,7 @@ hash_data = (
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Config change** (explicit):
|
||||
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
|
|
@ -193,10 +210,12 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Cache hit rate:**
|
||||
|
||||
- **High:** During normal operation (coordinator updates every 15min, price data unchanged)
|
||||
- **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
|
||||
- **Cache hit:** `<`1ms (hash comparison + dict lookup)
|
||||
- **Savings:** ~70% of calculation time (most updates hit cache)
|
||||
|
|
@ -212,6 +231,7 @@ hash_data = (
|
|||
**Status:** ✅ **Clean separation** - enrichment only, no redundancy
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"timestamp": ...,
|
||||
|
|
@ -224,6 +244,7 @@ hash_data = (
|
|||
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
|
||||
|
||||
**Current behavior:**
|
||||
|
||||
- Caches **only enriched price data** (price + statistics)
|
||||
- **Does NOT cache periods** (handled by Period Calculation Cache)
|
||||
- Invalidated when:
|
||||
|
|
@ -232,6 +253,7 @@ hash_data = (
|
|||
- New update cycle begins
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- DataTransformer: Handles price enrichment only
|
||||
- PeriodCalculator: Handles period calculation only (with hash-based cache)
|
||||
- Coordinator: Assembles final data on-demand from both caches
|
||||
|
|
@ -243,6 +265,7 @@ hash_data = (
|
|||
## Cache Invalidation Flow
|
||||
|
||||
### User Changes Options (Config Flow)
|
||||
|
||||
```
|
||||
User saves options
|
||||
↓
|
||||
|
|
@ -267,6 +290,7 @@ Fresh data fetch with new config
|
|||
```
|
||||
|
||||
### Midnight Turnover (Day Transition)
|
||||
|
||||
```
|
||||
Timer #2 fires at 00:00
|
||||
↓
|
||||
|
|
@ -286,6 +310,7 @@ Fresh API fetch for new day
|
|||
```
|
||||
|
||||
### Tomorrow Data Arrives (~13:00)
|
||||
|
||||
```
|
||||
Coordinator update cycle
|
||||
↓
|
||||
|
|
@ -327,12 +352,14 @@ API Data Cache (price_data, user_data)
|
|||
```
|
||||
|
||||
**No cache invalidation cascades:**
|
||||
|
||||
- Config cache invalidation is **explicit** (on options update)
|
||||
- Period cache invalidation is **automatic** (via hash mismatch)
|
||||
- Transformation cache invalidation is **automatic** (on midnight/config change)
|
||||
- Translation cache is **never invalidated** (read-only after load)
|
||||
|
||||
**Thread safety:**
|
||||
|
||||
- All caches are accessed from `MainThread` only (Home Assistant event loop)
|
||||
- No locking needed (single-threaded execution model)
|
||||
|
||||
|
|
@ -341,6 +368,7 @@ API Data Cache (price_data, user_data)
|
|||
## Performance Characteristics
|
||||
|
||||
### Typical Operation (No Changes)
|
||||
|
||||
```
|
||||
Coordinator Update (every 15 min)
|
||||
├─> API fetch: SKIP (cache valid)
|
||||
|
|
@ -353,6 +381,7 @@ Total: ~16ms (down from ~600ms without caching)
|
|||
```
|
||||
|
||||
### After Midnight Turnover
|
||||
|
||||
```
|
||||
Coordinator Update (00:00)
|
||||
├─> API fetch: ~500ms (cache cleared, fetch new day)
|
||||
|
|
@ -365,6 +394,7 @@ Total: ~755ms (expected once per day)
|
|||
```
|
||||
|
||||
### After Config Change
|
||||
|
||||
```
|
||||
Options Update
|
||||
├─> Cache invalidation: `<`1ms
|
||||
|
|
@ -382,7 +412,7 @@ Options Update
|
|||
## Summary Table
|
||||
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
|------------|----------|------|--------------|---------|
|
||||
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- |
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
|
|
@ -392,12 +422,14 @@ Options Update
|
|||
**Total memory overhead:** ~116KB per coordinator instance (main + subentries)
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 97% reduction in API calls (from every 15min to once per day)
|
||||
- 70% reduction in period calculation time (cache hits during normal operation)
|
||||
- 98% reduction in config access time (30+ lookups → 1 cache check)
|
||||
- Zero file I/O during runtime (translations cached at startup)
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- Memory usage: ~116KB per home (negligible for modern systems)
|
||||
- Code complexity: 5 cache invalidation points (well-tested, documented)
|
||||
- Debugging: Must understand cache lifetime when investigating stale data issues
|
||||
|
|
@ -407,7 +439,9 @@ Options Update
|
|||
## Debugging Cache Issues
|
||||
|
||||
### Symptom: Stale data after config change
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Is `_handle_options_update()` called? (should see "Options updated" log)
|
||||
2. Are `invalidate_config_cache()` methods executed?
|
||||
3. Does `async_request_refresh()` trigger?
|
||||
|
|
@ -415,7 +449,9 @@ Options Update
|
|||
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
|
||||
|
||||
### Symptom: Period calculation not updating
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Verify hash changes when data changes: `_compute_periods_hash()`
|
||||
2. Check `_last_periods_hash` vs `current_hash`
|
||||
3. Look for "Using cached period calculation" vs "Calculating periods" logs
|
||||
|
|
@ -423,7 +459,9 @@ Options Update
|
|||
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
|
||||
|
||||
### Symptom: Yesterday's prices shown as today
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `is_cache_valid()` logic in `coordinator/cache.py`
|
||||
2. Midnight turnover execution (Timer #2)
|
||||
3. Cache clear confirmation in logs
|
||||
|
|
@ -431,7 +469,9 @@ Options Update
|
|||
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
|
||||
|
||||
### Symptom: Missing translations
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `async_load_translations()` called at startup?
|
||||
2. Translation files exist in `/translations/` and `/custom_translations/`?
|
||||
3. Cache population: `_TRANSLATIONS_CACHE` keys
|
||||
|
|
|
|||
|
|
@ -41,12 +41,14 @@ class TimeService:
|
|||
```
|
||||
|
||||
**When prefix is required:**
|
||||
|
||||
- Public classes used across multiple modules
|
||||
- All exception classes
|
||||
- All coordinator and entity classes
|
||||
- Data classes (dataclasses, NamedTuples) used as public APIs
|
||||
|
||||
**When prefix can be omitted:**
|
||||
|
||||
- Private helper classes within a single module (prefix with `_` underscore)
|
||||
- Type aliases and callbacks (e.g., `TimeServiceCallback`)
|
||||
- Small internal NamedTuples for function returns
|
||||
|
|
@ -71,6 +73,7 @@ class DataFetcher: # Should be TibberPricesDataFetcher
|
|||
**Current Technical Debt:**
|
||||
|
||||
Many existing classes lack the `TibberPrices` prefix. Before refactoring:
|
||||
|
||||
1. Document the plan in `/planning/class-naming-refactoring.md`
|
||||
2. Use `multi_replace_string_in_file` for bulk renames
|
||||
3. Test thoroughly after each module
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ git checkout -b fix/issue-123-description
|
|||
```
|
||||
|
||||
**Branch naming:**
|
||||
|
||||
- `feature/` - New features
|
||||
- `fix/` - Bug fixes
|
||||
- `docs/` - Documentation only
|
||||
|
|
@ -45,6 +46,7 @@ git checkout -b fix/issue-123-description
|
|||
Edit code, following [Coding Guidelines](coding-guidelines.md).
|
||||
|
||||
**Run checks frequently:**
|
||||
|
||||
```bash
|
||||
./scripts/type-check # Pyright type checking
|
||||
./scripts/lint # Ruff linting (auto-fix)
|
||||
|
|
@ -78,6 +80,7 @@ async def test_your_feature(hass, coordinator):
|
|||
```
|
||||
|
||||
Run your test:
|
||||
|
||||
```bash
|
||||
./scripts/test tests/test_your_feature.py -v
|
||||
```
|
||||
|
|
@ -97,6 +100,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
```
|
||||
|
||||
**Commit types:**
|
||||
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation
|
||||
|
|
@ -105,6 +109,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
- `chore:` - Maintenance
|
||||
|
||||
**Add scope when relevant:**
|
||||
|
||||
- `feat(sensors):` - Sensor platform
|
||||
- `fix(coordinator):` - Data coordinator
|
||||
- `docs(user):` - User documentation
|
||||
|
|
@ -124,32 +129,40 @@ Then open Pull Request on GitHub.
|
|||
Title: Short, descriptive (50 chars max)
|
||||
|
||||
Description should include:
|
||||
|
||||
```markdown
|
||||
## What
|
||||
|
||||
Brief description of changes
|
||||
|
||||
## Why
|
||||
|
||||
Problem being solved or feature rationale
|
||||
|
||||
## How
|
||||
|
||||
Implementation approach
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Manual testing in Home Assistant
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] Type checking passes
|
||||
- [ ] Linting passes
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
(If any - describe migration path)
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
### PR Checklist
|
||||
|
||||
Before submitting:
|
||||
|
||||
- [ ] Code follows [Coding Guidelines](coding-guidelines.md)
|
||||
- [ ] All tests pass (`./scripts/test`)
|
||||
- [ ] Type checking passes (`./scripts/type-check`)
|
||||
|
|
@ -170,6 +183,7 @@ Before submitting:
|
|||
### What Reviewers Look For
|
||||
|
||||
✅ **Good:**
|
||||
|
||||
- Clear, self-explanatory code
|
||||
- Appropriate comments for complex logic
|
||||
- Tests covering edge cases
|
||||
|
|
@ -177,6 +191,7 @@ Before submitting:
|
|||
- Follows existing patterns
|
||||
|
||||
❌ **Avoid:**
|
||||
|
||||
- Large PRs (>500 lines) - split into smaller ones
|
||||
- Mixing unrelated changes
|
||||
- Missing tests for new features
|
||||
|
|
@ -193,6 +208,7 @@ Before submitting:
|
|||
## Finding Issues to Work On
|
||||
|
||||
Good first issues are labeled:
|
||||
|
||||
- `good first issue` - Beginner-friendly
|
||||
- `help wanted` - Maintainers welcome contributions
|
||||
- `documentation` - Docs improvements
|
||||
|
|
@ -210,6 +226,7 @@ Be respectful, constructive, and patient. We're all volunteers! 🙏
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Setup Guide](setup.md) - DevContainer setup
|
||||
- [Coding Guidelines](coding-guidelines.md) - Style guide
|
||||
- [Testing](testing.md) - Writing tests
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ comments: false
|
|||
## 🎯 Why Are These Tests Critical?
|
||||
|
||||
Home Assistant integrations run **continuously** in the background. Resource leaks lead to:
|
||||
|
||||
- **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable
|
||||
- **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases
|
||||
- **Timer Leaks**: Timers continue running after unload → unnecessary background tasks
|
||||
|
|
@ -26,6 +27,7 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.1 Listener Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`)
|
||||
- Minute-update listeners are correctly removed (`async_add_minute_update_listener()`)
|
||||
- Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`)
|
||||
|
|
@ -33,11 +35,13 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
- Binary sensor cleanup removes ALL registered listeners
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Each registered listener holds references to Entity + Coordinator
|
||||
- Without cleanup: Entities are not freed by GC → Memory Leak
|
||||
- With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `async_add_time_sensitive_listener()`, `async_add_minute_update_listener()`
|
||||
- `coordinator/core.py` → `register_lifecycle_callback()`
|
||||
- `sensor/core.py` → `async_will_remove_from_hass()`
|
||||
|
|
@ -46,32 +50,38 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.2 Timer Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is cancelled and reference cleared
|
||||
- Minute timer is cancelled and reference cleared
|
||||
- Both timers are cancelled together
|
||||
- Cleanup works even when timers are `None`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Uncancelled timers continue running after integration unload
|
||||
- HA's `async_track_utc_time_change()` creates persistent callbacks
|
||||
- Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `cancel_timers()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
#### 1.3 Config Entry Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Options update listener is registered via `async_on_unload()`
|
||||
- Cleanup function is correctly passed to `async_on_unload()`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- `entry.add_update_listener()` registers permanent callback
|
||||
- Without `async_on_unload()`: Listener remains active after reload → duplicate updates
|
||||
- Pattern: `entry.async_on_unload(entry.add_update_listener(handler))`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/core.py` → `__init__()` (listener registration)
|
||||
- `__init__.py` → `async_unload_entry()`
|
||||
|
||||
|
|
@ -82,16 +92,19 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 2.1 Config Cache Invalidation
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- DataTransformer config cache is invalidated on options change
|
||||
- PeriodCalculator config + period cache is invalidated
|
||||
- Trend calculator cache is cleared on coordinator update
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Stale config → Sensors use old user settings
|
||||
- Stale period cache → Incorrect best/peak price periods
|
||||
- Stale trend cache → Outdated trend analysis
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/data_transformation.py` → `invalidate_config_cache()`
|
||||
- `coordinator/periods.py` → `invalidate_config_cache()`
|
||||
- `sensor/calculators/trend.py` → `clear_trend_cache()`
|
||||
|
|
@ -103,15 +116,18 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 3.1 Persistent Storage Removal
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Storage file is deleted on config entry removal
|
||||
- Cache is saved on shutdown (no data loss)
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Without storage removal: Old files remain after uninstallation
|
||||
- Without cache save on shutdown: Data loss on HA restart
|
||||
- Storage path: `.storage/tibber_prices.{entry_id}`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `__init__.py` → `async_remove_entry()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
|
|
@ -120,12 +136,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_timer_scheduling.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is registered with correct parameters
|
||||
- Minute timer is registered with correct parameters
|
||||
- Timers can be re-scheduled (override old timer)
|
||||
- Midnight turnover detection works correctly
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer parameters → Entities update at wrong times
|
||||
- Without timer override on re-schedule → Multiple parallel timers → Performance problem
|
||||
|
||||
|
|
@ -134,12 +152,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_sensor_timer_assignment.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys
|
||||
- All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys
|
||||
- Both lists are disjoint (no overlap)
|
||||
- Sensor and binary sensor platforms are checked
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer assignment → Sensors update at wrong times
|
||||
- Overlap → Duplicate updates → Performance problem
|
||||
|
||||
|
|
@ -150,10 +170,12 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 6. Async Task Management
|
||||
|
||||
**Current Status:** Fire-and-forget pattern for short tasks
|
||||
|
||||
- `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds)
|
||||
- `coordinator/core.py` → Cache storage (short-lived, max 100ms)
|
||||
|
||||
**Why no tests needed:**
|
||||
|
||||
- No long-running tasks (all < 2 seconds)
|
||||
- HA's event loop handles short tasks automatically
|
||||
- Task exceptions are already logged
|
||||
|
|
@ -163,6 +185,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 7. API Session Cleanup
|
||||
|
||||
**Current Status:** ✅ Correctly implemented
|
||||
|
||||
- `async_get_clientsession(hass)` is used (shared session)
|
||||
- No new sessions are created
|
||||
- HA manages session lifecycle automatically
|
||||
|
|
@ -172,6 +195,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 8. Translation Cache Memory
|
||||
|
||||
**Current Status:** ✅ Bounded cache
|
||||
|
||||
- Max ~5-10 languages × 5KB = 50KB total
|
||||
- Module-level cache without re-loading
|
||||
- Practically no memory issue
|
||||
|
|
@ -181,11 +205,13 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 9. Coordinator Data Structure Integrity
|
||||
|
||||
**Current Status:** Manually tested via `./scripts/develop`
|
||||
|
||||
- Midnight turnover works correctly (observed over several days)
|
||||
- Missing keys are handled via `.get()` with defaults
|
||||
- 80+ sensors access `coordinator.data` without errors
|
||||
|
||||
**Structure:**
|
||||
|
||||
```python
|
||||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
|
|
@ -197,6 +223,7 @@ coordinator.data = {
|
|||
### 10. Service Response Memory
|
||||
|
||||
**Current Status:** HA's response lifecycle
|
||||
|
||||
- HA automatically frees service responses after return
|
||||
- ApexCharts ~20KB response is one-time per call
|
||||
- No response accumulation in integration code
|
||||
|
|
@ -208,7 +235,7 @@ coordinator.data = {
|
|||
### ✅ Implemented Tests (41 total)
|
||||
|
||||
| Category | Status | Tests | File | Coverage |
|
||||
|----------|--------|-------|------|----------|
|
||||
| ----------------------- | ------ | ------ | --------------------------------- | ------------------- |
|
||||
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
|
||||
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
|
||||
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
|
|
@ -222,7 +249,7 @@ coordinator.data = {
|
|||
### 📋 Analyzed but Not Implemented (Nice-to-Have)
|
||||
|
||||
| Category | Status | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| ------------------------ | ------ | ---------------------------------------------------- |
|
||||
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
|
||||
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
|
||||
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
|
||||
|
|
@ -230,6 +257,7 @@ coordinator.data = {
|
|||
| Service Response Memory | 📋 | HA automatically frees service responses |
|
||||
|
||||
**Legend:**
|
||||
|
||||
- ✅ = Fully tested or pattern verified correct
|
||||
- 📋 = Analyzed, low priority for testing (no known issues)
|
||||
|
||||
|
|
@ -238,6 +266,7 @@ coordinator.data = {
|
|||
### ✅ All Critical Patterns Tested
|
||||
|
||||
All essential memory leak prevention patterns are covered by 41 tests:
|
||||
|
||||
- ✅ Listeners are correctly removed (no callback leaks)
|
||||
- ✅ Timers are cancelled (no background task leaks)
|
||||
- ✅ Config entry cleanup works (no dangling listeners)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ Restart Home Assistant to apply.
|
|||
### Key Log Messages
|
||||
|
||||
**Coordinator Updates:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator] Successfully fetched price data
|
||||
[custom_components.tibber_prices.coordinator] Cache valid, using cached data
|
||||
|
|
@ -27,6 +28,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**Period Calculation:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator.periods] Calculating BEST PRICE periods: flex=15.0%
|
||||
[custom_components.tibber_prices.coordinator.periods] Day 2024-12-06: Found 2 periods
|
||||
|
|
@ -34,6 +36,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**API Errors:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.api] API request failed: Unauthorized
|
||||
[custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s
|
||||
|
|
@ -67,6 +70,7 @@ Restart Home Assistant to apply.
|
|||
### Set Breakpoints
|
||||
|
||||
**Coordinator update:**
|
||||
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _async_update_data(self) -> dict:
|
||||
|
|
@ -75,6 +79,7 @@ async def _async_update_data(self) -> dict:
|
|||
```
|
||||
|
||||
**Period calculation:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(...) -> list[dict]:
|
||||
|
|
@ -91,6 +96,7 @@ def calculate_periods(...) -> list[dict]:
|
|||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `-v` - Verbose output
|
||||
- `-s` - Show print statements
|
||||
- `-k pattern` - Run tests matching pattern
|
||||
|
|
@ -102,6 +108,7 @@ Set breakpoint in test file, use "Debug Test" CodeLens.
|
|||
### Useful Test Patterns
|
||||
|
||||
**Print coordinator data:**
|
||||
|
||||
```python
|
||||
def test_something(coordinator):
|
||||
print(f"Coordinator data: {coordinator.data}")
|
||||
|
|
@ -109,6 +116,7 @@ def test_something(coordinator):
|
|||
```
|
||||
|
||||
**Inspect period attributes:**
|
||||
|
||||
```python
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
|
|
@ -122,11 +130,13 @@ def test_periods(hass, coordinator):
|
|||
### Integration Not Loading
|
||||
|
||||
**Check:**
|
||||
|
||||
```bash
|
||||
grep "tibber_prices" config/home-assistant.log
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
|
||||
- Syntax error in Python code → Check logs for traceback
|
||||
- Missing dependency → Run `uv sync`
|
||||
- Wrong file permissions → `chmod +x scripts/*`
|
||||
|
|
@ -134,12 +144,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
### Sensors Not Updating
|
||||
|
||||
**Check coordinator state:**
|
||||
|
||||
```python
|
||||
# In Developer Tools > Template
|
||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
||||
```
|
||||
|
||||
**Debug in code:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
|
|
@ -149,6 +161,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
### Period Calculation Wrong
|
||||
|
||||
**Enable detailed period logs:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
|
|
@ -156,6 +169,7 @@ _LOGGER.debug("Candidate intervals: %s",
|
|||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
||||
```
|
||||
[period_building] Flex filter blocked: 45 intervals
|
||||
[period_building] Min distance blocked: 12 intervals
|
||||
|
|
@ -200,6 +214,7 @@ python -m pstats profile.stats
|
|||
### Remote Debugging with debugpy
|
||||
|
||||
Add to coordinator code:
|
||||
|
||||
```python
|
||||
import debugpy
|
||||
debugpy.listen(5678)
|
||||
|
|
@ -212,11 +227,13 @@ Connect from VS Code with remote attach configuration.
|
|||
### IPython REPL
|
||||
|
||||
Install in container:
|
||||
|
||||
```bash
|
||||
uv pip install ipython
|
||||
```
|
||||
|
||||
Add breakpoint:
|
||||
|
||||
```python
|
||||
from IPython import embed
|
||||
embed() # Drops into interactive shell
|
||||
|
|
@ -225,6 +242,7 @@ embed() # Drops into interactive shell
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Testing Guide](testing.md) - Writing and running tests
|
||||
- [Setup Guide](setup.md) - Development environment
|
||||
- [Architecture](architecture.md) - Code structure
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ Documentation is organized in two Docusaurus sites:
|
|||
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
|
||||
|
||||
**Best practices:**
|
||||
|
||||
- Use clear examples and code snippets
|
||||
- Keep docs up-to-date with code changes
|
||||
- Add new pages to appropriate `sidebars.ts` for navigation
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Guidelines for maintaining and improving integration performance.
|
|||
## Performance Goals
|
||||
|
||||
Target metrics:
|
||||
|
||||
- **Coordinator update**: <500ms (typical: 200-300ms)
|
||||
- **Sensor update**: <10ms per sensor
|
||||
- **Period calculation**: <100ms (typical: 20-50ms)
|
||||
|
|
@ -64,6 +65,7 @@ python -m aioprof homeassistant -c config
|
|||
### Caching
|
||||
|
||||
**1. Persistent Cache** (API data):
|
||||
|
||||
```python
|
||||
# Already implemented in coordinator/cache.py
|
||||
store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
|
|
@ -71,6 +73,7 @@ data = await store.async_load()
|
|||
```
|
||||
|
||||
**2. Translation Cache** (in-memory):
|
||||
|
||||
```python
|
||||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
|
@ -83,6 +86,7 @@ def get_translation(path: str, language: str) -> dict:
|
|||
```
|
||||
|
||||
**3. Config Cache** (invalidated on options change):
|
||||
|
||||
```python
|
||||
class DataTransformer:
|
||||
def __init__(self):
|
||||
|
|
@ -100,6 +104,7 @@ class DataTransformer:
|
|||
### Lazy Loading
|
||||
|
||||
**Load data only when needed:**
|
||||
|
||||
```python
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict | None:
|
||||
|
|
@ -113,6 +118,7 @@ def extra_state_attributes(self) -> dict | None:
|
|||
### Bulk Operations
|
||||
|
||||
**Process multiple items at once:**
|
||||
|
||||
```python
|
||||
# ❌ Slow - loop with individual operations
|
||||
for interval in intervals:
|
||||
|
|
@ -126,6 +132,7 @@ results = enrich_intervals_bulk(intervals)
|
|||
### Async Best Practices
|
||||
|
||||
**1. Concurrent API calls:**
|
||||
|
||||
```python
|
||||
# ❌ Sequential (slow)
|
||||
user_data = await fetch_user_data()
|
||||
|
|
@ -139,6 +146,7 @@ user_data, price_data = await asyncio.gather(
|
|||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
||||
```python
|
||||
# ❌ Blocking
|
||||
result = heavy_computation() # Blocks for seconds
|
||||
|
|
@ -152,6 +160,7 @@ result = await hass.async_add_executor_job(heavy_computation)
|
|||
### Avoid Memory Leaks
|
||||
|
||||
**1. Clear references:**
|
||||
|
||||
```python
|
||||
class Coordinator:
|
||||
async def async_shutdown(self):
|
||||
|
|
@ -162,6 +171,7 @@ class Coordinator:
|
|||
```
|
||||
|
||||
**2. Use weak references for callbacks:**
|
||||
|
||||
```python
|
||||
import weakref
|
||||
|
||||
|
|
@ -176,6 +186,7 @@ class Manager:
|
|||
### Efficient Data Structures
|
||||
|
||||
**Use appropriate types:**
|
||||
|
||||
```python
|
||||
# ❌ List for lookups (O(n))
|
||||
if timestamp in timestamp_list:
|
||||
|
|
@ -197,11 +208,13 @@ results = (x for x in items if condition(x))
|
|||
### Minimize API Calls
|
||||
|
||||
**Already implemented:**
|
||||
|
||||
- Cache valid until midnight
|
||||
- User data cached for 24h
|
||||
- Only poll when tomorrow data expected
|
||||
|
||||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
|
|
@ -210,6 +223,7 @@ _LOGGER.debug("API call: %s (cache_age=%s)",
|
|||
### Smart Updates
|
||||
|
||||
**Only update when needed:**
|
||||
|
||||
```python
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch data from API."""
|
||||
|
|
@ -226,6 +240,7 @@ async def _async_update_data(self) -> dict:
|
|||
### State Class Selection
|
||||
|
||||
**Affects long-term statistics storage:**
|
||||
|
||||
```python
|
||||
# ❌ MEASUREMENT for prices (stores every change)
|
||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
||||
|
|
@ -240,6 +255,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
### Attribute Size
|
||||
|
||||
**Keep attributes minimal:**
|
||||
|
||||
```python
|
||||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
|
|
@ -317,6 +333,7 @@ _LOGGER.debug("Current memory usage: %.2f MB", memory_mb)
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Caching Strategy](caching-strategy.md) - Cache layers
|
||||
- [Architecture](architecture.md) - System design
|
||||
- [Debugging](debugging.md) - Profiling tools
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ This document explains the mathematical foundations and design decisions behind
|
|||
**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
|
||||
|
|
@ -23,6 +24,7 @@ Period detection uses **three independent filters** (all must pass):
|
|||
**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)
|
||||
|
|
@ -32,6 +34,7 @@ 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)
|
||||
|
|
@ -41,6 +44,7 @@ in_flex = price >= (daily_max - daily_max × flex)
|
|||
**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))
|
||||
|
|
@ -50,6 +54,7 @@ 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)
|
||||
|
|
@ -86,6 +91,7 @@ The integration maintains **two independent sets** of volatility thresholds:
|
|||
- 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)
|
||||
|
|
@ -107,21 +113,25 @@ period_low_threshold = PRICE_LEVEL_THRESHOLDS["volatility_low"] # Always 10%
|
|||
#### 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)
|
||||
|
|
@ -132,11 +142,13 @@ Max accepted = 15 × (1 - 0.05) = 14.25 ct/kWh
|
|||
### 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%)
|
||||
|
|
@ -149,6 +161,7 @@ daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
|
|||
**Approach:** Reduce Min_Distance proportionally as Flex increases.
|
||||
|
||||
**Formula:**
|
||||
|
||||
```python
|
||||
if flex > 0.20: # 20% threshold
|
||||
flex_excess = flex - 0.20
|
||||
|
|
@ -159,7 +172,7 @@ if flex > 0.20: # 20% threshold
|
|||
**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 |
|
||||
|
|
@ -167,6 +180,7 @@ if flex > 0.20: # 20% threshold
|
|||
| 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"
|
||||
|
|
@ -174,6 +188,7 @@ if flex > 0.20: # 20% threshold
|
|||
**Implementation:** See `level_filtering.py` → `check_interval_criteria()`
|
||||
|
||||
**Code Extract:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/level_filtering.py
|
||||
|
||||
|
|
@ -209,12 +224,14 @@ def check_interval_criteria(price, criteria):
|
|||
```
|
||||
|
||||
**Why Linear Scaling?**
|
||||
|
||||
- Simple and predictable
|
||||
- No abrupt behavior changes
|
||||
- Easy to reason about for users and developers
|
||||
- Alternative considered: Exponential scaling (rejected as too aggressive)
|
||||
|
||||
**Why 25% Minimum?**
|
||||
|
||||
- Below this, min_distance loses semantic meaning
|
||||
- Even on flat days, some quality filter needed
|
||||
- Prevents "every interval is a period" scenario
|
||||
|
|
@ -227,12 +244,14 @@ def check_interval_criteria(price, criteria):
|
|||
### Implementation Constants
|
||||
|
||||
**Defined in `coordinator/period_handlers/core.py`:**
|
||||
|
||||
```python
|
||||
MAX_SAFE_FLEX = 0.50 # 50% - hard cap: above this, period detection becomes unreliable
|
||||
MAX_OUTLIER_FLEX = 0.25 # 25% - cap for outlier filtering: above this, spike detection too permissive
|
||||
```
|
||||
|
||||
**Defined in `const.py`:**
|
||||
|
||||
```python
|
||||
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode (default enabled)
|
||||
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection)
|
||||
|
|
@ -255,16 +274,19 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Goal:** Find practical time windows for running appliances
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Appliances need time to complete cycles (dishwasher: 2-3h, EV charging: 4-8h)
|
||||
- Short periods are impractical (not worth automation overhead)
|
||||
- User wants genuinely cheap times, not just "slightly below average"
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- **60 min minimum** - Ensures period is long enough for meaningful use
|
||||
- **15% flex** - Stricter selection, focuses on truly cheap times
|
||||
- **Reasoning:** Better to find fewer, higher-quality periods than many mediocre ones
|
||||
|
||||
**User behavior:**
|
||||
|
||||
- Automations trigger actions (turn on devices)
|
||||
- Wrong automation = wasted energy/money
|
||||
- Preference: Conservative (miss some savings) over aggressive (false positives)
|
||||
|
|
@ -274,16 +296,19 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Goal:** Alert users to expensive periods for consumption reduction
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Brief price spikes still matter (even 15-30 min is worth avoiding)
|
||||
- Early warning more valuable than perfect accuracy
|
||||
- User can manually decide whether to react
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- **30 min minimum** - Catches shorter expensive spikes
|
||||
- **20% flex** - More permissive, earlier detection
|
||||
- **Reasoning:** Better to warn early (even if not peak) than miss expensive periods
|
||||
|
||||
**User behavior:**
|
||||
|
||||
- Notifications/alerts (informational)
|
||||
- Wrong alert = minor inconvenience, not cost
|
||||
- Preference: Sensitive (catch more) over specific (catch only extremes)
|
||||
|
|
@ -293,17 +318,20 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Peak Price Volatility:**
|
||||
|
||||
Price curves tend to have:
|
||||
|
||||
- **Sharp spikes** during peak hours (morning/evening)
|
||||
- **Shorter duration** at maximum (1-2 hours typical)
|
||||
- **Higher variance** in peak times than cheap times
|
||||
|
||||
**Example day:**
|
||||
|
||||
```
|
||||
Cheap period: 02:00-07:00 (5 hours at 10-12 ct) ← Gradual, stable
|
||||
Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
||||
```
|
||||
|
||||
**Implication:**
|
||||
|
||||
- Stricter flex on peak (15%) might miss real expensive periods (too brief)
|
||||
- Longer min_length (60 min) might exclude legitimate spikes
|
||||
- Solution: More flexible thresholds for peak detection
|
||||
|
|
@ -311,16 +339,19 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
|||
#### Design Alternatives Considered
|
||||
|
||||
**Option 1: Symmetric defaults (rejected)**
|
||||
|
||||
- Both 60 min, both 15% flex
|
||||
- Problem: Misses short but expensive spikes
|
||||
- User feedback: "Why didn't I get warned about the 30-min price spike?"
|
||||
|
||||
**Option 2: Same defaults, let users figure it out (rejected)**
|
||||
|
||||
- No guidance on best practices
|
||||
- Users would need to experiment to find good values
|
||||
- Most users stick with defaults, so defaults matter
|
||||
|
||||
**Option 3: Current approach (adopted)**
|
||||
|
||||
- **All values user-configurable** via config flow options
|
||||
- **Different installation defaults** for Best Price vs. Peak Price
|
||||
- Defaults reflect recommended practices for each use case
|
||||
|
|
@ -336,12 +367,14 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
|||
**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.
|
||||
|
|
@ -352,6 +385,7 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
|
|||
**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
|
||||
|
|
@ -363,23 +397,28 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
|
|||
#### 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)
|
||||
|
||||
---
|
||||
|
|
@ -395,6 +434,7 @@ Ensure **minimum periods per day** are found even when baseline filters are too
|
|||
### 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
|
||||
|
|
@ -418,6 +458,7 @@ for attempt in range(max_relaxation_attempts):
|
|||
```
|
||||
|
||||
**Constants:**
|
||||
|
||||
```python
|
||||
FLEX_WARNING_THRESHOLD_RELAXATION = 0.25 # 25% - INFO: suggest lowering to 15-20%
|
||||
FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: very high for relaxation mode
|
||||
|
|
@ -447,6 +488,7 @@ MAX_FLEX_HARD_LIMIT = 0.50 # 50% - absolute maximum (enforced in core.py)
|
|||
**Historical Context (Pre-November 2025):**
|
||||
|
||||
The algorithm previously used percentage-based increments that scaled with base flex:
|
||||
|
||||
```python
|
||||
increment = base_flex × (step_pct / 100) # REMOVED
|
||||
```
|
||||
|
|
@ -454,6 +496,7 @@ increment = base_flex × (step_pct / 100) # REMOVED
|
|||
This caused exponential escalation with high base flex values (e.g., 40% → 50% → 60% → 70% in just 6 steps), making behavior unpredictable. The fixed 3% increment solves this by providing consistent, controlled escalation regardless of starting point.
|
||||
|
||||
**Warning Messages:**
|
||||
|
||||
```python
|
||||
if base_flex >= FLEX_HIGH_THRESHOLD_RELAXATION: # 30%
|
||||
_LOGGER.warning(
|
||||
|
|
@ -472,12 +515,14 @@ elif base_flex >= FLEX_WARNING_THRESHOLD_RELAXATION: # 25%
|
|||
### 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)
|
||||
|
|
@ -492,6 +537,7 @@ Day 2025-11-19:
|
|||
### Key Files and Functions
|
||||
|
||||
**Period Calculation Entry Point:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(
|
||||
|
|
@ -502,6 +548,7 @@ def calculate_periods(
|
|||
```
|
||||
|
||||
**Flex + Distance Filtering:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/level_filtering.py
|
||||
def check_interval_criteria(
|
||||
|
|
@ -511,6 +558,7 @@ def check_interval_criteria(
|
|||
```
|
||||
|
||||
**Relaxation Orchestration:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/relaxation.py
|
||||
def calculate_periods_with_relaxation(...) -> tuple[dict, dict]
|
||||
|
|
@ -541,6 +589,7 @@ def relax_single_day(...) -> tuple[dict, dict]
|
|||
- Rejects asymmetric outliers (threshold: 1.5 std dev)
|
||||
- Preserves legitimate price shifts (morning/evening peaks)
|
||||
- Algorithm:
|
||||
|
||||
```python
|
||||
residual = abs(actual - predicted)
|
||||
symmetry_threshold = 1.5 × std_dev
|
||||
|
|
@ -563,6 +612,7 @@ def relax_single_day(...) -> tuple[dict, dict]
|
|||
- Catches patterns like: 18, 35, 19, 34, 18 (alternating spikes)
|
||||
|
||||
**Constants:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/outlier_filtering.py
|
||||
|
||||
|
|
@ -573,18 +623,21 @@ MIN_CONTEXT_SIZE = 3 # Minimum intervals for regression
|
|||
```
|
||||
|
||||
**Data Integrity:**
|
||||
|
||||
- Original prices stored in `_original_price` field
|
||||
- All statistics (daily min/max/avg) use original prices
|
||||
- Smoothing only affects period formation logic
|
||||
- Smart counting: Only counts smoothing that changed period outcome
|
||||
|
||||
**Performance:**
|
||||
|
||||
- Single pass through price data
|
||||
- O(n) complexity with small context window
|
||||
- No iterative refinement needed
|
||||
- Typical processing time: `<`1ms for 96 intervals
|
||||
|
||||
**Example Debug Output:**
|
||||
|
||||
```
|
||||
DEBUG: [2025-11-11T14:30:00+01:00] Outlier detected: 35.2 ct
|
||||
DEBUG: Context: 18.5, 19.1, 19.3, 19.8, 20.2 ct
|
||||
|
|
@ -624,6 +677,7 @@ DEBUG: Asymmetry ratio: 3.2 (>1.5 threshold) → confirmed outlier
|
|||
## Debugging Tips
|
||||
|
||||
**Enable DEBUG logging:**
|
||||
|
||||
```yaml
|
||||
# configuration.yaml
|
||||
logger:
|
||||
|
|
@ -633,6 +687,7 @@ logger:
|
|||
```
|
||||
|
||||
**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
|
||||
|
|
@ -645,17 +700,20 @@ logger:
|
|||
### ❌ 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
|
||||
|
|
@ -664,16 +722,19 @@ 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 # Use default 5%
|
||||
```
|
||||
|
|
@ -681,16 +742,19 @@ best_price_min_distance_from_avg: 5 # Use default 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
|
||||
|
|
@ -706,11 +770,13 @@ best_price_min_distance_from_avg: 5
|
|||
**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
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Filter statistics: 96 intervals checked
|
||||
DEBUG: Filtered by FLEX: 12/96 (12.5%) ← Low percentage = good variation
|
||||
|
|
@ -724,11 +790,13 @@ DEBUG: After build_periods: 3 raw periods found
|
|||
**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"
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Filter statistics: 96 intervals checked
|
||||
DEBUG: Filtered by FLEX: 45/96 (46.9%) ← High percentage = poor variation
|
||||
|
|
@ -743,11 +811,13 @@ DEBUG: Day 2025-11-11: Baseline insufficient (1 < 2), starting relaxation
|
|||
**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)
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Outlier detected: 38.5 ct (threshold: 4.2 ct)
|
||||
DEBUG: Smoothed to: 20.1 ct (trend prediction)
|
||||
|
|
@ -762,6 +832,7 @@ DEBUG: After build_periods: 4 raw periods found
|
|||
**Initial State:** Baseline finds 1 period, target is 2
|
||||
|
||||
**Expected Flow:**
|
||||
|
||||
```
|
||||
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
|
||||
DEBUG: Day 2025-11-11: Baseline found 1 period (need 2)
|
||||
|
|
@ -777,6 +848,7 @@ INFO: Day 2025-11-11: Success after 1 relaxation phase (2 periods)
|
|||
**Initial State:** Strict filters, very flat day
|
||||
|
||||
**Expected Flow:**
|
||||
|
||||
```
|
||||
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
|
||||
DEBUG: Day 2025-11-11: Baseline found 0 periods (need 2)
|
||||
|
|
@ -854,6 +926,7 @@ When debugging period calculation issues:
|
|||
**Concept:** Auto-adjust Flex based on daily price variation
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
```python
|
||||
# Pseudo-code for adaptive flex
|
||||
variation = (daily_max - daily_min) / daily_avg
|
||||
|
|
@ -867,11 +940,13 @@ else: # Normal day
|
|||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Eliminates need for relaxation on most days
|
||||
- Self-adjusting to market conditions
|
||||
- Better user experience (less configuration needed)
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Harder to predict behavior (less transparent)
|
||||
- May conflict with user's mental model
|
||||
- Needs extensive testing across different markets
|
||||
|
|
@ -883,17 +958,20 @@ else: # Normal day
|
|||
**Concept:** Learn optimal Flex/Distance from user feedback
|
||||
|
||||
**Approach:**
|
||||
|
||||
- Track which periods user actually uses (automation triggers)
|
||||
- Classify days by pattern (normal/flat/volatile/bimodal)
|
||||
- Apply pattern-specific defaults
|
||||
- Learn per-user preferences over time
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Personalized to user's actual behavior
|
||||
- Adapts to local market patterns
|
||||
- Could discover non-obvious patterns
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Requires user feedback mechanism (not implemented)
|
||||
- Privacy concerns (storing usage patterns)
|
||||
- Complexity for users to understand "why this period?"
|
||||
|
|
@ -906,22 +984,26 @@ else: # Normal day
|
|||
**Concept:** Balance multiple goals simultaneously
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Period count vs. quality (cheap vs. very cheap)
|
||||
- Period duration vs. price level (long mediocre vs. short excellent)
|
||||
- Temporal distribution (spread throughout day vs. clustered)
|
||||
- User's stated use case (EV charging vs. heat pump vs. dishwasher)
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
- Pareto optimization (find trade-off frontier)
|
||||
- User chooses point on frontier via preferences
|
||||
- Genetic algorithm or simulated annealing
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- More sophisticated period selection
|
||||
- Better match to user's actual needs
|
||||
- Could handle complex appliance requirements
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Much more complex to implement
|
||||
- Harder to explain to users
|
||||
- Computational cost (may need caching)
|
||||
|
|
@ -936,14 +1018,17 @@ else: # Normal day
|
|||
**Current:** 3% cap may be too aggressive for very low base Flex
|
||||
|
||||
**Example:**
|
||||
|
||||
- Base flex 5% + 3% increment = 8% (60% increase!)
|
||||
- Base flex 15% + 3% increment = 18% (20% increase)
|
||||
|
||||
**Possible Solution:**
|
||||
|
||||
- Percentage-based increment: `increment = max(base_flex × 0.20, 0.03)`
|
||||
- This gives: 5% → 6% (20%), 15% → 18% (20%), 40% → 43% (7.5%)
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Very low base flex (`<`10%) unusual
|
||||
- Users with strict requirements likely disable relaxation
|
||||
- Simplicity preferred over edge case optimization
|
||||
|
|
@ -953,6 +1038,7 @@ else: # Normal day
|
|||
**Current:** Linear scaling may be too aggressive/conservative
|
||||
|
||||
**Alternative:** Non-linear curve
|
||||
|
||||
```python
|
||||
# Example: Exponential scaling
|
||||
scale_factor = 0.25 + 0.75 × exp(-5 × (flex - 0.20))
|
||||
|
|
@ -962,6 +1048,7 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
```
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Linear is easier to reason about
|
||||
- No evidence that non-linear is better
|
||||
- Would need extensive testing
|
||||
|
|
@ -971,15 +1058,18 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
**Issue:** May find all periods in one part of day
|
||||
|
||||
**Example:**
|
||||
|
||||
- All 3 "best price" periods between 02:00-08:00
|
||||
- No periods in evening (when user might want to run appliances)
|
||||
|
||||
**Possible Solution:**
|
||||
|
||||
- Add "spread" parameter (prefer distributed periods)
|
||||
- Weight periods by time-of-day preferences
|
||||
- Consider user's typical usage patterns
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Adds complexity
|
||||
- Users can work around with multiple automations
|
||||
- Different users have different needs (no one-size-fits-all)
|
||||
|
|
@ -991,6 +1081,7 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
**Design Principle:** Each interval is evaluated using its **own day's** reference prices (daily min/max/avg).
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In period_building.py build_periods():
|
||||
for price_data in all_prices:
|
||||
|
|
@ -1042,6 +1133,7 @@ Period crossing midnight: 23:45 Day 1 → 00:15 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)
|
||||
|
|
@ -1053,6 +1145,7 @@ This is **mathematically correct** - 21ct is genuinely expensive on a day where
|
|||
**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
|
||||
|
||||
|
|
@ -1061,10 +1154,12 @@ This explains why absolute prices jump at midnight despite minimal demand change
|
|||
**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
|
||||
|
|
@ -1095,6 +1190,7 @@ Low volatility (< 15%) means classification changes are less economically signif
|
|||
**Status:** Per-day evaluation is intentional design prioritizing mathematical correctness.
|
||||
|
||||
**See Also:**
|
||||
|
||||
- User documentation: `docs/user/docs/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)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- Must be a **class attribute** (not instance attribute)
|
||||
- Use `frozenset` for immutability and performance
|
||||
- Applied automatically by Home Assistant's Recorder component
|
||||
|
|
@ -40,6 +41,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `description`, `usage_tips`
|
||||
|
||||
**Reason:** Static, large text strings (100-500 chars each) that:
|
||||
|
||||
- Never change or change very rarely
|
||||
- Don't provide analytical value in history
|
||||
- Consume significant database space when recorded every state change
|
||||
|
|
@ -50,6 +52,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
### 2. Large Nested Structures
|
||||
|
||||
**Attributes:**
|
||||
|
||||
- `periods` (binary_sensor) - Array of all period summaries
|
||||
- `data` (chart_data_export) - Complete price data arrays
|
||||
- `trend_attributes` - Detailed trend analysis
|
||||
|
|
@ -58,6 +61,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
- `volatility_attributes` - Detailed volatility breakdown
|
||||
|
||||
**Reason:** Complex nested data structures that are:
|
||||
|
||||
- Serialized to JSON for storage (expensive)
|
||||
- Create large database rows (2-20 KB each)
|
||||
- Slow down history queries
|
||||
|
|
@ -66,6 +70,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Impact:** ~10-30 KB saved per state change for affected sensors
|
||||
|
||||
**Example - periods array:**
|
||||
|
||||
```json
|
||||
{
|
||||
"periods": [
|
||||
|
|
@ -76,7 +81,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
"price_mean": 18.5,
|
||||
"price_median": 18.3,
|
||||
"price_min": 17.2,
|
||||
"price_max": 19.8,
|
||||
"price_max": 19.8
|
||||
// ... 10+ more attributes × 10-20 periods
|
||||
}
|
||||
]
|
||||
|
|
@ -88,6 +93,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Change every update cycle (every 15 minutes or more frequently)
|
||||
- Don't provide long-term analytical value
|
||||
- Create state changes even when core values haven't changed
|
||||
|
|
@ -103,6 +109,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Configuration values that rarely change
|
||||
- Wastes space when recorded repeatedly
|
||||
- Can be derived from other attributes or from entity state
|
||||
|
|
@ -114,6 +121,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Only relevant at moment of reading
|
||||
- Won't be valid after some time
|
||||
- Similar to `entity_picture` in HA core image entities
|
||||
|
|
@ -128,6 +136,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Detailed technical information not needed for historical analysis
|
||||
- Only useful for debugging during active development
|
||||
- Boolean `relaxation_active` is kept for high-level analysis
|
||||
|
|
@ -139,6 +148,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `periods_total`, `periods_remaining`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Can be calculated from other attributes
|
||||
- Redundant information
|
||||
- Doesn't add analytical value to history
|
||||
|
|
@ -152,22 +162,27 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
These attributes **remain in history** because they provide essential analytical value:
|
||||
|
||||
### Time-Series Core
|
||||
|
||||
- `timestamp` - Critical for time-series analysis (ALWAYS FIRST)
|
||||
- All price values - Core sensor states
|
||||
|
||||
### Diagnostics & Tracking
|
||||
|
||||
- `cache_age_minutes` - Numeric value for diagnostics tracking over time
|
||||
- `updates_today` - Tracking API usage patterns
|
||||
|
||||
### Data Completeness
|
||||
|
||||
- `interval_count`, `intervals_available` - Data completeness metrics
|
||||
- `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status
|
||||
|
||||
### Period Data
|
||||
|
||||
- `start`, `end`, `duration_minutes` - Core period timing
|
||||
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
|
||||
|
||||
### High-Level Status
|
||||
|
||||
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
|
||||
|
||||
## Expected Database Impact
|
||||
|
|
@ -175,6 +190,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Space Savings
|
||||
|
||||
**Per state change:**
|
||||
|
||||
- Before: ~3-8 KB average
|
||||
- After: ~0.5-1.5 KB average
|
||||
- **Reduction: 60-85%**
|
||||
|
|
@ -196,6 +212,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Real-World Impact
|
||||
|
||||
For a typical installation with:
|
||||
|
||||
- 80+ sensors
|
||||
- Updates every 15 minutes
|
||||
- ~10 sensors updating every minute
|
||||
|
|
@ -214,7 +231,7 @@ For a typical installation with:
|
|||
- Class: `TibberPricesBinarySensor`
|
||||
- 30 attributes excluded
|
||||
|
||||
## When to Update _unrecorded_attributes
|
||||
## When to Update \_unrecorded_attributes
|
||||
|
||||
### Add to Exclusion List When:
|
||||
|
||||
|
|
@ -265,6 +282,7 @@ After modifying `_unrecorded_attributes`:
|
|||
4. **Confirm excluded attributes** don't appear in new state writes
|
||||
|
||||
**SQL Query to check attribute presence:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
state_id,
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ In CI/CD (`$CI` or `$GITHUB_ACTIONS`), AI is automatically disabled.
|
|||
**In DevContainer (automatic):**
|
||||
|
||||
git-cliff is automatically installed when the DevContainer is built:
|
||||
|
||||
- **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile)
|
||||
- **git-cliff**: Installed via cargo in `scripts/setup/setup`
|
||||
|
||||
|
|
@ -120,6 +121,7 @@ Simply rebuild the container (VS Code: "Dev Containers: Rebuild Container") and
|
|||
**Manual installation (outside DevContainer):**
|
||||
|
||||
**git-cliff** (template-based):
|
||||
|
||||
```bash
|
||||
# See: https://git-cliff.org/docs/installation
|
||||
|
||||
|
|
@ -191,7 +193,7 @@ All methods produce GitHub-flavored Markdown with emoji categories:
|
|||
## 🎯 When to Use Which
|
||||
|
||||
| Method | Use Case | Pros | Cons |
|
||||
|--------|----------|------|------|
|
||||
| --------------------- | --------------------- | ----------------------------- | ------------------------ |
|
||||
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
|
||||
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
|
||||
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
|
||||
|
|
@ -219,6 +221,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. Script bumps manifest.json → commits → creates tag locally
|
||||
2. You push commit + tag together
|
||||
3. Release workflow sees tag → generates notes → creates release
|
||||
|
|
@ -242,6 +245,7 @@ git push
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You push manifest.json change
|
||||
2. Auto-Tag workflow detects change → creates tag automatically
|
||||
3. Release workflow sees new tag → creates release
|
||||
|
|
@ -263,6 +267,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You create and push tag manually
|
||||
2. Release workflow creates release
|
||||
3. Auto-Tag workflow skips (tag already exists)
|
||||
|
|
@ -282,19 +287,24 @@ git push origin main v0.3.0
|
|||
## 🛡️ Safety Features
|
||||
|
||||
### 1. **Version Validation**
|
||||
|
||||
Both helper script and auto-tag workflow validate version format (X.Y.Z).
|
||||
|
||||
### 2. **No Duplicate Tags**
|
||||
|
||||
- Helper script checks if tag exists (local + remote)
|
||||
- Auto-tag workflow checks if tag exists before creating
|
||||
|
||||
### 3. **Atomic Operations**
|
||||
|
||||
Helper script creates commit + tag locally. You decide when to push.
|
||||
|
||||
### 4. **Version Bumps Filtered**
|
||||
|
||||
Release notes automatically exclude `chore(release): bump version` commits.
|
||||
|
||||
### 5. **Rollback Instructions**
|
||||
|
||||
Helper script shows how to undo if you change your mind.
|
||||
|
||||
---
|
||||
|
|
@ -330,6 +340,7 @@ git push -f origin main v0.3.0
|
|||
**Auto-tag didn't create tag:**
|
||||
|
||||
Check workflow runs in GitHub Actions. Common causes:
|
||||
|
||||
- Tag already exists remotely
|
||||
- Invalid version format in manifest.json
|
||||
- manifest.json not in the commit that was pushed
|
||||
|
|
@ -348,6 +359,7 @@ Check workflow runs in GitHub Actions. Common causes:
|
|||
## 💡 Tips
|
||||
|
||||
1. **Conventional Commits:** Use proper commit format for best results:
|
||||
|
||||
```
|
||||
feat(scope): Add new feature
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ The Tibber Prices integration includes a proactive repair notification system th
|
|||
The repairs system is implemented in `coordinator/repairs.py` via the `TibberPricesRepairManager` class, which is instantiated in the coordinator and integrated into the update cycle.
|
||||
|
||||
**Design Principles:**
|
||||
|
||||
- **Proactive**: Detect issues before they become critical
|
||||
- **User-friendly**: Clear explanations with actionable guidance
|
||||
- **Auto-clearing**: Repairs automatically disappear when conditions resolve
|
||||
|
|
@ -19,10 +20,12 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
**Issue ID:** `tomorrow_data_missing_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`)
|
||||
- Tomorrow's electricity price data is still not available
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Tomorrow's data becomes available
|
||||
- Automatically checks on every successful API update
|
||||
|
||||
|
|
@ -30,6 +33,7 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
Users cannot plan ahead for tomorrow's electricity usage optimization. Automations relying on tomorrow's prices will not work.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In coordinator update cycle
|
||||
has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"])
|
||||
|
|
@ -40,6 +44,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `warning_hour`: Hour after which warning appears (default: 18)
|
||||
|
||||
|
|
@ -48,10 +53,12 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
**Issue ID:** `rate_limit_exceeded_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Integration encounters 3 or more consecutive rate limit errors (HTTP 429)
|
||||
- Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD`
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Successful API call completes (no rate limit error)
|
||||
- Error counter resets to 0
|
||||
|
||||
|
|
@ -59,6 +66,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
|
|
@ -74,6 +82,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `error_count`: Number of consecutive rate limit errors
|
||||
|
||||
|
|
@ -82,10 +91,12 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
**Issue ID:** `home_not_found_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Home configured in this integration is no longer present in Tibber account
|
||||
- Detected during user data refresh (daily check)
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Home reappears in Tibber account (unlikely - manual cleanup expected)
|
||||
- Integration entry is removed (shutdown cleanup)
|
||||
|
||||
|
|
@ -93,6 +104,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
Integration cannot fetch data for a non-existent home. User must remove the config entry and re-add if needed.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# After user data update
|
||||
home_exists = self._data_fetcher._check_home_exists(home_id)
|
||||
|
|
@ -103,6 +115,7 @@ else:
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the missing home
|
||||
- `entry_id`: Config entry ID for reference
|
||||
|
||||
|
|
@ -153,6 +166,7 @@ Each repair type maintains internal state to avoid redundant operations:
|
|||
### Lifecycle Integration
|
||||
|
||||
**Coordinator Initialization:**
|
||||
|
||||
```python
|
||||
self._repair_manager = TibberPricesRepairManager(
|
||||
hass=hass,
|
||||
|
|
@ -162,6 +176,7 @@ self._repair_manager = TibberPricesRepairManager(
|
|||
```
|
||||
|
||||
**Update Cycle Integration:**
|
||||
|
||||
```python
|
||||
# Success path - check conditions
|
||||
if result and "priceInfo" in result:
|
||||
|
|
@ -178,6 +193,7 @@ if is_rate_limit:
|
|||
```
|
||||
|
||||
**Shutdown Cleanup:**
|
||||
|
||||
```python
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shut down coordinator and clean up."""
|
||||
|
|
@ -196,6 +212,7 @@ Repairs use Home Assistant's standard translation system. Translations are defin
|
|||
- `/translations/sv.json`
|
||||
|
||||
**Structure:**
|
||||
|
||||
```json
|
||||
{
|
||||
"issues": {
|
||||
|
|
@ -210,10 +227,12 @@ Repairs use Home Assistant's standard translation system. Translations are defin
|
|||
## Home Assistant Integration
|
||||
|
||||
Repairs appear in:
|
||||
|
||||
- **Settings → System → Repairs** (main repairs panel)
|
||||
- **Notifications** (bell icon in UI shows repair count)
|
||||
|
||||
Repair properties:
|
||||
|
||||
- **`is_fixable=False`**: No automated fix available (user action required)
|
||||
- **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical)
|
||||
- **`translation_key`**: References `issues.{key}` in translation files
|
||||
|
|
@ -228,6 +247,7 @@ Repair properties:
|
|||
4. When tomorrow data arrives (next API fetch), repair clears
|
||||
|
||||
**Manual trigger:**
|
||||
|
||||
```python
|
||||
# Temporarily set warning hour to current hour for testing
|
||||
TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
||||
|
|
@ -240,6 +260,7 @@ TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
|||
3. Successful API call clears the repair
|
||||
|
||||
**Manual test:**
|
||||
|
||||
- Reduce API polling interval to trigger rate limiting
|
||||
- Or temporarily return HTTP 429 in API client
|
||||
|
||||
|
|
@ -263,6 +284,7 @@ To add a new repair type:
|
|||
7. **Document** in this file
|
||||
|
||||
**Example template:**
|
||||
|
||||
```python
|
||||
async def check_new_condition(self, *, param: bool) -> None:
|
||||
"""Check new condition and create/clear repair."""
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ This document explains the timer/scheduler system in the Tibber Prices integrati
|
|||
The integration uses **three independent timer mechanisms** for different purposes:
|
||||
|
||||
| Timer | Type | Interval | Purpose | Trigger Method |
|
||||
|-------|------|----------|---------|----------------|
|
||||
| ------------ | ----------- | ------------------ | -------------------- | ------------------------------- |
|
||||
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` |
|
||||
| **Timer #2** | Custom | :00, :15, :30, :45 | Entity state refresh | `async_track_utc_time_change()` |
|
||||
| **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
|
||||
|
|
@ -27,6 +27,7 @@ The integration uses **three independent timer mechanisms** for different purpos
|
|||
**Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes`
|
||||
|
||||
**What it is:**
|
||||
|
||||
- HA provides this timer system automatically when you inherit from `DataUpdateCoordinator`
|
||||
- Triggers `_async_update_data()` method every 15 minutes
|
||||
- **Not** synchronized to clock boundaries (each installation has different start time)
|
||||
|
|
@ -53,16 +54,19 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
```
|
||||
|
||||
**Load Distribution:**
|
||||
|
||||
- Each HA installation starts Timer #1 at different times → natural distribution
|
||||
- Tomorrow data check adds 0-30s random delay → prevents "thundering herd" on Tibber API
|
||||
- Result: API load spread over ~30 minutes instead of all at once
|
||||
|
||||
**Midnight Coordination:**
|
||||
|
||||
- Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects)
|
||||
- If midnight turnover needed → performs it and returns early
|
||||
- Timer #2 will see turnover already done and skip gracefully
|
||||
|
||||
**Why we use HA's timer:**
|
||||
|
||||
- Automatic restart after HA restart
|
||||
- Built-in retry logic for temporary failures
|
||||
- Standard HA integration pattern
|
||||
|
|
@ -79,6 +83,7 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
**Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll**
|
||||
|
||||
**Problem it solves:**
|
||||
|
||||
- Timer #1 runs every 15 minutes but NOT synchronized to clock (:03, :18, :33, :48)
|
||||
- Current price changes at :00, :15, :30, :45 → entities would show stale data for up to 15 minutes
|
||||
- Example: 14:00 new price, but Timer #1 ran at 13:58 → next update at 14:13 → users see old price until 14:13
|
||||
|
|
@ -100,22 +105,26 @@ async def _handle_quarter_hour_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Smart Boundary Tolerance:**
|
||||
|
||||
- Uses `round_to_nearest_quarter_hour()` with ±2 second tolerance
|
||||
- HA may schedule timer at 14:59:58 → rounds to 15:00:00 (shows new interval)
|
||||
- HA restart at 14:59:30 → stays at 14:45:00 (shows current interval)
|
||||
- See [Architecture](./architecture.md#3-quarter-hour-precision) for details
|
||||
|
||||
**Absolute Time Scheduling:**
|
||||
|
||||
- `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...)
|
||||
- NOT relative delays ("in 15 minutes")
|
||||
- If triggered at 14:59:58 → next trigger is 15:15:00, NOT 15:00:00 (prevents double updates)
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- All sensors that depend on "current interval" (e.g., `current_interval_price`, `next_interval_price`)
|
||||
- Binary sensors that check "is now in period?" (e.g., `best_price_period_active`)
|
||||
- ~50-60 entities out of 120+ total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- HA's built-in coordinator doesn't support exact boundary timing
|
||||
- We need **absolute time** triggers, not periodic intervals
|
||||
- Allows fast entity updates without expensive data transformation
|
||||
|
|
@ -140,6 +149,7 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- `best_price_remaining_minutes` - Countdown timer
|
||||
- `peak_price_remaining_minutes` - Countdown timer
|
||||
- `best_price_progress` - Progress bar (0-100%)
|
||||
|
|
@ -147,11 +157,13 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
- ~10 entities total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- Users want smooth countdowns (not jumping 15 minutes at a time)
|
||||
- Progress bars need minute-by-minute updates
|
||||
- Very lightweight (no data processing, just state recalculation)
|
||||
|
||||
**Why NOT every second:**
|
||||
|
||||
- Minute precision sufficient for countdown UX
|
||||
- Reduces CPU load (60× fewer updates than seconds)
|
||||
- Home Assistant best practice (avoid sub-minute updates)
|
||||
|
|
@ -194,6 +206,7 @@ class ListenerManager:
|
|||
```
|
||||
|
||||
**Why this pattern:**
|
||||
|
||||
- Decouples timer logic from entity logic
|
||||
- One timer can notify many entities efficiently
|
||||
- Entities can unregister when removed (cleanup)
|
||||
|
|
@ -279,11 +292,13 @@ class ListenerManager:
|
|||
### Reason 1: Load Distribution on Tibber API
|
||||
|
||||
If all installations used synchronized timers:
|
||||
|
||||
- ❌ Everyone fetches at 13:00:00 → Tibber API overload
|
||||
- ❌ Everyone fetches at 14:00:00 → Tibber API overload
|
||||
- ❌ "Thundering herd" problem
|
||||
|
||||
With HA's unsynchronized timer:
|
||||
|
||||
- ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ...
|
||||
- ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ...
|
||||
- ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ...
|
||||
|
|
@ -316,6 +331,7 @@ def _should_update_price_data(self) -> str:
|
|||
**Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data.
|
||||
|
||||
**API fetch only when:**
|
||||
|
||||
- Tomorrow data missing/invalid (after 13:00)
|
||||
- Cache expired (midnight turnover)
|
||||
- Explicit user refresh
|
||||
|
|
@ -339,6 +355,7 @@ def _should_update_price_data(self) -> str:
|
|||
## Performance Characteristics
|
||||
|
||||
### Timer #1 (DataUpdateCoordinator)
|
||||
|
||||
- **Triggers:** Every 15 minutes (unsynchronized)
|
||||
- **Fast path:** ~2ms (cache check, return existing data)
|
||||
- **Slow path:** ~600ms (API fetch + transform + calculate)
|
||||
|
|
@ -346,12 +363,14 @@ def _should_update_price_data(self) -> str:
|
|||
- **API calls:** ~1-2 times/day (cached otherwise)
|
||||
|
||||
### Timer #2 (Quarter-Hour Refresh)
|
||||
|
||||
- **Triggers:** 96 times/day (exact boundaries)
|
||||
- **Processing:** ~5ms (notify 60 entities)
|
||||
- **No API calls:** Uses cached/transformed data
|
||||
- **No transformation:** Just entity state updates
|
||||
|
||||
### Timer #3 (Minute Refresh)
|
||||
|
||||
- **Triggers:** 1440 times/day (every minute)
|
||||
- **Processing:** ~1ms (notify 10 entities)
|
||||
- **No API calls:** No data processing at all
|
||||
|
|
@ -417,17 +436,20 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
## Summary
|
||||
|
||||
**Three independent timers:**
|
||||
|
||||
1. **Timer #1** (HA built-in, 15 min, unsynchronized) → Data fetching (when needed)
|
||||
2. **Timer #2** (Custom, :00/:15/:30/:45) → Entity state updates (always)
|
||||
3. **Timer #3** (Custom, every minute) → Countdown/progress (always)
|
||||
|
||||
**Key insights:**
|
||||
|
||||
- Timer #1 unsynchronized = good (load distribution on API)
|
||||
- Timer #2 synchronized = good (user sees correct data immediately)
|
||||
- Timer #3 synchronized = good (smooth countdown UX)
|
||||
- All three coordinate gracefully (atomic midnight checks, no conflicts)
|
||||
|
||||
**"Listener" terminology:**
|
||||
|
||||
- Timer = mechanism that triggers
|
||||
- Listener = callback that gets called
|
||||
- Observer pattern = entities register, coordinator notifies
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ query($homeId: ID!) {
|
|||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `homeId`: Tibber home identifier
|
||||
- `resolution`: Always `QUARTER_HOURLY`
|
||||
- `first`: 384 intervals (4 days of data)
|
||||
|
|
@ -85,10 +86,12 @@ query($homeId: ID!) {
|
|||
## Rate Limits
|
||||
|
||||
Tibber API rate limits (as of 2024):
|
||||
|
||||
- **5000 requests per hour** per token
|
||||
- **Burst limit:** 100 requests per minute
|
||||
|
||||
Integration stays well below these limits:
|
||||
|
||||
- Polls every 15 minutes = 96 requests/day
|
||||
- User data cached for 24h = 1 request/day
|
||||
- **Total:** ~100 requests/day per home
|
||||
|
|
@ -106,6 +109,7 @@ Integration stays well below these limits:
|
|||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
|
||||
- `startsAt`: ISO 8601 timestamp with timezone
|
||||
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
|
||||
|
|
@ -119,6 +123,7 @@ Integration stays well below these limits:
|
|||
```
|
||||
|
||||
Supported currencies:
|
||||
|
||||
- `EUR` (Euro) - displayed as ct/kWh
|
||||
- `NOK` (Norwegian Krone) - displayed as øre/kWh
|
||||
- `SEK` (Swedish Krona) - displayed as öre/kWh
|
||||
|
|
@ -128,42 +133,52 @@ Supported currencies:
|
|||
### Common Error Responses
|
||||
|
||||
**Invalid Token:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Rate Limit Exceeded:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Home Not Found:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Integration handles these with:
|
||||
|
||||
- Exponential backoff retry (3 attempts)
|
||||
- ConfigEntryAuthFailed for auth errors
|
||||
- ConfigEntryNotReady for temporary failures
|
||||
|
|
@ -171,6 +186,7 @@ Integration handles these with:
|
|||
## Data Transformation
|
||||
|
||||
Raw API data is enriched with:
|
||||
|
||||
- **Trailing 24h average** - Calculated from previous intervals
|
||||
- **Leading 24h average** - Calculated from future intervals
|
||||
- **Price difference %** - Deviation from average
|
||||
|
|
@ -181,6 +197,7 @@ See `utils/price.py` for enrichment logic.
|
|||
---
|
||||
|
||||
💡 **External Resources:**
|
||||
|
||||
- [Tibber API Documentation](https://developer.tibber.com/docs/overview)
|
||||
- [GraphQL Explorer](https://developer.tibber.com/explorer)
|
||||
- [Get API Token](https://developer.tibber.com/settings/access-token)
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ flowchart TB
|
|||
The integration uses **5 independent caching layers** for optimal performance:
|
||||
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
|-------|----------|----------|--------------|--------|
|
||||
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ |
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
|
|
@ -196,7 +196,7 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
### Core Components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|-----------|------|----------------|
|
||||
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
|
|
@ -210,7 +210,7 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
|
||||
|
||||
| Component | Files | Lines | Responsibility |
|
||||
|-----------|-------|-------|----------------|
|
||||
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- |
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
|
|
@ -219,6 +219,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
|
||||
**Calculator Package** (`sensor/calculators/`):
|
||||
|
||||
- `base.py` - Abstract BaseCalculator with coordinator access
|
||||
- `interval.py` - Single interval calculations (current/next/previous)
|
||||
- `rolling_hour.py` - 5-interval rolling windows
|
||||
|
|
@ -230,6 +231,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
- `metadata.py` - Home/metering metadata
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 58% reduction in core.py (2,170 → 909 lines)
|
||||
- Clear separation: Calculators (logic) vs Attributes (presentation)
|
||||
- Independent testability for each calculator
|
||||
|
|
@ -238,7 +240,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
### Helper Utilities
|
||||
|
||||
| Utility | File | Purpose |
|
||||
|---------|------|---------|
|
||||
| ----------------- | ------------------ | ------------------------------------------------- |
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
|
|
@ -296,26 +298,31 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
Sensors organized by **calculation method** (refactored Nov 2025):
|
||||
|
||||
**Unified Handler Methods** (`sensor/core.py`):
|
||||
|
||||
- `_get_interval_value(offset, type)` - current/next/previous intervals
|
||||
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
|
||||
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
|
||||
- `_get_24h_window_value(stat_func)` - trailing/leading statistics
|
||||
|
||||
**Routing** (`sensor/value_getters.py`):
|
||||
|
||||
- Single source of truth mapping 80+ entity keys to calculator methods
|
||||
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
|
||||
|
||||
**Calculators** (`sensor/calculators/`):
|
||||
|
||||
- Each calculator inherits from `BaseCalculator` with coordinator access
|
||||
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
|
||||
- Complex logic isolated (e.g., `TrendCalculator` has internal caching)
|
||||
|
||||
**Attributes** (`sensor/attributes/`):
|
||||
|
||||
- Separate from business logic, handles state presentation
|
||||
- Builds extra_state_attributes dicts for entity classes
|
||||
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Minimal code duplication across 80+ sensors
|
||||
- Clear separation of concerns (calculation vs presentation)
|
||||
- Easy to extend: Add sensor → choose pattern → add to routing
|
||||
|
|
@ -334,7 +341,7 @@ Sensors organized by **calculation method** (refactored Nov 2025):
|
|||
### CPU Optimization
|
||||
|
||||
| Optimization | Location | Savings |
|
||||
|--------------|----------|---------|
|
||||
| ------------------- | ------------------------ | ---------------------------- |
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total)
|
||||
- **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
|
||||
- **Timestamps**: Last update times for validation
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
|
||||
- **User data**: 24 hours (refreshed daily)
|
||||
- **Survives**: HA restarts via persistent Storage
|
||||
|
|
@ -36,6 +38,7 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Midnight turnover** (Timer #2 in coordinator):
|
||||
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
|
|
@ -45,6 +48,7 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
```
|
||||
|
||||
2. **Cache validation on load**:
|
||||
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
|
|
@ -71,18 +75,22 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
|
||||
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Forever** (until HA restart)
|
||||
- No invalidation during runtime
|
||||
|
||||
**When populated:**
|
||||
|
||||
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
|
||||
- Lazy loading: If translation missing, attempts file load once
|
||||
|
||||
**Access pattern:**
|
||||
|
||||
```python
|
||||
# Non-blocking synchronous access from cached data
|
||||
description = get_translation("binary_sensor.best_price_period.description", "en")
|
||||
|
|
@ -101,6 +109,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**What is cached:**
|
||||
|
||||
### DataTransformer Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"thresholds": {"low": 15, "high": 35},
|
||||
|
|
@ -110,6 +119,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
### PeriodCalculator Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||
|
|
@ -118,10 +128,12 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until `invalidate_config_cache()` is called
|
||||
- Built once on first use per coordinator update cycle
|
||||
|
||||
**Invalidation trigger:**
|
||||
|
||||
- **Options change** (user reconfigures integration):
|
||||
```python
|
||||
# coordinator/core.py
|
||||
|
|
@ -132,6 +144,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Before:** ~30 dict lookups + type conversions per update = ~50μs
|
||||
- **After:** 1 cache check = ~1μs
|
||||
- **Savings:** ~98% (50μs → 1μs per update)
|
||||
|
|
@ -147,6 +160,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"best_price": {
|
||||
|
|
@ -161,6 +175,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Cache key:** Hash of relevant inputs
|
||||
|
||||
```python
|
||||
hash_data = (
|
||||
today_signature, # (startsAt, rating_level) for each interval
|
||||
|
|
@ -172,6 +187,7 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until price data changes (today's intervals modified)
|
||||
- Until config changes (flex, thresholds, filters)
|
||||
- Recalculated at midnight (new today data)
|
||||
|
|
@ -179,6 +195,7 @@ hash_data = (
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Config change** (explicit):
|
||||
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
|
|
@ -193,10 +210,12 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Cache hit rate:**
|
||||
|
||||
- **High:** During normal operation (coordinator updates every 15min, price data unchanged)
|
||||
- **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
|
||||
- **Cache hit:** `<`1ms (hash comparison + dict lookup)
|
||||
- **Savings:** ~70% of calculation time (most updates hit cache)
|
||||
|
|
@ -212,6 +231,7 @@ hash_data = (
|
|||
**Status:** ✅ **Clean separation** - enrichment only, no redundancy
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"timestamp": ...,
|
||||
|
|
@ -224,6 +244,7 @@ hash_data = (
|
|||
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
|
||||
|
||||
**Current behavior:**
|
||||
|
||||
- Caches **only enriched price data** (price + statistics)
|
||||
- **Does NOT cache periods** (handled by Period Calculation Cache)
|
||||
- Invalidated when:
|
||||
|
|
@ -232,6 +253,7 @@ hash_data = (
|
|||
- New update cycle begins
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- DataTransformer: Handles price enrichment only
|
||||
- PeriodCalculator: Handles period calculation only (with hash-based cache)
|
||||
- Coordinator: Assembles final data on-demand from both caches
|
||||
|
|
@ -243,6 +265,7 @@ hash_data = (
|
|||
## Cache Invalidation Flow
|
||||
|
||||
### User Changes Options (Config Flow)
|
||||
|
||||
```
|
||||
User saves options
|
||||
↓
|
||||
|
|
@ -267,6 +290,7 @@ Fresh data fetch with new config
|
|||
```
|
||||
|
||||
### Midnight Turnover (Day Transition)
|
||||
|
||||
```
|
||||
Timer #2 fires at 00:00
|
||||
↓
|
||||
|
|
@ -286,6 +310,7 @@ Fresh API fetch for new day
|
|||
```
|
||||
|
||||
### Tomorrow Data Arrives (~13:00)
|
||||
|
||||
```
|
||||
Coordinator update cycle
|
||||
↓
|
||||
|
|
@ -327,12 +352,14 @@ API Data Cache (price_data, user_data)
|
|||
```
|
||||
|
||||
**No cache invalidation cascades:**
|
||||
|
||||
- Config cache invalidation is **explicit** (on options update)
|
||||
- Period cache invalidation is **automatic** (via hash mismatch)
|
||||
- Transformation cache invalidation is **automatic** (on midnight/config change)
|
||||
- Translation cache is **never invalidated** (read-only after load)
|
||||
|
||||
**Thread safety:**
|
||||
|
||||
- All caches are accessed from `MainThread` only (Home Assistant event loop)
|
||||
- No locking needed (single-threaded execution model)
|
||||
|
||||
|
|
@ -341,6 +368,7 @@ API Data Cache (price_data, user_data)
|
|||
## Performance Characteristics
|
||||
|
||||
### Typical Operation (No Changes)
|
||||
|
||||
```
|
||||
Coordinator Update (every 15 min)
|
||||
├─> API fetch: SKIP (cache valid)
|
||||
|
|
@ -353,6 +381,7 @@ Total: ~16ms (down from ~600ms without caching)
|
|||
```
|
||||
|
||||
### After Midnight Turnover
|
||||
|
||||
```
|
||||
Coordinator Update (00:00)
|
||||
├─> API fetch: ~500ms (cache cleared, fetch new day)
|
||||
|
|
@ -365,6 +394,7 @@ Total: ~755ms (expected once per day)
|
|||
```
|
||||
|
||||
### After Config Change
|
||||
|
||||
```
|
||||
Options Update
|
||||
├─> Cache invalidation: `<`1ms
|
||||
|
|
@ -382,7 +412,7 @@ Options Update
|
|||
## Summary Table
|
||||
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
|------------|----------|------|--------------|---------|
|
||||
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- |
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
|
|
@ -392,12 +422,14 @@ Options Update
|
|||
**Total memory overhead:** ~116KB per coordinator instance (main + subentries)
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 97% reduction in API calls (from every 15min to once per day)
|
||||
- 70% reduction in period calculation time (cache hits during normal operation)
|
||||
- 98% reduction in config access time (30+ lookups → 1 cache check)
|
||||
- Zero file I/O during runtime (translations cached at startup)
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- Memory usage: ~116KB per home (negligible for modern systems)
|
||||
- Code complexity: 5 cache invalidation points (well-tested, documented)
|
||||
- Debugging: Must understand cache lifetime when investigating stale data issues
|
||||
|
|
@ -407,7 +439,9 @@ Options Update
|
|||
## Debugging Cache Issues
|
||||
|
||||
### Symptom: Stale data after config change
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Is `_handle_options_update()` called? (should see "Options updated" log)
|
||||
2. Are `invalidate_config_cache()` methods executed?
|
||||
3. Does `async_request_refresh()` trigger?
|
||||
|
|
@ -415,7 +449,9 @@ Options Update
|
|||
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
|
||||
|
||||
### Symptom: Period calculation not updating
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Verify hash changes when data changes: `_compute_periods_hash()`
|
||||
2. Check `_last_periods_hash` vs `current_hash`
|
||||
3. Look for "Using cached period calculation" vs "Calculating periods" logs
|
||||
|
|
@ -423,7 +459,9 @@ Options Update
|
|||
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
|
||||
|
||||
### Symptom: Yesterday's prices shown as today
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `is_cache_valid()` logic in `coordinator/cache.py`
|
||||
2. Midnight turnover execution (Timer #2)
|
||||
3. Cache clear confirmation in logs
|
||||
|
|
@ -431,7 +469,9 @@ Options Update
|
|||
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
|
||||
|
||||
### Symptom: Missing translations
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `async_load_translations()` called at startup?
|
||||
2. Translation files exist in `/translations/` and `/custom_translations/`?
|
||||
3. Cache population: `_TRANSLATIONS_CACHE` keys
|
||||
|
|
|
|||
|
|
@ -41,12 +41,14 @@ class TimeService:
|
|||
```
|
||||
|
||||
**When prefix is required:**
|
||||
|
||||
- Public classes used across multiple modules
|
||||
- All exception classes
|
||||
- All coordinator and entity classes
|
||||
- Data classes (dataclasses, NamedTuples) used as public APIs
|
||||
|
||||
**When prefix can be omitted:**
|
||||
|
||||
- Private helper classes within a single module (prefix with `_` underscore)
|
||||
- Type aliases and callbacks (e.g., `TimeServiceCallback`)
|
||||
- Small internal NamedTuples for function returns
|
||||
|
|
@ -71,6 +73,7 @@ class DataFetcher: # Should be TibberPricesDataFetcher
|
|||
**Current Technical Debt:**
|
||||
|
||||
Many existing classes lack the `TibberPrices` prefix. Before refactoring:
|
||||
|
||||
1. Document the plan in `/planning/class-naming-refactoring.md`
|
||||
2. Use `multi_replace_string_in_file` for bulk renames
|
||||
3. Test thoroughly after each module
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ git checkout -b fix/issue-123-description
|
|||
```
|
||||
|
||||
**Branch naming:**
|
||||
|
||||
- `feature/` - New features
|
||||
- `fix/` - Bug fixes
|
||||
- `docs/` - Documentation only
|
||||
|
|
@ -45,6 +46,7 @@ git checkout -b fix/issue-123-description
|
|||
Edit code, following [Coding Guidelines](coding-guidelines.md).
|
||||
|
||||
**Run checks frequently:**
|
||||
|
||||
```bash
|
||||
./scripts/type-check # Pyright type checking
|
||||
./scripts/lint # Ruff linting (auto-fix)
|
||||
|
|
@ -78,6 +80,7 @@ async def test_your_feature(hass, coordinator):
|
|||
```
|
||||
|
||||
Run your test:
|
||||
|
||||
```bash
|
||||
./scripts/test tests/test_your_feature.py -v
|
||||
```
|
||||
|
|
@ -97,6 +100,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
```
|
||||
|
||||
**Commit types:**
|
||||
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation
|
||||
|
|
@ -105,6 +109,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
- `chore:` - Maintenance
|
||||
|
||||
**Add scope when relevant:**
|
||||
|
||||
- `feat(sensors):` - Sensor platform
|
||||
- `fix(coordinator):` - Data coordinator
|
||||
- `docs(user):` - User documentation
|
||||
|
|
@ -124,32 +129,40 @@ Then open Pull Request on GitHub.
|
|||
Title: Short, descriptive (50 chars max)
|
||||
|
||||
Description should include:
|
||||
|
||||
```markdown
|
||||
## What
|
||||
|
||||
Brief description of changes
|
||||
|
||||
## Why
|
||||
|
||||
Problem being solved or feature rationale
|
||||
|
||||
## How
|
||||
|
||||
Implementation approach
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Manual testing in Home Assistant
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] Type checking passes
|
||||
- [ ] Linting passes
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
(If any - describe migration path)
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
### PR Checklist
|
||||
|
||||
Before submitting:
|
||||
|
||||
- [ ] Code follows [Coding Guidelines](coding-guidelines.md)
|
||||
- [ ] All tests pass (`./scripts/test`)
|
||||
- [ ] Type checking passes (`./scripts/type-check`)
|
||||
|
|
@ -170,6 +183,7 @@ Before submitting:
|
|||
### What Reviewers Look For
|
||||
|
||||
✅ **Good:**
|
||||
|
||||
- Clear, self-explanatory code
|
||||
- Appropriate comments for complex logic
|
||||
- Tests covering edge cases
|
||||
|
|
@ -177,6 +191,7 @@ Before submitting:
|
|||
- Follows existing patterns
|
||||
|
||||
❌ **Avoid:**
|
||||
|
||||
- Large PRs (>500 lines) - split into smaller ones
|
||||
- Mixing unrelated changes
|
||||
- Missing tests for new features
|
||||
|
|
@ -193,6 +208,7 @@ Before submitting:
|
|||
## Finding Issues to Work On
|
||||
|
||||
Good first issues are labeled:
|
||||
|
||||
- `good first issue` - Beginner-friendly
|
||||
- `help wanted` - Maintainers welcome contributions
|
||||
- `documentation` - Docs improvements
|
||||
|
|
@ -210,6 +226,7 @@ Be respectful, constructive, and patient. We're all volunteers! 🙏
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Setup Guide](setup.md) - DevContainer setup
|
||||
- [Coding Guidelines](coding-guidelines.md) - Style guide
|
||||
- [Testing](testing.md) - Writing tests
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ comments: false
|
|||
## 🎯 Why Are These Tests Critical?
|
||||
|
||||
Home Assistant integrations run **continuously** in the background. Resource leaks lead to:
|
||||
|
||||
- **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable
|
||||
- **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases
|
||||
- **Timer Leaks**: Timers continue running after unload → unnecessary background tasks
|
||||
|
|
@ -26,6 +27,7 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.1 Listener Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`)
|
||||
- Minute-update listeners are correctly removed (`async_add_minute_update_listener()`)
|
||||
- Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`)
|
||||
|
|
@ -33,11 +35,13 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
- Binary sensor cleanup removes ALL registered listeners
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Each registered listener holds references to Entity + Coordinator
|
||||
- Without cleanup: Entities are not freed by GC → Memory Leak
|
||||
- With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `async_add_time_sensitive_listener()`, `async_add_minute_update_listener()`
|
||||
- `coordinator/core.py` → `register_lifecycle_callback()`
|
||||
- `sensor/core.py` → `async_will_remove_from_hass()`
|
||||
|
|
@ -46,32 +50,38 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.2 Timer Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is cancelled and reference cleared
|
||||
- Minute timer is cancelled and reference cleared
|
||||
- Both timers are cancelled together
|
||||
- Cleanup works even when timers are `None`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Uncancelled timers continue running after integration unload
|
||||
- HA's `async_track_utc_time_change()` creates persistent callbacks
|
||||
- Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `cancel_timers()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
#### 1.3 Config Entry Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Options update listener is registered via `async_on_unload()`
|
||||
- Cleanup function is correctly passed to `async_on_unload()`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- `entry.add_update_listener()` registers permanent callback
|
||||
- Without `async_on_unload()`: Listener remains active after reload → duplicate updates
|
||||
- Pattern: `entry.async_on_unload(entry.add_update_listener(handler))`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/core.py` → `__init__()` (listener registration)
|
||||
- `__init__.py` → `async_unload_entry()`
|
||||
|
||||
|
|
@ -82,16 +92,19 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 2.1 Config Cache Invalidation
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- DataTransformer config cache is invalidated on options change
|
||||
- PeriodCalculator config + period cache is invalidated
|
||||
- Trend calculator cache is cleared on coordinator update
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Stale config → Sensors use old user settings
|
||||
- Stale period cache → Incorrect best/peak price periods
|
||||
- Stale trend cache → Outdated trend analysis
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/data_transformation.py` → `invalidate_config_cache()`
|
||||
- `coordinator/periods.py` → `invalidate_config_cache()`
|
||||
- `sensor/calculators/trend.py` → `clear_trend_cache()`
|
||||
|
|
@ -103,15 +116,18 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 3.1 Persistent Storage Removal
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Storage file is deleted on config entry removal
|
||||
- Cache is saved on shutdown (no data loss)
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Without storage removal: Old files remain after uninstallation
|
||||
- Without cache save on shutdown: Data loss on HA restart
|
||||
- Storage path: `.storage/tibber_prices.{entry_id}`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `__init__.py` → `async_remove_entry()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
|
|
@ -120,12 +136,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_timer_scheduling.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is registered with correct parameters
|
||||
- Minute timer is registered with correct parameters
|
||||
- Timers can be re-scheduled (override old timer)
|
||||
- Midnight turnover detection works correctly
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer parameters → Entities update at wrong times
|
||||
- Without timer override on re-schedule → Multiple parallel timers → Performance problem
|
||||
|
||||
|
|
@ -134,12 +152,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_sensor_timer_assignment.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys
|
||||
- All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys
|
||||
- Both lists are disjoint (no overlap)
|
||||
- Sensor and binary sensor platforms are checked
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer assignment → Sensors update at wrong times
|
||||
- Overlap → Duplicate updates → Performance problem
|
||||
|
||||
|
|
@ -150,10 +170,12 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 6. Async Task Management
|
||||
|
||||
**Current Status:** Fire-and-forget pattern for short tasks
|
||||
|
||||
- `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds)
|
||||
- `coordinator/core.py` → Cache storage (short-lived, max 100ms)
|
||||
|
||||
**Why no tests needed:**
|
||||
|
||||
- No long-running tasks (all < 2 seconds)
|
||||
- HA's event loop handles short tasks automatically
|
||||
- Task exceptions are already logged
|
||||
|
|
@ -163,6 +185,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 7. API Session Cleanup
|
||||
|
||||
**Current Status:** ✅ Correctly implemented
|
||||
|
||||
- `async_get_clientsession(hass)` is used (shared session)
|
||||
- No new sessions are created
|
||||
- HA manages session lifecycle automatically
|
||||
|
|
@ -172,6 +195,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 8. Translation Cache Memory
|
||||
|
||||
**Current Status:** ✅ Bounded cache
|
||||
|
||||
- Max ~5-10 languages × 5KB = 50KB total
|
||||
- Module-level cache without re-loading
|
||||
- Practically no memory issue
|
||||
|
|
@ -181,11 +205,13 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 9. Coordinator Data Structure Integrity
|
||||
|
||||
**Current Status:** Manually tested via `./scripts/develop`
|
||||
|
||||
- Midnight turnover works correctly (observed over several days)
|
||||
- Missing keys are handled via `.get()` with defaults
|
||||
- 80+ sensors access `coordinator.data` without errors
|
||||
|
||||
**Structure:**
|
||||
|
||||
```python
|
||||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
|
|
@ -197,6 +223,7 @@ coordinator.data = {
|
|||
### 10. Service Response Memory
|
||||
|
||||
**Current Status:** HA's response lifecycle
|
||||
|
||||
- HA automatically frees service responses after return
|
||||
- ApexCharts ~20KB response is one-time per call
|
||||
- No response accumulation in integration code
|
||||
|
|
@ -208,7 +235,7 @@ coordinator.data = {
|
|||
### ✅ Implemented Tests (41 total)
|
||||
|
||||
| Category | Status | Tests | File | Coverage |
|
||||
|----------|--------|-------|------|----------|
|
||||
| ----------------------- | ------ | ------ | --------------------------------- | ------------------- |
|
||||
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
|
||||
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
|
||||
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
|
|
@ -222,7 +249,7 @@ coordinator.data = {
|
|||
### 📋 Analyzed but Not Implemented (Nice-to-Have)
|
||||
|
||||
| Category | Status | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| ------------------------ | ------ | ---------------------------------------------------- |
|
||||
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
|
||||
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
|
||||
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
|
||||
|
|
@ -230,6 +257,7 @@ coordinator.data = {
|
|||
| Service Response Memory | 📋 | HA automatically frees service responses |
|
||||
|
||||
**Legend:**
|
||||
|
||||
- ✅ = Fully tested or pattern verified correct
|
||||
- 📋 = Analyzed, low priority for testing (no known issues)
|
||||
|
||||
|
|
@ -238,6 +266,7 @@ coordinator.data = {
|
|||
### ✅ All Critical Patterns Tested
|
||||
|
||||
All essential memory leak prevention patterns are covered by 41 tests:
|
||||
|
||||
- ✅ Listeners are correctly removed (no callback leaks)
|
||||
- ✅ Timers are cancelled (no background task leaks)
|
||||
- ✅ Config entry cleanup works (no dangling listeners)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ Restart Home Assistant to apply.
|
|||
### Key Log Messages
|
||||
|
||||
**Coordinator Updates:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator] Successfully fetched price data
|
||||
[custom_components.tibber_prices.coordinator] Cache valid, using cached data
|
||||
|
|
@ -27,6 +28,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**Period Calculation:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator.periods] Calculating BEST PRICE periods: flex=15.0%
|
||||
[custom_components.tibber_prices.coordinator.periods] Day 2024-12-06: Found 2 periods
|
||||
|
|
@ -34,6 +36,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**API Errors:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.api] API request failed: Unauthorized
|
||||
[custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s
|
||||
|
|
@ -67,6 +70,7 @@ Restart Home Assistant to apply.
|
|||
### Set Breakpoints
|
||||
|
||||
**Coordinator update:**
|
||||
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _async_update_data(self) -> dict:
|
||||
|
|
@ -75,6 +79,7 @@ async def _async_update_data(self) -> dict:
|
|||
```
|
||||
|
||||
**Period calculation:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(...) -> list[dict]:
|
||||
|
|
@ -91,6 +96,7 @@ def calculate_periods(...) -> list[dict]:
|
|||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `-v` - Verbose output
|
||||
- `-s` - Show print statements
|
||||
- `-k pattern` - Run tests matching pattern
|
||||
|
|
@ -102,6 +108,7 @@ Set breakpoint in test file, use "Debug Test" CodeLens.
|
|||
### Useful Test Patterns
|
||||
|
||||
**Print coordinator data:**
|
||||
|
||||
```python
|
||||
def test_something(coordinator):
|
||||
print(f"Coordinator data: {coordinator.data}")
|
||||
|
|
@ -109,6 +116,7 @@ def test_something(coordinator):
|
|||
```
|
||||
|
||||
**Inspect period attributes:**
|
||||
|
||||
```python
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
|
|
@ -122,11 +130,13 @@ def test_periods(hass, coordinator):
|
|||
### Integration Not Loading
|
||||
|
||||
**Check:**
|
||||
|
||||
```bash
|
||||
grep "tibber_prices" config/home-assistant.log
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
|
||||
- Syntax error in Python code → Check logs for traceback
|
||||
- Missing dependency → Run `uv sync`
|
||||
- Wrong file permissions → `chmod +x scripts/*`
|
||||
|
|
@ -134,12 +144,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
### Sensors Not Updating
|
||||
|
||||
**Check coordinator state:**
|
||||
|
||||
```python
|
||||
# In Developer Tools > Template
|
||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
||||
```
|
||||
|
||||
**Debug in code:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
|
|
@ -149,6 +161,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
### Period Calculation Wrong
|
||||
|
||||
**Enable detailed period logs:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
|
|
@ -156,6 +169,7 @@ _LOGGER.debug("Candidate intervals: %s",
|
|||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
||||
```
|
||||
[period_building] Flex filter blocked: 45 intervals
|
||||
[period_building] Min distance blocked: 12 intervals
|
||||
|
|
@ -200,6 +214,7 @@ python -m pstats profile.stats
|
|||
### Remote Debugging with debugpy
|
||||
|
||||
Add to coordinator code:
|
||||
|
||||
```python
|
||||
import debugpy
|
||||
debugpy.listen(5678)
|
||||
|
|
@ -212,11 +227,13 @@ Connect from VS Code with remote attach configuration.
|
|||
### IPython REPL
|
||||
|
||||
Install in container:
|
||||
|
||||
```bash
|
||||
uv pip install ipython
|
||||
```
|
||||
|
||||
Add breakpoint:
|
||||
|
||||
```python
|
||||
from IPython import embed
|
||||
embed() # Drops into interactive shell
|
||||
|
|
@ -225,6 +242,7 @@ embed() # Drops into interactive shell
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Testing Guide](testing.md) - Writing and running tests
|
||||
- [Setup Guide](setup.md) - Development environment
|
||||
- [Architecture](architecture.md) - Code structure
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ Documentation is organized in two Docusaurus sites:
|
|||
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
|
||||
|
||||
**Best practices:**
|
||||
|
||||
- Use clear examples and code snippets
|
||||
- Keep docs up-to-date with code changes
|
||||
- Add new pages to appropriate `sidebars.ts` for navigation
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Guidelines for maintaining and improving integration performance.
|
|||
## Performance Goals
|
||||
|
||||
Target metrics:
|
||||
|
||||
- **Coordinator update**: <500ms (typical: 200-300ms)
|
||||
- **Sensor update**: <10ms per sensor
|
||||
- **Period calculation**: <100ms (typical: 20-50ms)
|
||||
|
|
@ -64,6 +65,7 @@ python -m aioprof homeassistant -c config
|
|||
### Caching
|
||||
|
||||
**1. Persistent Cache** (API data):
|
||||
|
||||
```python
|
||||
# Already implemented in coordinator/cache.py
|
||||
store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
|
|
@ -71,6 +73,7 @@ data = await store.async_load()
|
|||
```
|
||||
|
||||
**2. Translation Cache** (in-memory):
|
||||
|
||||
```python
|
||||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
|
@ -83,6 +86,7 @@ def get_translation(path: str, language: str) -> dict:
|
|||
```
|
||||
|
||||
**3. Config Cache** (invalidated on options change):
|
||||
|
||||
```python
|
||||
class DataTransformer:
|
||||
def __init__(self):
|
||||
|
|
@ -100,6 +104,7 @@ class DataTransformer:
|
|||
### Lazy Loading
|
||||
|
||||
**Load data only when needed:**
|
||||
|
||||
```python
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict | None:
|
||||
|
|
@ -113,6 +118,7 @@ def extra_state_attributes(self) -> dict | None:
|
|||
### Bulk Operations
|
||||
|
||||
**Process multiple items at once:**
|
||||
|
||||
```python
|
||||
# ❌ Slow - loop with individual operations
|
||||
for interval in intervals:
|
||||
|
|
@ -126,6 +132,7 @@ results = enrich_intervals_bulk(intervals)
|
|||
### Async Best Practices
|
||||
|
||||
**1. Concurrent API calls:**
|
||||
|
||||
```python
|
||||
# ❌ Sequential (slow)
|
||||
user_data = await fetch_user_data()
|
||||
|
|
@ -139,6 +146,7 @@ user_data, price_data = await asyncio.gather(
|
|||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
||||
```python
|
||||
# ❌ Blocking
|
||||
result = heavy_computation() # Blocks for seconds
|
||||
|
|
@ -152,6 +160,7 @@ result = await hass.async_add_executor_job(heavy_computation)
|
|||
### Avoid Memory Leaks
|
||||
|
||||
**1. Clear references:**
|
||||
|
||||
```python
|
||||
class Coordinator:
|
||||
async def async_shutdown(self):
|
||||
|
|
@ -162,6 +171,7 @@ class Coordinator:
|
|||
```
|
||||
|
||||
**2. Use weak references for callbacks:**
|
||||
|
||||
```python
|
||||
import weakref
|
||||
|
||||
|
|
@ -176,6 +186,7 @@ class Manager:
|
|||
### Efficient Data Structures
|
||||
|
||||
**Use appropriate types:**
|
||||
|
||||
```python
|
||||
# ❌ List for lookups (O(n))
|
||||
if timestamp in timestamp_list:
|
||||
|
|
@ -197,11 +208,13 @@ results = (x for x in items if condition(x))
|
|||
### Minimize API Calls
|
||||
|
||||
**Already implemented:**
|
||||
|
||||
- Cache valid until midnight
|
||||
- User data cached for 24h
|
||||
- Only poll when tomorrow data expected
|
||||
|
||||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
|
|
@ -210,6 +223,7 @@ _LOGGER.debug("API call: %s (cache_age=%s)",
|
|||
### Smart Updates
|
||||
|
||||
**Only update when needed:**
|
||||
|
||||
```python
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch data from API."""
|
||||
|
|
@ -226,6 +240,7 @@ async def _async_update_data(self) -> dict:
|
|||
### State Class Selection
|
||||
|
||||
**Affects long-term statistics storage:**
|
||||
|
||||
```python
|
||||
# ❌ MEASUREMENT for prices (stores every change)
|
||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
||||
|
|
@ -240,6 +255,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
### Attribute Size
|
||||
|
||||
**Keep attributes minimal:**
|
||||
|
||||
```python
|
||||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
|
|
@ -317,6 +333,7 @@ _LOGGER.debug("Current memory usage: %.2f MB", memory_mb)
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Caching Strategy](caching-strategy.md) - Cache layers
|
||||
- [Architecture](architecture.md) - System design
|
||||
- [Debugging](debugging.md) - Profiling tools
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ This document explains the mathematical foundations and design decisions behind
|
|||
**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
|
||||
|
|
@ -23,6 +24,7 @@ Period detection uses **three independent filters** (all must pass):
|
|||
**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)
|
||||
|
|
@ -32,6 +34,7 @@ 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)
|
||||
|
|
@ -41,6 +44,7 @@ in_flex = price >= (daily_max - daily_max × flex)
|
|||
**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))
|
||||
|
|
@ -50,6 +54,7 @@ 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)
|
||||
|
|
@ -86,6 +91,7 @@ The integration maintains **two independent sets** of volatility thresholds:
|
|||
- 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)
|
||||
|
|
@ -107,21 +113,25 @@ period_low_threshold = PRICE_LEVEL_THRESHOLDS["volatility_low"] # Always 10%
|
|||
#### 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)
|
||||
|
|
@ -132,11 +142,13 @@ Max accepted = 15 × (1 - 0.05) = 14.25 ct/kWh
|
|||
### 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%)
|
||||
|
|
@ -149,6 +161,7 @@ daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
|
|||
**Approach:** Reduce Min_Distance proportionally as Flex increases.
|
||||
|
||||
**Formula:**
|
||||
|
||||
```python
|
||||
if flex > 0.20: # 20% threshold
|
||||
flex_excess = flex - 0.20
|
||||
|
|
@ -159,7 +172,7 @@ if flex > 0.20: # 20% threshold
|
|||
**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 |
|
||||
|
|
@ -167,6 +180,7 @@ if flex > 0.20: # 20% threshold
|
|||
| 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"
|
||||
|
|
@ -174,6 +188,7 @@ if flex > 0.20: # 20% threshold
|
|||
**Implementation:** See `level_filtering.py` → `check_interval_criteria()`
|
||||
|
||||
**Code Extract:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/level_filtering.py
|
||||
|
||||
|
|
@ -209,12 +224,14 @@ def check_interval_criteria(price, criteria):
|
|||
```
|
||||
|
||||
**Why Linear Scaling?**
|
||||
|
||||
- Simple and predictable
|
||||
- No abrupt behavior changes
|
||||
- Easy to reason about for users and developers
|
||||
- Alternative considered: Exponential scaling (rejected as too aggressive)
|
||||
|
||||
**Why 25% Minimum?**
|
||||
|
||||
- Below this, min_distance loses semantic meaning
|
||||
- Even on flat days, some quality filter needed
|
||||
- Prevents "every interval is a period" scenario
|
||||
|
|
@ -227,12 +244,14 @@ def check_interval_criteria(price, criteria):
|
|||
### Implementation Constants
|
||||
|
||||
**Defined in `coordinator/period_handlers/core.py`:**
|
||||
|
||||
```python
|
||||
MAX_SAFE_FLEX = 0.50 # 50% - hard cap: above this, period detection becomes unreliable
|
||||
MAX_OUTLIER_FLEX = 0.25 # 25% - cap for outlier filtering: above this, spike detection too permissive
|
||||
```
|
||||
|
||||
**Defined in `const.py`:**
|
||||
|
||||
```python
|
||||
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode (default enabled)
|
||||
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection)
|
||||
|
|
@ -255,16 +274,19 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Goal:** Find practical time windows for running appliances
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Appliances need time to complete cycles (dishwasher: 2-3h, EV charging: 4-8h)
|
||||
- Short periods are impractical (not worth automation overhead)
|
||||
- User wants genuinely cheap times, not just "slightly below average"
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- **60 min minimum** - Ensures period is long enough for meaningful use
|
||||
- **15% flex** - Stricter selection, focuses on truly cheap times
|
||||
- **Reasoning:** Better to find fewer, higher-quality periods than many mediocre ones
|
||||
|
||||
**User behavior:**
|
||||
|
||||
- Automations trigger actions (turn on devices)
|
||||
- Wrong automation = wasted energy/money
|
||||
- Preference: Conservative (miss some savings) over aggressive (false positives)
|
||||
|
|
@ -274,16 +296,19 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Goal:** Alert users to expensive periods for consumption reduction
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Brief price spikes still matter (even 15-30 min is worth avoiding)
|
||||
- Early warning more valuable than perfect accuracy
|
||||
- User can manually decide whether to react
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- **30 min minimum** - Catches shorter expensive spikes
|
||||
- **20% flex** - More permissive, earlier detection
|
||||
- **Reasoning:** Better to warn early (even if not peak) than miss expensive periods
|
||||
|
||||
**User behavior:**
|
||||
|
||||
- Notifications/alerts (informational)
|
||||
- Wrong alert = minor inconvenience, not cost
|
||||
- Preference: Sensitive (catch more) over specific (catch only extremes)
|
||||
|
|
@ -293,17 +318,20 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Peak Price Volatility:**
|
||||
|
||||
Price curves tend to have:
|
||||
|
||||
- **Sharp spikes** during peak hours (morning/evening)
|
||||
- **Shorter duration** at maximum (1-2 hours typical)
|
||||
- **Higher variance** in peak times than cheap times
|
||||
|
||||
**Example day:**
|
||||
|
||||
```
|
||||
Cheap period: 02:00-07:00 (5 hours at 10-12 ct) ← Gradual, stable
|
||||
Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
||||
```
|
||||
|
||||
**Implication:**
|
||||
|
||||
- Stricter flex on peak (15%) might miss real expensive periods (too brief)
|
||||
- Longer min_length (60 min) might exclude legitimate spikes
|
||||
- Solution: More flexible thresholds for peak detection
|
||||
|
|
@ -311,16 +339,19 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
|||
#### Design Alternatives Considered
|
||||
|
||||
**Option 1: Symmetric defaults (rejected)**
|
||||
|
||||
- Both 60 min, both 15% flex
|
||||
- Problem: Misses short but expensive spikes
|
||||
- User feedback: "Why didn't I get warned about the 30-min price spike?"
|
||||
|
||||
**Option 2: Same defaults, let users figure it out (rejected)**
|
||||
|
||||
- No guidance on best practices
|
||||
- Users would need to experiment to find good values
|
||||
- Most users stick with defaults, so defaults matter
|
||||
|
||||
**Option 3: Current approach (adopted)**
|
||||
|
||||
- **All values user-configurable** via config flow options
|
||||
- **Different installation defaults** for Best Price vs. Peak Price
|
||||
- Defaults reflect recommended practices for each use case
|
||||
|
|
@ -336,12 +367,14 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
|||
**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.
|
||||
|
|
@ -352,6 +385,7 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
|
|||
**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
|
||||
|
|
@ -363,23 +397,28 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
|
|||
#### 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)
|
||||
|
||||
---
|
||||
|
|
@ -395,6 +434,7 @@ Ensure **minimum periods per day** are found even when baseline filters are too
|
|||
### 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
|
||||
|
|
@ -418,6 +458,7 @@ for attempt in range(max_relaxation_attempts):
|
|||
```
|
||||
|
||||
**Constants:**
|
||||
|
||||
```python
|
||||
FLEX_WARNING_THRESHOLD_RELAXATION = 0.25 # 25% - INFO: suggest lowering to 15-20%
|
||||
FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: very high for relaxation mode
|
||||
|
|
@ -447,6 +488,7 @@ MAX_FLEX_HARD_LIMIT = 0.50 # 50% - absolute maximum (enforced in core.py)
|
|||
**Historical Context (Pre-November 2025):**
|
||||
|
||||
The algorithm previously used percentage-based increments that scaled with base flex:
|
||||
|
||||
```python
|
||||
increment = base_flex × (step_pct / 100) # REMOVED
|
||||
```
|
||||
|
|
@ -454,6 +496,7 @@ increment = base_flex × (step_pct / 100) # REMOVED
|
|||
This caused exponential escalation with high base flex values (e.g., 40% → 50% → 60% → 70% in just 6 steps), making behavior unpredictable. The fixed 3% increment solves this by providing consistent, controlled escalation regardless of starting point.
|
||||
|
||||
**Warning Messages:**
|
||||
|
||||
```python
|
||||
if base_flex >= FLEX_HIGH_THRESHOLD_RELAXATION: # 30%
|
||||
_LOGGER.warning(
|
||||
|
|
@ -472,12 +515,14 @@ elif base_flex >= FLEX_WARNING_THRESHOLD_RELAXATION: # 25%
|
|||
### 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)
|
||||
|
|
@ -492,6 +537,7 @@ Day 2025-11-19:
|
|||
### Key Files and Functions
|
||||
|
||||
**Period Calculation Entry Point:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(
|
||||
|
|
@ -502,6 +548,7 @@ def calculate_periods(
|
|||
```
|
||||
|
||||
**Flex + Distance Filtering:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/level_filtering.py
|
||||
def check_interval_criteria(
|
||||
|
|
@ -511,6 +558,7 @@ def check_interval_criteria(
|
|||
```
|
||||
|
||||
**Relaxation Orchestration:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/relaxation.py
|
||||
def calculate_periods_with_relaxation(...) -> tuple[dict, dict]
|
||||
|
|
@ -541,6 +589,7 @@ def relax_single_day(...) -> tuple[dict, dict]
|
|||
- Rejects asymmetric outliers (threshold: 1.5 std dev)
|
||||
- Preserves legitimate price shifts (morning/evening peaks)
|
||||
- Algorithm:
|
||||
|
||||
```python
|
||||
residual = abs(actual - predicted)
|
||||
symmetry_threshold = 1.5 × std_dev
|
||||
|
|
@ -563,6 +612,7 @@ def relax_single_day(...) -> tuple[dict, dict]
|
|||
- Catches patterns like: 18, 35, 19, 34, 18 (alternating spikes)
|
||||
|
||||
**Constants:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/outlier_filtering.py
|
||||
|
||||
|
|
@ -573,18 +623,21 @@ MIN_CONTEXT_SIZE = 3 # Minimum intervals for regression
|
|||
```
|
||||
|
||||
**Data Integrity:**
|
||||
|
||||
- Original prices stored in `_original_price` field
|
||||
- All statistics (daily min/max/avg) use original prices
|
||||
- Smoothing only affects period formation logic
|
||||
- Smart counting: Only counts smoothing that changed period outcome
|
||||
|
||||
**Performance:**
|
||||
|
||||
- Single pass through price data
|
||||
- O(n) complexity with small context window
|
||||
- No iterative refinement needed
|
||||
- Typical processing time: `<`1ms for 96 intervals
|
||||
|
||||
**Example Debug Output:**
|
||||
|
||||
```
|
||||
DEBUG: [2025-11-11T14:30:00+01:00] Outlier detected: 35.2 ct
|
||||
DEBUG: Context: 18.5, 19.1, 19.3, 19.8, 20.2 ct
|
||||
|
|
@ -624,6 +677,7 @@ DEBUG: Asymmetry ratio: 3.2 (>1.5 threshold) → confirmed outlier
|
|||
## Debugging Tips
|
||||
|
||||
**Enable DEBUG logging:**
|
||||
|
||||
```yaml
|
||||
# configuration.yaml
|
||||
logger:
|
||||
|
|
@ -633,6 +687,7 @@ logger:
|
|||
```
|
||||
|
||||
**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
|
||||
|
|
@ -645,17 +700,20 @@ logger:
|
|||
### ❌ 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
|
||||
|
|
@ -664,16 +722,19 @@ 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 # Use default 5%
|
||||
```
|
||||
|
|
@ -681,16 +742,19 @@ best_price_min_distance_from_avg: 5 # Use default 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
|
||||
|
|
@ -706,11 +770,13 @@ best_price_min_distance_from_avg: 5
|
|||
**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
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Filter statistics: 96 intervals checked
|
||||
DEBUG: Filtered by FLEX: 12/96 (12.5%) ← Low percentage = good variation
|
||||
|
|
@ -724,11 +790,13 @@ DEBUG: After build_periods: 3 raw periods found
|
|||
**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"
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Filter statistics: 96 intervals checked
|
||||
DEBUG: Filtered by FLEX: 45/96 (46.9%) ← High percentage = poor variation
|
||||
|
|
@ -743,11 +811,13 @@ DEBUG: Day 2025-11-11: Baseline insufficient (1 < 2), starting relaxation
|
|||
**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)
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Outlier detected: 38.5 ct (threshold: 4.2 ct)
|
||||
DEBUG: Smoothed to: 20.1 ct (trend prediction)
|
||||
|
|
@ -762,6 +832,7 @@ DEBUG: After build_periods: 4 raw periods found
|
|||
**Initial State:** Baseline finds 1 period, target is 2
|
||||
|
||||
**Expected Flow:**
|
||||
|
||||
```
|
||||
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
|
||||
DEBUG: Day 2025-11-11: Baseline found 1 period (need 2)
|
||||
|
|
@ -777,6 +848,7 @@ INFO: Day 2025-11-11: Success after 1 relaxation phase (2 periods)
|
|||
**Initial State:** Strict filters, very flat day
|
||||
|
||||
**Expected Flow:**
|
||||
|
||||
```
|
||||
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
|
||||
DEBUG: Day 2025-11-11: Baseline found 0 periods (need 2)
|
||||
|
|
@ -854,6 +926,7 @@ When debugging period calculation issues:
|
|||
**Concept:** Auto-adjust Flex based on daily price variation
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
```python
|
||||
# Pseudo-code for adaptive flex
|
||||
variation = (daily_max - daily_min) / daily_avg
|
||||
|
|
@ -867,11 +940,13 @@ else: # Normal day
|
|||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Eliminates need for relaxation on most days
|
||||
- Self-adjusting to market conditions
|
||||
- Better user experience (less configuration needed)
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Harder to predict behavior (less transparent)
|
||||
- May conflict with user's mental model
|
||||
- Needs extensive testing across different markets
|
||||
|
|
@ -883,17 +958,20 @@ else: # Normal day
|
|||
**Concept:** Learn optimal Flex/Distance from user feedback
|
||||
|
||||
**Approach:**
|
||||
|
||||
- Track which periods user actually uses (automation triggers)
|
||||
- Classify days by pattern (normal/flat/volatile/bimodal)
|
||||
- Apply pattern-specific defaults
|
||||
- Learn per-user preferences over time
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Personalized to user's actual behavior
|
||||
- Adapts to local market patterns
|
||||
- Could discover non-obvious patterns
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Requires user feedback mechanism (not implemented)
|
||||
- Privacy concerns (storing usage patterns)
|
||||
- Complexity for users to understand "why this period?"
|
||||
|
|
@ -906,22 +984,26 @@ else: # Normal day
|
|||
**Concept:** Balance multiple goals simultaneously
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Period count vs. quality (cheap vs. very cheap)
|
||||
- Period duration vs. price level (long mediocre vs. short excellent)
|
||||
- Temporal distribution (spread throughout day vs. clustered)
|
||||
- User's stated use case (EV charging vs. heat pump vs. dishwasher)
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
- Pareto optimization (find trade-off frontier)
|
||||
- User chooses point on frontier via preferences
|
||||
- Genetic algorithm or simulated annealing
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- More sophisticated period selection
|
||||
- Better match to user's actual needs
|
||||
- Could handle complex appliance requirements
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Much more complex to implement
|
||||
- Harder to explain to users
|
||||
- Computational cost (may need caching)
|
||||
|
|
@ -936,14 +1018,17 @@ else: # Normal day
|
|||
**Current:** 3% cap may be too aggressive for very low base Flex
|
||||
|
||||
**Example:**
|
||||
|
||||
- Base flex 5% + 3% increment = 8% (60% increase!)
|
||||
- Base flex 15% + 3% increment = 18% (20% increase)
|
||||
|
||||
**Possible Solution:**
|
||||
|
||||
- Percentage-based increment: `increment = max(base_flex × 0.20, 0.03)`
|
||||
- This gives: 5% → 6% (20%), 15% → 18% (20%), 40% → 43% (7.5%)
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Very low base flex (`<`10%) unusual
|
||||
- Users with strict requirements likely disable relaxation
|
||||
- Simplicity preferred over edge case optimization
|
||||
|
|
@ -953,6 +1038,7 @@ else: # Normal day
|
|||
**Current:** Linear scaling may be too aggressive/conservative
|
||||
|
||||
**Alternative:** Non-linear curve
|
||||
|
||||
```python
|
||||
# Example: Exponential scaling
|
||||
scale_factor = 0.25 + 0.75 × exp(-5 × (flex - 0.20))
|
||||
|
|
@ -962,6 +1048,7 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
```
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Linear is easier to reason about
|
||||
- No evidence that non-linear is better
|
||||
- Would need extensive testing
|
||||
|
|
@ -971,15 +1058,18 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
**Issue:** May find all periods in one part of day
|
||||
|
||||
**Example:**
|
||||
|
||||
- All 3 "best price" periods between 02:00-08:00
|
||||
- No periods in evening (when user might want to run appliances)
|
||||
|
||||
**Possible Solution:**
|
||||
|
||||
- Add "spread" parameter (prefer distributed periods)
|
||||
- Weight periods by time-of-day preferences
|
||||
- Consider user's typical usage patterns
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Adds complexity
|
||||
- Users can work around with multiple automations
|
||||
- Different users have different needs (no one-size-fits-all)
|
||||
|
|
@ -991,6 +1081,7 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
**Design Principle:** Each interval is evaluated using its **own day's** reference prices (daily min/max/avg).
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In period_building.py build_periods():
|
||||
for price_data in all_prices:
|
||||
|
|
@ -1042,6 +1133,7 @@ Period crossing midnight: 23:45 Day 1 → 00:15 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)
|
||||
|
|
@ -1053,6 +1145,7 @@ This is **mathematically correct** - 21ct is genuinely expensive on a day where
|
|||
**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
|
||||
|
||||
|
|
@ -1061,10 +1154,12 @@ This explains why absolute prices jump at midnight despite minimal demand change
|
|||
**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
|
||||
|
|
@ -1095,6 +1190,7 @@ Low volatility (< 15%) means classification changes are less economically signif
|
|||
**Status:** Per-day evaluation is intentional design prioritizing mathematical correctness.
|
||||
|
||||
**See Also:**
|
||||
|
||||
- User documentation: `docs/user/docs/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)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- Must be a **class attribute** (not instance attribute)
|
||||
- Use `frozenset` for immutability and performance
|
||||
- Applied automatically by Home Assistant's Recorder component
|
||||
|
|
@ -40,6 +41,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `description`, `usage_tips`
|
||||
|
||||
**Reason:** Static, large text strings (100-500 chars each) that:
|
||||
|
||||
- Never change or change very rarely
|
||||
- Don't provide analytical value in history
|
||||
- Consume significant database space when recorded every state change
|
||||
|
|
@ -50,6 +52,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
### 2. Large Nested Structures
|
||||
|
||||
**Attributes:**
|
||||
|
||||
- `periods` (binary_sensor) - Array of all period summaries
|
||||
- `data` (chart_data_export) - Complete price data arrays
|
||||
- `trend_attributes` - Detailed trend analysis
|
||||
|
|
@ -58,6 +61,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
- `volatility_attributes` - Detailed volatility breakdown
|
||||
|
||||
**Reason:** Complex nested data structures that are:
|
||||
|
||||
- Serialized to JSON for storage (expensive)
|
||||
- Create large database rows (2-20 KB each)
|
||||
- Slow down history queries
|
||||
|
|
@ -66,6 +70,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Impact:** ~10-30 KB saved per state change for affected sensors
|
||||
|
||||
**Example - periods array:**
|
||||
|
||||
```json
|
||||
{
|
||||
"periods": [
|
||||
|
|
@ -76,7 +81,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
"price_mean": 18.5,
|
||||
"price_median": 18.3,
|
||||
"price_min": 17.2,
|
||||
"price_max": 19.8,
|
||||
"price_max": 19.8
|
||||
// ... 10+ more attributes × 10-20 periods
|
||||
}
|
||||
]
|
||||
|
|
@ -88,6 +93,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Change every update cycle (every 15 minutes or more frequently)
|
||||
- Don't provide long-term analytical value
|
||||
- Create state changes even when core values haven't changed
|
||||
|
|
@ -103,6 +109,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Configuration values that rarely change
|
||||
- Wastes space when recorded repeatedly
|
||||
- Can be derived from other attributes or from entity state
|
||||
|
|
@ -114,6 +121,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Only relevant at moment of reading
|
||||
- Won't be valid after some time
|
||||
- Similar to `entity_picture` in HA core image entities
|
||||
|
|
@ -128,6 +136,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Detailed technical information not needed for historical analysis
|
||||
- Only useful for debugging during active development
|
||||
- Boolean `relaxation_active` is kept for high-level analysis
|
||||
|
|
@ -139,6 +148,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `periods_total`, `periods_remaining`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Can be calculated from other attributes
|
||||
- Redundant information
|
||||
- Doesn't add analytical value to history
|
||||
|
|
@ -152,22 +162,27 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
These attributes **remain in history** because they provide essential analytical value:
|
||||
|
||||
### Time-Series Core
|
||||
|
||||
- `timestamp` - Critical for time-series analysis (ALWAYS FIRST)
|
||||
- All price values - Core sensor states
|
||||
|
||||
### Diagnostics & Tracking
|
||||
|
||||
- `cache_age_minutes` - Numeric value for diagnostics tracking over time
|
||||
- `updates_today` - Tracking API usage patterns
|
||||
|
||||
### Data Completeness
|
||||
|
||||
- `interval_count`, `intervals_available` - Data completeness metrics
|
||||
- `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status
|
||||
|
||||
### Period Data
|
||||
|
||||
- `start`, `end`, `duration_minutes` - Core period timing
|
||||
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
|
||||
|
||||
### High-Level Status
|
||||
|
||||
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
|
||||
|
||||
## Expected Database Impact
|
||||
|
|
@ -175,6 +190,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Space Savings
|
||||
|
||||
**Per state change:**
|
||||
|
||||
- Before: ~3-8 KB average
|
||||
- After: ~0.5-1.5 KB average
|
||||
- **Reduction: 60-85%**
|
||||
|
|
@ -196,6 +212,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Real-World Impact
|
||||
|
||||
For a typical installation with:
|
||||
|
||||
- 80+ sensors
|
||||
- Updates every 15 minutes
|
||||
- ~10 sensors updating every minute
|
||||
|
|
@ -214,7 +231,7 @@ For a typical installation with:
|
|||
- Class: `TibberPricesBinarySensor`
|
||||
- 30 attributes excluded
|
||||
|
||||
## When to Update _unrecorded_attributes
|
||||
## When to Update \_unrecorded_attributes
|
||||
|
||||
### Add to Exclusion List When:
|
||||
|
||||
|
|
@ -265,6 +282,7 @@ After modifying `_unrecorded_attributes`:
|
|||
4. **Confirm excluded attributes** don't appear in new state writes
|
||||
|
||||
**SQL Query to check attribute presence:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
state_id,
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ In CI/CD (`$CI` or `$GITHUB_ACTIONS`), AI is automatically disabled.
|
|||
**In DevContainer (automatic):**
|
||||
|
||||
git-cliff is automatically installed when the DevContainer is built:
|
||||
|
||||
- **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile)
|
||||
- **git-cliff**: Installed via cargo in `scripts/setup/setup`
|
||||
|
||||
|
|
@ -120,6 +121,7 @@ Simply rebuild the container (VS Code: "Dev Containers: Rebuild Container") and
|
|||
**Manual installation (outside DevContainer):**
|
||||
|
||||
**git-cliff** (template-based):
|
||||
|
||||
```bash
|
||||
# See: https://git-cliff.org/docs/installation
|
||||
|
||||
|
|
@ -191,7 +193,7 @@ All methods produce GitHub-flavored Markdown with emoji categories:
|
|||
## 🎯 When to Use Which
|
||||
|
||||
| Method | Use Case | Pros | Cons |
|
||||
|--------|----------|------|------|
|
||||
| --------------------- | --------------------- | ----------------------------- | ------------------------ |
|
||||
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
|
||||
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
|
||||
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
|
||||
|
|
@ -219,6 +221,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. Script bumps manifest.json → commits → creates tag locally
|
||||
2. You push commit + tag together
|
||||
3. Release workflow sees tag → generates notes → creates release
|
||||
|
|
@ -242,6 +245,7 @@ git push
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You push manifest.json change
|
||||
2. Auto-Tag workflow detects change → creates tag automatically
|
||||
3. Release workflow sees new tag → creates release
|
||||
|
|
@ -263,6 +267,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You create and push tag manually
|
||||
2. Release workflow creates release
|
||||
3. Auto-Tag workflow skips (tag already exists)
|
||||
|
|
@ -282,19 +287,24 @@ git push origin main v0.3.0
|
|||
## 🛡️ Safety Features
|
||||
|
||||
### 1. **Version Validation**
|
||||
|
||||
Both helper script and auto-tag workflow validate version format (X.Y.Z).
|
||||
|
||||
### 2. **No Duplicate Tags**
|
||||
|
||||
- Helper script checks if tag exists (local + remote)
|
||||
- Auto-tag workflow checks if tag exists before creating
|
||||
|
||||
### 3. **Atomic Operations**
|
||||
|
||||
Helper script creates commit + tag locally. You decide when to push.
|
||||
|
||||
### 4. **Version Bumps Filtered**
|
||||
|
||||
Release notes automatically exclude `chore(release): bump version` commits.
|
||||
|
||||
### 5. **Rollback Instructions**
|
||||
|
||||
Helper script shows how to undo if you change your mind.
|
||||
|
||||
---
|
||||
|
|
@ -330,6 +340,7 @@ git push -f origin main v0.3.0
|
|||
**Auto-tag didn't create tag:**
|
||||
|
||||
Check workflow runs in GitHub Actions. Common causes:
|
||||
|
||||
- Tag already exists remotely
|
||||
- Invalid version format in manifest.json
|
||||
- manifest.json not in the commit that was pushed
|
||||
|
|
@ -348,6 +359,7 @@ Check workflow runs in GitHub Actions. Common causes:
|
|||
## 💡 Tips
|
||||
|
||||
1. **Conventional Commits:** Use proper commit format for best results:
|
||||
|
||||
```
|
||||
feat(scope): Add new feature
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ The Tibber Prices integration includes a proactive repair notification system th
|
|||
The repairs system is implemented in `coordinator/repairs.py` via the `TibberPricesRepairManager` class, which is instantiated in the coordinator and integrated into the update cycle.
|
||||
|
||||
**Design Principles:**
|
||||
|
||||
- **Proactive**: Detect issues before they become critical
|
||||
- **User-friendly**: Clear explanations with actionable guidance
|
||||
- **Auto-clearing**: Repairs automatically disappear when conditions resolve
|
||||
|
|
@ -19,10 +20,12 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
**Issue ID:** `tomorrow_data_missing_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`)
|
||||
- Tomorrow's electricity price data is still not available
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Tomorrow's data becomes available
|
||||
- Automatically checks on every successful API update
|
||||
|
||||
|
|
@ -30,6 +33,7 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
Users cannot plan ahead for tomorrow's electricity usage optimization. Automations relying on tomorrow's prices will not work.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In coordinator update cycle
|
||||
has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"])
|
||||
|
|
@ -40,6 +44,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `warning_hour`: Hour after which warning appears (default: 18)
|
||||
|
||||
|
|
@ -48,10 +53,12 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
**Issue ID:** `rate_limit_exceeded_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Integration encounters 3 or more consecutive rate limit errors (HTTP 429)
|
||||
- Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD`
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Successful API call completes (no rate limit error)
|
||||
- Error counter resets to 0
|
||||
|
||||
|
|
@ -59,6 +66,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
|
|
@ -74,6 +82,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `error_count`: Number of consecutive rate limit errors
|
||||
|
||||
|
|
@ -82,10 +91,12 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
**Issue ID:** `home_not_found_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Home configured in this integration is no longer present in Tibber account
|
||||
- Detected during user data refresh (daily check)
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Home reappears in Tibber account (unlikely - manual cleanup expected)
|
||||
- Integration entry is removed (shutdown cleanup)
|
||||
|
||||
|
|
@ -93,6 +104,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
Integration cannot fetch data for a non-existent home. User must remove the config entry and re-add if needed.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# After user data update
|
||||
home_exists = self._data_fetcher._check_home_exists(home_id)
|
||||
|
|
@ -103,6 +115,7 @@ else:
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the missing home
|
||||
- `entry_id`: Config entry ID for reference
|
||||
|
||||
|
|
@ -153,6 +166,7 @@ Each repair type maintains internal state to avoid redundant operations:
|
|||
### Lifecycle Integration
|
||||
|
||||
**Coordinator Initialization:**
|
||||
|
||||
```python
|
||||
self._repair_manager = TibberPricesRepairManager(
|
||||
hass=hass,
|
||||
|
|
@ -162,6 +176,7 @@ self._repair_manager = TibberPricesRepairManager(
|
|||
```
|
||||
|
||||
**Update Cycle Integration:**
|
||||
|
||||
```python
|
||||
# Success path - check conditions
|
||||
if result and "priceInfo" in result:
|
||||
|
|
@ -178,6 +193,7 @@ if is_rate_limit:
|
|||
```
|
||||
|
||||
**Shutdown Cleanup:**
|
||||
|
||||
```python
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shut down coordinator and clean up."""
|
||||
|
|
@ -196,6 +212,7 @@ Repairs use Home Assistant's standard translation system. Translations are defin
|
|||
- `/translations/sv.json`
|
||||
|
||||
**Structure:**
|
||||
|
||||
```json
|
||||
{
|
||||
"issues": {
|
||||
|
|
@ -210,10 +227,12 @@ Repairs use Home Assistant's standard translation system. Translations are defin
|
|||
## Home Assistant Integration
|
||||
|
||||
Repairs appear in:
|
||||
|
||||
- **Settings → System → Repairs** (main repairs panel)
|
||||
- **Notifications** (bell icon in UI shows repair count)
|
||||
|
||||
Repair properties:
|
||||
|
||||
- **`is_fixable=False`**: No automated fix available (user action required)
|
||||
- **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical)
|
||||
- **`translation_key`**: References `issues.{key}` in translation files
|
||||
|
|
@ -228,6 +247,7 @@ Repair properties:
|
|||
4. When tomorrow data arrives (next API fetch), repair clears
|
||||
|
||||
**Manual trigger:**
|
||||
|
||||
```python
|
||||
# Temporarily set warning hour to current hour for testing
|
||||
TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
||||
|
|
@ -240,6 +260,7 @@ TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
|||
3. Successful API call clears the repair
|
||||
|
||||
**Manual test:**
|
||||
|
||||
- Reduce API polling interval to trigger rate limiting
|
||||
- Or temporarily return HTTP 429 in API client
|
||||
|
||||
|
|
@ -263,6 +284,7 @@ To add a new repair type:
|
|||
7. **Document** in this file
|
||||
|
||||
**Example template:**
|
||||
|
||||
```python
|
||||
async def check_new_condition(self, *, param: bool) -> None:
|
||||
"""Check new condition and create/clear repair."""
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ This document explains the timer/scheduler system in the Tibber Prices integrati
|
|||
The integration uses **three independent timer mechanisms** for different purposes:
|
||||
|
||||
| Timer | Type | Interval | Purpose | Trigger Method |
|
||||
|-------|------|----------|---------|----------------|
|
||||
| ------------ | ----------- | ------------------ | -------------------- | ------------------------------- |
|
||||
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` |
|
||||
| **Timer #2** | Custom | :00, :15, :30, :45 | Entity state refresh | `async_track_utc_time_change()` |
|
||||
| **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
|
||||
|
|
@ -27,6 +27,7 @@ The integration uses **three independent timer mechanisms** for different purpos
|
|||
**Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes`
|
||||
|
||||
**What it is:**
|
||||
|
||||
- HA provides this timer system automatically when you inherit from `DataUpdateCoordinator`
|
||||
- Triggers `_async_update_data()` method every 15 minutes
|
||||
- **Not** synchronized to clock boundaries (each installation has different start time)
|
||||
|
|
@ -53,16 +54,19 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
```
|
||||
|
||||
**Load Distribution:**
|
||||
|
||||
- Each HA installation starts Timer #1 at different times → natural distribution
|
||||
- Tomorrow data check adds 0-30s random delay → prevents "thundering herd" on Tibber API
|
||||
- Result: API load spread over ~30 minutes instead of all at once
|
||||
|
||||
**Midnight Coordination:**
|
||||
|
||||
- Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects)
|
||||
- If midnight turnover needed → performs it and returns early
|
||||
- Timer #2 will see turnover already done and skip gracefully
|
||||
|
||||
**Why we use HA's timer:**
|
||||
|
||||
- Automatic restart after HA restart
|
||||
- Built-in retry logic for temporary failures
|
||||
- Standard HA integration pattern
|
||||
|
|
@ -79,6 +83,7 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
**Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll**
|
||||
|
||||
**Problem it solves:**
|
||||
|
||||
- Timer #1 runs every 15 minutes but NOT synchronized to clock (:03, :18, :33, :48)
|
||||
- Current price changes at :00, :15, :30, :45 → entities would show stale data for up to 15 minutes
|
||||
- Example: 14:00 new price, but Timer #1 ran at 13:58 → next update at 14:13 → users see old price until 14:13
|
||||
|
|
@ -100,22 +105,26 @@ async def _handle_quarter_hour_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Smart Boundary Tolerance:**
|
||||
|
||||
- Uses `round_to_nearest_quarter_hour()` with ±2 second tolerance
|
||||
- HA may schedule timer at 14:59:58 → rounds to 15:00:00 (shows new interval)
|
||||
- HA restart at 14:59:30 → stays at 14:45:00 (shows current interval)
|
||||
- See [Architecture](./architecture.md#3-quarter-hour-precision) for details
|
||||
|
||||
**Absolute Time Scheduling:**
|
||||
|
||||
- `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...)
|
||||
- NOT relative delays ("in 15 minutes")
|
||||
- If triggered at 14:59:58 → next trigger is 15:15:00, NOT 15:00:00 (prevents double updates)
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- All sensors that depend on "current interval" (e.g., `current_interval_price`, `next_interval_price`)
|
||||
- Binary sensors that check "is now in period?" (e.g., `best_price_period_active`)
|
||||
- ~50-60 entities out of 120+ total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- HA's built-in coordinator doesn't support exact boundary timing
|
||||
- We need **absolute time** triggers, not periodic intervals
|
||||
- Allows fast entity updates without expensive data transformation
|
||||
|
|
@ -140,6 +149,7 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- `best_price_remaining_minutes` - Countdown timer
|
||||
- `peak_price_remaining_minutes` - Countdown timer
|
||||
- `best_price_progress` - Progress bar (0-100%)
|
||||
|
|
@ -147,11 +157,13 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
- ~10 entities total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- Users want smooth countdowns (not jumping 15 minutes at a time)
|
||||
- Progress bars need minute-by-minute updates
|
||||
- Very lightweight (no data processing, just state recalculation)
|
||||
|
||||
**Why NOT every second:**
|
||||
|
||||
- Minute precision sufficient for countdown UX
|
||||
- Reduces CPU load (60× fewer updates than seconds)
|
||||
- Home Assistant best practice (avoid sub-minute updates)
|
||||
|
|
@ -194,6 +206,7 @@ class ListenerManager:
|
|||
```
|
||||
|
||||
**Why this pattern:**
|
||||
|
||||
- Decouples timer logic from entity logic
|
||||
- One timer can notify many entities efficiently
|
||||
- Entities can unregister when removed (cleanup)
|
||||
|
|
@ -279,11 +292,13 @@ class ListenerManager:
|
|||
### Reason 1: Load Distribution on Tibber API
|
||||
|
||||
If all installations used synchronized timers:
|
||||
|
||||
- ❌ Everyone fetches at 13:00:00 → Tibber API overload
|
||||
- ❌ Everyone fetches at 14:00:00 → Tibber API overload
|
||||
- ❌ "Thundering herd" problem
|
||||
|
||||
With HA's unsynchronized timer:
|
||||
|
||||
- ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ...
|
||||
- ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ...
|
||||
- ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ...
|
||||
|
|
@ -316,6 +331,7 @@ def _should_update_price_data(self) -> str:
|
|||
**Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data.
|
||||
|
||||
**API fetch only when:**
|
||||
|
||||
- Tomorrow data missing/invalid (after 13:00)
|
||||
- Cache expired (midnight turnover)
|
||||
- Explicit user refresh
|
||||
|
|
@ -339,6 +355,7 @@ def _should_update_price_data(self) -> str:
|
|||
## Performance Characteristics
|
||||
|
||||
### Timer #1 (DataUpdateCoordinator)
|
||||
|
||||
- **Triggers:** Every 15 minutes (unsynchronized)
|
||||
- **Fast path:** ~2ms (cache check, return existing data)
|
||||
- **Slow path:** ~600ms (API fetch + transform + calculate)
|
||||
|
|
@ -346,12 +363,14 @@ def _should_update_price_data(self) -> str:
|
|||
- **API calls:** ~1-2 times/day (cached otherwise)
|
||||
|
||||
### Timer #2 (Quarter-Hour Refresh)
|
||||
|
||||
- **Triggers:** 96 times/day (exact boundaries)
|
||||
- **Processing:** ~5ms (notify 60 entities)
|
||||
- **No API calls:** Uses cached/transformed data
|
||||
- **No transformation:** Just entity state updates
|
||||
|
||||
### Timer #3 (Minute Refresh)
|
||||
|
||||
- **Triggers:** 1440 times/day (every minute)
|
||||
- **Processing:** ~1ms (notify 10 entities)
|
||||
- **No API calls:** No data processing at all
|
||||
|
|
@ -417,17 +436,20 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
## Summary
|
||||
|
||||
**Three independent timers:**
|
||||
|
||||
1. **Timer #1** (HA built-in, 15 min, unsynchronized) → Data fetching (when needed)
|
||||
2. **Timer #2** (Custom, :00/:15/:30/:45) → Entity state updates (always)
|
||||
3. **Timer #3** (Custom, every minute) → Countdown/progress (always)
|
||||
|
||||
**Key insights:**
|
||||
|
||||
- Timer #1 unsynchronized = good (load distribution on API)
|
||||
- Timer #2 synchronized = good (user sees correct data immediately)
|
||||
- Timer #3 synchronized = good (smooth countdown UX)
|
||||
- All three coordinate gracefully (atomic midnight checks, no conflicts)
|
||||
|
||||
**"Listener" terminology:**
|
||||
|
||||
- Timer = mechanism that triggers
|
||||
- Listener = callback that gets called
|
||||
- Observer pattern = entities register, coordinator notifies
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ query($homeId: ID!) {
|
|||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `homeId`: Tibber home identifier
|
||||
- `resolution`: Always `QUARTER_HOURLY`
|
||||
- `first`: 384 intervals (4 days of data)
|
||||
|
|
@ -85,10 +86,12 @@ query($homeId: ID!) {
|
|||
## Rate Limits
|
||||
|
||||
Tibber API rate limits (as of 2024):
|
||||
|
||||
- **5000 requests per hour** per token
|
||||
- **Burst limit:** 100 requests per minute
|
||||
|
||||
Integration stays well below these limits:
|
||||
|
||||
- Polls every 15 minutes = 96 requests/day
|
||||
- User data cached for 24h = 1 request/day
|
||||
- **Total:** ~100 requests/day per home
|
||||
|
|
@ -106,6 +109,7 @@ Integration stays well below these limits:
|
|||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
|
||||
- `startsAt`: ISO 8601 timestamp with timezone
|
||||
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
|
||||
|
|
@ -119,6 +123,7 @@ Integration stays well below these limits:
|
|||
```
|
||||
|
||||
Supported currencies:
|
||||
|
||||
- `EUR` (Euro) - displayed as ct/kWh
|
||||
- `NOK` (Norwegian Krone) - displayed as øre/kWh
|
||||
- `SEK` (Swedish Krona) - displayed as öre/kWh
|
||||
|
|
@ -128,42 +133,52 @@ Supported currencies:
|
|||
### Common Error Responses
|
||||
|
||||
**Invalid Token:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Rate Limit Exceeded:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Home Not Found:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Integration handles these with:
|
||||
|
||||
- Exponential backoff retry (3 attempts)
|
||||
- ConfigEntryAuthFailed for auth errors
|
||||
- ConfigEntryNotReady for temporary failures
|
||||
|
|
@ -171,6 +186,7 @@ Integration handles these with:
|
|||
## Data Transformation
|
||||
|
||||
Raw API data is enriched with:
|
||||
|
||||
- **Trailing 24h average** - Calculated from previous intervals
|
||||
- **Leading 24h average** - Calculated from future intervals
|
||||
- **Price difference %** - Deviation from average
|
||||
|
|
@ -181,6 +197,7 @@ See `utils/price.py` for enrichment logic.
|
|||
---
|
||||
|
||||
💡 **External Resources:**
|
||||
|
||||
- [Tibber API Documentation](https://developer.tibber.com/docs/overview)
|
||||
- [GraphQL Explorer](https://developer.tibber.com/explorer)
|
||||
- [Get API Token](https://developer.tibber.com/settings/access-token)
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ flowchart TB
|
|||
The integration uses **5 independent caching layers** for optimal performance:
|
||||
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
|-------|----------|----------|--------------|--------|
|
||||
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ |
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
|
|
@ -196,7 +196,7 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
### Core Components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|-----------|------|----------------|
|
||||
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
|
|
@ -210,7 +210,7 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
|
||||
|
||||
| Component | Files | Lines | Responsibility |
|
||||
|-----------|-------|-------|----------------|
|
||||
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- |
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
|
|
@ -219,6 +219,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
|
||||
**Calculator Package** (`sensor/calculators/`):
|
||||
|
||||
- `base.py` - Abstract BaseCalculator with coordinator access
|
||||
- `interval.py` - Single interval calculations (current/next/previous)
|
||||
- `rolling_hour.py` - 5-interval rolling windows
|
||||
|
|
@ -230,6 +231,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
- `metadata.py` - Home/metering metadata
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 58% reduction in core.py (2,170 → 909 lines)
|
||||
- Clear separation: Calculators (logic) vs Attributes (presentation)
|
||||
- Independent testability for each calculator
|
||||
|
|
@ -238,7 +240,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
### Helper Utilities
|
||||
|
||||
| Utility | File | Purpose |
|
||||
|---------|------|---------|
|
||||
| ----------------- | ------------------ | ------------------------------------------------- |
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
|
|
@ -296,26 +298,31 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
Sensors organized by **calculation method** (refactored Nov 2025):
|
||||
|
||||
**Unified Handler Methods** (`sensor/core.py`):
|
||||
|
||||
- `_get_interval_value(offset, type)` - current/next/previous intervals
|
||||
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
|
||||
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
|
||||
- `_get_24h_window_value(stat_func)` - trailing/leading statistics
|
||||
|
||||
**Routing** (`sensor/value_getters.py`):
|
||||
|
||||
- Single source of truth mapping 80+ entity keys to calculator methods
|
||||
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
|
||||
|
||||
**Calculators** (`sensor/calculators/`):
|
||||
|
||||
- Each calculator inherits from `BaseCalculator` with coordinator access
|
||||
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
|
||||
- Complex logic isolated (e.g., `TrendCalculator` has internal caching)
|
||||
|
||||
**Attributes** (`sensor/attributes/`):
|
||||
|
||||
- Separate from business logic, handles state presentation
|
||||
- Builds extra_state_attributes dicts for entity classes
|
||||
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Minimal code duplication across 80+ sensors
|
||||
- Clear separation of concerns (calculation vs presentation)
|
||||
- Easy to extend: Add sensor → choose pattern → add to routing
|
||||
|
|
@ -334,7 +341,7 @@ Sensors organized by **calculation method** (refactored Nov 2025):
|
|||
### CPU Optimization
|
||||
|
||||
| Optimization | Location | Savings |
|
||||
|--------------|----------|---------|
|
||||
| ------------------- | ------------------------ | ---------------------------- |
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total)
|
||||
- **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
|
||||
- **Timestamps**: Last update times for validation
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
|
||||
- **User data**: 24 hours (refreshed daily)
|
||||
- **Survives**: HA restarts via persistent Storage
|
||||
|
|
@ -36,6 +38,7 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Midnight turnover** (Timer #2 in coordinator):
|
||||
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
|
|
@ -45,6 +48,7 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
```
|
||||
|
||||
2. **Cache validation on load**:
|
||||
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
|
|
@ -71,18 +75,22 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
|
||||
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Forever** (until HA restart)
|
||||
- No invalidation during runtime
|
||||
|
||||
**When populated:**
|
||||
|
||||
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
|
||||
- Lazy loading: If translation missing, attempts file load once
|
||||
|
||||
**Access pattern:**
|
||||
|
||||
```python
|
||||
# Non-blocking synchronous access from cached data
|
||||
description = get_translation("binary_sensor.best_price_period.description", "en")
|
||||
|
|
@ -101,6 +109,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**What is cached:**
|
||||
|
||||
### DataTransformer Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"thresholds": {"low": 15, "high": 35},
|
||||
|
|
@ -110,6 +119,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
### PeriodCalculator Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||
|
|
@ -118,10 +128,12 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until `invalidate_config_cache()` is called
|
||||
- Built once on first use per coordinator update cycle
|
||||
|
||||
**Invalidation trigger:**
|
||||
|
||||
- **Options change** (user reconfigures integration):
|
||||
```python
|
||||
# coordinator/core.py
|
||||
|
|
@ -132,6 +144,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Before:** ~30 dict lookups + type conversions per update = ~50μs
|
||||
- **After:** 1 cache check = ~1μs
|
||||
- **Savings:** ~98% (50μs → 1μs per update)
|
||||
|
|
@ -147,6 +160,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"best_price": {
|
||||
|
|
@ -161,6 +175,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Cache key:** Hash of relevant inputs
|
||||
|
||||
```python
|
||||
hash_data = (
|
||||
today_signature, # (startsAt, rating_level) for each interval
|
||||
|
|
@ -172,6 +187,7 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until price data changes (today's intervals modified)
|
||||
- Until config changes (flex, thresholds, filters)
|
||||
- Recalculated at midnight (new today data)
|
||||
|
|
@ -179,6 +195,7 @@ hash_data = (
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Config change** (explicit):
|
||||
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
|
|
@ -193,10 +210,12 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Cache hit rate:**
|
||||
|
||||
- **High:** During normal operation (coordinator updates every 15min, price data unchanged)
|
||||
- **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
|
||||
- **Cache hit:** `<`1ms (hash comparison + dict lookup)
|
||||
- **Savings:** ~70% of calculation time (most updates hit cache)
|
||||
|
|
@ -212,6 +231,7 @@ hash_data = (
|
|||
**Status:** ✅ **Clean separation** - enrichment only, no redundancy
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"timestamp": ...,
|
||||
|
|
@ -224,6 +244,7 @@ hash_data = (
|
|||
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
|
||||
|
||||
**Current behavior:**
|
||||
|
||||
- Caches **only enriched price data** (price + statistics)
|
||||
- **Does NOT cache periods** (handled by Period Calculation Cache)
|
||||
- Invalidated when:
|
||||
|
|
@ -232,6 +253,7 @@ hash_data = (
|
|||
- New update cycle begins
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- DataTransformer: Handles price enrichment only
|
||||
- PeriodCalculator: Handles period calculation only (with hash-based cache)
|
||||
- Coordinator: Assembles final data on-demand from both caches
|
||||
|
|
@ -243,6 +265,7 @@ hash_data = (
|
|||
## Cache Invalidation Flow
|
||||
|
||||
### User Changes Options (Config Flow)
|
||||
|
||||
```
|
||||
User saves options
|
||||
↓
|
||||
|
|
@ -267,6 +290,7 @@ Fresh data fetch with new config
|
|||
```
|
||||
|
||||
### Midnight Turnover (Day Transition)
|
||||
|
||||
```
|
||||
Timer #2 fires at 00:00
|
||||
↓
|
||||
|
|
@ -286,6 +310,7 @@ Fresh API fetch for new day
|
|||
```
|
||||
|
||||
### Tomorrow Data Arrives (~13:00)
|
||||
|
||||
```
|
||||
Coordinator update cycle
|
||||
↓
|
||||
|
|
@ -327,12 +352,14 @@ API Data Cache (price_data, user_data)
|
|||
```
|
||||
|
||||
**No cache invalidation cascades:**
|
||||
|
||||
- Config cache invalidation is **explicit** (on options update)
|
||||
- Period cache invalidation is **automatic** (via hash mismatch)
|
||||
- Transformation cache invalidation is **automatic** (on midnight/config change)
|
||||
- Translation cache is **never invalidated** (read-only after load)
|
||||
|
||||
**Thread safety:**
|
||||
|
||||
- All caches are accessed from `MainThread` only (Home Assistant event loop)
|
||||
- No locking needed (single-threaded execution model)
|
||||
|
||||
|
|
@ -341,6 +368,7 @@ API Data Cache (price_data, user_data)
|
|||
## Performance Characteristics
|
||||
|
||||
### Typical Operation (No Changes)
|
||||
|
||||
```
|
||||
Coordinator Update (every 15 min)
|
||||
├─> API fetch: SKIP (cache valid)
|
||||
|
|
@ -353,6 +381,7 @@ Total: ~16ms (down from ~600ms without caching)
|
|||
```
|
||||
|
||||
### After Midnight Turnover
|
||||
|
||||
```
|
||||
Coordinator Update (00:00)
|
||||
├─> API fetch: ~500ms (cache cleared, fetch new day)
|
||||
|
|
@ -365,6 +394,7 @@ Total: ~755ms (expected once per day)
|
|||
```
|
||||
|
||||
### After Config Change
|
||||
|
||||
```
|
||||
Options Update
|
||||
├─> Cache invalidation: `<`1ms
|
||||
|
|
@ -382,7 +412,7 @@ Options Update
|
|||
## Summary Table
|
||||
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
|------------|----------|------|--------------|---------|
|
||||
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- |
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
|
|
@ -392,12 +422,14 @@ Options Update
|
|||
**Total memory overhead:** ~116KB per coordinator instance (main + subentries)
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 97% reduction in API calls (from every 15min to once per day)
|
||||
- 70% reduction in period calculation time (cache hits during normal operation)
|
||||
- 98% reduction in config access time (30+ lookups → 1 cache check)
|
||||
- Zero file I/O during runtime (translations cached at startup)
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- Memory usage: ~116KB per home (negligible for modern systems)
|
||||
- Code complexity: 5 cache invalidation points (well-tested, documented)
|
||||
- Debugging: Must understand cache lifetime when investigating stale data issues
|
||||
|
|
@ -407,7 +439,9 @@ Options Update
|
|||
## Debugging Cache Issues
|
||||
|
||||
### Symptom: Stale data after config change
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Is `_handle_options_update()` called? (should see "Options updated" log)
|
||||
2. Are `invalidate_config_cache()` methods executed?
|
||||
3. Does `async_request_refresh()` trigger?
|
||||
|
|
@ -415,7 +449,9 @@ Options Update
|
|||
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
|
||||
|
||||
### Symptom: Period calculation not updating
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Verify hash changes when data changes: `_compute_periods_hash()`
|
||||
2. Check `_last_periods_hash` vs `current_hash`
|
||||
3. Look for "Using cached period calculation" vs "Calculating periods" logs
|
||||
|
|
@ -423,7 +459,9 @@ Options Update
|
|||
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
|
||||
|
||||
### Symptom: Yesterday's prices shown as today
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `is_cache_valid()` logic in `coordinator/cache.py`
|
||||
2. Midnight turnover execution (Timer #2)
|
||||
3. Cache clear confirmation in logs
|
||||
|
|
@ -431,7 +469,9 @@ Options Update
|
|||
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
|
||||
|
||||
### Symptom: Missing translations
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `async_load_translations()` called at startup?
|
||||
2. Translation files exist in `/translations/` and `/custom_translations/`?
|
||||
3. Cache population: `_TRANSLATIONS_CACHE` keys
|
||||
|
|
|
|||
|
|
@ -41,12 +41,14 @@ class TimeService:
|
|||
```
|
||||
|
||||
**When prefix is required:**
|
||||
|
||||
- Public classes used across multiple modules
|
||||
- All exception classes
|
||||
- All coordinator and entity classes
|
||||
- Data classes (dataclasses, NamedTuples) used as public APIs
|
||||
|
||||
**When prefix can be omitted:**
|
||||
|
||||
- Private helper classes within a single module (prefix with `_` underscore)
|
||||
- Type aliases and callbacks (e.g., `TimeServiceCallback`)
|
||||
- Small internal NamedTuples for function returns
|
||||
|
|
@ -71,6 +73,7 @@ class DataFetcher: # Should be TibberPricesDataFetcher
|
|||
**Current Technical Debt:**
|
||||
|
||||
Many existing classes lack the `TibberPrices` prefix. Before refactoring:
|
||||
|
||||
1. Document the plan in `/planning/class-naming-refactoring.md`
|
||||
2. Use `multi_replace_string_in_file` for bulk renames
|
||||
3. Test thoroughly after each module
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ git checkout -b fix/issue-123-description
|
|||
```
|
||||
|
||||
**Branch naming:**
|
||||
|
||||
- `feature/` - New features
|
||||
- `fix/` - Bug fixes
|
||||
- `docs/` - Documentation only
|
||||
|
|
@ -45,6 +46,7 @@ git checkout -b fix/issue-123-description
|
|||
Edit code, following [Coding Guidelines](coding-guidelines.md).
|
||||
|
||||
**Run checks frequently:**
|
||||
|
||||
```bash
|
||||
./scripts/type-check # Pyright type checking
|
||||
./scripts/lint # Ruff linting (auto-fix)
|
||||
|
|
@ -78,6 +80,7 @@ async def test_your_feature(hass, coordinator):
|
|||
```
|
||||
|
||||
Run your test:
|
||||
|
||||
```bash
|
||||
./scripts/test tests/test_your_feature.py -v
|
||||
```
|
||||
|
|
@ -97,6 +100,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
```
|
||||
|
||||
**Commit types:**
|
||||
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation
|
||||
|
|
@ -105,6 +109,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
- `chore:` - Maintenance
|
||||
|
||||
**Add scope when relevant:**
|
||||
|
||||
- `feat(sensors):` - Sensor platform
|
||||
- `fix(coordinator):` - Data coordinator
|
||||
- `docs(user):` - User documentation
|
||||
|
|
@ -124,32 +129,40 @@ Then open Pull Request on GitHub.
|
|||
Title: Short, descriptive (50 chars max)
|
||||
|
||||
Description should include:
|
||||
|
||||
```markdown
|
||||
## What
|
||||
|
||||
Brief description of changes
|
||||
|
||||
## Why
|
||||
|
||||
Problem being solved or feature rationale
|
||||
|
||||
## How
|
||||
|
||||
Implementation approach
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Manual testing in Home Assistant
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] Type checking passes
|
||||
- [ ] Linting passes
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
(If any - describe migration path)
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
### PR Checklist
|
||||
|
||||
Before submitting:
|
||||
|
||||
- [ ] Code follows [Coding Guidelines](coding-guidelines.md)
|
||||
- [ ] All tests pass (`./scripts/test`)
|
||||
- [ ] Type checking passes (`./scripts/type-check`)
|
||||
|
|
@ -170,6 +183,7 @@ Before submitting:
|
|||
### What Reviewers Look For
|
||||
|
||||
✅ **Good:**
|
||||
|
||||
- Clear, self-explanatory code
|
||||
- Appropriate comments for complex logic
|
||||
- Tests covering edge cases
|
||||
|
|
@ -177,6 +191,7 @@ Before submitting:
|
|||
- Follows existing patterns
|
||||
|
||||
❌ **Avoid:**
|
||||
|
||||
- Large PRs (>500 lines) - split into smaller ones
|
||||
- Mixing unrelated changes
|
||||
- Missing tests for new features
|
||||
|
|
@ -193,6 +208,7 @@ Before submitting:
|
|||
## Finding Issues to Work On
|
||||
|
||||
Good first issues are labeled:
|
||||
|
||||
- `good first issue` - Beginner-friendly
|
||||
- `help wanted` - Maintainers welcome contributions
|
||||
- `documentation` - Docs improvements
|
||||
|
|
@ -210,6 +226,7 @@ Be respectful, constructive, and patient. We're all volunteers! 🙏
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Setup Guide](setup.md) - DevContainer setup
|
||||
- [Coding Guidelines](coding-guidelines.md) - Style guide
|
||||
- [Testing](testing.md) - Writing tests
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ comments: false
|
|||
## 🎯 Why Are These Tests Critical?
|
||||
|
||||
Home Assistant integrations run **continuously** in the background. Resource leaks lead to:
|
||||
|
||||
- **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable
|
||||
- **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases
|
||||
- **Timer Leaks**: Timers continue running after unload → unnecessary background tasks
|
||||
|
|
@ -26,6 +27,7 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.1 Listener Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`)
|
||||
- Minute-update listeners are correctly removed (`async_add_minute_update_listener()`)
|
||||
- Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`)
|
||||
|
|
@ -33,11 +35,13 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
- Binary sensor cleanup removes ALL registered listeners
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Each registered listener holds references to Entity + Coordinator
|
||||
- Without cleanup: Entities are not freed by GC → Memory Leak
|
||||
- With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `async_add_time_sensitive_listener()`, `async_add_minute_update_listener()`
|
||||
- `coordinator/core.py` → `register_lifecycle_callback()`
|
||||
- `sensor/core.py` → `async_will_remove_from_hass()`
|
||||
|
|
@ -46,32 +50,38 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.2 Timer Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is cancelled and reference cleared
|
||||
- Minute timer is cancelled and reference cleared
|
||||
- Both timers are cancelled together
|
||||
- Cleanup works even when timers are `None`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Uncancelled timers continue running after integration unload
|
||||
- HA's `async_track_utc_time_change()` creates persistent callbacks
|
||||
- Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `cancel_timers()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
#### 1.3 Config Entry Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Options update listener is registered via `async_on_unload()`
|
||||
- Cleanup function is correctly passed to `async_on_unload()`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- `entry.add_update_listener()` registers permanent callback
|
||||
- Without `async_on_unload()`: Listener remains active after reload → duplicate updates
|
||||
- Pattern: `entry.async_on_unload(entry.add_update_listener(handler))`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/core.py` → `__init__()` (listener registration)
|
||||
- `__init__.py` → `async_unload_entry()`
|
||||
|
||||
|
|
@ -82,16 +92,19 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 2.1 Config Cache Invalidation
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- DataTransformer config cache is invalidated on options change
|
||||
- PeriodCalculator config + period cache is invalidated
|
||||
- Trend calculator cache is cleared on coordinator update
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Stale config → Sensors use old user settings
|
||||
- Stale period cache → Incorrect best/peak price periods
|
||||
- Stale trend cache → Outdated trend analysis
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/data_transformation.py` → `invalidate_config_cache()`
|
||||
- `coordinator/periods.py` → `invalidate_config_cache()`
|
||||
- `sensor/calculators/trend.py` → `clear_trend_cache()`
|
||||
|
|
@ -103,15 +116,18 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 3.1 Persistent Storage Removal
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Storage file is deleted on config entry removal
|
||||
- Cache is saved on shutdown (no data loss)
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Without storage removal: Old files remain after uninstallation
|
||||
- Without cache save on shutdown: Data loss on HA restart
|
||||
- Storage path: `.storage/tibber_prices.{entry_id}`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `__init__.py` → `async_remove_entry()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
|
|
@ -120,12 +136,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_timer_scheduling.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is registered with correct parameters
|
||||
- Minute timer is registered with correct parameters
|
||||
- Timers can be re-scheduled (override old timer)
|
||||
- Midnight turnover detection works correctly
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer parameters → Entities update at wrong times
|
||||
- Without timer override on re-schedule → Multiple parallel timers → Performance problem
|
||||
|
||||
|
|
@ -134,12 +152,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_sensor_timer_assignment.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys
|
||||
- All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys
|
||||
- Both lists are disjoint (no overlap)
|
||||
- Sensor and binary sensor platforms are checked
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer assignment → Sensors update at wrong times
|
||||
- Overlap → Duplicate updates → Performance problem
|
||||
|
||||
|
|
@ -150,10 +170,12 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 6. Async Task Management
|
||||
|
||||
**Current Status:** Fire-and-forget pattern for short tasks
|
||||
|
||||
- `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds)
|
||||
- `coordinator/core.py` → Cache storage (short-lived, max 100ms)
|
||||
|
||||
**Why no tests needed:**
|
||||
|
||||
- No long-running tasks (all < 2 seconds)
|
||||
- HA's event loop handles short tasks automatically
|
||||
- Task exceptions are already logged
|
||||
|
|
@ -163,6 +185,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 7. API Session Cleanup
|
||||
|
||||
**Current Status:** ✅ Correctly implemented
|
||||
|
||||
- `async_get_clientsession(hass)` is used (shared session)
|
||||
- No new sessions are created
|
||||
- HA manages session lifecycle automatically
|
||||
|
|
@ -172,6 +195,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 8. Translation Cache Memory
|
||||
|
||||
**Current Status:** ✅ Bounded cache
|
||||
|
||||
- Max ~5-10 languages × 5KB = 50KB total
|
||||
- Module-level cache without re-loading
|
||||
- Practically no memory issue
|
||||
|
|
@ -181,11 +205,13 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 9. Coordinator Data Structure Integrity
|
||||
|
||||
**Current Status:** Manually tested via `./scripts/develop`
|
||||
|
||||
- Midnight turnover works correctly (observed over several days)
|
||||
- Missing keys are handled via `.get()` with defaults
|
||||
- 80+ sensors access `coordinator.data` without errors
|
||||
|
||||
**Structure:**
|
||||
|
||||
```python
|
||||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
|
|
@ -197,6 +223,7 @@ coordinator.data = {
|
|||
### 10. Service Response Memory
|
||||
|
||||
**Current Status:** HA's response lifecycle
|
||||
|
||||
- HA automatically frees service responses after return
|
||||
- ApexCharts ~20KB response is one-time per call
|
||||
- No response accumulation in integration code
|
||||
|
|
@ -208,7 +235,7 @@ coordinator.data = {
|
|||
### ✅ Implemented Tests (41 total)
|
||||
|
||||
| Category | Status | Tests | File | Coverage |
|
||||
|----------|--------|-------|------|----------|
|
||||
| ----------------------- | ------ | ------ | --------------------------------- | ------------------- |
|
||||
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
|
||||
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
|
||||
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
|
|
@ -222,7 +249,7 @@ coordinator.data = {
|
|||
### 📋 Analyzed but Not Implemented (Nice-to-Have)
|
||||
|
||||
| Category | Status | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| ------------------------ | ------ | ---------------------------------------------------- |
|
||||
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
|
||||
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
|
||||
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
|
||||
|
|
@ -230,6 +257,7 @@ coordinator.data = {
|
|||
| Service Response Memory | 📋 | HA automatically frees service responses |
|
||||
|
||||
**Legend:**
|
||||
|
||||
- ✅ = Fully tested or pattern verified correct
|
||||
- 📋 = Analyzed, low priority for testing (no known issues)
|
||||
|
||||
|
|
@ -238,6 +266,7 @@ coordinator.data = {
|
|||
### ✅ All Critical Patterns Tested
|
||||
|
||||
All essential memory leak prevention patterns are covered by 41 tests:
|
||||
|
||||
- ✅ Listeners are correctly removed (no callback leaks)
|
||||
- ✅ Timers are cancelled (no background task leaks)
|
||||
- ✅ Config entry cleanup works (no dangling listeners)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ Restart Home Assistant to apply.
|
|||
### Key Log Messages
|
||||
|
||||
**Coordinator Updates:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator] Successfully fetched price data
|
||||
[custom_components.tibber_prices.coordinator] Cache valid, using cached data
|
||||
|
|
@ -27,6 +28,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**Period Calculation:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator.periods] Calculating BEST PRICE periods: flex=15.0%
|
||||
[custom_components.tibber_prices.coordinator.periods] Day 2024-12-06: Found 2 periods
|
||||
|
|
@ -34,6 +36,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**API Errors:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.api] API request failed: Unauthorized
|
||||
[custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s
|
||||
|
|
@ -67,6 +70,7 @@ Restart Home Assistant to apply.
|
|||
### Set Breakpoints
|
||||
|
||||
**Coordinator update:**
|
||||
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _async_update_data(self) -> dict:
|
||||
|
|
@ -75,6 +79,7 @@ async def _async_update_data(self) -> dict:
|
|||
```
|
||||
|
||||
**Period calculation:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(...) -> list[dict]:
|
||||
|
|
@ -91,6 +96,7 @@ def calculate_periods(...) -> list[dict]:
|
|||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `-v` - Verbose output
|
||||
- `-s` - Show print statements
|
||||
- `-k pattern` - Run tests matching pattern
|
||||
|
|
@ -102,6 +108,7 @@ Set breakpoint in test file, use "Debug Test" CodeLens.
|
|||
### Useful Test Patterns
|
||||
|
||||
**Print coordinator data:**
|
||||
|
||||
```python
|
||||
def test_something(coordinator):
|
||||
print(f"Coordinator data: {coordinator.data}")
|
||||
|
|
@ -109,6 +116,7 @@ def test_something(coordinator):
|
|||
```
|
||||
|
||||
**Inspect period attributes:**
|
||||
|
||||
```python
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
|
|
@ -122,11 +130,13 @@ def test_periods(hass, coordinator):
|
|||
### Integration Not Loading
|
||||
|
||||
**Check:**
|
||||
|
||||
```bash
|
||||
grep "tibber_prices" config/home-assistant.log
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
|
||||
- Syntax error in Python code → Check logs for traceback
|
||||
- Missing dependency → Run `uv sync`
|
||||
- Wrong file permissions → `chmod +x scripts/*`
|
||||
|
|
@ -134,12 +144,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
### Sensors Not Updating
|
||||
|
||||
**Check coordinator state:**
|
||||
|
||||
```python
|
||||
# In Developer Tools > Template
|
||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
||||
```
|
||||
|
||||
**Debug in code:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
|
|
@ -149,6 +161,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
### Period Calculation Wrong
|
||||
|
||||
**Enable detailed period logs:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
|
|
@ -156,6 +169,7 @@ _LOGGER.debug("Candidate intervals: %s",
|
|||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
||||
```
|
||||
[period_building] Flex filter blocked: 45 intervals
|
||||
[period_building] Min distance blocked: 12 intervals
|
||||
|
|
@ -200,6 +214,7 @@ python -m pstats profile.stats
|
|||
### Remote Debugging with debugpy
|
||||
|
||||
Add to coordinator code:
|
||||
|
||||
```python
|
||||
import debugpy
|
||||
debugpy.listen(5678)
|
||||
|
|
@ -212,11 +227,13 @@ Connect from VS Code with remote attach configuration.
|
|||
### IPython REPL
|
||||
|
||||
Install in container:
|
||||
|
||||
```bash
|
||||
uv pip install ipython
|
||||
```
|
||||
|
||||
Add breakpoint:
|
||||
|
||||
```python
|
||||
from IPython import embed
|
||||
embed() # Drops into interactive shell
|
||||
|
|
@ -225,6 +242,7 @@ embed() # Drops into interactive shell
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Testing Guide](testing.md) - Writing and running tests
|
||||
- [Setup Guide](setup.md) - Development environment
|
||||
- [Architecture](architecture.md) - Code structure
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ Documentation is organized in two Docusaurus sites:
|
|||
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
|
||||
|
||||
**Best practices:**
|
||||
|
||||
- Use clear examples and code snippets
|
||||
- Keep docs up-to-date with code changes
|
||||
- Add new pages to appropriate `sidebars.ts` for navigation
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Guidelines for maintaining and improving integration performance.
|
|||
## Performance Goals
|
||||
|
||||
Target metrics:
|
||||
|
||||
- **Coordinator update**: <500ms (typical: 200-300ms)
|
||||
- **Sensor update**: <10ms per sensor
|
||||
- **Period calculation**: <100ms (typical: 20-50ms)
|
||||
|
|
@ -64,6 +65,7 @@ python -m aioprof homeassistant -c config
|
|||
### Caching
|
||||
|
||||
**1. Persistent Cache** (API data):
|
||||
|
||||
```python
|
||||
# Already implemented in coordinator/cache.py
|
||||
store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
|
|
@ -71,6 +73,7 @@ data = await store.async_load()
|
|||
```
|
||||
|
||||
**2. Translation Cache** (in-memory):
|
||||
|
||||
```python
|
||||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
|
@ -83,6 +86,7 @@ def get_translation(path: str, language: str) -> dict:
|
|||
```
|
||||
|
||||
**3. Config Cache** (invalidated on options change):
|
||||
|
||||
```python
|
||||
class DataTransformer:
|
||||
def __init__(self):
|
||||
|
|
@ -100,6 +104,7 @@ class DataTransformer:
|
|||
### Lazy Loading
|
||||
|
||||
**Load data only when needed:**
|
||||
|
||||
```python
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict | None:
|
||||
|
|
@ -113,6 +118,7 @@ def extra_state_attributes(self) -> dict | None:
|
|||
### Bulk Operations
|
||||
|
||||
**Process multiple items at once:**
|
||||
|
||||
```python
|
||||
# ❌ Slow - loop with individual operations
|
||||
for interval in intervals:
|
||||
|
|
@ -126,6 +132,7 @@ results = enrich_intervals_bulk(intervals)
|
|||
### Async Best Practices
|
||||
|
||||
**1. Concurrent API calls:**
|
||||
|
||||
```python
|
||||
# ❌ Sequential (slow)
|
||||
user_data = await fetch_user_data()
|
||||
|
|
@ -139,6 +146,7 @@ user_data, price_data = await asyncio.gather(
|
|||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
||||
```python
|
||||
# ❌ Blocking
|
||||
result = heavy_computation() # Blocks for seconds
|
||||
|
|
@ -152,6 +160,7 @@ result = await hass.async_add_executor_job(heavy_computation)
|
|||
### Avoid Memory Leaks
|
||||
|
||||
**1. Clear references:**
|
||||
|
||||
```python
|
||||
class Coordinator:
|
||||
async def async_shutdown(self):
|
||||
|
|
@ -162,6 +171,7 @@ class Coordinator:
|
|||
```
|
||||
|
||||
**2. Use weak references for callbacks:**
|
||||
|
||||
```python
|
||||
import weakref
|
||||
|
||||
|
|
@ -176,6 +186,7 @@ class Manager:
|
|||
### Efficient Data Structures
|
||||
|
||||
**Use appropriate types:**
|
||||
|
||||
```python
|
||||
# ❌ List for lookups (O(n))
|
||||
if timestamp in timestamp_list:
|
||||
|
|
@ -197,11 +208,13 @@ results = (x for x in items if condition(x))
|
|||
### Minimize API Calls
|
||||
|
||||
**Already implemented:**
|
||||
|
||||
- Cache valid until midnight
|
||||
- User data cached for 24h
|
||||
- Only poll when tomorrow data expected
|
||||
|
||||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
|
|
@ -210,6 +223,7 @@ _LOGGER.debug("API call: %s (cache_age=%s)",
|
|||
### Smart Updates
|
||||
|
||||
**Only update when needed:**
|
||||
|
||||
```python
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch data from API."""
|
||||
|
|
@ -226,6 +240,7 @@ async def _async_update_data(self) -> dict:
|
|||
### State Class Selection
|
||||
|
||||
**Affects long-term statistics storage:**
|
||||
|
||||
```python
|
||||
# ❌ MEASUREMENT for prices (stores every change)
|
||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
||||
|
|
@ -240,6 +255,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
### Attribute Size
|
||||
|
||||
**Keep attributes minimal:**
|
||||
|
||||
```python
|
||||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
|
|
@ -317,6 +333,7 @@ _LOGGER.debug("Current memory usage: %.2f MB", memory_mb)
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Caching Strategy](caching-strategy.md) - Cache layers
|
||||
- [Architecture](architecture.md) - System design
|
||||
- [Debugging](debugging.md) - Profiling tools
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ This document explains the mathematical foundations and design decisions behind
|
|||
**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
|
||||
|
|
@ -23,6 +24,7 @@ Period detection uses **three independent filters** (all must pass):
|
|||
**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)
|
||||
|
|
@ -32,6 +34,7 @@ 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)
|
||||
|
|
@ -41,6 +44,7 @@ in_flex = price >= (daily_max - daily_max × flex)
|
|||
**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))
|
||||
|
|
@ -50,6 +54,7 @@ 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)
|
||||
|
|
@ -86,6 +91,7 @@ The integration maintains **two independent sets** of volatility thresholds:
|
|||
- 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)
|
||||
|
|
@ -107,21 +113,25 @@ period_low_threshold = PRICE_LEVEL_THRESHOLDS["volatility_low"] # Always 10%
|
|||
#### 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)
|
||||
|
|
@ -132,11 +142,13 @@ Max accepted = 15 × (1 - 0.05) = 14.25 ct/kWh
|
|||
### 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%)
|
||||
|
|
@ -149,6 +161,7 @@ daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
|
|||
**Approach:** Reduce Min_Distance proportionally as Flex increases.
|
||||
|
||||
**Formula:**
|
||||
|
||||
```python
|
||||
if flex > 0.20: # 20% threshold
|
||||
flex_excess = flex - 0.20
|
||||
|
|
@ -159,7 +172,7 @@ if flex > 0.20: # 20% threshold
|
|||
**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 |
|
||||
|
|
@ -167,6 +180,7 @@ if flex > 0.20: # 20% threshold
|
|||
| 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"
|
||||
|
|
@ -174,6 +188,7 @@ if flex > 0.20: # 20% threshold
|
|||
**Implementation:** See `level_filtering.py` → `check_interval_criteria()`
|
||||
|
||||
**Code Extract:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/level_filtering.py
|
||||
|
||||
|
|
@ -209,12 +224,14 @@ def check_interval_criteria(price, criteria):
|
|||
```
|
||||
|
||||
**Why Linear Scaling?**
|
||||
|
||||
- Simple and predictable
|
||||
- No abrupt behavior changes
|
||||
- Easy to reason about for users and developers
|
||||
- Alternative considered: Exponential scaling (rejected as too aggressive)
|
||||
|
||||
**Why 25% Minimum?**
|
||||
|
||||
- Below this, min_distance loses semantic meaning
|
||||
- Even on flat days, some quality filter needed
|
||||
- Prevents "every interval is a period" scenario
|
||||
|
|
@ -227,12 +244,14 @@ def check_interval_criteria(price, criteria):
|
|||
### Implementation Constants
|
||||
|
||||
**Defined in `coordinator/period_handlers/core.py`:**
|
||||
|
||||
```python
|
||||
MAX_SAFE_FLEX = 0.50 # 50% - hard cap: above this, period detection becomes unreliable
|
||||
MAX_OUTLIER_FLEX = 0.25 # 25% - cap for outlier filtering: above this, spike detection too permissive
|
||||
```
|
||||
|
||||
**Defined in `const.py`:**
|
||||
|
||||
```python
|
||||
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode (default enabled)
|
||||
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection)
|
||||
|
|
@ -255,16 +274,19 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Goal:** Find practical time windows for running appliances
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Appliances need time to complete cycles (dishwasher: 2-3h, EV charging: 4-8h)
|
||||
- Short periods are impractical (not worth automation overhead)
|
||||
- User wants genuinely cheap times, not just "slightly below average"
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- **60 min minimum** - Ensures period is long enough for meaningful use
|
||||
- **15% flex** - Stricter selection, focuses on truly cheap times
|
||||
- **Reasoning:** Better to find fewer, higher-quality periods than many mediocre ones
|
||||
|
||||
**User behavior:**
|
||||
|
||||
- Automations trigger actions (turn on devices)
|
||||
- Wrong automation = wasted energy/money
|
||||
- Preference: Conservative (miss some savings) over aggressive (false positives)
|
||||
|
|
@ -274,16 +296,19 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Goal:** Alert users to expensive periods for consumption reduction
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Brief price spikes still matter (even 15-30 min is worth avoiding)
|
||||
- Early warning more valuable than perfect accuracy
|
||||
- User can manually decide whether to react
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- **30 min minimum** - Catches shorter expensive spikes
|
||||
- **20% flex** - More permissive, earlier detection
|
||||
- **Reasoning:** Better to warn early (even if not peak) than miss expensive periods
|
||||
|
||||
**User behavior:**
|
||||
|
||||
- Notifications/alerts (informational)
|
||||
- Wrong alert = minor inconvenience, not cost
|
||||
- Preference: Sensitive (catch more) over specific (catch only extremes)
|
||||
|
|
@ -293,17 +318,20 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Peak Price Volatility:**
|
||||
|
||||
Price curves tend to have:
|
||||
|
||||
- **Sharp spikes** during peak hours (morning/evening)
|
||||
- **Shorter duration** at maximum (1-2 hours typical)
|
||||
- **Higher variance** in peak times than cheap times
|
||||
|
||||
**Example day:**
|
||||
|
||||
```
|
||||
Cheap period: 02:00-07:00 (5 hours at 10-12 ct) ← Gradual, stable
|
||||
Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
||||
```
|
||||
|
||||
**Implication:**
|
||||
|
||||
- Stricter flex on peak (15%) might miss real expensive periods (too brief)
|
||||
- Longer min_length (60 min) might exclude legitimate spikes
|
||||
- Solution: More flexible thresholds for peak detection
|
||||
|
|
@ -311,16 +339,19 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
|||
#### Design Alternatives Considered
|
||||
|
||||
**Option 1: Symmetric defaults (rejected)**
|
||||
|
||||
- Both 60 min, both 15% flex
|
||||
- Problem: Misses short but expensive spikes
|
||||
- User feedback: "Why didn't I get warned about the 30-min price spike?"
|
||||
|
||||
**Option 2: Same defaults, let users figure it out (rejected)**
|
||||
|
||||
- No guidance on best practices
|
||||
- Users would need to experiment to find good values
|
||||
- Most users stick with defaults, so defaults matter
|
||||
|
||||
**Option 3: Current approach (adopted)**
|
||||
|
||||
- **All values user-configurable** via config flow options
|
||||
- **Different installation defaults** for Best Price vs. Peak Price
|
||||
- Defaults reflect recommended practices for each use case
|
||||
|
|
@ -336,12 +367,14 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
|||
**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.
|
||||
|
|
@ -352,6 +385,7 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
|
|||
**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
|
||||
|
|
@ -363,23 +397,28 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
|
|||
#### 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)
|
||||
|
||||
---
|
||||
|
|
@ -395,6 +434,7 @@ Ensure **minimum periods per day** are found even when baseline filters are too
|
|||
### 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
|
||||
|
|
@ -418,6 +458,7 @@ for attempt in range(max_relaxation_attempts):
|
|||
```
|
||||
|
||||
**Constants:**
|
||||
|
||||
```python
|
||||
FLEX_WARNING_THRESHOLD_RELAXATION = 0.25 # 25% - INFO: suggest lowering to 15-20%
|
||||
FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: very high for relaxation mode
|
||||
|
|
@ -447,6 +488,7 @@ MAX_FLEX_HARD_LIMIT = 0.50 # 50% - absolute maximum (enforced in core.py)
|
|||
**Historical Context (Pre-November 2025):**
|
||||
|
||||
The algorithm previously used percentage-based increments that scaled with base flex:
|
||||
|
||||
```python
|
||||
increment = base_flex × (step_pct / 100) # REMOVED
|
||||
```
|
||||
|
|
@ -454,6 +496,7 @@ increment = base_flex × (step_pct / 100) # REMOVED
|
|||
This caused exponential escalation with high base flex values (e.g., 40% → 50% → 60% → 70% in just 6 steps), making behavior unpredictable. The fixed 3% increment solves this by providing consistent, controlled escalation regardless of starting point.
|
||||
|
||||
**Warning Messages:**
|
||||
|
||||
```python
|
||||
if base_flex >= FLEX_HIGH_THRESHOLD_RELAXATION: # 30%
|
||||
_LOGGER.warning(
|
||||
|
|
@ -472,12 +515,14 @@ elif base_flex >= FLEX_WARNING_THRESHOLD_RELAXATION: # 25%
|
|||
### 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)
|
||||
|
|
@ -492,6 +537,7 @@ Day 2025-11-19:
|
|||
### Key Files and Functions
|
||||
|
||||
**Period Calculation Entry Point:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(
|
||||
|
|
@ -502,6 +548,7 @@ def calculate_periods(
|
|||
```
|
||||
|
||||
**Flex + Distance Filtering:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/level_filtering.py
|
||||
def check_interval_criteria(
|
||||
|
|
@ -511,6 +558,7 @@ def check_interval_criteria(
|
|||
```
|
||||
|
||||
**Relaxation Orchestration:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/relaxation.py
|
||||
def calculate_periods_with_relaxation(...) -> tuple[dict, dict]
|
||||
|
|
@ -541,6 +589,7 @@ def relax_single_day(...) -> tuple[dict, dict]
|
|||
- Rejects asymmetric outliers (threshold: 1.5 std dev)
|
||||
- Preserves legitimate price shifts (morning/evening peaks)
|
||||
- Algorithm:
|
||||
|
||||
```python
|
||||
residual = abs(actual - predicted)
|
||||
symmetry_threshold = 1.5 × std_dev
|
||||
|
|
@ -563,6 +612,7 @@ def relax_single_day(...) -> tuple[dict, dict]
|
|||
- Catches patterns like: 18, 35, 19, 34, 18 (alternating spikes)
|
||||
|
||||
**Constants:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/outlier_filtering.py
|
||||
|
||||
|
|
@ -573,18 +623,21 @@ MIN_CONTEXT_SIZE = 3 # Minimum intervals for regression
|
|||
```
|
||||
|
||||
**Data Integrity:**
|
||||
|
||||
- Original prices stored in `_original_price` field
|
||||
- All statistics (daily min/max/avg) use original prices
|
||||
- Smoothing only affects period formation logic
|
||||
- Smart counting: Only counts smoothing that changed period outcome
|
||||
|
||||
**Performance:**
|
||||
|
||||
- Single pass through price data
|
||||
- O(n) complexity with small context window
|
||||
- No iterative refinement needed
|
||||
- Typical processing time: `<`1ms for 96 intervals
|
||||
|
||||
**Example Debug Output:**
|
||||
|
||||
```
|
||||
DEBUG: [2025-11-11T14:30:00+01:00] Outlier detected: 35.2 ct
|
||||
DEBUG: Context: 18.5, 19.1, 19.3, 19.8, 20.2 ct
|
||||
|
|
@ -624,6 +677,7 @@ DEBUG: Asymmetry ratio: 3.2 (>1.5 threshold) → confirmed outlier
|
|||
## Debugging Tips
|
||||
|
||||
**Enable DEBUG logging:**
|
||||
|
||||
```yaml
|
||||
# configuration.yaml
|
||||
logger:
|
||||
|
|
@ -633,6 +687,7 @@ logger:
|
|||
```
|
||||
|
||||
**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
|
||||
|
|
@ -645,17 +700,20 @@ logger:
|
|||
### ❌ 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
|
||||
|
|
@ -664,16 +722,19 @@ 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 # Use default 5%
|
||||
```
|
||||
|
|
@ -681,16 +742,19 @@ best_price_min_distance_from_avg: 5 # Use default 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
|
||||
|
|
@ -706,11 +770,13 @@ best_price_min_distance_from_avg: 5
|
|||
**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
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Filter statistics: 96 intervals checked
|
||||
DEBUG: Filtered by FLEX: 12/96 (12.5%) ← Low percentage = good variation
|
||||
|
|
@ -724,11 +790,13 @@ DEBUG: After build_periods: 3 raw periods found
|
|||
**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"
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Filter statistics: 96 intervals checked
|
||||
DEBUG: Filtered by FLEX: 45/96 (46.9%) ← High percentage = poor variation
|
||||
|
|
@ -743,11 +811,13 @@ DEBUG: Day 2025-11-11: Baseline insufficient (1 < 2), starting relaxation
|
|||
**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)
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Outlier detected: 38.5 ct (threshold: 4.2 ct)
|
||||
DEBUG: Smoothed to: 20.1 ct (trend prediction)
|
||||
|
|
@ -762,6 +832,7 @@ DEBUG: After build_periods: 4 raw periods found
|
|||
**Initial State:** Baseline finds 1 period, target is 2
|
||||
|
||||
**Expected Flow:**
|
||||
|
||||
```
|
||||
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
|
||||
DEBUG: Day 2025-11-11: Baseline found 1 period (need 2)
|
||||
|
|
@ -777,6 +848,7 @@ INFO: Day 2025-11-11: Success after 1 relaxation phase (2 periods)
|
|||
**Initial State:** Strict filters, very flat day
|
||||
|
||||
**Expected Flow:**
|
||||
|
||||
```
|
||||
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
|
||||
DEBUG: Day 2025-11-11: Baseline found 0 periods (need 2)
|
||||
|
|
@ -854,6 +926,7 @@ When debugging period calculation issues:
|
|||
**Concept:** Auto-adjust Flex based on daily price variation
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
```python
|
||||
# Pseudo-code for adaptive flex
|
||||
variation = (daily_max - daily_min) / daily_avg
|
||||
|
|
@ -867,11 +940,13 @@ else: # Normal day
|
|||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Eliminates need for relaxation on most days
|
||||
- Self-adjusting to market conditions
|
||||
- Better user experience (less configuration needed)
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Harder to predict behavior (less transparent)
|
||||
- May conflict with user's mental model
|
||||
- Needs extensive testing across different markets
|
||||
|
|
@ -883,17 +958,20 @@ else: # Normal day
|
|||
**Concept:** Learn optimal Flex/Distance from user feedback
|
||||
|
||||
**Approach:**
|
||||
|
||||
- Track which periods user actually uses (automation triggers)
|
||||
- Classify days by pattern (normal/flat/volatile/bimodal)
|
||||
- Apply pattern-specific defaults
|
||||
- Learn per-user preferences over time
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Personalized to user's actual behavior
|
||||
- Adapts to local market patterns
|
||||
- Could discover non-obvious patterns
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Requires user feedback mechanism (not implemented)
|
||||
- Privacy concerns (storing usage patterns)
|
||||
- Complexity for users to understand "why this period?"
|
||||
|
|
@ -906,22 +984,26 @@ else: # Normal day
|
|||
**Concept:** Balance multiple goals simultaneously
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Period count vs. quality (cheap vs. very cheap)
|
||||
- Period duration vs. price level (long mediocre vs. short excellent)
|
||||
- Temporal distribution (spread throughout day vs. clustered)
|
||||
- User's stated use case (EV charging vs. heat pump vs. dishwasher)
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
- Pareto optimization (find trade-off frontier)
|
||||
- User chooses point on frontier via preferences
|
||||
- Genetic algorithm or simulated annealing
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- More sophisticated period selection
|
||||
- Better match to user's actual needs
|
||||
- Could handle complex appliance requirements
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Much more complex to implement
|
||||
- Harder to explain to users
|
||||
- Computational cost (may need caching)
|
||||
|
|
@ -936,14 +1018,17 @@ else: # Normal day
|
|||
**Current:** 3% cap may be too aggressive for very low base Flex
|
||||
|
||||
**Example:**
|
||||
|
||||
- Base flex 5% + 3% increment = 8% (60% increase!)
|
||||
- Base flex 15% + 3% increment = 18% (20% increase)
|
||||
|
||||
**Possible Solution:**
|
||||
|
||||
- Percentage-based increment: `increment = max(base_flex × 0.20, 0.03)`
|
||||
- This gives: 5% → 6% (20%), 15% → 18% (20%), 40% → 43% (7.5%)
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Very low base flex (`<`10%) unusual
|
||||
- Users with strict requirements likely disable relaxation
|
||||
- Simplicity preferred over edge case optimization
|
||||
|
|
@ -953,6 +1038,7 @@ else: # Normal day
|
|||
**Current:** Linear scaling may be too aggressive/conservative
|
||||
|
||||
**Alternative:** Non-linear curve
|
||||
|
||||
```python
|
||||
# Example: Exponential scaling
|
||||
scale_factor = 0.25 + 0.75 × exp(-5 × (flex - 0.20))
|
||||
|
|
@ -962,6 +1048,7 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
```
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Linear is easier to reason about
|
||||
- No evidence that non-linear is better
|
||||
- Would need extensive testing
|
||||
|
|
@ -971,15 +1058,18 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
**Issue:** May find all periods in one part of day
|
||||
|
||||
**Example:**
|
||||
|
||||
- All 3 "best price" periods between 02:00-08:00
|
||||
- No periods in evening (when user might want to run appliances)
|
||||
|
||||
**Possible Solution:**
|
||||
|
||||
- Add "spread" parameter (prefer distributed periods)
|
||||
- Weight periods by time-of-day preferences
|
||||
- Consider user's typical usage patterns
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Adds complexity
|
||||
- Users can work around with multiple automations
|
||||
- Different users have different needs (no one-size-fits-all)
|
||||
|
|
@ -991,6 +1081,7 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
**Design Principle:** Each interval is evaluated using its **own day's** reference prices (daily min/max/avg).
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In period_building.py build_periods():
|
||||
for price_data in all_prices:
|
||||
|
|
@ -1042,6 +1133,7 @@ Period crossing midnight: 23:45 Day 1 → 00:15 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)
|
||||
|
|
@ -1053,6 +1145,7 @@ This is **mathematically correct** - 21ct is genuinely expensive on a day where
|
|||
**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
|
||||
|
||||
|
|
@ -1061,10 +1154,12 @@ This explains why absolute prices jump at midnight despite minimal demand change
|
|||
**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
|
||||
|
|
@ -1095,6 +1190,7 @@ Low volatility (< 15%) means classification changes are less economically signif
|
|||
**Status:** Per-day evaluation is intentional design prioritizing mathematical correctness.
|
||||
|
||||
**See Also:**
|
||||
|
||||
- User documentation: `docs/user/docs/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)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- Must be a **class attribute** (not instance attribute)
|
||||
- Use `frozenset` for immutability and performance
|
||||
- Applied automatically by Home Assistant's Recorder component
|
||||
|
|
@ -40,6 +41,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `description`, `usage_tips`
|
||||
|
||||
**Reason:** Static, large text strings (100-500 chars each) that:
|
||||
|
||||
- Never change or change very rarely
|
||||
- Don't provide analytical value in history
|
||||
- Consume significant database space when recorded every state change
|
||||
|
|
@ -50,6 +52,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
### 2. Large Nested Structures
|
||||
|
||||
**Attributes:**
|
||||
|
||||
- `periods` (binary_sensor) - Array of all period summaries
|
||||
- `data` (chart_data_export) - Complete price data arrays
|
||||
- `trend_attributes` - Detailed trend analysis
|
||||
|
|
@ -58,6 +61,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
- `volatility_attributes` - Detailed volatility breakdown
|
||||
|
||||
**Reason:** Complex nested data structures that are:
|
||||
|
||||
- Serialized to JSON for storage (expensive)
|
||||
- Create large database rows (2-20 KB each)
|
||||
- Slow down history queries
|
||||
|
|
@ -66,6 +70,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Impact:** ~10-30 KB saved per state change for affected sensors
|
||||
|
||||
**Example - periods array:**
|
||||
|
||||
```json
|
||||
{
|
||||
"periods": [
|
||||
|
|
@ -76,7 +81,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
"price_mean": 18.5,
|
||||
"price_median": 18.3,
|
||||
"price_min": 17.2,
|
||||
"price_max": 19.8,
|
||||
"price_max": 19.8
|
||||
// ... 10+ more attributes × 10-20 periods
|
||||
}
|
||||
]
|
||||
|
|
@ -88,6 +93,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Change every update cycle (every 15 minutes or more frequently)
|
||||
- Don't provide long-term analytical value
|
||||
- Create state changes even when core values haven't changed
|
||||
|
|
@ -103,6 +109,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Configuration values that rarely change
|
||||
- Wastes space when recorded repeatedly
|
||||
- Can be derived from other attributes or from entity state
|
||||
|
|
@ -114,6 +121,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Only relevant at moment of reading
|
||||
- Won't be valid after some time
|
||||
- Similar to `entity_picture` in HA core image entities
|
||||
|
|
@ -128,6 +136,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Detailed technical information not needed for historical analysis
|
||||
- Only useful for debugging during active development
|
||||
- Boolean `relaxation_active` is kept for high-level analysis
|
||||
|
|
@ -139,6 +148,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `periods_total`, `periods_remaining`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Can be calculated from other attributes
|
||||
- Redundant information
|
||||
- Doesn't add analytical value to history
|
||||
|
|
@ -152,22 +162,27 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
These attributes **remain in history** because they provide essential analytical value:
|
||||
|
||||
### Time-Series Core
|
||||
|
||||
- `timestamp` - Critical for time-series analysis (ALWAYS FIRST)
|
||||
- All price values - Core sensor states
|
||||
|
||||
### Diagnostics & Tracking
|
||||
|
||||
- `cache_age_minutes` - Numeric value for diagnostics tracking over time
|
||||
- `updates_today` - Tracking API usage patterns
|
||||
|
||||
### Data Completeness
|
||||
|
||||
- `interval_count`, `intervals_available` - Data completeness metrics
|
||||
- `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status
|
||||
|
||||
### Period Data
|
||||
|
||||
- `start`, `end`, `duration_minutes` - Core period timing
|
||||
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
|
||||
|
||||
### High-Level Status
|
||||
|
||||
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
|
||||
|
||||
## Expected Database Impact
|
||||
|
|
@ -175,6 +190,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Space Savings
|
||||
|
||||
**Per state change:**
|
||||
|
||||
- Before: ~3-8 KB average
|
||||
- After: ~0.5-1.5 KB average
|
||||
- **Reduction: 60-85%**
|
||||
|
|
@ -196,6 +212,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Real-World Impact
|
||||
|
||||
For a typical installation with:
|
||||
|
||||
- 80+ sensors
|
||||
- Updates every 15 minutes
|
||||
- ~10 sensors updating every minute
|
||||
|
|
@ -214,7 +231,7 @@ For a typical installation with:
|
|||
- Class: `TibberPricesBinarySensor`
|
||||
- 30 attributes excluded
|
||||
|
||||
## When to Update _unrecorded_attributes
|
||||
## When to Update \_unrecorded_attributes
|
||||
|
||||
### Add to Exclusion List When:
|
||||
|
||||
|
|
@ -265,6 +282,7 @@ After modifying `_unrecorded_attributes`:
|
|||
4. **Confirm excluded attributes** don't appear in new state writes
|
||||
|
||||
**SQL Query to check attribute presence:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
state_id,
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ In CI/CD (`$CI` or `$GITHUB_ACTIONS`), AI is automatically disabled.
|
|||
**In DevContainer (automatic):**
|
||||
|
||||
git-cliff is automatically installed when the DevContainer is built:
|
||||
|
||||
- **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile)
|
||||
- **git-cliff**: Installed via cargo in `scripts/setup/setup`
|
||||
|
||||
|
|
@ -120,6 +121,7 @@ Simply rebuild the container (VS Code: "Dev Containers: Rebuild Container") and
|
|||
**Manual installation (outside DevContainer):**
|
||||
|
||||
**git-cliff** (template-based):
|
||||
|
||||
```bash
|
||||
# See: https://git-cliff.org/docs/installation
|
||||
|
||||
|
|
@ -191,7 +193,7 @@ All methods produce GitHub-flavored Markdown with emoji categories:
|
|||
## 🎯 When to Use Which
|
||||
|
||||
| Method | Use Case | Pros | Cons |
|
||||
|--------|----------|------|------|
|
||||
| --------------------- | --------------------- | ----------------------------- | ------------------------ |
|
||||
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
|
||||
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
|
||||
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
|
||||
|
|
@ -219,6 +221,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. Script bumps manifest.json → commits → creates tag locally
|
||||
2. You push commit + tag together
|
||||
3. Release workflow sees tag → generates notes → creates release
|
||||
|
|
@ -242,6 +245,7 @@ git push
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You push manifest.json change
|
||||
2. Auto-Tag workflow detects change → creates tag automatically
|
||||
3. Release workflow sees new tag → creates release
|
||||
|
|
@ -263,6 +267,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You create and push tag manually
|
||||
2. Release workflow creates release
|
||||
3. Auto-Tag workflow skips (tag already exists)
|
||||
|
|
@ -282,19 +287,24 @@ git push origin main v0.3.0
|
|||
## 🛡️ Safety Features
|
||||
|
||||
### 1. **Version Validation**
|
||||
|
||||
Both helper script and auto-tag workflow validate version format (X.Y.Z).
|
||||
|
||||
### 2. **No Duplicate Tags**
|
||||
|
||||
- Helper script checks if tag exists (local + remote)
|
||||
- Auto-tag workflow checks if tag exists before creating
|
||||
|
||||
### 3. **Atomic Operations**
|
||||
|
||||
Helper script creates commit + tag locally. You decide when to push.
|
||||
|
||||
### 4. **Version Bumps Filtered**
|
||||
|
||||
Release notes automatically exclude `chore(release): bump version` commits.
|
||||
|
||||
### 5. **Rollback Instructions**
|
||||
|
||||
Helper script shows how to undo if you change your mind.
|
||||
|
||||
---
|
||||
|
|
@ -330,6 +340,7 @@ git push -f origin main v0.3.0
|
|||
**Auto-tag didn't create tag:**
|
||||
|
||||
Check workflow runs in GitHub Actions. Common causes:
|
||||
|
||||
- Tag already exists remotely
|
||||
- Invalid version format in manifest.json
|
||||
- manifest.json not in the commit that was pushed
|
||||
|
|
@ -348,6 +359,7 @@ Check workflow runs in GitHub Actions. Common causes:
|
|||
## 💡 Tips
|
||||
|
||||
1. **Conventional Commits:** Use proper commit format for best results:
|
||||
|
||||
```
|
||||
feat(scope): Add new feature
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ The Tibber Prices integration includes a proactive repair notification system th
|
|||
The repairs system is implemented in `coordinator/repairs.py` via the `TibberPricesRepairManager` class, which is instantiated in the coordinator and integrated into the update cycle.
|
||||
|
||||
**Design Principles:**
|
||||
|
||||
- **Proactive**: Detect issues before they become critical
|
||||
- **User-friendly**: Clear explanations with actionable guidance
|
||||
- **Auto-clearing**: Repairs automatically disappear when conditions resolve
|
||||
|
|
@ -19,10 +20,12 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
**Issue ID:** `tomorrow_data_missing_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`)
|
||||
- Tomorrow's electricity price data is still not available
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Tomorrow's data becomes available
|
||||
- Automatically checks on every successful API update
|
||||
|
||||
|
|
@ -30,6 +33,7 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
Users cannot plan ahead for tomorrow's electricity usage optimization. Automations relying on tomorrow's prices will not work.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In coordinator update cycle
|
||||
has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"])
|
||||
|
|
@ -40,6 +44,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `warning_hour`: Hour after which warning appears (default: 18)
|
||||
|
||||
|
|
@ -48,10 +53,12 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
**Issue ID:** `rate_limit_exceeded_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Integration encounters 3 or more consecutive rate limit errors (HTTP 429)
|
||||
- Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD`
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Successful API call completes (no rate limit error)
|
||||
- Error counter resets to 0
|
||||
|
||||
|
|
@ -59,6 +66,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
|
|
@ -74,6 +82,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `error_count`: Number of consecutive rate limit errors
|
||||
|
||||
|
|
@ -82,10 +91,12 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
**Issue ID:** `home_not_found_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Home configured in this integration is no longer present in Tibber account
|
||||
- Detected during user data refresh (daily check)
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Home reappears in Tibber account (unlikely - manual cleanup expected)
|
||||
- Integration entry is removed (shutdown cleanup)
|
||||
|
||||
|
|
@ -93,6 +104,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
Integration cannot fetch data for a non-existent home. User must remove the config entry and re-add if needed.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# After user data update
|
||||
home_exists = self._data_fetcher._check_home_exists(home_id)
|
||||
|
|
@ -103,6 +115,7 @@ else:
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the missing home
|
||||
- `entry_id`: Config entry ID for reference
|
||||
|
||||
|
|
@ -153,6 +166,7 @@ Each repair type maintains internal state to avoid redundant operations:
|
|||
### Lifecycle Integration
|
||||
|
||||
**Coordinator Initialization:**
|
||||
|
||||
```python
|
||||
self._repair_manager = TibberPricesRepairManager(
|
||||
hass=hass,
|
||||
|
|
@ -162,6 +176,7 @@ self._repair_manager = TibberPricesRepairManager(
|
|||
```
|
||||
|
||||
**Update Cycle Integration:**
|
||||
|
||||
```python
|
||||
# Success path - check conditions
|
||||
if result and "priceInfo" in result:
|
||||
|
|
@ -178,6 +193,7 @@ if is_rate_limit:
|
|||
```
|
||||
|
||||
**Shutdown Cleanup:**
|
||||
|
||||
```python
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shut down coordinator and clean up."""
|
||||
|
|
@ -196,6 +212,7 @@ Repairs use Home Assistant's standard translation system. Translations are defin
|
|||
- `/translations/sv.json`
|
||||
|
||||
**Structure:**
|
||||
|
||||
```json
|
||||
{
|
||||
"issues": {
|
||||
|
|
@ -210,10 +227,12 @@ Repairs use Home Assistant's standard translation system. Translations are defin
|
|||
## Home Assistant Integration
|
||||
|
||||
Repairs appear in:
|
||||
|
||||
- **Settings → System → Repairs** (main repairs panel)
|
||||
- **Notifications** (bell icon in UI shows repair count)
|
||||
|
||||
Repair properties:
|
||||
|
||||
- **`is_fixable=False`**: No automated fix available (user action required)
|
||||
- **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical)
|
||||
- **`translation_key`**: References `issues.{key}` in translation files
|
||||
|
|
@ -228,6 +247,7 @@ Repair properties:
|
|||
4. When tomorrow data arrives (next API fetch), repair clears
|
||||
|
||||
**Manual trigger:**
|
||||
|
||||
```python
|
||||
# Temporarily set warning hour to current hour for testing
|
||||
TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
||||
|
|
@ -240,6 +260,7 @@ TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
|||
3. Successful API call clears the repair
|
||||
|
||||
**Manual test:**
|
||||
|
||||
- Reduce API polling interval to trigger rate limiting
|
||||
- Or temporarily return HTTP 429 in API client
|
||||
|
||||
|
|
@ -263,6 +284,7 @@ To add a new repair type:
|
|||
7. **Document** in this file
|
||||
|
||||
**Example template:**
|
||||
|
||||
```python
|
||||
async def check_new_condition(self, *, param: bool) -> None:
|
||||
"""Check new condition and create/clear repair."""
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ This document explains the timer/scheduler system in the Tibber Prices integrati
|
|||
The integration uses **three independent timer mechanisms** for different purposes:
|
||||
|
||||
| Timer | Type | Interval | Purpose | Trigger Method |
|
||||
|-------|------|----------|---------|----------------|
|
||||
| ------------ | ----------- | ------------------ | -------------------- | ------------------------------- |
|
||||
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` |
|
||||
| **Timer #2** | Custom | :00, :15, :30, :45 | Entity state refresh | `async_track_utc_time_change()` |
|
||||
| **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
|
||||
|
|
@ -27,6 +27,7 @@ The integration uses **three independent timer mechanisms** for different purpos
|
|||
**Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes`
|
||||
|
||||
**What it is:**
|
||||
|
||||
- HA provides this timer system automatically when you inherit from `DataUpdateCoordinator`
|
||||
- Triggers `_async_update_data()` method every 15 minutes
|
||||
- **Not** synchronized to clock boundaries (each installation has different start time)
|
||||
|
|
@ -53,16 +54,19 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
```
|
||||
|
||||
**Load Distribution:**
|
||||
|
||||
- Each HA installation starts Timer #1 at different times → natural distribution
|
||||
- Tomorrow data check adds 0-30s random delay → prevents "thundering herd" on Tibber API
|
||||
- Result: API load spread over ~30 minutes instead of all at once
|
||||
|
||||
**Midnight Coordination:**
|
||||
|
||||
- Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects)
|
||||
- If midnight turnover needed → performs it and returns early
|
||||
- Timer #2 will see turnover already done and skip gracefully
|
||||
|
||||
**Why we use HA's timer:**
|
||||
|
||||
- Automatic restart after HA restart
|
||||
- Built-in retry logic for temporary failures
|
||||
- Standard HA integration pattern
|
||||
|
|
@ -79,6 +83,7 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
**Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll**
|
||||
|
||||
**Problem it solves:**
|
||||
|
||||
- Timer #1 runs every 15 minutes but NOT synchronized to clock (:03, :18, :33, :48)
|
||||
- Current price changes at :00, :15, :30, :45 → entities would show stale data for up to 15 minutes
|
||||
- Example: 14:00 new price, but Timer #1 ran at 13:58 → next update at 14:13 → users see old price until 14:13
|
||||
|
|
@ -100,22 +105,26 @@ async def _handle_quarter_hour_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Smart Boundary Tolerance:**
|
||||
|
||||
- Uses `round_to_nearest_quarter_hour()` with ±2 second tolerance
|
||||
- HA may schedule timer at 14:59:58 → rounds to 15:00:00 (shows new interval)
|
||||
- HA restart at 14:59:30 → stays at 14:45:00 (shows current interval)
|
||||
- See [Architecture](./architecture.md#3-quarter-hour-precision) for details
|
||||
|
||||
**Absolute Time Scheduling:**
|
||||
|
||||
- `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...)
|
||||
- NOT relative delays ("in 15 minutes")
|
||||
- If triggered at 14:59:58 → next trigger is 15:15:00, NOT 15:00:00 (prevents double updates)
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- All sensors that depend on "current interval" (e.g., `current_interval_price`, `next_interval_price`)
|
||||
- Binary sensors that check "is now in period?" (e.g., `best_price_period_active`)
|
||||
- ~50-60 entities out of 120+ total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- HA's built-in coordinator doesn't support exact boundary timing
|
||||
- We need **absolute time** triggers, not periodic intervals
|
||||
- Allows fast entity updates without expensive data transformation
|
||||
|
|
@ -140,6 +149,7 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- `best_price_remaining_minutes` - Countdown timer
|
||||
- `peak_price_remaining_minutes` - Countdown timer
|
||||
- `best_price_progress` - Progress bar (0-100%)
|
||||
|
|
@ -147,11 +157,13 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
- ~10 entities total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- Users want smooth countdowns (not jumping 15 minutes at a time)
|
||||
- Progress bars need minute-by-minute updates
|
||||
- Very lightweight (no data processing, just state recalculation)
|
||||
|
||||
**Why NOT every second:**
|
||||
|
||||
- Minute precision sufficient for countdown UX
|
||||
- Reduces CPU load (60× fewer updates than seconds)
|
||||
- Home Assistant best practice (avoid sub-minute updates)
|
||||
|
|
@ -194,6 +206,7 @@ class ListenerManager:
|
|||
```
|
||||
|
||||
**Why this pattern:**
|
||||
|
||||
- Decouples timer logic from entity logic
|
||||
- One timer can notify many entities efficiently
|
||||
- Entities can unregister when removed (cleanup)
|
||||
|
|
@ -279,11 +292,13 @@ class ListenerManager:
|
|||
### Reason 1: Load Distribution on Tibber API
|
||||
|
||||
If all installations used synchronized timers:
|
||||
|
||||
- ❌ Everyone fetches at 13:00:00 → Tibber API overload
|
||||
- ❌ Everyone fetches at 14:00:00 → Tibber API overload
|
||||
- ❌ "Thundering herd" problem
|
||||
|
||||
With HA's unsynchronized timer:
|
||||
|
||||
- ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ...
|
||||
- ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ...
|
||||
- ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ...
|
||||
|
|
@ -316,6 +331,7 @@ def _should_update_price_data(self) -> str:
|
|||
**Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data.
|
||||
|
||||
**API fetch only when:**
|
||||
|
||||
- Tomorrow data missing/invalid (after 13:00)
|
||||
- Cache expired (midnight turnover)
|
||||
- Explicit user refresh
|
||||
|
|
@ -339,6 +355,7 @@ def _should_update_price_data(self) -> str:
|
|||
## Performance Characteristics
|
||||
|
||||
### Timer #1 (DataUpdateCoordinator)
|
||||
|
||||
- **Triggers:** Every 15 minutes (unsynchronized)
|
||||
- **Fast path:** ~2ms (cache check, return existing data)
|
||||
- **Slow path:** ~600ms (API fetch + transform + calculate)
|
||||
|
|
@ -346,12 +363,14 @@ def _should_update_price_data(self) -> str:
|
|||
- **API calls:** ~1-2 times/day (cached otherwise)
|
||||
|
||||
### Timer #2 (Quarter-Hour Refresh)
|
||||
|
||||
- **Triggers:** 96 times/day (exact boundaries)
|
||||
- **Processing:** ~5ms (notify 60 entities)
|
||||
- **No API calls:** Uses cached/transformed data
|
||||
- **No transformation:** Just entity state updates
|
||||
|
||||
### Timer #3 (Minute Refresh)
|
||||
|
||||
- **Triggers:** 1440 times/day (every minute)
|
||||
- **Processing:** ~1ms (notify 10 entities)
|
||||
- **No API calls:** No data processing at all
|
||||
|
|
@ -417,17 +436,20 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
## Summary
|
||||
|
||||
**Three independent timers:**
|
||||
|
||||
1. **Timer #1** (HA built-in, 15 min, unsynchronized) → Data fetching (when needed)
|
||||
2. **Timer #2** (Custom, :00/:15/:30/:45) → Entity state updates (always)
|
||||
3. **Timer #3** (Custom, every minute) → Countdown/progress (always)
|
||||
|
||||
**Key insights:**
|
||||
|
||||
- Timer #1 unsynchronized = good (load distribution on API)
|
||||
- Timer #2 synchronized = good (user sees correct data immediately)
|
||||
- Timer #3 synchronized = good (smooth countdown UX)
|
||||
- All three coordinate gracefully (atomic midnight checks, no conflicts)
|
||||
|
||||
**"Listener" terminology:**
|
||||
|
||||
- Timer = mechanism that triggers
|
||||
- Listener = callback that gets called
|
||||
- Observer pattern = entities register, coordinator notifies
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ query($homeId: ID!) {
|
|||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `homeId`: Tibber home identifier
|
||||
- `resolution`: Always `QUARTER_HOURLY`
|
||||
- `first`: 384 intervals (4 days of data)
|
||||
|
|
@ -85,10 +86,12 @@ query($homeId: ID!) {
|
|||
## Rate Limits
|
||||
|
||||
Tibber API rate limits (as of 2024):
|
||||
|
||||
- **5000 requests per hour** per token
|
||||
- **Burst limit:** 100 requests per minute
|
||||
|
||||
Integration stays well below these limits:
|
||||
|
||||
- Polls every 15 minutes = 96 requests/day
|
||||
- User data cached for 24h = 1 request/day
|
||||
- **Total:** ~100 requests/day per home
|
||||
|
|
@ -106,6 +109,7 @@ Integration stays well below these limits:
|
|||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
|
||||
- `startsAt`: ISO 8601 timestamp with timezone
|
||||
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
|
||||
|
|
@ -119,6 +123,7 @@ Integration stays well below these limits:
|
|||
```
|
||||
|
||||
Supported currencies:
|
||||
|
||||
- `EUR` (Euro) - displayed as ct/kWh
|
||||
- `NOK` (Norwegian Krone) - displayed as øre/kWh
|
||||
- `SEK` (Swedish Krona) - displayed as öre/kWh
|
||||
|
|
@ -128,42 +133,52 @@ Supported currencies:
|
|||
### Common Error Responses
|
||||
|
||||
**Invalid Token:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Rate Limit Exceeded:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Home Not Found:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"errors": [
|
||||
{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Integration handles these with:
|
||||
|
||||
- Exponential backoff retry (3 attempts)
|
||||
- ConfigEntryAuthFailed for auth errors
|
||||
- ConfigEntryNotReady for temporary failures
|
||||
|
|
@ -171,6 +186,7 @@ Integration handles these with:
|
|||
## Data Transformation
|
||||
|
||||
Raw API data is enriched with:
|
||||
|
||||
- **Trailing 24h average** - Calculated from previous intervals
|
||||
- **Leading 24h average** - Calculated from future intervals
|
||||
- **Price difference %** - Deviation from average
|
||||
|
|
@ -181,6 +197,7 @@ See `utils/price.py` for enrichment logic.
|
|||
---
|
||||
|
||||
💡 **External Resources:**
|
||||
|
||||
- [Tibber API Documentation](https://developer.tibber.com/docs/overview)
|
||||
- [GraphQL Explorer](https://developer.tibber.com/explorer)
|
||||
- [Get API Token](https://developer.tibber.com/settings/access-token)
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ flowchart TB
|
|||
The integration uses **5 independent caching layers** for optimal performance:
|
||||
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
|-------|----------|----------|--------------|--------|
|
||||
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ |
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
|
|
@ -196,7 +196,7 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
### Core Components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|-----------|------|----------------|
|
||||
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
|
|
@ -210,7 +210,7 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
|
||||
|
||||
| Component | Files | Lines | Responsibility |
|
||||
|-----------|-------|-------|----------------|
|
||||
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- |
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
|
|
@ -219,6 +219,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
|
||||
**Calculator Package** (`sensor/calculators/`):
|
||||
|
||||
- `base.py` - Abstract BaseCalculator with coordinator access
|
||||
- `interval.py` - Single interval calculations (current/next/previous)
|
||||
- `rolling_hour.py` - 5-interval rolling windows
|
||||
|
|
@ -230,6 +231,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
- `metadata.py` - Home/metering metadata
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 58% reduction in core.py (2,170 → 909 lines)
|
||||
- Clear separation: Calculators (logic) vs Attributes (presentation)
|
||||
- Independent testability for each calculator
|
||||
|
|
@ -238,7 +240,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
### Helper Utilities
|
||||
|
||||
| Utility | File | Purpose |
|
||||
|---------|------|---------|
|
||||
| ----------------- | ------------------ | ------------------------------------------------- |
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
|
|
@ -296,26 +298,31 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
Sensors organized by **calculation method** (refactored Nov 2025):
|
||||
|
||||
**Unified Handler Methods** (`sensor/core.py`):
|
||||
|
||||
- `_get_interval_value(offset, type)` - current/next/previous intervals
|
||||
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
|
||||
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
|
||||
- `_get_24h_window_value(stat_func)` - trailing/leading statistics
|
||||
|
||||
**Routing** (`sensor/value_getters.py`):
|
||||
|
||||
- Single source of truth mapping 80+ entity keys to calculator methods
|
||||
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
|
||||
|
||||
**Calculators** (`sensor/calculators/`):
|
||||
|
||||
- Each calculator inherits from `BaseCalculator` with coordinator access
|
||||
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
|
||||
- Complex logic isolated (e.g., `TrendCalculator` has internal caching)
|
||||
|
||||
**Attributes** (`sensor/attributes/`):
|
||||
|
||||
- Separate from business logic, handles state presentation
|
||||
- Builds extra_state_attributes dicts for entity classes
|
||||
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Minimal code duplication across 80+ sensors
|
||||
- Clear separation of concerns (calculation vs presentation)
|
||||
- Easy to extend: Add sensor → choose pattern → add to routing
|
||||
|
|
@ -334,7 +341,7 @@ Sensors organized by **calculation method** (refactored Nov 2025):
|
|||
### CPU Optimization
|
||||
|
||||
| Optimization | Location | Savings |
|
||||
|--------------|----------|---------|
|
||||
| ------------------- | ------------------------ | ---------------------------- |
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total)
|
||||
- **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
|
||||
- **Timestamps**: Last update times for validation
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
|
||||
- **User data**: 24 hours (refreshed daily)
|
||||
- **Survives**: HA restarts via persistent Storage
|
||||
|
|
@ -36,6 +38,7 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Midnight turnover** (Timer #2 in coordinator):
|
||||
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
|
|
@ -45,6 +48,7 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
```
|
||||
|
||||
2. **Cache validation on load**:
|
||||
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
|
|
@ -71,18 +75,22 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
|
||||
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Forever** (until HA restart)
|
||||
- No invalidation during runtime
|
||||
|
||||
**When populated:**
|
||||
|
||||
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
|
||||
- Lazy loading: If translation missing, attempts file load once
|
||||
|
||||
**Access pattern:**
|
||||
|
||||
```python
|
||||
# Non-blocking synchronous access from cached data
|
||||
description = get_translation("binary_sensor.best_price_period.description", "en")
|
||||
|
|
@ -101,6 +109,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**What is cached:**
|
||||
|
||||
### DataTransformer Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"thresholds": {"low": 15, "high": 35},
|
||||
|
|
@ -110,6 +119,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
### PeriodCalculator Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||
|
|
@ -118,10 +128,12 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until `invalidate_config_cache()` is called
|
||||
- Built once on first use per coordinator update cycle
|
||||
|
||||
**Invalidation trigger:**
|
||||
|
||||
- **Options change** (user reconfigures integration):
|
||||
```python
|
||||
# coordinator/core.py
|
||||
|
|
@ -132,6 +144,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Before:** ~30 dict lookups + type conversions per update = ~50μs
|
||||
- **After:** 1 cache check = ~1μs
|
||||
- **Savings:** ~98% (50μs → 1μs per update)
|
||||
|
|
@ -147,6 +160,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"best_price": {
|
||||
|
|
@ -161,6 +175,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Cache key:** Hash of relevant inputs
|
||||
|
||||
```python
|
||||
hash_data = (
|
||||
today_signature, # (startsAt, rating_level) for each interval
|
||||
|
|
@ -172,6 +187,7 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until price data changes (today's intervals modified)
|
||||
- Until config changes (flex, thresholds, filters)
|
||||
- Recalculated at midnight (new today data)
|
||||
|
|
@ -179,6 +195,7 @@ hash_data = (
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Config change** (explicit):
|
||||
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
|
|
@ -193,10 +210,12 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Cache hit rate:**
|
||||
|
||||
- **High:** During normal operation (coordinator updates every 15min, price data unchanged)
|
||||
- **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
|
||||
- **Cache hit:** `<`1ms (hash comparison + dict lookup)
|
||||
- **Savings:** ~70% of calculation time (most updates hit cache)
|
||||
|
|
@ -212,6 +231,7 @@ hash_data = (
|
|||
**Status:** ✅ **Clean separation** - enrichment only, no redundancy
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"timestamp": ...,
|
||||
|
|
@ -224,6 +244,7 @@ hash_data = (
|
|||
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
|
||||
|
||||
**Current behavior:**
|
||||
|
||||
- Caches **only enriched price data** (price + statistics)
|
||||
- **Does NOT cache periods** (handled by Period Calculation Cache)
|
||||
- Invalidated when:
|
||||
|
|
@ -232,6 +253,7 @@ hash_data = (
|
|||
- New update cycle begins
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- DataTransformer: Handles price enrichment only
|
||||
- PeriodCalculator: Handles period calculation only (with hash-based cache)
|
||||
- Coordinator: Assembles final data on-demand from both caches
|
||||
|
|
@ -243,6 +265,7 @@ hash_data = (
|
|||
## Cache Invalidation Flow
|
||||
|
||||
### User Changes Options (Config Flow)
|
||||
|
||||
```
|
||||
User saves options
|
||||
↓
|
||||
|
|
@ -267,6 +290,7 @@ Fresh data fetch with new config
|
|||
```
|
||||
|
||||
### Midnight Turnover (Day Transition)
|
||||
|
||||
```
|
||||
Timer #2 fires at 00:00
|
||||
↓
|
||||
|
|
@ -286,6 +310,7 @@ Fresh API fetch for new day
|
|||
```
|
||||
|
||||
### Tomorrow Data Arrives (~13:00)
|
||||
|
||||
```
|
||||
Coordinator update cycle
|
||||
↓
|
||||
|
|
@ -327,12 +352,14 @@ API Data Cache (price_data, user_data)
|
|||
```
|
||||
|
||||
**No cache invalidation cascades:**
|
||||
|
||||
- Config cache invalidation is **explicit** (on options update)
|
||||
- Period cache invalidation is **automatic** (via hash mismatch)
|
||||
- Transformation cache invalidation is **automatic** (on midnight/config change)
|
||||
- Translation cache is **never invalidated** (read-only after load)
|
||||
|
||||
**Thread safety:**
|
||||
|
||||
- All caches are accessed from `MainThread` only (Home Assistant event loop)
|
||||
- No locking needed (single-threaded execution model)
|
||||
|
||||
|
|
@ -341,6 +368,7 @@ API Data Cache (price_data, user_data)
|
|||
## Performance Characteristics
|
||||
|
||||
### Typical Operation (No Changes)
|
||||
|
||||
```
|
||||
Coordinator Update (every 15 min)
|
||||
├─> API fetch: SKIP (cache valid)
|
||||
|
|
@ -353,6 +381,7 @@ Total: ~16ms (down from ~600ms without caching)
|
|||
```
|
||||
|
||||
### After Midnight Turnover
|
||||
|
||||
```
|
||||
Coordinator Update (00:00)
|
||||
├─> API fetch: ~500ms (cache cleared, fetch new day)
|
||||
|
|
@ -365,6 +394,7 @@ Total: ~755ms (expected once per day)
|
|||
```
|
||||
|
||||
### After Config Change
|
||||
|
||||
```
|
||||
Options Update
|
||||
├─> Cache invalidation: `<`1ms
|
||||
|
|
@ -382,7 +412,7 @@ Options Update
|
|||
## Summary Table
|
||||
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
|------------|----------|------|--------------|---------|
|
||||
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- |
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
|
|
@ -392,12 +422,14 @@ Options Update
|
|||
**Total memory overhead:** ~116KB per coordinator instance (main + subentries)
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 97% reduction in API calls (from every 15min to once per day)
|
||||
- 70% reduction in period calculation time (cache hits during normal operation)
|
||||
- 98% reduction in config access time (30+ lookups → 1 cache check)
|
||||
- Zero file I/O during runtime (translations cached at startup)
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- Memory usage: ~116KB per home (negligible for modern systems)
|
||||
- Code complexity: 5 cache invalidation points (well-tested, documented)
|
||||
- Debugging: Must understand cache lifetime when investigating stale data issues
|
||||
|
|
@ -407,7 +439,9 @@ Options Update
|
|||
## Debugging Cache Issues
|
||||
|
||||
### Symptom: Stale data after config change
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Is `_handle_options_update()` called? (should see "Options updated" log)
|
||||
2. Are `invalidate_config_cache()` methods executed?
|
||||
3. Does `async_request_refresh()` trigger?
|
||||
|
|
@ -415,7 +449,9 @@ Options Update
|
|||
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
|
||||
|
||||
### Symptom: Period calculation not updating
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Verify hash changes when data changes: `_compute_periods_hash()`
|
||||
2. Check `_last_periods_hash` vs `current_hash`
|
||||
3. Look for "Using cached period calculation" vs "Calculating periods" logs
|
||||
|
|
@ -423,7 +459,9 @@ Options Update
|
|||
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
|
||||
|
||||
### Symptom: Yesterday's prices shown as today
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `is_cache_valid()` logic in `coordinator/cache.py`
|
||||
2. Midnight turnover execution (Timer #2)
|
||||
3. Cache clear confirmation in logs
|
||||
|
|
@ -431,7 +469,9 @@ Options Update
|
|||
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
|
||||
|
||||
### Symptom: Missing translations
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `async_load_translations()` called at startup?
|
||||
2. Translation files exist in `/translations/` and `/custom_translations/`?
|
||||
3. Cache population: `_TRANSLATIONS_CACHE` keys
|
||||
|
|
|
|||
|
|
@ -41,12 +41,14 @@ class TimeService:
|
|||
```
|
||||
|
||||
**When prefix is required:**
|
||||
|
||||
- Public classes used across multiple modules
|
||||
- All exception classes
|
||||
- All coordinator and entity classes
|
||||
- Data classes (dataclasses, NamedTuples) used as public APIs
|
||||
|
||||
**When prefix can be omitted:**
|
||||
|
||||
- Private helper classes within a single module (prefix with `_` underscore)
|
||||
- Type aliases and callbacks (e.g., `TimeServiceCallback`)
|
||||
- Small internal NamedTuples for function returns
|
||||
|
|
@ -71,6 +73,7 @@ class DataFetcher: # Should be TibberPricesDataFetcher
|
|||
**Current Technical Debt:**
|
||||
|
||||
Many existing classes lack the `TibberPrices` prefix. Before refactoring:
|
||||
|
||||
1. Document the plan in `/planning/class-naming-refactoring.md`
|
||||
2. Use `multi_replace_string_in_file` for bulk renames
|
||||
3. Test thoroughly after each module
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue