mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
chore(style): normalize Markdown list indentation across all docs
Convert four-space-indented list items (`- item`) to standard two-space (`- item`) in AGENTS.md, CONTRIBUTING.md, README.md, and all Docusaurus documentation pages (developer and user, including versioned snapshots). No content changes. Release-Notes: skip
This commit is contained in:
parent
e163a47d57
commit
aa9a1200b8
339 changed files with 16987 additions and 12955 deletions
83
AGENTS.md
83
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)
|
||||
|
|
@ -866,6 +883,7 @@ _Check-only flow (CI/CD-oriented):_
|
|||
```
|
||||
|
||||
_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.
|
||||
|
|
@ -913,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")
|
||||
|
|
@ -944,9 +961,11 @@ When changes are complete and ready for testing:
|
|||
**Purpose:** Keep commit guidance centralized and avoid duplicated/contradictory rules.
|
||||
|
||||
**Authoritative commit-message instructions:**
|
||||
|
||||
- Use `.github/instructions/commit-messages.instructions.md` for commit type/scope, Impact footer style, and release-notes skip trailers.
|
||||
|
||||
**Critical behavior rules (still enforced here):**
|
||||
|
||||
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.
|
||||
|
|
@ -955,6 +974,7 @@ When changes are complete and ready for testing:
|
|||
6. When suggesting commits, include exact files to stage.
|
||||
|
||||
**Internal/unreleased fixes:**
|
||||
|
||||
- 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`
|
||||
|
|
@ -966,21 +986,18 @@ When changes are complete and ready for testing:
|
|||
**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):
|
||||
|
|
@ -1006,7 +1023,6 @@ When changes are complete and ready for testing:
|
|||
```
|
||||
|
||||
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
|
||||
|
|
@ -1119,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
|
||||
|
|
@ -1128,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`
|
||||
|
|
@ -1223,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
|
||||
|
|
@ -1232,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
|
||||
|
|
@ -1358,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"]
|
||||
|
|
@ -1374,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)
|
||||
|
|
@ -1381,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)
|
||||
|
|
@ -1399,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
|
||||
|
|
@ -1410,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
|
||||
|
|
@ -1539,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
|
||||
|
|
@ -1558,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
|
||||
|
|
@ -1569,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`)
|
||||
|
|
@ -1576,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
|
||||
|
|
@ -1584,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)
|
||||
|
|
@ -1593,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:
|
||||
|
|
@ -1604,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:
|
||||
|
|
@ -1680,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)
|
||||
|
|
@ -1688,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
|
||||
|
|
@ -1855,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)
|
||||
|
|
@ -1866,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?"
|
||||
|
|
@ -1876,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
|
||||
|
|
@ -2214,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)
|
||||
|
|
@ -2225,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,
|
||||
|
|
@ -2243,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,
|
||||
|
|
@ -2262,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
|
||||
|
|
@ -2395,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
|
||||
|
|
@ -2407,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`
|
||||
|
|
@ -2417,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)`
|
||||
|
|
@ -2437,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)
|
||||
|
|
@ -2481,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
|
||||
|
||||
|
|
@ -2569,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)
|
||||
|
|
@ -2580,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
|
||||
|
|
@ -2592,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
|
||||
|
||||
|
|
|
|||
|
|
@ -73,14 +73,17 @@ 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
|
||||
|
||||
|
|
@ -119,6 +122,7 @@ See `.github/instructions/commit-messages.instructions.md` for detailed commit-m
|
|||
- **Python version**: 3.13+
|
||||
|
||||
Always run before committing:
|
||||
|
||||
```bash
|
||||
./scripts/lint
|
||||
```
|
||||
|
|
@ -144,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)
|
||||
|
|
@ -153,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+ |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -140,6 +149,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**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
|
||||
|
|
@ -153,23 +163,28 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue